Skip to main content
The hang is fixed: the guest now forks a single-threaded zygote before tokio starts any threads, and that zygote handles every clone3() (src/guest/src/container/zygote.rs). If you hit a concurrent exec hang today, do not assume it is this bug — re-diagnose from the Troubleshooting section below.

Conclusion

Root cause: musl’s __malloc_lock deadlocks after clone3() in a multi-threaded process

The guest binary (boxlite-guest) is statically linked against musl libc (aarch64-unknown-linux-musl) and runs on a multi-threaded tokio runtime. When libcontainer calls clone3() to fork the intermediate process, the child inherits a locked __malloc_lock (musl’s global allocator mutex, symbol address 0x16f6e68 in BSS) — the lock was held by another tokio thread that happened to be doing a heap allocation at the moment of the fork. Because musl does not install a pthread_atfork handler to reset __malloc_lock in the child, the intermediate process deadlocks on its first memory allocation — before it can send any channel message or close the inherited file descriptors. The parent then blocks forever on recvmsg().

Deadlock Call Graph

Why it is intermittent (~30-50%)

The deadlock occurs only when another thread happens to hold __malloc_lock at the exact instant clone3() runs. When several tokio workers allocate memory frequently, this overlap is likely but not certain:

Why it is musl-specific

Fix Options

