overengineeringstudio / overengineeringstudio/effect-utils

@myobie/pty: spawnDaemon leaves zombie daemon when spawner exits without close()

Open
#677 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

area:pty-effect origin:agent
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 via detached: true + implicit setsid())

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)

  1. 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).
  2. Optional PTY_SPAWNER_PID env var — daemon polls kill -0 <pid> every N seconds, shuts down when spawner gone. Caller opts in. Requires caller cooperation but gives tight binding.
  3. prctl(PR_SET_PDEATHSIG, SIGTERM) (Linux only) — kernel sends SIGTERM when original parent dies. Catch: after detached: true, the parent may already be init by the time the daemon installs this; order-sensitive.
  4. 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/pty is 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_PDEATHSIG on 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.