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:
co-authored by
Claude Opus 5
parent
b1523d3e42
commit
73813e4738
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user