test(subprocess): cover native settlement edges
This commit is contained in:
1 parent
eb17028720
commit
da042b8e10
3 files changed
+65
-9
No files matched your search
@@ -57,7 +57,12 @@ export function observeChildLifecycle(child: ChildProcess): {
|
||||
* @returns true on completion, false when the bound aborts first.
|
||||
*/
|
||||
export async function waitWithAbort(pending: Promise<void>, signal?: AbortSignal): Promise<boolean> {
|
||||
if (signal?.aborted) return false
|
||||
if (signal?.aborted) {
|
||||
void pending.catch(() => {
|
||||
// This caller declined the wait; a later caller still observes the cached rejection.
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (signal === undefined) {
|
||||
await pending
|
||||
return true
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { BoundProcessOwner } from '../src/managed-owner.ts'
|
||||
import { waitWithAbort } from '../src/managed-owner.ts'
|
||||
import { bindManagedProcess } from '../src/spawn.ts'
|
||||
|
||||
function spec(graceMs = 30): SubprocessSpawnSpec {
|
||||
@@ -15,6 +16,16 @@ function spec(graceMs = 30): SubprocessSpawnSpec {
|
||||
}
|
||||
|
||||
describe('managed process binding', () => {
|
||||
it('contains owner failure after an already-aborted wait returns false', async () => {
|
||||
const controller = new AbortController()
|
||||
const ownerFailure = Promise.withResolvers<undefined>()
|
||||
controller.abort()
|
||||
|
||||
await expect(waitWithAbort(ownerFailure.promise, controller.signal)).resolves.toBe(false)
|
||||
ownerFailure.reject(new Error('owner unavailable'))
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
})
|
||||
|
||||
it('keeps direct outcome separate from managed-range quiescence', async () => {
|
||||
const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -75,32 +86,31 @@ describe('managed process binding', () => {
|
||||
expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('waits for raw and collected output streams after the direct outcome', async () => {
|
||||
it.each([
|
||||
['raw', 'pipe'],
|
||||
['collected', { maxBytes: 1024 }],
|
||||
] as const)('waits for %s output EOF after the direct outcome', async (_label, stdoutMode) => {
|
||||
const stdout = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
|
||||
const request = {
|
||||
...spec(1_000),
|
||||
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 1024 } } as const,
|
||||
stdio: { stdin: 'ignore', stdout: stdoutMode, stderr: 'inherit' } as const,
|
||||
}
|
||||
const handle = bindManagedProcess(request, {
|
||||
stdin: null,
|
||||
stdout,
|
||||
stderr,
|
||||
stderr: null,
|
||||
pid: 4242,
|
||||
direct: direct.promise,
|
||||
owner: { signal: vi.fn(), waitForExit: async () => {} },
|
||||
})
|
||||
stdout.resume()
|
||||
if (stdoutMode === 'pipe') stdout.resume()
|
||||
let doneSettled = false
|
||||
void handle.done.then(() => { doneSettled = true })
|
||||
direct.resolve({ exitCode: 23, signal: null })
|
||||
await Promise.resolve()
|
||||
expect(doneSettled).toBe(false)
|
||||
stdout.end()
|
||||
await Promise.resolve()
|
||||
expect(doneSettled).toBe(false)
|
||||
stderr.end()
|
||||
await expect(Promise.race([
|
||||
handle.done,
|
||||
new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 100)),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process'
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { bindManagedProcess } from '../src/spawn.ts'
|
||||
@@ -153,6 +154,46 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes target identity before reporting cwd restoration failure', async () => {
|
||||
const preload = join(scratch, `fail-runner-cwd-restore-${Date.now()}.mjs`)
|
||||
writeFileSync(preload, `
|
||||
const originalChdir = process.chdir.bind(process)
|
||||
let calls = 0
|
||||
process.chdir = (path) => {
|
||||
calls += 1
|
||||
if (calls === 2) {
|
||||
const error = new Error('injected runner cwd restoration failure')
|
||||
error.code = 'ENOENT'
|
||||
error.syscall = 'chdir'
|
||||
throw error
|
||||
}
|
||||
originalChdir(path)
|
||||
}
|
||||
`)
|
||||
const previousNodeOptions = process.env.NODE_OPTIONS
|
||||
process.env.NODE_OPTIONS = [previousNodeOptions, `--import=${pathToFileURL(preload).href}`]
|
||||
.filter((value): value is string => value !== undefined && value.length > 0)
|
||||
.join(' ')
|
||||
try {
|
||||
const command = process.env.ComSpec ?? process.env.COMSPEC
|
||||
if (command === undefined) throw new Error('expected ComSpec for the Windows runner test')
|
||||
const request = spec([command, '/d', '/s', '/c', 'exit 0'])
|
||||
const launch = launchWindowsJob(request)
|
||||
expect(launch.pid).toBeGreaterThan(0)
|
||||
const failure = await launch.direct.catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({
|
||||
message: 'injected runner cwd restoration failure',
|
||||
code: 'ENOENT',
|
||||
syscall: 'chdir',
|
||||
})
|
||||
expect(failure).not.toHaveProperty('path')
|
||||
await expect(launch.owner.waitForExit()).rejects.toThrow('before proving its managed range empty')
|
||||
} finally {
|
||||
if (previousNodeOptions === undefined) Reflect.deleteProperty(process.env, 'NODE_OPTIONS')
|
||||
else process.env.NODE_OPTIONS = previousNodeOptions
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves missing-target and invalid-executable rejection errors', async () => {
|
||||
const relativeExecutable = `relative-node-${String(Date.now())}.exe`
|
||||
copyFileSync(process.execPath, join(scratch, relativeExecutable))
|
||||
|
||||
Reference in new issue
Block a user