Issues with TCP Socket Management and Ghost Data on ESP32 (Swift)

Hi everyone,

I'm developing an iOS app using Swift (Foundation, Network, and Combine) that communicates via TCP with a weighing scale. The scale uses an internal ESP32 module acting as a Wi-Fi Access Point (no internet access) specifically for data transmission. The app connects to this network and opens a socket to receive weight data and send command strings.

I’m currently facing two main issues:

Socket Management: The socket isn't closing properly. Occasionally, the app opens multiple simultaneous connections instead of maintaining a single one. Since the ESP32 has a client limit, these ghost connections eventually hang the communication module.

Invalid Outbound Data: The connection drops frequently because the scale receives invalid strings from the app. My logs show strange character sequences (like "gggggggggfdhj" or "vfgdddddddddddtty") being sent involuntarily. I haven't programmed these strings, and they cause the scale to terminate the session due to protocol violations.

How can I ensure proper socket closure and prevent these random data packets?

Additionally, a technical question: Is it possible to keep this TCP connection active in the background indefinitely on iOS while the user interacts with other apps?

Answered by DTS Engineer in 887695022

You’re using the term socket as a synonym for network connection. That’s not correct, and it muddies the waters when it comes to how to approach this problem. For example, one of your issues relates to connection viability, and the best way to handle that problem varies depending on whether you’re using the BSD Sockets API or the Network framework API.

Is it possible to keep this TCP connection active in the background indefinitely on iOS while the user interacts with other apps?

No. See iOS Background Execution Limits.

Invalid Outbound Data … My logs show strange character sequences (like "gggggggggfdhj" or "vfgdddddddddddtty") being sent involuntarily.

It’s very unlikely that’s being generated by Network framework. The majority of times when I see problems like this it’s because the app itself is sending that junk, usually because it’s using unsafe pointers. However, the Swift code you posted contains no unsafe pointers (yay!) so that’s not the cause.

The next thing to look at is concurrency. Network framework itself is thread safe. That is, you can work with a single NWConnection from multiple threads without the connection itself getting confused. However, it’s best to confine access to the connection to be from a single thread (or queue) just so you avoid confusing yourself.

Looking at your code I see two queues in play:

  • You create a custom serial queue (self.queue) for the NWConnection.
  • You also do work on the main queue.

This is likely to cause problems. For example, you current manipulate self.buffer from both queues — in disconnect() to work with it from the main queue and in receive() you work with it from the connection’s queue — and that’s undefined behaviour in Swift.

IMO you should just use the main queue for everything here. Using a secondary queue makes sense if your pushing a lot of data through the connection, but that’s clearly not the case here.

If that doesn’t fix things, the next step is to use an RVI packet trace to see what’s happening on the ‘wire’. This will tell you whether the junk is being generated on the send side by the iOS device or on the receive side by your accessory.

Connection Management: The connection isn't closing properly.

It’s hard to offer insight without a lot more details about who is closing the connection and when. One specific thing to watch out for is connection defuncting. TN2277 Networking and Multitasking explains this concept, albeit under an older name (socket resource reclaim). If your app suspends and the connection gets defuncted, the remote peer won’t hear about this unless it tries to send some traffic on the connection. So, it’s important that you not let this happen, usually by closing the connection as your app moves to the background.

If that’s not in then I’m gonna need a more detail explanation as to what triggers this problem.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks for this great post and so interesting, I have always worked with tcp sockets and probably the most fun programming time of my life. But definitely it’s been a while a far from using sockets with Swift. However, there are many great engineers that work on that team and I’m sure they are going to jump in this thread if we can unpack all the details.

My first thought was I didn’t see any code showing how are you consuming the sockets to make sure you are not running out of available sockets because the iOS app is abandoning connections without properly closing them.

You need a strict teardown process. Whenever you disconnect, encounter an error, or the app goes to the background, you must explicitly cancel the connection and break any retain cycles in your state handlers.

Would you be able to create a simple focused project to share here?

That'll help us better understand what's going on. If you're not familiar with preparing a test project, take a look at Creating a test project.

