overengineeringstudio / overengineeringstudio/effect-utils
@myobie/pty: spawnDaemon leaves zombie daemon when spawner exits without close()
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 82
- Forks
- 2
- Avg merge
- 1d 8h
- Merged PRs (30d)
- 121
Description
Summary
@myobie/pty's spawnDaemon() produces a detached PtyServer process that has no mechanism to self-terminate when its spawner exits or crashes. The daemon is reparented to init and survives indefinitely.
We have ~12 such zombie daemons currently consuming ~80G of pinned node_modules across stale branch worktrees (multi-day-old, one is 27 days old). Reproducible standalone in <1 minute.
Minimal repro
mkdir pty-zombie-repro && cd pty-zombie-repro
cat > package.json <<'JSON'
{"name":"pty-zombie-repro","type":"module","dependencies":{"@myobie/pty":"0.9.0"}}
JSON
bun install
bun pm trust @myobie/pty # postinstall
cat > spawner.mjs <<'EOF2'
import { spawnDaemon } from '@myobie/pty/client'
import * as fs from 'node:fs'
process.env.PTY_SESSION_DIR = '/tmp/pty-zombie-sessions'
fs.mkdirSync(process.env.PTY_SESSION_DIR, { recursive: true })
await spawnDaemon({
name: 'zombie-A',
command: '/bin/bash',
args: ['-i'],
cwd: process.cwd(),
})
process.exit(0) // spawner exits cleanly — no close() called
EOF2
node spawner.mjs
pid=$(cat /tmp/pty-zombie-sessions/zombie-A.pid)
ps -p $pid -o pid,etime,cmd --no-headers # daemon still alive
Output:
{"name":"zombie-A","sessionDir":"/tmp/pty-zombie-sessions"}
<DAEMON_PID> 00:13 /…/node /…/node_modules/@myobie/pty/dist/server.js
Scenarios tested
| Scenario | Spawner | Daemon outcome |
|---|---|---|
| A | process.exit(0) (no close) |
survives — leak |
| B | SIGKILL on spawner mid-flight | survives — leak |
| C | manual SIGHUP to daemon | dies (default Node SIGHUP), but kernel never sends it |
| D | manual SIGTERM to daemon | dies cleanly (handler exists) |
ps confirms full detachment after spawn:
- daemon
PPID=1(reparented to init) - daemon
PGID=<self>,SID=<self>(own session viadetached: true+ implicitsetsid())
Root cause
@myobie/pty/dist/spawn.js:
const child = spawn(launcherCmd, [...launcherArgs, serverModule], {
detached: true, // own session
stdio: ["ignore", "ignore", "pipe"],
env: { ...process.env, PTY_SERVER_CONFIG: config },
});
child.unref(); // node event loop ignores it
@myobie/pty/dist/server.js only handles two signals:
process.on("SIGTERM", () => cleanShutdown(0));
process.on("SIGINT", () => cleanShutdown(0));
No SIGHUP handler, no parent-PID polling, no idle timeout, no prctl(PR_SET_PDEATHSIG). Combined with the new session via detached: true, the kernel sends nothing when the spawner dies.
Design intent vs reality
@myobie/pty/client docstring (in @overeng/pty-effect):
"this subpath wraps the detached daemon client API for long-lived sessions that survive process restarts."
Detachment is intentional — daemon should outlive any single client. The bug is that nothing reclaims the daemon when no client ever comes back.
@overeng/pty-effect's client.ts release path calls conn.disconnect() only (closes the client socket; does not kill the daemon). That's consistent with the design — but means upstream is the right layer to fix self-cleanup.
Fix options (least-disruptive first)
- Idle timeout in
server.js— shut down after N minutes with no client attached. One config field, no API change. Best match for the design intent (survives client restarts, not abandonment). - Optional
PTY_SPAWNER_PIDenv var — daemon pollskill -0 <pid>every N seconds, shuts down when spawner gone. Caller opts in. Requires caller cooperation but gives tight binding. prctl(PR_SET_PDEATHSIG, SIGTERM)(Linux only) — kernel sends SIGTERM when original parent dies. Catch: afterdetached: true, the parent may already be init by the time the daemon installs this; order-sensitive.- Wrapper-side bookkeeping in
@overeng/pty-effect— track spawned daemons, kill on exit. Doesn't fix the underlying library; same leak would recur elsewhere.
Recommend (1) + (2) combined upstream: idle timeout always on (default e.g. 60 min), spawner-PID watcher as opt-in.
Mitigation today
pkill -f '@myobie/pty/dist/server.js' is safe to run against confirmed-abandoned daemons (their parent is pid 1, no clients ever return). Useful for one-off cleanups.
Open questions
- Where should the fix land?
@myobie/ptyis upstream; do we have a path to patch there, or do we wrap in@overeng/pty-effect? - Idle-timeout default — 60 min reasonable? Should it be off-by-default for known long-lived servers?
PR_SET_PDEATHSIGon Linux gives us a clean signal — worth a Linux-only optimization if upstream is willing?
Posted on behalf of @schickling
| field | value |
|---|---|
agent_name |
⛰️ cl2-cliff |
agent_session_id |
85dbed96-97d4-412b-b261-38c05a50f8ca |
agent_tool |
Claude Code |
agent_tool_version |
2.1.139 |
agent_runtime |
Claude Code 2.1.139 |
agent_model |
claude-opus-4-7 |
worktree |
pty-zombie-repro |
machine |
dev3 |
tooling_profile |
dotfiles@4e6515b |
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
The issue points to @myobie/pty/dist/spawn.js and server.js; read spawnDaemon's detached child setup and the server's signal handling first. Re-run the standalone reproduction and the listed process scenarios to validate the chosen cleanup behavior, including that abandoned daemons no longer remain while intended detached sessions still work.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100