Parcourir la source

test(python): make stray fragment coverage deterministic

ZiyaZhang il y a 2 semaines
Parent
commit
7bce0c26f5

+ 2 - 2
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.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/testing/2026-09-10-hosted-image-test-assumptions.md
-2026-09-10-hosted-image-test-assumptions.md: e6cd1ef37dce318920d75545f816006a98989076
-2026-09-10-hosted-image-test-assumptions.zh.md: e60910aebdc2952a2bfb6ab25963a973ea41ac87
+2026-09-10-hosted-image-test-assumptions.md: 0c894e89f910db9b952f79b985ef1c04e4963ef0
+2026-09-10-hosted-image-test-assumptions.zh.md: 3acd759b405109c82ef890f41ee90780b3d3080b

+ 6 - 0
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md

@@ -8,6 +8,8 @@ English | [中文](2026-09-10-hosted-image-test-assumptions.zh.md)
 
 The [failover leg](../process/2026-09-09-blacksmith-failover-leg.md) runs this suite on pools this repository does not own — Blacksmith's ephemeral images, and the in-house `vm-backup` and `dsh-win-ci` standbys. On the hosted image the coverage lanes failed on host properties their cases never named: whether the host offered a usable user-systemd scope decided which containment a mocked PTY exit raced; the wall-clock grace a managed scope needed before it could take a `SIGKILL` was below what a loaded image provides; a starved reader coalesced writes the illegal-UTF-8 residual cases assumed arrived as separate chunks; and a Windows Server image refuses `CoCreateInstance(CLSID_FileOpenDialog)` outright.
 
+The stray-fragment sealing case also assumed `os.sched_yield()` would produce at least 1024 stdout data events. In [run 34474886667](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34474886667), its assertions passed without reaching the seal branch, so Linux coverage failed.
+
 ## Decision
 
 Every case names the host property it depends on, so the same revision reports the same verdict on the in-house pool and on a hosted image.
@@ -20,6 +22,8 @@ Terminal cases that drive a mocked PTY exit pin the containment they need (`inte
 
 Both illegal-UTF-8 residual cases in `packages/experimental/code-runtime-python/tests/runtime.spec.ts` pace their writes with `time.sleep(0.001)`: `os.sched_yield()` lets a loaded reader coalesce the writes into one chunk, and the coalesced chunk is what the wrapped `Buffer.concat` measures (the hosted image measured 2563 against the 2048 bound with a correct implementation). Their payloads stay above that bound — 3200 bytes for the `0xFF` case and 1100 `ED A0 80` sequences, 3300 raw bytes, for the CESU-8 case, past the 3072-byte budget a raw-byte undercount reaches — so the undercount still flushes above 2048. Each carries a 20s case budget for the paced writes plus the interpreter start.
 
+The stray-fragment case keeps the real CPython child but splits that child's stdout data events into single-byte buffers. This fixture controls the fragment count regardless of kernel coalescing and restores its opt-in flag and `Buffer.concat` wrapper in `finally`. It asserts the complete 60000-byte log, a maximum concat input count of 1024, and the existing 256 KiB copy budget. Removing the seal fails the input-count assertion; repeatedly merging the sealed prefix fails the copy budget.
+
 The Linux coverage lane grants `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the Windows coverage lane, because the disposal cases in `subprocess-local` and `bash-sandbox` exceed the 5000ms default when the lane's partitions, workers, and sibling gates share one host.
 
 The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)` through PowerShell instead of gating on `process.platform`. An image that answers `CLASS_E_CLASSNOTAVAILABLE` (0x80040111) runs the clean-rejection case and skips the real-dialog case, so `win32-dialog.ts` keeps its file coverage without a host that can open a dialog. Every exception from that activation reads as refusal, so a host failing the probe for another reason only loses the real-dialog case; a probe that cannot run at all keeps the win32 assumption.
@@ -34,6 +38,8 @@ The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)`
 
 **Cutting the illegal-UTF-8 payloads to keep the cases fast.** Rejected: below the 2048 bound the assertion can no longer fail for the undercount it names, which leaves the regression unguarded.
 
