fix(host): race directory reads against the caller's signal; report aborts as cancelled

Every filesystem await in the browse scan (opendir and each read) now
races the signal through raceAbort, so a stalled network open/read stops
with a departed caller and an already-aborted request rejects even for
an empty level; the abandoned settlement is swallowed and an abandoned
open that still mints a handle is closed, never leaked. apiproxy maps an
aborted listing to the cancelled wire code, matching pickDirectory and
command.execute, instead of reporting a false internal failure. The
fixture spec call sites gain the wire signal argument the previous
commit's static lane flagged.
This commit is contained in:
creatixchu
2026-07-29 05:05:36 +08:00
parent 7503390590
commit baaa535235
6 changed files with 131 additions and 15 deletions
@@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker'
import BrowseDirectoryPicker, { boundedInsert, fullyQualified } from '../src/index.ts'
import BrowseDirectoryPicker, { boundedInsert, fullyQualified, raceAbort } from '../src/index.ts'
import type { ListingCandidate } from '../src/index.ts'
let root: string
@@ -86,8 +86,15 @@ describe('BrowseDirectoryPicker', () => {
it('stops the scan with the caller: an aborted signal rejects with its own reason', async () => {
const gone = new AbortController()
gone.abort(new Error('caller left'))
// The abort surfaces as-is, not dressed as an unreadable directory.
// The abort surfaces as-is, not dressed as an unreadable directory —
// and rejects even before any level row is read.
await expect(capability.list(root, gone.signal)).rejects.toThrow('caller left')
// The abandoned open that still succeeds is closed, not leaked.
await new Promise(resolve => setTimeout(resolve, 10))
// Aborted against a missing target: the abandoned open rejects on its
// own and there is nothing to close.
await expect(capability.list(join(root, 'no-such-dir'), gone.signal)).rejects.toThrow('caller left')
await new Promise(resolve => setTimeout(resolve, 10))
// A live signal changes nothing about ordinary failures.
const live = new AbortController()
const missing = join(root, 'no-such-dir')
@@ -96,6 +103,34 @@ describe('BrowseDirectoryPicker', () => {
expect((failure as DirectoryPickerError).code).toBe('directory-unreadable')
})
it('raceAbort follows the operation until the signal wins, and swallows the abandoned settlement', async () => {
// No signal / settled operations: plain passthrough, listener removed.
await expect(raceAbort(Promise.resolve('ok'), undefined)).resolves.toBe('ok')
const live = new AbortController()
await expect(raceAbort(Promise.resolve('ok'), live.signal)).resolves.toBe('ok')
// Failure passthrough keeps the operation's own error.
await expect(raceAbort(Promise.reject(new Error('raw failure')), live.signal)).rejects.toThrow('raw failure')
// The abort wins over a pending operation and carries its own reason;
// the operation's late rejection is swallowed, never unhandled.
const rejections: unknown[] = []
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
let rejectLate!: (reason: unknown) => void
const pending = new Promise<never>((_resolve, reject) => { rejectLate = reject })
const controller = new AbortController()
const raced = raceAbort(pending, controller.signal)
// A bare-string abort reason exercises the Error wrap.
controller.abort('caller left')
await expect(raced).rejects.toThrow('caller left')
rejectLate(new Error('late read failure'))
await new Promise(resolve => setTimeout(resolve, 10))
expect(rejections).toEqual([])
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => {
const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false })
const window: ListingCandidate[] = []