mor
This commit is contained in:
parent
ff1f474324
commit
e151208e15
1 changed files with 2933 additions and 784 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue