fix(e2b): close remote lifecycle gaps
This commit is contained in:
@@ -199,6 +199,7 @@ export class E2BFileSystem extends FileSystem {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
let sampledBytes = 0
|
||||
let completed = false
|
||||
try {
|
||||
while (true) {
|
||||
assertNotAborted(signal, 'read')
|
||||
@@ -222,9 +223,17 @@ export class E2BFileSystem extends FileSystem {
|
||||
} catch (error: unknown) {
|
||||
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
|
||||
}
|
||||
completed = true
|
||||
} catch (error: unknown) {
|
||||
throw mapError(error, 'read', displayPath, signal)
|
||||
} finally {
|
||||
if (!completed) {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch (_streamCancellationFailure) {
|
||||
// The primary read outcome owns the result; cancellation is best-effort after early stop.
|
||||
}
|
||||
}
|
||||
reader.releaseLock()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import E2BFileSystem from '@deepseek-ai/dsh-fs-e2b'
|
||||
import * as E2BFsInvariant from '../src/invariant.ts'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface RemoteNode {
|
||||
type: FileType
|
||||
@@ -38,6 +38,8 @@ class FakeRemote {
|
||||
readonly removals: string[] = []
|
||||
readonly commands: string[] = []
|
||||
streamChunks: Uint8Array[] | undefined
|
||||
streamKeepOpen = false
|
||||
readonly streamCancel = vi.fn()
|
||||
nextCommandError: unknown
|
||||
nextInfoError: unknown
|
||||
nextListError: unknown
|
||||
@@ -148,10 +150,11 @@ class FakeRemote {
|
||||
if (options.format === 'bytes') return data.slice()
|
||||
const chunks = this.streamChunks ?? [data.slice()]
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
start: (controller) => {
|
||||
for (const chunk of chunks) controller.enqueue(chunk)
|
||||
controller.close()
|
||||
if (!this.streamKeepOpen) controller.close()
|
||||
},
|
||||
cancel: () => { this.streamCancel() },
|
||||
})
|
||||
},
|
||||
list: async (path: string, options?: { depth?: number; signal?: AbortSignal }): Promise<EntryInfo[]> => {
|
||||
@@ -303,6 +306,22 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
|
||||
expect(initiallyBuffered).toBe('€')
|
||||
})
|
||||
|
||||
it('cancels a remote stream when its consumer stops early', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/text.txt', 'ab')
|
||||
remote.streamChunks = [bytes('a'), bytes('b')]
|
||||
remote.streamKeepOpen = true
|
||||
const { fs } = await setup(remote)
|
||||
const stream = await fs.streamText(await fs.resolve('text.txt'))
|
||||
|
||||
for await (const chunk of stream) {
|
||||
expect(chunk).toBe('a')
|
||||
break
|
||||
}
|
||||
|
||||
expect(remote.streamCancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('matches local binary sampling while edits still reject any NUL byte', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/late-nul.txt', `${'a'.repeat(8192)}\0tail`)
|
||||
|
||||
Reference in New Issue
Block a user