fix(aiui): a context gather that hangs must not strand the request

Reported: "`files` context request times out". sanitizeFiles makes three
sequential calls into the File Browser app — login, getUsage, listDirectory
— wrapped in a try/catch. A catch only sees a REJECTION. A socket that
connects and then says nothing leaves the promise pending forever, so
handleContextRequest never posts a `context:response` and the AIUI side sits
until its own bridge timeout instead. The File Browser is a plausible source
of exactly that: on this node `/app/filebrowser/api/resources/` does not even
route (404), and its session-cookie path is the subject of a separate open
bug.

The guard goes at handleContextRequest rather than inside sanitizeFiles, so
no category — present or future — can strand the bridge. `files` is merely
the one with three network hops today; sanitizeSystem is also async.

withTimeout resolves rather than rejects, because the caller's one job is to
always answer, and a rejection would just relocate the problem into a catch.
A late null is safe by the protocol's existing shape: the AIUI reader
already treats a response with no usable data as "nothing to show", the same
as an empty category.

Two tests: a never-settling File Browser still produces a
`context:response`, and a healthy category still returns real data rather
than being flattened to null. 27/27 contextBroker, vue-tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-08 09:18:44 -04:00
co-authored by Claude Opus 5
parent b1523d3e42
commit 73813e4738
2 changed files with 103 additions and 1 deletions
@@ -21,6 +21,7 @@ vi.mock('@/api/filebrowser-client', () => ({
import { ContextBroker } from '../contextBroker'
import { useAIPermissionsStore } from '@/stores/aiPermissions'
import { rpcClient } from '@/api/rpc-client'
import { fileBrowserClient } from '@/api/filebrowser-client'
describe('ContextBroker', () => {
let broker: ContextBroker
@@ -438,4 +439,61 @@ describe('ContextBroker', () => {
])
})
})
describe('context gathering always answers', () => {
it('responds when the gatherer never settles — the reported files hang', async () => {
// A File Browser that accepts the connection and then says nothing:
// the promise stays PENDING rather than rejecting, which is precisely
// what sanitizeFiles' try/catch cannot see. Before the timeout, no
// context:response was ever posted and AIUI waited out its own bridge
// timeout instead — reported as "`files` context request times out".
vi.useFakeTimers()
try {
const perms = useAIPermissionsStore()
perms.enableAll()
;(fileBrowserClient.login as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise(() => {}),
)
const pending = (
broker as unknown as {
handleContextRequest(id: string, category: string): Promise<void>
}
).handleContextRequest('req-hang', 'files')
await vi.advanceTimersByTimeAsync(10_000)
await pending
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'context:response',
id: 'req-hang',
data: null,
permitted: true,
}),
expect.anything(),
)
} finally {
vi.useRealTimers()
}
})
it('a healthy category still returns its data, not null', async () => {
const perms = useAIPermissionsStore()
perms.enableAll()
await (
broker as unknown as {
handleContextRequest(id: string, category: string): Promise<void>
}
).handleContextRequest('req-ok', 'apps')
const posted = mockPostMessage.mock.calls.find(
(c) => (c[0] as { id?: string }).id === 'req-ok',
)
expect(posted).toBeDefined()
expect((posted![0] as { permitted: boolean }).permitted).toBe(true)
expect((posted![0] as { data: unknown }).data).not.toBeNull()
})
})
})
+45 -1
View File
@@ -91,6 +91,38 @@ function mergeBundles(bundles: ArchyContentBundle[]): ArchyContentBundle {
)
}
/**
* Ceiling on one `context:request` gather. Comfortably above a healthy
* File Browser round trip (login + usage + list) and far below AIUI's own
* bridge timeout, so a stall surfaces here as an empty category rather than
* there as a dead request.
*/
const CONTEXT_FETCH_TIMEOUT_MS = 10_000
/**
* Resolve to `null` if `p` has not settled within `ms`.
*
* Deliberately resolves rather than rejects: the caller's job is to always
* post a `context:response`, and a rejection would just move the problem to
* a catch block. The pending promise is left to finish on its own — nothing
* downstream reads it once we have answered.
*/
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(null), ms)
p.then(
(v) => {
clearTimeout(timer)
resolve(v)
},
() => {
clearTimeout(timer)
resolve(null)
},
)
})
}
/**
* Context Broker — mediates all communication between AIUI (iframe) and Archy.
*
@@ -580,7 +612,19 @@ export class ContextBroker {
return
}
const data = await this.fetchAndSanitize(category, query)
// Always answer, even if the gatherer never settles. `sanitizeFiles`
// makes three sequential calls into the File Browser app (login, usage,
// list) and `sanitizeSystem` awaits the app store; a connection that
// HANGS rather than erroring leaves their promise pending forever, so no
// `context:response` is ever posted and the AIUI side waits out its own
// bridge timeout instead. That is the reported "`files` context request
// times out" — the try/catch in sanitizeFiles only covers rejection, and
// a stalled socket does not reject.
//
// A late `null` is safe by the protocol's own shape: the AIUI reader
// treats a response with no usable `data` as "nothing to show" and logs
// nothing, exactly as it already does for an empty category.
const data = await withTimeout(this.fetchAndSanitize(category, query), CONTEXT_FETCH_TIMEOUT_MS)
this.postToIframe({
type: 'context:response',
id,