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

View File

@@ -1090,6 +1090,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// stops the backend's directory scan instead of outliving it.
return ok(request, await capability.list(request.payload.path, signal))
} catch (error: unknown) {
// An abort is the caller's own timeout/disconnect, not a server
// failure — same code pickDirectory and command.execute report.
if (signal.aborted) {
return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} })
}
return err(request, directoryError(error))
}
},

View File

@@ -193,6 +193,20 @@ describe('host.listDirectory / host.createDirectory', () => {
})
})
it('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => {
const { api } = await harness(undefined, {
kind: 'browse',
list: (_path, signal) => new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
}),
createDirectory: async () => '/never',
})
const abort = new AbortController()
const pending = api.host.listDirectory(request({}), abort.signal)
abort.abort()
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
})
it('refuses the browse RPCs under a native composition', async () => {
const { api } = await harness()
expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({