b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
332 lines
8.8 KiB
TypeScript
332 lines
8.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { createInitLock, initWithLock } from "../../../src/utils/init-lock.js";
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/** A promise that never settles — simulates an init whose owning request
|
|
* context was torn down mid-await (workerd cancels the continuation, so
|
|
* neither `then` nor `finally` ever runs). */
|
|
function neverSettles<T>(): Promise<T> {
|
|
return new Promise<T>(() => {});
|
|
}
|
|
|
|
describe("initWithLock", () => {
|
|
it("returns the cached value without calling init", async () => {
|
|
const lock = createInitLock();
|
|
let initCalls = 0;
|
|
const result = await initWithLock(
|
|
lock,
|
|
() => "cached",
|
|
async () => {
|
|
initCalls++;
|
|
return "fresh";
|
|
},
|
|
);
|
|
expect(result).toBe("cached");
|
|
expect(initCalls).toBe(0);
|
|
});
|
|
|
|
it("runs init once and shares the result with concurrent waiters", async () => {
|
|
const lock = createInitLock();
|
|
let cache: string | null = null;
|
|
let initCalls = 0;
|
|
const init = async () => {
|
|
initCalls++;
|
|
await sleep(50);
|
|
cache = "value";
|
|
return "value";
|
|
};
|
|
const opts = { pollMs: 10 };
|
|
const results = await Promise.all(
|
|
Array.from({ length: 5 }, () => initWithLock(lock, () => cache, init, opts)),
|
|
);
|
|
expect(results).toEqual(["value", "value", "value", "value", "value"]);
|
|
expect(initCalls).toBe(1);
|
|
});
|
|
|
|
it("reclaims the lock after the deadline when the owner is abandoned", async () => {
|
|
const lock = createInitLock();
|
|
let cache: string | null = null;
|
|
|
|
// First caller claims the lock, then its continuation dies: init never
|
|
// settles, so the post-await cleanup never runs and the lock looks
|
|
// held forever. This is the poisoned-isolate scenario from production.
|
|
void initWithLock(
|
|
lock,
|
|
() => cache,
|
|
() => neverSettles<string>(),
|
|
{
|
|
deadlineMs: 100,
|
|
pollMs: 10,
|
|
},
|
|
);
|
|
expect(lock.ownerStartedAt).not.toBeNull();
|
|
|
|
await sleep(120);
|
|
|
|
// A later request must reclaim the stale lock and initialize.
|
|
const result = await initWithLock(
|
|
lock,
|
|
() => cache,
|
|
async () => {
|
|
cache = "recovered";
|
|
return "recovered";
|
|
},
|
|
{ deadlineMs: 100, pollMs: 10, maxWaitMs: 1000 },
|
|
);
|
|
expect(result).toBe("recovered");
|
|
});
|
|
|
|
it("releases the lock when init throws so the next caller can retry", async () => {
|
|
const lock = createInitLock();
|
|
let cache: string | null = null;
|
|
|
|
await expect(
|
|
initWithLock(
|
|
lock,
|
|
() => cache,
|
|
() => Promise.reject(new Error("boom")),
|
|
{ pollMs: 10 },
|
|
),
|
|
).rejects.toThrow("boom");
|
|
expect(lock.ownerStartedAt).toBeNull();
|
|
|
|
const result = await initWithLock(
|
|
lock,
|
|
() => cache,
|
|
async () => {
|
|
cache = "ok";
|
|
return "ok";
|
|
},
|
|
{ pollMs: 10 },
|
|
);
|
|
expect(result).toBe("ok");
|
|
});
|
|
|
|
it("gives up after maxWaitMs instead of waiting forever", async () => {
|
|
const lock = createInitLock();
|
|
|
|
void initWithLock(
|
|
lock,
|
|
() => null,
|
|
() => neverSettles<string>(),
|
|
{
|
|
deadlineMs: 60_000,
|
|
pollMs: 10,
|
|
},
|
|
);
|
|
|
|
await expect(
|
|
initWithLock(
|
|
lock,
|
|
() => null,
|
|
async () => "late",
|
|
{
|
|
deadlineMs: 60_000,
|
|
pollMs: 10,
|
|
maxWaitMs: 100,
|
|
},
|
|
),
|
|
).rejects.toThrow(/timed out/i);
|
|
});
|
|
|
|
it("reclaims from a live-but-slow owner without running init more than twice", async () => {
|
|
// The dangerous path for deadline tuning: the owner is healthy but
|
|
// slower than deadlineMs (e.g. contended migrations). A waiter
|
|
// reclaims and runs a second init; both must resolve, init must run
|
|
// at most twice, and the cache must converge.
|
|
const lock = createInitLock();
|
|
let cache: string | null = null;
|
|
let initCalls = 0;
|
|
|
|
const owner = initWithLock(
|
|
lock,
|
|
() => cache,
|
|
async () => {
|
|
initCalls++;
|
|
await sleep(250);
|
|
cache = "slow-owner";
|
|
return "slow-owner";
|
|
},
|
|
{ deadlineMs: 100, pollMs: 10, maxWaitMs: 2000 },
|
|
);
|
|
await sleep(10);
|
|
const waiter = initWithLock(
|
|
lock,
|
|
() => cache,
|
|
async () => {
|
|
initCalls++;
|
|
cache = "reclaimer";
|
|
return "reclaimer";
|
|
},
|
|
{ deadlineMs: 100, pollMs: 10, maxWaitMs: 2000 },
|
|
);
|
|
|
|
expect(await owner).toBe("slow-owner");
|
|
expect(await waiter).toBe("reclaimer");
|
|
expect(initCalls).toBe(2);
|
|
|
|
// Cache has converged: later callers are served from it, no third init.
|
|
// Note which value converged: these test inits write the cache
|
|
// UNGATED, so the slow owner's late write wins (last-writer-wins) and
|
|
// `cache` is "slow-owner" here, not the reclaimer's value. Real
|
|
// callers gate publication on the isCurrentClaim predicate (see the
|
|
// "gates publication" test below), which makes the reclaimer win.
|
|
expect(cache).toBe("slow-owner");
|
|
const third = await initWithLock(
|
|
lock,
|
|
() => cache,
|
|
async () => {
|
|
initCalls++;
|
|
return "third";
|
|
},
|
|
{ deadlineMs: 100, pollMs: 10 },
|
|
);
|
|
expect(third).toBe(cache);
|
|
expect(initCalls).toBe(2);
|
|
});
|
|
|
|
it("gates publication so a reclaimed slow owner cannot overwrite the reclaimer's value", async () => {
|
|
// Real callers publish through the isCurrentClaim predicate: when a
|
|
// slow owner finishes after being reclaimed, its publication is
|
|
// suppressed and the reclaimer's published value survives.
|
|
const lock = createInitLock();
|
|
let cache: string | null = null;
|
|
const claimChecks: boolean[] = [];
|
|
|
|
const makeInit = (value: string, delayMs: number) => async (isCurrentClaim: () => boolean) => {
|
|
await sleep(delayMs);
|
|
const current = isCurrentClaim();
|
|
claimChecks.push(current);
|
|
if (current) cache = value;
|
|
return value;
|
|
};
|
|
|
|
const owner = initWithLock(lock, () => cache, makeInit("slow-owner", 250), {
|
|
deadlineMs: 100,
|
|
pollMs: 10,
|
|
maxWaitMs: 2000,
|
|
});
|
|
await sleep(10);
|
|
const reclaimer = initWithLock(lock, () => cache, makeInit("reclaimer", 50), {
|
|
deadlineMs: 100,
|
|
pollMs: 10,
|
|
maxWaitMs: 2000,
|
|
});
|
|
|
|
// Each init still returns its own value to its own caller...
|
|
expect(await owner).toBe("slow-owner");
|
|
expect(await reclaimer).toBe("reclaimer");
|
|
// ...but only the reclaimer (the current claim) published.
|
|
expect(cache).toBe("reclaimer");
|
|
expect(claimChecks).toEqual([true, false]);
|
|
});
|
|
|
|
it("does not let a finished stale owner release the reclaimer's lock", async () => {
|
|
// The clobber race: owner A is slow and eventually FAILS (so it never
|
|
// populates the cache), waiter B reclaims at the deadline, then A's
|
|
// cleanup runs while B is still mid-init. A must not release B's
|
|
// claim — if it does, a third caller C arriving in that window
|
|
// claims the lock and starts a third concurrent init.
|
|
const lock = createInitLock();
|
|
let cache: string | null = null;
|
|
let initCalls = 0;
|
|
const opts = { deadlineMs: 300, pollMs: 20, maxWaitMs: 3000 };
|
|
|
|
// A: claims at t=0, rejects at t≈400 (after B reclaimed at t≈300+).
|
|
const ownerA = initWithLock(
|
|
lock,
|
|
() => cache,
|
|
async () => {
|
|
initCalls++;
|
|
await sleep(400);
|
|
throw new Error("slow failure");
|
|
},
|
|
opts,
|
|
).catch((error: unknown) => error);
|
|
|
|
// B: arrives early, reclaims at t≈300-340, succeeds at t≈520-540.
|
|
await sleep(20);
|
|
const reclaimerB = initWithLock(
|
|
lock,
|
|
() => cache,
|
|
async () => {
|
|
initCalls++;
|
|
await sleep(200);
|
|
cache = "reclaimer";
|
|
return "reclaimer";
|
|
},
|
|
opts,
|
|
);
|
|
|
|
// C: arrives at t≈440 — after A's cleanup ran, while B is mid-init.
|
|
// C must wait for B's result, not start a third init.
|
|
await sleep(420);
|
|
const lateC = initWithLock(
|
|
lock,
|
|
() => cache,
|
|
async () => {
|
|
initCalls++;
|
|
cache = "late";
|
|
return "late";
|
|
},
|
|
opts,
|
|
);
|
|
|
|
expect(await reclaimerB).toBe("reclaimer");
|
|
expect(await lateC).toBe("reclaimer");
|
|
expect(await ownerA).toBeInstanceOf(Error);
|
|
expect(initCalls).toBe(2);
|
|
});
|
|
|
|
it("anchors the in-flight init promise and swallows its rejection", async () => {
|
|
const lock = createInitLock();
|
|
const anchored: Promise<void>[] = [];
|
|
|
|
await expect(
|
|
initWithLock(
|
|
lock,
|
|
() => null,
|
|
() => Promise.reject(new Error("boom")),
|
|
{
|
|
pollMs: 10,
|
|
anchor: (promise) => anchored.push(promise),
|
|
},
|
|
),
|
|
).rejects.toThrow("boom");
|
|
|
|
expect(anchored).toHaveLength(1);
|
|
// The anchored copy must never reject (it goes to waitUntil, where a
|
|
// rejection would surface as an unhandled error in the host).
|
|
await expect(anchored[0]).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("lets a waiter pick up a value cached by the owner mid-wait", async () => {
|
|
const lock = createInitLock();
|
|
let cache: string | null = null;
|
|
|
|
const owner = initWithLock(
|
|
lock,
|
|
() => cache,
|
|
async () => {
|
|
await sleep(40);
|
|
cache = "owner-value";
|
|
return "owner-value";
|
|
},
|
|
{ pollMs: 10 },
|
|
);
|
|
await sleep(5);
|
|
const waiter = initWithLock(
|
|
lock,
|
|
() => cache,
|
|
async () => "waiter-value",
|
|
{ pollMs: 10, maxWaitMs: 1000 },
|
|
);
|
|
expect(await owner).toBe("owner-value");
|
|
expect(await waiter).toBe("owner-value");
|
|
});
|
|
});
|