test(windows): make native coverage fixtures portable
This commit is contained in:
+2
-2
@@ -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-08-native-windows-pull-request-ci.md
|
||||
2026-08-08-native-windows-pull-request-ci.md: e90bfad28298fb578d080e8bda5b9f029b419e7e
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: eeec48e339998347b8ea0c51d254170e270b31f3
|
||||
2026-08-08-native-windows-pull-request-ci.md: c824111cbd3d5f4126b0a383c99d0600b3e33ecf
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: 76e719d073669e4af551224ebf04bbf9284126a2
|
||||
@@ -20,6 +20,8 @@ The first native run exposed two failures hidden by the compatibility lane. Docu
|
||||
|
||||
The coverage follow-up then exercised the serial heavy suites on the native host and removed their remaining path-spelling assumptions. Filesystem identity assertions compare native real paths instead of Git's slash convention with Node's temporary-directory spelling; quoted diagnostics are matched in their escaped form; TypeScript-owned file names are compared after separator normalization; and Typert passes a slash-normalized config name consistently across TypeScript's read and parse boundary so malformed Windows configs produce the owned analysis error instead of a compiler debug failure. The Oxlint subprocess contract also uses the same explicit twenty-second budget as its neighboring executable probes. These are portability repairs to supported tests and parser behavior, not platform skips or coverage exclusions.
|
||||
|
||||
The blocking coverage gate exposed two more fixture contracts that had never run on the native lane. The JSONL materialization fault now asserts the structured filesystem error code because the Windows durable-directory implementation owns an `ENOTDIR` code without copying it into human prose. The ACP teardown ladder now uses Node children instead of assuming a POSIX shell and asserts Windows' force-termination outcome rather than POSIX signal names; POSIX still proves the `SIGTERM` and `SIGKILL` tiers. Those suites load native bindings or own real process trees, so the Windows thread pool runs them in the existing fork-isolated project while still merging their coverage into the same per-file threshold.
|
||||
|
||||
The next exact-head run exposed one remaining observational built-bin failure: its lifecycle fixtures used `process.kill()` or `subprocess.kill()` to send `SIGTERM`, which unconditionally terminates a Windows target instead of delivering the registered process event for graceful disposal. POSIX acceptance still sends the real signal. On Windows the fixture requests that same registered event from inside the child, directly for a self-terminating probe and through a marker for parent-controlled lifecycle cases, so the assembled shutdown and disposal path remains covered without asserting an operating-system facility that does not exist. That acceptance then exposed the underlying early-shutdown race: a signal could dispose the root after boot returned while fallback HMR watchers were mounting, and the resulting inactive-service error escaped as a boot failure. Post-boot setup now admits work only while the authoritative root fiber is active and contains a concurrent setup error only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud.
|
||||
|
||||
Wine-only infrastructure is absent from the supported workflow: there is no apt-cache producer, compatibility script, hoisted snapshot install, Windows Node download, or local `check:windows-wine` command. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) remains historical evidence for its measured latency and fidelity trade-offs, not a current execution path.
|
||||
|
||||
@@ -20,7 +20,9 @@ Status: implemented
|
||||
|
||||
随后,覆盖率后续工作在原生宿主上运行了串行的高负载测试套件,并移除了其中残留的路径拼写假设。文件系统标识断言改为比较原生真实路径,不再直接比较遵循 Git 斜杠约定的路径与 Node 的临时目录拼写;带引号的诊断文本按 JSON 转义后的形式匹配;TypeScript 提供的文件名在统一分隔符后再比较;Typert 则让经过斜杠归一化的配置名称一致贯穿 TypeScript 的读取与解析边界,使格式错误的 Windows 配置产生 Typert 自有的分析错误,而非编译器的调试故障。Oxlint 子进程契约也采用与相邻可执行文件探测相同的显式 20 秒预算。这些都是针对受支持测试与解析器行为的可移植性修复,不是按平台跳过测试或设置覆盖率排除项。
|
||||
|
||||
下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture(测试前置数据)通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。
|
||||
这项阻断覆盖率门禁又暴露出两项从未在原生通道上运行过的 fixture 契约。JSONL 实体化故障场景现在断言结构化文件系统错误码,因为 Windows 的持久目录实现拥有 `ENOTDIR` 错误码,却不会将其复制进人类可读文本。ACP(Agent Client Protocol)拆卸阶梯现在使用 Node 子进程,不再假定 POSIX shell,并断言 Windows 的强制终止结果而非 POSIX 信号名称;POSIX 仍会证明 `SIGTERM` 与 `SIGKILL` 两级。这些套件会加载原生绑定或拥有真实进程树,因此 Windows 线程池会让它们在现有的 fork 隔离项目中运行,同时仍将这些套件的覆盖率汇入同一项逐文件阈值。
|
||||
|
||||
下一次分支头精确运行暴露出观测项中剩余的一项 built-bin 故障:其生命周期 fixture 通过 `process.kill()` 或 `subprocess.kill()` 发送 `SIGTERM`;在 Windows 上,这种调用会无条件终止目标进程,而不会交付为优雅释放所注册的进程事件。POSIX 验收仍发送真实信号。在 Windows 上,fixture 改为从子进程内部请求同一个已注册事件:自终止探测直接请求,由父进程控制的生命周期场景则通过标记请求;因此,完整组装后的关闭与释放路径仍得到覆盖,也无需断言操作系统提供了本不存在的信号机制。该项验收随即暴露出底层的提前关闭竞态:boot 返回后,回退 HMR watcher 仍在挂载,此时信号可能对根 fiber 执行 dispose(资源释放),由此产生的服务未激活错误会逸出并被报告为 boot 失败。boot 后 setup 现在只会在权威根 fiber 仍处于活跃状态时接纳工作;只有当本次调用所记录的信号已取得关闭流程所有权时,才会隔离并发 setup 错误,无关的 HMR 故障仍会响亮失败。
|
||||
|
||||
受支持的工作流不含 Wine 专属基础设施:不存在 apt 缓存生产者、兼容性脚本、对仓库快照执行的 hoisted 安装、Windows Node 下载或本地 `check:windows-wine` 命令。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)仍作为其实测延迟与保真度取舍的历史证据,而非当前执行路径。
|
||||
|
||||
|
||||
@@ -62,6 +62,17 @@ async function expectFlushError(promise: Promise<unknown>, message: RegExp): Pro
|
||||
throw new Error('expected flush to reject')
|
||||
}
|
||||
|
||||
async function expectFlushCode(promise: Promise<unknown>, codes: readonly string[]): Promise<void> {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(codes).toContain((error as NodeJS.ErrnoException).code)
|
||||
return
|
||||
}
|
||||
throw new Error('expected flush to reject')
|
||||
}
|
||||
|
||||
async function freshRoot(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
|
||||
dirs.push(dir)
|
||||
@@ -1343,7 +1354,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
|
||||
appendClosedTurn(s)
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(ctx2.sessions.flush(s)).rejects.toThrow(/EEXIST|ENOTDIR/)
|
||||
await expectFlushCode(ctx2.sessions.flush(s), ['EEXIST', 'ENOTDIR'])
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
||||
import * as acp from '../src/index.ts'
|
||||
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
@@ -108,14 +109,19 @@ describe('child env layering (through the subprocess seam)', () => {
|
||||
// The spec.env layer merges after the seam's scrub, so the child's own
|
||||
// explicitly-forwarded key survives while ambient credentials do not.
|
||||
const running = spawnSubprocess({
|
||||
argv: ['bash', '-c', 'echo "[${ACP_TEST_AMBIENT_SECRET_TOKEN:-absent}|$DEEPSEEK_API_KEY]"'],
|
||||
argv: [
|
||||
process.execPath,
|
||||
'--input-type=module',
|
||||
'--eval',
|
||||
'process.stdout.write(JSON.stringify([process.env.ACP_TEST_AMBIENT_SECRET_TOKEN ?? "absent", process.env.DEEPSEEK_API_KEY]))',
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
||||
graceMs: 1000,
|
||||
env: { DEEPSEEK_API_KEY: 'explicit' },
|
||||
})
|
||||
await running.done
|
||||
expect(running.collected.stdout!.readFrom(0).text.trim()).toBe('[absent|explicit]')
|
||||
expect(running.collected.stdout!.readFrom(0).text).toBe('["absent","explicit"]')
|
||||
} finally {
|
||||
delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN
|
||||
}
|
||||
@@ -139,42 +145,50 @@ describe('child env layering (through the subprocess seam)', () => {
|
||||
})
|
||||
|
||||
describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', () => {
|
||||
const bash = (command: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({
|
||||
argv: ['bash', '-c', command],
|
||||
const node = (source: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({
|
||||
argv: [process.execPath, '--input-type=module', '--eval', source],
|
||||
cwd: process.cwd(),
|
||||
stdio: { stdin, stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
||||
graceMs: 200,
|
||||
})
|
||||
const expectHostTermination = (outcome: SubprocessOutcome, posixSignal: NodeJS.Signals): void => {
|
||||
if (process.platform === 'win32') {
|
||||
expect(outcome.signal).toBeNull()
|
||||
expect(outcome.exitCode).not.toBe(0)
|
||||
} else {
|
||||
expect(outcome.signal).toBe(posixSignal)
|
||||
}
|
||||
}
|
||||
|
||||
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
|
||||
const child = bash('read -r line; exit 0')
|
||||
const child = node('process.stdin.resume(); process.stdin.on("end", () => process.exit(0))')
|
||||
await disposeAcpChild(child, 5_000)
|
||||
const outcome = await child.done
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
expect(outcome.signal).toBeNull()
|
||||
})
|
||||
|
||||
it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => {
|
||||
const child = bash('sleep 60')
|
||||
it('tier 2: an EOF-deaf child reaches the host terminate outcome', async () => {
|
||||
const child = node('setInterval(() => {}, 60_000)')
|
||||
await disposeAcpChild(child, 100)
|
||||
const outcome = await child.done
|
||||
expect(outcome.signal).toBe('SIGTERM')
|
||||
expectHostTermination(outcome, 'SIGTERM')
|
||||
})
|
||||
|
||||
it('tier 3: a TERM-trapping child dies by the escalation SIGKILL', async () => {
|
||||
const child = bash("trap '' TERM; echo armed; sleep 60", 'ignore')
|
||||
it('tier 3: a TERM-trapping child reaches the host force-termination outcome', async () => {
|
||||
const child = node('process.on("SIGTERM", () => {}); process.stdout.write("armed\\n"); setInterval(() => {}, 60_000)', 'ignore')
|
||||
// Wait for the trap to arm so SIGTERM cannot race the default handler.
|
||||
while (!child.collected.stdout!.readFrom(0).text.includes('armed')) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
await disposeAcpChild(child, 50)
|
||||
const outcome = await child.done
|
||||
expect(outcome.signal).toBe('SIGKILL')
|
||||
expectHostTermination(outcome, 'SIGKILL')
|
||||
})
|
||||
|
||||
it('observes a spawn-level rejection and returns without a process to reap', async () => {
|
||||
const child = spawnSubprocess({
|
||||
argv: ['bash', '-c', 'true'],
|
||||
argv: [process.execPath, '--input-type=module', '--eval', ''],
|
||||
cwd: '/nonexistent-dir-dsh-acp-ladder-test',
|
||||
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
||||
graceMs: 200,
|
||||
|
||||
@@ -70,6 +70,8 @@ const coverageExemptExcludes = coverageExemptRaw === '1'
|
||||
// that worker threads cannot isolate reliably under aggregate gate contention.
|
||||
// Keep the narrow exception in forks while the rest of the inventory avoids per-file processes.
|
||||
const processBoundTests = [
|
||||
'packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts',
|
||||
'packages/subagent/subagent-acp/tests/subagent-acp.spec.ts',
|
||||
'packages/subprocess/subprocess-local/tests/spawn.spec.ts',
|
||||
'packages/context/time-context/tests/time-context.spec.ts',
|
||||
'packages/llm/llm-pi-ai/tests/adapter.spec.ts',
|
||||
|
||||
Reference in New Issue
Block a user