diff --git a/scripts/lib/bounded-pnpm-script.ts b/scripts/lib/bounded-pnpm-script.ts index 75929be..56c1d27 100644 --- a/scripts/lib/bounded-pnpm-script.ts +++ b/scripts/lib/bounded-pnpm-script.ts @@ -3,7 +3,7 @@ import type { SpawnSyncOptionsWithStringEncoding } from "node:child_process"; export const PNPM_SCRIPT_TIMEOUT_MS = 60_000; export const PNPM_SCRIPT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; -type BoundedPnpmScriptInvocation = Readonly<{ +type BoundedChildInvocation = Readonly<{ command: string; arguments: readonly string[]; options: SpawnSyncOptionsWithStringEncoding; @@ -20,11 +20,25 @@ export function createBoundedPnpmScriptInvocation(input: Readonly<{ pnpmCli: string; script: string; environment: NodeJS.ProcessEnv; -}>): BoundedPnpmScriptInvocation { - return Object.freeze({ +}>): BoundedChildInvocation { + return createBoundedChildInvocation({ command: input.nodePath, - arguments: Object.freeze([input.pnpmCli, "run", input.script]), + arguments: [input.pnpmCli, "run", input.script], + environment: input.environment, + }); +} + +export function createBoundedChildInvocation(input: Readonly<{ + command: string; + arguments: readonly string[]; + environment: NodeJS.ProcessEnv; + cwd?: string; +}>): BoundedChildInvocation { + return Object.freeze({ + command: input.command, + arguments: Object.freeze([...input.arguments]), options: Object.freeze({ + ...(input.cwd === undefined ? {} : { cwd: input.cwd }), encoding: "utf8", env: input.environment, killSignal: "SIGTERM", diff --git a/scripts/lib/http-scenario-evidence.ts b/scripts/lib/http-scenario-evidence.ts index 59ecbf6..7280f5e 100644 --- a/scripts/lib/http-scenario-evidence.ts +++ b/scripts/lib/http-scenario-evidence.ts @@ -80,6 +80,26 @@ export const httpScenarioAssertionGroupsSchema = z if (groups.retry.count !== groups.retry.reasons.length) { fail(["retry", "count"], "must equal retry reasons length"); } + if (groups.retry.count !== groups.fetch.count - 1) { + fail(["retry", "count"], "must equal the non-final fetch attempt count"); + } + for (const [index, reason] of groups.retry.reasons.entries()) { + const status = groups.status.attempts[index]; + const expectedReason = + status === "NETWORK_REJECTION" + ? "NETWORK_FAILURE" + : status === 429 + ? "HTTP_429" + : status === 503 + ? "HTTP_503" + : null; + if (reason !== expectedReason) { + fail( + ["retry", "reasons", index], + "must match the corresponding non-final attempt status", + ); + } + } if (groups.fetch.count !== groups.status.attempts.length) { fail(["fetch", "count"], "must equal status attempts length"); } diff --git a/src/adapters/http/http-execution-v3.ts b/src/adapters/http/http-execution-v3.ts index 9b53c5f..7de66b1 100644 --- a/src/adapters/http/http-execution-v3.ts +++ b/src/adapters/http/http-execution-v3.ts @@ -166,6 +166,8 @@ export type ContractHttpExecutorDependencies = Readonly<{ }>, ): Promise | CredentialPatchOutcome; fetcher?: typeof fetch; + /** Adapter seam for the common bounded response reader. */ + readBoundedResponseBytes?: typeof readBoundedBytes; monotonicNow?: () => number; sleep?: (ms: number, signal: AbortSignal) => Promise; random?: () => number; @@ -276,6 +278,8 @@ export function createContractHttpExecutor( dependencies: ContractHttpExecutorDependencies, ): ContractHttpExecutor { const fetcher = dependencies.fetcher ?? fetch; + const readResponseBytes = + dependencies.readBoundedResponseBytes ?? readBoundedBytes; const now = dependencies.monotonicNow ?? (() => performance.now()); const random = dependencies.random ?? Math.random; const sleep = @@ -647,6 +651,7 @@ export function createContractHttpExecutor( response, context, attemptState, + readResponseBytes, ); attemptState = "SETTLED"; if ( @@ -722,6 +727,7 @@ async function admitResponse( response: Response, context: HttpExecutionContext, attemptState: PhysicalAttemptState, + readResponseBytes: typeof readBoundedBytes, ): Promise> { const contract = operation.contract; const policy = operation.frontend; @@ -777,7 +783,14 @@ async function admitResponse( retryAfterMs, }); } - return admitProblem(operation, response, status, metadata, attemptState); + return admitProblem( + operation, + response, + status, + metadata, + attemptState, + readResponseBytes, + ); } // Success status: body policy first. @@ -821,7 +834,7 @@ async function admitResponse( const emptyAllowed = contract.emptyBodyStatuses.includes(status); const mediaOk = isJsonMediaType(response.headers.get("content-type")); - const bytes = await readBoundedBytes(response, policy.responseByteLimit); + const bytes = await readResponseBytes(response, policy.responseByteLimit); if (!bytes.ok) { return settled( bytes.code === "RESPONSE_TOO_LARGE" @@ -958,12 +971,13 @@ async function admitProblem( status: number, metadata: SafeResponseMetadata, attemptState: PhysicalAttemptState, + readResponseBytes: typeof readBoundedBytes, ): Promise> { const contract = operation.contract; const isCommand = contract.commandEffect !== null; const retryable = RETRYABLE_STATUSES.has(status); - const bytes = await readBoundedBytes( + const bytes = await readResponseBytes( response, HTTP_EXECUTION_CEILINGS.problemResponseBytes, ); diff --git a/tests/fixtures/test-evidence/scenarios/receipt-semantic-invalid.json b/tests/fixtures/test-evidence/scenarios/receipt-semantic-invalid.json index d5a055d..4ad0566 100644 --- a/tests/fixtures/test-evidence/scenarios/receipt-semantic-invalid.json +++ b/tests/fixtures/test-evidence/scenarios/receipt-semantic-invalid.json @@ -13,7 +13,7 @@ "status": { "attempts": [200], "final": 200 }, "outcome": { "kind": "SUCCESS", "detail": null }, "effect": { "outcome": "NOT_APPLICABLE", "observer": "NOT_APPLICABLE" }, - "retry": { "count": 0, "reasons": [] }, + "retry": { "count": 1, "reasons": ["HTTP_503"] }, "fetch": { "count": 1, "observerAttempts": 1, "agrees": false }, "media": { "attempts": ["application/json"], "final": "application/json" }, "body": { "attempts": [{ "disposition": "FULLY_READ_WITHIN_BOUND", "pulledBytes": 2, "ceiling": 16 }] }, @@ -23,7 +23,7 @@ "status": { "attempts": [200], "final": 200 }, "outcome": { "kind": "SUCCESS", "detail": null }, "effect": { "outcome": "NOT_APPLICABLE", "observer": "NOT_APPLICABLE" }, - "retry": { "count": 0, "reasons": [] }, + "retry": { "count": 1, "reasons": ["HTTP_503"] }, "fetch": { "count": 1, "observerAttempts": 1, "agrees": false }, "media": { "attempts": ["application/json"], "final": "application/json" }, "body": { "attempts": [{ "disposition": "FULLY_READ_WITHIN_BOUND", "pulledBytes": 2, "ceiling": 16 }] }, diff --git a/tests/integration/http-scenario-catalog.test.ts b/tests/integration/http-scenario-catalog.test.ts index 2c17fb5..7cc783c 100644 --- a/tests/integration/http-scenario-catalog.test.ts +++ b/tests/integration/http-scenario-catalog.test.ts @@ -3,9 +3,9 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { readBoundedBytes } from "../../src/adapters/http/bounded-body-reader.ts"; import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts"; import { - HTTP_EXECUTION_CEILINGS, type InstalledHttpContract, } from "../../src/contracts/external-contract-runtime.ts"; import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts"; @@ -42,6 +42,7 @@ type AttemptTrace = { status: AttemptStatus; media: string | null; pulledBytes: number; + appliedCeiling: number | null; completed: boolean; cancelled: boolean; }; @@ -73,6 +74,7 @@ function tracingFetcher(attempts: AttemptTrace[]): typeof fetch { status: "PENDING_ABORT", media: null, pulledBytes: 0, + appliedCeiling: null, completed: false, cancelled: false, }; @@ -109,6 +111,9 @@ function tracingFetcher(attempts: AttemptTrace[]): typeof fetch { }, cancel(reason) { trace.cancelled = true; + // MSW-transferred oversized bodies leave the original cancel promise + // permanently pending. Record and request cancellation without making + // the proxy response less responsive than the real executor contract. void reader.cancel(reason).catch(() => {}); }, }, @@ -161,19 +166,6 @@ function bodyDisposition( throw new Error(`Response body was neither consumed nor cancelled: ${trace.status}`); } -function appliedBodyCeiling( - trace: AttemptTrace, - operation: InstalledHttpContract, -): number { - if (typeof trace.status !== "number") return 0; - if (operation.contract.acceptedStatuses.includes(trace.status)) { - return operation.frontend.responseByteLimit; - } - return [404, 409, 422, 503].includes(trace.status) - ? HTTP_EXECUTION_CEILINGS.problemResponseBytes - : 0; -} - async function waitForHandler( ready: Promise, executionId: string, @@ -240,6 +232,15 @@ async function executeScenario( }), fetcher, random: () => 0, + readBoundedResponseBytes: async (response, maximumBytes) => { + const trace = physicalAttempts.at(-1); + if (!trace) throw new Error("Body reader ran before a physical attempt"); + if (trace.appliedCeiling !== null) { + throw new Error("Body reader ran more than once for one attempt"); + } + trace.appliedCeiling = maximumBytes; + return readBoundedBytes(response, maximumBytes); + }, sleep: async () => { const prior = physicalAttempts.at(-1); if (!prior) throw new Error("Retry sleep occurred before an attempt"); @@ -331,7 +332,7 @@ async function executeScenario( Object.freeze({ disposition: bodyDisposition(attempt, outcome), pulledBytes: attempt.pulledBytes, - ceiling: appliedBodyCeiling(attempt, operation), + ceiling: attempt.appliedCeiling ?? 0, }), ), ), diff --git a/tests/unit/http-scenario-evidence.test.ts b/tests/unit/http-scenario-evidence.test.ts index 360bfd3..36f96ec 100644 --- a/tests/unit/http-scenario-evidence.test.ts +++ b/tests/unit/http-scenario-evidence.test.ts @@ -11,6 +11,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + createBoundedChildInvocation, createBoundedPnpmScriptInvocation, formatPnpmScriptFailure, } from "../../scripts/lib/bounded-pnpm-script.ts"; @@ -111,9 +112,9 @@ async function runFixture( 2, )}\n`, ); - return spawnSync( - process.execPath, - [ + const invocation = createBoundedChildInvocation({ + command: process.execPath, + arguments: [ "scripts/check-test-evidence.ts", "--scenario-only", "--source-root", @@ -127,11 +128,41 @@ async function runFixture( "--artifact", artifactPath, ], - { encoding: "utf8", cwd: process.cwd() }, + environment: process.env, + cwd: process.cwd(), + }); + return spawnSync( + invocation.command, + [...invocation.arguments], + invocation.options, ); } describe("HTTP scenario evidence receipt schema", () => { + it("bounds checker fixture children with the shared process policy", () => { + const environment = { CI: "true" }; + + expect( + createBoundedChildInvocation({ + command: "/runtime/node", + arguments: ["scripts/check-test-evidence.ts", "--scenario-only"], + environment, + cwd: "/workspace", + }), + ).toEqual({ + command: "/runtime/node", + arguments: ["scripts/check-test-evidence.ts", "--scenario-only"], + options: { + cwd: "/workspace", + encoding: "utf8", + env: environment, + killSignal: "SIGTERM", + maxBuffer: 16 * 1024 * 1024, + timeout: 60_000, + }, + }); + }); + it("bounds every orchestration child by time and captured output", () => { const environment = { CI: "true" }; @@ -175,6 +206,22 @@ describe("HTTP scenario evidence receipt schema", () => { ); }); + it("preserves output overflow codes in child diagnostics", () => { + const error = Object.assign(new Error("stdout maxBuffer exceeded"), { + code: "ENOBUFS", + }); + + expect( + formatPnpmScriptFailure("check:http-scenario-evidence", { + status: null, + signal: null, + error, + }), + ).toBe( + "check:http-scenario-evidence failed: exit=null, signal=none, error=ENOBUFS: stdout maxBuffer exceeded", + ); + }); + it("rejects internally inconsistent assertion groups as schema drift", async () => { const receipt = JSON.parse( await readFile( @@ -195,6 +242,18 @@ describe("HTTP scenario evidence receipt schema", () => { expect.objectContaining({ path: ["rows", 0, "observed", "fetch", "agrees"], }), + expect.objectContaining({ + path: ["rows", 0, "expected", "retry", "count"], + }), + expect.objectContaining({ + path: ["rows", 0, "expected", "retry", "reasons", 0], + }), + expect.objectContaining({ + path: ["rows", 0, "observed", "retry", "count"], + }), + expect.objectContaining({ + path: ["rows", 0, "observed", "retry", "reasons", 0], + }), ]), ); } @@ -275,6 +334,13 @@ describe("HTTP scenario evidence receipt schema", () => { }; }, }, + { + label: "a deadline override drift", + diagnostic: "[TEST-EVIDENCE-DEADLINE-DRIFT]", + mutate(_expectations: HttpScenarioExpectation[], receipt: MutableReceipt) { + receipt.rows[0]!.testDeadlineOverrideMs = 500; + }, + }, ])("rejects $label with its stable diagnostic", async (fixture) => { const { expectations, receipt } = await validFixture(); fixture.mutate(expectations, receipt);