diff --git a/packages/bash-local/src/run.ts b/packages/bash-local/src/run.ts index 93551c8e6b..39e5de5d88 100644 --- a/packages/bash-local/src/run.ts +++ b/packages/bash-local/src/run.ts @@ -195,7 +195,15 @@ export class OutputCollector { /** Close the spill file (if any) and return the final output. */ finalize(): CollectedOutput { if (this.spillFd !== undefined) { - closeSync(this.spillFd) + try { + closeSync(this.spillFd) + } catch { + // close can surface delayed writeback failures (for example EIO/ENOSPC) + // after writeSync appeared to succeed. Keep finalize total so runBash's + // close handler still resolves, but stop advertising a spill file that + // may be missing its tail. + this.spillFile = undefined + } this.spillFd = undefined } return this.snapshot() diff --git a/packages/bash-local/tests/run.spec.ts b/packages/bash-local/tests/run.spec.ts index 7f17ba1a94..ff1d92b519 100644 --- a/packages/bash-local/tests/run.spec.ts +++ b/packages/bash-local/tests/run.spec.ts @@ -4,6 +4,21 @@ import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local' +const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } })) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + closeSync(fd: number): void { + if (failNextClose.value) { + failNextClose.value = false + throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' }) + } + actual.closeSync(fd) + }, + } +}) + const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-')) function spec(command: string, overrides: Partial[0]> = {}) { @@ -160,6 +175,19 @@ describe('output truncation and spill', () => { expect(result.stdout.text.length).toBe(500) expect(result.stdout.spillPath).toBeUndefined() }) + + it('settles with the tail and no spill path when final spill close fails', async () => { + failNextClose.value = true + const result = await runBash( + spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }), + { spillDir }, + ).done + expect(failNextClose.value).toBe(false) + expect(result.exitCode).toBe(0) + expect(result.stdout.truncated).toBe(true) + expect(result.stdout.text).toContain('line-0200') + expect(result.stdout.spillPath).toBeUndefined() + }) }) describe('OutputCollector', () => { @@ -200,6 +228,22 @@ describe('OutputCollector', () => { expect(collector.totalBytes).toBe(8) expect(collector.finalize().text).toBe('bbbb') }) + + it('contains close failures and drops the spill path', () => { + const collector = new OutputCollector(4, 'closefail', spillDir) + collector.push(Buffer.from('aaaa')) + collector.push(Buffer.from('bbbb')) + expect(collector.snapshot().spillPath).toBeDefined() + + failNextClose.value = true + let out: ReturnType + expect(() => { out = collector.finalize() }).not.toThrow() + + expect(failNextClose.value).toBe(false) + expect(out!.text).toBe('bbbb') + expect(out!.truncated).toBe(true) + expect(out!.spillPath).toBeUndefined() + }) }) describe('killGroup', () => {