From bad70fa444b0146ba80c92ec6800fb9e00784aea Mon Sep 17 00:00:00 2001 From: Takoyaki White Date: Wed, 19 Aug 2026 04:38:06 +0800 Subject: [PATCH] fix(directory-picker-native): run win32 dialog worker under plain node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source-plane worker launched through `--import tsx/esm` with a Windows absolute path, which Node's ESM loader misparses as an `e:` scheme URL and rejects with ERR_UNSUPPORTED_ESM_URL_SCHEME before the first IPC message — the UI then only sees "win32 folder dialog worker exited before reporting a result". The worker's modules use only erasable TS syntax, so launch it directly under Node's native type stripping (stable since 22.18, inside the engines range) with no loader chain. Also switch the built/source arm test to `pathname.endsWith('.ts')` so bundler query strings cannot misclassify source modules as built, and pin the spawn args plus a real dialog smoke against the source arm. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- ...08-19-win32-dialog-worker-source-launch.md | 74 +++++++++++++++++++ ...19-win32-dialog-worker-source-launch.zh.md | 63 ++++++++++++++++ .../host/directory-picker-native/package.json | 3 +- .../src/win32-dialog-host.ts | 17 +++-- .../tests/win32-dialog-host.spec.ts | 13 ++++ .../tests/win32-dialog.spec.ts | 5 +- pnpm-lock.yaml | 3 - 7 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-19-win32-dialog-worker-source-launch.zh.md 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: