The "agent started but produced no output within 60s / stdio transport
stalled" lockup was never in the container stack — it was the approval
server's unix-socket transport. UnixSocketByteConn.close() did
shutdown(2)+close(2), freeing the fd NUMBER while the serve loop could
still dispatch its next blocking read(2) on the stored raw int. The race
arms on every SSE-streamed tools/call (respondStreamingToolCall closes
the connection mid-serve-loop; instant auto-approvals make the window
widest). The straggler read then landed on a RECYCLED descriptor —
another session's MCP socket, a container's stdout vsock channel —
parking a global-queue thread forever on, and stealing bytes from, an
unrelated stream. Zombies accumulated until every session stalled at
once (the 2026-07-14 00:02 wedge: 26 threads parked in receive() with
only 2 live unix connections). Yesterday's container-side fixes were
correct but orthogonal, which is why build 898 still stalled.
Fix: close() now only shuts the socket down (wakes a parked read with
EOF, fails later writes with EPIPE); the descriptor is close(2)d exactly
once, in deinit, and every offloaded read/write block strongly captures
self so the fd number cannot be recycled while any block that could
still pass it to the kernel is in flight. receive() is additionally
cancellation-aware, so stop()'s task cancellation unwedges parked reads
instead of stranding them. Regression test drives ten SSE-close /
fresh-connect cycles over a real AF_UNIX socket.
Also found while validating the earlier patch: ensureInitfs trusted a
bare cached vminit.ext4 forever, so repointing vminitReference at the
custom -nucleic1 image never took effect (containers still boot the
Jun-21 upstream initfs — guest patch #8 never actually deployed). The
cache is now keyed on the reference via a sidecar file, the pull
authenticates against GHCR with the app's GitHub token (the package is
private; anonymous pulls 403), and a failed re-pull falls back to the
existing cache so an offline launch still boots.
Co-Authored-By: Claude Fable 5 <[email protected]>
Overview doc so other agents/humans understand the shared-control-container
isolation work: the failure vectors + fixes (stdio wedge, connection leak,
control-plane HOL, OOM cross-kill, CPU/fork-bomb, per-session memory.max), the
host-vs-guest shipping surfaces, the per-exec cgroup layout + graceful fallback,
and the local vminit-image build/validate workflow + -nucleicN tag invariant.
Cross-linked from the vendored PATCHES.md.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds an opt-in hard per-session memory ceiling on top of patch #9's scoped-OOM.
The exec already ships the full OCI Spec, so the limit rides
spec.linux.resources.memory.limit — no RPC/protobuf change:
- host framework: LinuxProcessConfiguration.memoryLimitInBytes; LinuxContainer.exec
stamps it onto the exec spec.
- guest: Server+GRPC.createProcess reads it back and applies it as the exec
cgroup's memory.max (new Cgroup2Manager.setMemoryMax) via createExec/ManagedProcess.
- Nucleic: ContainerServiceSettings.controlPerSessionMemoryGiB (default 0 = off),
applied only to the shared control container (ContainerManager.exec); wired
through ContainerEngine.exec.
So one session can't consume the whole shared container's memory before its own
(oom.group-scoped) OOM. Default off preserves #9's behavior. Compile-verified host
+ musl guest; rides the pending -nucleic2 image, still runtime-pending.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Restructures the guest cgroup layout so each exec gets its OWN child cgroup
(/container/<id>/<execID>) with memory.oom.group=1, a fair cpu.weight, and a
pids.max backstop — so one control session can't OOM-kill, starve, or fork-bomb
its siblings in the shared container. The container init moves to its own leaf
so the container cgroup can delegate controllers to children (cgroup v2
no-internal-process rule). New Cgroup2Manager helpers: setOomGroup/setCpuWeight/
setPidsMax/remove.
Best-effort with graceful fallback: any failure in the per-exec setup wipes the
partial state and reverts to today's flat layout, and each exec falls back to the
container cgroup — a cgroup hiccup degrades to current behavior, never a failed
start.
COMPILE-VERIFIED via the musl cross-build; NOT yet runtime-validated. Built as
image tag -nucleic2; vminitReference stays on the validated -nucleic1 until
-nucleic2 is checked in a real container. A hard host-configured per-exec
memory.max (exec-RPC resources field) remains a follow-up.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
cctl is an uncommitted build artifact; the image lives in a user-global content
store. Make login/push depend on a real $(CTZN_DIR)/bin/cctl file target that
builds it once if missing, so they work from a fresh checkout after the session
worktree that ran 'make vminit-image' is gone.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
cctl login stores the credential in the macOS Keychain, which cctl images push
reads automatically — so pushing needs no REGISTRY_* env vars after a one-time
login. Env vars remain as a fallback.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The swiftly toolchain used by the vminit-image CI resolves Foundation/
Virtualization but not the 'os' overlay, so 'import os' failed with
"no such module 'os'". Guarding the diagnostic logger degrades it to a
no-op under such toolchains while local (Xcode) builds keep it.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The macos-15 runner's SDK lacks vmnet_network_ref / VZVmnetNetworkDeviceAttachment
(macOS 26 APIs used by Containerization's VmnetNetwork/NATNetworkInterface), so
`make containerization` failed to build cctl before reaching the init packaging.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Host-side (ships with a normal swift build):
- LinuxProcess: non-blocking stdio relay (O_NONBLOCK + nucleicDrainNonBlocking)
so a wedged stream can't head-of-line-block sibling execs' relays; atomic
stdio-or-abort start (patches #5, #6).
- Vminitd: bounded deleteProcess timeout so teardown can't hang a wedged
channel (patch #7).
- ContainerizedProcessHandle: call LinuxProcess.delete() after exit and on
force-close — fixes a per-turn leak (per-exec vsock/gRPC connection +
runConnections() task) in the long-lived shared control container. Likely
the "degrades until app restart" root cause.
- ClaudeCodeBackend: map the atomic-start abort to a recoverable AgentError so
a failed launch settles as retryable instead of locking the composer.
Guest-side (rides the custom vminitd initfs; inert until the image is built):
- ManagedProcess: offload the blocking start off the gRPC event loop (patch #8).
- Per-exec cgroups (patch #9) recorded as design only — cross-cutting.
Pipeline:
- .github/workflows/vminit-image.yml builds vminitd from the vendored source
and pushes ghcr.io/abkslm/vminit; ContainerEngine.vminitReference repointed
at the custom image.
Co-Authored-By: Claude Opus 4.8 <[email protected]>