Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

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");
});
});