Skip to content

feat: make SwiftWebServer @MainActor for honest Sendable conformance#5

Merged
atom2ueki merged 4 commits into
mainfrom
feat/main-actor
May 1, 2026
Merged

feat: make SwiftWebServer @MainActor for honest Sendable conformance#5
atom2ueki merged 4 commits into
mainfrom
feat/main-actor

Conversation

@atom2ueki
Copy link
Copy Markdown
Owner

Summary

Annotate `SwiftWebServer` with `@MainActor` so it is automatically `Sendable` and the type system reflects what the runtime already requires. The class installs its `CFSocket` accept callbacks via `CFRunLoopGetCurrent()`, so `listen()`/`close()` already had to be called from a thread running its own run loop (in practice: main on iOS/macOS apps). `@MainActor` makes that contract explicit.

This replaces consumer workarounds like `@unchecked Sendable` boxes (which CodingPlanAuthKit's `LocalCallbackServer` had to use against 0.1.0/0.2.0).

Why not `@unchecked Sendable`?

The class has mutable state (`_status`, `_currentPort`, route handlers, middleware manager, sockets, the static `connections` dict) accessed from CFSocket callbacks, route handlers, and configuration calls. Without internal synchronization, declaring `@unchecked Sendable` would over-promise. `@MainActor` is honest: the implementation needs main-runloop isolation, so the type system says so.

Per-request work — still on a background queue

To keep `recv()` from blocking main, `Connection` continues to run on its own dispatch queue. The configuration state it reads during request handling (routes, middleware, static directories) is annotated `nonisolated(unsafe)` under the documented contract that it is configured before `listen()` and treated as read-only after. `middlewareManagerInternal` and `findStaticFile(for:)` are `nonisolated` for the same reason.

If a consumer mutates routes after `listen()`, the behavior is undefined — same as before, just now spelled out.

CFSocket accept callbacks

The `@convention(c)` accept callbacks now wrap their `handleConnect` call in `MainActor.assumeIsolated`. That's a runtime assertion of what the run loop already guarantees — it satisfies the type system without changing behavior.

Migration

Consumer call sites that were already on `@MainActor` (or hopping there via `Task { @mainactor in ... }`) keep working unchanged. Sites that were inadvertently calling from a background context will now get a compiler diagnostic instead of a silent race — strictly an improvement.

The package now declares `macOS(.v12)` as a platform minimum. `MainActor` itself is macOS 10.15+; sticking to a modern minimum keeps the surface clean.

Test plan

  • `swift build` clean.
  • `swift test` — 43 tests, 0 failures. Test classes annotated `@MainActor` (they construct and configure the server).
  • Manual smoke test against CodingPlanAuthKit's OAuth flow on a real iOS/macOS app (consumer PR will pin to 0.3.0).

Suggested release

`0.3.0` — minor bump for the new `Sendable` conformance, the platform minimum, and the `@MainActor` isolation requirement.

The class is fundamentally main-runloop-coupled — listen() installs
CFSocket accept callbacks via CFRunLoopGetCurrent(), which means it
already requires the calling thread to be running its own CFRunLoop
(in practice: main on iOS/macOS apps). Annotating the class
@mainactor matches that runtime reality and makes the type
automatically Sendable, so consumers no longer need an
@unchecked Sendable wrapper to pass instances across actor boundaries.

The CFSocket accept callbacks (which fire on the main run loop) now
wrap their @mainactor calls in MainActor.assumeIsolated, asserting at
runtime the same isolation the API contract already requires.

Per-request work in Connection still runs on a background dispatch
queue (so blocking recv() doesn't stall main). The configuration
state Connection reads during request handling — routeHandlers, the
router instance, the middleware manager, staticDirectories — is
annotated nonisolated(unsafe) under the documented contract that it
is configured before listen() and treated as read-only thereafter.
findStaticFile(for:) and middlewareManagerInternal are nonisolated
for the same reason.

Package now declares macOS 12 as a platform minimum (MainActor
itself is macOS 10.15+, but staying on a clean modern minimum).

Test classes get @mainactor since they construct and configure the
server. All 43 tests pass.

Suggested release: 0.3.0 — minor bump for the Sendable conformance,
the new platform minimum, and the @mainactor isolation requirement
on consumer call sites that were previously implicitly main-thread.
@atom2ueki
Copy link
Copy Markdown
Owner Author

@claude review this PR

@claude
Copy link
Copy Markdown

claude Bot commented May 1, 2026

Claude finished @atom2ueki's task in 2m 5s —— View job


PR Review: feat: make SwiftWebServer @mainactor for honest Sendable conformance

  • Read changed files and git diff
  • Analyze Swift concurrency correctness
  • Check @mainactor and nonisolated(unsafe) usage
  • Review CFSocket callback changes
  • Review test annotations
  • Post final review feedback

Overall: This is a well-reasoned and correctly implemented change. The motivation is sound — @MainActor makes the runtime contract explicit rather than relying on @unchecked Sendable. The implementation is mostly correct. I have a few issues worth addressing.


Correctness Issues

swift-tools-version needs to be bumped (medium)

Package.swift still declares swift-tools-version:5.6, but nonisolated(unsafe) (SE-0414) requires Swift 5.10. A user with Swift 5.6–5.9 will get a hard compiler error with no clear guidance. The tools version should communicate the real minimum:

// swift-tools-version:5.10

Fix this →

nonisolated(unsafe) on mutable var routeHandlers is the riskiest annotation (medium)

The PR's documented contract — "configure before listen(), read-only after" — is correct but entirely unenforced. routeHandlers is a var dict, and any call to get(), post(), etc. after listen() silently creates a data race. A cheap guard in the route-registration methods would make the contract load-bearing:

// In Sources/SwiftWebServer/Core/SwiftWebServer.swift
func get(_ path: String, completion: @escaping (Request, Response) -> Void) {
    precondition(!isRunning, "Routes must be registered before listen()")
    router.get(path, handler: completion)
    ...
}

This isn't strictly required for the PR to be mergeable, but it would prevent silent undefined behavior.


Minor Issues

IPv6 callback missing its comment (low)

The IPv4 accept callback got a detailed comment explaining why MainActor.assumeIsolated is safe here. The IPv6 callback at line ~249 has the same assumeIsolated block but no comment. Minor inconsistency that could confuse a future reader.

// IPv6 socket (Sources/SwiftWebServer/Core/SwiftWebServer.swift ~line 248)
{ (socket, _, address, data, info) in
    // ← same comment as IPv4 would help here
    MainActor.assumeIsolated {

Fix this →

MockResponse.init() isolation is inconsistent (low, test-only)

In MiddlewareTests.swift, MockResponse gets @MainActor on its init() but the class itself is unannotated. The class-level @MainActor on MiddlewareTests propagates context for callers within the test class, but MockResponse being partially annotated is a bit awkward. Since MockResponse constructs a SwiftWebServer (now @MainActor), annotating the entire class @MainActor would be cleaner.


Pre-existing issues (not introduced by this PR, noted for awareness)

  • Retain cycle: SwiftWebServer.connections → Connection → server: SwiftWebServer? (non-weak reference). Could prevent server deallocation when connections are alive.
  • Unbalanced retain: Unmanaged.passRetained(self) in listen() has release: nil in the CFSocketContext, permanently leaking one refcount.

Both of these are outside the scope of this PR.


What's done well

  • MainActor.assumeIsolated in @convention(c) callbacks is the right tool. It's a runtime assertion — not a behavioral change — that satisfies the type system because CFSocket callbacks genuinely fire on the run loop where the source was scheduled, and listen() enforces that to be the main run loop via CFRunLoopGetCurrent().
  • nonisolated internal var middlewareManagerInternal and nonisolated internal func findStaticFile(for:) are correctly annotated for use from Connection's background queue.
  • Test classes annotated @MainActor cleanly handles the requirement that server construction happens on the main actor.
  • Documentation is clear and accurate — the concurrency section in the class doc string, and the inline comments explaining the nonisolated(unsafe) contract, are well-written.

Verdict: Approve once swift-tools-version is bumped to 5.10. The precondition for post-listen() route mutation is a good-to-have but not a blocker.

@atom2ueki
Copy link
Copy Markdown
Owner Author

@codex review this PR

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de85d1b302

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/SwiftWebServer/Core/SwiftWebServer.swift
atom2ueki added 2 commits May 1, 2026 17:57
Codex review on PR #5 caught that the new sources use nonisolated(unsafe)
(Swift 5.10) and MainActor.assumeIsolated (Swift 5.9), but the manifest
still declared swift-tools-version:5.6. Consumers on Swift 5.6-5.9 would
have hit a parse error when SPM resolved this package.

Raising the manifest tools version to 5.10 — the minimum that supports
all the annotations introduced for @mainactor isolation — so dependency
resolution honestly reflects the source's compiler requirements.
- Make the "configure before listen()" contract load-bearing: every
  route registration (get/post/put/delete) and every middleware/static
  directory mutator now goes through `assertNotRunning()`, which is a
  precondition that fires with a clear message if the caller mutates
  config after the server reaches `.running`. The state in question is
  annotated `nonisolated(unsafe)` because Connection reads it from a
  background queue; mutating after listen() was undefined behavior, and
  this turns "undefined" into a deterministic crash.

- Add the same explanatory comment to the IPv6 CFSocket accept callback
  that the IPv4 one already had, so a future reader doesn't have to
  hunt across the file to understand why MainActor.assumeIsolated is
  safe in a @convention(c) block.

- Promote `@MainActor` from `MockResponse.init()` to the whole class
  in MiddlewareTests so the isolation declaration matches MockConnection
  and the parent class context.

Pre-existing issues called out by review (out of scope for this PR):
- Connection holds a non-weak reference to SwiftWebServer; combined
  with SwiftWebServer.connections holding strong refs to Connections,
  this is a retain cycle that prevents server deallocation while
  connections are alive.
- listen() does Unmanaged.passRetained(self) into CFSocketContext but
  the context's `release` callback is nil, leaking one refcount per
  listen().

Both worth opening separate issues for; not touching them here to keep
the @mainactor PR focused.
@atom2ueki
Copy link
Copy Markdown
Owner Author

@claude — review feedback addressed. Fix-up commits:

Finding Status Where
Medium — swift-tools-version:5.65.10 Fixed earlier 9256d8a (also resolved Codex's P1 thread)
Medium — unenforced "configure before listen()" contract Fixed 59781c1 — added assertNotRunning() helper, applied to every route registration (get/post/put/delete and all variadic-middleware overloads via the base methods) plus use(_:), use(_:_:), use(_:_:_:), and use(staticDirectory:). Mutating config after .running now triggers a precondition with a clear message.
Low — IPv6 callback missing the comment Fixed 59781c1 — copied the IPv4 explanation onto the IPv6 callback.
Low — MockResponse partial @MainActor Fixed 59781c1 — promoted from init() to the whole class.

Pre-existing items called out (out of scope, not touching in this PR):

  • Connection.server non-weak + SwiftWebServer.connections strong → retain cycle blocking server dealloc.
  • Unmanaged.passRetained(self) into CFSocketContext with no release callback → permanent +1 retain per listen().

Worth opening separate issues for both. Build + 43 tests still green.

@claude
Copy link
Copy Markdown

claude Bot commented May 1, 2026

Claude finished @atom2ueki's task in 1m 8s —— View job


Re-review: Verifying fix-up commits

  • Read updated changed files
  • Verify assertNotRunning() guard applied correctly
  • Verify IPv6 callback comment added
  • Verify MockResponse class-level @MainActor
  • Confirm swift-tools-version:5.10
  • Post final verdict

All four findings from the original review are fully addressed. Here's what I verified in the diff:

Medium — swift-tools-version:5.10 (Package.swift) ✅

First line is now // swift-tools-version:5.10 with an added comment explaining why (nonisolated(unsafe) + MainActor.assumeIsolated). .macOS(.v12) platform minimum also present.

Medium — assertNotRunning() guard (SwiftWebServer.swift) ✅

The helper is correctly implemented using the #function default-argument trick — when get() calls assertNotRunning(), the precondition message will read "SwiftWebServer.get(_:completion:) must be called before listen()...", which is exactly what a developer needs to see. Applied to all eight relevant call sites:

Method Guarded
use(_ middleware:)
use(_ path:, _ middleware:)
use(_ method:, _ path:, _ middleware:)
get(_:completion:)
post(_:completion:)
put(_:completion:)
delete(_:completion:)
use(staticDirectory:)

Low — IPv6 callback comment (SwiftWebServer.swift) ✅

The IPv6 @convention(c) callback now has the same explanation as IPv4: "Same as the IPv4 callback above: this fires on the run loop the source was added to, which listen() requires to be the main run loop."

Low — MockResponse class-level @MainActor (MiddlewareTests.swift) ✅

@MainActor is now on the class declaration, not just init(). All three test classes (SwiftWebServerTests, MiddlewareTests, BindRequestTests) are properly annotated.


Verdict: All review findings resolved. This PR is ready to merge.

The pre-existing issues (retain cycle on Connection.server, unbalanced passRetained in CFSocketContext) are correctly scoped out — worth filing as separate issues before 0.3.0 ships if any consumers call listen() more than once in a process lifetime.

@atom2ueki
Copy link
Copy Markdown
Owner Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 59781c1f85

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/SwiftWebServer/Core/SwiftWebServer.swift
Codex P2 review on PR #5 caught that the variadic overloads
(get/post/put/delete with `_ middleware: Middleware...`) were inserting
middleware into the manager *before* delegating to the base method that
held the assertNotRunning() check. A caller registering a route after
listen() would mutate shared request-path state on a live server and
only then trap, reintroducing the race the precondition was meant to
prevent.

Move assertNotRunning() to the top of each variadic overload, before
any middleware insertion, so the trap fires before any state is
touched.
@atom2ueki
Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@atom2ueki atom2ueki merged commit 15de0c3 into main May 1, 2026
2 checks passed
@atom2ueki atom2ueki deleted the feat/main-actor branch May 1, 2026 10:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant