mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 08:35:07 +08:00
- Pin the supported fetch surface to an explicit Node and Undici profile. - Generate deterministic WebIDL, AbortSignal, and stream differential campaigns. - Match Node stream pull scheduling while preserving typed source identity.
442 lines
12 KiB
TypeScript
442 lines
12 KiB
TypeScript
import type { FetchCompatProfile } from "../../packages/compiler/src/compat/fetch-profile.js";
|
|
|
|
export const FETCH_CONFORMANCE_SEED = 0x24_15_07_24;
|
|
|
|
export const FETCH_CONFORMANCE_SCENARIOS = [
|
|
"abort-events",
|
|
"stream-traces",
|
|
"webidl-operations",
|
|
] as const;
|
|
|
|
type GeneratedScenario = (typeof FETCH_CONFORMANCE_SCENARIOS)[number];
|
|
|
|
class XorShift32 {
|
|
constructor(private state: number) {
|
|
if (state === 0) this.state = 0x9e3779b9;
|
|
}
|
|
|
|
next(): number {
|
|
let value = this.state | 0;
|
|
value ^= value << 13;
|
|
value ^= value >>> 17;
|
|
value ^= value << 5;
|
|
this.state = value | 0;
|
|
return value >>> 0;
|
|
}
|
|
|
|
pick(size: number): number {
|
|
return this.next() % size;
|
|
}
|
|
}
|
|
|
|
function webIdlOperations(): string {
|
|
return String.raw`
|
|
let webIdlEffects = "";
|
|
function webIdlEffect(label) {
|
|
webIdlEffects += label;
|
|
return "ignored";
|
|
}
|
|
|
|
const abortReason = JSON.parse('{"value":"before"}');
|
|
const abortedSignal = AbortSignal.abort(
|
|
abortReason,
|
|
webIdlEffect("abort "),
|
|
);
|
|
abortReason.value = "after";
|
|
console.log(
|
|
"webidl abort:",
|
|
webIdlEffects,
|
|
abortedSignal.aborted,
|
|
abortedSignal.reason.value,
|
|
);
|
|
|
|
const combinedSignal = AbortSignal.any(
|
|
[abortedSignal],
|
|
webIdlEffect("any "),
|
|
);
|
|
console.log(
|
|
"webidl any:",
|
|
webIdlEffects,
|
|
combinedSignal.aborted,
|
|
combinedSignal.reason.value,
|
|
);
|
|
|
|
try {
|
|
combinedSignal.throwIfAborted();
|
|
console.log("webidl throwIfAborted: no-error");
|
|
} catch (error) {
|
|
console.log(
|
|
"webidl throwIfAborted:",
|
|
error.value,
|
|
);
|
|
}
|
|
|
|
webIdlEffects = "";
|
|
try {
|
|
AbortSignal.timeout(
|
|
JSON.parse('{"delay":1}'),
|
|
webIdlEffect("timeout-surplus"),
|
|
);
|
|
console.log("webidl invalid timeout: no-error");
|
|
} catch (error) {
|
|
console.log(
|
|
"webidl invalid timeout:",
|
|
webIdlEffects,
|
|
error.name,
|
|
error.code || "no-code",
|
|
error.message,
|
|
);
|
|
}
|
|
|
|
try {
|
|
AbortSignal.timeout();
|
|
console.log("webidl missing timeout: no-error");
|
|
} catch (error) {
|
|
console.log(
|
|
"webidl missing timeout:",
|
|
error.name,
|
|
error.code || "no-code",
|
|
error.message,
|
|
);
|
|
}
|
|
|
|
try {
|
|
AbortSignal.any();
|
|
console.log("webidl missing any: no-error");
|
|
} catch (error) {
|
|
console.log(
|
|
"webidl missing any:",
|
|
error.name,
|
|
error.code || "no-code",
|
|
error.message,
|
|
);
|
|
}
|
|
|
|
try {
|
|
AbortSignal.any(JSON.parse('{"not":"a sequence"}'));
|
|
console.log("webidl invalid any: no-error");
|
|
} catch (error) {
|
|
console.log(
|
|
"webidl invalid any:",
|
|
error.name,
|
|
error.code || "no-code",
|
|
error.message,
|
|
);
|
|
}
|
|
|
|
try {
|
|
ReadableStream.from();
|
|
console.log("webidl missing from: no-error");
|
|
} catch (error) {
|
|
console.log(
|
|
"webidl missing from:",
|
|
error.name,
|
|
error.code || "no-code",
|
|
error.message,
|
|
);
|
|
}
|
|
|
|
const fromItem = { value: "before" };
|
|
const fromItems = [fromItem];
|
|
webIdlEffects = "";
|
|
const fromStream = ReadableStream.from(
|
|
fromItems,
|
|
webIdlEffect("from-surplus"),
|
|
);
|
|
fromItems[0].value = "after";
|
|
const fromReader = fromStream.getReader();
|
|
const fromPart = await fromReader.read();
|
|
const fromDone = await fromReader.read();
|
|
console.log(
|
|
"webidl from:",
|
|
webIdlEffects,
|
|
fromPart.done,
|
|
fromPart.done ? "done" : fromPart.value.value,
|
|
fromDone.done,
|
|
fromStream.locked,
|
|
);
|
|
await fromReader.closed;
|
|
`;
|
|
}
|
|
|
|
function abortEvents(): string {
|
|
return String.raw`
|
|
let abortEventOrder = "";
|
|
let abortEventNameEffects = "";
|
|
const eventSignal = AbortSignal.timeout(0);
|
|
|
|
function abortEventNameToString() {
|
|
abortEventNameEffects += this.operation;
|
|
return "abort";
|
|
}
|
|
|
|
function keptAbortListener(event) {
|
|
abortEventOrder +=
|
|
this === eventSignal &&
|
|
event.target === eventSignal &&
|
|
event.currentTarget === eventSignal
|
|
? "listener-this "
|
|
: "listener-wrong ";
|
|
}
|
|
|
|
function removedAbortListener() {
|
|
abortEventOrder += "removed ";
|
|
}
|
|
|
|
function onAbortListener(event) {
|
|
abortEventOrder +=
|
|
this === eventSignal && event.target === eventSignal
|
|
? "onabort-this"
|
|
: "onabort-wrong";
|
|
}
|
|
|
|
eventSignal.addEventListener(
|
|
{ operation: "add ", toString: abortEventNameToString },
|
|
keptAbortListener,
|
|
);
|
|
eventSignal.addEventListener("abort", removedAbortListener);
|
|
eventSignal.removeEventListener(
|
|
{ operation: "remove", toString: abortEventNameToString },
|
|
removedAbortListener,
|
|
);
|
|
eventSignal.onabort = onAbortListener;
|
|
console.log("abort events before:", eventSignal.aborted);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
console.log(
|
|
"abort events after:",
|
|
abortEventNameEffects,
|
|
abortEventOrder,
|
|
eventSignal.aborted,
|
|
);
|
|
`;
|
|
}
|
|
|
|
interface TraceState {
|
|
locked: boolean;
|
|
reader: string;
|
|
readerSerial: number;
|
|
reads: number;
|
|
terminal: boolean;
|
|
}
|
|
|
|
function acquireReader(lines: string[], trace: number, state: TraceState): void {
|
|
state.readerSerial++;
|
|
state.reader = `trace${trace}Reader${state.readerSerial}`;
|
|
lines.push(`const ${state.reader} = trace${trace}Stream.getReader();`);
|
|
lines.push(
|
|
`console.log("trace ${trace} acquire ${state.readerSerial}:", trace${trace}Stream.locked);`,
|
|
);
|
|
state.locked = true;
|
|
}
|
|
|
|
function readOnce(lines: string[], trace: number, state: TraceState): void {
|
|
const part = `trace${trace}Part${state.reads}`;
|
|
lines.push(`const ${part} = await ${state.reader}.read();`);
|
|
lines.push(
|
|
`console.log("trace ${trace} read ${state.reads}:", ${part}.done, ${part}.done ? "done" : ${part}.value);`,
|
|
);
|
|
state.reads++;
|
|
}
|
|
|
|
function releaseReader(lines: string[], trace: number, state: TraceState): void {
|
|
lines.push(`${state.reader}.releaseLock();`);
|
|
lines.push(
|
|
`console.log("trace ${trace} release ${state.readerSerial}:", trace${trace}Stream.locked);`,
|
|
);
|
|
state.locked = false;
|
|
}
|
|
|
|
function oneStreamTrace(rng: XorShift32, trace: number): string {
|
|
const startChunk = rng.pick(2) === 0;
|
|
const chunks = 2 + rng.pick(3);
|
|
const pullChunks = chunks - (startChunk ? 1 : 0);
|
|
const lines: string[] = [
|
|
`let trace${trace}Pulls = 0;`,
|
|
`let trace${trace}Cancel = "none";`,
|
|
`function trace${trace}Start(controller) {`,
|
|
` console.log("trace ${trace} start:", this.marker, controller.desiredSize);`,
|
|
...(startChunk
|
|
? [
|
|
` controller.enqueue("trace-${trace}-start");`,
|
|
` console.log("trace ${trace} start enqueue:", controller.desiredSize);`,
|
|
]
|
|
: []),
|
|
`}`,
|
|
`function trace${trace}Pull(controller) {`,
|
|
` trace${trace}Pulls++;`,
|
|
` console.log("trace ${trace} pull:", this.marker, trace${trace}Pulls, controller.desiredSize);`,
|
|
` if (trace${trace}Pulls <= ${pullChunks}) {`,
|
|
` controller.enqueue("trace-${trace}-pull-" + trace${trace}Pulls);`,
|
|
` }`,
|
|
` if (trace${trace}Pulls === ${pullChunks}) controller.close();`,
|
|
`}`,
|
|
`function trace${trace}CancelFn(reason) {`,
|
|
` trace${trace}Cancel = reason;`,
|
|
` console.log("trace ${trace} cancel callback:", this.marker, reason);`,
|
|
`}`,
|
|
`const trace${trace}Source = {`,
|
|
` marker: "source-${trace}",`,
|
|
` start: trace${trace}Start,`,
|
|
` pull: trace${trace}Pull,`,
|
|
` cancel: trace${trace}CancelFn,`,
|
|
`};`,
|
|
`const trace${trace}Stream = new ReadableStream(trace${trace}Source);`,
|
|
];
|
|
const state: TraceState = {
|
|
locked: false,
|
|
reader: "",
|
|
readerSerial: 0,
|
|
reads: 0,
|
|
terminal: false,
|
|
};
|
|
|
|
acquireReader(lines, trace, state);
|
|
readOnce(lines, trace, state);
|
|
|
|
// Each residue class forces one important transition; the remaining
|
|
// steps are seeded model choices. This keeps coverage stable while still
|
|
// exploring different valid traces when the seed or count changes.
|
|
if (trace % 4 === 1) {
|
|
releaseReader(lines, trace, state);
|
|
acquireReader(lines, trace, state);
|
|
} else if (trace % 4 === 2) {
|
|
lines.push(`await ${state.reader}.cancel("reader-cancel-${trace}");`);
|
|
lines.push(`console.log("trace ${trace} reader cancel:", trace${trace}Stream.locked);`);
|
|
state.terminal = true;
|
|
} else if (trace % 4 === 3) {
|
|
releaseReader(lines, trace, state);
|
|
lines.push(`await trace${trace}Stream.cancel("stream-cancel-${trace}");`);
|
|
lines.push(`console.log("trace ${trace} stream cancel:", trace${trace}Stream.locked);`);
|
|
state.terminal = true;
|
|
}
|
|
|
|
const randomSteps = 2 + rng.pick(5);
|
|
for (let step = 0; step < randomSteps && !state.terminal; step++) {
|
|
if (!state.locked) {
|
|
if (rng.pick(3) === 0) {
|
|
lines.push(`await trace${trace}Stream.cancel("random-cancel-${trace}-${step}");`);
|
|
lines.push(`console.log("trace ${trace} random stream cancel ${step}:", trace${trace}Stream.locked);`);
|
|
state.terminal = true;
|
|
} else {
|
|
acquireReader(lines, trace, state);
|
|
}
|
|
continue;
|
|
}
|
|
switch (rng.pick(4)) {
|
|
case 0:
|
|
case 1:
|
|
readOnce(lines, trace, state);
|
|
if (state.reads >= chunks) state.terminal = true;
|
|
break;
|
|
case 2:
|
|
releaseReader(lines, trace, state);
|
|
break;
|
|
default:
|
|
lines.push(`await ${state.reader}.cancel("random-reader-cancel-${trace}-${step}");`);
|
|
lines.push(`console.log("trace ${trace} random reader cancel ${step}:", trace${trace}Stream.locked);`);
|
|
state.terminal = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!state.locked) acquireReader(lines, trace, state);
|
|
if (!state.terminal) {
|
|
while (state.reads <= chunks) readOnce(lines, trace, state);
|
|
} else {
|
|
readOnce(lines, trace, state);
|
|
}
|
|
lines.push(`await ${state.reader}.closed;`);
|
|
lines.push(
|
|
`console.log("trace ${trace} final:", trace${trace}Pulls, trace${trace}Cancel, trace${trace}Stream.locked);`,
|
|
);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function streamTraces(seed: number, traceCount: number): string {
|
|
const rng = new XorShift32(seed);
|
|
const traces: string[] = [];
|
|
for (let trace = 0; trace < traceCount; trace++) {
|
|
traces.push(`{\n${oneStreamTrace(rng, trace)}\n}`);
|
|
}
|
|
traces.push(String.raw`
|
|
{
|
|
const streamErrorReason = { marker: "stream-error" };
|
|
const erroredStream = new ReadableStream({
|
|
start(controller) {
|
|
console.log("stream error desired size:", controller.desiredSize);
|
|
controller.error(streamErrorReason);
|
|
console.log("stream error after:", controller.desiredSize);
|
|
},
|
|
});
|
|
const erroredReader = erroredStream.getReader();
|
|
try {
|
|
await erroredReader.read();
|
|
console.log("stream error read: no-error");
|
|
} catch (error) {
|
|
console.log("stream error read:", error.marker);
|
|
}
|
|
try {
|
|
await erroredReader.closed;
|
|
console.log("stream error closed: no-error");
|
|
} catch (error) {
|
|
console.log("stream error closed:", error.marker);
|
|
}
|
|
}
|
|
|
|
try {
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.close();
|
|
controller.close();
|
|
},
|
|
});
|
|
console.log("stream double close: no-error");
|
|
} catch (error) {
|
|
console.log("stream double close:", error.name, error.message);
|
|
}
|
|
`);
|
|
return traces.join("\n");
|
|
}
|
|
|
|
const SCENARIO_GENERATORS: Record<
|
|
GeneratedScenario,
|
|
(seed: number, traceCount: number) => string
|
|
> = {
|
|
"abort-events": () => abortEvents(),
|
|
"stream-traces": (seed, traceCount) => streamTraces(seed, traceCount),
|
|
"webidl-operations": () => webIdlOperations(),
|
|
};
|
|
|
|
export function generatedScenarioIds(profile: FetchCompatProfile): string[] {
|
|
return [...new Set(
|
|
profile.operations.flatMap((operation) =>
|
|
operation.evidence.flatMap((item) =>
|
|
item.generated === undefined ? [] : [item.generated]
|
|
)
|
|
),
|
|
)].sort();
|
|
}
|
|
|
|
export function generateFetchConformanceProgram(
|
|
profile: FetchCompatProfile,
|
|
options: { seed?: number; traceCount?: number } = {},
|
|
): string {
|
|
const seed = options.seed ?? FETCH_CONFORMANCE_SEED;
|
|
const traceCount = options.traceCount ?? 12;
|
|
const scenarios = generatedScenarioIds(profile);
|
|
const sections: string[] = [
|
|
`// Generated from NODE24_FETCH_COMPAT_PROFILE. Seed: 0x${seed.toString(16)}.`,
|
|
];
|
|
for (const scenario of scenarios) {
|
|
if (!Object.hasOwn(SCENARIO_GENERATORS, scenario)) {
|
|
throw new Error(`unknown generated fetch conformance scenario: ${scenario}`);
|
|
}
|
|
sections.push(`// scenario: ${scenario}`);
|
|
sections.push(
|
|
SCENARIO_GENERATORS[scenario as GeneratedScenario](seed, traceCount),
|
|
);
|
|
}
|
|
sections.push("export {};", "");
|
|
return sections.join("\n");
|
|
}
|