-
Notifications
You must be signed in to change notification settings - Fork 118
Fix race condition in Lambda+LocalServer causing NIOAsyncWriter fatal error (Bug #635) #636
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
… error This fixes a known issue with NIO's async server channel API where cancellation can cause accepted connections to be dropped before being read from the async stream, resulting in NIOAsyncWriter being deallocated without finish() being called. The fix replaces the async bind() API with the traditional callback-based childChannelInitializer, handling each connection immediately in a Task.detached to avoid the async stream cancellation race. Fixes #635
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR addresses a race condition in the Lambda local server that causes a fatal error: "Deinited NIOAsyncWriter without calling finish()". The fix switches from NIO's async bind() API to the traditional callback-based childChannelInitializer approach, eliminating the problematic async stream that could drop unread channels during cancellation.
Changes:
- Replaced async bind() with callback-based childChannelInitializer that spawns detached tasks
- Simplified server shutdown logic by removing the connection iteration loop
- Removed withTaskCancellationHandler wrapper from handleConnection since detached tasks handle cancellation differently
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Task.detached { | ||
| await server.handleConnection(channel: asyncChannel, logger: logger) | ||
| } |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using Task.detached means connection handling tasks are not tracked or awaited. When the server shuts down, there's no mechanism to wait for these detached tasks to complete. While this solves the NIOAsyncWriter race condition, it could lead to abrupt connection terminations during shutdown. Consider documenting this limitation or tracking these tasks in a collection if graceful connection shutdown is important for local testing scenarios.
On fast machines, the local Lambda server crashes with:
This occurs in
NIOAsyncChannelHandler.channelActive()when child connection channels are created.Root Cause
This is a known issue with NIO's async server channel API (see swift-nio#2637).
The fundamental problem:
bind()API createsNIOAsyncChannelinstances for incoming connectionsexecuteThenClose()called on themNIOAsyncWriteris deallocated withoutfinish()being called → fatal errorWhy graceful shutdown doesn't help:
Even closing the server channel gracefully doesn't eliminate the race - there's a timing window where:
IMHO, this is an inherent limitation of the
async bind()API when combined with task cancellation.Solution
I stopped using the
async bind()API entirely. Instead, I use the traditional callback-basedchildChannelInitializer:NIOAsyncChanneldirectly inchildChannelInitializer(synchronous context)Task.detachedto handle the connectionexecuteThenClose()called immediately, preventing the writer from being droppedThis approach avoids the async stream entirely, eliminating the race condition.
Changes
async bind()with traditionalchildChannelInitializerTask.detachedthat immediately callsexecuteThenClose()Trade-offs
Task.detached(unstructured concurrency) to bridge NIO's event-loop world with Swift concurrencyTesting
Tested on fast machines where the race condition was reliably reproducible. The crash no longer occurs.
References
Fixes #635