A plugin's background workers outlive a plugin that exits on its own
- Status: Done
- Kind: Bug
- Authors: jp
- Date: 2026-10-01
- Implements: 072
- Label: client=cli
- Label: domain=plugins
- Label: package=jp_cli
- Label: type=bug
When a plugin exits by itself (it sends exit, or it closes stdout), JP reaps the process it spawned and does nothing about the rest of its process tree. A worker the plugin started in the background keeps running after jp returns, and keeps any port it bound.
Tree-wide termination only happens on the kill path: stop_plugin calls ProcessTree::kill once the shutdown grace period runs out (T-0sz2vpy). A plugin that exits cleanly never reaches that path.
Why
run_plugin(crates/jp_cli/src/cmd/plugin/dispatch.rs) ends withchild.wait()and a bounded join on the stderr reader. Neither one touches the plugin's process group or job object.- The bounded join keeps
jpfrom hanging on a surviving descendant, so the leak is silent: nothing is logged unless the stderr join times out.
RFD 072 says JP owns the plugin's process tree for the lifetime of the invocation, not only the process it spawned. That guarantee holds today only for a plugin that has to be killed.
Scope
A shell-script plugin that runs server &, does its work, sends exit, and returns. The server is still running after jp exits.
Fix
Terminate the tree on every exit path in run_plugin, not only after the grace period.
- Unix:
kill(-pgid, SIGKILL)once the plugin has exited. Do it before the leader is reaped (for example, wait withwaitid(..., WEXITED | WNOWAIT)first, then signal the group, then reap). A zombie leader keeps the group ID reserved, so the signal cannot reach an unrelated group that reused the PID. - Windows: terminate the job at cleanup, or create it with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEso closing the handle does the same. - With the tree gone, the stderr join only times out for a descendant that deliberately left the group, which RFD 072 leaves unsupported.
Verifying
A shell plugin that starts sleep 600 &, prints the worker's PID, sends exit with code 0, and exits. After run_plugin returns, the worker's PID no longer exists.
Comments
- From: jp
- Date: 2026-10-01T12:40:45Z
Fixed on the RFD 072 branch. run_plugin ends with ProcessTree::finish, which runs on every exit path, clean or killed.
- Unix:
waitid(P_PID, pid, WEXITED | WNOWAIT)waits for the plugin to exit and leaves it a zombie, so its pid and the group id stay reserved. Then the group is killed, then the plugin is reaped. - Windows: the job is terminated after the plugin exits. A job is addressed by its handle, so the order does not matter there.
dispatch::tests::a_worker_dies_with_a_plugin_that_exits_on_its_own is the "Verifying" check, through the real run_plugin: a script starts sleep 600 &, answers init, sends exit 0, and exits; the worker is gone afterwards. It fails when the kill is removed from finish. The ordering itself (kill before reap) is not testable deterministically, since the failure it prevents is a pid reuse race.