fix(host): cancellable listing scans and O(log window) insertion

capability.list gains an optional AbortSignal threaded from the RPC
carrier's request signal (the pickDirectory pattern): a disconnected or
timed-out caller stops the opendir loop instead of the scan outliving
its caller, and the abort surfaces as its own reason rather than a
directory-unreadable dressing. boundedInsert rejects a full window's
at-or-beyond-tail candidate on one comparison and binary-inserts
retained candidates, so an oversized level no longer pays a window scan
per dirent.
This commit is contained in:
creatixchu
2026-07-29 04:42:13 +08:00
parent da970ea269
commit 7503390590
17 changed files with 68 additions and 27 deletions
@@ -74,9 +74,22 @@ export interface ListingCandidate {
* @returns true when an eviction happened (the level has candidates beyond the window).
*/
export function boundedInsert(window: ListingCandidate[], candidate: ListingCandidate, keep: number): boolean {
const at = window.findIndex(existing => candidate.name.localeCompare(existing.name) < 0)
if (at === -1) window.push(candidate)
else window.splice(at, 0, candidate)
// Full window, name at or beyond the tail: one comparison rejects, so an
// oversized level costs O(1) per candidate past the head instead of a
// window scan (100k children against a 1,001 window must not approach
// 10^8 comparisons).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- a full window (length === keep >= 1) has a tail
if (window.length === keep && candidate.name.localeCompare(window[window.length - 1]!.name) >= 0) return true
// Binary insertion keeps a retained candidate at O(log keep) comparisons.
let lo = 0
let hi = window.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
if (candidate.name.localeCompare(window[mid]!.name) < 0) hi = mid
else lo = mid + 1
}
window.splice(lo, 0, candidate)
if (window.length <= keep) return false
window.pop()
return true
@@ -131,7 +144,7 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
private readonly browseCapability: DirectoryPickerCapability = {
kind: 'browse',
list: path => this.list(path),
list: (path, signal) => this.list(path, signal),
createDirectory: (path, name) => this.createDirectory(path, name),
}
@@ -147,7 +160,7 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
return this.browseCapability
}
private async list(path?: string): Promise<DirectoryListing> {
private async list(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
const home = homedir()
// The seam contract takes fully qualified paths only; resolve() would
// silently rebase a relative or empty wire value under the host process
@@ -169,6 +182,9 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
try {
const level = await opendir(target)
for await (const dirent of level) {
// A disconnected/timed-out caller stops the scan here; throwing out
// of the loop closes the directory handle via the iterator's return.
signal?.throwIfAborted()
// Only rows a browser could enter contend for the window; dirent
// says "directory" outright, a symlink needs the later stat probe.
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue
@@ -176,6 +192,8 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
if (boundedInsert(window, candidate, keep)) evicted = true
}
} catch (error: unknown) {
// An abort is the caller's own reason, not an unreadable directory.
signal?.throwIfAborted()
throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`)
}
const entries: DirectoryEntry[] = []