diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index ab021be141..0ab4c03964 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -285,7 +285,13 @@ export class LocalBashExecutor extends ShellExecutor { }, (error: unknown) => { // Background provider failures settle as killed and surface through the read path. proc.status = 'killed' - providerFailureNote = `subprocess failed before reporting an outcome: ${String(error)}` + let detail = 'unprintable provider failure' + try { + detail = String(error) + } catch { + // Provider-owned rejection values cannot make ShellProcess.done reject. + } + providerFailureNote = `subprocess failed before reporting an outcome: ${detail}` this.onProcessDone(proc, providerFailureNote, true, error) }), readOutput: (): ShellProcessRead => { diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index 7668012964..9cc63475da 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -322,6 +322,32 @@ describe('LocalBashExecutor.start (background process handles)', () => { expect(proc.readOutput().delta).toBe('') }) + it('settles an unprintable provider rejection instead of rejecting done', async () => { + const { ctx, bash } = await setup() + const emptyReader: SubprocessOutputReader = { + readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), + } + const providerError = new Error('unprintable provider error') + Object.defineProperty(providerError, Symbol.toPrimitive, { + value: () => { throw new Error('provider formatting must not escape') }, + }) + vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({ + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: { stdout: emptyReader, stderr: emptyReader }, + done: Promise.reject(providerError), + terminate: vi.fn(), + waitForExit: async () => true, + } satisfies SubprocessHandle) + + const proc = bash.start(bash.resolve({ command: 'true' })) + await expect(proc.done).resolves.toBeUndefined() + expect(proc.status).toBe('killed') + expect(proc.readOutput().delta).toContain('unprintable provider failure') + expect(proc.readOutput().delta).toBe('') + }) + it('an asynchronous creation failure settles as killed with a stage-neutral note', async () => { const { bash } = await setup() const proc = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' })) diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index 92770a2742..99d7d1564b 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -314,7 +314,13 @@ export class PwshLocalExecutor extends ShellExecutor { }, (error: unknown) => { // Background provider failures settle as killed and surface through the read path. proc.status = 'killed' - providerFailureNote = `subprocess failed before reporting an outcome: ${String(error)}` + let detail = 'unprintable provider failure' + try { + detail = String(error) + } catch { + // Provider-owned rejection values cannot make ShellProcess.done reject. + } + providerFailureNote = `subprocess failed before reporting an outcome: ${detail}` this.onProcessDone(proc, providerFailureNote, true, error) }), readOutput: (): ShellProcessRead => { diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 341826fa5a..3997ac5787 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -212,6 +212,23 @@ describe('spawn construction (pure, every platform)', () => { expect(output).not.toContain('spawn failed:') expect(proc.readOutput().delta).toBe('') }) + + it('settles an unprintable provider rejection instead of rejecting done', async () => { + const ctx = new Context() + const subprocess = new CapturingSubprocessRuntime(ctx) + await ctx.plugin(PwshLocalExecutor) + const providerError = new Error('unprintable provider error') + Object.defineProperty(providerError, Symbol.toPrimitive, { + value: () => { throw new Error('provider formatting must not escape') }, + }) + subprocess.done = Promise.reject(providerError) + + const proc = ctx.shell.start(ctx.shell.resolve({ command: 'Write-Output maybe-ran' })) + await expect(proc.done).resolves.toBeUndefined() + expect(proc.status).toBe('killed') + expect(proc.readOutput().delta).toContain('unprintable provider failure') + expect(proc.readOutput().delta).toBe('') + }) }) describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 80cd06c781..7352e55623 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -156,7 +156,7 @@ interface DirectRange { } class SystemdScopeOwner implements BoundProcessOwner { - private establishment: 'pending' | 'established' | 'never-created' = 'pending' + private establishment: 'pending' | 'established' = 'pending' private stopped = false private observation: Promise | undefined private killFailure: Error | undefined @@ -229,7 +229,6 @@ class SystemdScopeOwner implements BoundProcessOwner { this.observeRequestConsumption() if (this.establishment === 'established') return false if (!this.direct.running() && existsSync(this.files.requestPath)) { - this.establishment = 'never-created' return false } if (this.killFailure !== undefined) throw this.killFailure diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index b17787a08e..b203ea4460 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -344,7 +344,13 @@ export function validateSubprocessSpec(spec: SubprocessSpawnSpec): void { throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } if (spec.signal?.aborted) { - throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) + let reason = 'aborted' + try { + reason = String(spec.signal.reason ?? reason) + } catch { + // Arbitrary caller-owned reasons cannot escape the stable Error boundary. + } + throw new Error(`aborted before spawn: ${reason}`) } const [program] = spec.argv if (program === undefined || program.length === 0) { diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index a2ef3262c1..d6d89b53de 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -141,7 +141,6 @@ export function launchWindowsJob( const rangeExit = Promise.withResolvers() let directResultType: WindowsRunnerResult['type'] | undefined let runnerSpawned = false - let runnerNeverCreated = false const failInfrastructure = (error: unknown): void => { direct.reject(error) rangeExit.reject(error) @@ -193,7 +192,6 @@ export function launchWindowsJob( }) child.once('error', (error) => { if (!runnerSpawned) { - runnerNeverCreated = true direct.reject(error) rangeExit.resolve() return @@ -201,7 +199,7 @@ export function launchWindowsJob( failInfrastructure(error) }) child.once('close', (exitCode, signal) => { - if (runnerNeverCreated) return + if (!runnerSpawned) return const clean = exitCode === 0 && signal === null && directResultType !== undefined if (clean) { rangeExit.resolve() diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index f268fd16e2..5916790b3b 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -323,6 +323,15 @@ describe('spawnSubprocess', () => { expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) }) .toThrow(new Error(message)) } + + const controller = new AbortController() + controller.abort({ + [Symbol.toPrimitive]() { + throw new Error('reason formatting must not escape') + }, + }) + expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) }) + .toThrow(new Error('aborted before spawn: aborted')) }) it('rejects with a spawn error for a nonexistent cwd', async () => {