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?

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") }
}
Issues with TCP Socket Management and Ghost Data on ESP32 (Swift)
 
 
Q