Skip to content

fix: kill the whole engine process tree on timeout and run tini as PID 1 - #67

Open
PatrickSt1991 wants to merge 1 commit into
johnpc:mainfrom
PatrickSt1991:fix/timeout-kills-process-tree
Open

PatrickSt1991 wants to merge 1 commit into
johnpc:mainfrom
PatrickSt1991:fix/timeout-kills-process-tree

Conversation

@PatrickSt1991

@PatrickSt1991 PatrickSt1991 commented Sep 20, 2026 •

Copy link
Copy Markdown

Problem

After running subsyncarr for a couple of weeks I found 13 <defunct> processes on the host, all children of the subsyncarr node process: pairs of ffsubsync + ffmpeg, plus a few alass.

Cause:

  1. execPromise in src/helpers.ts uses child_process.exec with the timeout option. When the timeout fires, node only sends SIGTERM to the shell it spawned. ffsubsync/alass and the ffmpeg they start are not signalled and keep running until they finish on their own. So the timeout frees the queue but does not actually stop the work, and the CPU stays busy.
  2. The container has no init process. entrypoint.sh does exec gosu node "$@", so node is PID 1. When the orphaned engine processes finally exit they are re-parented to node, which never calls wait() on processes it did not spawn, and they stay as zombies until the container is restarted.

Fix

  • execPromise now uses spawn with shell: true and detached: true, so the command leads its own process group. On timeout the whole group is killed with kill(-pid, SIGTERM), followed by SIGKILL after 5 seconds. stdout/stderr/code/signal/killed are still attached to the rejection, so the engine wrappers keep working unchanged. The error message contains "timed out", which the existing isTimeout checks in the engine files match.
  • The Dockerfile installs tini and uses it as the entrypoint in front of entrypoint.sh. It only reaps orphans and forwards signals; gosu still drops to the node user as before.
  • README: short note under Timeout Configuration.
  • New test src/__tests__/helpers.test.ts: runs sleep 30 & echo $!; wait with a 300 ms timeout and asserts the backgrounded sleep is gone afterwards. With the old exec based code that grandchild survives.

Notes

  • On Windows detached is not used and only the direct child is killed, same as before.

execPromise used child_process.exec with the built-in timeout, which only
signals the shell. ffsubsync/alass and the ffmpeg processes they spawn kept
running to completion after a timeout, and because node is PID 1 in the
container they were re-parented to node and left as <defunct> zombies once
they exited (13 of them after two weeks on my instance).

- spawn the command detached so it leads its own process group, and on
  timeout kill the group with SIGTERM, then SIGKILL after 5s
- keep stdout/stderr on the rejection like before, capped at 10 MB
- add tini as PID 1 so any process that still ends up orphaned is reaped
- add a regression test that checks a backgrounded grandchild is dead
  after a timeout
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.

1 participant