Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/design/hook-process-tree-cancellation.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ On POSIX, HookRunner starts each command hook as a detached child so the shell l

The root child closing does not cancel escalation because descendants can remain in the group after the root exits. After SIGKILL is accepted, HookRunner does not wait for process IDs to disappear: terminated processes may remain briefly visible as zombies until their new parent reaps them.

While a POSIX command hook is running, HookRunner keeps its owned process group registered with a synchronous process-exit fallback. If the parent reaches Node's exit event before normal cancellation completes, the fallback sends SIGKILL to every active hook group instead of leaving the detached tree behind. Temporary SIGHUP, SIGINT, SIGQUIT, and SIGTERM handlers reclaim active groups before either re-raising an otherwise unhandled signal or leaving graceful parent shutdown to an application handler.
While a POSIX command hook is running, HookRunner keeps its owned process group registered with a synchronous process-exit fallback. If the parent reaches Node's exit event before normal cancellation completes, the fallback sends SIGKILL to every process-scoped hook group instead of leaving the detached tree behind. Temporary SIGHUP, SIGINT, SIGQUIT, and SIGTERM handlers reclaim those groups before either re-raising an otherwise unhandled signal or leaving graceful parent shutdown to an application handler.

Command hooks for `MessageDisplay`, `StopFailure`, and `SessionDelete` are exceptions because those events are dispatched fire-and-forget and their output has no control effect. Qwen synchronously writes their input to a mode-0600 temporary file, then starts an unreferenced, detached supervisor whose stdin, stdout, and stderr do not depend on Qwen. The supervisor opens the input file as the hook's stdin and removes its directory entry after spawning the hook where the platform permits, otherwise retrying when the hook completes, so queued pipe writes cannot be lost when Qwen exits and sensitive input is not retained longer than necessary. The internal Node supervisor does not inherit user `NODE_OPTIONS`; it passes the original value separately and restores it only for the actual hook command.

Graceful supervisor exit and handled termination signals remove staged input and terminate an owned hook group. An untrappable SIGKILL in the short interval between staging input and the supervisor unlinking it can leave the mode-0600 file behind. An untrappable SIGKILL after the hook starts can also leave the independently owned hook group running. Closing those host-failure gaps requires an external reaper or a separate process-group identity channel and is outside this change.

On POSIX, the supervisor starts the command in a separate owned process group and retains the configured deadline after Qwen exits. Root-process close records the exit status but is not completion while another process remains in the group. Normal completion ends the supervisor as soon as the group is empty; timeout sends TERM and then KILL to the group. While Qwen is still alive, AbortSignal cancellation terminates the supervisor, which forwards the same tree cleanup to the command group. Generic `async: true` hooks remain process-scoped: their captured output belongs to AsyncHookRegistry and, on POSIX, they are reclaimed when the Qwen process exits.

Windows does not expose POSIX process-group signals. HookRunner instead invokes the absolute System32 `taskkill.exe` path asynchronously with `/f /t /pid` and a bounded execution time. A failed taskkill falls back to force-killing the direct child and emits a diagnostic warning. If the root process has already exited, taskkill cannot reconstruct descendants from that former PID; reclaiming that case requires a Windows Job Object or descendant tracking and remains outside this change.

Expand All @@ -34,5 +40,7 @@ After tree termination, HookRunner waits up to one second for the root child to

## Test plan

- Unit-test process-group ownership, TERM-to-KILL timing, root-close races, timeout/abort races, parent-exit fallback, normal completion, spawn errors, and Windows taskkill fallback.
- Unit-test process-group ownership, parent-independent supervisor selection, TERM-to-KILL timing, root-close races, timeout/abort races, parent-exit fallback, normal completion, spawn errors, and Windows taskkill fallback.
- Run a POSIX process test whose descendant confirms receipt of SIGTERM, ignores it, and is then made non-running by group SIGKILL.
- Run POSIX process tests proving process-scoped async hooks are reaped on parent exit while `MessageDisplay`, `StopFailure`, and `SessionDelete` hooks let Qwen exit naturally and still finish afterward.
- Run POSIX process tests proving surviving hooks keep their timeout after Qwen exits, retain supervision when the root exits before a descendant, preserve explicit abort, and receive input larger than the OS pipe buffer in full.
8 changes: 6 additions & 2 deletions docs/users/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,7 @@ When sent to the model, injected `additionalContext` is appended as its own mess
}
```

The hook uses the deleting runtime's normal session fields (`session_id`, `transcript_path`, and `cwd`); over ACP, `transcript_path` is empty because the deleting runtime has no transcript of its own. `SessionDelete` currently fires for the interactive `/delete` flow and ACP's explicit `deleteSession` method; daemon REST batch deletion and internal cleanup do not emit it.
The hook uses the deleting runtime's normal session fields (`session_id`, `transcript_path`, and `cwd`); over ACP, `transcript_path` is empty because the deleting runtime has no transcript of its own. `SessionDelete` currently fires for the interactive `/delete` flow and ACP's explicit `deleteSession` method; daemon REST batch deletion and internal cleanup do not emit it. A command hook is left to finish if Qwen exits after dispatch; its stdout and stderr are ignored and remain independent of Qwen's pipes.

#### MessageDisplay

Expand Down Expand Up @@ -886,6 +886,8 @@ The `context_usage`, `context_limit`, and `input_tokens` fields allow hook scrip
- Billing error notifications
- Error statistics collection

A command hook is left to finish if Qwen exits after dispatch; its stdout and stderr are ignored and remain independent of Qwen's pipes.

#### SubagentStart

**Purpose**: Executed when a subagent (like the Task tool) is started to set up context or permissions.
Expand Down Expand Up @@ -1347,10 +1349,12 @@ Hooks are configured in Qwen Code settings, typically in `.qwen/settings.json` o

Only `command` type supports asynchronous execution. Setting `"async": true` runs the hook in the background without blocking the main flow.

Async hooks are scoped to the Qwen process because their captured output is delivered through the in-memory async hook registry. On POSIX, Qwen reclaims a still-running async hook process tree when it exits, except for event types whose sections explicitly guarantee fire-and-forget completion after exit. Windows cannot reconstruct a descendant tree after its root exits, so full parent-exit reclamation there requires a Job Object or descendant tracking.

**Features:**

- Cannot return decision control (operation has already occurred)
- Results are injected in the next conversation turn via `systemMessage` or `additionalContext`
- Results are injected in the next conversation turn via `systemMessage` or `additionalContext`, except for output-ignored fire-and-forget event types documented above
- Suitable for auditing, logging, background testing, etc.

**Example:**
Expand Down
Loading
Loading