Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions Sources/VoxClawCore/Network/NetworkSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
}
}
Expand Down
45 changes: 45 additions & 0 deletions Tests/VoxClawCoreTests/NetworkListenerIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<requestCount { _ = try await session.data(from: url) }
try await Task.sleep(for: .milliseconds(500))

let growth = Self.openDescriptorCount() - before
// Leaking would grow roughly 1:1 with requests. Allow generous headroom
// for client-side sockets still winding down.
#expect(growth < requestCount / 4, "descriptor count grew by \(growth) over \(requestCount) requests")

// And the listener must still be answering after the burst.
let (_, response) = try await session.data(from: url)
#expect((response as? HTTPURLResponse)?.statusCode == 200)
}

/// Open file descriptors for the current process.
private static func openDescriptorCount() -> Int {
(try? FileManager.default.contentsOfDirectory(atPath: "/dev/fd").count) ?? 0
}

@Test func readEndpointAcceptsJSON() async throws {
let appState = AppState()
let settings = SettingsManager()
Expand Down
Loading