Bläddra i källkod

test(subprocess): cover native settlement edges

pku-xht 1 månad sedan
förälder
incheckning
da042b8e10

+ 6 - 1
packages/subprocess/subprocess-local/src/managed-owner.ts

@@ -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

+ 18 - 8
packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts

@@ -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)),

+ 41 - 0
packages/subprocess/subprocess-local/tests/native-windows.spec.ts

@@ -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))