4201eaed3f
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.
160 lines
6.1 KiB
TypeScript
160 lines
6.1 KiB
TypeScript
/**
|
|
* 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, spawnDialogWorker } from './win32-dialog-host.ts'
|
|
import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts'
|
|
|
|
/** The child-process surface the driver drives (satisfied by `node:child_process`). */
|
|
export interface Win32DialogWorkerLike {
|
|
/**
|
|
* Subscribe to a child-process event.
|
|
* @param event - `message`, `error`, or `exit`.
|
|
* @param listener - the event consumer.
|
|
*/
|
|
on(event: 'message', listener: (message: Win32DialogWorkerMessage) => void): unknown
|
|
on(event: 'error', listener: (error: Error) => void): unknown
|
|
on(event: 'exit', listener: (code: number) => void): unknown
|
|
/**
|
|
* Force-stop the child; the abort path's last resort when `WM_CLOSE`
|
|
* never lands (e.g. the dialog window was never created).
|
|
* @returns whether a kill signal was delivered.
|
|
*/
|
|
kill(): boolean
|
|
/**
|
|
* Release the event-loop reference. Called once the pick settles so a
|
|
* 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 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>
|
|
/** Abort-service cadence override so tests never wait wall-clock time. */
|
|
closeRetryMs?: number
|
|
}
|
|
|
|
/** The dialog title every host shows. */
|
|
export const DIALOG_TITLE = 'Select Workspace Directory'
|
|
|
|
/** `WM_CLOSE` re-post cadence while an abort waits for the worker to unwind. */
|
|
const CLOSE_RETRY_MS = 150
|
|
/** Abort-service attempts before force-terminating the worker. */
|
|
const CLOSE_MAX_ATTEMPTS = 20
|
|
|
|
/** Fail loudly if the closed worker-to-driver union gains an unhandled member. */
|
|
/* v8 ignore start -- closed-union backstop; unreachable without a TypeScript contract violation */
|
|
function assertNever(value: never): never {
|
|
throw new TypeError(`unknown win32 dialog worker message kind: ${String(value)}`)
|
|
}
|
|
/* v8 ignore stop */
|
|
|
|
/**
|
|
* Open the modern Win32 folder picker off the event loop.
|
|
* @param signal - caller lifetime; abort closes the dialog and rejects.
|
|
* @param internals - worker/window seams for deterministic tests.
|
|
* @returns the selected path, or null when the user cancels.
|
|
*/
|
|
export async function pickWin32Directory(
|
|
signal: AbortSignal,
|
|
internals: Win32DialogInternals = {},
|
|
): Promise<string | null> {
|
|
if (signal.aborted) throw new Error('native directory picker aborted')
|
|
const spawnWorker = internals.spawnWorker ?? spawnDialogWorker
|
|
const closeWindows = internals.closeThreadWindows ?? hostCloseThreadWindows
|
|
const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS
|
|
|
|
const worker: Win32DialogWorkerLike = spawnWorker({ title: DIALOG_TITLE })
|
|
let dialogThreadId: number | undefined
|
|
let closeTimer: NodeJS.Timeout | undefined
|
|
let settled = false
|
|
|
|
return await new Promise<string | null>((resolve, reject) => {
|
|
const settle = (outcome: () => void): void => {
|
|
if (settled) return
|
|
settled = true
|
|
if (closeTimer !== undefined) clearInterval(closeTimer)
|
|
signal.removeEventListener('abort', onAbort)
|
|
worker.unref?.()
|
|
outcome()
|
|
}
|
|
|
|
const postClose = (): void => {
|
|
// Before `showing` there is no window to close; the budget below still
|
|
// 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 kill is the backstop.
|
|
if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined)
|
|
}
|
|
|
|
// 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 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(() => {
|
|
worker.kill()
|
|
reject(new Error('native directory picker aborted (dialog unresponsive; worker killed)'))
|
|
})
|
|
return
|
|
}
|
|
postClose()
|
|
}, closeRetryMs)
|
|
postClose()
|
|
}
|
|
|
|
const onAbort = (): void => {
|
|
serviceAbort()
|
|
}
|
|
signal.addEventListener('abort', onAbort, { once: true })
|
|
|
|
worker.on('message', (message: Win32DialogWorkerMessage) => {
|
|
switch (message.kind) {
|
|
case 'showing':
|
|
dialogThreadId = message.threadId
|
|
// An abort that raced ahead of this notice now has a window to hit.
|
|
if (signal.aborted) postClose()
|
|
return
|
|
case 'done':
|
|
settle(() => {
|
|
if (signal.aborted) reject(new Error('native directory picker aborted'))
|
|
else resolve(message.path)
|
|
})
|
|
return
|
|
case 'error':
|
|
settle(() => {
|
|
reject(new Error(`win32 folder dialog failed: ${message.message}`))
|
|
})
|
|
return
|
|
/* v8 ignore next 2 -- closed worker-owned union; a fourth kind becomes a compile error */
|
|
default:
|
|
assertNever(message)
|
|
}
|
|
})
|
|
worker.on('error', (error: Error) => {
|
|
settle(() => {
|
|
reject(error)
|
|
})
|
|
})
|
|
worker.on('exit', () => {
|
|
settle(() => {
|
|
reject(new Error('win32 folder dialog worker exited before reporting a result'))
|
|
})
|
|
})
|
|
})
|
|
}
|