lukevp / ESC-POS-.NET

Efficient, Easy to Use Thermal Printing & POS (Windows/Linux/OSX, WiFi/BT/USB/Ethernet)
MIT License
540 stars 182 forks source link

Network printer interface freezes when unplugged #107

Closed RobNL closed 3 years ago

RobNL commented 3 years ago

Case 1: A .net core 3.1 app, where I use ESCPOS_NET Printer is accessible on an IP address Create a new instance of network printer. Start monitoring. Unplug it by pulling the LAN cable or shutting down.

Result 1: The app seems to freeze...

Result 2: In one test, the app received and transmitted data to the disconnected printer. Then that status was changed to offline. How can it send data to a disconnected printer?

What I would expected to happen: -On a write attempt, the connection is checked and the status is changed to offline. -Not freezing, but a retry for connecting every now and then (with sleep between tries)

Case 2:

Printer is NOT accessible on an IP address because I've pulled the plug Create a new instance of network printer, with retryontimeout=true. The app freezes, doesn't start up. Maybe an infinite loop?

Excepted:

lukevp commented 3 years ago

Hey @RobNL, the network printing was all set up by @kodejack I believe, I don't have a networked printer unfortunately. Can you run the app in debug mode and reproduce these with stepping turned on to see if you can figure out where the issue is?

My guess is that this is probably where the issue is. This code looks like it attempts to reconnect and then if it isn't connected, it will set the timeout to disconnected and invoke a status change (which is, as you say, an infinite loop). There is no pause between this, so it looks like it will invoke it over and over.

image

