Răsfoiți Sursa

Merge pull request #3280 from deepseek-harness/fix/ci-node-compile-cache-data-disk

ci: redirect Node compile cache to data-volume runner temp
Tianyi Cui 2 săptămâni în urmă
părinte
comite
6c4cd033e3

+ 2 - 2
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md
-2026-08-18-in-job-partitioned-coverage.md: ef22245354bbeb26d551e227a6b5ca26ca22306c
-2026-08-18-in-job-partitioned-coverage.zh.md: 67b434a8559f32da5bd2f863f526ce2b10e36b4c
+2026-08-18-in-job-partitioned-coverage.md: 8a3eb202255ecbf680dfa0af656fd5b89e958001
+2026-08-18-in-job-partitioned-coverage.zh.md: e7ca2692485fa4f9f0135821963e64347c3cdfdd

+ 1 - 1
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md

@@ -30,7 +30,7 @@ A normal failed test still emits a blob through `--coverage.reportOnFailure`, al
 
 `scripts/coverage-partitions.spec.ts` pins argument construction, package-script separator removal, one-worker partitions, weighted longest-processing-time assignment (including a case that fails when assignment ignores recorded weights), the single merged threshold command, failed-test merging, failure diagnostics before complete-blob validation, waiting for sibling partitions after a spawn failure, and link-safe cleanup. `scripts/run-gates.spec.ts` pins opt-in selection, invalid-count rejection, both native Windows coverage gates' complete-build dependency, the complete Windows inventory with its blocking split, and unbuffered streamed output. React fake-timer cases that can move between partitions advance timers inside `act()`; geometry-dependent portal tests stub their element rectangles so a different shard schedule cannot turn deferred updates or jsdom coordinates into coverage-only failures.
 
-Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds under the earlier gate ordering; those values compare partition latency, not the current peak. The current coverage phase runs four instrumented partition processes beside two exempt workers, for six coverage execution units. Sixteen partitions would raise that phase to eighteen before any still-running production-site work or system overhead. Four partitions keep separate-process isolation and match Linux, at the cost of a longer single-job coverage wall time; the trade-off is accepted to reduce vitest worker startup failures under high self-hosted concurrency. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency.
+Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds under the earlier gate ordering; those values compare partition latency, not the current peak. The current coverage phase runs four instrumented partition processes beside two exempt workers, for six coverage execution units. Sixteen partitions would raise that phase to eighteen before any still-running production-site work or system overhead. Four partitions keep separate-process isolation and match Linux, at the cost of a longer single-job coverage wall time; the trade-off is accepted to reduce vitest worker startup failures under high self-hosted concurrency. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 192 across the failover VM's 32 runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md

@@ -30,7 +30,7 @@ Status: implemented
 
 `scripts/coverage-partitions.spec.ts` 固定了参数构造、包脚本分隔符移除、单 worker 分区、加权最长处理时间分配(含一个在分配忽略记录权重时必然失败的用例)、唯一一次合并阈值命令、失败测试合并、完整 blob 校验前的失败诊断、spawn 失败后等待兄弟分区,以及链接安全清理。`scripts/run-gates.spec.ts` 固定了显式启用、非法数量拒绝、两道原生 Windows 覆盖率门禁对完整构建的依赖、完整 Windows 清单及其阻断性划分,以及不缓冲的流式输出。可能在分区间移动的 React fake-timer 用例会在 `act()` 内推进计时器;依赖几何位置的 portal 测试会固定元素矩形,使不同分片调度不会把延迟更新或 jsdom 坐标变成只在覆盖率运行中出现的失败。
 
