Unit tests remove the dsh-* temp dirs they create
单测删除自己创建的 dsh-* 临时目录
Test processes create `/tmp/dsh-*` directories with `mkdtemp(join(tmpdir(), 'dsh-*'))` and leave them behind. On the self-hosted Linux CI host (32 runner instances sharing one `/tmp`) the residue exhausted the root partition's inode capacity twice (issue #3134, 2026-08-13 and 2026-08-26). The machine-side `dsh-tmp-sweep` timer and the CI lane sweep (kept, unmerged, on branch `fix/ci-tmp-residue-cleanup`) remove resid
English
Problem
Test processes create /tmp/dsh-* directories with mkdtemp(join(tmpdir(), 'dsh-*')) and leave them behind. On the self-hosted Linux CI host (32 runner instances sharing one /tmp) the residue exhausted the root partition's inode capacity twice (issue #3134, 2026-08-13 and 2026-08-26). The machine-side dsh-tmp-sweep timer and the CI lane sweep (kept, unmerged, on branch fix/ci-tmp-residue-cleanup) remove residue after the fact but leave the producing defect in place. Human review of #3233 (2026-08-28) rejected the sweep: unit tests must clean up the directories they create instead.
Decision
Retrofit removal of every dsh-* temp dir a spec file creates, at the owning test's teardown:
- Spec files that created dirs without removing any now track each created root in a module-level list and delete the list in
afterEach/afterAll(rm/rmSyncwithrecursive: true, force: true), the convention already used across the session packages. Root-creating helpers (tmp(),tempDir(),fakeLauncher(), harness functions) register the root at creation, so every caller is covered at one point. - Module-scope fixture dirs shared by a whole file (executor spill dirs) are removed in
afterAllafter the last test. - The file list came from the observed-residue inventory on the CI host (a template histogram of current
/tmp/dsh-*dirs): only spec files whose dirs actually appeared were leak sources. Files that already remove their dirs (agent-team, tool-subagent, list-children, hooks coverage cases) were confirmed clean on the normal-exit path and left unchanged. - Product cleanup is limited to the per-process spill directory of
dsh-subprocess-local/spawn(privateSpillDir): it is removed at a JavaScript-observable process exit when it holds no completed spill file — completed spill files are retained as full-output recovery artifacts until an external cleanup, so only directories that never spilled (the dominant residue shape on the CI host: 92% of sampleddsh-subprocess-*dirs are empty) are removed. The removal is best-effort (ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code).dsh-spill-local's default root is deliberately NOT exit-deleted: it is covered by the package's own 30-day startup sweep, and the retention decision forbids deleting fresh spill artifacts that resumed or forked sessions may still reference.
Verification
- Targeted local runs of every changed unit spec passed (the 36 changed
*.spec.tsfiles, exercised in grouped runs), including the suites that exercise the changed product source; the two changed web*.e2e.tsfiles run under the web e2e lane. - CI runs the changed specs on the Linux and Windows coverage lanes; after a full green run, the fixed files' residue templates (observed at up to ~5,000 dirs per two hours each, e.g.
dsh-profile-,dsh-app-boot-,dsh-presets-*,dsh-upload-index-) should no longer appear in fresh/tmpresidue on the CI host.
Alternatives considered
Keep the sweep-only approach (rejected in review)
Sweep steps and timers delete residue after it exists; they do not stop local runs from accumulating, and a machine sweep cannot distinguish a dead run's residue from a live one's. The reviewer decision was per-test cleanup, implemented here for the normal-exit path.
Introduce a shared temp-dir helper package
Not chosen: the files that leak each create roots through their own small helpers, and tracking them at those helpers is a per-file one-point change. A new test-support package would add a dependency without reducing the per-file audit.
Consequences
- Bought: on normal completion — including failed tests — a spec's
dsh-*dirs are removed at teardown; adsh-subprocess-localper-process spill directory holding no completed spill file is removed at a JavaScript-observable process exit. - Cost: a process killed with SIGKILL (a cancelled run, a timeout kill) cannot run any in-process teardown; its in-flight residue remains. The machine-side timer stays as the backstop for that path.
- Cost: dirs created by a spawned child are covered only when the test knows their paths; product-owned spill dirs holding spill files, and
dsh-spill-local's default root, keep their files per the existing retention policy and are cleaned by that package's own sweep.
中文
Problem
测试进程用 mkdtemp(join(tmpdir(), 'dsh-*')) 创建 /tmp/dsh-* 目录后不清理。在自托管 Linux CI 主机上(32 个 runner 实例共享一个 /tmp),残留两次耗尽根分区 inode(issue #3134,2026-08-13 与 2026-08-26)。机器侧 dsh-tmp-sweep timer 与 CI lane sweep(保留未合并,在分支 fix/ci-tmp-residue-cleanup 上)都是事后删除残留,未修掉产生残留的缺陷本体。人类 review #3233(2026-08-28)否决了 sweep:单测应改为自己清理创建的目录。
Decision
为 spec 文件创建的每个 dsh-* 临时目录补上删除路径,挂在所属测试的 teardown 上:
- 创建目录但从不删除的 spec 文件,现在把每个创建的 root 记入模块级列表,并在
afterEach/afterAll里删除(rm/rmSync带recursive: true, force: true)——与 session 包既有的roots.splice(0)约定一致。创建 root 的 helper(tmp()、tempDir()、fakeLauncher()、harness 函数)在创建处登记,一个点覆盖全部调用方。 - 整文件共享的模块级 fixture 目录(executor spill 目录)在最后一个测试之后的
afterAll里删除。 - 目标文件清单来自 CI 主机上的残留实测清单(当前
/tmp/dsh-*目录的模板直方图):只有目录确实出现在残留里的 spec 文件才是泄漏源。已有删除逻辑的文件(agent-team、tool-subagent、list-children、hooks coverage cases)确认在正常结束路径上本来干净,不改。 - 产品侧清理限定在
dsh-subprocess-local/spawn的每进程 spill 目录(privateSpillDir):在 JavaScript 可观察的进程退出时,仅当目录未持有任何已完成的 spill 文件才删除——已完成的 spill 文件作为完整输出恢复产物保留到外部清理,因此只有从未 spill 过的目录(CI 主机残留的主流形态:抽样dsh-subprocess-*目录 92% 为空)会被删除。删除是 best-effort(ENOENT/ENOTEMPTY/EBUSY/EPERM 不得改变退出码)。dsh-spill-local的默认 root 刻意不做退出删除:该 root 由包自带的 30 天启动 sweep 覆盖,且保留策略 note禁止删除 resume/fork 会话仍可能引用的新 spill 产物。
Verification
- 本地定向跑过全部改动单测 spec 通过(36 个改动的
*.spec.ts文件,分组运行),含直接使用改动后产品源码的套件;2 个改动的 web*.e2e.ts由 web e2e lane 承载。 - CI 在 Linux 与 Windows coverage lane 跑改动 spec;一次全绿后,被修文件的残留模板(实测每两小时最多各约 5,000 个目录,如
dsh-profile-、dsh-app-boot-、dsh-presets-*、dsh-upload-index-)应不再出现在 CI 主机的新鲜/tmp残留里。
Alternatives considered
保留纯 sweep 方案(review 否决)
Sweep 步骤与 timer 只删已存在的残留;本地运行仍会累积,机器 sweep 也区分不了已死 run 的残留与存活 run 的目录。review 的决定是逐测试清理,本实现覆盖正常结束路径。
引入共享临时目录 helper 包
未选:泄漏文件各自通过自己的小 helper 创建 root,在那些 helper 处登记是每个文件单点改动;新增 test-support 包只会增加依赖,不减少逐文件审计量。
Consequences
- 收益:正常结束(含测试失败)时,spec 的
dsh-*目录在 teardown 删除;dsh-subprocess-local未持有任何已完成 spill 文件的每进程 spill 目录在 JavaScript 可观察的进程退出时删除。 - 代价:被 SIGKILL 的进程(run 被取消、超时被杀)无法运行任何进程内 teardown,飞行中的残留仍在——机器侧 timer 继续兜底该路径。
- 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有、仍持有 spill 文件的目录与
dsh-spill-local的默认 root 按既有保留策略保留文件,由该包自身的 sweep 清理。