Albert
  Worldwide Developer Relations.

Hello,

To assist with the investigation, I have created a simplified test project that isolates the issue I’m encountering. This specific implementation is where I am seeing the most consistent problems.

Could you please take a look at this example and let me know if you see anything that might be causing the failure? The test code is provided below:

//  TCPClient.swift

import Foundation
import Network
import Combine

class TCPClient: ObservableObject {
    private var connection: NWConnection?
    private let queue = DispatchQueue(label: "TelnetQueue")

    @Published var currentMessage: String = "---"
    @Published var isConnected: Bool = false

    // Contador de soquetes solicitado para monitoramento
    private var socketAttemptCount: Int = 0

    private let host: NWEndpoint.Host = "192.168.1.50"
    private let port: NWEndpoint.Port = 23

    private let allowedCommands = ["HRS", "BTz", "BTd"]
    private var buffer = Data()

    func toggleConnection() {
        if isConnected {
            disconnect()
        } else {
            connect()
        }
    }

    func connect() {
        // Incrementa e imprime a contagem toda vez que tenta abrir uma nova conexão
        socketAttemptCount += 1
        print("TENTATIVA DE ABERTURA DE SOQUETE Nº: \(socketAttemptCount)")

        guard connection == nil else {
            print("AVISO: Já existe uma instância de conexão. Abortando duplicata.")
            return
        }

        let tcpOptions = NWProtocolTCP.Options()
        tcpOptions.noDelay = true

        // Configuração de Keep-Alive de baixo nível (estilo Android)
        tcpOptions.enableKeepalive = true
        tcpOptions.keepaliveIdle = 5    // Tempo (segundos) de espera antes de testar a conexão
        tcpOptions.keepaliveInterval = 1 // Intervalo entre testes se não houver resposta
        tcpOptions.keepaliveCount = 3    // Tentativas antes de derrubar

        let parameters = NWParameters(tls: nil, tcp: tcpOptions)
        parameters.prohibitedInterfaceTypes = [.cellular] // Garante uso do Wi-Fi local

        connection = NWConnection(host: host, port: port, using: parameters)

        connection?.stateUpdateHandler = { [weak self] state in
            guard let self = self else { return }
            print("STATE UPDATE:", state)

            switch state {
            case .ready:
                print("ESTADO: Pronto (Conectado à balança)")
                DispatchQueue.main.async {
                    self.isConnected = true
                    self.currentMessage = "CONECTADO"
                    self.receive()

                    // Envia o comando inicial uma única vez
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                        if self.isConnected { self.sendHRS() }
                    }
                }
            case .failed(let error):
                print("ESTADO: Falha crítica: \(error)")
                self.disconnect()
            case .cancelled:
                print("ESTADO: Soquete cancelado e memória liberada.")
                DispatchQueue.main.async { self.isConnected = false }
            case .waiting(let error):
                print("ESTADO: Aguardando rede (Pode ser erro de porta no ESP32): \(error)")
            default:
                break
            }
        }

        connection?.start(queue: queue)
    }

    func disconnect() {
        print("DISCONNECT CHAMADO - Limpando recursos...")

        // Zera o contador e o estado ao desconectar
        socketAttemptCount = 0

        DispatchQueue.main.async {
            self.isConnected = false
            self.connection?.stateUpdateHandler = nil // Remove o handler antes de cancelar para evitar loops
            self.connection?.cancel()
            self.connection = nil
        }

        DispatchQueue.main.async {
            self.currentMessage = "DESCONECTADO"
            self.buffer.removeAll()
            print("Contador de soquetes zerado.")
        }
    }

    private func receive() {
        // Verificação de segurança: soquete deve existir e o app deve querer estar conectado
        guard let connection = connection, isConnected else { return }

        connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
            guard let self = self else { return }

            if let data = data, !data.isEmpty {
                self.buffer.append(data)

                // Processa apenas mensagens completas (terminadas em CRLF)
                while let range = self.buffer.range(of: Data([0x0d, 0x0a])) {
                    let lineData = self.buffer.subdata(in: 0..<range.lowerBound)
                    self.buffer.removeSubrange(0..<range.upperBound)

                    if let text = String(data: lineData, encoding: .ascii) {
                        DispatchQueue.main.async {
                            // Limpa espaços e exibe o valor da balança
                            self.currentMessage = text.trimmingCharacters(in: .whitespacesAndNewlines)
                        }
                    }
                }
            }

            // Se o ESP32 enviar um pacote de fechamento (FIN)
            if isComplete {
                print("AVISO: O servidor (ESP32) encerrou a conexão.")
                self.disconnect()
                return
            }

            if error != nil {
                self.disconnect()
                return
            }

            // Mantém a escuta ativa enquanto estiver conectado
            if self.isConnected {
                self.receive()
            }
        }
    }

    func send(_ command: String) {
        guard isConnected, let connection = connection, connection.state == .ready else { return }

        guard allowedCommands.contains(where: { command.starts(with: $0) }) else { return }

        guard let data = (command + "\r\n").data(using: .ascii) else { return }

        // Envio com proteção de processamento
        connection.send(content: data, completion: .contentProcessed { [weak self] error in
            if let error = error {
                print("Erro ao enviar comando: \(error)")
                self?.disconnect()
            }
        })
    }

    // Funções auxiliares mantidas conforme sua estrutura original
    func buildHRSCommand() -> String {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.dateFormat = "HH:mm dd/MM/yy"
        return "HRS" + formatter.string(from: Date())
    }

    func sendHRS() { send(buildHRSCommand()) }
    func sendBTz() { send("BTz") }
    func sendBTd() { send("BTd") }
}

You’re using the term socket as a synonym for network connection. That’s not correct, and it muddies the waters when it comes to how to approach this problem. For example, one of your issues relates to connection viability, and the best way to handle that problem varies depending on whether you’re using the BSD Sockets API or the Network framework API.

Is it possible to keep this TCP connection active in the background indefinitely on iOS while the user interacts with other apps?

No. See iOS Background Execution Limits.

Invalid Outbound Data … My logs show strange character sequences (like "gggggggggfdhj" or "vfgdddddddddddtty") being sent involuntarily.

It’s very unlikely that’s being generated by Network framework. The majority of times when I see problems like this it’s because the app itself is sending that junk, usually because it’s using unsafe pointers. However, the Swift code you posted contains no unsafe pointers (yay!) so that’s not the cause.

The next thing to look at is concurrency. Network framework itself is thread safe. That is, you can work with a single NWConnection from multiple threads without the connection itself getting confused. However, it’s best to confine access to the connection to be from a single thread (or queue) just so you avoid confusing yourself.

Looking at your code I see two queues in play:

  • You create a custom serial queue (self.queue) for the NWConnection.
  • You also do work on the main queue.

This is likely to cause problems. For example, you current manipulate self.buffer from both queues — in disconnect() to work with it from the main queue and in receive() you work with it from the connection’s queue — and that’s undefined behaviour in Swift.

IMO you should just use the main queue for everything here. Using a secondary queue makes sense if your pushing a lot of data through the connection, but that’s clearly not the case here.

If that doesn’t fix things, the next step is to use an RVI packet trace to see what’s happening on the ‘wire’. This will tell you whether the junk is being generated on the send side by the iOS device or on the receive side by your accessory.

Connection Management: The connection isn't closing properly.

It’s hard to offer insight without a lot more details about who is closing the connection and when. One specific thing to watch out for is connection defuncting. TN2277 Networking and Multitasking explains this concept, albeit under an older name (socket resource reclaim). If your app suspends and the connection gets defuncted, the remote peer won’t hear about this unless it tries to send some traffic on the connection. So, it’s important that you not let this happen, usually by closing the connection as your app moves to the background.

If that’s not in then I’m gonna need a more detail explanation as to what triggers this problem.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Issues with TCP Socket Management and Ghost Data on ESP32 (Swift)
 
 
Q