apple / apple/containerization
LinuxContainer.create() leaks the VZ VM/XPC process when vm.start() throws — no do/catch around it
- Dominant language
- Swift
- Stars
- 8.9k
- Forks
- 359
- Avg merge
- 2d 20h
- Merged PRs (30d)
- 13
Description
### Summary
`LinuxContainer.create()` (`Sources/Containerization/LinuxContainer.swift`, pinned `0.35.0`) calls the raw `vm.start()` *outside* the `do/catch` block that otherwise cleans up on failure. If `vm.start()` itself throws — e.g. a `vmnet` allocation failure surfaced through the network device attachment — the underlying `VZVirtualMachine` (and its `com.apple.Virtualization.VirtualMachine.xpc` helper process) is never stopped. Since `vm` is a local variable inside `create()`, nothing outside the function ever holds a reference to it, so there is no way for a caller to stop or clean it up after the fact either.
```swift
public func create() async throws {
try await self.state.withLock { state in
...
let vm = try await self.vmm.create(config: creationConfig)
let relayManager = UnixSocketRelayManager(vm: vm, log: self.logger)
try await vm.start() // <-- NOT inside the do/catch below; a throw here leaks `vm`
do {
try await vm.withAgent { agent in ... }
state = .created(.init(vm: vm, relayManager: relayManager, ...))
} catch {
try? await relayManager.stopAll()
try? await vm.stop()
state.setErrored(error: error)
throw error
}
}
}
```
Because `state` is never transitioned away from `.initialized` when `vm.start()` throws here, a caller's subsequent `container.stop()` also can't help — `stop()` requires `.created`/`.started` state (via `state.createdState("stop")` / `state.startedState("stop")`), both of which throw against `.initialized`, so `stop()` is a no-op in this exact failure mode.
### Impact
In our case (Anglesite, a macOS app embedding this package), a transient `vmnet` NAT-subnet contention (a known single-consumer limitation — see apple/container's vmnet plugin) causes exactly this: `vm.start()` throws `vmnet_return_t(rawValue: 1002)`, and the resulting leaked VZ process permanently holds the vmnet lease. Every subsequent boot attempt — including retries and full app relaunches — fails identically, because the orphaned `com.apple.Virtualization.VirtualMachine.xpc` process survives even a full quit/relaunch of the embedding app (it isn't reaped as a child of our process). The only recovery is manually finding and killing that PID.
### Suggested fix
Wrap `vm.start()` itself in a `do/catch` that calls `try? await vm.stop()` (and `relayManager.stopAll()`) on failure, mirroring the existing catch that already covers the post-start setup steps — e.g.:
```swift
let vm = try await self.vmm.create(config: creationConfig)
let relayManager = UnixSocketRelayManager(vm: vm, log: self.logger)
do {
try await vm.start()
try await vm.withAgent { agent in ... }
state = .created(.init(vm: vm, relayManager: relayManager, ...))
} catch {
try? await relayManager.stopAll()
try? await vm.stop()
state.setErrored(error: error)
throw error
}
```
### Environment
- Package version: `0.35.0` (pinned via `.upToNextMinor(from: "0.35.0")`)
- macOS: Apple Silicon, Virtualization.framework via `VZVirtualMachineManager`
Happy to send a PR with the fix above if useful — wanted to check the intended failure-handling shape first, since I'm not certain whether `vm.stop()` is safe to call on a VM that never finished `start()`.
Contributor guide
Research direction
Start in Sources/Containerization/LinuxContainer.swift at LinuxContainer.create() and compare the vm.start() path with the existing post-start do/catch cleanup. Verify the failure path for a throwing vm.start(), including relay shutdown, VM stopping, and the transition to an errored state. Done means the thrown error is propagated without leaving the VZ helper process or vmnet lease behind.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- macos, swift
- Domain
- operating-systems
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100