apple / apple/containerization

LinuxContainer.create() leaks the VZ VM/XPC process when vm.start() throws — no do/catch around it

Open Beginner friendly
#804 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.