fix: balance CFSocket context retain to prevent server leak (#7)#9
Conversation
`listen()` used `Unmanaged.passRetained(self)` for the CFSocket context's `info` pointer with both `retain` and `release` callbacks set to nil, so nothing balanced the +1 retain. Once `listen()` ran, the server could never deinit — even after `close()`. Switch to `passUnretained(self)` and provide matching `retain`/`release` callbacks. CFSocketCreate now retains via `retain` per socket, and CFSocketInvalidate releases via `release` per socket, so the IPv4 and IPv6 sockets each contribute a balanced retain/release pair against `self`. Adds a regression test that listens on an ephemeral loopback port, closes, and asserts the server deinits via a weak reference.
|
@claude Please review this PR. Specific things to verify:
Repo context: |
|
Claude finished @atom2ueki's task in 2m 21s —— View job PR ReviewTasks
1. CFSocket retain/release callback contractYour understanding is correct. // on create
if (context->retain) (context->retain)(context->info);
// on invalidate
if (context->release) (context->release)(context->info);With two sockets (IPv4 + IPv6) sharing one context:
Total: 0. Balanced. Your explanation in the PR description of why the alternative ( 2. ARC lifecycle on every code pathTracing each path with the current code: Success (both IPv4 + IPv6)
Non-fatal IPv6 bind failure (falls back to IPv4-only) — lines 344–358 CFSocketInvalidate(ipv6cfsocket)
ipv6cfsocket = nilIPv6 was created (→
Double One pre-existing issue (not introduced by this PR) — let unsafeIntTrue = withUnsafePointer(to: intTrue) { truePointer in
return truePointer // ← pointer escapes the closure
}The returned 3. Regression test reliabilityThe test is well-constructed and will reliably catch a regression. Why it catches the old bug: If Timing — is 1 second right? The actual deinit should happen synchronously: One observation: The test uses VerdictApprove. The retain/release analysis is correct, all code paths are balanced, and the regression test will reliably catch a reintroduction of the leak. The pre-existing |
Two follow-ups from Claude's review:
1. The pre-existing `withUnsafePointer(to: intTrue) { return truePointer }`
pattern escapes the pointer past the closure scope (UB in Swift's
ownership model). Move both setsockopt calls inside a single
`withUnsafePointer(to: &intTrue)` block so the pointer never escapes.
2. The original regression test only exercised the IPv4-only path (one
CFSocket → one retain/release pair). Refactor it into a helper and
add a `host: "localhost"` variant so the dual-stack case (two
sockets sharing one context → two retain/release pairs) is also
covered — that's the path the issue called out as the worst-case
leak.
|
@claude Pushed a follow-up that addresses both suggestions from your review:
Could you re-validate? If clean, I'll merge. |
|
Claude finished @atom2ueki's task in 1m 14s —— View job Re-validationTasks
1.
|
Summary
Fixes #7.
SwiftWebServer.listen(_:host:completion:)usedUnmanaged.passRetained(self)for the CFSocket context'sinfopointerwith both
retainandreleasecallbacksnil, so the +1 retain wasnever balanced. The server could never deinit once
listen()ran, evenafter
close().This PR switches to
passUnretained(self)and provides matchingretain/releasecallbacks. CFSocketCreate retains viaretainpersocket, and CFSocketInvalidate releases via
releaseper socket, soboth the IPv4 and IPv6 sockets contribute balanced retain/release pairs
against
self.Why this approach over the suggestion in the issue
The issue suggested using
passRetained(self)plus areleasecallbackthat calls
Unmanaged<SwiftWebServer>.fromOpaque(ptr).release(). Thatwould over-release: a single
passRetainedproduces +1 retain, but thesame context is registered with two CFSockets, and CFSocket calls the
releasecallback once per socket on invalidation — net result, tworeleases against one retain. Pairing
passUnretainedwith bothretainandreleasecallbacks lets CFSocket manage the lifecyclecleanly per-socket.
Test plan
swift build— cleanswift test— all 44 tests pass, including new regressiontestServerDeinitsAfterCloseDoesNotLeak: listens on anephemeral loopback port, closes, asserts the server deinits via a
weak reference (with a brief run-loop spin to let CF's deferred
release fire).
Reviewers
Tagging @claude for review. Will also run
/codex reviewon this PR.Closes #7