Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <absolute .ts path>`. 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 `<cwd>\file:\<path>`
(`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).
Original file line number Diff line number Diff line change
@@ -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 破坏成 `<cwd>\file:\<path>`
(`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)。
3 changes: 1 addition & 2 deletions packages/host/directory-picker-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"tsx": "^4.19.2"
"@deepseek-ai/cordis": "workspace:^"
}
}
17 changes: 12 additions & 5 deletions packages/host/directory-picker-native/src/win32-dialog-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -25,11 +26,17 @@ export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType<typeo
// child-only Node mode bypasses application startup and its single-instance lock.
const env = { ...process.env, DSH_DIALOG_TITLE: data.title, ELECTRON_RUN_AS_NODE: '1' }
const stdio: StdioOptions = ['ignore', 'inherit', 'inherit', 'ipc']
// Pathname (not the raw URL): bundlers/tests append query strings (?v=...) to
// the URL, which would misclassify source modules as built and silently test
// the wrong arm. The pathname ends with .ts only for the unbuilt source plane.
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */
if (!import.meta.url.endsWith('.ts')) {
if (!new URL(import.meta.url).pathname.endsWith('.ts')) {
return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true })
}
return spawn(process.execPath, ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true })
// `node <absolute .ts path>`: 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'
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading