Bläddra i källkod

refactor(picker): drive the Win32 dialog from a spawned child process

The koffi IFileOpenDialog conversation runs in a spawned child process instead of a worker thread: the dialog is the child's first window, so Windows activates it without a foreground call, and a native fault stays contained to the child. The driver maps the child's message protocol onto a promise and services aborts by posting WM_CLOSE to the dialog thread's windows, killing the child when the close budget is exhausted. The built worker ships as lib/worker.cjs (the ./worker export) under plain node, and win32-dialog.spec.ts returns to the thread-safe pool.
Huanqi Cao 1 månad sedan
förälder
incheckning
4201eaed3f

+ 15 - 42
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<Win32DialogBindings> {
             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<void> {
     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<boolean> {
-  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
-}

+ 17 - 21
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<typeof spawn> {
+  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'

+ 28 - 15
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)
   }
 })()

+ 22 - 53
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<number>
+  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<void>
-  /** Replaces the real foreground raise (`win32-dialog-host.ts`). */
-  raiseDialogWindow?: (threadId: number) => Promise<boolean>
   /** 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<string | null>((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(() => {

+ 14 - 11
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<Win32DialogWorkerMessage>((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`))
       })
     })

+ 44 - 24
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')
   })
 })

+ 13 - 49
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<typeof vi.fn>
-  raise: ReturnType<typeof vi.fn>
 }
 
 function harness(overrides: Partial<Win32DialogInternals> = {}): 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<number>((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)
   })
 

+ 0 - 4
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',