From 73813e47385a0b4cbb964622a134c32daade062b Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 8 Aug 2026 09:18:44 -0400 Subject: [PATCH] fix(aiui): a context gather that hangs must not strand the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../services/__tests__/contextBroker.test.ts | 58 +++++++++++++++++++ neode-ui/src/services/contextBroker.ts | 46 ++++++++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/neode-ui/src/services/__tests__/contextBroker.test.ts b/neode-ui/src/services/__tests__/contextBroker.test.ts index 3499d3bb..479d077b 100644 --- a/neode-ui/src/services/__tests__/contextBroker.test.ts +++ b/neode-ui/src/services/__tests__/contextBroker.test.ts @@ -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).mockReturnValue( + new Promise(() => {}), + ) + + const pending = ( + broker as unknown as { + handleContextRequest(id: string, category: string): Promise + } + ).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 + } + ).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() + }) + }) }) diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index d09bfd41..244b60ad 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -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(p: Promise, ms: number): Promise { + 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,