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:
@@ -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`))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user