Adopted and implemented: Option B (the zygote pre-fork model). The implementation is in src/guest/src/container/zygote.rs: the zygote is forked before tokio starts any threads, stays single-threaded for its entire lifetime, and handles all build() calls over IPC (a SEQPACKET socket, serialized with a mutex) — so clone3() always runs in a single-threaded context where __malloc_lock cannot be held by another thread. This is the same pattern used by runwasi’s Zygote (PR #775) and runc’s nsexec. The investigation report originally recommended “A (short term) + B (long term)”; the more thorough Option B was adopted in the end.

Upstream status (youki)

This is a known issue in the youki project, tracked at containers/youki#2144 (“cargo test with musl hangs occasionally”), raised in July 2023 by maintainer yihuaf. The issue was never truly fixed in any release (including v0.6.0). Upstream only worked around it: The note on PR #2685: “The root cause is that the tests run in a multi-threaded environment, which should not happen in real use.” Why upstream does not treat it as a problem, but BoxLite is affected:
Upstream assumes youki runs as a standalone single-threaded CLI, where no other thread holds __malloc_lock at fork time. BoxLite embeds libcontainer in a multi-threaded tokio runtime — exactly the scenario upstream considers “should not happen in real use”. Upgrading libcontainer does not fix the problem: the v0.6.0 release (2025-02) made no changes to clone3, fork, or multi-threaded behavior. The fix had to be implemented by BoxLite itself (the zygote above).

Reproducing the shape of the bug

The following is a regression-verification script: it issues several concurrent exec calls against the same box and waits for all of them to return. On the current, fixed version it should pass reliably — this is the same class of workload that previously had a ~30-50% chance of hanging. Use it as a smoke test for whether this bug has regressed.
To run a test closer to the original reproduction inside the source repository, use the guest-side zygote concurrency test (it asserts that 4 concurrent build() calls all complete with no deadlock; see the test module at the end of src/guest/src/container/zygote.rs). Prefer make test; do not call cargo directly.
  • This page is for contributors and kernel/runtime debuggers; it is not an SDK usage tutorial. Before reading, you should understand: process fork/clone3 semantics, Unix-domain SOCK_SEQPACKET socketpairs, futexes, and the difference between musl and glibc in fork() safety.
  • Reading/modifying the guest and zygote source and running the Rust tests requires the BoxLite source repository and make / cargo test (prefer the make targets; do not call cargo directly).

Debug Process (methodology reference)

The procedure below is the reusable core of this page. Source paths have been updated to the real repository layout with the src/guest/... prefix. libcontainer/... refers to the vendored youki crate (a dependency, not under the BoxLite repository’s src/).

Step 1: Reproduce the hang

Tool: cargo test --nocapture + a loop runner. Run test_concurrent_exec_high_concurrency in a loop: it issues 8 concurrent exec("echo hello_N") calls against the same VM and waits for all of them to complete.
Observation: A failure rate of roughly ~30-50%. When it hangs, one exec never returns and the test times out at 120s. The hang always occurs inside TenantContainerBuilder::build() within the guest VM. Conclusion: A reproducible race. The container mutex guarantees that only one build() runs at a time, yet it still hangs intermittently.

Step 2: Locate where build() blocks

Tool: Reading the libcontainer (youki v0.5.7) source. Trace the call path (the first two lines are BoxLite guest source; the rest is the vendored youki crate):
Key findings:
  • build() creates a pipe (O_CLOEXEC), then create()container_main_process().
  • container_main_process() creates 3 channel pairs with socketpair(AF_UNIX, SOCK_SEQPACKET, SOCK_CLOEXEC), then calls clone3().
  • After the fork, the parent closes main_sender and inter_sender, then blocks on main_receiver.wait_for_intermediate_ready() (a single recvmsg).
  • The channel Sender/Receiver wrap raw fds and have no Drop implementation — they must be closed explicitly or they leak.
  • clone3() is called without CLONE_FILES — the child gets a copy of the fd table.
  • The intermediate process forks the init process using clone3_sibling (with CLONE_PARENT and no exit signal).
Conclusion: The block is in recvmsg on the main_receiver SEQPACKET socket, waiting for the intermediate process to send intermediate_ready.

Step 3: Add a watchdog diagnostic thread

Tool: Custom diagnostic code added to src/guest/src/container/command.rs. Add a watchdog thread that fires if build() has not completed within 3 seconds:
Initial diagnosis: Scan /proc for youki / child processes. Finding: No child process named after youki exists. The parent thread’s wchan = __skb_wait_for_more_packets (a SEQPACKET recv wait). Conclusion: Confirms the hang is in the parent’s channel recv, and that no clearly named child process is alive.

Step 4: Read the parent thread’s syscall info

Tool: /proc/self/task/<tid>/wchan and /proc/self/task/<tid>/syscall.
Result (consistent across all hangs):
Conclusion: The parent blocks on recvmsg(fd=24). The fd number can differ across runs (24 or 23, because of gaps in fd allocation) but is always the main_receiver SEQPACKET.

Step 5: Dump all open fds

Tool: readlink over /proc/self/fd/.
Result (typical hang):
Conclusion: 4 SEQPACKETs are alive (6 created − 2 senders closed = 4), matching the expected state after the parent closes main_sender/inter_sender.

Step 6: Inspect socket state via /proc/net/unix

Tool: Filter /proc/net/unix by socket inode.
Result:
Key observation: Every SEQPACKET has RefCount=3. For a connected Unix socketpair, RefCount=3 = 1 (self) + 1 (file/fd) + 1 (peer reference), which means the peer socket is still alive — some process still holds the sender-side fd. Conclusion: Even though the parent closed its own copies, the sender-side peers are still alive. Another process holds copies of main_sender/inter_sender.

Step 7: Verify the peer is alive with poll() and FIONREAD

Tool: Call poll() and ioctl(FIONREAD) on the blocked fd.
Result (all hangs):
Conclusion: The kernel explicitly confirms the peer socket (main_sender) is still alive and held by some process. If all copies had been closed, POLLHUP would appear.

Step 8: Extend /proc/net/unix to all SEQPACKETs

Tool: Relax the /proc/net/unix filter to include all Type 0005 entries.
Result: Now 6 SEQPACKETs appear (previously only 4):
Conclusion: The peer sockets (inodes 1244, 1246) are alive in the system but not in this process’s fd table. Another process holds them.

Step 9: Scan every process’s fd table to find the socket holder

Tool: readlink over /proc/<pid>/fd/ across all PIDs.
Result (the breakthrough):
Conclusion: PID 248 holds copies of the sender-side sockets (1244, 1246) that the parent already closed. These are fd copies inherited from clone3(). PID 248 is the intermediate child process that never closed them — because it never ran its initial setup.

Step 10: Identify the blocked child process

Tool: /proc/<pid>/{comm,stat,wchan,syscall,stack}.
Result:
Key findings:
  • PID 248 is a child of the guest agent (ppid=211).
  • comm=tokio-runtime-w — inherited from the thread name of the tokio worker that performed the fork.
  • Blocked in futex_wait_queue — waiting on a userspace futex (address 0x16f6e68).
  • syscall arguments: futex(0x16f6e68, FUTEX_WAIT_PRIVATE, 0x80000002)
    • FUTEX_WAIT_PRIVATE = an in-process futex (not cross-process).
    • 0x80000002 = locked + contended (NPTL mutex encoding).
Conclusion: The intermediate child process is deadlocked on a userspace mutex. It inherited a locked mutex from the parent’s multi-threaded address space, but the lock-holding thread does not exist in the child. This is the classic “fork in a multi-threaded process” problem.

Step 11: Resolve the futex address to a symbol

Tool: Run llvm-nm on the statically linked guest binary.
Conclusion: The futex at 0x16f6e68 is __malloc_lock — musl libc’s global heap allocator mutex. The intermediate process deadlocks on its first malloc(), because __malloc_lock was held by another tokio thread at fork time and musl has no pthread_atfork handler to reset it in the child.

Step 12: Confirm the mechanism

The mutex is not shared across processes. clone3() copies the entire address space into the child; the child gets a snapshot of the mutex in its locked state, but the lock-holding thread (Thread B) does not exist in the child:
Why __malloc_lock is the worst inherited lock: it is unavoidable. The intermediate process can hardly run any meaningful code without allocating memory — Vec::new(), String::from(), format!(), and PathBuf operations all call malloc() and all try to acquire __malloc_lock.

Environment Details (at investigation time)

libcontainer channel architecture

Test Results Summary (reproduction data, pre-fix)

Diagnostic Techniques Reference (reusable techniques)

Files Read During Investigation

BoxLite guest source uses the src/guest/... prefix; libcontainer/... is the vendored youki crate (a dependency, not under this repository’s src/).
The fix is implemented in src/guest/src/container/zygote.rs (Option B: a single-threaded zygote, pre-forked, serializing all build() calls over IPC).

Troubleshooting

The deadlock described in this investigation has been fixed. If you still hit a “concurrent exec hangs / never returns” symptom, re-diagnose using the table below — it is very likely not the musl bug described here.
Environment constraint: BoxLite requires hardware virtualization. Linux + KVM; macOS uses Apple Hypervisor.framework (no /dev/kvm needed on macOS arm64); macOS Intel is not supported.