mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(agent-readiness): WebMCP readiness wait + teardown on destroy (#3316)
Addresses three findings on PR #3361. P1 — startup race. Early registration is required for scanner probes, but a tool invoked during the window between register and Phase-4 UI init threw "Search modal is not initialised yet." Both scanners and agents that probe-and-invoke hit this. Bindings now await a uiReady promise that resolves after searchManager.init and countryIntel.init. A 10s timeout keeps a broken init from hanging the caller. After readiness, a still-null target is a real failure and still throws. Mechanics: App constructor builds uiReady as a Promise with its resolve stored on the instance; Phase-4 end calls resolveUiReady; waitForUiReady races uiReady against a timeout; both bindings await it. P2 — AbortController was returned and dropped. registerWebMcpTools returns a controller so callers can unregister on teardown, but App discarded it. Stored on App now and aborted in destroy, so test harnesses and SPA re-inits don't accumulate stale registrations. P2 — test coverage. Added assertions for: bindings await waitForUiReady before accessing state; resolveUiReady fires after countryIntel.init; waitForUiReady uses Promise.race with a timeout; destroy aborts the stored controller. Kept silent-success guard assertions so bindings still throw when state is absent post-readiness. Tests: 16 webmcp, 6682 full suite, all green.
This commit is contained in:
61
src/App.ts
61
src/App.ts
@@ -113,6 +113,18 @@ export class App {
|
||||
private modules: { destroy(): void }[] = [];
|
||||
private unsubAiFlow: (() => void) | null = null;
|
||||
private unsubFreeTier: (() => void) | null = null;
|
||||
// Resolves once Phase-4 UI modules (searchManager, countryIntel) have
|
||||
// initialised so WebMCP bindings can await readiness before touching
|
||||
// the nullable UI targets. Avoids the startup race where an agent
|
||||
// discovers a tool via early registerTool and invokes it before the
|
||||
// target panel exists.
|
||||
private uiReady!: Promise<void>;
|
||||
private resolveUiReady!: () => void;
|
||||
// Returned by registerWebMcpTools when running in a registerTool-capable
|
||||
// browser — aborting it unregisters every tool. destroy() triggers it
|
||||
// so that test harnesses / same-document re-inits don't accumulate
|
||||
// duplicate registrations.
|
||||
private webMcpController: AbortController | null = null;
|
||||
private visiblePanelPrimed = new Set<string>();
|
||||
private visiblePanelPrimeRaf: number | null = null;
|
||||
private bootstrapHydrationState: BootstrapHydrationState = getBootstrapHydrationState();
|
||||
@@ -418,6 +430,10 @@ export class App {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) throw new Error(`Container ${containerId} not found`);
|
||||
|
||||
this.uiReady = new Promise<void>((resolve) => {
|
||||
this.resolveUiReady = resolve;
|
||||
});
|
||||
|
||||
const PANEL_ORDER_KEY = 'panel-order';
|
||||
const PANEL_SPANS_KEY = 'worldmonitor-panel-spans';
|
||||
|
||||
@@ -808,20 +824,24 @@ export class App {
|
||||
// WebMCP — register synchronously before any init awaits so agent
|
||||
// scanners (isitagentready.com, in-browser agents) find the tools on
|
||||
// their first probe. No-op in browsers without navigator.modelContext.
|
||||
// Bindings close over lazy refs; when a tool fires later and its UI
|
||||
// target is still null they throw, and the invocation-logging shim in
|
||||
// webmcp.ts surfaces that as isError:true instead of a silent success.
|
||||
registerWebMcpTools({
|
||||
// Bindings await `this.uiReady` (resolves after Phase-4 UI init) so
|
||||
// a tool invoked during the startup window waits for the target
|
||||
// panel to exist instead of throwing. A 10s timeout keeps a genuinely
|
||||
// broken state from hanging the caller. Store the returned controller
|
||||
// so destroy() can unregister every tool on teardown.
|
||||
this.webMcpController = registerWebMcpTools({
|
||||
openCountryBriefByCode: async (code, country) => {
|
||||
await this.waitForUiReady();
|
||||
if (!this.state.countryBriefPage) {
|
||||
throw new Error('Country brief panel is not initialised yet');
|
||||
throw new Error('Country brief panel is not initialised');
|
||||
}
|
||||
await this.countryIntel.openCountryBriefByCode(code, country);
|
||||
},
|
||||
resolveCountryName: (code) => CountryIntelManager.resolveCountryName(code),
|
||||
openSearch: () => {
|
||||
openSearch: async () => {
|
||||
await this.waitForUiReady();
|
||||
if (!this.state.searchModal) {
|
||||
throw new Error('Search modal is not initialised yet');
|
||||
throw new Error('Search modal is not initialised');
|
||||
}
|
||||
this.state.searchModal.open();
|
||||
},
|
||||
@@ -1056,6 +1076,8 @@ export class App {
|
||||
this.searchManager.init();
|
||||
this.eventHandlers.setupMapLayerHandlers();
|
||||
this.countryIntel.init();
|
||||
// Unblock any WebMCP tool invocations that arrived during startup.
|
||||
this.resolveUiReady();
|
||||
|
||||
// Phase 5: Event listeners + URL sync
|
||||
this.eventHandlers.init();
|
||||
@@ -1208,6 +1230,31 @@ export class App {
|
||||
this.cachedModeBannerEl = null;
|
||||
this.state.map?.destroy();
|
||||
disconnectAisStream();
|
||||
// Unregister every WebMCP tool so a same-document re-init (tests,
|
||||
// HMR, SPA harness) doesn't leave the browser with stale bindings
|
||||
// pointing at a disposed App.
|
||||
this.webMcpController?.abort();
|
||||
this.webMcpController = null;
|
||||
}
|
||||
|
||||
// Waits for Phase-4 UI modules (searchManager + countryIntel) to finish
|
||||
// initialising. WebMCP bindings call this before touching nullable UI
|
||||
// state so a tool invoked during startup waits rather than throwing;
|
||||
// the timeout guards against a genuinely broken init path hanging the
|
||||
// agent forever.
|
||||
private async waitForUiReady(timeoutMs = 10_000): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`UI did not initialise within ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.race([this.uiReady, timeout]);
|
||||
} finally {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private handleDeepLinks(): void {
|
||||
|
||||
@@ -55,7 +55,11 @@ interface NavigatorWithWebMcp extends Navigator {
|
||||
export interface WebMcpAppBindings {
|
||||
openCountryBriefByCode(code: string, country: string): Promise<void>;
|
||||
resolveCountryName(code: string): string;
|
||||
openSearch(): void;
|
||||
// Returns a Promise because implementations may await a readiness signal
|
||||
// (e.g. waiting for the search modal to exist during startup) before
|
||||
// dispatching. Tool executes must `await` it so rejections surface to
|
||||
// withInvocationLogging's catch path.
|
||||
openSearch(): void | Promise<void>;
|
||||
}
|
||||
|
||||
const ISO2 = /^[A-Z]{2}$/;
|
||||
@@ -118,7 +122,7 @@ export function buildWebMcpTools(app: WebMcpAppBindings): WebMcpTool[] {
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: withInvocationLogging('openSearch', async () => {
|
||||
app.openSearch();
|
||||
await app.openSearch();
|
||||
return textResult('Opened search palette.');
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -87,11 +87,14 @@ describe('webmcp.ts: tool behaviour (source-level invariants)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// App.ts wiring — guards against silent-success bugs where a binding
|
||||
// forwards to a nullable UI target whose no-op the tool then falsely
|
||||
// reports as success. Bindings MUST throw when the target is absent
|
||||
// so withInvocationLogging's catch path can return isError:true.
|
||||
describe('webmcp App.ts binding: guard against silent success', () => {
|
||||
// App.ts wiring — guards against two classes of bug:
|
||||
// (1) Silent success when a binding forwards to a nullable UI target.
|
||||
// (2) Startup race when a tool is invoked during the window between
|
||||
// early registration (needed for scanners) and Phase-4 UI init.
|
||||
// Bindings await a readiness signal before touching UI state and fall
|
||||
// through to a throw if the signal never resolves; withInvocationLogging
|
||||
// converts that throw into isError:true.
|
||||
describe('webmcp App.ts binding: readiness + teardown', () => {
|
||||
const appSrc = readFileSync(resolve(ROOT, 'src/App.ts'), 'utf-8');
|
||||
const bindingBlock = appSrc.match(
|
||||
/registerWebMcpTools\(\{[\s\S]+?\}\);/,
|
||||
@@ -128,17 +131,61 @@ describe('webmcp App.ts binding: guard against silent success', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('openSearch binding throws when searchModal is absent', () => {
|
||||
it('both bindings await the UI-readiness signal before touching state', () => {
|
||||
// Before-fix regression: openSearch threw immediately on first
|
||||
// invocation during startup. Both bindings must wait for Phase-4
|
||||
// UI init to complete, then check the state, then dispatch.
|
||||
assert.match(
|
||||
bindingBlock[0],
|
||||
/openSearch:[\s\S]+?await this\.waitForUiReady\(\)[\s\S]+?this\.state\.searchModal/,
|
||||
'openSearch must await waitForUiReady() before accessing searchModal',
|
||||
);
|
||||
assert.match(
|
||||
bindingBlock[0],
|
||||
/openCountryBriefByCode:[\s\S]+?await this\.waitForUiReady\(\)[\s\S]+?this\.state\.countryBriefPage/,
|
||||
'openCountryBriefByCode must await waitForUiReady() before accessing countryBriefPage',
|
||||
);
|
||||
});
|
||||
|
||||
it('bindings still throw (not silently succeed) when state is absent after readiness', () => {
|
||||
// The silent-success guard from PR #3356 review must survive the
|
||||
// readiness refactor. After awaiting readiness, a missing target is
|
||||
// a real failure — throw so withInvocationLogging returns isError.
|
||||
assert.match(
|
||||
bindingBlock[0],
|
||||
/openSearch:[\s\S]+?if \(!this\.state\.searchModal\)[\s\S]+?throw new Error/,
|
||||
);
|
||||
});
|
||||
|
||||
it('openCountryBriefByCode binding throws when countryBriefPage is absent', () => {
|
||||
assert.match(
|
||||
bindingBlock[0],
|
||||
/openCountryBriefByCode:[\s\S]+?if \(!this\.state\.countryBriefPage\)[\s\S]+?throw new Error/,
|
||||
);
|
||||
});
|
||||
|
||||
it('uiReady is resolved after Phase-4 UI modules initialise', () => {
|
||||
// waitForUiReady() hangs forever if nothing ever resolves uiReady.
|
||||
// The resolve must live right after countryIntel.init() so that all
|
||||
// Phase-4 modules are ready by the time waiters unblock.
|
||||
assert.match(
|
||||
appSrc,
|
||||
/this\.countryIntel\.init\(\);[\s\S]{0,200}this\.resolveUiReady\(\)/,
|
||||
'resolveUiReady() must fire after countryIntel.init() in Phase 4',
|
||||
);
|
||||
});
|
||||
|
||||
it('waitForUiReady enforces a timeout so a broken init cannot hang the agent', () => {
|
||||
assert.match(
|
||||
appSrc,
|
||||
/private async waitForUiReady\(timeoutMs = [\d_]+\)[\s\S]+?Promise\.race\(\[this\.uiReady/,
|
||||
);
|
||||
});
|
||||
|
||||
it('destroy() aborts the WebMCP controller so re-inits do not duplicate registrations', () => {
|
||||
const destroyBody = appSrc.match(/public destroy\(\): void \{([\s\S]+?)\n \}/);
|
||||
assert.ok(destroyBody, 'could not locate destroy() body');
|
||||
assert.match(
|
||||
destroyBody[1],
|
||||
/this\.webMcpController\?\.abort\(\)/,
|
||||
'destroy() must abort the stored WebMCP AbortController',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user