Skip to content

fix(cli): exit watch and files once their shutdown flush returns - #209

Merged
baairon merged 1 commit into
mainfrom
claude/happy-planck-w8fw5n-207
Nov 4, 2026
Merged

baairon merged 1 commit into
mainfrom
claude/happy-planck-w8fw5n-207

Conversation

@baairon

@baairon baairon commented Nov 4, 2026

Copy link
Copy Markdown
Owner

What and why

Fixes #207. Follows #208 (rebased onto it after the squash merge).

A headless torlnk watch under a supervisor flushes queue.json / seeds.json / history.json the instant SIGTERM arrives, then stays alive for hours until kill -9. The reporter read the main point correctly: src/index.tsx exits seed and serve explicitly once their shutdown promise settles (#183 and 918d3f6), but the watch and files branches only had the error handler. They relied on the event loop draining on its own, which means waiting on webtorrent's async teardown.

One peer connection webtorrent has lost track of is enough to keep that teardown from ever finishing. That is the #205 orphan (_drain dialing a peer that removePeer already destroyed): on webtorrent 2.8.5 it is a ref'd UDP socket of its own, on 3.x it keeps the shared uTP server socket from closing. Either way the loop never empties, so the webtorrent 3 upgrade in #208 does not fix this on its own. The same hang makes torlnk update report a daemon as "still shutting down; skipped" and never relaunch it.

Reproduced by injecting exactly one such orphan into the real torlnk process (a --import preload that queues a peer, destroys it while queued, and lets _drain dial it against a local uTP peer that accepts and never speaks), then sending SIGTERM:

mode one orphan, webtorrent 2.8.5 one orphan, webtorrent 3.0.21 after this change (3.0.21)
watch alive after 20 s, needed SIGKILL alive after 20 s, needed SIGKILL exits in 255 ms, status 0
serve exits in 255 ms exits in 255 ms unchanged
seed exits in 255 ms exits in 254 ms unchanged

Without the orphan every mode exits, which is why this only shows on real swarms. With an empty queue no webtorrent client even exists (TorrentEngine.ensureClient is lazy).

Two corrections to the issue text: the deferred client.destroy() in engine.destroy() does run when the process stays alive, it just cannot complete, so it is not the cause. And serve has had the explicit exit since 1.8.0 and could not be made to hang.

The change mirrors #183: the watch and files branches get the same .then(() => process.exit(0)) the other two modes have, plus one comment stating the rule every headless mode now follows. The daemon modules keep resolving from their signal handler after the synchronous flush, which is the contract that keeps them unit-testable; the CLI exits right there and lets the OS reclaim the sockets, the bargain engine.destroy() and the TUI's forceExit already make.

Verified on the rebuilt bundle: watch exits in 255 ms with status 0 and all three state files carry the signal's timestamp, so the flush still runs ahead of the exit; files exits in 255 ms with a throttled download in flight.

Checklist

  • npm run typecheck is clean
  • npm test passes
  • New logic has a test (vitest; mock node built-ins for platform code) — none added: the change is two promise-chain lines in the untested CLI entry point, the same shape fix: exit after headless server shutdown #183 landed; verification is the end-to-end SIGTERM run above
  • If I added a key, I updated both HELP_GROUPS and footerHints in src/ui/keymap.ts — n/a
  • If I added a Store field, I updated makeStore in scripts/render-previews-impl.tsx — n/a
  • OS-touching code works on Windows, macOS, and Linux — process.exit after the promise settles is platform-neutral; CI matrix covers all three
  • One concern, with a Conventional Commits title (feat: / fix: / docs: / chore:)

Generated by Claude Code

A headless `torlnk watch` under a supervisor flushed queue.json, seeds.json
and history.json the instant SIGTERM arrived and then stayed alive for hours
until kill -9. seed and serve exit explicitly once their promise settles
(#183, 918d3f6); watch and files only had the error handler, so they relied
on the event loop draining on its own, which means waiting on webtorrent's
async teardown. One peer connection webtorrent has lost track of (the #205
orphan) is enough to keep that from ever finishing: on 2.8.5 it is a ref'd
UDP socket of its own, on 3.x it keeps the shared uTP server socket from
closing. Either way the loop never empties, and the same hang makes
`torlnk update` report a daemon as still shutting down and never relaunch it.

Reproduced on both webtorrent lines by injecting a single orphan into the
real process: watch stayed alive 20 s after SIGTERM and needed SIGKILL while
serve and seed exited in 255 ms. With this change watch exits in 255 ms with
status 0, its state files still rewritten at the signal; files exits the
same way with a transfer in flight.

The daemon modules keep resolving from their signal handler after the
synchronous flush, which is the contract that keeps them unit-testable; the
CLI exits right there and lets the OS reclaim the sockets, the bargain
engine.destroy() and the TUI's forceExit already make. serve was reported
too but has had this exit since 1.8.0 and could not be made to hang.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hei7LB3kzJELvzVCTmVrQa
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

0 finding(s)

No findings.

@baairon
baairon merged commit 664819d into main Nov 4, 2026
9 checks passed
@baairon
baairon deleted the claude/happy-planck-w8fw5n-207 branch November 4, 2026 06:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

headless watch/serve/files flush state on SIGINT/SIGTERM but never exit (missing process.exit on the watch path)

1 participant