diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md new file mode 100644 index 0000000000..da5ef0da21 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md @@ -0,0 +1,74 @@ +# Agent Note: Win32 dialog worker source launch drops the tsx bootstrap + +Status: implemented + +English | [中文](2026-08-19-win32-dialog-worker-source-launch.zh.md) + +## Problem + +On Windows, the source-plane folder dialog worker never started: the Web UI +reported `win32 folder dialog worker exited before reporting a result`. The +root cause is the launch vector, not koffi: `spawnDialogWorker`'s source arm +ran `node --import tsx/esm `. With a loader registered via +`--import`, Node's ESM loader resolves the entry as a URL, and a Windows +absolute path (`E:\...`) becomes an `e:` scheme URL, throwing +`ERR_UNSUPPORTED_ESM_URL_SCHEME` before the worker's first IPC message. The +driver then surfaces only its generic exit error. Packaged consumers were +unaffected because they launch the built `worker.cjs` under plain node; the +bug hit every source launch (`pnpm dsh web`) on Windows. + +CI missed it for a second, compounding reason: the built/source arm choice +tested `import.meta.url.endsWith('.ts')`, and under Vitest/Vite the URL carries +a query string (`?v=...`), so the win32 smoke test silently exercised the built +arm — never the broken source launch. + +## Decision + +Run the source worker under plain node with native type stripping, no tsx +bootstrap: + +```ts +spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], ...) +``` + +This is safe because the worker's dependency chain (worker, bindings, logic) +uses only erasable TypeScript syntax — no parameter properties, decorators, or +value namespaces — unlike the CLI source graph that keeps the tsx ESM hook. +Native type stripping is stable since Node 22.18, inside the engines range +(`^22.19.0 || >=24.0.0`). The built arm (`worker.cjs` under plain node) is +unchanged. + +The arm choice now reads `new URL(import.meta.url).pathname.endsWith('.ts')` so +bundler query strings cannot misclassify source modules as built. This makes +the win32 smoke test exercise the real source launch, and the new +`win32-dialog-host.spec.ts` case pins that the source arm passes the worker +path positionally with no `--import` flag. + +## Alternatives considered + +**Pass the worker as a `file://` URL instead of a path.** Rejected: tsx's +tsconfig-paths hook mangles `file://` URLs into `\file:\` +(`ERR_MODULE_NOT_FOUND`); keeping any tsx involvement leaves a fragile launch. + +**Probe koffi availability and fall back to pure-Node dialogs.** Out of scope: +dshcode pins koffi 3.1.1, which predates the broken 3.1.3/3.1.4 win32-x64 +prebuilds, so the worker's koffi usage is not the failure on this codebase; the +worker itself crashed before koffi ever loaded. + +## Consequences + +- Windows source launches (`pnpm dsh web`) open the folder dialog again; the + failure mode (`e:` scheme URL) is gone with the loader chain. +- No functional change for packaged hosts or POSIX: they already ran the worker + under plain node. +- The win32 smoke test now covers the source arm end to end; a regression to a + loader-bootstrapped launch is caught by both the spawn-args pin and the real + dialog smoke. + +## Verification + +`packages/host/directory-picker-native/tests/win32-dialog.spec.ts` opens and +abort-closes a real dialog through the source launch. The new +`win32-dialog-host.spec.ts` case asserts the positional worker path with no +`--import`. Full package suite: 48 passed, 1 skipped (the win32-skipped +built-worker e2e). diff --git a/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md new file mode 100644 index 0000000000..f74012d482 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md @@ -0,0 +1,63 @@ +# Agent Note: Win32 对话框 worker 源码启动去掉 tsx 引导 + +Status: implemented + +[English](2026-08-19-win32-dialog-worker-source-launch.md) | 中文 + +## 问题 + +在 Windows 上,源码层面的文件夹对话框 worker 从未成功启动:Web UI 报 +`win32 folder dialog worker exited before reporting a result`。根因在启动方式而非 +koffi:`spawnDialogWorker` 的源码分支以 `node --import tsx/esm <绝对路径 .ts>` +运行。通过 `--import` 注册 loader 后,Node 的 ESM loader 会把入口当作 URL +解析,Windows 绝对路径(`E:\...`)变成 `e:` 协议 URL,在 worker 发出第一条 +IPC 消息之前就抛出 `ERR_UNSUPPORTED_ESM_URL_SCHEME`。驱动端于是只上报笼统的 +退出错误。打包用户不受影响,因为他们以纯 node 启动构建产物 `worker.cjs`; +该 bug 命中 Windows 上每一次源码启动(`pnpm dsh web`)。 + +CI 漏掉它还有第二个叠加原因:built/源码分支选择用的是 +`import.meta.url.endsWith('.ts')`,而在 Vitest/Vite 下 URL 带查询串(`?v=...`), +win32 冒烟测试静默地走了 built 分支——从未覆盖坏掉的源码启动。 + +## 决策 + +源码 worker 以纯 node + 原生类型剥离运行,不再经过 tsx 引导: + +```ts +spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], ...) +``` + +这是安全的,因为 worker 的依赖链(worker、bindings、logic)只用可擦除的 +TypeScript 语法——没有参数属性、装饰器或值命名空间——不像保留 tsx ESM +hook 的 CLI 源码图。原生类型剥离自 Node 22.18 起稳定,落在 engines 范围 +(`^22.19.0 || >=24.0.0`)内。built 分支(纯 node 跑 `worker.cjs`)不变。 + +分支选择改为 `new URL(import.meta.url).pathname.endsWith('.ts')`,bundler +查询串无法再把源码模块误判为 built 产物。这让 win32 冒烟测试真正覆盖源码 +启动;新增的 `win32-dialog-host.spec.ts` 用例固定源码分支以位置参数传入 +worker 路径且不带 `--import`。 + +## 考虑过的备选方案 + +**把 worker 作为 `file://` URL 而非路径传入。**拒绝:tsx 的 tsconfig-paths +钩子会把 `file://` URL 破坏成 `\file:\` +(`ERR_MODULE_NOT_FOUND`);只要还沾 tsx,启动就是脆弱的。 + +**探测 koffi 可用性并回退到纯 Node 对话框。**超出范围:dshcode 固定 +koffi 3.1.1,早于损坏的 3.1.3/3.1.4 win32-x64 预编译,worker 的 koffi 用法 +在本代码库并非故障点;worker 在 koffi 加载之前就已崩溃。 + +## 影响 + +- Windows 源码启动(`pnpm dsh web`)重新能弹出文件夹对话框;`e:` 协议 + URL 这一失败模式随 loader 链一起消失。 +- 打包宿主与 POSIX 无功能变化:它们本来就在纯 node 下运行 worker。 +- win32 冒烟测试现在端到端覆盖源码分支;回归到 loader 引导启动会同时被 + spawn 参数固定与真实对话框冒烟测试抓住。 + +## 验证 + +`packages/host/directory-picker-native/tests/win32-dialog.spec.ts` 通过源码 +启动真实打开并中止关闭对话框。新增的 `win32-dialog-host.spec.ts` 用例断言 +位置参数的 worker 路径且不带 `--import`。包内完整测试套件:48 通过, +1 跳过(win32 跳过的 built-worker e2e)。 diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index dcbaf930ea..68f2beb2d4 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -47,7 +47,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "tsx": "^4.19.2" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts index d4277b4aad..f238dd9376 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -14,9 +14,10 @@ import type { Win32DialogWorkerData } from './win32-dialog-worker.ts' /** * Spawn the dialog child process. Built consumers launch the bundled CJS * entry next to this module under plain node; unbuilt (source) consumers - * bootstrap tsx first, mirroring the dsh CLI's source launch. The dialog is - * the child's first window, so Windows activates it without a foreground - * call. + * run the worker directly under Node's native type stripping (stable since + * 22.18, covered by the engines range) — the worker's three modules use + * only erasable TS syntax, so no tsx bootstrap is needed. The dialog is the + * child's first window, so Windows activates it without a foreground call. * @param data - the child payload (dialog title). * @returns the spawned child process. */ @@ -25,11 +26,17 @@ export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType`: Node's ESM loader accepts a file path here (no + // tsx hook in front of it), so the absolute Windows path cannot be misparsed + // as an `e:` scheme URL — the failure mode when tsx's loader chain is active. + return spawn(process.execPath, [fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true }) } export { closeThreadWindows } from './win32-dialog-bindings.ts' diff --git a/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts index c8f785f6c1..aa169772ce 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-host.spec.ts @@ -1,4 +1,5 @@ import type { ChildProcess, SpawnOptions } from 'node:child_process' +import { fileURLToPath } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type SpawnWorker = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess @@ -39,4 +40,16 @@ describe('spawnDialogWorker', () => { }) expect(process.env.ELECTRON_RUN_AS_NODE).toBe('') }) + + // Regression guard for the source plane: the worker must launch under plain + // node with native type stripping (no tsx bootstrap), so the absolute Windows + // path is a positional file argument, not a URL through a loader chain. + it('launches the source worker under plain node with no loader flags', () => { + spawnDialogWorker({ title: 'Source-plane guard' }) + + expect(spawnMock).toHaveBeenCalledOnce() + const args = spawnMock.mock.calls[0]?.[1] + expect(args).toEqual([fileURLToPath(new URL('../src/win32-dialog-worker.ts', import.meta.url))]) + expect(args).not.toContain('--import') + }) }) diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts index 8e7d6951b8..99d4ed932a 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -145,8 +145,9 @@ describe('pickWin32Directory', () => { expect(close.mock.calls.length).toBeGreaterThan(10) }) - // POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped - // worker spawns, loads koffi, fails to load ole32.dll, and reports the error. + // POSIX hosts exercise the REAL default plumbing end to end: the source + // worker spawns under plain node with native type stripping (no tsx + // bootstrap), loads koffi, fails to load ole32.dll, and reports the error. it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => { await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed') }, 30_000) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2860dc4eb0..367b2fa516 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5198,9 +5198,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - tsx: - specifier: ^4.19.2 - version: 4.22.4 packages/host/frontend-static: dependencies: