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 maintaineryihuaf.
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:
__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 concurrentexec 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 concurrentbuild()calls all complete with no deadlock; see the test module at the end ofsrc/guest/src/container/zygote.rs). Prefermake test; do not callcargodirectly.
- This page is for contributors and kernel/runtime debuggers; it is not an SDK usage tutorial. Before reading, you should understand: process
fork/clone3semantics, Unix-domainSOCK_SEQPACKETsocketpairs, futexes, and the difference between musl and glibc infork()safety. - Reading/modifying the guest and zygote source and running the Rust tests requires the BoxLite source repository and
make/cargo test(prefer themaketargets; do not callcargodirectly).
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 thesrc/guest/...prefix.libcontainer/...refers to the vendored youki crate (a dependency, not under the BoxLite repository’ssrc/).
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.
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):build()creates a pipe (O_CLOEXEC), thencreate()→container_main_process().container_main_process()creates 3 channel pairs withsocketpair(AF_UNIX, SOCK_SEQPACKET, SOCK_CLOEXEC), then callsclone3().- After the fork, the parent closes
main_senderandinter_sender, then blocks onmain_receiver.wait_for_intermediate_ready()(a singlerecvmsg). - The channel
Sender/Receiverwrap raw fds and have no Drop implementation — they must be closed explicitly or they leak. clone3()is called withoutCLONE_FILES— the child gets a copy of the fd table.- The intermediate process forks the init process using
clone3_sibling(withCLONE_PARENTand no exit signal).
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 tosrc/guest/src/container/command.rs.
Add a watchdog thread that fires if build() has not completed within 3 seconds:
/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.
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/.
main_sender/inter_sender.
Step 6: Inspect socket state via /proc/net/unix
Tool: Filter/proc/net/unix by socket inode.
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: Callpoll() and ioctl(FIONREAD) on the blocked fd.
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.
Step 9: Scan every process’s fd table to find the socket holder
Tool: readlink over/proc/<pid>/fd/ across all PIDs.
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}.
- 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 (address0x16f6e68). - syscall arguments:
futex(0x16f6e68, FUTEX_WAIT_PRIVATE, 0x80000002)FUTEX_WAIT_PRIVATE= an in-process futex (not cross-process).0x80000002= locked + contended (NPTL mutex encoding).
Step 11: Resolve the futex address to a symbol
Tool: Runllvm-nm on the statically linked guest binary.
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:
__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 thesrc/guest/...prefix;libcontainer/...is the vendored youki crate (a dependency, not under this repository’ssrc/).
The fix is implemented insrc/guest/src/container/zygote.rs(Option B: a single-threaded zygote, pre-forked, serializing allbuild()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.