-已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒;这些数据来自先前的门禁顺序,只用于比较分区延迟,不代表当前峰值。当前的覆盖率阶段会让 4 个插桩分区进程与 2 个豁免 worker 并行,共形成 6 个覆盖率执行单元。若改为 16 个分区,则在尚未结束的生产网站工作或系统开销计入之前,该阶段就会达到 18 个执行单元。4 个分区保留独立进程隔离并与 Linux 对齐,代价是单 job 覆盖率墙钟更长;这是为了降低自托管高并发下 vitest worker 启动失败而接受的取舍。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。
+已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒;这些数据来自先前的门禁顺序,只用于比较分区延迟,不代表当前峰值。当前的覆盖率阶段会让 4 个插桩分区进程与 2 个豁免 worker 并行,共形成 6 个覆盖率执行单元。若改为 16 个分区,则在尚未结束的生产网站工作或系统开销计入之前,该阶段就会达到 18 个执行单元。4 个分区保留独立进程隔离并与 Linux 对齐,代价是单 job 覆盖率墙钟更长;这是为了降低自托管高并发下 vitest worker 启动失败而接受的取舍。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 32 个 runner 实例最多合计 192 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。
 
 ## 曾考虑的替代方案
 

+ 6 - 0
.agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md
+2026-08-28-ci-node-compile-cache-data-disk.md: 3f5f7eb2f00a37b635b63093f60c66688b82316b
+2026-08-28-ci-node-compile-cache-data-disk.zh.md: c55a6c73cf98f0dd0b31b50d64e7937ee9e14305

+ 43 - 0
.agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md

@@ -0,0 +1,43 @@
+# Agent Note: Redirect the Node compile cache to the data-volume runner temp
+
+Status: implemented
+
+English | [中文](2026-08-28-ci-node-compile-cache-data-disk.zh.md)
+
+## Problem
+
+The self-hosted Linux CI VM (`vm-backup` pool, 32 runner instances on one host) exhausts the root partition's inode capacity. Issue #3134's residue (`/tmp/dsh-*`) is one source; a second, larger source is the Node.js module compile cache. Tools in the CI toolchain call `module.enableCompileCache()` explicitly: pnpm 11.7.0 enables the cache in its entry (`module.enableCompileCache?.()` in `bin/pnpm.mjs`) on every invocation, and TypeScript does so in `tsc`/`tsserver`; vitest forwards the API but does not enable it itself. Every such call writes the serialized V8 bytecode cache under `os.tmpdir()/node-compile-cache`. On the shared VM that is the root partition's `/tmp`: measured 2026-08-28 at **697,389 inodes and 9.2 GB**, with 34,110 files younger than 1 hour — the cache grows on every CI run and is never cleaned, so the root partition's 3,276,800 inodes trend toward exhaustion even after the `dsh-*` residue is controlled.
+
+## Decision
+
+Each Linux lane that can run on the `vm-backup` pool under failover (`ci.yml` static/coverage/snapshots — hosted by default, self-hosted only when `DSH_CI_FAILOVER_LINUX=selfhosted` — and `ci-master.yml` serial standby, always self-hosted) redirects `NODE_COMPILE_CACHE` to the per-runner data-volume temp dir `${{ runner.temp }}/node-compile-cache`. `runner.temp` lives on `/data_local` (1 TB, ~1% inode used) and is per-runner (`_workNN/_temp`), so the cache stops consuming root-partition inodes.
+
+The redirect is a step right after `actions/checkout` that writes `NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache` into `$GITHUB_ENV`, so every later step in the lane — `pnpm/action-setup`, the store-path probe, install, Playwright install, and the test gate — inherits it. Injection is required because the `runner` context is unavailable in job-level `env` (the same constraint as the earlier TMPDIR work), and a step-level env on the gate step alone would leave the earlier pnpm calls writing to the root partition's `/tmp`. A confined child (bwrap/Landlock) whose sandbox does not grant the `runner.temp` path inherits the variable but **silently skips caching** — verified on the VM: with `NODE_COMPILE_CACHE` pointing at an ungranted path inside bwrap, `node` runs normally (exit 0), unlike `mkdtemp` which fails hard with a read-only filesystem error. The compile cache is best-effort by design; a failed write is a cache miss, not a crash.
+
+## Verification
+
+- VM probe: `NODE_COMPILE_CACHE=/data_local/ci/compile-cache-probe node -e 'require("node:fs")'` wrote a `v22.23.2-x64-*` cache subdirectory on the data disk (location switch effective).
+- VM probe (bwrap): with `NODE_COMPILE_CACHE` set to a path the bwrap profile does not grant, `node` ran normally (exit 0) — cache write failure is tolerated.
+- `scripts/ci-workflow.spec.ts` asserts every Linux lane injects `NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache` (a `$GITHUB_ENV` `KEY=VALUE` line) into `$GITHUB_ENV` before `pnpm/action-setup`; the position assertion fails if the injection moves after the first pnpm call.
+- CI lanes: the three required Linux jobs (hosted by default, self-hosted `vm-backup` under `DSH_CI_FAILOVER_LINUX`) run the full suite under the new env; a regression in cache handling would surface as lane failure.
+
+## Alternatives considered
+
+### Why not disable the compile cache entirely?
+
+`NODE_DISABLE_COMPILE_CACHE=1` would stop root-partition growth immediately but forfeit the startup speedup on every run, and the cache is a legitimately useful Node feature (enabled explicitly by pnpm and TypeScript). Redirecting preserves the benefit while moving the cost off the constrained partition.
+
+### Why not add `node-compile-cache` to the `dsh-*` residue sweep?
+
+The CI sweep (added in the residue-cleanup change) targets test residue; the compile cache is a cache, not residue. Deleting it every run would discard the speedup the cache exists to provide. Redirecting is the structural fix: the cache's growth moves to the volume sized for it.
+
+### Why not job-level env or gate-step env only?
+
+The `runner` context is only available in step-level `env`; job-level `env` evaluates it to an empty string (GitHub contexts-availability), which would silently leave the cache on the root partition. A step-level env on the gate step alone would cover only that step: every earlier pnpm invocation in the lane (setup, store-path probe, install) would still write to the root partition's `/tmp`. Injecting into `$GITHUB_ENV` in a step between checkout and `pnpm/action-setup` sets the variable before the lane's first pnpm call, so one step covers the whole lane.
+
+## Consequences
+
+- **Bought**: the Node compile cache stops consuming root-partition inodes; inode pressure from this source is removed without losing the cache's startup benefit. The cache now lives in per-runner `_workNN/_temp` on the data volume.
+- **Cost**: the cache accumulates in `runner.temp`, which the runner does not empty between jobs (measured earlier) — but on the data volume (~1% inode used) that is harmless.
+- **Cost**: confined children without the `runner.temp` grant skip caching for their own `node` invocations; this is a cache miss, not a failure, and matches Node's best-effort contract.
+- **Cost**: the change touches CI configuration only; local development keeps the default `os.tmpdir()` location.

+ 43 - 0
.agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: 将 Node 编译缓存重定向到数据卷 runner 临时目录
+
+Status: implemented
+
+[English](2026-08-28-ci-node-compile-cache-data-disk.md) | 中文
+
+## 问题
+
+自托管 Linux CI 虚拟机(`vm-backup` 池,32 个 runner 实例共宿一机)的根分区 inode 正在耗尽。issue #3134 的残留(`/tmp/dsh-*`)是来源之一;第二个、更大的来源是 Node.js 模块编译缓存。CI 工具链中的工具显式调用 `module.enableCompileCache()`:pnpm 11.7.0 在入口(`bin/pnpm.mjs` 中的 `module.enableCompileCache?.()`)每次调用都启用缓存,TypeScript 在 `tsc`/`tsserver` 中启用;vitest 转发该 API 但自身不启用。每次这样的调用都把序列化 V8 字节码缓存写到 `os.tmpdir()/node-compile-cache`。在共享虚拟机上即根分区的 `/tmp`:2026-08-28 实测为 **697,389 个 inode、9.2 GB**,其中 34,110 个文件不足 1 小时——缓存每次 CI 运行都在增长且从不清理,即使 `dsh-*` 残留被控制,根分区 3,276,800 个 inode 仍趋向耗尽。
+
+## 决策
+
+每个可能运行在 `vm-backup` 池的 Linux lane(`ci.yml` static/coverage/snapshots——默认 hosted,仅 `DSH_CI_FAILOVER_LINUX=selfhosted` 时自托管;`ci-master.yml` serial standby——始终自托管)都把 `NODE_COMPILE_CACHE` 重定向到 per-runner 数据卷临时目录 `${{ runner.temp }}/node-compile-cache`。`runner.temp` 在 `/data_local`(1 TB,inode 用量约 1%)上,per-runner(`_workNN/_temp`),因此缓存不再消耗根分区 inode。
+
+重定向是在 `actions/checkout` 之后的一个 step,把 `NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache` 写入 `$GITHUB_ENV`,因此 lane 中后续每个 step——`pnpm/action-setup`、store 路径探测、安装、Playwright 安装和测试门禁——都会继承该变量。必须用注入而非 job 级 env:`runner` 上下文在 job 级 `env` 不可用(与早前 TMPDIR 工作相同的约束);而仅给门禁 step 设 step 级 env 会让更早的 pnpm 调用继续写根分区 `/tmp`。sandbox(bwrap/Landlock)未授权 `runner.temp` 路径的受限子进程会继承该变量但**静默跳过缓存**——已在虚拟机上验证:`NODE_COMPILE_CACHE` 指向 bwrap 内未授权路径时,`node` 正常运行(exit 0),与 `mkdtemp` 的只读文件系统硬失败不同。编译缓存按设计是尽力而为;写失败只是缓存未命中,不是崩溃。
+
+## 验证
+
+- VM 探针:`NODE_COMPILE_CACHE=/data_local/ci/compile-cache-probe node -e 'require("node:fs")'` 在数据盘写出了 `v22.23.2-x64-*` 缓存子目录(位置切换生效)。
+- VM 探针(bwrap):`NODE_COMPILE_CACHE` 指向 bwrap profile 未授权的路径时,`node` 正常运行(exit 0)——缓存写失败被容忍。
+- `scripts/ci-workflow.spec.ts` 断言每个 Linux lane 都在 `pnpm/action-setup` 之前把 `NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache`(`$GITHUB_ENV` 的 `KEY=VALUE` 行)注入 `$GITHUB_ENV`;位置断言在注入移出首次 pnpm 调用之后时会失败。
+- CI lane:三个必需的 Linux job(默认 hosted,`DSH_CI_FAILOVER_LINUX` 时自托管 `vm-backup`)会在新 env 下跑完整套件;缓存处理回归会表现为 lane 失败。
+
+## 备选方案
+
+### 为什么不彻底禁用编译缓存?
+
+`NODE_DISABLE_COMPILE_CACHE=1` 会立即停止根分区增长,但会放弃每次运行的启动加速,而缓存是 Node 正当有用的特性(由 pnpm 和 TypeScript 显式启用)。重定向在保留收益的同时把成本移出受限分区。
+
+### 为什么不把 `node-compile-cache` 纳入 `dsh-*` 清理?
+
+CI 清理(残留清理改动中新增)针对测试残留;编译缓存是缓存而非残留。每次运行删掉它会丢弃缓存本要提供的加速。重定向是结构性修复:缓存的增长移到为它准备的卷上。
+
+### 为什么不用 job 级 env 或只给门禁 step 设 env?
+
+`runner` 上下文只在 step 级 `env` 可用;job 级 `env` 会求值为空字符串(GitHub contexts-availability),静默让缓存留在根分区。只给门禁 step 设 step 级 env 也只覆盖那一个 step:lane 中更早的每次 pnpm 调用(setup、store 路径探测、安装)仍会写根分区 `/tmp`。在 checkout 与 `pnpm/action-setup` 之间的 step 注入 `$GITHUB_ENV`,使变量在 lane 首次 pnpm 调用之前生效,一个 step 即可覆盖整条 lane。
+
+## 后果
+
+- **买到**:Node 编译缓存不再消耗根分区 inode;该来源的 inode 压力被移除且不损失缓存的启动收益。缓存现在位于数据卷上的 per-runner `_workNN/_temp`。
+- **代价**:缓存在 `runner.temp` 累积,而 runner 不会在 job 之间清空它(早前实测)——但在数据卷(inode 用量约 1%)上无碍。
+- **代价**:没有 `runner.temp` 授权的受限子进程会为其自身的 `node` 调用跳过缓存;这是缓存未命中而非失败,符合 Node 的尽力而为契约。
+- **代价**:改动只涉及 CI 配置;本地开发保持默认 `os.tmpdir()` 位置。