+**Pacing the fragment-count case with sleeps.** Rejected: the operating system still owns pipe-read boundaries, so sleeps cannot guarantee the 1024 fragments this branch requires. Controlling the data-event boundary preserves the real subprocess and gives the branch a deterministic stimulus.
+
 ## Consequences
 
 The suite's verdict no longer depends on which pool served the lane, at the cost of fixtures pinned to one containment choice: the `linux-scope` and win32-job paths keep their own dedicated cases instead of being reached through these ones. The ACP dispose case costs about 10s of wall clock per run and each residual case about 3.5s, all deterministic rather than host-paced. Hosted-image evidence: [run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) failed on these cases, [run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) is green with this diff plus the 90000ms lane budget, and `windows node 24 / coverage` is green on five consecutive hosted runs, where the probe reports the refusal (`clsid-probe=refused`).

+ 6 - 0
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md

@@ -8,6 +8,8 @@ Status: implemented
 
 [故障切换支路](../process/2026-09-09-blacksmith-failover-leg.zh.md)会把这套测试跑在本仓库不拥有的池上——Blacksmith 的临时镜像,以及自有的 `vm-backup` 与 `dsh-win-ci` 备用池。在托管镜像上,coverage 各通道的失败来自用例从未点明的宿主属性:宿主是否提供可用的用户级 systemd scope,决定了被 mock 的 PTY 退出会与哪种 containment 竞争;托管 scope 接受 `SIGKILL` 之前所需的墙钟宽限,低于负载镜像实际提供的量;读端被抢占时会把非法 UTF-8 残余用例假定为独立分块的写入合并成一个分块;以及 Windows Server 镜像直接拒绝 `CoCreateInstance(CLSID_FileOpenDialog)`。
 
+杂散输出的分片封块用例还假定 `os.sched_yield()` 会产生至少 1024 次 stdout data 事件。在 [run 34474886667](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34474886667) 中,该用例的断言全部通过,却未进入封块分支,导致 Linux coverage 失败。
+
 ## 决策
 
 每个用例都点明它依赖的宿主属性,因此同一份修订在自有池与托管镜像上给出同样的结论。
@@ -20,6 +22,8 @@ Status: implemented
 
 `packages/experimental/code-runtime-python/tests/runtime.spec.ts` 的两个非法 UTF-8 残余用例都用 `time.sleep(0.001)` 控制写入节奏:`os.sched_yield()` 会让被抢占的读端把多次写入合并成一个分块,而被包裹的 `Buffer.concat` 测量的正是该分块(在正确实现下,托管镜像测得 2563,超过了 2048 的界)。两个用例的载荷都保持在该界之上——`0xFF` 用例 3200 字节,CESU-8 用例 1100 个 `ED A0 80` 序列(3300 原始字节,超过按原始字节计费会触及的 3072 字节预算)——因此少计仍然会在 2048 之上触发 flush。两者各自带有 20s 的用例预算,容纳带节奏的写入与解释器启动。
 
+分片封块用例保留真实的 CPython 子进程,但将该子进程的 stdout data 事件拆成单字节缓冲区。这个 fixture 控制分片数量,不再取决于内核是否合并写入,并在 `finally` 中恢复启用标志与 `Buffer.concat` 包装。它断言完整的 60000 字节日志、最大 concat 输入数量为 1024,以及原有的 256 KiB 复制预算。移除封块会触发输入数量断言;反复合并已封块的前缀会超出复制预算。
+
 Linux coverage 通道授予 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 Windows coverage 通道一致,因为当该通道的分区、worker 与同级门禁共用一个宿主时,`subprocess-local` 与 `bash-sandbox` 的处置用例会超过 5000ms 默认值。
 
 Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateInstance(CLSID_FileOpenDialog)`,而不再按 `process.platform` 分流。回答 `CLASS_E_CLASSNOTAVAILABLE`(0x80040111)的镜像会跑干净的拒绝用例并跳过真实对话框用例,因此 `win32-dialog.ts` 在没有可开对话框的宿主上仍保有文件覆盖率。该激活过程抛出的任何异常都按拒绝解读,因此因其它原因探测失败的宿主只会失去真实对话框用例;完全无法运行的探测则保留 win32 假设。
@@ -34,6 +38,8 @@ Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateIn
 
 **削减非法 UTF-8 的载荷以让用例更快。** 否决:低于 2048 的界之后,断言再也无法为它所点名的少计而失败,等于让该回归失去守护。
 
+**用 sleep 控制分片数量用例的写入节奏。** 否决:管道读取的边界仍由操作系统决定,因此 sleep 无法保证该分支需要的 1024 个分片。控制 data 事件边界可以保留真实子进程,并确定性地触发该分支。
+
 ## 后果
 
 套件的结论不再取决于哪个池服务了这条通道,代价是被钉在某一 containment 选择上的 fixture:`linux-scope` 与 win32-job 两条路径仍由各自的专用用例覆盖,而不是经由这些用例抵达。ACP 处置用例每次运行约 10s 墙钟,每个残余用例约 3.5s,且都是确定成本而非随宿主浮动。托管镜像证据:[run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) 在这些用例上失败,[run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) 在本改动加 90000ms 通道预算下转绿,`windows node 24 / coverage` 在连续五次托管运行中为绿,其中探针报告拒绝(`clsid-probe=refused`)。

+ 38 - 33
packages/experimental/code-runtime-python/tests/runtime.spec.ts

@@ -28,7 +28,7 @@ import type { CodeBindingFunction, CodeJsonValue, CodeRunResult } from '@deepsee
  * records the same race and solves it with argv-based identity; recording the
  * mkdtempSync results is the fs-mock equivalent.
  */
-const { failNextCopyOf, stagedDirs, tempDirs, tempFiles } = vi.hoisted(() => ({
+const { failNextCopyOf, stagedDirs, tempDirs, tempFiles, splitStdout } = vi.hoisted(() => ({
   failNextCopyOf: { value: undefined as string | undefined },
   stagedDirs: [] as string[],
   // Test-created temp dirs/files, registered by the helpers below and removed
@@ -38,7 +38,28 @@ const { failNextCopyOf, stagedDirs, tempDirs, tempFiles } = vi.hoisted(() => ({
   // tests themselves build).
   tempDirs: [] as string[],
   tempFiles: [] as string[],
+  splitStdout: { value: false },
 }))
+vi.mock('node:child_process', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:child_process')>()
+  return {
+    ...actual,
+    spawn(...args: Parameters<typeof actual.spawn>) {
+      const child = actual.spawn(...args)
+      if (splitStdout.value && child.stdout !== null) {
+        // The kernel may coalesce writes; this case owns the data-event boundaries.
+        const emit = child.stdout.emit.bind(child.stdout)
+        child.stdout.emit = (event: string | symbol, ...values: unknown[]): boolean => {
+          const chunk = values[0]
+          if (event !== 'data' || !Buffer.isBuffer(chunk)) return emit(event, ...values)
+          for (let index = 0; index < chunk.length; index += 1) emit('data', chunk.subarray(index, index + 1))
+          return true
+        }
+      }
+      return child
+    },
+  }
+})
 vi.mock('node:fs', async (importOriginal) => {
   const actual = await importOriginal<typeof import('node:fs')>()
   return {
@@ -59,10 +80,9 @@ vi.mock('node:fs', async (importOriginal) => {
 })
 
 /**
- * Integration suite over REAL python3 subprocesses (no subprocess mocks — it is
- * cheap and local, per docs/testing.md's real-over-mock policy; the only mock is
- * `node:fs.copyFileSync` for the staging-failure cases). Each test builds a fresh
- * runtime so budgets can be tuned per case.
+ * Integration suite over real python3 subprocesses. The staging-failure cases
+ * mock `copyFileSync`; the fragment-count case controls stdout data-event boundaries.
+ * Each test builds a fresh runtime so budgets can be tuned per case.
  */
 async function setup(config: Config = {}) {
   const ctx = new Context()
@@ -93,6 +113,7 @@ function makeTempDirSync(prefix: string): string {
 // Remove every fixture this file created, so repeated runs do not accumulate
 // `dsh-*` directories and wrappers in the shared tmpdir.
 afterEach(() => {
+  splitStdout.value = false
   for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
   for (const file of tempFiles.splice(0)) rmSync(file, { force: true })
 })
@@ -4760,54 +4781,38 @@ describe('PythonCodeRuntime — hostile peer', () => {
   }, 40_000)
 
   it('seals trickled stray fragments into blocks without recopying the sealed prefix', async () => {
-    // The stray-capture buffer has the same object-overhead exposure as the fd-3
-    // reader above: each newline-free `data` chunk is its own Buffer, so a
-    // program pacing single-byte `os.write(1, ...)` accumulates one object per
-    // write, which the serialized-cost counter cannot see. Past MAX_PENDING_CHUNKS
-    // the fragments seal into a finished block; re-merging the whole residual at
-    // each threshold instead would copy the sealed prefix again and again, making
-    // the cumulative copy volume quadratic. `Buffer.concat` is wrapped to measure
-    // that volume — both shapes admit the same final log entry, so the copy total
-    // is the discriminator. maxLogBytes is raised so the trickle is retained,
-    // not truncated, which is what forces the fragments to accumulate and seal.
-    const realConcat = Buffer.concat.bind(Buffer)
+    // One-byte data events force the fragment-count limit independently of pipe
+    // coalescing. The input-list bound rejects missing seals; copied bytes reject
+    // repeatedly merging the sealed prefix. Both regressions preserve log text.
+    const realConcat = Buffer.concat
+    const previousSplitStdout = splitStdout.value
     let copied = 0
+    let maxParts = 0
     Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer<ArrayBuffer> => {
       for (const part of list) copied += part.length
+      maxParts = Math.max(maxParts, list.length)
       return realConcat(list, total)
     }
     let result: CodeRunResult
     try {
+      splitStdout.value = true
       const { runtime } = await setup({ maxLogBytes: 200_000, maxWallMs: 30_000 })
       result = await runtime.run({
         program: [
           'import os',
-          'for _ in range(60000):',
-          '    os.write(1, b"x")',
-          '    os.sched_yield()',
-          'os.write(1, b"\\n")',
+          'os.write(1, b"x" * 60000 + b"\\n")',
           'return "done"',
         ].join('\n'),
         bindings: [],
       })
     } finally {
       Buffer.concat = realConcat
+      splitStdout.value = previousSplitStdout
     }
     expect(result.error).toBeUndefined()
     expect(result.value).toBe('done')
-    // The trickle coalesces into one log line (no interior newlines). Its exact
-    // length depends on pipe coalescing, but it is one entry and non-empty.
-    expect(result.logs.length).toBe(1)
-    expect((result.logs[0] as string).length).toBeGreaterThan(0)
-    // Sealing appends a finished block rather than re-merging everything held, so
-    // each byte is copied a bounded number of times. Re-merging the whole
-    // residual at every seal threshold instead makes the cumulative copy volume
-    // quadratic. Measured like the fd-3 sibling above rather than reasoned about:
-    // this sealed shape copies about 120 KB for 60000 trickled bytes, the
-    // re-merging shape about 538 KB (the stray path adds one whole-residual
-    // concat at the terminating newline over the fd-3 sibling's 119/540, landing
-    // at the same order). 256 KiB sits between them with margin on both sides, so
-    // reverting the seal to a re-merge turns this assertion red.
+    expect(result.logs).toEqual(['x'.repeat(60000)])
+    expect(maxParts).toBe(1024)
     expect(copied).toBeLessThan(256 * 1024)
   }, 40_000)