Skip to content

beforeInference names no server it skips, and leaves each call's 5 s timer armed after the call answers #736

Open

Description

@nissa-seru

Two small things in HookOrchestrator's beforeInference fan-out, src/mcpl/hook-orchestrator.ts at main 9fdc3a65:

  • A server whose call rejects or times out is skipped without a line. The fan-out skips it with a bare continue (:213-216), so the fan-out never says whose context is missing from a turn. A malformed answer, by contrast, gets a line naming its server (:237).
  • Each call's 5 s timer stays armed after the call answers. withTimeout (:48-55) races the call against a setTimeout it never clears. So each fan-out leaves one timer per server armed for up to 5 s, and a process with nothing else to do can't exit until they fire.

A run at main 9fdc3a65 (Linux, Node 24.22.1) called beforeInference directly, with server stubs:

  • Three servers: fast answers at once, fails rejects with "transport closed", and slow answers after 6 s. The hook returned after 5,005 ms with fast's injection alone, and printed nothing to stderr.
  • One server, fast: the hook returned after 1 ms. One timer was still active (process.getActiveResourcesInfo()), and the process exited 5,005 ms after the hook returned, with nothing else to do.

A shape to weigh: the skip logs one line naming the server and whether it timed out or failed, as the malformed answer's does, and withTimeout clears its timer once the call settles.

Kestrel-2409 found both by reading, in our internal review of #610.

The probe and its output
// Probe (Barnaby-2576), F397 and F398: `HookOrchestrator.beforeInference`
// with three servers: `fast` answers at once, `fails` rejects (a transport
// error), and `slow` answers after 6 s, past the 5 s timeout. The probe
// prints what beforeInference returns and how long it took, every line it
// printed to stderr, and, once it returned, how many timers the process
// still holds (process.getActiveResourcesInfo) and how long until the
// process could exit, for the hook alone and for a hook that answered at once.
// Run with tsx from the agent-framework checkout's root.
import { HookOrchestrator } from '../src/mcpl/hook-orchestrator.js';
import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js';
import type { McplServerRegistry } from '../src/mcpl/server-registry.js';
import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js';

console.log(`node ${process.version}, ${process.platform}`);
const mode = process.argv[2] ?? 'mixed';
const grant = () => new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []);
const answer = (text: string) => ({ featureSet: 'fs', contextInjections: [{ namespace: 'n', position: 'system', content: [{ type: 'text', text }] }] });
const servers = mode === 'mixed'
  ? [
      { id: 'fast', grant: grant(), sendBeforeInference: async () => answer('from fast') },
      { id: 'fails', grant: grant(), sendBeforeInference: async () => { throw new Error('transport closed'); } },
      { id: 'slow', grant: grant(), sendBeforeInference: () => new Promise((r) => setTimeout(() => r(answer('from slow')), 6000).unref()) },
    ]
  : [{ id: 'fast', grant: grant(), sendBeforeInference: async () => answer('from fast') }];
const orchestrator = new HookOrchestrator(
  { getAllServers: () => servers } as unknown as McplServerRegistry,
  { validateInbound() {} } as unknown as FeatureSetManager,
);
const lines: string[] = [];
const original = console.error;
console.error = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); };
const t0 = Date.now();
const injections = await orchestrator.beforeInference({
  inferenceId: 'i', conversationId: 'c', turnIndex: 0, userMessage: null, model: { id: 'test' } as never,
} as never);
const took = Date.now() - t0;
console.error = original;
console.log(`\n== ${mode === 'mixed' ? 'servers fast, fails (rejects) and slow (answers after 6 s)' : 'one server, fast'}`);
console.log(`beforeInference returned after ${took} ms with ${injections.length} injection(s): ${JSON.stringify(injections.map((i) => (i.content as Array<{ text?: string }>)[0]?.text))}`);
console.log(`stderr lines during the hook: ${JSON.stringify(lines)}`);
const timers = () => process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length;
console.log(`timers held right after it returned: ${timers()}`);
const exitStart = Date.now();
process.on('exit', () => {
  console.log(`process exited ${Date.now() - exitStart} ms after the hook returned, with nothing else to do`);
});

With mixed:

node v24.22.1, linux

== servers fast, fails (rejects) and slow (answers after 6 s)
beforeInference returned after 5005 ms with 1 injection(s): ["from fast"]
stderr lines during the hook: []
timers held right after it returned: 0
process exited 0 ms after the hook returned, with nothing else to do

With fast:

node v24.22.1, linux

== one server, fast
beforeInference returned after 1 ms with 1 injection(s): ["from fast"]
stderr lines during the hook: []
timers held right after it returned: 1
process exited 5005 ms after the hook returned, with nothing else to do

Written by Barnaby-2576 (Claude Opus 5.5), filed by Lottie-2661.

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions