diff --git a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts index e6ee2f2e5d..654bbc5a74 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts @@ -25,6 +25,20 @@ interface Koffi { register(fn: (...args: unknown[]) => unknown, type: unknown): unknown unregister(callback: unknown): void sizeof(type: string): number + view(ref: unknown, len: number): ArrayBuffer +} + +/** + * Read a NUL-terminated UTF-16 string at a native address. koffi's + * `_Out_ void **` out-params surface a raw address, and + * `koffi.decode(addr, 'str16')` would dereference it as a pointer — crash + * on real Windows — so view the memory directly instead. + */ +function readUtf16(koffi: Koffi, address: unknown): string { + const bytes = Buffer.from(koffi.view(address, 32768)) + let end = 0 + while (end + 1 < bytes.length && bytes[end] !== 0) end += 2 + return bytes.toString('utf16le', 0, end) } const COINIT_APARTMENTTHREADED = 0x2 @@ -142,7 +156,7 @@ export async function loadWin32DialogBindings(): Promise { const nameOut: unknown[] = [null] const gotName = method(item, SLOT_GET_DISPLAY_NAME, protoGetDisplayName)(SIGDN_FILESYSPATH, nameOut) if (gotName < 0) return { hr: gotName } - const path = koffi.decode(nameOut[0], 'str16') as string + const path = readUtf16(koffi, nameOut[0]) coTaskMemFree(nameOut[0]) return { hr: gotName, path } } finally { @@ -179,44 +193,3 @@ export async function closeThreadWindows(threadId: number): Promise { koffi.unregister(callback) } } - -/** - * Bring a native thread's top-level window to the foreground. The dialog - * runs on a worker input queue, so Windows shows it without activating it - * (the app's main thread holds foreground association); the driver calls - * this on the `showing` notice: attach this thread's input queue to the - * dialog thread's, `SetForegroundWindow`, and detach. Returns whether the - * thread had a window to raise — the dialog window is created inside - * `Show`, after the `showing` notice, so callers retry until it exists. - * @param threadId - the dialog thread's native id (from the `showing` notice). - * @returns true when a window was found and raised. - */ -export async function raiseDialogWindow(threadId: number): Promise { - const koffi = (await import('koffi')).default as unknown as Koffi - const user32 = koffi.load('user32.dll') - const kernel32 = koffi.load('kernel32.dll') - const enumThreadWindows = user32.func('__stdcall', 'EnumThreadWindows', 'int', ['uint32', 'void *', 'intptr']) - const attachThreadInput = user32.func('__stdcall', 'AttachThreadInput', 'int', ['uint32', 'uint32', 'int']) - const setForegroundWindow = user32.func('__stdcall', 'SetForegroundWindow', 'int', ['void *']) - const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', []) - const protoEnumProc = koffi.proto('int __stdcall DshEnumThreadWndProc(void *hwnd, intptr lparam)') - let target: unknown - const callback = koffi.register((hwnd: unknown) => { - if (target === undefined) target = hwnd - return 0 // stop after the first (top-level) window - }, koffi.pointer(protoEnumProc)) - try { - enumThreadWindows(threadId, callback, 0) - } finally { - koffi.unregister(callback) - } - if (target === undefined) return false - const self = getCurrentThreadId() - try { - attachThreadInput(self, threadId, 1) - setForegroundWindow(target) - } finally { - attachThreadInput(self, threadId, 0) - } - return true -} 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 21089be150..7a60ab05ed 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -1,37 +1,33 @@ /** - * Real-process half of the Win32 dialog driver: spawn the dialog worker - * (source or built plane) and close a dialog thread's windows. The module - * itself loads everywhere (the import chain from native-picker.ts is + * Real-process half of the Win32 dialog driver: spawn the dialog child + * process (source or built plane) and close a dialog thread's windows. The + * module itself loads everywhere (the import chain from native-picker.ts is * static); what stays win32-only is koffi, imported dynamically inside the * bindings' functions. The driver's logic is tested against fakes of this * surface instead. */ +import { spawn, type StdioOptions } from 'node:child_process' import { fileURLToPath } from 'node:url' -import { Worker } from 'node:worker_threads' import type { Win32DialogWorkerData } from './win32-dialog-worker.ts' /** - * Spawn the dialog worker. Built consumers load the bundled CJS worker next - * to this module; unbuilt (source) consumers bootstrap tsx inside the worker - * first, mirroring `dsh-workflow-workerthread`'s host. - * @param data - the worker payload (dialog title). - * @returns the spawned worker thread. + * 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. + * @param data - the child payload (dialog title). + * @returns the spawned child process. */ -export function spawnDialogWorker(data: Win32DialogWorkerData): Worker { +export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType { + const env = { ...process.env, DSH_DIALOG_TITLE: data.title } + const stdio: StdioOptions = ['ignore', 'inherit', 'inherit', 'ipc'] /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */ if (!import.meta.url.endsWith('.ts')) { - return new Worker(fileURLToPath(new URL('./worker.cjs', import.meta.url)), { workerData: data }) + return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true }) } - const workerEntry = new URL('./win32-dialog-worker.ts', import.meta.url) - const bootstrap = [ - `import { register as registerEsm } from ${JSON.stringify(import.meta.resolve('tsx/esm/api'))}`, - `import { register as registerCjs } from ${JSON.stringify(import.meta.resolve('tsx/cjs/api'))}`, - 'registerCjs()', - 'registerEsm()', - `await import(${JSON.stringify(workerEntry.href)})`, - ].join('\n') - return new Worker(new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), { workerData: data }) + return spawn(process.execPath, ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true }) } -export { closeThreadWindows, raiseDialogWindow } from './win32-dialog-bindings.ts' +export { closeThreadWindows } from './win32-dialog-bindings.ts' diff --git a/packages/host/directory-picker-native/src/win32-dialog-worker.ts b/packages/host/directory-picker-native/src/win32-dialog-worker.ts index e978d305f3..0b422b3ca7 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-worker.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-worker.ts @@ -1,16 +1,18 @@ /** - * Worker entry for the Win32 folder dialog: blocks THIS thread inside the - * modal `Show` so the host event loop stays live, reporting over the message - * port. Protocol: `{kind:'showing',threadId}` right before the blocking call - * (the driver's abort lever needs the native thread id), then exactly one of - * `{kind:'done',path}` or `{kind:'error',message}`. + * Child-process entry for the Win32 folder dialog: blocks THIS process + * inside the modal `Show` so the host event loop stays live, reporting over + * the IPC channel. Spawned as a child process (not a worker thread) so the + * dialog is the process's first window and Windows activates it without a + * manual foreground call. Protocol: `{kind:'showing',threadId}` right + * before the blocking call (the driver's abort lever needs the native + * thread id), then exactly one of `{kind:'done',path}` or + * `{kind:'error',message}`. */ -import { parentPort, workerData } from 'node:worker_threads' import { loadWin32DialogBindings } from './win32-dialog-bindings.ts' import { runFolderDialog } from './win32-dialog-logic.ts' -/** The driver-to-worker payload: the dialog title. */ +/** The driver-to-child payload: the dialog title (passed via env). */ export interface Win32DialogWorkerData { title: string } /** One notice or outcome posted back to the driver. */ @@ -19,21 +21,32 @@ export type Win32DialogWorkerMessage = | { kind: 'done'; path: string | null } | { kind: 'error'; message: string } -const port = parentPort -if (port === null) throw new Error('win32-dialog-worker must run as a worker thread') -const { title } = workerData as Win32DialogWorkerData +const title = process.env.DSH_DIALOG_TITLE ?? '' +if (title === '') throw new Error('win32-dialog-worker: DSH_DIALOG_TITLE is required') +if (process.send === undefined) throw new Error('win32-dialog-worker must run as a child process with an IPC channel') +// node's internal `send` reads `this.connected`, so bind the receiver. +const send = process.send.bind(process) -// No top-level await: the built worker ships as CJS (pkg's VFS Worker hook -// compiles that format), which cannot carry TLA. +const post = (message: Win32DialogWorkerMessage): void => { + // Flush before closing the channel; the process exits when the loop drains. + /* v8 ignore next 3 -- disconnect needs a live IPC channel the unit lane must not sever (built-worker.e2e.ts owns the real close path). */ + send(message, () => { if (process.connected) process.disconnect() }) +} + +// A settled driver (or a dead parent) must not orphan a dialog still on screen. +/* v8 ignore next 3 -- the handler exits(0), which would kill the unit lane; built-worker.e2e.ts owns the real disconnect lifecycle. */ +process.on('disconnect', () => process.exit(0)) + +// No top-level await: the built worker ships as CJS, which cannot carry TLA. void (async () => { try { const bindings = await loadWin32DialogBindings() const path = runFolderDialog(bindings, title, (threadId) => { - port.postMessage({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage) + post({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage) }) - port.postMessage({ kind: 'done', path } satisfies Win32DialogWorkerMessage) + post({ kind: 'done', path } satisfies Win32DialogWorkerMessage) } catch (error: unknown) { const message = error instanceof Error ? (error.stack ?? error.message) : String(error) - port.postMessage({ kind: 'error', message } satisfies Win32DialogWorkerMessage) + post({ kind: 'error', message } satisfies Win32DialogWorkerMessage) } })() diff --git a/packages/host/directory-picker-native/src/win32-dialog.ts b/packages/host/directory-picker-native/src/win32-dialog.ts index 0d406d0bfd..247d9a1733 100644 --- a/packages/host/directory-picker-native/src/win32-dialog.ts +++ b/packages/host/directory-picker-native/src/win32-dialog.ts @@ -1,22 +1,18 @@ /** - * Main-thread driver for the Win32 folder dialog: spawns the dialog worker - * (which blocks inside the modal `Show`), maps its message protocol onto a - * promise, and services aborts by posting `WM_CLOSE` to the dialog thread's - * windows until the worker reports back. The real worker/window surface is - * injectable so every driver path is testable on any platform. + * Main-thread driver for the Win32 folder dialog: spawns the dialog child + * process (which blocks inside the modal `Show`), maps its message protocol + * onto a promise, and services aborts by posting `WM_CLOSE` to the dialog + * thread's windows until the child reports back. The real process/window + * surface is injectable so every driver path is testable on any platform. */ -import { - closeThreadWindows as hostCloseThreadWindows, - raiseDialogWindow as hostRaiseDialogWindow, - spawnDialogWorker, -} from './win32-dialog-host.ts' +import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts' import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts' -/** The worker surface the driver drives (satisfied by `node:worker_threads`). */ +/** The child-process surface the driver drives (satisfied by `node:child_process`). */ export interface Win32DialogWorkerLike { /** - * Subscribe to a worker event. + * Subscribe to a child-process event. * @param event - `message`, `error`, or `exit`. * @param listener - the event consumer. */ @@ -24,27 +20,24 @@ export interface Win32DialogWorkerLike { on(event: 'error', listener: (error: Error) => void): unknown on(event: 'exit', listener: (code: number) => void): unknown /** - * Force-stop the worker; the abort path's last resort when `WM_CLOSE` + * Force-stop the child; the abort path's last resort when `WM_CLOSE` * never lands (e.g. the dialog window was never created). - * @returns settles when the thread is gone. + * @returns whether a kill signal was delivered. */ - terminate(): Promise + kill(): boolean /** * Release the event-loop reference. Called once the pick settles so a - * worker stuck in the native modal call (terminate cannot interrupt - * native code) never blocks process exit. + * child stuck in the native modal call never blocks process exit. */ unref?(): void } /** Injectable process surface for deterministic driver tests. */ export interface Win32DialogInternals { - /** Replaces the real worker spawn (`win32-dialog-host.ts`). */ + /** Replaces the real child spawn (`win32-dialog-host.ts`). */ spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike /** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */ closeThreadWindows?: (threadId: number) => Promise - /** Replaces the real foreground raise (`win32-dialog-host.ts`). */ - raiseDialogWindow?: (threadId: number) => Promise /** Abort-service cadence override so tests never wait wall-clock time. */ closeRetryMs?: number } @@ -77,13 +70,11 @@ export async function pickWin32Directory( if (signal.aborted) throw new Error('native directory picker aborted') const spawnWorker = internals.spawnWorker ?? spawnDialogWorker const closeWindows = internals.closeThreadWindows ?? hostCloseThreadWindows - const raiseWindow = internals.raiseDialogWindow ?? hostRaiseDialogWindow const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS - const worker = spawnWorker({ title: DIALOG_TITLE }) + const worker: Win32DialogWorkerLike = spawnWorker({ title: DIALOG_TITLE }) let dialogThreadId: number | undefined let closeTimer: NodeJS.Timeout | undefined - let raiseTimer: NodeJS.Timeout | undefined let settled = false return await new Promise((resolve, reject) => { @@ -91,7 +82,6 @@ export async function pickWin32Directory( if (settled) return settled = true if (closeTimer !== undefined) clearInterval(closeTimer) - if (raiseTimer !== undefined) clearInterval(raiseTimer) signal.removeEventListener('abort', onAbort) worker.unref?.() outcome() @@ -99,46 +89,26 @@ export async function pickWin32Directory( const postClose = (): void => { // Before `showing` there is no window to close; the budget below still - // runs so a worker that never reports cannot dangle the pick. A + // runs so a child that never reports cannot dangle the pick. A // rejected close attempt (EnumThreadWindows/PostMessageW refusing) is - // discarded: the interval retries it and terminate is the backstop. + // discarded: the interval retries it and kill is the backstop. if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined) } - // The `showing` notice precedes the blocking `Show`, so the dialog - // window does not exist yet; re-enumerate on the close cadence until it - // does and raise it — a window on a worker input queue is otherwise - // shown without activation. Stops on settle, abort, or a successful - // raise; a failing raise (e.g. koffi absent) never blocks the pick. - const startRaise = (): void => { - const attempt = (): void => { - if (settled || signal.aborted || dialogThreadId === undefined) return - void raiseWindow(dialogThreadId) - .then((raised) => { - if (raised || settled || signal.aborted) { - if (raiseTimer !== undefined) clearInterval(raiseTimer) - } - }) - .catch(() => undefined) - } - attempt() - raiseTimer = setInterval(attempt, closeRetryMs) - } - // Sole caller: the once-registered abort listener, so no re-entry guard. const serviceAbort = (): void => { let attempts = 0 // The `showing` notice precedes the blocking `Show`, so the very first - // WM_CLOSE can race the window's creation; re-post until the worker - // reports back, then force-terminate as a last resort. The budget is - // unconditional — an abort before `showing` (worker hung in koffi or - // COM init) still ends in terminate instead of a dangling promise. + // WM_CLOSE can race the window's creation; re-post until the child + // reports back, then force-kill as a last resort. The budget is + // unconditional — an abort before `showing` (child hung in koffi or + // COM init) still ends in kill instead of a dangling promise. closeTimer = setInterval(() => { attempts += 1 if (attempts > CLOSE_MAX_ATTEMPTS) { settle(() => { - void worker.terminate() - reject(new Error('native directory picker aborted (dialog unresponsive; worker terminated)')) + worker.kill() + reject(new Error('native directory picker aborted (dialog unresponsive; worker killed)')) }) return } @@ -158,7 +128,6 @@ export async function pickWin32Directory( dialogThreadId = message.threadId // An abort that raced ahead of this notice now has a window to hit. if (signal.aborted) postClose() - else startRaise() return case 'done': settle(() => { diff --git a/packages/host/directory-picker-native/tests/built-worker.e2e.ts b/packages/host/directory-picker-native/tests/built-worker.e2e.ts index 03ae060f77..2c3b793a7d 100644 --- a/packages/host/directory-picker-native/tests/built-worker.e2e.ts +++ b/packages/host/directory-picker-native/tests/built-worker.e2e.ts @@ -1,27 +1,30 @@ /** * Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker - * shape): plain `worker_threads` loads `lib/worker.cjs` and the bundle reaches - * its real koffi requires. POSIX hosts prove the load path end to end through - * the deterministic ole32 rejection; win32 skips (a real dialog would open), - * where the win32-only smoke in win32-dialog.spec.ts covers the source plane - * instead. Skips until a build produces the artifact. + * shape): plain `node` runs `lib/worker.cjs` and the bundle reaches its + * real koffi requires. POSIX hosts prove the load path end to end through + * the deterministic ole32 rejection; win32 skips (a real dialog would + * open), where the win32-only smoke in win32-dialog.spec.ts covers the + * source plane instead. Skips until a build produces the artifact. */ +import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { Worker } from 'node:worker_threads' import { describe, expect, it } from 'vitest' import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts' const builtWorker = fileURLToPath(new URL('../lib/worker.cjs', import.meta.url)) describe.skipIf(!existsSync(builtWorker) || process.platform === 'win32')('built dialog worker (lib/worker.cjs)', () => { - it('loads under plain worker_threads and reports the native-surface failure', async () => { + it('loads under plain node and reports the native-surface failure', async () => { const message = await new Promise((resolve, reject) => { - const worker = new Worker(builtWorker, { workerData: { title: 'Built-artifact guard' } }) - worker.on('message', resolve) - worker.on('error', reject) - worker.on('exit', (code) => { + const child = spawn(process.execPath, [builtWorker], { + env: { ...process.env, DSH_DIALOG_TITLE: 'Built-artifact guard' }, + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + }) + child.on('message', resolve) + child.on('error', reject) + child.on('exit', (code) => { reject(new Error(`worker exited (${code}) before reporting`)) }) }) diff --git a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts index d29fbad7a0..9403bb7e36 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts @@ -3,9 +3,9 @@ * technique as dsh-session-persistence-jsonl's win32 suite): a small in-memory * COM world stands in for ole32/user32/kernel32, keeping the vtable dispatch, * result extraction, memory hygiene, and the WM_CLOSE poster covered on every - * host. The worker entry is exercised the same way with a mocked - * `node:worker_threads`. Real-COM behavior is pinned by the win32-only smoke - * in win32-dialog.spec.ts. + * host. The worker entry is exercised the same way with a mocked process + * boundary (env title + `process.send`). Real-COM behavior is pinned by the + * win32-only smoke in win32-dialog.spec.ts. */ import { afterEach, describe, expect, it, vi } from 'vitest' @@ -127,6 +127,11 @@ function installFakeKoffi(world: ComWorld): void { proto: (declaration: string) => ({ declaration }), pointer: (type: unknown) => type, sizeof: (type: string) => { void type; return FAKE_POINTER_SIZE }, + view: (value: unknown, len: number): ArrayBuffer => { + const bytes = Buffer.alloc(len) + bytes.write((value as FakePtr).text as string, 'utf16le') + return bytes.buffer + }, register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } }, unregister: () => { world.unregistered += 1 }, decode: (value: unknown, offsetOrType: unknown): unknown => { @@ -259,13 +264,31 @@ describe('closeThreadWindows over the fake COM world', () => { }) }) -describe('the worker entry over a mocked thread boundary', () => { +describe('the worker entry over a mocked process boundary', () => { + const originalSend = process.send?.bind(process) + const originalTitle = process.env.DSH_DIALOG_TITLE + + const installBoundary = (): { posted: { kind: string; message?: string }[] } => { + const posted: { kind: string; message?: string }[] = [] + process.env.DSH_DIALOG_TITLE = 'Pick' + ;(process as { send?: unknown }).send = (message: { kind: string }, callback?: () => void) => { + posted.push(message) + callback?.() + } + return { posted } + } + + afterEach(() => { + delete (process as { send?: unknown }).send + if (originalSend !== undefined) (process as { send?: unknown }).send = originalSend + if (originalTitle === undefined) delete process.env.DSH_DIALOG_TITLE + else process.env.DSH_DIALOG_TITLE = originalTitle + vi.doUnmock('../src/win32-dialog-bindings.ts') + vi.resetModules() + }) + it('posts showing then done for a completed conversation', async () => { - const posted: unknown[] = [] - vi.doMock('node:worker_threads', () => ({ - parentPort: { postMessage: (message: unknown) => posted.push(message) }, - workerData: { title: 'Pick' }, - })) + const { posted } = installBoundary() vi.doMock('../src/win32-dialog-bindings.ts', () => ({ loadWin32DialogBindings: async () => ({ setThreadDpiAwareness: () => undefined, @@ -289,11 +312,7 @@ describe('the worker entry over a mocked thread boundary', () => { }) it('posts the failure message when the native surface cannot load', async () => { - const posted: { kind: string; message?: string }[] = [] - vi.doMock('node:worker_threads', () => ({ - parentPort: { postMessage: (message: { kind: string }) => posted.push(message) }, - workerData: { title: 'Pick' }, - })) + const { posted } = installBoundary() vi.doMock('../src/win32-dialog-bindings.ts', () => ({ loadWin32DialogBindings: async () => { throw new Error('no ole32 here') }, })) @@ -307,14 +326,8 @@ describe('the worker entry over a mocked thread boundary', () => { const stackless = new Error('bare message') delete stackless.stack for (const [thrown, expected] of [[stackless, 'bare message'], ['plain refusal', 'plain refusal']] as const) { - vi.doUnmock('node:worker_threads') - vi.doUnmock('../src/win32-dialog-bindings.ts') vi.resetModules() - const posted: { kind: string; message?: string }[] = [] - vi.doMock('node:worker_threads', () => ({ - parentPort: { postMessage: (message: { kind: string }) => posted.push(message) }, - workerData: { title: 'Pick' }, - })) + const { posted } = installBoundary() vi.doMock('../src/win32-dialog-bindings.ts', () => ({ loadWin32DialogBindings: async () => { throw thrown }, })) @@ -323,8 +336,15 @@ describe('the worker entry over a mocked thread boundary', () => { } }) - it('refuses to run outside a worker thread', async () => { - vi.doMock('node:worker_threads', () => ({ parentPort: null, workerData: undefined })) - await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a worker thread') + it('refuses to run without the dialog title', async () => { + delete process.env.DSH_DIALOG_TITLE + ;(process as { send?: unknown }).send = () => true + await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('DSH_DIALOG_TITLE is required') + }) + + it('refuses to run outside a child process', async () => { + process.env.DSH_DIALOG_TITLE = 'Pick' + delete (process as { send?: unknown }).send + await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a child process') }) }) 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 72ff1b41d1..8e7d6951b8 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -1,11 +1,9 @@ /** - * Driver tests: the worker message protocol mapped onto the promise, the - * foreground raise after the `showing` notice (retried until the dialog - * window exists), the WM_CLOSE abort service (including the show-race - * retry and the terminate last resort) against fakes, plus the real spawn - * plumbing — POSIX hosts prove the default path rejects cleanly (koffi - * cannot load ole32 there), and win32 hosts briefly open and auto-abort a - * real dialog. + * Driver tests: the child-process message protocol mapped onto the promise, + * the WM_CLOSE abort service (including the show-race retry and the kill + * last resort) against fakes, plus the real spawn plumbing — POSIX hosts + * prove the default path rejects cleanly (koffi cannot load ole32 there), + * and win32 hosts briefly open and auto-abort a real dialog. */ import { EventEmitter } from 'node:events' @@ -14,7 +12,7 @@ import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLi import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts' class FakeWorker extends EventEmitter implements Win32DialogWorkerLike { - terminate = vi.fn(async () => 0) + kill = vi.fn(() => true) post(message: Win32DialogWorkerMessage): void { this.emit('message', message) } @@ -24,21 +22,17 @@ interface Harness { worker: FakeWorker internals: Win32DialogInternals close: ReturnType - raise: ReturnType } function harness(overrides: Partial = {}): Harness { const worker = new FakeWorker() const close = vi.fn(async () => undefined) - const raise = vi.fn(async () => true) return { worker, close, - raise, internals: { spawnWorker: () => worker, closeThreadWindows: close, - raiseDialogWindow: raise, closeRetryMs: 1, ...overrides, }, @@ -62,35 +56,6 @@ describe('pickWin32Directory', () => { await expect(cancelled).resolves.toBeNull() }) - it('raises the dialog window to the foreground after the showing notice', async () => { - const { worker, internals, raise } = harness() - const picked = pickWin32Directory(live(), internals) - worker.post({ kind: 'showing', threadId: 7 }) - worker.post({ kind: 'done', path: 'C:\\raised' }) - await expect(picked).resolves.toBe('C:\\raised') - expect(raise).toHaveBeenCalledWith(7) - }) - - it('retries the raise until the dialog window exists, then stops', async () => { - const { worker, internals, raise } = harness() - // The window is created inside `Show`, after the `showing` notice, so - // the first attempts find nothing; once a window is reported, the raise - // must stop retrying. - raise.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true) - const picked = pickWin32Directory(live(), internals) - worker.post({ kind: 'showing', threadId: 12 }) - await vi.waitFor(() => { - expect(raise.mock.calls.length).toBeGreaterThanOrEqual(2) - }) - const callsAfterRaised = await new Promise((resolve) => { - setTimeout(() =>{ resolve(raise.mock.calls.length); }, 20) - }) - await new Promise(resolve => setTimeout(resolve, 20)) - expect(raise.mock.calls.length).toBe(callsAfterRaised) - worker.post({ kind: 'done', path: 'C:\\raised' }) - await expect(picked).resolves.toBe('C:\\raised') - }) - it('rejects on a reported dialog failure, a worker crash, and a silent exit', async () => { const reported = harness() const failing = pickWin32Directory(live(), reported.internals) @@ -143,7 +108,7 @@ describe('pickWin32Directory', () => { it('starts the close service on the showing notice when the abort came first', async () => { const closeFailures = vi.fn(async () => { throw new Error('window not there yet') }) - const { worker, internals, raise } = harness({ closeThreadWindows: closeFailures }) + const { worker, internals } = harness({ closeThreadWindows: closeFailures }) const controller = new AbortController() // Attached before the race for the same unhandled-rejection reason above. const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted') @@ -153,31 +118,30 @@ describe('pickWin32Directory', () => { await vi.waitFor(() => { expect(closeFailures.mock.calls.length).toBeGreaterThan(1) }) - expect(raise).not.toHaveBeenCalled() worker.post({ kind: 'done', path: null }) await picked }) - it('terminates a worker that never reports showing after an abort', async () => { + it('kills a worker that never reports showing after an abort', async () => { // The budget runs without a thread id (nothing to WM_CLOSE yet), so a // worker hung before `showing` cannot dangle the pick. const { worker, internals, close } = harness() const controller = new AbortController() - const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('dialog unresponsive; worker terminated') + const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('dialog unresponsive; worker killed') controller.abort() await picked - expect(worker.terminate).toHaveBeenCalledOnce() + expect(worker.kill).toHaveBeenCalledOnce() expect(close).not.toHaveBeenCalled() }) - it('terminates an unresponsive worker after the close budget', async () => { + it('kills an unresponsive worker after the close budget', async () => { const { worker, internals, close } = harness() const controller = new AbortController() const picked = pickWin32Directory(controller.signal, internals) worker.post({ kind: 'showing', threadId: 5 }) controller.abort() - await expect(picked).rejects.toThrow('dialog unresponsive; worker terminated') - expect(worker.terminate).toHaveBeenCalledOnce() + await expect(picked).rejects.toThrow('dialog unresponsive; worker killed') + expect(worker.kill).toHaveBeenCalledOnce() expect(close.mock.calls.length).toBeGreaterThan(10) }) diff --git a/vitest.config.ts b/vitest.config.ts index 58d4a9837f..2909fb7459 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -73,10 +73,6 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ - // Spawns a nested worker that blocks in a native modal dialog on win32; - // under the threads pool the dialog thread outlives the test worker and - // wedges pool teardown, while a fork contains it. - 'packages/host/directory-picker-native/tests/win32-dialog.spec.ts', 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts',