This commit is contained in:
Andras Schmelczer 2026-09-27 13:58:54 +01:00
commit e151208e15

View file

@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { Checkpoint } from "./checkpoint";
export type RequestKind = "create" | "content" | "manifest";
export interface RequestRecord {
@ -7,6 +8,13 @@ export interface RequestRecord {
url: string;
body: Record<string, unknown>;
status?: number;
responseType?: string;
}
export interface RequestCheckpoint {
kind: RequestKind | "read-content" | "events";
point: "before" | "after";
responseType?: string;
}
interface InjectedFault {
@ -52,6 +60,79 @@ export function classifyRequest(
export class NetworkFaults {
public readonly requests: RequestRecord[] = [];
public readonly schedule: { type: string; detail: unknown }[] = [];
private readonly checkpoints: {
spec: RequestCheckpoint;
gate: Checkpoint<unknown>;
firstRequest: number;
}[] = [];
public hold(spec: RequestCheckpoint): void {
assert(
spec.point === "after" || spec.responseType === undefined,
"Response predicates require an after-response checkpoint"
);
this.checkpoints.at(-1)?.gate.assertConsumed();
this.checkpoints.push({
spec,
gate: new Checkpoint(),
firstRequest: this.requests.length
});
this.schedule.push({ type: "armed", detail: spec });
}
public waitForHeldRequest(): Promise<unknown> {
const current = this.checkpoints.at(-1);
assert(current, "No request checkpoint armed");
return current.gate.wait();
}
public requestsSinceCheckpoint(): RequestRecord[] {
const current = this.checkpoints.at(-1);
assert(current, "No request checkpoint armed");
return this.requests.slice(current.firstRequest);
}
public resumeRequest(): void {
const current = this.checkpoints.at(-1);
assert(current, "No request checkpoint armed");
assert(
current.gate.fired,
"Cannot release a request checkpoint before it is reached"
);
current.gate.resume();
this.schedule.push({ type: "released", detail: current.spec });
}
public releaseRequests(): void {
for (const { gate } of this.checkpoints) gate.resume();
}
public assertCheckpointsConsumed(): void {
for (const { gate } of this.checkpoints) gate.assertConsumed();
assert(
!this.observation ||
(this.observation.fired && this.observation.released),
"Observation checkpoint was not exercised and released"
);
}
private async requestCheckpoint(
kind: RequestCheckpoint["kind"] | undefined,
point: RequestCheckpoint["point"],
detail: unknown,
signal?: AbortSignal | null,
responseType?: string
): Promise<void> {
const current = this.checkpoints.at(-1);
if (
!current ||
current.gate.fired ||
current.spec.kind !== kind ||
current.spec.point !== point ||
(current.spec.responseType !== undefined &&
current.spec.responseType !== responseType)
)
return;
this.schedule.push({
type: "reached",
detail: structuredClone({ spec: current.spec, request: detail })
});
await current.gate.enter(detail, signal);
}
private observation?: {
released: boolean;
fired: boolean;
@ -150,11 +231,7 @@ export class NetworkFaults {
await this.armed.reached;
}
public assertConsumed(): void {
assert(
!this.observation ||
(this.observation.fired && this.observation.released),
"Observation checkpoint was not exercised and released"
);
this.assertCheckpointsConsumed();
for (const fault of this.faults) {
assert(fault.fired, "Armed network fault never fired");
assert(fault.retried, "Interrupted CAS request was never retried");
@ -175,6 +252,13 @@ export class NetworkFaults {
(input instanceof Request ? input.signal : undefined);
if (observing) await this.observationCheckpoint(signal);
const record = classifyRequest(input, init);
const kind =
record?.kind ??
(observing && /\/documents\/.*\/content$/.test(url.pathname)
? "read-content"
: observing && url.pathname.endsWith("/events-since")
? "events"
: undefined);
if (record) {
// A client serializes its persisted CAS requests. Its next CAS
// after a lost response must replay the interrupted operation,
@ -192,6 +276,10 @@ export class NetworkFaults {
"Retry changed payload/request identity"
);
this.armed!.retried = true;
this.schedule.push({
type: `retry:${record.kind}`,
detail: structuredClone(record)
});
}
const previous = this.requests.find(
(r) => r.body.requestId === record.body.requestId
@ -216,23 +304,45 @@ export class NetworkFaults {
const fail = () => {
fault!.interrupted = structuredClone(record!);
fault!.fired = true;
this.schedule.push({
type: `drop:${fault!.kind}:${fault!.point}`,
detail: structuredClone(record)
});
fault!.resolve();
throw new TypeError(
`Injected ${fault!.point}-commit ${fault!.kind} network failure`
);
};
if (matches && fault.point === "before") fail();
await this.requestCheckpoint(
kind,
"before",
record ?? { url: url.href },
signal
);
const response = await fetch(input, init);
if (observing) await this.observationCheckpoint(signal);
if (record) record.status = response.status;
if (record) {
record.status = response.status;
if (response.ok)
record.responseType = (
(await response.clone().json()) as { type?: string }
).type;
}
await this.requestCheckpoint(
kind,
"after",
record ?? { url: url.href },
signal,
record?.responseType
);
// Only drop accepted replies, never confuse a rejected CAS with commit.
if (
matches &&
!fault.fired &&
fault.point === "after" &&
response.ok &&
((await response.clone().json()) as { type?: string }).type ===
"Accepted"
record?.responseType === "Accepted"
) {
await response.body?.cancel();
fail();