Files
oh-my-claudecode/scripts/lib/bounded-git-timeout.mjs
Bellman 84787817b5 fix(windows): bound generic-hook runner timeout ownership and nested git (#3493) (#3496)
* fix(windows): bound generic-hook runner timeout ownership and nested git (#3493)

Native Windows generic-hook run.cjs parents survived 30-66 min despite
3-10s manifest budgets. Two runner-side gaps plus unbounded nested git
calls left the generic hook path without reliable termination.

run.cjs (generic branch):
- Replace the blocking spawnSync generic dispatch with an async spawn
  child supervisor (runGenericChild) that owns a setTimeout and reaps the
  OS process tree while the child is still alive. spawnSync only returns
  after the child fully exits, so post-call cleanup was unreachable.
- POSIX: spawn detached and process.kill(-pid, SIGKILL) the group with a
  direct-child fallback. Windows: taskkill /T /F /PID bounded by its own
  timeout:2000 so the reap cannot hang the handler. Job Object kill-on-
  close remains the documented escalation if the Windows PID-survival
  test regresses.
- Always arm a runner-owned timer: resolveGenericTimeoutMs falls back to
  DEFAULT_GENERIC_TIMEOUT_MS (59500ms = max manifest budget - cushion)
  when manifest resolution is null (closes the unbounded Gap A) and is
  bypassed when the manifest resolves.
- Pin the exit-code contract (result.status ?? 0 semantics): numeric exit
  propagates; signal-only, spawn error, reap failure, and timeout fail
  open with 0; single-resolve guard; timer cleared on every terminal path.
- Add a require.main===module guard + module.exports so internals are
  unit-testable without executing dispatch. #3490 worker path, allowlist,
  provenance, and fail-open are unchanged.

Nested git bounding:
- Add shared BOUNDED_GIT_TIMEOUT_MS=2000 (scripts/lib + templates/hooks/
  lib, byte-identical) and apply it at the 8 owned sites: the 4 unbounded
  resolveTranscriptPath git calls and the 4 over-budget 5000ms calls
  (resolveOmcRoot x2, code-simplifier x2). Already-bounded compliant sites
  (context-guard-stop 1000ms, workflow-drift-guard 2000ms) are untouched.

Tests:
- run-cjs-generic-timeout: exports/selection, POSIX group reap, A2 exit
  states, single-terminalization.
- windows-prompt-hook-runner: generic-path Windows PID-survival reap
  assertion (runs on the windows-latest CI lane).
- bounded-git-timeout-parity: both constant copies match.
- windows-hide-hooks: two-tier static scan (every generic-hook git call
  bounded; owned sites must use the shared constant; compliant sites
  preserved) with independent-tier negative seeds.
- installer: require bounded-git-timeout.mjs in templates/hooks/lib.

Fixes #3493

* fix(windows): reap detached generic-hook tree on runner termination (#3493)

Hostile review found that runGenericChild detached the hook into its own
POSIX process group but reaped only from the inner timeout callback. If
run.cjs was terminated or cancelled BEFORE its timer fired (outer
hooks.json timeout, Ctrl-C, parent kill), the detached hook tree was
orphaned - reintroducing the #3493 orphan class for the outer-termination
path and regressing prior POSIX cancellation behavior.

- Register SIGTERM/SIGINT/SIGHUP and process 'exit' handlers while the
  generic child is live; on runner termination reap the process tree
  (idempotent, single-terminal guard) before exiting fail-open.
- Remove the handlers on every terminal path (timeout/exit/error) so no
  listeners leak across in-process supervisor calls.
- reapTree now returns success/failure and inspects the Windows taskkill
  status.
- Add a POSIX subprocess test that kills the runner before its timeout and
  asserts the detached child and grandchild are reaped.

* fix(windows): guarantee runner exit on failed reap; non-blocking taskkill (#3493)

Second hostile review found the timeout path could still leave run.cjs
alive on Windows: reapTree returned false when taskkill was missing/denied/
timed out, but the live ChildProcess handle kept the event loop alive, so
run.cjs never exited — the exact "run.cjs parent alive" symptom of #3493.

- Add child.unref() on the timeout path so the runner ALWAYS exits fail-open
  even when the best-effort tree reap does not complete.
- Make the Windows reap non-blocking: spawn `taskkill /T /F` detached +
  unref'd (fire-and-forget) instead of a synchronous spawnSync that could
  block up to 2000ms past the outer hooks.json budget. POSIX group-kill is
  already instantaneous.
- Drop the now-unused spawnSync import.
- Clarify the POSIX outer-cancellation test comment (Windows programmatic
  SIGTERM force-terminates rather than delivering a catchable signal, so that
  path is POSIX-specific; killing the grandchild proves the whole group,
  including the direct hook child, was reaped).

---------

Co-authored-by: clawdbot <clawdbot@users.noreply.github.com>
2026-07-15 15:39:32 +00:00

5 lines
291 B
JavaScript

// Budget-coherent PER-CALL ceiling for nested git calls in generic hooks.
// 2000ms < the smallest hook manifest budget (3s), so an inner git timeout
// fires before the runner's generic-execution timeout. Non-proportional by design (see #3493).
export const BOUNDED_GIT_TIMEOUT_MS = 2000;