Skip to content

Adopt swift-service-lifecycle for server transports#133

Merged
odrobnik merged 1 commit into
mainfrom
claude/suspicious-newton-f98cdd
Jun 9, 2026
Merged

Adopt swift-service-lifecycle for server transports#133
odrobnik merged 1 commit into
mainfrom
claude/suspicious-newton-f98cdd

Conversation

@odrobnik

@odrobnik odrobnik commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

The server transports currently start and stop through a bespoke SignalHandler that traps only SIGINT and exits via Foundation.exit(). This PR adopts swift-service-lifecycle so the transports run inside a standard ServiceGroup with proper, ordered, signal-driven graceful shutdown.

The dependency is NIO-free (only swift-log — already a dependency — and swift-async-algorithms), swift-tools 6.1, imposes no platform-floor bump, and is gated behind the Server trait, so the core still builds NIO-free.

Why — benefits

1. SIGTERM now triggers graceful shutdown

The old handler caught only SIGINT. Every production launcher — docker stop, launchd, systemd, Kubernetes — terminates with SIGTERM, which was ignored, so the server was hard-killed (exit 143), dropping in-flight requests and skipping cleanup. Now both signals drive a graceful drain → exit 0.

2. Clean async exit + shutdown timeout

Foundation.exit(0) hard-exited past async unwinding, deinits, and log flushing, and shutdownGracefully had no timeout (a wedged transport could hang teardown forever). ServiceGroup.run() now returns normally — the process unwinds cleanly — with a bounded graceful-shutdown timeout.

3. Ecosystem interoperability

The transports conform to ServiceLifecycle.Service, so consumers embedding SwiftMCP in a Swift-on-server app (Hummingbird 2, the Vapor ecosystem, anything from the swift-server group) can drop an MCP transport into their existing ServiceGroup alongside their HTTP server / DB pool / metrics, under one coordinated lifecycle.

4. Coordinated, ordered multi-service shutdown

The HTTP + TCP combination is now one group with shared signal handling and ordered teardown, replacing the manual "stop the TCP transport in a catch block" juggling.

5. Less bespoke code to own

Deletes 182 lines of hand-rolled signal / dispatch-source handling (two copies of SignalHandler.swift). Net −83 lines even after adding the Service conformances — signal masking, re-entrancy guards, and exit semantics are now the library's concern.

What changed

LibraryHTTPSSETransport, StdioTransport, and TCPBonjourTransport conform to Service; each run() observes graceful shutdown via withGracefulShutdownHandler and returns cleanly. Backward compatible: called standalone (outside a group), the handler never fires and behavior is identical to before.

Demos — both CLIs build a ServiceGroup(gracefulShutdownSignals: [.sigterm, .sigint]) instead of SignalHandler + transport.run(). The stdio command uses a stderr-bound lifecycle logger so ServiceGroup messages never corrupt the stdout JSON-RPC stream.

Package.swift — adds swift-service-lifecycle (from: "2.6.0"), gated .when(traits: ["Server"]) on the library and both demo targets.

Bonus: stdio EOF fix

The migration surfaced a latent bug: the stdio read loop slept-and-retried on readLine() == nil, so after the host closed stdin it spun at ~10 Hz until killed. The two duplicated loops are consolidated into one readLoop() that returns on EOF — combined with successTerminationBehavior: .gracefullyShutdownGroup, closing stdin now shuts the server down cleanly.

Verification

  • swift build (default traits, full)
  • swift build --target SwiftMCP --disable-default-traits — NIO-free core (service-lifecycle not linked)
  • swift build --traits "Client,OpenAPI"Server trait off
  • swift test421/421
  • HTTP server + SIGTERM → exit 0 (previously hard-killed, 143); SIGINT → exit 0
  • stdio request → correct initialize response, and the process now exits on its own on stdin EOF

🤖 Generated with Claude Code

Replace the bespoke SIGINT-only signal handling with swift-server's
ServiceGroup, so the server transports start, run, and shut down through
a standard, well-tested lifecycle.

- HTTPSSETransport, StdioTransport, and TCPBonjourTransport conform to
  `ServiceLifecycle.Service`; their `run()` observes graceful shutdown via
  `withGracefulShutdownHandler` and returns cleanly. Backward compatible:
  called standalone (no group), behavior is unchanged.
- Both demo CLIs build a `ServiceGroup` with `[.sigterm, .sigint]`
  instead of the hand-rolled `SignalHandler` (both copies deleted).
- The dependency is NIO-free (swift-log + swift-async-algorithms) and
  gated behind the `Server` trait, so the core still builds NIO-free.
- Fix a latent stdio bug surfaced by the migration: consolidate the two
  duplicated read loops into one `readLoop()` that returns on stdin EOF
  instead of spinning at ~10 Hz until killed.

Benefits: SIGTERM now triggers a graceful drain (exit 0) instead of a
hard kill (143) under docker/launchd/systemd; shutdown unwinds via async
return with a bounded timeout instead of Foundation.exit(); and the
Service conformance lets consumers drop MCP transports into their own
ServiceGroup (Hummingbird/Vapor/etc.).

Verified: full + NIO-free-core + Server-off builds; 421/421 tests;
SIGTERM/SIGINT -> exit 0; stdio exits on EOF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@odrobnik odrobnik merged commit fc21e82 into main Jun 9, 2026
7 checks passed
@odrobnik odrobnik deleted the claude/suspicious-newton-f98cdd branch June 9, 2026 17:10
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