+ 6 - 0
.github/workflows/ci-master.yml

@@ -146,6 +146,12 @@ jobs:
         with:
           fetch-depth: 0
 
+      # Redirect the Node compile cache (enabled by pnpm and TypeScript) off
+      # the root partition's /tmp before the first pnpm call in this lane —
+      # see .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
+      - name: Redirect Node compile cache to runner temp
+        run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"
+
       - uses: pnpm/action-setup@v4
         with:
           dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}

+ 18 - 0
.github/workflows/ci.yml

@@ -56,6 +56,12 @@ jobs:
           fetch-depth: 0
           persist-credentials: false
 
+      # Redirect the Node compile cache (enabled by pnpm and TypeScript) off
+      # the root partition's /tmp before the first pnpm call in this lane —
+      # see .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
+      - name: Redirect Node compile cache to runner temp
+        run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"
+
       - uses: pnpm/action-setup@v4
         with:
           dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
@@ -113,6 +119,12 @@ jobs:
         with:
           persist-credentials: false
 
+      # Redirect the Node compile cache (enabled by pnpm and TypeScript) off
+      # the root partition's /tmp before the first pnpm call in this lane —
+      # see .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
+      - name: Redirect Node compile cache to runner temp
+        run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"
+
       - uses: pnpm/action-setup@v4
         with:
           dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
@@ -224,6 +236,12 @@ jobs:
         with:
           persist-credentials: false
 
+      # Redirect the Node compile cache (enabled by pnpm and TypeScript) off
+      # the root partition's /tmp before the first pnpm call in this lane —
+      # see .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
+      - name: Redirect Node compile cache to runner temp
+        run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"
+
       - uses: pnpm/action-setup@v4
         with:
           dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}

+ 30 - 0
scripts/ci-workflow.spec.ts

@@ -374,6 +374,36 @@ describe('CI workflow', () => {
     }
   })
 
+  it('redirects the Node compile cache to the data-volume runner temp before the first pnpm call', () => {
+    const prWorkflow = loadWorkflow('.github/workflows/ci.yml')
+    const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml')
+    const redirectLanes = [
+      [prWorkflow, 'node-24'],
+      [prWorkflow, 'node-24-coverage'],
+      [prWorkflow, 'node-24-consumers'],
+      [masterWorkflow, 'serial-linux-selfhosted'],
+    ] as const
+    for (const [workflow, jobKey] of redirectLanes) {
+      const job = workflowJob(workflow, jobKey)
+      if (!Array.isArray(job.steps)) throw new TypeError(`${jobKey} must define steps`)
+      const redirectStepIndex = job.steps.findIndex((step): step is Record<string, unknown> & { run: string } => (
+        isRecord(step) && typeof step.run === 'string'
+          && step.run.includes('NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache')
+          && step.run.includes('"$GITHUB_ENV"')
+      ))
+      // Removing this injection would send every pnpm call in the lane (setup,
+      // store-path probe, install, and the gate) back to the root partition's
+      // /tmp; rationale in
+      // .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
+      expect(redirectStepIndex, `${jobKey} must inject NODE_COMPILE_CACHE into GITHUB_ENV`).toBeGreaterThan(-1)
+      const pnpmSetupIndex = job.steps.findIndex((step): step is Record<string, unknown> & { uses: string } => (
+        isRecord(step) && typeof step.uses === 'string' && step.uses.includes('pnpm/action-setup')
+      ))
+      expect(pnpmSetupIndex, `${jobKey} must run pnpm/action-setup`).toBeGreaterThan(-1)
+      expect(redirectStepIndex, `${jobKey} must redirect before pnpm/action-setup runs pnpm`).toBeLessThan(pnpmSetupIndex)
+    }
+  })
+
   it('keeps supported LSP source under native Windows coverage', () => {
     const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')