conformance/src/runner/server.ts
Avi Seth 88ac3e595f
fix(runner): bound server scenarios with a per-scenario timeout (#454)
The client runner has had a 30s per-scenario timeout since it was written,
but `runServerConformanceTest` awaited `scenario.run(ctx)` with no bound. A
server under test that accepts the TCP connection and then never answers an
initialize POST, a tools call, or an SSE read stalled the entire suite run,
not just its own scenario.

#316 bounded the session-termination DELETEs in teardown, but that is a
call-site fix: covering the rest that way needs the same treatment on every
raw fetch in every scenario. Bounding at the runner makes those per-call
bounds defense-in-depth instead of the only protection.

`--timeout` now applies to server mode too, with the same 30s default as
client mode. On expiry the scenario reports a `scenario-timeout` FAILURE
naming the elapsed bound, so a wedged server reads as a failing scenario
rather than a hung process.

The timed-out scenario promise is left pending deliberately — a read blocked
on a socket has no cancellation channel — with its rejection swallowed so a
late failure can't surface against whichever scenario is running by then.

Fixes #427

Co-authored-by: Avi Seth <aviseth@users.noreply.github.com>
2026-09-06 20:14:58 +01:00

237 lines
7 KiB
TypeScript

import { promises as fs } from 'fs';
import path from 'path';
import {
ConformanceCheck,
SpecVersion,
LATEST_SPEC_VERSION,
DRAFT_PROTOCOL_VERSION
} from '../types';
import { getClientScenario, isScenarioApplicableAt } from '../scenarios';
import { connectFor, type RunContext } from '../connection';
import {
resetWireValidation,
wireSchemaChecks
} from '../validation/wire-schema';
import { createResultDir, formatPrettyChecks } from './utils';
/**
* Format markdown-style text for terminal output using ANSI codes
*/
function formatMarkdown(text: string): string {
return (
text
// Bold text: **text** -> bold
.replace(/\*\*([^*]+)\*\*/g, '\x1b[1m$1\x1b[0m')
// Inline code: `code` -> dim/gray
.replace(/`([^`]+)`/g, '\x1b[2m$1\x1b[0m')
);
}
/**
* Bound `scenario.run` so a server that accepts connections but never answers
* fails one scenario instead of stalling the whole suite.
*
* The losing promise is left pending on purpose: a scenario blocked on a socket
* read has no cancellation channel, so there is nothing to await. Its rejection
* is swallowed to keep a late failure from surfacing as an unhandled rejection
* against whichever scenario happens to be running by then.
*/
async function runScenarioBounded(
run: Promise<ConformanceCheck[]>,
scenarioName: string,
timeout: number
): Promise<ConformanceCheck[]> {
const timedOut = Symbol('timed-out');
let timeoutHandle: NodeJS.Timeout | undefined;
run.catch(() => {});
const result = await Promise.race([
run,
new Promise<typeof timedOut>((resolve) => {
timeoutHandle = setTimeout(() => resolve(timedOut), timeout);
})
]);
clearTimeout(timeoutHandle);
if (result !== timedOut) {
return result;
}
console.log(`\nScenario timed out after ${timeout}ms`);
return [
{
id: 'scenario-timeout',
name: 'Scenario completes within the timeout',
description:
'The scenario must finish within the configured timeout. A server that ' +
'accepts the connection but never responds leaves it running forever.',
status: 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: `Scenario '${scenarioName}' did not complete within ${timeout}ms. The server under test accepted the connection but did not finish the exchange.`
}
];
}
export async function runServerConformanceTest(
serverUrl: string,
scenarioName: string,
outputDir?: string,
specVersion?: SpecVersion,
force = false,
timeout: number = 30000
): Promise<{
checks: ConformanceCheck[];
resultDir?: string;
scenarioDescription: string;
skipped?: boolean;
}> {
let resultDir: string | undefined;
if (outputDir) {
resultDir = createResultDir(outputDir, scenarioName, 'server');
await fs.mkdir(resultDir, { recursive: true });
}
// Scenario is guaranteed to exist by CLI validation
const scenario = getClientScenario(scenarioName)!;
// An explicitly-requested spec version outside the scenario's applicability
// window is a contradiction: running anyway would test something other than
// what the flag claims. Skip (exit 0) unless --force.
if (
specVersion !== undefined &&
!force &&
!isScenarioApplicableAt(scenario.source, specVersion)
) {
const introduced =
'introducedIn' in scenario.source
? `introduced in ${scenario.source.introducedIn}` +
(scenario.source.removedIn !== undefined
? `, removed in ${scenario.source.removedIn}`
: '')
: 'extension scenario, not on the spec timeline';
console.log(
`SKIPPED: scenario '${scenarioName}' is not applicable at spec version ` +
`${specVersion} (${introduced}). Use --force to run it anyway.`
);
return {
checks: [],
resultDir,
scenarioDescription: scenario.description,
skipped: true
};
}
// When --spec-version is omitted, infer the version from the scenario's
// declared source so draft-only scenarios get the draft (stateless)
// connection rather than the stateful latest-spec default. Extension
// scenarios are off-timeline; today every extension in this repo lives
// on draft, so they fall under the same inference.
const resolvedSpecVersion =
specVersion ??
('extensionId' in scenario.source ||
scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION
? DRAFT_PROTOCOL_VERSION
: LATEST_SPEC_VERSION);
console.log(
`Running client scenario '${scenarioName}' against server: ${serverUrl}`
);
const ctx: RunContext = {
serverUrl,
specVersion: resolvedSpecVersion,
connect: (opts) => connectFor(resolvedSpecVersion)(serverUrl, opts)
};
resetWireValidation();
const checks = await runScenarioBounded(
scenario.run(ctx),
scenarioName,
timeout
);
checks.push(...wireSchemaChecks(resolvedSpecVersion));
if (resultDir) {
await fs.writeFile(
path.join(resultDir, 'checks.json'),
JSON.stringify(checks, null, 2)
);
console.log(`Results saved to ${resultDir}`);
}
return {
checks,
resultDir,
scenarioDescription: scenario.description
};
}
export function printServerResults(
checks: ConformanceCheck[],
scenarioDescription: string,
verbose: boolean = false
): {
passed: number;
failed: number;
denominator: number;
warnings: number;
} {
const denominator = checks.filter(
(c) => c.status === 'SUCCESS' || c.status === 'FAILURE'
).length;
const passed = checks.filter((c) => c.status === 'SUCCESS').length;
const failed = checks.filter((c) => c.status === 'FAILURE').length;
const warnings = checks.filter((c) => c.status === 'WARNING').length;
if (verbose) {
console.log(JSON.stringify(checks, null, 2));
} else {
console.log(`Checks:\n${formatPrettyChecks(checks)}`);
}
console.log(`\nTest Results:`);
console.log(
`Passed: ${passed}/${denominator}, ${failed} failed, ${warnings} warnings`
);
if (failed > 0) {
console.log('\n=== Failed Checks ===');
checks
.filter((c) => c.status === 'FAILURE')
.forEach((c) => {
console.log(`\n - ${c.name}: ${c.description}`);
if (c.errorMessage) {
console.log(` Error: ${c.errorMessage}`);
}
console.log(`\n${formatMarkdown(scenarioDescription)}`);
});
}
return { passed, failed, denominator, warnings };
}
export function printServerSummary(
allResults: { scenario: string; checks: ConformanceCheck[] }[]
): { totalPassed: number; totalFailed: number } {
console.log('\n\n=== SUMMARY ===');
let totalPassed = 0;
let totalFailed = 0;
for (const result of allResults) {
const passed = result.checks.filter((c) => c.status === 'SUCCESS').length;
const failed = result.checks.filter((c) => c.status === 'FAILURE').length;
totalPassed += passed;
totalFailed += failed;
const status = failed === 0 ? '✓' : '✗';
console.log(
`${status} ${result.scenario}: ${passed} passed, ${failed} failed`
);
}
console.log(`\nTotal: ${totalPassed} passed, ${totalFailed} failed`);
return { totalPassed, totalFailed };
}