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 { ContextBroker } from '../contextBroker'
|
||||||
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||||
|
|
||||||
describe('ContextBroker', () => {
|
describe('ContextBroker', () => {
|
||||||
let broker: 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.
|
* Context Broker — mediates all communication between AIUI (iframe) and Archy.
|
||||||
*
|
*
|
||||||
@@ -580,7 +612,19 @@ export class ContextBroker {
|
|||||||
return
|
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({
|
this.postToIframe({
|
||||||
type: 'context:response',
|
type: 'context:response',
|
||||||
id,
|
id,
|
||||||
|
|||||||
Reference in New Issue
Block a user