It would be good to add some delay to this to defer to the background thread. Something like this (you'll have to test, but this will probably fix the freezing and infinite looping. It may never reconnect though).

if (!IsConnected)
{
    Status = new PrinterStatusEventArgs()
    {
        DeviceConnectionTimeout = true,
    };
    Task.Run(() =>
    {
        Task.Delay(1000);
        StatusChanged?.Invoke(this, Status);
    });
}

It would be good to also fix the Reconnect event to successfully reconnect to your printer.

lukevp commented 3 years ago

In reference to your question of how the app can send data to a disconnected printer, you will have to look at the underlying .NET Core network stack. A lot of times, if a device is unplugged, it doesn't send a connection close event on the TCP connection, so there is no way to tell it is not listening until a message is sent that doesn't receive an ACK. So there are definitely situations where data can be sent to the printer and it turns out it's already not listening. There's not much that can be done about that, but ideally you'd have some type of retry buffer and not clear an item out of it until it's successfully written, so we could implement something like that.

RobNL commented 3 years ago

Thanks for your reply. I've already taken your code as a basis and made a new class, where I've added some stuff to make it more responsive, because the socket.connect blocks the application for 2 minutes.

The socket class is extremely funky and the printer adds troubles of its own so it's difficult to make something that works well. So far I've got this, which works fairly well a.t.m. although it's not a finished product and not tested in a production environment.

I'll share it because there's nothing secret here and I pulled some extra tricks from fora.

using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading.Tasks; using ESCPOS_NET;

/*

A socket: To connect to an offline printer, the sockect.connect has a 90 seconds timeout and blocks the application. --> remedy: the ASYNC connect is used.

The printer also has a timeout. It'll forcibly close the socket from the printer's end, if there's no write attempt in 90 seconds. --> remedy: a KeepAlive thread with a simple write command.

The socket has a Poll that only returns its internal state. It doesn't have a 'ping'. --> remedy: do a PING before trying to connect. Also do

The socket stays alive and connected and writes data, even if the cable is unplugged. --> remedy: do a PING before a write.

Another case: The LAN cable to the printer is unplugged for more than 90 seconds. The connection is then plugged back in. The printer has focibly closed the connection A write will fail --> remedy: on write fail, retry 1x to reconnect automaticallu.

*/

namespace Printer.Lib {

public enum PrinterErrors
{
    none = 0,
    connection_could_not_be_made = 1,
    connection_fail = 2,
    write_try1_failed = 4,
    write_try2_failed = 5,
    keepAlive_fail = 6,
    connection_was_lost = 7,
    ping_nosuccess = 8,
}

public class PrinterStatus
{
    public bool isConnected = false;

    public PrinterErrors errorCode = PrinterErrors.none;
    public string errorMessage = "";
    public string warningMessage = "";

    public bool HasError()
    {
        return this.errorCode != PrinterErrors.none;
    }

    public bool HasWarning()
    {
        return this.warningMessage != "";
    }

    public void SetError(PrinterErrors errorCode, string errorMessage)
    {
        this.errorCode = errorCode;
        this.errorMessage = errorMessage;
    }
}

public class NetPrinter : BasePrinter
{
    public string host;
    public int port;

    private Socket socket;
    private NetworkStream sockStream;

    private System.Net.NetworkInformation.Ping ping;

    public PrinterStatus printerStatus = new PrinterStatus();

    private bool isConnectingAsync = false;

    private bool isDevelopmentEnvironment = false;

    // after a write, there should be an expiration date.
    // after this expiration date, keepalive shouldn't keep writing to the printer.
    private DateTime writeDateExpire;
    private int expireMinutes = 150;  // expire after 2.5 hours

    private System.Timers.Timer readIntervals;
    private System.Timers.Timer pingIntervals;
    private System.Timers.Timer keepAliveIntervals;

    public NetPrinter(string ipAddress, int portNumber, bool isDevelopmentEnvironment, int KeepAliveTimeoutMinutes)
        : base()
    {

        this.host = ipAddress;
        this.port = portNumber;
        this.isDevelopmentEnvironment = isDevelopmentEnvironment;
        this.expireMinutes = KeepAliveTimeoutMinutes;

        this.writeDateExpire = DateTime.Now.AddMinutes(expireMinutes);

        // initialize this first, because it's used..
        this.ping = new System.Net.NetworkInformation.Ping();

        // connect to the printer:
        this.Connect();

        // consume messages from the printer:
        this.readIntervals = new System.Timers.Timer();
        this.readIntervals.Elapsed += this.ReadAll;
        this.readIntervals.Interval = 10000; // check every 10 seconds.
        this.readIntervals.Enabled = true;

        if (this.isDevelopmentEnvironment)
        {
            // pinging the printer:
            this.pingIntervals = new System.Timers.Timer();
            this.pingIntervals.Elapsed += this.Ping;
            this.pingIntervals.Interval = 1000; // check every 1 seconds.
            this.pingIntervals.Enabled = true;
        }

        // sending data to the printer to let it know that we're still there:
        // otherwise the printer will forcibly close the socket and then we get an error on writing to the socket after 90s printer timeout.
        this.keepAliveIntervals = new System.Timers.Timer();
        this.keepAliveIntervals.Elapsed += this.KeepAlive;
        this.keepAliveIntervals.Interval = 60000; // check every 60 seconds.
        this.keepAliveIntervals.Enabled = true;

    }

    // https://docs.microsoft.com/en-us/dotnet/framework/network-programming/asynchronous-client-socket-example

    private void ConnectCallback(IAsyncResult ar)
    {
        try
        {
            // Retrieve the socket from the state object.  
            Socket client = (Socket)ar.AsyncState;

            // Complete the connection.  
            client.EndConnect(ar);

            if (client.Connected)
            {

                this.sockStream = new NetworkStream(socket);

                this.Writer = new BinaryWriter(sockStream, new UTF8Encoding(), true);
                this.Reader = new BinaryReader(sockStream, new UTF8Encoding(), true);

                this.printerStatus.isConnected = true;
                this.printerStatus.errorCode = PrinterErrors.none;
                this.printerStatus.errorMessage = "";

                Console.WriteLine(string.Format("A connection was established on {0}:{1}", this.host, this.port));
            }
            else
            {
                this.printerStatus.isConnected = false;
                this.printerStatus.errorCode = PrinterErrors.connection_could_not_be_made;
                //this.printerStatus.errorMessage = string.Format("No connection on port {0}", this.port);
                this.printerStatus.errorMessage = "";

                // reset the socket
                this.socket = null;

                this.sockStream = null;
                this.Reader = null;
                this.Writer = null;

                Console.WriteLine(string.Format("No connection could be made to {0}:{1}", this.host, this.port));

            }

        }
        catch (Exception ex)
        {
            // reset the socket
            this.socket = null;

            this.sockStream = null;
            this.Reader = null;
            this.Writer = null;

            this.printerStatus.isConnected = false;
            this.printerStatus.errorCode = PrinterErrors.connection_fail;
            //this.printerStatus.errorMessage = string.Format("No connection on port {0}", this.port);
            this.printerStatus.errorMessage = string.Format("No connection on port {0}", this.port);

            Console.WriteLine(string.Format("An error occured while connecting to {0}:{1} : {2}", this.host, this.port, ex.Message));
        }
        finally
        {

            this.isConnectingAsync = false;

        }
    }

    private bool TestOrRetryConnectSocket()
    {

        try
        {

            // there are 2 things to check.
            // 1. The socket can time-out after ??? seconds
            // 2. The ping (for more immediate results)

            // the socket has timed out --> so reset the socket.
            if (this.socket != null && !this.socket.Connected && !this.isConnectingAsync)
            {
                this.socket = null;
            }

            // if the socket is not defined, then we've to make a new connection attampt.
            // otherwise, there's an ongoing async connection process to try and create a connection.

            if (this.socket == null) // we can try making a new connection
            {

                bool isPingSuccess;

                try
                {

                    isPingSuccess = this.DoPing();

                    Console.WriteLine("ConnectSocket: Ping on {0} : {1}", this.host, isPingSuccess);

                }
                catch (Exception ex) // ping attempt
                {

                    Console.WriteLine("ConnectSocket: Ping failed on {0} with error {1}", this.host, ex.Message);

                    isPingSuccess = false;
                }

                if (isPingSuccess)
                {
                    // only try creating a new socket, if there's a PING result.

                    this.socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
                    //this.socket.SetIPProtectionLevel(IPProtectionLevel.Unrestricted);

                    this.socket.SendTimeout = 1000;
                    this.socket.ReceiveTimeout = 1000;

                    try
                    {

                        this.isConnectingAsync = true;

                        this.socket.BeginConnect(this.host, this.port, new AsyncCallback(ConnectCallback), this.socket)
                            .AsyncWaitHandle
                            .WaitOne(1000, true);
                        // note that this waits 1 s, but the async-connect will still continue for 90 seconds.
                        // so be careful there aren't duplicate connection-attempts.

                        Console.WriteLine("ConnectSocket: Attempt connection on {0}:{1} with socket.Connected = {2}", this.host, this.port, this.socket.Connected);

                        // check if it's been connected after this 1 second.
                        return this.socket.Connected;

                    }
                    catch (Exception ex) // socket attempt
                    {
                        // something went wrong, so reset the attempt.
                        this.isConnectingAsync = false;
                        this.socket = null;

                        Console.WriteLine("ConnectSocket: Attempt connection on {0}:{1} failed with error = {2}", this.host, this.port, ex.Message);

                        return false;
                    }

                }
                else
                {
                    return false;
                }

            }
            else
            {

                try
                {

                    // the socket hasn't timed out yet.
                    // send a ping to see if the printer can be reached again (so the socket should also work again)...
                    // you have to ping, otherwise you cannot see a lan-cable being unplugged. It'll keep the connection open for a long time in that case.

                    bool isPingSuccess = this.DoPing();

                    //Console.WriteLine(string.Format("Ping on {0} Success = {1} Socket status = {2}", this.host,
                    //  isPingSuccess,
                    //  (this.socket == null ? false : this.socket.Connected)));

                    Console.WriteLine("Connection test on {0}:{1} returns ping = {2} socket.connected = {3}", this.host, this.port, isPingSuccess, this.socket.Connected);

                    // return the current status of the ongoing connection-attempt.
                    return isPingSuccess && this.socket.Connected;

                }
                catch
                {

                    return false;

                }
            }

        }
        catch (Exception ex) // socket attempt
        {
            // something went wrong...

            Console.WriteLine("ConnectSocket: Attempt connection on {0}:{1} failed with error = {2}", this.host, this.port, ex.Message);

            return false;
        }
    }

    private void Connect()
    {

        if (this.TestOrRetryConnectSocket())
        {

            this.printerStatus.isConnected = true;
            this.printerStatus.errorCode = PrinterErrors.none;
            this.printerStatus.errorMessage = "";

        }
        else
        {

            this.printerStatus.isConnected = false;
            this.printerStatus.errorCode = PrinterErrors.connection_could_not_be_made;
            //this.printerStatus.errorMessage = string.Format("No connection on port {0}", this.port);
            this.printerStatus.errorMessage = "";

        }

    }

    private void TestConnect()
    {

        // try to reconnect to the printer
        // OR do a ping to check the connection-status (to keep from 'writing' to a severed LAN connection without any warning).

        if (this.TestOrRetryConnectSocket())
        {

            if (!this.printerStatus.isConnected)
            {
                this.printerStatus.isConnected = true;
                this.printerStatus.errorCode = PrinterErrors.none;
                this.printerStatus.errorMessage = "";
            }

        }
        else
        {

            if (this.printerStatus.isConnected)
            {
                this.printerStatus.isConnected = false;
                this.printerStatus.errorCode = PrinterErrors.connection_was_lost;
                this.printerStatus.errorMessage = "";
            }

        }

    }

    private object writeLock = new object();

    public override void Write(byte[] bytes)
    {

        this.writeDateExpire = DateTime.Now.AddMinutes(expireMinutes);

        this.TestConnect();

        if (this.printerStatus.isConnected) // ping succeeded
        {

            try
            {
                lock (writeLock) // this prevents from sending overlapping messages to this printer in asynced pages
                {
                    // writes to the stream
                    base.Write(bytes);

                    // NOTE:
                    // on sending data to a socket that was forcibly closed by the printer (due to timeout from the printer), this can throw an error.
                    // at that point, socket.Connected is set to false.

                }
            }
            catch (Exception ex)
            {

                this.printerStatus.isConnected = false;
                this.printerStatus.errorCode = PrinterErrors.write_try1_failed;
                this.printerStatus.errorMessage = ex.Message;

                Console.WriteLine(string.Format("Write error [attempt 1] {0}:{1} : {2}", this.host, this.port.ToString(), ex.Message));

            }

            if (!this.printerStatus.isConnected)
            {
                // attempt a reconnect and try to write the data again. (in case the printer forcibly closed the connection...)

                this.TestConnect();

                if (this.printerStatus.isConnected)
                {

                    try
                    {
                        lock (writeLock) // this prevents from sending overlapping messages to this printer in asynced pages
                        {
                            // writes to the stream
                            base.Write(bytes);

                        }
                    }
                    catch (Exception ex)
                    {

                        this.printerStatus.isConnected = false;
                        this.printerStatus.errorCode = PrinterErrors.write_try2_failed;
                        this.printerStatus.errorMessage = ex.Message;

                        Console.WriteLine(string.Format("Write error [attempt 2] {0}:{1} : {2}", this.host, this.port.ToString(), ex.Message));

                    }

                }

            }

        }

    }

    public bool DoPing()
    {

        try
        {

            byte[] pingData = new byte[8];
            System.Security.Cryptography.RandomNumberGenerator.Fill(pingData);

            // send a ping with a few data.
            var reply = ping.Send(this.host, 1000, pingData);

            //Console.WriteLine(string.Format("Ping on {0} Success = {1} Socket status = {2}", this.host,
            //  (reply.Status == System.Net.NetworkInformation.IPStatus.Success),
            //  (this.socket == null ? false : this.socket.Connected)));

            return reply.Status == System.Net.NetworkInformation.IPStatus.Success;

        }
        catch
        {
            return false;
        }

    }

    public void Ping() // this is for debugging
    {

        bool isSuccess = this.DoPing();

        if (isSuccess != this.printerStatus.isConnected)
        {

            if (isSuccess)
            {

                // change the status into connected
                // but only if the socket is still active!

                if (this.socket.Connected)
                {
                    this.printerStatus.isConnected = true;
                    this.printerStatus.errorCode = PrinterErrors.none;
                    this.printerStatus.errorMessage = "";

                    Console.WriteLine(string.Format("Connection recovered on {0}:{1}", this.host, this.port));
                }

            }
            else
            {
                // change the status to not connected
                // note that this.socket.Connected   remains true even if the cable is unplugged, so ONLY rely on the ping in this case

                this.printerStatus.isConnected = false;
                this.printerStatus.errorCode = PrinterErrors.ping_nosuccess;
                //this.printerStatus.errorMessage = string.Format("connection lost on {0}:{1}", this.host, this.port);
                this.printerStatus.errorMessage = "";

                Console.WriteLine(string.Format("Connection lost on {0}:{1}", this.host, this.port));

            }

        }
    }

    public void Ping(object source, System.Timers.ElapsedEventArgs e)
    {
        this.Ping();

    }

    public void KeepAlive(object source, System.Timers.ElapsedEventArgs e)
    {

        // only keep polling the printer if the last write action wasn't too long ago.
        if (DateTime.Now < this.writeDateExpire)
        {

            // first, check if the LAN cable hasn't been unplugged.
            // namely, the socket stays open for a while and writes will continue, even if the LAN is unplugged.
            // those writes could flood the printer, perhaps, when it is reconnected?
            if (this.DoPing())
            {

                // after 90 s of not receiving data from us, the epson printer forcibly closes the socket ; then, a 'write' action from us will fail.

                var EscPos = new ESCPOS_NET.Emitters.EPSON();

                try
                {
                    lock (writeLock) // this prevents from sending overlapping messages to this printer in asynced pages
                    {
                        // writes to the stream
                        base.Write(EscPos.Clear());
                    }

                    Console.WriteLine(string.Format("Keepalive on {0}:{1} at {2}", this.host, this.port, DateTime.Now.ToString("HH:mm")));
                }
                catch (Exception ex)
                {

                    this.printerStatus.isConnected = false;
                    this.printerStatus.errorCode = PrinterErrors.keepAlive_fail;
                    //this.printerStatus.errorMessage = string.Format("Write error {0}:{1} : {2}", this.host, this.port.ToString(), ex.Message + " ; " + (ex.StackTrace == null ? "" : ex.StackTrace));
                    this.printerStatus.errorMessage = ex.Message;

                }

            }

        }

    }

    // this should be executed every now and then to read status-updates from the printer.
    // there's no need for checks on ping and such... that's only important for writes.
    public void ReadAll(object source, System.Timers.ElapsedEventArgs e)
    {

        //// re-test the connection to the printer
        //// if necessary, tries to reconnect.

        //try
        //{

        //  if (this.TestOrRetryConnectSocket())
        //  {

        //      if (!this.printerStatus.isConnected)
        //      {
        //          this.printerStatus.isConnected = true;
        //          this.printerStatus.errorCode = PrinterErrors.none;
        //          this.printerStatus.errorMessage = "";

        //          Console.WriteLine("Connection restored");
        //      }

        //  }
        //  else
        //  {

        //      if (this.printerStatus.isConnected)
        //      {
        //          this.printerStatus.isConnected = false;
        //          this.printerStatus.errorCode = PrinterErrors.no_connection;
        //          this.printerStatus.errorMessage = string.Format("no connection after retry on {0}:{1}", this.host, this.port);
        //      }

        //  }

        //}
        //catch (Exception ex)
        //{
        //  this.printerStatus.isConnected = false;
        //  this.printerStatus.errorCode = PrinterErrors.no_connection;
        //  this.printerStatus.errorMessage = string.Format("no connection after retry on {0}:{1} : {2}", this.host, this.port, ex.Message);
        //}

        if (this.socket != null && this.socket.Connected)
        {

            // you can read if the socket is active.
            // note that the printer doesn't even have to be alive at this point. It doesn't really matter for a read.

            try
            {

                if (this.Reader.PeekChar() != -1)
                {

                    Console.WriteLine("reading messages from the printer");

                    while (this.Reader.PeekChar() != -1) // read until there's no character remaining.
                    {
                        var b = this.Reader.ReadByte();
                        this.ReadBuffer.Enqueue(b);

                        // the following changes the this.status properties.
                        this.DataAvailable(false);
                    }
                }

            }
            catch (OperationCanceledException ex)
            {
                Console.WriteLine(string.Format("read cancelled exception: {0}", ex.Message));
            }
            catch (IOException ex)
            {
                this.DataAvailable(true);
                Console.WriteLine(string.Format("read exception: {0}", ex.Message));
            }
            catch (Exception ex)
            {
                // swallow the exception
                Console.WriteLine(string.Format("read exception: {0}", ex.Message));
            }

            Console.WriteLine(string.Format("ReadAll on {0}:{1}", this.host, this.port));

        }

    }

    protected override void OverridableDispose()
    {
        this.sockStream?.Close();
        this.sockStream?.Dispose();
        this.socket?.Close();
        this.socket?.Dispose();
    }

}

}

RobNL commented 3 years ago

Actually the KeepAlive thread doesn't work well, so you can ignore that part...

lukevp commented 3 years ago

Hey @RobNL! Since we last spoke, I purchased both an Ethernet as well as a Wi-Fi printer. I did a major deep dive around the networking code in ESCPOS_NET and have completely rewritten it. The latest master of ESCPOS has been bumped a major version to account for some breaking changes.

I would love it if you'd give it a try and provide feedback! The latest version seems to work no matter what you do - unplugging the printer halfway through a print, opening/closing the print door, jamming, disconnecting wifi, whatever... Sometimes it does take a little while to determine the printer is no longer connected (if the printer doesn't gracefully close the TCP connection, the only way to really know it's gone is to wait for the timeout). It should be considerably more robust now.

Looking forward to your feedback!

fatihbabacan92 commented 3 years ago

@lukevp

I have written a lot of hacky bypasses to make the network part work seamlessly, on an older version of your library though.

On Windows/UWP it works perfect, especially with USB. But the network printer sometimes seems to congest and get disabled for two minutes. There's also a 90 second timeout after connecting to the printer. If you don't send a print request within those seconds, the printer also gets disabled for two minutes. I worked around that by only connecting when we have something to send (after polling for incoming orders every 10s), and then I dispose the printer via printer.dispose(). I also had rewritten some parts within your library to prevent socket crashes.

I do still have problems with Android devices going to the background. Sometimes it doesn't want to connect for two minutes or a restart is required when our app switches from foreground to background. We stop the printing service for the foreground and then start the printing service for the background.

I have a deadline to make this work, but I'll surely update to the latest version next week and test it out. I'll create a PR for the remaining problems you may have overlooked in the new version.

lukevp commented 3 years ago

Thanks @fatihbabacan92 , glad to hear you are using the library and the USB part works well! I developed the library originally on Windows with USB, so that's certainly why that's the most stable interface 🤔

This version is significantly more performant with the networking. I'm very interested to hear your feedback. It's a ground-up rewrite so I wouldn't worry about your past experience and give this a try.

You should also no longer need to connect on-demand. You can create one Printer object and keep it for the lifetime of your application, and it will automatically connect as soon as the printer comes online and fire events anytime the printer changes status. The 90 second timeout is not a native thing to sockets or the library, that's implemented on the printer side (it drops connections after x seconds if you have it configured that way). I'd suggest you change that in the printer settings - if you have an Epson, you can use EpsonNet to disable the TCP connection timeout. This new version will still work in that scenario, it will just be re-connecting every 90 seconds.

The main reason why you'd want to use the persistent connection is that when the printer connects, it enables automatic status back, so you can get events anytime the printer changes states right away. If you don't keep the persistent connection, you won't know if a print will actually go through when it comes time to print.

If you do want to print on-demand anyway, and are OK losing the status messages, you can create and dispose of printers for each print, but I haven't tested this use case.

Regarding Android devices, I haven't tested in that scenario. How are you using it on Android? Is it in a Xamarin app? My guess is going in the background causes a lot of the timers to get messed up since backgrounding on mobile typically throttles the code significantly. however, the auto-connect and health-check logic in the new version may just work. I'd love to hear if you have issues with Android in the new version and I can set up a similar test to give it a try.

fatihbabacan92 commented 3 years ago

@lukevp First of all, I already quickly migrated to the new version. It works marvelous without having to change our code too much.

We still use the dispose method. We only need the status to check if the drawer is open or if the paper is out. So it works out greatly for us. What we do is API Call Order -> if orders > 0 -> if !isPrinterOnline -> Connect -> if !order.printed -> print order -> if isPrinterOnline -> mark order as printed -> dispose. After printing the order we check if the printer is still online, that way we know if it was printed or not :)

Yes, we are using Xamarin for UWP, iOS and Android. Your library even works on MacOS with Avalonia. The issue we had was just a conflict by not properly destroying the Foreground Service. When the App pauses we dispose the printer and reconnect via the Foreground Service.

Thank you for the great work Luke! If you want more info about Xamarin or whenever someone here needs help about it, tag me.

lukevp commented 3 years ago

That's awesome feedback @fatihbabacan92 , I'm glad the new version is working well! That's really great that you're using it cross-platform. If you do come across any issues, please open an issue so we can discuss, I'll close this one since both of us have validated the new version's networking functionality. There is a separate story that's been open a while for Xamarin, so I'll tag you over there.