Summary
Under a rapid same-rootfs reload loop, the previous worker generation's mount teardown races the new prototype's proc mount on the same host path <rootfs>/proc, with no serialization. The loser hits mount(...proc...) ENOENT, the prototype exits 1, a respawn storm follows, the generation never converges, and dangling app↔router fds trip the fd-leak teardown check.
This surfaces as a flaky CI failure in test_go_isolation_rootfs_automount_tmpfs / ..._regression — see PR #78, run 27793786107 (test (go-1.25) red, test (go-1.26) green on the identical daemon and ns_inspect app). Original flaky context: #60.
Credit: root cause first pinned by @andypost in #78 (review).
Symptom (failing run, go-1.25)
[alert] mount("none", "/tmp/unit-test-…/proc", "proc", 2097162, "") (2: No such file or directory)
[notice] process … exited with code 1
FAILED test_go_isolation_rootfs_automount_tmpfs - mounts did not converge after reload (status=None)
ERROR ... - AssertionError: descriptors leak router (assert 53 <= 0)
The race
Each reload swaps the worker generation; two operations touch <rootfs>/proc concurrently:
- new prototype (child):
mkdir + mount("none", <rootfs>/proc, "proc", …) — nxt_isolation_prepare_rootfs() (src/nxt_application.c:562 → src/nxt_isolation.c:778)
- old-gen teardown (main process, host mount ns):
umount2(<rootfs>/proc, MNT_DETACH) — nxt_isolation_unmount_all() (src/nxt_isolation.c:744, registered as isolation.cleanup at :602)
The lazy detach lands on the path the new generation is mid-mounting → ENOENT → prototype exit 1 → respawn storm → status=None + leaked port fds. The Go toolchain version only shifts when the new mount() fires relative to the old umount2(); 1.25 hits the window, 1.26 misses it. This is timing, not a Go-version code path.
Why the existing mitigation is insufficient
The private-mount-namespace mitigation is already in place (cceba1e9 private mount namespace, 2063c666 depin reload race; tests load with namespaces: {mount: True} and skip_alert the ENOENT; proc-before-tmpfs ordering in nxt_isolation_mount_compare, src/nxt_isolation.c:716). It is necessary but not sufficient, because of the ordering in the child setup:
nxt_isolation_prepare_rootfs mounts <rootfs>/proc — src/nxt_application.c:562
- only afterwards
nxt_isolation_change_root → nxt_isolation_pivot_root severs propagation with mount("", "/", MS_SLAVE|MS_REC, "") — src/nxt_isolation.c:878
unshare(CLONE_NEWNS) alone does not detach mount propagation. On a systemd host, / (and /tmp, where temp_dir lives) is MS_SHARED by default, so in the window between unshare and pivot_root the new generation's proc mount is still propagation-linked to the host — exactly while the host-side teardown umount2s the same path. MS_SLAVE arrives too late to close the window.
Proposed fixes (pick one / combine)
- Sever propagation before mounting: make the rootfs/
/ MS_REC|MS_PRIVATE (or MS_SLAVE) before nxt_isolation_prepare_rootfs, not inside pivot_root after it.
- Don't host-umount per-worker automounts: when the worker has a private mount ns, let its automounts die with the namespace instead of
umount2-ing them from the host ns in nxt_isolation_unmount_all.
- Serialize reload teardown: ensure the old generation's mount teardown completes before the new prototype starts mounting the same rootfs.
Test-side follow-up (PR #78)
- Until the C fix lands, mark
test_go_isolation_rootfs_automount_tmpfs[_regression] xfail/skip on go-1.25, referencing this issue, to unblock the CI-workflow refactor (do not drop go-1.25 from the matrix — it is a supported runtime per pkg/eol.json).
- The
_regression variant (×100 reload loop) is a CI time-bomb regardless of toolchain; consider gating it behind an opt-in marker.
Environment
- ubuntu-latest GitHub runner, root (
is_su=True), rootfs isolation + namespaces: {mount: True} + tmpfs automount toggled per reload.
- Reproduces deterministically on the go-1.25 toolchain; not observed on go-1.26.
Summary
Under a rapid same-rootfs reload loop, the previous worker generation's mount teardown races the new prototype's
procmount on the same host path<rootfs>/proc, with no serialization. The loser hitsmount(...proc...) ENOENT, the prototype exits 1, a respawn storm follows, the generation never converges, and dangling app↔router fds trip the fd-leak teardown check.This surfaces as a flaky CI failure in
test_go_isolation_rootfs_automount_tmpfs/..._regression— see PR #78, run27793786107(test (go-1.25)red,test (go-1.26)green on the identical daemon andns_inspectapp). Original flaky context: #60.Credit: root cause first pinned by @andypost in #78 (review).
Symptom (failing run, go-1.25)
The race
Each reload swaps the worker generation; two operations touch
<rootfs>/procconcurrently:mkdir+mount("none", <rootfs>/proc, "proc", …)—nxt_isolation_prepare_rootfs()(src/nxt_application.c:562→src/nxt_isolation.c:778)umount2(<rootfs>/proc, MNT_DETACH)—nxt_isolation_unmount_all()(src/nxt_isolation.c:744, registered asisolation.cleanupat:602)The lazy detach lands on the path the new generation is mid-mounting → ENOENT → prototype exit 1 → respawn storm →
status=None+ leaked port fds. The Go toolchain version only shifts when the newmount()fires relative to the oldumount2(); 1.25 hits the window, 1.26 misses it. This is timing, not a Go-version code path.Why the existing mitigation is insufficient
The private-mount-namespace mitigation is already in place (
cceba1e9private mount namespace,2063c666depin reload race; tests load withnamespaces: {mount: True}andskip_alertthe ENOENT; proc-before-tmpfs ordering innxt_isolation_mount_compare,src/nxt_isolation.c:716). It is necessary but not sufficient, because of the ordering in the child setup:nxt_isolation_prepare_rootfsmounts<rootfs>/proc—src/nxt_application.c:562nxt_isolation_change_root→nxt_isolation_pivot_rootsevers propagation withmount("", "/", MS_SLAVE|MS_REC, "")—src/nxt_isolation.c:878unshare(CLONE_NEWNS)alone does not detach mount propagation. On a systemd host,/(and/tmp, wheretemp_dirlives) isMS_SHAREDby default, so in the window betweenunshareandpivot_rootthe new generation's proc mount is still propagation-linked to the host — exactly while the host-side teardownumount2s the same path.MS_SLAVEarrives too late to close the window.Proposed fixes (pick one / combine)
/MS_REC|MS_PRIVATE(orMS_SLAVE) beforenxt_isolation_prepare_rootfs, not insidepivot_rootafter it.umount2-ing them from the host ns innxt_isolation_unmount_all.Test-side follow-up (PR #78)
test_go_isolation_rootfs_automount_tmpfs[_regression]xfail/skip on go-1.25, referencing this issue, to unblock the CI-workflow refactor (do not drop go-1.25 from the matrix — it is a supported runtime perpkg/eol.json)._regressionvariant (×100 reload loop) is a CI time-bomb regardless of toolchain; consider gating it behind an opt-in marker.Environment
is_su=True), rootfs isolation +namespaces: {mount: True}+ tmpfs automount toggled per reload.