Ver Fonte

fix(bash-local): contain spill close failures

Tianyi Cui há 3 meses atrás
pai
commit
a9c36ad576
2 ficheiros alterados com 53 adições e 1 exclusões
  1. 9 1
      packages/bash-local/src/run.ts
  2. 44 0
      packages/bash-local/tests/run.spec.ts

+ 9 - 1
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()

+ 44 - 0
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<typeof import('node:fs')>()
+  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<Parameters<typeof runBash>[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<typeof collector.finalize>
+    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', () => {