test(windows): terminate worker fixtures from host
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: ddc66b433874b0a95c4dd30669f1f0d2804cdd3e
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: 45518cd1a83b35bd441c00b5666f61f04e9a0705
|
||||
2026-08-08-native-windows-pull-request-ci.md: 9c6663048683e2a0501622109d4e34c557af2c09
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: 51074b51abe228745af450741cbbdfd6a0fb7927
|
||||
@@ -22,7 +22,7 @@ The standard native lane gives coverage and the top-level gate scheduler one wor
|
||||
|
||||
The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias.
|
||||
|
||||
Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling.
|
||||
Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Worker-death fixtures drive real termination from the host after observing their protocol preconditions instead of calling `process.exit()` inside a nested Windows Worker; this preserves the worker-exit contract without exposing the enclosing Vitest fork to Node's process-wide native exit assertion. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling.
|
||||
|
||||
Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity, attaches listeners, and awaits its main watcher before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Status: implemented
|
||||
|
||||
首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。
|
||||
|
||||
可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。
|
||||
可移植文件系统 fixture(测试前置数据)通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL,只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture;该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。worker 死亡 fixture 会先观察其协议前置条件,再由宿主触发真实终止,而不在嵌套 Windows Worker 中调用 `process.exit()`;这样既保留了 worker 退出约定,也不会让外围 Vitest fork 暴露于 Node 进程级的原生退出断言。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。
|
||||
|
||||
原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill(技能)根与 Cordis HMR(热模块替换)在发现和诊断时保留配置路径;模块 HMR 则使用规范写法作为 Node 加载缓存标识、挂接监听器并在插件启动完成前等待主 watcher 就绪,因此启动后立即发生的编辑不会与初始扫描形成竞态。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。
|
||||
|
||||
|
||||
@@ -1239,22 +1239,18 @@ describe('dsh-workflow-workerthread', () => {
|
||||
await ctx.plugin(WorkerWorkflowEngine, { provider: 'doomed', maxConcurrentAgents: 2 })
|
||||
const runEnds: WorkflowResultInfo[] = []
|
||||
ctx.on('workflow/end', (_info, result) => { runEnds.push(result) })
|
||||
const childStarted = Promise.withResolvers<undefined>()
|
||||
ctx.on('workflow/agent-start', () => { childStarted.resolve(undefined) })
|
||||
const handle = ctx.workflows.start({
|
||||
// The stray child's start RPC reaches the host, then the script kills
|
||||
// its own worker through the documented vm escape — the host must
|
||||
// settle `error` with the exit diagnostics and wind the child down.
|
||||
...scripted(`
|
||||
agent('doomed')
|
||||
const proc = ${ESCAPE}
|
||||
const st = globalThis.constructor.constructor('return setTimeout')()
|
||||
await new Promise(resolve => st(resolve, 200))
|
||||
proc.exit(7)
|
||||
`),
|
||||
...scripted("return await agent('doomed')"),
|
||||
parent: fakeParent(),
|
||||
})
|
||||
const worker = (handle as unknown as { worker: Worker }).worker
|
||||
await childStarted.promise
|
||||
await worker.terminate()
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('exit code 7')
|
||||
expect(result.error).toContain('exit code 1')
|
||||
expect(result.agentsStarted).toBe(1)
|
||||
// A worker death is a stop reason like any other: workflow/end fires
|
||||
// with the error outcome — for a bus observer it is the only obituary.
|
||||
@@ -1306,26 +1302,22 @@ describe('dsh-workflow-workerthread', () => {
|
||||
})
|
||||
ctx.on('workflow/end', () => { order.push('run-end') })
|
||||
const handle = ctx.workflows.start({
|
||||
// Same choreography as the force-settle pairing test, but the worker
|
||||
// DIES (the documented vm escape) instead of being terminated: the
|
||||
// exit path must close slow's pair from the ledger too. The escaped
|
||||
// setTimeout lets the already-posted messages flush before the kill.
|
||||
...scripted(`
|
||||
const p = agent('slow')
|
||||
await agent('fast')
|
||||
const proc = ${ESCAPE}
|
||||
const st = globalThis.constructor.constructor('return setTimeout')()
|
||||
await new Promise(resolve => st(resolve, 150))
|
||||
proc.exit(7)
|
||||
await new Promise(() => {})
|
||||
`),
|
||||
parent,
|
||||
})
|
||||
const worker = (handle as unknown as { worker: Worker }).worker
|
||||
await waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) })
|
||||
const fast = provider.runs.find(run => (run.request.prompt[0] as { text?: string }).text === 'fast')!
|
||||
fast.settle(text('fast done'))
|
||||
await waitFor(() => { expect(ends).toContainEqual({ seq: 2, outcome: 'completed' }) })
|
||||
await worker.terminate()
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('exit code 7')
|
||||
expect(result.error).toContain('exit code 1')
|
||||
expect(ends).toEqual([
|
||||
{ seq: 2, outcome: 'completed' },
|
||||
{ seq: 1, outcome: 'cancelled' },
|
||||
@@ -1340,21 +1332,22 @@ describe('dsh-workflow-workerthread', () => {
|
||||
// guard in post()).
|
||||
const { ctx, parent, provider } = await setup({ disposeDelayMs: 300 })
|
||||
const handle = ctx.workflows.start({
|
||||
// The STRAY child settles instantly, so its wrapper starts the slow
|
||||
// host-side disposal concurrently while the script goes on to kill
|
||||
// its own worker — the ack then resolves into a dead thread.
|
||||
...scripted(`
|
||||
agent('stray, never awaited')
|
||||
const proc = ${ESCAPE}
|
||||
const st = globalThis.constructor.constructor('return setTimeout')()
|
||||
await new Promise(resolve => st(resolve, 150))
|
||||
proc.exit(5)
|
||||
await new Promise(() => {})
|
||||
`),
|
||||
parent,
|
||||
})
|
||||
const worker = (handle as unknown as { worker: Worker }).worker
|
||||
await waitFor(() => {
|
||||
expect(provider.runs).toHaveLength(1)
|
||||
expect(provider.runs[0]!.disposeCalls).toBe(1)
|
||||
expect(provider.runs[0]!.disposed).toBe(false)
|
||||
})
|
||||
await worker.terminate()
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('exit code 5')
|
||||
expect(result.error).toContain('exit code 1')
|
||||
// Result already settled — this is the reap's promptness (bounded
|
||||
// above the mock's fixed 300ms dispose delay, not a cold-start race);
|
||||
// tight explicit bound (see the helper's doc comment).
|
||||
@@ -1366,20 +1359,19 @@ describe('dsh-workflow-workerthread', () => {
|
||||
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } })
|
||||
const handle = ctx.workflows.start({
|
||||
...scripted(`
|
||||
const proc = ${ESCAPE}
|
||||
const st = globalThis.constructor.constructor('return setTimeout')()
|
||||
log('armed')
|
||||
await new Promise(resolve => st(resolve, 400))
|
||||
proc.exit(3)
|
||||
await new Promise(() => {})
|
||||
`),
|
||||
parent,
|
||||
})
|
||||
const worker = (handle as unknown as { worker: Worker }).worker
|
||||
const logs: string[] = []
|
||||
ctx.on('workflow/log', (_info, message) => { logs.push(message) })
|
||||
await waitFor(() => { expect(logs).toContain('armed') })
|
||||
handle.cancel('stop it')
|
||||
// The grace is deliberately huge: only the worker's own death (exit 3,
|
||||
// unreachable by the cancel — the script ignores hooks) settles this.
|
||||
// The grace is deliberately huge: only the host-triggered worker death,
|
||||
// not the cancellation timer, settles this.
|
||||
await worker.terminate()
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(result.error).toContain('stop it')
|
||||
|
||||
Reference in New Issue
Block a user