|
|
@@ -1,1252 +1,666 @@
|
|
|
-import { spawn, spawnSync } from 'node:child_process'
|
|
|
-import type { ChildProcess } from 'node:child_process'
|
|
|
import { EventEmitter } from 'node:events'
|
|
|
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
|
+import {
|
|
|
+ existsSync,
|
|
|
+ mkdtempSync,
|
|
|
+ mkdirSync,
|
|
|
+ readdirSync,
|
|
|
+ rmSync,
|
|
|
+ statSync,
|
|
|
+ symlinkSync,
|
|
|
+ unlinkSync,
|
|
|
+ writeFileSync,
|
|
|
+} from 'node:fs'
|
|
|
import { tmpdir } from 'node:os'
|
|
|
import { join } from 'node:path'
|
|
|
-import { fileURLToPath } from 'node:url'
|
|
|
-import { describe, expect, it, vi } from 'vitest'
|
|
|
-import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
|
|
+import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
import { Win32Error } from '@deepseek-ai/dsh-win32-process'
|
|
|
import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process'
|
|
|
import {
|
|
|
- cleanupAfterRunner,
|
|
|
- runnerDirectResult,
|
|
|
- runnerFiles,
|
|
|
+ cleanupLinuxLaunchFiles,
|
|
|
+ consumeLinuxLaunchRequest,
|
|
|
+ createLinuxLaunchFiles,
|
|
|
+ deserializeRunnerError,
|
|
|
+ isWindowsTerminateRequest,
|
|
|
+ linuxLaunchFilesFromLocator,
|
|
|
+ parseWindowsRunnerResult,
|
|
|
+ parseWindowsStartRequest,
|
|
|
+ readLinuxStartupError,
|
|
|
+ serializeRunnerError,
|
|
|
+ writeLinuxStartupError,
|
|
|
+} from '../src/runner-protocol.ts'
|
|
|
+import {
|
|
|
+ consumeRunnerSelection,
|
|
|
+ parseRunnerTargetArgv,
|
|
|
+ runnerEnvironment,
|
|
|
+ runnerInvocationAvailable,
|
|
|
runnerStdio,
|
|
|
spawnRunnerInvocation,
|
|
|
+ SUBPROCESS_RUNNER_ENV,
|
|
|
+ targetEnvironment,
|
|
|
+ validateTerminalTarget,
|
|
|
+ WINDOWS_RUNNER_SELECTION,
|
|
|
} from '../src/runner-launch.ts'
|
|
|
-import { observeChildLifecycle } from '../src/managed-owner.ts'
|
|
|
import {
|
|
|
- appendRunnerEvent,
|
|
|
- cleanupRunnerFiles,
|
|
|
- consumeRunnerRequest,
|
|
|
- createRunnerFiles,
|
|
|
- deserializeSpawnError,
|
|
|
- readRunnerEventsAsync,
|
|
|
- serializeSpawnError,
|
|
|
-} from '../src/runner-protocol.ts'
|
|
|
-import { reportSpawnRunnerFailure, runSpawnRunner } from '../src/spawn-runner.ts'
|
|
|
+ reportSpawnRunnerFailure,
|
|
|
+ runSpawnRunner,
|
|
|
+} from '../src/spawn-runner.ts'
|
|
|
+import type { SpawnRunnerInternals } from '../src/spawn-runner.ts'
|
|
|
|
|
|
-const sourceInvocation = [
|
|
|
- process.execPath,
|
|
|
- '--import',
|
|
|
- 'tsx/esm',
|
|
|
- fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/bin.ts')),
|
|
|
-]
|
|
|
+const scratch: string[] = []
|
|
|
|
|
|
-function spec(overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
|
|
|
- return {
|
|
|
- argv: [process.execPath, '-e', ''],
|
|
|
- cwd: process.cwd(),
|
|
|
- stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } },
|
|
|
- graceMs: 100,
|
|
|
- ...overrides,
|
|
|
- }
|
|
|
-}
|
|
|
+afterEach(() => {
|
|
|
+ for (const path of scratch.splice(0)) rmSync(path, { recursive: true, force: true })
|
|
|
+ vi.restoreAllMocks()
|
|
|
+})
|
|
|
|
|
|
-function fakeChild(pid: number | undefined): ChildProcess {
|
|
|
- const child = new EventEmitter() as ChildProcess
|
|
|
- Object.assign(child, { pid, exitCode: null, signalCode: null })
|
|
|
- return child
|
|
|
+function track<T extends { directory: string }>(files: T): T {
|
|
|
+ scratch.push(files.directory)
|
|
|
+ return files
|
|
|
}
|
|
|
|
|
|
class FakeRunnerHost extends EventEmitter {
|
|
|
- env: NodeJS.ProcessEnv = {}
|
|
|
+ env: NodeJS.ProcessEnv = { [SUBPROCESS_RUNNER_ENV]: 'stale', SAFE: 'bootstrap' }
|
|
|
exitCode: number | undefined
|
|
|
- connected = false
|
|
|
+ connected = true
|
|
|
directory = process.cwd()
|
|
|
- readonly disconnect = vi.fn(() => { this.connected = false })
|
|
|
+ sent: unknown[] = []
|
|
|
+ sendFailure: Error | undefined
|
|
|
+ sendThrown: unknown
|
|
|
|
|
|
cwd(): string { return this.directory }
|
|
|
- chdir(directory: string): void { this.directory = directory }
|
|
|
+ chdir(path: string): void { this.directory = path }
|
|
|
+ disconnect(): void {
|
|
|
+ if (!this.connected) return
|
|
|
+ this.connected = false
|
|
|
+ this.emit('disconnect')
|
|
|
+ }
|
|
|
+ send(message: unknown, callback?: (error: Error | null) => void): boolean {
|
|
|
+ if (this.sendThrown !== undefined) throw this.sendThrown
|
|
|
+ this.sent.push(message)
|
|
|
+ queueMicrotask(() => { callback?.(this.sendFailure ?? null) })
|
|
|
+ return true
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-function asRunnerHost(host: FakeRunnerHost): Parameters<typeof runSpawnRunner>[1] {
|
|
|
- return host as unknown as Parameters<typeof runSpawnRunner>[1]
|
|
|
+function hostArgument(host: FakeRunnerHost): Parameters<typeof runSpawnRunner>[2] {
|
|
|
+ return host as unknown as Parameters<typeof runSpawnRunner>[2]
|
|
|
}
|
|
|
|
|
|
-type RunnerInternals = NonNullable<Parameters<typeof runSpawnRunner>[2]>
|
|
|
-
|
|
|
-const fakeWin32Api = {} as Win32ProcessBindings
|
|
|
-const fakeProcessHandle = 60n as NativePtr
|
|
|
-const fakeJobHandle = 50n as NativePtr
|
|
|
-
|
|
|
-function fakeRunnerInternals(overrides: Partial<RunnerInternals> = {}): RunnerInternals {
|
|
|
- let nextPipeHandle = 70n
|
|
|
+function internals(overrides: Partial<SpawnRunnerInternals> = {}): SpawnRunnerInternals {
|
|
|
return {
|
|
|
- spawn,
|
|
|
- loadWin32ProcessBindings: vi.fn(() => fakeWin32Api),
|
|
|
- openNamedPipeForStdio: vi.fn(() => nextPipeHandle++),
|
|
|
+ execve: vi.fn(() => { throw Object.assign(new Error('missing'), { code: 'ENOENT' }) }),
|
|
|
+ loadWin32ProcessBindings: vi.fn(() => ({} as Win32ProcessBindings)),
|
|
|
spawnCurrentTokenJobProcess: vi.fn(() => ({
|
|
|
- pid: 1234,
|
|
|
- process: fakeProcessHandle,
|
|
|
- job: fakeJobHandle,
|
|
|
+ pid: 123,
|
|
|
+ process: 10n as NativePtr,
|
|
|
+ job: 20n as NativePtr,
|
|
|
})),
|
|
|
+ closeCurrentProcessStandardHandles: vi.fn(),
|
|
|
pollProcessExit: vi.fn(() => 0),
|
|
|
isJobEmpty: vi.fn(() => true),
|
|
|
terminateJob: vi.fn(),
|
|
|
- waitForProcessExit: vi.fn(() => 0),
|
|
|
closeHandleChecked: vi.fn(),
|
|
|
...overrides,
|
|
|
- } as RunnerInternals
|
|
|
-}
|
|
|
-
|
|
|
-function win32RunnerArgs(
|
|
|
- requestPath: string,
|
|
|
- eventsPath: string,
|
|
|
- pipes: string[] = [],
|
|
|
-): string[] {
|
|
|
- return [
|
|
|
- '--mode', 'win32',
|
|
|
- '--request', requestPath,
|
|
|
- '--events', eventsPath,
|
|
|
- ...pipes,
|
|
|
- ]
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-function runRunner(invocation: string[], requestPath: string, eventsPath: string) {
|
|
|
- const [command, ...prefix] = invocation
|
|
|
- return spawnSync(command as string, [
|
|
|
- ...prefix,
|
|
|
- '--mode',
|
|
|
- 'node',
|
|
|
- '--request',
|
|
|
- requestPath,
|
|
|
- '--events',
|
|
|
- eventsPath,
|
|
|
- ], { encoding: 'utf8', timeout: 10_000 })
|
|
|
+async function runWindows(
|
|
|
+ host: FakeRunnerHost,
|
|
|
+ native: SpawnRunnerInternals,
|
|
|
+ start: unknown = { type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes', dsh_subprocess_runner: 'restored' } },
|
|
|
+): Promise<void> {
|
|
|
+ const running = runSpawnRunner(
|
|
|
+ WINDOWS_RUNNER_SELECTION,
|
|
|
+ ['--', 'tool.exe', 'literal arg'],
|
|
|
+ hostArgument(host),
|
|
|
+ native,
|
|
|
+ )
|
|
|
+ host.emit('message', start)
|
|
|
+ await running
|
|
|
}
|
|
|
|
|
|
-describe('spawn runner transport', () => {
|
|
|
- it('selects the source runner without publishing a runner package face', () => {
|
|
|
- expect(spawnRunnerInvocation()).toEqual(sourceInvocation)
|
|
|
- const manifest = JSON.parse(readFileSync(
|
|
|
- fileURLToPath(new URL('../package.json', import.meta.url)),
|
|
|
- 'utf8',
|
|
|
- )) as { exports: Record<string, unknown> }
|
|
|
- expect(manifest.exports).not.toHaveProperty('./spawn-runner')
|
|
|
- expect(manifest.exports['./package.json']).toBe('./package.json')
|
|
|
- })
|
|
|
-
|
|
|
- it('observes runner events without SharedArrayBuffer', async () => {
|
|
|
- const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'SharedArrayBuffer')
|
|
|
- Object.defineProperty(globalThis, 'SharedArrayBuffer', { configurable: true, value: undefined })
|
|
|
- vi.resetModules()
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- const isolated = await import('../src/runner-launch.ts')
|
|
|
- const result = isolated.runnerDirectResult(fakeChild(123), files, new Promise<void>(() => {}))
|
|
|
- appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
|
|
|
- appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null })
|
|
|
- await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
|
|
|
- expect(result.pid).toBe(456)
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'SharedArrayBuffer')
|
|
|
- else Object.defineProperty(globalThis, 'SharedArrayBuffer', descriptor)
|
|
|
- vi.resetModules()
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it('re-enters a packaged executable through its private runner dispatch', () => {
|
|
|
- const packagedProcess = process as NodeJS.Process & { pkg?: unknown }
|
|
|
- const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg')
|
|
|
- Object.defineProperty(packagedProcess, 'pkg', { configurable: true, value: {} })
|
|
|
- try {
|
|
|
- expect(spawnRunnerInvocation()).toEqual([process.execPath, '--dsh-internal-subprocess-runner'])
|
|
|
- } finally {
|
|
|
- if (original === undefined) Reflect.deleteProperty(packagedProcess, 'pkg')
|
|
|
- else Object.defineProperty(packagedProcess, 'pkg', original)
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it('supports the node runner capability probe', () => {
|
|
|
- const result = spawnSync(sourceInvocation[0] as string, [
|
|
|
- ...sourceInvocation.slice(1),
|
|
|
- '--mode',
|
|
|
- 'probe-node',
|
|
|
- ], { encoding: 'utf8', timeout: 10_000 })
|
|
|
- expect(result.error).toBeUndefined()
|
|
|
- expect(result.status).toBe(0)
|
|
|
- })
|
|
|
-
|
|
|
- it('runs the Node target lifecycle in-process through the coverable runner logic', async () => {
|
|
|
- const files = createRunnerFiles({
|
|
|
- argv: [process.execPath, '-e', 'process.exit(12)'],
|
|
|
- cwd: process.cwd(),
|
|
|
- env: {},
|
|
|
+describe('closed runner protocol', () => {
|
|
|
+ it('creates, consumes, reports through, and cleans one private Linux exchange', () => {
|
|
|
+ const files = track(createLinuxLaunchFiles({ cwd: '/target', env: { A: '1' } }))
|
|
|
+ expect(statSync(files.directory).mode & 0o777).toBe(0o700)
|
|
|
+ expect(statSync(files.requestPath).mode & 0o777).toBe(0o600)
|
|
|
+ expect(linuxLaunchFilesFromLocator(files.requestPath)).toEqual(files)
|
|
|
+ expect(consumeLinuxLaunchRequest(files.requestPath)).toEqual({ cwd: '/target', env: { A: '1' } })
|
|
|
+ expect(existsSync(files.requestPath)).toBe(false)
|
|
|
+
|
|
|
+ const failure = Object.assign(new Error('spawn missing'), {
|
|
|
+ name: 'SpawnError', code: 'ENOENT', errno: -2, syscall: 'spawn tool', path: 'tool', spawnargs: ['x'],
|
|
|
})
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- try {
|
|
|
- await runSpawnRunner([
|
|
|
- '--mode', 'node',
|
|
|
- '--request', files.requestPath,
|
|
|
- '--events', files.eventsPath,
|
|
|
- ], asRunnerHost(host))
|
|
|
- expect(host.exitCode).toBe(12)
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([
|
|
|
- expect.objectContaining({ type: 'started' }),
|
|
|
- { type: 'exit', exitCode: 12, signal: null },
|
|
|
- ])
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it('reports an in-process Node target spawn failure', async () => {
|
|
|
- const files = createRunnerFiles({
|
|
|
- argv: [`missing-dsh-runner-target-${String(process.pid)}-${String(Date.now())}`],
|
|
|
- cwd: process.cwd(),
|
|
|
- env: {},
|
|
|
+ writeLinuxStartupError(files, { type: 'spawn-error', error: serializeRunnerError(failure) })
|
|
|
+ expect(statSync(files.startupErrorPath).mode & 0o777).toBe(0o600)
|
|
|
+ const result = readLinuxStartupError(files.startupErrorPath)
|
|
|
+ expect(result).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT', path: 'tool', spawnargs: ['x'] } })
|
|
|
+ expect(deserializeRunnerError(result!.error)).toMatchObject({
|
|
|
+ name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', errno: -2,
|
|
|
})
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- try {
|
|
|
- await runSpawnRunner([
|
|
|
- '--mode', 'node',
|
|
|
- '--request', files.requestPath,
|
|
|
- '--events', files.eventsPath,
|
|
|
- ], asRunnerHost(host))
|
|
|
- expect(host.exitCode).toBe(127)
|
|
|
- const [event] = await readRunnerEventsAsync(files.eventsPath)
|
|
|
- expect(event?.type).toBe('spawn-error')
|
|
|
- if (event?.type !== 'spawn-error') throw new Error('expected spawn error')
|
|
|
- expect(event.error.code).toBe('ENOENT')
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
+ writeFileSync(join(files.directory, '.startup-error.tmp'), 'incomplete')
|
|
|
+ cleanupLinuxLaunchFiles(files)
|
|
|
+ expect(existsSync(files.directory)).toBe(false)
|
|
|
})
|
|
|
|
|
|
- it('contains a post-start Node runner error and ignores scope signals', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: process.cwd(), env: {} })
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- const child = Object.assign(new EventEmitter(), { pid: 4321 }) as ChildProcess
|
|
|
- const injectedSpawn = vi.fn(() => {
|
|
|
- queueMicrotask(() => {
|
|
|
- host.emit('SIGTERM')
|
|
|
- child.emit('spawn')
|
|
|
- child.emit('error', new Error('post-start node failure'))
|
|
|
- child.emit('exit', 0, null)
|
|
|
- })
|
|
|
- return child
|
|
|
- }) as unknown as typeof spawn
|
|
|
+ it('removes the private directory when request creation fails partway through', () => {
|
|
|
+ const isolatedTmp = mkdtempSync(join(tmpdir(), 'dsh-launch-failure-spec-'))
|
|
|
+ vi.stubEnv('TMPDIR', isolatedTmp)
|
|
|
+ vi.stubEnv('TMP', isolatedTmp)
|
|
|
+ vi.stubEnv('TEMP', isolatedTmp)
|
|
|
try {
|
|
|
- await runSpawnRunner([
|
|
|
- '--mode', 'node',
|
|
|
- '--request', files.requestPath,
|
|
|
- '--events', files.eventsPath,
|
|
|
- ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn }))
|
|
|
- expect(injectedSpawn).toHaveBeenCalledTimes(1)
|
|
|
- expect(injectedSpawn).toHaveBeenCalledWith('node', [], {
|
|
|
- cwd: process.cwd(),
|
|
|
- env: {},
|
|
|
- stdio: 'inherit',
|
|
|
- detached: true,
|
|
|
- })
|
|
|
- expect(host.exitCode).toBe(127)
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([
|
|
|
- { type: 'started', pid: 4321 },
|
|
|
- { type: 'runner-error', error: { name: 'Error', message: 'post-start node failure' } },
|
|
|
- ])
|
|
|
- expect(host.listenerCount('SIGTERM')).toBe(0)
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it('maps a signal-only Node exit to the runner failure exit code', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: process.cwd(), env: {} })
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- const child = Object.assign(new EventEmitter(), { pid: 4321 }) as ChildProcess
|
|
|
- const injectedSpawn = vi.fn(() => {
|
|
|
- queueMicrotask(() => {
|
|
|
- child.emit('spawn')
|
|
|
- child.emit('exit', null, 'SIGTERM')
|
|
|
+ const stringify = vi.spyOn(JSON, 'stringify').mockImplementationOnce(() => {
|
|
|
+ throw new Error('request serialization failed')
|
|
|
})
|
|
|
- return child
|
|
|
- }) as unknown as typeof spawn
|
|
|
- try {
|
|
|
- await runSpawnRunner([
|
|
|
- '--mode', 'node',
|
|
|
- '--request', files.requestPath,
|
|
|
- '--events', files.eventsPath,
|
|
|
- ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn }))
|
|
|
- expect(host.exitCode).toBe(1)
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([
|
|
|
- { type: 'started', pid: 4321 },
|
|
|
- { type: 'exit', exitCode: null, signal: 'SIGTERM' },
|
|
|
- ])
|
|
|
+ expect(() => createLinuxLaunchFiles({ cwd: '/target', env: {} })).toThrow('request serialization failed')
|
|
|
+ stringify.mockRestore()
|
|
|
+ expect(readdirSync(isolatedTmp)).toEqual([])
|
|
|
} finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
+ vi.unstubAllEnvs()
|
|
|
+ rmSync(isolatedTmp, { recursive: true, force: true })
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('runs the in-process capability probes and always closes the probe Job', async () => {
|
|
|
- const nodeHost = new FakeRunnerHost()
|
|
|
- await expect(runSpawnRunner(
|
|
|
- ['--mode', 'probe-node'],
|
|
|
- asRunnerHost(nodeHost),
|
|
|
- fakeRunnerInternals(),
|
|
|
- )).resolves.toBeUndefined()
|
|
|
+ it('strictly rejects malformed Linux and Windows messages', () => {
|
|
|
+ const files = track(createLinuxLaunchFiles({ cwd: '/ok', env: {} }))
|
|
|
+ writeFileSync(files.requestPath, JSON.stringify({ cwd: '/ok', env: {}, extra: true }))
|
|
|
+ expect(() => consumeLinuxLaunchRequest(files.requestPath)).toThrow('invalid Linux launch request')
|
|
|
+ expect(() => linuxLaunchFilesFromLocator('relative.json')).toThrow('invalid Linux launch-request locator')
|
|
|
+ expect(readLinuxStartupError(files.startupErrorPath)).toBeUndefined()
|
|
|
+ writeFileSync(files.startupErrorPath, 'null')
|
|
|
+ expect(() => readLinuxStartupError(files.startupErrorPath)).toThrow('invalid startup error')
|
|
|
+ writeFileSync(files.startupErrorPath, JSON.stringify({
|
|
|
+ type: 'unknown', error: { name: 'Error', message: 'bad' },
|
|
|
+ }))
|
|
|
+ expect(() => readLinuxStartupError(files.startupErrorPath)).toThrow('unknown error result')
|
|
|
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- host.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
|
|
|
- host.directory = 'C:\\runner'
|
|
|
- const internals = fakeRunnerInternals()
|
|
|
- await expect(runSpawnRunner(
|
|
|
- ['--mode', 'probe-win32'],
|
|
|
- asRunnerHost(host),
|
|
|
- internals,
|
|
|
- )).resolves.toBeUndefined()
|
|
|
- expect(internals.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(fakeWin32Api, {
|
|
|
- command: 'C:\\Windows\\System32\\cmd.exe',
|
|
|
- args: ['/d', '/s', '/c', 'exit 0'],
|
|
|
- cwd: 'C:\\runner',
|
|
|
+ expect(parseWindowsStartRequest({ type: 'start', cwd: 'C:\\x', env: { A: '1' } })).toEqual({
|
|
|
+ type: 'start', cwd: 'C:\\x', env: { A: '1' },
|
|
|
})
|
|
|
- expect(internals.waitForProcessExit).toHaveBeenCalledWith(fakeWin32Api, fakeProcessHandle)
|
|
|
- expect(internals.closeHandleChecked).toHaveBeenCalledWith(
|
|
|
- fakeWin32Api,
|
|
|
- fakeJobHandle,
|
|
|
- 'subprocess Windows Job probe',
|
|
|
- )
|
|
|
-
|
|
|
- const legacyHost = new FakeRunnerHost()
|
|
|
- legacyHost.env.COMSPEC = 'legacy-cmd.exe'
|
|
|
- const failing = fakeRunnerInternals({ waitForProcessExit: vi.fn(() => 9) })
|
|
|
- await expect(runSpawnRunner(
|
|
|
- ['--mode', 'probe-win32'],
|
|
|
- asRunnerHost(legacyHost),
|
|
|
- failing,
|
|
|
- )).rejects.toThrow('probe exited with code 9')
|
|
|
- expect(failing.closeHandleChecked).toHaveBeenCalledWith(
|
|
|
- fakeWin32Api,
|
|
|
- fakeJobHandle,
|
|
|
- 'subprocess Windows Job probe',
|
|
|
- )
|
|
|
-
|
|
|
- await expect(runSpawnRunner(
|
|
|
- ['--mode', 'probe-win32'],
|
|
|
- asRunnerHost(new FakeRunnerHost()),
|
|
|
- fakeRunnerInternals(),
|
|
|
- )).rejects.toThrow('without ComSpec')
|
|
|
- })
|
|
|
-
|
|
|
- it('runs the Win32 target, forwards every pipe, and waits for an empty Job', async () => {
|
|
|
- vi.useFakeTimers()
|
|
|
- const files = createRunnerFiles({
|
|
|
- argv: ['tool.exe', 'literal $HOME'],
|
|
|
- cwd: 'C:\\target',
|
|
|
- env: { ONLY: 'kept' },
|
|
|
+ expect(() => parseWindowsStartRequest({ type: 'start', cwd: 'C:\\x', env: {}, extra: 1 })).toThrow()
|
|
|
+ expect(isWindowsTerminateRequest({ type: 'terminate' })).toBe(true)
|
|
|
+ expect(isWindowsTerminateRequest({ type: 'terminate', reason: 'no' })).toBe(false)
|
|
|
+ expect(parseWindowsRunnerResult({ type: 'start-cancelled' })).toEqual({ type: 'start-cancelled' })
|
|
|
+ expect(parseWindowsRunnerResult({ type: 'target-exit', exitCode: null, signal: 'SIGTERM' })).toEqual({
|
|
|
+ type: 'target-exit', exitCode: null, signal: 'SIGTERM',
|
|
|
})
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- host.env.STALE = 'removed'
|
|
|
- host.directory = 'C:\\runner'
|
|
|
- host.connected = true
|
|
|
- const pollProcessExit = vi.fn()
|
|
|
- .mockReturnValueOnce(undefined)
|
|
|
- .mockReturnValueOnce(42)
|
|
|
- const isJobEmpty = vi.fn()
|
|
|
- .mockReturnValueOnce(false)
|
|
|
- .mockReturnValueOnce(true)
|
|
|
- const internals = fakeRunnerInternals({ pollProcessExit, isJobEmpty })
|
|
|
- try {
|
|
|
- const running = runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
|
|
|
- '--stdin-pipe', '\\\\.\\pipe\\stdin',
|
|
|
- '--stdout-pipe', '\\\\.\\pipe\\stdout',
|
|
|
- '--stderr-pipe', '\\\\.\\pipe\\stderr',
|
|
|
- ]), asRunnerHost(host), internals)
|
|
|
- await vi.advanceTimersByTimeAsync(30)
|
|
|
- await running
|
|
|
-
|
|
|
- expect(host.env).toEqual({ ONLY: 'kept' })
|
|
|
- expect(host.directory).toBe('C:\\runner')
|
|
|
- expect(host.disconnect).toHaveBeenCalledOnce()
|
|
|
- expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith(
|
|
|
- 1,
|
|
|
- fakeWin32Api,
|
|
|
- '\\\\.\\pipe\\stdin',
|
|
|
- 'read',
|
|
|
- )
|
|
|
- expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith(
|
|
|
- 2,
|
|
|
- fakeWin32Api,
|
|
|
- '\\\\.\\pipe\\stdout',
|
|
|
- 'write',
|
|
|
- )
|
|
|
- expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith(
|
|
|
- 3,
|
|
|
- fakeWin32Api,
|
|
|
- '\\\\.\\pipe\\stderr',
|
|
|
- 'write',
|
|
|
- )
|
|
|
- expect(internals.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(
|
|
|
- fakeWin32Api,
|
|
|
- { command: 'tool.exe', args: ['literal $HOME'], cwd: 'C:\\target' },
|
|
|
- {
|
|
|
- stdin: 70n,
|
|
|
- stdout: 71n,
|
|
|
- stderr: 72n,
|
|
|
- },
|
|
|
- )
|
|
|
- expect(pollProcessExit).toHaveBeenCalledTimes(2)
|
|
|
- expect(isJobEmpty).toHaveBeenCalledTimes(2)
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([
|
|
|
- { type: 'started', pid: 1234 },
|
|
|
- { type: 'exit', exitCode: 42, signal: null },
|
|
|
- ])
|
|
|
- expect(internals.closeHandleChecked).toHaveBeenCalledWith(
|
|
|
- fakeWin32Api,
|
|
|
- fakeProcessHandle,
|
|
|
- 'ordinary direct process',
|
|
|
- )
|
|
|
- expect(internals.closeHandleChecked).toHaveBeenCalledWith(
|
|
|
- fakeWin32Api,
|
|
|
- fakeJobHandle,
|
|
|
- 'ordinary process Job',
|
|
|
- )
|
|
|
- } finally {
|
|
|
- vi.useRealTimers()
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it('accepts only the Win32 terminate IPC message and coalesces disconnect', async () => {
|
|
|
- vi.useFakeTimers()
|
|
|
- const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- const internals = fakeRunnerInternals()
|
|
|
- try {
|
|
|
- const running = runSpawnRunner(
|
|
|
- win32RunnerArgs(files.requestPath, files.eventsPath),
|
|
|
- asRunnerHost(host),
|
|
|
- internals,
|
|
|
- )
|
|
|
- host.emit('message', null)
|
|
|
- host.emit('message', 'terminate')
|
|
|
- host.emit('message', { type: 'other' })
|
|
|
- host.emit('message', { type: 'terminate' })
|
|
|
- host.emit('message', { type: 'terminate' })
|
|
|
- host.emit('disconnect')
|
|
|
- await vi.advanceTimersByTimeAsync(10)
|
|
|
- await running
|
|
|
-
|
|
|
- expect(internals.terminateJob).toHaveBeenCalledOnce()
|
|
|
- expect(internals.terminateJob).toHaveBeenCalledWith(fakeWin32Api, fakeJobHandle, 1)
|
|
|
- expect(host.disconnect).not.toHaveBeenCalled()
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([
|
|
|
- { type: 'started', pid: 1234 },
|
|
|
- { type: 'exit', exitCode: 0, signal: null },
|
|
|
- ])
|
|
|
- } finally {
|
|
|
- vi.useRealTimers()
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it('reports a non-Error Win32 termination failure and closes both live handles', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- host.connected = true
|
|
|
- const terminateJob = vi.fn(() => { throw 'raw termination failure' })
|
|
|
- const internals = fakeRunnerInternals({ terminateJob })
|
|
|
- try {
|
|
|
- const running = runSpawnRunner(
|
|
|
- win32RunnerArgs(files.requestPath, files.eventsPath),
|
|
|
- asRunnerHost(host),
|
|
|
- internals,
|
|
|
- )
|
|
|
- host.emit('disconnect')
|
|
|
- await running
|
|
|
-
|
|
|
- expect(host.exitCode).toBe(127)
|
|
|
- expect(host.disconnect).toHaveBeenCalledOnce()
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([
|
|
|
- { type: 'started', pid: 1234 },
|
|
|
- { type: 'runner-error', error: { name: 'Error', message: 'raw termination failure' } },
|
|
|
- ])
|
|
|
- expect(internals.closeHandleChecked).toHaveBeenCalledWith(
|
|
|
- fakeWin32Api,
|
|
|
- fakeProcessHandle,
|
|
|
- 'ordinary direct process cleanup',
|
|
|
- )
|
|
|
- expect(internals.closeHandleChecked).toHaveBeenCalledWith(
|
|
|
- fakeWin32Api,
|
|
|
- fakeJobHandle,
|
|
|
- 'ordinary process Job cleanup',
|
|
|
- )
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it.each([
|
|
|
- [2, 'ENOENT'],
|
|
|
- [3, 'ENOENT'],
|
|
|
- [267, 'ENOENT'],
|
|
|
- [5, 'EACCES'],
|
|
|
- [193, 'EFTYPE'],
|
|
|
- [999, 'UNKNOWN'],
|
|
|
- ] as const)('maps Win32 CreateProcess error %i to %s', async (win32Code, code) => {
|
|
|
- const files = createRunnerFiles({
|
|
|
- argv: ['missing.exe', 'literal argument'],
|
|
|
- cwd: 'C:\\target',
|
|
|
- env: {},
|
|
|
+ expect(parseWindowsRunnerResult({ type: 'spawn-error', error: { name: 'Error', message: 'bad' } })).toEqual({
|
|
|
+ type: 'spawn-error', error: { name: 'Error', message: 'bad' },
|
|
|
})
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- const internals = fakeRunnerInternals({
|
|
|
- spawnCurrentTokenJobProcess: vi.fn(() => {
|
|
|
- throw new Win32Error('CreateProcessW', win32Code)
|
|
|
- }),
|
|
|
+ for (const invalid of [
|
|
|
+ null,
|
|
|
+ { type: 'unknown' },
|
|
|
+ { type: 'start-cancelled', payload: 1 },
|
|
|
+ { type: 'target-exit', exitCode: -1, signal: null },
|
|
|
+ { type: 'target-exit', exitCode: 0, signal: 'NOPE' },
|
|
|
+ { type: 'runner-error', error: { name: 'Error', message: 'bad', cause: {} } },
|
|
|
+ ]) expect(() => parseWindowsRunnerResult(invalid)).toThrow()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('contains cleanup failures and removes a substituted symlink only', () => {
|
|
|
+ const files = track(createLinuxLaunchFiles({ cwd: '/ok', env: {} }))
|
|
|
+ cleanupLinuxLaunchFiles(files)
|
|
|
+ cleanupLinuxLaunchFiles(files)
|
|
|
+
|
|
|
+ const target = join(tmpdir(), `dsh-runner-cleanup-target-${String(process.pid)}`)
|
|
|
+ const link = join(tmpdir(), `dsh-runner-cleanup-link-${String(process.pid)}`)
|
|
|
+ scratch.push(target, link)
|
|
|
+ mkdirSync(target, { recursive: true })
|
|
|
+ symlinkSync(target, link)
|
|
|
+ cleanupLinuxLaunchFiles({
|
|
|
+ directory: link,
|
|
|
+ requestPath: join(link, 'launch-request.json'),
|
|
|
+ startupErrorPath: join(link, 'startup-error.json'),
|
|
|
})
|
|
|
- try {
|
|
|
- await runSpawnRunner(
|
|
|
- win32RunnerArgs(files.requestPath, files.eventsPath),
|
|
|
- asRunnerHost(host),
|
|
|
- internals,
|
|
|
- )
|
|
|
- expect(host.exitCode).toBeUndefined()
|
|
|
- const [event] = await readRunnerEventsAsync(files.eventsPath)
|
|
|
- expect(event?.type).toBe('spawn-error')
|
|
|
- if (event?.type !== 'spawn-error') throw new Error('expected spawn error')
|
|
|
- expect(event.error).toMatchObject({
|
|
|
- code,
|
|
|
- syscall: 'spawn missing.exe',
|
|
|
- path: 'missing.exe',
|
|
|
- spawnargs: ['literal argument'],
|
|
|
- })
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
+ expect(existsSync(link)).toBe(false)
|
|
|
+ expect(existsSync(target)).toBe(true)
|
|
|
|
|
|
- it('preserves a Win32 target spawn failure when restoring the runner cwd also fails', async () => {
|
|
|
- const files = createRunnerFiles({
|
|
|
- argv: ['missing.exe', 'literal argument'],
|
|
|
- cwd: 'C:\\target',
|
|
|
- env: {},
|
|
|
- })
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- host.directory = 'C:\\runner'
|
|
|
- const chdir = vi.fn((directory: string) => {
|
|
|
- if (directory === 'C:\\runner') throw new Error('cwd restore failed')
|
|
|
- host.directory = directory
|
|
|
- })
|
|
|
- host.chdir = chdir
|
|
|
- const internals = fakeRunnerInternals({
|
|
|
- spawnCurrentTokenJobProcess: vi.fn(() => {
|
|
|
- throw new Win32Error('CreateProcessW', 2)
|
|
|
- }),
|
|
|
- })
|
|
|
- try {
|
|
|
- await runSpawnRunner(
|
|
|
- win32RunnerArgs(files.requestPath, files.eventsPath),
|
|
|
- asRunnerHost(host),
|
|
|
- internals,
|
|
|
- )
|
|
|
- expect(chdir).toHaveBeenCalledTimes(2)
|
|
|
- expect(host.exitCode).toBeUndefined()
|
|
|
- const [event] = await readRunnerEventsAsync(files.eventsPath)
|
|
|
- expect(event?.type).toBe('spawn-error')
|
|
|
- if (event?.type !== 'spawn-error') throw new Error('expected spawn error')
|
|
|
- expect(event.error).toMatchObject({
|
|
|
- code: 'ENOENT',
|
|
|
- syscall: 'spawn missing.exe',
|
|
|
- path: 'missing.exe',
|
|
|
- spawnargs: ['literal argument'],
|
|
|
- })
|
|
|
- expect(event.error.message).not.toContain('cwd restore failed')
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
+ const blocked = track(createLinuxLaunchFiles({ cwd: '/ok', env: {} }))
|
|
|
+ unlinkSync(blocked.requestPath)
|
|
|
+ mkdirSync(blocked.requestPath)
|
|
|
+ cleanupLinuxLaunchFiles(blocked)
|
|
|
+ expect(existsSync(blocked.directory)).toBe(true)
|
|
|
})
|
|
|
+})
|
|
|
|
|
|
- it.each([
|
|
|
- [undefined, false],
|
|
|
- ['ENOENT', true],
|
|
|
- ] as const)('maps a target chdir failure with code %s', async (code, hasSpawnShape) => {
|
|
|
- const files = createRunnerFiles({ argv: ['tool.exe', 'arg'], cwd: 'C:\\missing', env: {} })
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- const error = Object.assign(new Error('target cwd failed'), {
|
|
|
- syscall: 'chdir',
|
|
|
- ...code === undefined ? {} : { code },
|
|
|
- })
|
|
|
- host.chdir = vi.fn(() => { throw error })
|
|
|
- try {
|
|
|
- await runSpawnRunner(
|
|
|
- win32RunnerArgs(files.requestPath, files.eventsPath),
|
|
|
- asRunnerHost(host),
|
|
|
- fakeRunnerInternals(),
|
|
|
- )
|
|
|
- expect(host.exitCode).toBeUndefined()
|
|
|
- const [event] = await readRunnerEventsAsync(files.eventsPath)
|
|
|
- expect(event?.type).toBe('spawn-error')
|
|
|
- if (event?.type !== 'spawn-error') throw new Error('expected spawn error')
|
|
|
- expect(typeof event.error.message).toBe('string')
|
|
|
- expect('path' in event.error).toBe(hasSpawnShape)
|
|
|
- if (hasSpawnShape) {
|
|
|
- expect(event.error).toMatchObject({
|
|
|
- code: 'ENOENT',
|
|
|
- syscall: 'spawn tool.exe',
|
|
|
- path: 'tool.exe',
|
|
|
- spawnargs: ['arg'],
|
|
|
- })
|
|
|
- } else {
|
|
|
- expect(event.error).toMatchObject({ message: 'target cwd failed', syscall: 'chdir' })
|
|
|
+describe('runner launch inputs', () => {
|
|
|
+ const spec = {
|
|
|
+ argv: ['node', 'a'],
|
|
|
+ cwd: process.cwd(),
|
|
|
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
|
|
|
+ graceMs: 100,
|
|
|
+ env: { EXPLICIT: 'yes' },
|
|
|
+ } as const
|
|
|
+
|
|
|
+ it('keeps target state out of the bootstrap environment and consumes its selector', () => {
|
|
|
+ const env = runnerEnvironment('/tmp/request')
|
|
|
+ expect(env[SUBPROCESS_RUNNER_ENV]).toBe('/tmp/request')
|
|
|
+ expect(env.SYSTEMD_LOG_TARGET).toBe('null')
|
|
|
+ expect(env.EXPLICIT).toBeUndefined()
|
|
|
+ expect(consumeRunnerSelection(env)).toBe('/tmp/request')
|
|
|
+ expect(env[SUBPROCESS_RUNNER_ENV]).toBeUndefined()
|
|
|
+ expect(consumeRunnerSelection({})).toBeUndefined()
|
|
|
+ expect(parseRunnerTargetArgv(['--', 'node', 'a'])).toEqual(['node', 'a'])
|
|
|
+ expect(() => parseRunnerTargetArgv(['node'])).toThrow('private -- delimiter')
|
|
|
+ expect(runnerStdio(spec, false)).toEqual(['pipe', 'pipe', 'inherit'])
|
|
|
+ expect(runnerStdio(spec, true)).toEqual(['pipe', 'pipe', 'inherit', 'ipc'])
|
|
|
+ expect(runnerStdio({
|
|
|
+ ...spec,
|
|
|
+ stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'pipe' },
|
|
|
+ }, false)).toEqual(['ignore', 'inherit', 'pipe'])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('validates every Node-baseline NUL location before launch', () => {
|
|
|
+ expect(targetEnvironment(spec)).toMatchObject({ EXPLICIT: 'yes' })
|
|
|
+ expect(validateTerminalTarget({ ...spec, rows: 24, cols: 80 })).toMatchObject({ EXPLICIT: 'yes' })
|
|
|
+ for (const invalid of [
|
|
|
+ { ...spec, argv: ['node\0'] },
|
|
|
+ { ...spec, argv: ['node', 'a\0'] },
|
|
|
+ { ...spec, cwd: 'bad\0cwd' },
|
|
|
+ { ...spec, env: { 'BAD\0KEY': 'x' } },
|
|
|
+ { ...spec, env: { BAD: 'x\0' } },
|
|
|
+ ]) {
|
|
|
+ try {
|
|
|
+ targetEnvironment(invalid)
|
|
|
+ throw new Error('expected targetEnvironment to reject')
|
|
|
+ } catch (error) {
|
|
|
+ expect(error).toMatchObject({ name: 'TypeError', code: 'ERR_INVALID_ARG_VALUE' })
|
|
|
}
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it.each([
|
|
|
- ['a non-CreateProcess Win32 error', new Win32Error('CreateFileW', 5), 'Win32Error'],
|
|
|
- ['a non-Error setup failure', 'raw pipe setup failure', 'Error'],
|
|
|
- ])('reports %s as runner infrastructure failure', async (_label, failure, name) => {
|
|
|
- const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- const internals = fakeRunnerInternals({
|
|
|
- openNamedPipeForStdio: vi.fn(() => { throw failure }),
|
|
|
- })
|
|
|
- try {
|
|
|
- await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
|
|
|
- '--stdin-pipe', '\\\\.\\pipe\\stdin',
|
|
|
- ]), asRunnerHost(host), internals)
|
|
|
- expect(host.exitCode).toBe(127)
|
|
|
- const [event] = await readRunnerEventsAsync(files.eventsPath)
|
|
|
- expect(event?.type).toBe('runner-error')
|
|
|
- if (event?.type !== 'runner-error') throw new Error('expected runner error')
|
|
|
- expect(event.error.name).toBe(name)
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
+ it('resolves the source runner entry and checks concrete paths without executing it', () => {
|
|
|
+ const invocation = spawnRunnerInvocation()
|
|
|
+ expect(invocation[0]).toBe(process.execPath)
|
|
|
+ expect(invocation).toContain('tsx/esm')
|
|
|
+ expect(runnerInvocationAvailable(invocation)).toBe(true)
|
|
|
+ expect(runnerInvocationAvailable(['/definitely/missing-dsh-runner'])).toBe(false)
|
|
|
+ expect(runnerInvocationAvailable(['node'])).toBe(true)
|
|
|
+ expect(runnerInvocationAvailable(['node', 'runner.js'])).toBe(true)
|
|
|
|
|
|
- it.each([
|
|
|
- ['an Error', new Error('stdio close failed')],
|
|
|
- ['a non-Error value', 'raw stdio close failure'],
|
|
|
- ])('reports %s from the initial stdio close and retries cleanup', async (_label, failure) => {
|
|
|
- const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
|
|
|
- let failedOnce = false
|
|
|
- const closeHandleChecked = vi.fn((_api, _handle, label: string) => {
|
|
|
- if (!failedOnce && label.includes('pipe')) {
|
|
|
- failedOnce = true
|
|
|
- throw failure
|
|
|
- }
|
|
|
- })
|
|
|
- const internals = fakeRunnerInternals({ closeHandleChecked })
|
|
|
+ Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
|
|
try {
|
|
|
- await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
|
|
|
- '--stdin-pipe', '\\\\.\\pipe\\stdin',
|
|
|
- ]), asRunnerHost(new FakeRunnerHost()), internals)
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([
|
|
|
- { type: 'started', pid: 1234 },
|
|
|
- {
|
|
|
- type: 'runner-error',
|
|
|
- error: { name: 'Error', message: failure instanceof Error ? failure.message : failure },
|
|
|
- },
|
|
|
- ])
|
|
|
- expect(closeHandleChecked).toHaveBeenCalledWith(
|
|
|
- fakeWin32Api,
|
|
|
- 70n,
|
|
|
- 'ordinary target stdin pipe',
|
|
|
- )
|
|
|
- expect(closeHandleChecked).toHaveBeenCalledWith(
|
|
|
- fakeWin32Api,
|
|
|
- 70n,
|
|
|
- 'ordinary target stdin pipe',
|
|
|
- )
|
|
|
+ expect(spawnRunnerInvocation()).toEqual([process.execPath])
|
|
|
} finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
+ Reflect.deleteProperty(process, 'pkg')
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('preserves the first stdio close failure while retaining every failed handle', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
|
|
|
- let remainingFailures = 2
|
|
|
- const closeHandleChecked = vi.fn((_api, _handle, label: string) => {
|
|
|
- if (remainingFailures > 0 && label.includes('pipe')) {
|
|
|
- remainingFailures -= 1
|
|
|
- throw remainingFailures === 1 ? new Error('first close failure') : 'second close failure'
|
|
|
- }
|
|
|
+ it('bounds non-Error and stackless runner failures', () => {
|
|
|
+ expect(serializeRunnerError('plain failure')).toMatchObject({
|
|
|
+ name: 'Error', message: 'plain failure',
|
|
|
})
|
|
|
- const internals = fakeRunnerInternals({ closeHandleChecked })
|
|
|
- try {
|
|
|
- await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [
|
|
|
- '--stdin-pipe', '\\\\.\\pipe\\stdin',
|
|
|
- '--stdout-pipe', '\\\\.\\pipe\\stdout',
|
|
|
- ]), asRunnerHost(new FakeRunnerHost()), internals)
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toContainEqual({
|
|
|
- type: 'runner-error',
|
|
|
- error: { name: 'Error', message: 'first close failure' },
|
|
|
- })
|
|
|
- expect(closeHandleChecked).toHaveBeenCalledTimes(6)
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
+ const stackless = new Error('stackless')
|
|
|
+ Reflect.deleteProperty(stackless, 'stack')
|
|
|
+ expect(serializeRunnerError(stackless)).toEqual({ name: 'Error', message: 'stackless' })
|
|
|
+ const minimal = deserializeRunnerError({ name: 'Error', message: 'minimal' })
|
|
|
+ expect(minimal).toMatchObject({ name: 'Error', message: 'minimal' })
|
|
|
+ expect(minimal).not.toHaveProperty('code')
|
|
|
+ expect(minimal).not.toHaveProperty('errno')
|
|
|
+ expect(minimal).not.toHaveProperty('syscall')
|
|
|
+ expect(minimal).not.toHaveProperty('path')
|
|
|
+ expect(minimal).not.toHaveProperty('spawnargs')
|
|
|
})
|
|
|
+})
|
|
|
|
|
|
- it.each([
|
|
|
- ['poll', 'poll failed'],
|
|
|
- ['direct close', 'direct close failed'],
|
|
|
- ['Job query', 'Job query failed'],
|
|
|
- ['Job close', 'Job close failed'],
|
|
|
- ] as const)('reports a Win32 %s failure and cleans remaining handles', async (stage, message) => {
|
|
|
- vi.useFakeTimers()
|
|
|
- const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
|
|
|
- const pollProcessExit = vi.fn(() => {
|
|
|
- if (stage === 'poll') throw new Error(message)
|
|
|
- return 0
|
|
|
+describe('Linux one-shot exec bootstrap', () => {
|
|
|
+ it('uses final cwd/env PATH while preserving the original argv', async () => {
|
|
|
+ const files = track(createLinuxLaunchFiles({
|
|
|
+ cwd: '/final/work',
|
|
|
+ env: { PATH: 'relative::/absolute', [SUBPROCESS_RUNNER_ENV]: 'target-value' },
|
|
|
+ }))
|
|
|
+ const host = new FakeRunnerHost()
|
|
|
+ const execve = vi.fn((_file: string, _argv: string[], _env: Record<string, string>) => {
|
|
|
+ throw Object.assign(new Error('not found'), { code: 'ENOENT' })
|
|
|
})
|
|
|
- const isJobEmpty = vi.fn(() => {
|
|
|
- if (stage === 'Job query') throw new Error(message)
|
|
|
- return true
|
|
|
+ await runSpawnRunner(files.requestPath, ['--', 'tool', 'literal arg'], hostArgument(host), internals({ execve }))
|
|
|
+ expect(host.directory).toBe('/final/work')
|
|
|
+ expect(host.env[SUBPROCESS_RUNNER_ENV]).toBeUndefined()
|
|
|
+ expect(execve.mock.calls.map(call => call[0])).toEqual([
|
|
|
+ '/final/work/relative/tool',
|
|
|
+ '/final/work/tool',
|
|
|
+ '/absolute/tool',
|
|
|
+ ])
|
|
|
+ expect(execve.mock.calls[0]?.[1]).toEqual(['tool', 'literal arg'])
|
|
|
+ expect(execve.mock.calls[0]?.[2]).toMatchObject({ [SUBPROCESS_RUNNER_ENV]: 'target-value' })
|
|
|
+ expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({
|
|
|
+ type: 'spawn-error', error: { code: 'ENOENT', path: 'tool' },
|
|
|
})
|
|
|
- const closeHandleChecked = vi.fn((_api, _handle, label: string) => {
|
|
|
- if (stage === 'direct close' && label === 'ordinary direct process') {
|
|
|
- throw new Error(message)
|
|
|
- }
|
|
|
- if (stage === 'Job close' && label === 'ordinary process Job') {
|
|
|
- throw new Error(message)
|
|
|
- }
|
|
|
- if (label.endsWith('cleanup')) throw new Error('ignored cleanup failure')
|
|
|
- })
|
|
|
- const internals = fakeRunnerInternals({ pollProcessExit, isJobEmpty, closeHandleChecked })
|
|
|
- try {
|
|
|
- const running = runSpawnRunner(
|
|
|
- win32RunnerArgs(files.requestPath, files.eventsPath),
|
|
|
- asRunnerHost(new FakeRunnerHost()),
|
|
|
- internals,
|
|
|
- )
|
|
|
- await vi.advanceTimersByTimeAsync(10)
|
|
|
- await running
|
|
|
-
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([
|
|
|
- { type: 'started', pid: 1234 },
|
|
|
- ...stage === 'poll' ? [] : [{ type: 'exit' as const, exitCode: 0, signal: null }],
|
|
|
- { type: 'runner-error', error: { name: 'Error', message } },
|
|
|
- ])
|
|
|
- expect(closeHandleChecked).toHaveBeenCalledWith(
|
|
|
- fakeWin32Api,
|
|
|
- fakeJobHandle,
|
|
|
- expect.stringContaining('Job'),
|
|
|
- )
|
|
|
- } finally {
|
|
|
- vi.useRealTimers()
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
})
|
|
|
|
|
|
- it('preserves the first failure when termination settles reentrantly during polling', async () => {
|
|
|
- vi.useFakeTimers()
|
|
|
- const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- const terminateJob = vi.fn(() => { throw new Error('reentrant termination failed') })
|
|
|
- const pollProcessExit = vi.fn(() => {
|
|
|
- host.emit('disconnect')
|
|
|
- return 0
|
|
|
- })
|
|
|
- const internals = fakeRunnerInternals({ terminateJob, pollProcessExit })
|
|
|
- try {
|
|
|
- const running = runSpawnRunner(
|
|
|
- win32RunnerArgs(files.requestPath, files.eventsPath),
|
|
|
- asRunnerHost(host),
|
|
|
- internals,
|
|
|
- )
|
|
|
- await vi.advanceTimersByTimeAsync(10)
|
|
|
- await running
|
|
|
+ it('uses the default PATH and stops on a non-search error', async () => {
|
|
|
+ const files = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
|
|
|
+ const execve = vi.fn((_file: string) => { throw Object.assign(new Error('denied'), { code: 'EACCES' }) })
|
|
|
+ await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({ execve }))
|
|
|
+ expect(execve.mock.calls.map(call => call[0])).toEqual(['/usr/bin/tool', '/bin/tool'])
|
|
|
+ expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'spawn-error', error: { code: 'EACCES' } })
|
|
|
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([
|
|
|
- { type: 'started', pid: 1234 },
|
|
|
- { type: 'exit', exitCode: 0, signal: null },
|
|
|
- {
|
|
|
- type: 'runner-error',
|
|
|
- error: { name: 'Error', message: 'reentrant termination failed' },
|
|
|
- },
|
|
|
- ])
|
|
|
- } finally {
|
|
|
- vi.useRealTimers()
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
+ const explicit = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
|
|
|
+ const fatal = vi.fn(() => { throw Object.assign(new Error('bad executable'), { code: 'EIO' }) })
|
|
|
+ await runSpawnRunner(explicit.requestPath, ['--', './tool'], hostArgument(new FakeRunnerHost()), internals({ execve: fatal }))
|
|
|
+ expect(fatal).toHaveBeenCalledOnce()
|
|
|
|
|
|
- it('reports failure while restoring cwd after a successful Win32 spawn', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- host.directory = 'C:\\runner'
|
|
|
- const chdir = vi.fn((directory: string) => {
|
|
|
- if (directory === 'C:\\runner') throw new Error('cwd restore failed')
|
|
|
- host.directory = directory
|
|
|
+ const stackless = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
|
|
|
+ await runSpawnRunner(stackless.requestPath, ['--', './tool'], hostArgument(new FakeRunnerHost()), internals({
|
|
|
+ execve: vi.fn(() => { throw new Error('unclassified failure') }),
|
|
|
+ }))
|
|
|
+ expect(readLinuxStartupError(stackless.startupErrorPath)).toMatchObject({
|
|
|
+ type: 'spawn-error', error: { message: 'unclassified failure' },
|
|
|
})
|
|
|
- host.chdir = chdir
|
|
|
- try {
|
|
|
- await runSpawnRunner(
|
|
|
- win32RunnerArgs(files.requestPath, files.eventsPath),
|
|
|
- asRunnerHost(host),
|
|
|
- fakeRunnerInternals(),
|
|
|
- )
|
|
|
- expect(chdir).toHaveBeenCalledTimes(2)
|
|
|
- expect(host.exitCode).toBe(127)
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([
|
|
|
- { type: 'started', pid: 1234 },
|
|
|
- { type: 'runner-error', error: { name: 'Error', message: 'cwd restore failed' } },
|
|
|
- ])
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
|
|
|
- it('disconnects after an uncaught Win32 binding setup failure', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} })
|
|
|
- const host = new FakeRunnerHost()
|
|
|
- host.connected = true
|
|
|
- const internals = fakeRunnerInternals({
|
|
|
- loadWin32ProcessBindings: vi.fn(() => { throw new Error('binding setup failed') }),
|
|
|
+ const searched = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
|
|
|
+ const searchedExecve = vi.fn()
|
|
|
+ .mockImplementationOnce(() => { throw Object.assign(new Error('not a directory'), { code: 'ENOTDIR' }) })
|
|
|
+ .mockImplementationOnce(() => { throw Object.assign(new Error('I/O failure'), { code: 'EIO' }) })
|
|
|
+ await runSpawnRunner(searched.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({
|
|
|
+ execve: searchedExecve as never,
|
|
|
+ }))
|
|
|
+ expect(readLinuxStartupError(searched.startupErrorPath)).toMatchObject({
|
|
|
+ type: 'spawn-error', error: { code: 'EIO' },
|
|
|
})
|
|
|
- try {
|
|
|
- await expect(runSpawnRunner(
|
|
|
- win32RunnerArgs(files.requestPath, files.eventsPath),
|
|
|
- asRunnerHost(host),
|
|
|
- internals,
|
|
|
- )).rejects.toThrow('binding setup failed')
|
|
|
- expect(host.disconnect).toHaveBeenCalledOnce()
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([])
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it.each([
|
|
|
- [['--mode'], 'missing value'],
|
|
|
- [['--unknown', 'value'], 'unknown argument'],
|
|
|
- [['--mode', 'unknown'], 'unknown mode'],
|
|
|
- [['--mode', 'node'], 'requires request and event paths'],
|
|
|
- ] as const)('rejects invalid runner arguments: %s', async (argv, message) => {
|
|
|
- await expect(runSpawnRunner([...argv], asRunnerHost(new FakeRunnerHost()))).rejects.toThrow(message)
|
|
|
})
|
|
|
|
|
|
- it('reports only failures whose arguments identify an event transport', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- reportSpawnRunnerFailure([
|
|
|
- '--mode', 'node',
|
|
|
- '--request', files.requestPath,
|
|
|
- '--events', files.eventsPath,
|
|
|
- ], new Error('runner main failed'))
|
|
|
- reportSpawnRunnerFailure(['--mode', 'probe-node'], new Error('ignored probe failure'))
|
|
|
- reportSpawnRunnerFailure(['--mode'], new Error('unparseable failure'))
|
|
|
- expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([
|
|
|
- { type: 'runner-error', error: { name: 'Error', message: 'runner main failed' } },
|
|
|
- ])
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
+ it('publishes request/protocol failures as runner errors', async () => {
|
|
|
+ const files = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
|
|
|
+ writeFileSync(files.requestPath, '{')
|
|
|
+ await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals())
|
|
|
+ expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'runner-error' })
|
|
|
|
|
|
- it('maps every target stdio disposition', () => {
|
|
|
- expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe'])
|
|
|
- expect(runnerStdio(spec({
|
|
|
- stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' },
|
|
|
- }))).toEqual(['pipe', 'inherit', 'inherit'])
|
|
|
- })
|
|
|
-
|
|
|
- it('materializes and consumes the exact runner request once', () => {
|
|
|
- const removed = `DSH_RUNNER_REMOVED_${process.pid}`
|
|
|
- const files = runnerFiles(spec({
|
|
|
- argv: [process.execPath, 'literal $HOME'],
|
|
|
- env: { RUNNER_VALUE: 'explicit', [removed]: undefined },
|
|
|
- }))
|
|
|
- try {
|
|
|
- const request = consumeRunnerRequest(files.requestPath)
|
|
|
- expect(request.argv).toEqual([process.execPath, 'literal $HOME'])
|
|
|
- expect(request.cwd).toBe(process.cwd())
|
|
|
- expect(request.env.RUNNER_VALUE).toBe('explicit')
|
|
|
- expect(request.env).not.toHaveProperty(removed)
|
|
|
- expect(existsSync(files.requestPath)).toBe(false)
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
+ const early = track(createLinuxLaunchFiles({ cwd: '/work', env: {} }))
|
|
|
+ await reportSpawnRunnerFailure(early.requestPath, new Error('delimiter failed'), hostArgument(new FakeRunnerHost()))
|
|
|
+ expect(readLinuxStartupError(early.startupErrorPath)).toMatchObject({
|
|
|
+ type: 'runner-error', error: { message: 'delimiter failed' },
|
|
|
+ })
|
|
|
})
|
|
|
+})
|
|
|
|
|
|
- it.each([
|
|
|
- ['non-object request', null, 'no executable'],
|
|
|
- ['non-array argv', { argv: 'node', cwd: '.', env: {} }, 'no executable'],
|
|
|
- ['empty argv', { argv: [], cwd: '.', env: {} }, 'no executable'],
|
|
|
- ['non-string argv', { argv: [1], cwd: '.', env: {} }, 'no executable'],
|
|
|
- ['non-string cwd', { argv: ['node'], cwd: 1, env: {} }, 'invalid cwd or environment'],
|
|
|
- ['non-record env', { argv: ['node'], cwd: '.', env: [] }, 'invalid cwd or environment'],
|
|
|
- ['non-string env value', { argv: ['node'], cwd: '.', env: { VALUE: 1 } }, 'invalid cwd or environment'],
|
|
|
- ])('rejects an invalid %s', (_label, request, message) => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- writeFileSync(files.requestPath, JSON.stringify(request))
|
|
|
- expect(() => consumeRunnerRequest(files.requestPath)).toThrow(message)
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
+describe('Windows Job runner protocol owner', () => {
|
|
|
+ it('maps the bounded Win32 process-creation error classes', async () => {
|
|
|
+ for (const [win32Code, code] of [
|
|
|
+ [3, 'ENOENT'],
|
|
|
+ [267, 'ENOENT'],
|
|
|
+ [5, 'EACCES'],
|
|
|
+ [193, 'EFTYPE'],
|
|
|
+ [999, 'UNKNOWN'],
|
|
|
+ ] as const) {
|
|
|
+ const host = new FakeRunnerHost()
|
|
|
+ await runWindows(host, internals({
|
|
|
+ spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', win32Code) }),
|
|
|
+ }))
|
|
|
+ expect(host.sent).toMatchObject([{ type: 'spawn-error', error: { code } }])
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('unlinks a substituted runner-directory link without traversing it', () => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- const outside = mkdtempSync(join(tmpdir(), 'dsh-runner-outside-'))
|
|
|
- const sentinel = join(outside, 'events.ndjson')
|
|
|
- writeFileSync(sentinel, 'keep')
|
|
|
- rmSync(files.directory, { recursive: true, force: true })
|
|
|
- symlinkSync(outside, files.directory, process.platform === 'win32' ? 'junction' : 'dir')
|
|
|
- try {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- expect(existsSync(files.directory)).toBe(false)
|
|
|
- expect(existsSync(sentinel)).toBe(true)
|
|
|
- } finally {
|
|
|
- rmSync(files.directory, { recursive: true, force: true })
|
|
|
- rmSync(outside, { recursive: true, force: true })
|
|
|
- }
|
|
|
+ it('rejects a Windows runner without an initial IPC channel', async () => {
|
|
|
+ const disconnected = new FakeRunnerHost()
|
|
|
+ disconnected.connected = false
|
|
|
+ await runSpawnRunner(
|
|
|
+ WINDOWS_RUNNER_SELECTION,
|
|
|
+ ['--', 'tool.exe'],
|
|
|
+ hostArgument(disconnected),
|
|
|
+ internals(),
|
|
|
+ )
|
|
|
+ expect(disconnected.exitCode).toBe(127)
|
|
|
+
|
|
|
+ const missingSend = new FakeRunnerHost()
|
|
|
+ Object.defineProperty(missingSend, 'send', { value: undefined })
|
|
|
+ await runSpawnRunner(
|
|
|
+ WINDOWS_RUNNER_SELECTION,
|
|
|
+ ['--', 'tool.exe'],
|
|
|
+ hostArgument(missingSend),
|
|
|
+ internals(),
|
|
|
+ )
|
|
|
+ expect(missingSend.exitCode).toBe(127)
|
|
|
})
|
|
|
|
|
|
- it('contains an unexpected owned-path cleanup failure', () => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- rmSync(files.requestPath, { force: true })
|
|
|
- mkdirSync(files.requestPath)
|
|
|
- try {
|
|
|
- expect(() => { cleanupRunnerFiles(files) }).not.toThrow()
|
|
|
- expect(existsSync(files.directory)).toBe(true)
|
|
|
- } finally {
|
|
|
- rmSync(files.directory, { recursive: true, force: true })
|
|
|
- }
|
|
|
- })
|
|
|
+ it('sends target-exit only after suspended Job launch and closes runner stdio', async () => {
|
|
|
+ const host = new FakeRunnerHost()
|
|
|
+ const native = internals()
|
|
|
+ await runWindows(host, native)
|
|
|
+ expect(native.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(expect.anything(), {
|
|
|
+ command: 'tool.exe', args: ['literal arg'], cwd: 'C:\\target',
|
|
|
+ })
|
|
|
+ expect(native.closeCurrentProcessStandardHandles).toHaveBeenCalledOnce()
|
|
|
+ expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 10n, 'ordinary direct process')
|
|
|
+ expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 20n, 'ordinary process Job')
|
|
|
+ expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }])
|
|
|
+ expect(host.exitCode).toBe(0)
|
|
|
+ expect(host.env).toEqual({ TARGET: 'yes', dsh_subprocess_runner: 'restored' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('exhausts spawn-error, runner-error, and payload-free start-cancelled', async () => {
|
|
|
+ const spawnHost = new FakeRunnerHost()
|
|
|
+ await runWindows(spawnHost, internals({
|
|
|
+ spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', 2) }),
|
|
|
+ }))
|
|
|
+ expect(spawnHost.sent).toMatchObject([{ type: 'spawn-error', error: { code: 'ENOENT', path: 'tool.exe' } }])
|
|
|
+ expect(spawnHost.exitCode).toBe(0)
|
|
|
|
|
|
- it('reads only complete known event records and propagates file errors', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual([])
|
|
|
- await expect(readRunnerEventsAsync(join(files.directory, 'missing.ndjson'))).resolves.toEqual([])
|
|
|
- appendRunnerEvent(files.eventsPath, { type: 'started', pid: 123 })
|
|
|
- appendRunnerEvent(files.eventsPath, {
|
|
|
- type: 'runner-error',
|
|
|
- error: { name: 'Error', message: 'runner failed' },
|
|
|
- })
|
|
|
- appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: null, signal: 'SIGTERM' })
|
|
|
- appendRunnerEvent(files.eventsPath, {
|
|
|
- type: 'spawn-error',
|
|
|
- error: {
|
|
|
- name: 'Error',
|
|
|
- message: 'spawn failed',
|
|
|
- code: 'ENOENT',
|
|
|
- errno: -2,
|
|
|
- syscall: 'spawn missing',
|
|
|
- path: 'missing',
|
|
|
- spawnargs: ['argument'],
|
|
|
- },
|
|
|
- })
|
|
|
- await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual([
|
|
|
- { type: 'started', pid: 123 },
|
|
|
- { type: 'runner-error', error: { name: 'Error', message: 'runner failed' } },
|
|
|
- { type: 'exit', exitCode: null, signal: 'SIGTERM' },
|
|
|
- {
|
|
|
- type: 'spawn-error',
|
|
|
- error: {
|
|
|
- name: 'Error',
|
|
|
- message: 'spawn failed',
|
|
|
- code: 'ENOENT',
|
|
|
- errno: -2,
|
|
|
- syscall: 'spawn missing',
|
|
|
- path: 'missing',
|
|
|
- spawnargs: ['argument'],
|
|
|
- },
|
|
|
- },
|
|
|
- ])
|
|
|
- writeFileSync(files.eventsPath, '{"type":"started","pid":123}\n{"type":"exit"')
|
|
|
- await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual([{ type: 'started', pid: 123 }])
|
|
|
- for (const event of [null, []]) {
|
|
|
- writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`)
|
|
|
- await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted invalid event')
|
|
|
- }
|
|
|
- writeFileSync(files.eventsPath, '{"type":"unknown"}\n')
|
|
|
- await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted unknown event')
|
|
|
- await expect(readRunnerEventsAsync(files.directory)).rejects.toThrow()
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
+ const runnerHost = new FakeRunnerHost()
|
|
|
+ await runWindows(runnerHost, internals({
|
|
|
+ loadWin32ProcessBindings: vi.fn(() => { throw new Error('binding failed') }),
|
|
|
+ }))
|
|
|
+ expect(runnerHost.sent).toMatchObject([{ type: 'runner-error', error: { message: 'binding failed' } }])
|
|
|
+ expect(runnerHost.exitCode).toBe(127)
|
|
|
|
|
|
- it.each([
|
|
|
- ['started without a pid', { type: 'started' }],
|
|
|
- ['started with a non-number pid', { type: 'started', pid: '1' }],
|
|
|
- ['started with a fractional pid', { type: 'started', pid: 1.5 }],
|
|
|
- ['started with a non-positive pid', { type: 'started', pid: 0 }],
|
|
|
- ['exit with a missing code', { type: 'exit', signal: null }],
|
|
|
- ['exit with a non-number code', { type: 'exit', exitCode: '0', signal: null }],
|
|
|
- ['exit with a fractional code', { type: 'exit', exitCode: 1.5, signal: null }],
|
|
|
- ['exit with a negative code', { type: 'exit', exitCode: -1, signal: null }],
|
|
|
- ['exit with a non-string signal', { type: 'exit', exitCode: 0, signal: 9 }],
|
|
|
- ['exit with an unknown signal', { type: 'exit', exitCode: 0, signal: 'NOT_A_SIGNAL' }],
|
|
|
- ['spawn error without an object', { type: 'spawn-error', error: null }],
|
|
|
- ['spawn error without a name', { type: 'spawn-error', error: { message: 'failed' } }],
|
|
|
- ['spawn error without a message', { type: 'spawn-error', error: { name: 'Error' } }],
|
|
|
- ['spawn error with a numeric code', { type: 'spawn-error', error: { name: 'Error', message: 'failed', code: 1 } }],
|
|
|
- ['spawn error with a string errno', { type: 'spawn-error', error: { name: 'Error', message: 'failed', errno: '1' } }],
|
|
|
- ['spawn error with a numeric syscall', { type: 'spawn-error', error: { name: 'Error', message: 'failed', syscall: 1 } }],
|
|
|
- ['spawn error with a numeric path', { type: 'spawn-error', error: { name: 'Error', message: 'failed', path: 1 } }],
|
|
|
- ['spawn error with non-array args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: 'arg' } }],
|
|
|
- ['spawn error with non-string args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: [1] } }],
|
|
|
- ])('rejects an invalid event payload: %s', async (_label, event) => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`)
|
|
|
- await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted invalid event')
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
+ const cancelledHost = new FakeRunnerHost()
|
|
|
+ const native = internals()
|
|
|
+ const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(cancelledHost), native)
|
|
|
+ cancelledHost.emit('message', { type: 'terminate' })
|
|
|
+ cancelledHost.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
|
|
|
+ await running
|
|
|
+ expect(cancelledHost.sent).toEqual([{ type: 'start-cancelled' }])
|
|
|
+ expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
- it('creates private request files and preserves Node-shaped error fields', () => {
|
|
|
- const files = createRunnerFiles({ argv: [process.execPath], cwd: process.cwd(), env: {} })
|
|
|
- try {
|
|
|
- if (process.platform !== 'win32') expect(statSync(files.requestPath).mode & 0o777).toBe(0o600)
|
|
|
- const source = Object.assign(new Error('spawn missing ENOENT'), {
|
|
|
- code: 'ENOENT',
|
|
|
- errno: -2,
|
|
|
- syscall: 'spawn missing',
|
|
|
- path: 'missing',
|
|
|
- spawnargs: ['literal $VALUE'],
|
|
|
- })
|
|
|
- const restored = deserializeSpawnError(serializeSpawnError(source)) as NodeJS.ErrnoException & {
|
|
|
- path?: string
|
|
|
- spawnargs?: string[]
|
|
|
- }
|
|
|
- expect(restored).toMatchObject({
|
|
|
- message: 'spawn missing ENOENT',
|
|
|
- code: 'ENOENT',
|
|
|
- errno: -2,
|
|
|
- syscall: 'spawn missing',
|
|
|
- path: 'missing',
|
|
|
- spawnargs: ['literal $VALUE'],
|
|
|
- })
|
|
|
- const minimal = serializeSpawnError('plain failure')
|
|
|
- expect(minimal).toEqual({ name: 'Error', message: 'plain failure' })
|
|
|
- expect(deserializeSpawnError(minimal)).toMatchObject({ name: 'Error', message: 'plain failure' })
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it('maps runner failures and missing direct results', async () => {
|
|
|
- const runnerFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- appendRunnerEvent(runnerFailure.eventsPath, {
|
|
|
- type: 'runner-error',
|
|
|
- error: { name: 'Error', message: 'runner setup failed', code: 'EIO' },
|
|
|
- })
|
|
|
- const result = runnerDirectResult(fakeChild(123), runnerFailure, new Promise<void>(() => {}))
|
|
|
- expect(result.pid).toBeUndefined()
|
|
|
- await expect(result.direct).rejects.toMatchObject({ message: 'runner setup failed', code: 'EIO' })
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(runnerFailure)
|
|
|
- }
|
|
|
-
|
|
|
- const afterStartFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- appendRunnerEvent(afterStartFailure.eventsPath, { type: 'started', pid: 456 })
|
|
|
- const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise<void>(() => {}))
|
|
|
- const directFailure = result.direct.catch((error: unknown) => error)
|
|
|
- appendRunnerEvent(afterStartFailure.eventsPath, {
|
|
|
- type: 'runner-error',
|
|
|
- error: { name: 'Error', message: 'post-start runner failed', code: 'EIO' },
|
|
|
- })
|
|
|
- await vi.waitFor(() => { expect(result.pid).toBe(456) })
|
|
|
- await expect(directFailure).resolves.toMatchObject({ message: 'post-start runner failed', code: 'EIO' })
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(afterStartFailure)
|
|
|
- }
|
|
|
-
|
|
|
- const missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 })
|
|
|
- const result = runnerDirectResult(fakeChild(123), missing, Promise.resolve())
|
|
|
- await vi.waitFor(() => { expect(result.pid).toBe(456) })
|
|
|
- await expect(result.direct).rejects.toThrow('exited without a direct-command result')
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(missing)
|
|
|
- }
|
|
|
-
|
|
|
+ it('cancels after accepting start but before target commit', async () => {
|
|
|
+ const host = new FakeRunnerHost()
|
|
|
+ const native = internals()
|
|
|
+ const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native)
|
|
|
+ host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
|
|
|
+ host.emit('message', { type: 'terminate' })
|
|
|
+ await running
|
|
|
+ expect(host.sent).toEqual([{ type: 'start-cancelled' }])
|
|
|
+ expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
- it('publishes terminal events already present when asynchronous observation starts', async () => {
|
|
|
- const failed = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- appendRunnerEvent(failed.eventsPath, {
|
|
|
- type: 'spawn-error',
|
|
|
- error: { name: 'Error', message: 'target missing', code: 'ENOENT' },
|
|
|
- })
|
|
|
- const result = runnerDirectResult(fakeChild(123), failed, new Promise<void>(() => {}))
|
|
|
- await expect(result.direct).rejects.toMatchObject({ message: 'target missing', code: 'ENOENT' })
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(failed)
|
|
|
- }
|
|
|
-
|
|
|
- const exited = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- appendRunnerEvent(exited.eventsPath, { type: 'started', pid: 456 })
|
|
|
- appendRunnerEvent(exited.eventsPath, { type: 'exit', exitCode: 23, signal: null })
|
|
|
- const result = runnerDirectResult(fakeChild(123), exited, new Promise<void>(() => {}))
|
|
|
- await expect(result.direct).resolves.toEqual({ exitCode: 23, signal: null })
|
|
|
- expect(result.pid).toBe(456)
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(exited)
|
|
|
- }
|
|
|
+ it('does not create a target after pre-commit IPC disconnect', async () => {
|
|
|
+ const host = new FakeRunnerHost()
|
|
|
+ const native = internals()
|
|
|
+ const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native)
|
|
|
+ host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
|
|
|
+ host.disconnect()
|
|
|
+ await running
|
|
|
+ await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
|
|
|
+ expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled()
|
|
|
+ expect(native.terminateJob).not.toHaveBeenCalled()
|
|
|
+ expect(host.exitCode).toBe(127)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('terminates and closes the unique Job immediately when IPC disconnects', async () => {
|
|
|
+ const host = new FakeRunnerHost()
|
|
|
+ const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) })
|
|
|
+ const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native)
|
|
|
+ host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
|
|
|
+ await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
|
|
|
+ host.disconnect()
|
|
|
+ await running
|
|
|
+ expect(native.terminateJob).toHaveBeenCalledWith(expect.anything(), 20n, 1)
|
|
|
+ expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 20n, 'ordinary process Job cleanup')
|
|
|
+ expect(host.exitCode).toBe(127)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('honors terminate after commit and treats result-send failure as infrastructure failure', async () => {
|
|
|
+ const host = new FakeRunnerHost()
|
|
|
+ const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) })
|
|
|
+ const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native)
|
|
|
+ host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
|
|
|
+ await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
|
|
|
+ host.emit('message', { type: 'terminate' })
|
|
|
+ host.emit('message', { type: 'terminate' })
|
|
|
+ expect(native.terminateJob).toHaveBeenCalledWith(expect.anything(), 20n, 1)
|
|
|
+ host.disconnect()
|
|
|
+ await running
|
|
|
+
|
|
|
+ const sendFailureHost = new FakeRunnerHost()
|
|
|
+ sendFailureHost.sendFailure = new Error('send failed')
|
|
|
+ const sendFailureNative = internals({ isJobEmpty: vi.fn(() => false) })
|
|
|
+ await runWindows(sendFailureHost, sendFailureNative)
|
|
|
+ expect(sendFailureNative.terminateJob).toHaveBeenCalledWith(expect.anything(), 20n, 1)
|
|
|
+ expect(sendFailureNative.closeHandleChecked).toHaveBeenCalledWith(
|
|
|
+ expect.anything(),
|
|
|
+ 20n,
|
|
|
+ 'ordinary process Job cleanup',
|
|
|
+ )
|
|
|
+ expect(sendFailureHost.exitCode).toBe(127)
|
|
|
})
|
|
|
|
|
|
- it('requires an event snapshot started after wrapper exit before reporting a missing result', async () => {
|
|
|
- const staleRead = Promise.withResolvers<Awaited<ReturnType<typeof readRunnerEventsAsync>>>()
|
|
|
- let readCount = 0
|
|
|
- vi.resetModules()
|
|
|
- vi.doMock('../src/runner-protocol.ts', async (importOriginal) => {
|
|
|
- const actual = await importOriginal<typeof import('../src/runner-protocol.ts')>()
|
|
|
- return {
|
|
|
- ...actual,
|
|
|
- readRunnerEventsAsync: vi.fn(async (eventsPath: string) => {
|
|
|
- readCount += 1
|
|
|
- if (readCount === 1) return staleRead.promise
|
|
|
- return actual.readRunnerEventsAsync(eventsPath)
|
|
|
- }),
|
|
|
- }
|
|
|
+ it('handles commit-time termination reentrancy and termination failure', async () => {
|
|
|
+ const reentrantHost = new FakeRunnerHost()
|
|
|
+ const reentrant = internals({
|
|
|
+ closeCurrentProcessStandardHandles: vi.fn(() => {
|
|
|
+ reentrantHost.emit('message', { type: 'terminate' })
|
|
|
+ }),
|
|
|
+ pollProcessExit: vi.fn(() => undefined),
|
|
|
+ isJobEmpty: vi.fn(() => false),
|
|
|
})
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
|
|
|
- const exited = Promise.withResolvers<undefined>()
|
|
|
- const isolated = await import('../src/runner-launch.ts')
|
|
|
- const result = isolated.runnerDirectResult(fakeChild(123), files, exited.promise)
|
|
|
- expect(readCount).toBe(1)
|
|
|
- exited.resolve(undefined)
|
|
|
- await Promise.resolve()
|
|
|
- appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null })
|
|
|
- staleRead.resolve([{ type: 'started', pid: 456 }])
|
|
|
- await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
|
|
|
- expect(readCount).toBe(2)
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- vi.doUnmock('../src/runner-protocol.ts')
|
|
|
- vi.resetModules()
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it('reports a missing direct result at runner exit without waiting for pipe close', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
|
|
|
- const child = new EventEmitter() as ChildProcess
|
|
|
- Object.assign(child, { pid: 123, exitCode: null, signalCode: null })
|
|
|
- const lifecycle = observeChildLifecycle(child)
|
|
|
- const result = runnerDirectResult(child, files, lifecycle.exited)
|
|
|
- child.emit('exit', 1, null)
|
|
|
- await expect(result.direct).rejects.toThrow('exited without a direct-command result')
|
|
|
- child.emit('close', 1, null)
|
|
|
- await lifecycle.closed
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
- })
|
|
|
-
|
|
|
- it('contains wrapper spawn errors while publishing the runner startup rejection', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- const child = spawn(`missing-dsh-native-runner-${String(process.pid)}-${String(Date.now())}`, [], {
|
|
|
- stdio: 'ignore',
|
|
|
- })
|
|
|
- const lifecycle = observeChildLifecycle(child)
|
|
|
- const result = runnerDirectResult(child, files, lifecycle.exited)
|
|
|
- expect(result.pid).toBeUndefined()
|
|
|
- await expect(result.direct).rejects.toThrow('runner failed to start')
|
|
|
- await expect(lifecycle.closed).resolves.toBeUndefined()
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
+ const reentrantRun = runSpawnRunner(
|
|
|
+ WINDOWS_RUNNER_SELECTION,
|
|
|
+ ['--', 'tool.exe'],
|
|
|
+ hostArgument(reentrantHost),
|
|
|
+ reentrant,
|
|
|
+ )
|
|
|
+ reentrantHost.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
|
|
|
+ await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
|
|
|
+ expect(reentrant.terminateJob).toHaveBeenCalledTimes(2)
|
|
|
+ reentrantHost.disconnect()
|
|
|
+ await reentrantRun
|
|
|
+
|
|
|
+ const failedHost = new FakeRunnerHost()
|
|
|
+ const failed = internals({
|
|
|
+ pollProcessExit: vi.fn(() => undefined),
|
|
|
+ isJobEmpty: vi.fn(() => false),
|
|
|
+ terminateJob: vi.fn(() => { throw new Error('terminate Job failed') }),
|
|
|
+ })
|
|
|
+ const failedRun = runSpawnRunner(
|
|
|
+ WINDOWS_RUNNER_SELECTION,
|
|
|
+ ['--', 'tool.exe'],
|
|
|
+ hostArgument(failedHost),
|
|
|
+ failed,
|
|
|
+ )
|
|
|
+ failedHost.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
|
|
|
+ await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
|
|
|
+ failedHost.emit('message', { type: 'terminate' })
|
|
|
+ await failedRun
|
|
|
+ expect(failedHost.sent).toMatchObject([{ type: 'runner-error', error: { message: 'terminate Job failed' } }])
|
|
|
})
|
|
|
|
|
|
- it('returns before target publication and updates the pid getter from runner events', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- try {
|
|
|
- const result = runnerDirectResult(fakeChild(process.pid), files, new Promise<void>(() => {}))
|
|
|
- expect(result.pid).toBeUndefined()
|
|
|
- appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
|
|
|
- await vi.waitFor(() => { expect(result.pid).toBe(456) })
|
|
|
- appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null })
|
|
|
- await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null })
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
+ it('finishes when a later poll observes Job emptiness after result delivery', async () => {
|
|
|
+ const host = new FakeRunnerHost()
|
|
|
+ const native = internals({
|
|
|
+ pollProcessExit: vi.fn().mockReturnValueOnce(0).mockReturnValue(undefined),
|
|
|
+ isJobEmpty: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
|
|
|
+ })
|
|
|
+ const running = runSpawnRunner(
|
|
|
+ WINDOWS_RUNNER_SELECTION,
|
|
|
+ ['--', 'tool.exe'],
|
|
|
+ hostArgument(host),
|
|
|
+ native,
|
|
|
+ )
|
|
|
+ host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
|
|
|
+ await running
|
|
|
+ expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }])
|
|
|
+ expect(native.isJobEmpty).toHaveBeenCalledTimes(2)
|
|
|
})
|
|
|
|
|
|
- it('cleans runner files only after the direct result and runner close settle', async () => {
|
|
|
- const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
|
|
|
- const closed = Promise.withResolvers<undefined>()
|
|
|
- cleanupAfterRunner(files, Promise.resolve({ exitCode: 0, signal: null }), closed.promise)
|
|
|
- await new Promise(resolve => setImmediate(resolve))
|
|
|
- expect(existsSync(files.directory)).toBe(true)
|
|
|
- closed.resolve(undefined)
|
|
|
- await new Promise(resolve => setImmediate(resolve))
|
|
|
- expect(existsSync(files.directory)).toBe(false)
|
|
|
- })
|
|
|
+ it('contains poll failures and queued ticks after disconnect', async () => {
|
|
|
+ const failedHost = new FakeRunnerHost()
|
|
|
+ await runWindows(failedHost, internals({
|
|
|
+ pollProcessExit: vi.fn(() => { throw new Error('poll failed') }),
|
|
|
+ }))
|
|
|
+ expect(failedHost.sent).toMatchObject([{ type: 'runner-error', error: { message: 'poll failed' } }])
|
|
|
|
|
|
- it('reports the direct target pid and exit outcome from the source entry', async () => {
|
|
|
- const files = createRunnerFiles({
|
|
|
- argv: [process.execPath, '-e', 'process.exit(7)'],
|
|
|
- cwd: process.cwd(),
|
|
|
- env: {},
|
|
|
+ let tick: (() => void) | undefined
|
|
|
+ const interval = vi.spyOn(globalThis, 'setInterval').mockImplementation((callback: () => void) => {
|
|
|
+ tick = callback
|
|
|
+ return 1 as unknown as ReturnType<typeof setInterval>
|
|
|
})
|
|
|
try {
|
|
|
- const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
|
|
|
- expect(result.error).toBeUndefined()
|
|
|
- const events = await readRunnerEventsAsync(files.eventsPath)
|
|
|
- expect(events).toHaveLength(2)
|
|
|
- expect(events[0]?.type).toBe('started')
|
|
|
- if (events[0]?.type !== 'started') throw new Error('expected started event')
|
|
|
- expect(events[0].pid).toBeGreaterThan(0)
|
|
|
- expect(events[1]).toEqual({ type: 'exit', exitCode: 7, signal: null })
|
|
|
+ const host = new FakeRunnerHost()
|
|
|
+ const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) })
|
|
|
+ const running = runSpawnRunner(
|
|
|
+ WINDOWS_RUNNER_SELECTION,
|
|
|
+ ['--', 'tool.exe'],
|
|
|
+ hostArgument(host),
|
|
|
+ native,
|
|
|
+ )
|
|
|
+ host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
|
|
|
+ await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
|
|
|
+ tick?.()
|
|
|
+ host.disconnect()
|
|
|
+ await running
|
|
|
+ tick?.()
|
|
|
} finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
+ interval.mockRestore()
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('preserves literal argv, cwd, and the exact target environment', () => {
|
|
|
- const files = createRunnerFiles({
|
|
|
- argv: [
|
|
|
- process.execPath,
|
|
|
- '-e',
|
|
|
- 'console.log(JSON.stringify({ cwd: process.cwd(), value: process.env.RUNNER_VALUE, arg: process.argv[1] }))',
|
|
|
- 'literal $HOME ${UNCHANGED}',
|
|
|
- ],
|
|
|
- cwd: process.cwd(),
|
|
|
- env: { RUNNER_VALUE: 'explicit' },
|
|
|
- })
|
|
|
- try {
|
|
|
- const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
|
|
|
- expect(result.status).toBe(0)
|
|
|
- expect(result.stdout.trim()).toBe(JSON.stringify({
|
|
|
- cwd: process.cwd(),
|
|
|
- value: 'explicit',
|
|
|
- arg: 'literal $HOME ${UNCHANGED}',
|
|
|
- }))
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
+ it('cleans a direct handle after the Job identity was already cleared', async () => {
|
|
|
+ const host = new FakeRunnerHost()
|
|
|
+ const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => true) })
|
|
|
+ const running = runSpawnRunner(
|
|
|
+ WINDOWS_RUNNER_SELECTION,
|
|
|
+ ['--', 'tool.exe'],
|
|
|
+ hostArgument(host),
|
|
|
+ native,
|
|
|
+ )
|
|
|
+ host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} })
|
|
|
+ await new Promise<void>((resolveImmediate) => { setImmediate(resolveImmediate) })
|
|
|
+ host.emit('message', { type: 'terminate' })
|
|
|
+ host.disconnect()
|
|
|
+ await running
|
|
|
+ expect(native.closeHandleChecked).toHaveBeenCalledWith(
|
|
|
+ expect.anything(), 10n, 'ordinary direct process cleanup',
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
- it('reports target spawn failure without executing a fallback command', async () => {
|
|
|
- const files = createRunnerFiles({
|
|
|
- argv: [`missing-dsh-runner-${Date.now()}`],
|
|
|
- cwd: process.cwd(),
|
|
|
- env: {},
|
|
|
- })
|
|
|
- try {
|
|
|
- const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath)
|
|
|
- expect(result.error).toBeUndefined()
|
|
|
- const events = await readRunnerEventsAsync(files.eventsPath)
|
|
|
- expect(events).toHaveLength(1)
|
|
|
- expect(events[0]).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT' } })
|
|
|
- } finally {
|
|
|
- cleanupRunnerFiles(files)
|
|
|
- }
|
|
|
+ it('fails closed for malformed or duplicate start messages and disconnected reporting', async () => {
|
|
|
+ const malformed = new FakeRunnerHost()
|
|
|
+ await runWindows(malformed, internals(), { type: 'start', cwd: 'C:\\x', env: {}, extra: true })
|
|
|
+ expect(malformed.sent).toMatchObject([{ type: 'runner-error' }])
|
|
|
+
|
|
|
+ const duplicate = new FakeRunnerHost()
|
|
|
+ const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) })
|
|
|
+ const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(duplicate), native)
|
|
|
+ duplicate.emit('message', { type: 'start', cwd: 'C:\\x', env: {} })
|
|
|
+ duplicate.emit('message', { type: 'start', cwd: 'C:\\x', env: {} })
|
|
|
+ await running
|
|
|
+ expect(duplicate.sent).toMatchObject([{ type: 'runner-error' }])
|
|
|
+
|
|
|
+ const raced = new FakeRunnerHost()
|
|
|
+ const racedRun = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(raced), internals())
|
|
|
+ const lateMessage = raced.listeners('message')[0] as ((value: unknown) => void) | undefined
|
|
|
+ const lateDisconnect = raced.listeners('disconnect')[0] as (() => void) | undefined
|
|
|
+ raced.emit('message', { type: 'bad' })
|
|
|
+ raced.emit('message', { type: 'bad' })
|
|
|
+ await racedRun
|
|
|
+ await Promise.resolve()
|
|
|
+ lateMessage?.({ type: 'bad' })
|
|
|
+ lateDisconnect?.()
|
|
|
+
|
|
|
+ const disconnected = new FakeRunnerHost()
|
|
|
+ disconnected.connected = false
|
|
|
+ await reportSpawnRunnerFailure(WINDOWS_RUNNER_SELECTION, new Error('early'), hostArgument(disconnected))
|
|
|
+ expect(disconnected.exitCode).toBe(127)
|
|
|
+
|
|
|
+ const connected = new FakeRunnerHost()
|
|
|
+ connected.sendThrown = new Error('synchronous send failure')
|
|
|
+ await reportSpawnRunnerFailure(WINDOWS_RUNNER_SELECTION, new Error('early'), hostArgument(connected))
|
|
|
+ expect(connected.exitCode).toBe(127)
|
|
|
+ expect(connected.connected).toBe(false)
|
|
|
+
|
|
|
+ const noSelection = new FakeRunnerHost()
|
|
|
+ await reportSpawnRunnerFailure(undefined, new Error('no selector'), hostArgument(noSelection))
|
|
|
+ expect(noSelection.exitCode).toBe(127)
|
|
|
})
|
|
|
-
|
|
|
})
|