Relationships
#1094 swamp serve leaks esbuild bundler subprocesses as zombies (reparented to PID 1, never reaped)
Opened by magistr · 7/11/2026
Summary
A long-running swamp serve process accumulates zombie (defunct) esbuild
processes that are never reaped. The bundler shells out to deno bundle
(esbuild-powered); each bundle leaves esbuild's service subprocess to be
orphaned and reparented to PID 1 — which, when PID 1 is swamp serve (the
documented container deployment), is never wait()ed on. They pile up for the
entire life of the process — roughly one per bundle — and only clear on restart.
Observed: 546 esbuild-linux-x <defunct> zombies on a swamp serve
instance up ~3d21h (~140/day). The oldest is as old as the serve process itself.
Confirmed still present in the latest build 20260711.014414.0-sha.c3fd7b11
(source audit below).
Environment
swamp serverunning20260707.035329.0-sha.84dbdd93(leak observed); root cause verified unchanged in20260711.014414.0-sha.c3fd7b11(latest).- Container
debian:stable-slim; serve is PID 1 inside the container (/proc/<pid>/status→NSpid: <hostpid> 1); no init wrapper. swamp serve --repo-dir /workspace --host [IP-2] --port 9090 --auth-mode token --admins user:magistr --ws-idle-timeout 5m --trust-proxy --log- Host: Unraid, kernel
pid_max= 32768.
Evidence
Host process census, grouped by leaking parent → container:
totalZombies: 549
byParent:
ppid <serve> parentComm "swamp" container swamp-serve count 546 { "esbuild-linux-x <defunct>": 546 }
ppid <traefik> parentComm "traefik" container traefik count 2 { "ssl_client <defunct>": 2 } # unrelated
ppid <atlas> parentComm "beam.smp" container atlas-app count 1 { "epmd <defunct>": 1 } # unrelated- All 546 esbuild zombies have
PPID == the serve process(PID 1 in the container). - There are zero
denozombies — swamp correctly reaps thedeno bundlechildren it spawns directly (await command.output()). Only the esbuild grandchild leaks. ps -o etimeon the oldest esbuild zombie ≈3-[IP-1]— defunct for the entire life of the serve process. A zombie persists only until its parentwait()s, so PID 1 (swamp serve) is provably never reaping these.
Mechanism
bundleExtension()(src/domain/models/bundle.ts:506-515) runsnew Deno.Command(denoPath, { args: ["bundle", ...] })andawait command.output(). It spawns and reaps thedeno bundlechild correctly.deno bundleis esbuild-powered and spawns anesbuild-linux-x64service subprocess (grandchild of swamp).- When
deno bundleexits, its esbuild service is orphaned and reparented to PID 1. In the container PID 1 isswamp serveitself. swamp serverunning as PID 1 installs noSIGCHLDreaper and is not a subreaper, so the reparented esbuild processes become permanent zombies.
Source audit of 20260711.014414.0-sha.c3fd7b11: no SIGCHLD / subreaper /
waitpid / reaping logic exists in src/serve/**, src/domain/models/bundle.ts,
or src/main.ts (the only "reap" references are run-tracker stale-run cleanup,
unrelated to OS processes). So nothing collects reparented children.
Impact
- Slow PID leak: ~140 zombies/day against a host-wide
pid_maxof 32768. Each holds a PID slot + task_struct until reaped. On a busy serve or a lowerpid_max, PID exhaustion is reached correspondingly sooner. - Only remediation today is restarting the serve process/container (resets to zero, confirming the accumulation is per-process, not persistent state).
Reproduction
- Run
swamp serveon a repo with user extension models that require bundling. - Drive bundle operations over time (scheduled workflows / model-method runs
that touch extensions — each triggers a
deno bundle). - On the host:
ps -eo pid,ppid,stat,comm | grep esbuild→ a monotonically growing set ofZ(defunct)esbuild-linux-xwhose PPID is the serve PID, none of which ever disappear.
Suggested fixes
- Serve-side reaper (preferred): when
swamp serveis PID 1, install aSIGCHLDhandler thatwait()s reparented children (or run the Deno process as a subreaper), so orphaned esbuild services are collected. - Bundler-side: ensure the esbuild service is torn down before/with the
deno bundlechild so it is never orphaned (may be an upstreamdeno bundleissue — it shouldn't leave its esbuild service running past exit). - Deployment mitigation to document: run the serve container under an init
(
docker run --init, composeinit: true, ortini). Because the zombies are reparented orphans (their real parentdeno bundlehas already exited), a PID-1 init reaper collects them — so this is a real workaround, and the docs/Dockerfile guidance forswamp serveshould recommend it.
How this was diagnosed / how to verify a fix
Reproducible via a zombies method added to a @docker/host swamp model: it
SSHes to the docker host, runs one ps census, groups defunct processes by
leaking parent PID, and attributes each parent to its container via
/proc/<ppid>/cgroup. After a fix (or with init: true), re-running it should
report byComm["esbuild-linux-x <defunct>"] == 0 for container swamp-serve.
Closed
No activity in this phase yet.
stack72 commented 7/11/2026, 10:15:24 PM
@magistr Thanks for the incredibly detailed write-up and diagnosis — this is thorough work.
We've confirmed this is a container deployment configuration issue, not a swamp code bug. As you noted in your own analysis, swamp correctly reaps its direct children (deno bundle) — the zombie accumulation only happens because esbuild grandchildren get reparented to PID 1, and swamp isn't designed to be an init process.
We verified this by running swamp serve outside a container and triggering multiple bundle operations — zero esbuild zombies. The system init (launchd/systemd) reaps the orphans as expected.
The fix is on the deployment side, which you already identified in your suggested fix #3:
- Docker Compose: add
init: trueto your service - Docker CLI: use
docker run --init
Both inject tini as PID 1, which will reap the orphaned esbuild processes automatically. Since you're using your own base image (debian:stable-slim), not our Dockerfile, this is the right place to address it.
Sign in to post a ripple.