Repository navigation
Description
I have done the following
- I have searched the existing issues
- If possible, I've reproduced the issue using the 'main' branch of this project
(Reproduced on the 0.51.0 tag, c411138. Onmain(cc8d321), the only later change toVminitd.swiftis the statistics field mapping, andinit/close()are unchanged.)
Steps to reproduce
Minimal Swift package (vminitd-close-trap): https://gist.github.com/dandmetcalf-stack/704e77f8a5b3a368a3b8d14572dece05. It depends only on Containerization 0.51.0, swift-nio and grpc-swift; the gist README shows how to restore the package layout.
A. No VM (deterministic, uses only the public Vminitd API):
import Containerization
import Foundation
import NIOPosix
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
var fds: [Int32] = [0, 0]
precondition(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0)
// The peer never answers, so nothing is in flight.
let agent = try await Vminitd(connection: FileHandle(fileDescriptor: fds[0], closeOnDealloc: false), group: group)
try? await agent.close() // close() runs before the Task created in init has called runConnections()
// When `agent` is released, the process traps (SIGTRAP).swift build, then runvminitd-close-trap no-vm 20.- The process exits with SIGTRAP (exit status 133) in the first round.
- Control:
vminitd-close-trap no-vm 20 10sleeps 10 ms betweeninitandclose(), and it does not trap.
B. Real VM (the way we hit it in production):
- Boot a
LinuxContainerwithVZVirtualMachineManager. We used the Kata 3.17.0 arm64 kernel, the vminit 0.51.0 initfs and a stockdebian:bookworm-slimrootfs, running/bin/sleep 600. - Repeatedly call
container.statistics(categories: [.cpu])from aTask, and cancel that task after a random 0–3 ms delay:
for _ in 0..<1000 {
let call = Task { _ = try? await container.statistics(categories: [.cpu]) }
usleep(UInt32.random(in: 0...3000))
call.cancel()
await call.value
}- The process traps with SIGTRAP, usually within the first few hundred iterations (0.07–0.52 s after the container starts).
Current behavior
The process terminates with EXC_BREAKPOINT / SIGTRAP:
NIOCore/NIOAsyncWriter.swift:177: Fatal error: Deinited NIOAsyncWriter without calling finish()
Measured on the machine below. Each "process" is one run of the reproducer.
| Scenario | Result |
|---|---|
A. no-vm, close() right after init |
40/40 processes trapped on their first close(). In 39 of them close() had thrown clientIsStopped; in 1 it had returned normally. |
A. no-vm, 10 ms delay before close() |
0/5 trapped (100 closes) |
B. VM, cancelled statistics() |
5/6 processes trapped, after 31–219 cancelled calls (0.07–0.52 s); 1 survived 1000 calls |
B. VM, the same call wrapped in Task.detached (cancellation not inherited) |
0/5 trapped (5000 calls) |
B. VM, with the close() change under "Possible fix" |
0/5 trapped (5000 cancelled calls); no-vm 0/10 trapped (200 closes) |
The trap shows up at two sites, both releasing the same unconsumed NIOAsyncChannel.
- When the
Vminitdvalue is destroyed inwithAgent(3 of 5 VM crash reports, and every no-VM report):
0 libswiftCore.dylib _assertionFailure(_:_:file:line:flags:)
1 NIOAsyncWriter.InternalClass.deinit (NIOAsyncWriter.swift:177)
5 outlined destroy of HTTP2ClientTransport.WrappedChannel.Configured?
6 HTTP2ClientTransport.WrappedChannel.deinit (WrappedChannel.swift)
10 GRPCClient.deinit (GRPCClient.swift)
14 destroy for Vminitd
15 VirtualMachineInstance.withAgent<A>(fn:) (LinuxContainer.swift:1636)
16 closure #1 in LinuxContainer.statistics(categories:) (LinuxContainer.swift:1279)
18 AsyncMutex.withLock<A>(_:) (AsyncMutex.swift:57)
19 LinuxContainer.statistics(categories:) (LinuxContainer.swift:1276)
- On the NIO event loop, when the channel becomes active after the
Vminitdhas already been closed and released (2 of 5 VM crash reports):
0 libswiftCore.dylib _assertionFailure(_:_:file:line:flags:)
1 NIOAsyncWriter.InternalClass.deinit (NIOAsyncWriter.swift:177)
5 NIOAsyncChannelHandler.channelActive(context:) (AsyncChannelHandler.swift:139)
13 NIOHTTP2Handler.channelActive(context:) (HTTP2ChannelHandler.swift:548)
21 ChannelPipeline.SynchronousOperations.fireChannelActive() (ChannelPipeline.swift:1475)
23 BaseSocketChannel.becomeActive0(promise:) (BaseSocketChannel.swift:1420)
24 BaseSocketChannel.registerAlreadyConfigured0(promise:) (BaseSocketChannel.swift:1020)
25 closure #2 in setupChannel #1 in ClientBootstrap.withConnectedSocket(_:) (Bootstrap.swift:1158)
Expected behavior
Calling close() on a Vminitd at any point after init returns should be safe, including before its connection task has started and after a cancelled RPC. Cancelling a task that calls a LinuxContainer API should surface as an error such as CancellationError or RPCError, not crash the host process.
Root cause (as far as we can tell)
The references below are to Containerization 0.51.0 and to the dependency versions that resolved in our build (grpc-swift-2 2.4.4, grpc-swift-nio-transport 2.11.0, swift-nio 2.104.0). The same code is present in grpc-swift-2 2.4.2 and grpc-swift-nio-transport 2.9.0.
Vminitd.init(Sources/Containerization/Vminitd.swift:36-62) builds the transport withHTTP2ClientTransport.WrappedChannel.wrapping(config:serviceConfig:makeChannel:). That call creates a pre-configuredNIOAsyncChannelwhose writer must be finished.initthen starts the client from an unstructured task (:59-61,Task { try await grpcClient.runConnections() }).initreturns without waiting for that task to start. It also resumes its continuation from insidechannelInitializer(:44), before the channel is registered or active.Vminitd.close()(:65-68) callsgrpcClient.beginGracefulShutdown()and then awaits the task.- If
close()gets there first,GRPCClientmoves straight from.notStartedto.stopped. It does not notify the transport (grpc-swift-2Sources/GRPCCore/GRPCClient.swift:124-129and:261-266).runConnections()then throwsclientIsStopped(:237-238,:98-122) and never callstransport.connect(). WrappedChannelconsumes and finishes its pre-configuredNIOAsyncChannelonly insideconnect(), throughconnection.executeThenClose(grpc-swift-nio-transportSources/GRPCNIOTransportCore/Client/WrappedChannel/WrappedChannel.swift:146-216,:172). Withoutconnect(), nothing finishes the outbound writer. When the last reference goes away,NIOAsyncWriter.InternalClass.deinithitspreconditionFailure(swift-nioSources/NIOCore/AsyncSequences/NIOAsyncWriter.swift:177). That release happens either inVminitd's destructor or inNIOAsyncChannelHandler.channelActive(Sources/NIOCore/AsyncChannel/AsyncChannelHandler.swift:139), whichever comes last.- There is a second, rarer path (1 of 40 no-VM processes). The client is already
.running, but the transport is still.idleor.configuringwhenbeginGracefulShutdown()arrives.WrappedChannel+State.swift:188-199then moves it to.shutDownor.shuttingDown. After that,connect()either returns early (WrappedChannel.swift:150) or closes the channel (:203-204) without finishing the pre-configuredNIOAsyncChannel. In this caseclose()returns normally and the process still traps on release.
A cancelled caller makes this likely because the RPC inside withAgent (LinuxContainer.swift:1628-1638) fails immediately. close() then runs microseconds after init. Any withAgent / dialAgent() caller can hit it: statistics, filesystemOperation, copy, exec and so on.
Callers can't fully avoid this themselves. Running the SDK call in Task.detached hides the caller's cancellation, but cancellation that starts inside the SDK can still reach an RPC. One example (not reproduced here) is the withThrowingTaskGroup in LinuxProcess.start (LinuxProcess.swift:336-361): if setupIO fails first, the group cancels the sibling agent.createProcess task.
Possible fix
Containerization-side options:
-
Make
close()safe before start. In our tests (patch below, with no other changes), cancelling the connection task instead of callingbeginGracefulShutdown()removed the trap: 0/5 VM processes and 0/10 no-VM processes trapped.runConnections()still runstransport.connect(), andexecuteThenClosefinishes the channel. A gRPC-only probe that forcesclose()to run first trapped 10/10 with the currentclose()and 0/10 with the cancel version (500 closes). The trade-off is that RPCs still in flight on this client are cancelled instead of drained. That seems acceptable for per-call clients (withAgent), but you would need to judge it for longer-lived agents.public func close() async throws { - self.grpcClient.beginGracefulShutdown() - try await self.connectionTask.value + self.connectionTask.cancel() + _ = await self.connectionTask.result } -
Alternatively, keep graceful shutdown but only after
runConnections()has reached the transport. For example,initcould wait until the connection task has started, orclose()could fall back to cancellation when the client never started.initcurrently returns before the channel is even active, so the rarer race in step 5 would still need handling.
Possibly upstream (grpc-swift-nio-transport / grpc-swift-2): WrappedChannel could finish or close its pre-configured NIOAsyncChannel when it is shut down or deinitialized without connect() having consumed it. GRPCClient.beginGracefulShutdown() in .notStarted could also tell the transport. Either change would fix this for every user of wrapping(config:serviceConfig:makeChannel:). We're happy to file it there as well if you think that's the better place.
Environment
- OS: macOS 27.0.1 (26A434), Apple M6 (Mac18,5)
- Xcode: none; Command Line Tools 27.0.0.0.1788430756
- Swift: Apple Swift version 6.4 (swiftlang-6.4.0.34.1 clang-2100.3.34.1)
- Containerization: 0.51.0 (c411138bab442564f65d5e2bebd4a807d5a3f990)
- Resolved: grpc-swift-2 2.4.4, grpc-swift-nio-transport 2.11.0, swift-nio 2.104.0, swift-nio-http2 1.46.0
(the same trap was first seen in an app embedding Containerization, built with grpc-swift-2 2.4.2,
grpc-swift-nio-transport 2.9.0, swift-nio 2.101.3)
- Guest: Kata 3.17.0 arm64 kernel, vminit 0.51.0 initfs, debian:bookworm-slim arm64 rootfs
- Binary: debug build, ad-hoc signed with com.apple.security.virtualization onlyRelevant log output
$ vminitd-close-trap no-vm 1
[...] round 1: close() threw clientIsStopped: "Can't call 'runConnections()' as the client is stopped (or is stopping). This can happen if the you call 'runConnections()' after shutting the client down or if you used 'withGRPCClient' with an empty body."
NIOCore/NIOAsyncWriter.swift:177: Fatal error: Deinited NIOAsyncWriter without calling finish()
(exit status 133, SIGTRAP)
$ run-vm.sh … 1000 0 unshielded.log
[...] container started in 313 ms; shield=false rounds=1000
[...] uncancelled statistics() ok
[...] round 1 CancellationError
[...] round 2 CancellationError
...
[...] round 31 CancellationError
NIOCore/NIOAsyncWriter.swift:177: Fatal error: Deinited NIOAsyncWriter without calling finish()
exit=133
$ run-vm.sh … 1000 1 shielded.log
[...] SURVIVED rounds=1000 shield=true outcomes=["ok": 1000]Code of Conduct
- I agree to follow this project's Code of Conduct
Activity
dandmetcalf-stack commentedon Oct 16, 2026
Cross-reference: the transport-side part of this (root cause steps 4–5) is filed as grpc/grpc-swift-nio-transport#202.
amritkang165 commentedon Nov 6, 2026
Could I help narrow the close-before-
runConnections()case using the supplied no-VM reproducer? I noticed #995 changes socket ownership and vminitd handoffs, and the transport-side issue is tracked in grpc/grpc-swift-nio-transport#202. I’d first check whether the reproducer still fails with those relevant changes, then share the result before proposing a lifecycle test or fix. Is there a useful remaining slice here that would avoid duplicating the current work?