Skip to content

fix: hook command grandchildren hold stdio pipes and hang triggers forever - #505

Merged
LeXwDeX merged 13 commits into
mainfrom
fix/500-hook-command-grandchildren
Sep 2, 2026
Merged

LeXwDeX merged 13 commits into
mainfrom
fix/500-hook-command-grandchildren

Conversation

@LeXwDeX

@LeXwDeX LeXwDeX commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Closes #500
Closes #501
Closes #502
Closes #503
Closes #504

Why

2026-09-02 资源泄露审计确认的 7 项风险中的 5 项确认级修复:hook 子进程挂死(用户环境可直接踩)、服务端/TUI 内存泄露 ×2、子进程孤儿化向量、磁盘无限增长 ×2。附带:cherry-pick 了 PR #509 的 CI ready_for_review 触发修复(本 PR 的 SpecGit 锚点同样依赖它)。

What changed

Evidence

  • 新增回归测试 ×5 组:hook 组收割真实孙进程(3056ms wall-clock 断言 + pgrep 确认孙进程已死)、share mount→dispose→remount listener 计数 5/5/10/10 配对(含反向 mutation 验证:撤修复即红)、TUI 卸载后事件不再触达 handler(fixture 语义测试)、stop() 对 SIGTERM-忽略真实进程 6s 内终止、MCP 进程树全杀(真实 sh 派生 sleep 后代)、memory 5 次 commit 后仅 3 代 + chmod 0555 注入 commit 仍成功、heap 轮转 3 用例
  • 本地:bun typecheck(根,29/29 包);packages/opencode 目标测试 56 pass;hook 全目录 148 pass;memory 全目录 106 pass;packages/tui 全量 250 pass / 1 skip;oxlint 4840/4850 棘轮内
  • CI:见下方 checks(Typecheck / Unit Tests (linux) / E2E linux+windows / SpecGit Acceptance)

Checklist

  • Why, What changed, and Evidence are filled in.
  • specgit finish exits 0.

…ldren cannot hang triggers

A timed-out command hook only SIGTERM'd the shell wrapper; grandchildren
keeping the stdio pipes open meant the close event never fired and the
hook trigger hung forever. Exit and stream-drain are now awaited
separately with a bounded grace, then the whole group (detached, negative
pid on POSIX; taskkill /T /F on Windows) is SIGKILL'd and reaped.
watch() discarded every Unsubscribe, so each instance dispose/remount
cycle leaked five permanent EventV2 listeners holding the instance
context. The finalizer now unsubscribes before closing the scope.
Three event.on handlers discarded their unsubscribe functions and
accumulated in the app-level SDK handler set on every route transition,
retaining the opentui editor and renderer trees.
…nt shutdown

stop() on POSIX returned right after SIGTERM, so servers ignoring it
stayed alive as orphans while instance finalizers reported success. It
now waits a bounded grace, escalates to SIGKILL, and awaits exit; the SDK
copy adapts the same escalation synchronously with an unref'd timer. MCP
client shutdown reaps the whole process tree through a shared
shutdownClient used by the state finalizer, closeClient, and the create
rollback path.
Every memory commit copied the full topic set into a new generations
directory that was never removed; keep the latest 3 plus orphan-staging
sweep, best-effort so a cleanup failure never fails a commit. Heap
snapshots (hundreds of MB each) now rotate to the latest 2 in the log
directory.
Redundant toString on stream text, .resolves on a synchronous prune
helper, and one intentional wire-shape JSON assertion (disabled inline
with reason, matching the schema-contract test precedent).
heap-<pid>-<ts> sorted lexicographically orders snapshots by pid across
runs (digit-count changes, wraparound), so pruning could delete the newest
snapshot and keep stale ones. Sort by the timestamp segment instead, surface
non-ENOENT readdir failures as warnings, fix the always-true missing-dir
assertion (unawaited async), and add a cross-pid regression test.
The finally-block process.exit() cut off pending async cleanup (instance
finalizers, MCP/process stop escalation) — the #503 clause the first pass
skipped. An unref'd 5s force-exit timer (matching the stop escalation
budget) keeps the hanging-subprocess protection while a clean drain exits
immediately with all cleanup completed.
@LeXwDeX
LeXwDeX force-pushed the fix/500-hook-command-grandchildren branch from 479469a to 7c44188 Compare September 2, 2026 07:35
The escalates-to-SIGKILL child wrote "ready" before registering its trap,
so a parent preempted between the two statements delivered SIGTERM to the
default handler and the child exited SIGTERM instead of escalating —
observed as a load-dependent failure on the linux runner (262ms, signalCode
SIGTERM). Ordering the trap first makes the ready signal prove the handler
is installed.
@LeXwDeX
LeXwDeX merged commit ed46f08 into main Sep 2, 2026
9 checks passed
@LeXwDeX
LeXwDeX deleted the fix/500-hook-command-grandchildren branch September 2, 2026 09:35
LeXwDeX added a commit that referenced this pull request Sep 2, 2026
Series file for the next stable derived from graphagent-v1.0.37: the #505
resource-leak fix wave and the #509 DAG workflow-tool consistency pass,
including the review-round fixes and the reverted bounded-exit-window
attempt. Render verified locally with release-notes.ts (exit 0).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment