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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user