From 681f7c1315026c70bf0cedca67ea1ba861466871 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:41:38 +0800 Subject: [PATCH] test(windows): terminate worker fixtures from host --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- .../tests/workflow-workerthread.spec.ts | 60 ++++++++----------- 4 files changed, 30 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 47608756de..62467124e2 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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-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 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index ddc66b4338..9c66630486 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -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. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 45518cd1a8..51074b51ab 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -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 强制执行该边界。 diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 79aad3af42..de190349a4 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -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() + 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')