diff --git a/Sources/VoxClawCore/Network/NetworkSession.swift b/Sources/VoxClawCore/Network/NetworkSession.swift index 1d6b326..ca1b790 100644 --- a/Sources/VoxClawCore/Network/NetworkSession.swift +++ b/Sources/VoxClawCore/Network/NetworkSession.swift @@ -331,13 +331,17 @@ final class NetworkSession: Sendable { headers += "\r\n" var responseData = headers.data(using: .utf8) ?? Data() responseData.append(bodyData) - connection.send(content: responseData, completion: .contentProcessed { [weak self] _ in - self?.connection.cancel() + // Capture the connection, not self: nothing retains the session past + // the receive handler, so a `weak self` here is usually already nil by + // the time the send completes and the socket is never closed (leaking + // one fd per request until the listener stops accepting entirely). + connection.send(content: responseData, completion: .contentProcessed { [connection] _ in + connection.cancel() }) } else { headers += "\r\n" - connection.send(content: headers.data(using: .utf8), completion: .contentProcessed { [weak self] _ in - self?.connection.cancel() + connection.send(content: headers.data(using: .utf8), completion: .contentProcessed { [connection] _ in + connection.cancel() }) } } diff --git a/Tests/VoxClawCoreTests/NetworkListenerIntegrationTests.swift b/Tests/VoxClawCoreTests/NetworkListenerIntegrationTests.swift index cc883b7..2e33860 100644 --- a/Tests/VoxClawCoreTests/NetworkListenerIntegrationTests.swift +++ b/Tests/VoxClawCoreTests/NetworkListenerIntegrationTests.swift @@ -38,6 +38,51 @@ struct NetworkListenerIntegrationTests { #expect(!body.contains(".local")) } + /// Each served request must close its socket. The session that owns the + /// connection is not retained past its receive handler, so a response path + /// that cancels via `weak self` silently leaks one descriptor per request + /// and the listener eventually stops accepting entirely. + @Test func servedRequestsDoNotLeakDescriptors() async throws { + let appState = AppState() + let settings = SettingsManager() + let listener = NetworkListener(port: Self.testPort, serviceName: nil, appState: appState, settings: settings) + + try listener.start(onReadRequest: { _ in }) + defer { listener.stop() } + + try await waitForListener(port: Self.testPort) + + let url = URL(string: "http://localhost:\(Self.testPort)/status")! + // Don't let URLSession pool connections; we want each request's server-side + // socket to be the only thing that could accumulate. + let config = URLSessionConfiguration.ephemeral + config.httpShouldUsePipelining = false + let session = URLSession(configuration: config) + defer { session.invalidateAndCancel() } + + // Warm up so one-time allocations aren't counted as growth. + for _ in 0..<10 { _ = try await session.data(from: url) } + let before = Self.openDescriptorCount() + + let requestCount = 200 + for _ in 0.. Int { + (try? FileManager.default.contentsOfDirectory(atPath: "/dev/fd").count) ?? 0 + } + @Test func readEndpointAcceptsJSON() async throws { let appState = AppState() let settings = SettingsManager()