Files
OmniRoute/tests/unit/bottleneck-doexpire-patch.test.ts

127 lines
4.9 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
// Import the patch module to test it in isolation
import { applyBottleneckDoExpirePatch } from "../../open-sse/services/bottleneckPatch.ts";
// Bottleneck's internal Job / States classes are not part of the public
// exports, so load them directly to build a deterministic RUNNING-state job.
// A real schedule() parks the job in QUEUED then advances it through RUNNING
// on _run; reproducing that exactly lets us hit the RUNNING branch that the
// expiry timer reaches — the branch the (now-fixed) patch is responsible for.
const require = createRequire(import.meta.url);
const BottleneckJob = require("bottleneck/lib/Job.js");
const BottleneckStates = require("bottleneck/lib/States.js");
const fakeEvents = { trigger: async () => false };
/** Build a Job that Bottleneck `_run` accepts (state QUEUED under options.id). */
function buildParkedJob() {
const states = new BottleneckStates(["RECEIVED", "QUEUED", "RUNNING", "EXECUTING", "DONE"]);
const job = new BottleneckJob(
() => {},
[],
{ id: "patch-under-test", expiration: 5000 },
{ id: "patch-under-test" },
true,
fakeEvents,
states,
Promise
);
states.start(job.options.id); // -> RECEIVED (idx 0)
states.next(job.options.id); // -> QUEUED (idx 1) — what _run expects
return { job, states };
}
test("applyBottleneckDoExpirePatch is idempotent", () => {
// Should not throw on multiple calls
applyBottleneckDoExpirePatch();
applyBottleneckDoExpirePatch();
assert.ok(true, "patch applied twice without error");
});
test("patched _run still dispatches jobs correctly", async () => {
applyBottleneckDoExpirePatch();
const { default: Bottleneck } = await import("bottleneck");
const limiter = new Bottleneck({
id: "test-doexpire-patch",
maxConcurrent: 2,
minTime: 0,
});
// Job should execute normally (no expiration triggered)
const result = await limiter.schedule({ expiration: 5000 }, async () => {
return "patched-ok";
});
assert.equal(result, "patched-ok");
await limiter.disconnect();
});
test("patched doExpire advances a RUNNING job to EXECUTING instead of crashing", async () => {
applyBottleneckDoExpirePatch();
const { default: Bottleneck } = await import("bottleneck");
const limiter = new Bottleneck({ id: "test-doexpire-parked", maxConcurrent: 1, minTime: 0 });
try {
const { job, states } = buildParkedJob();
// _run calls doRun (QUEUED -> RUNNING), then parks the job in RUNNING for
// `wait` ms before dispatching to EXECUTING. A large wait holds it in the
// exact state the expiry timer can reach — the branch the patch guards.
limiter._run("parked", job, 10000);
assert.equal(states.jobStatus(job.options.id), "RUNNING", "job must be RUNNING before expiry");
// Fire doExpire while the job is RUNNING. Unpatched Bottleneck compares
// `options.id === "RUNNING"` (always false), so _assertStatus("EXECUTING")
// throws and the job is stuck forever. The patched doExpire must advance
// RUNNING -> EXECUTING first, then run the original doExpire cleanly.
job.doExpire(() => true, () => {}, () => {});
assert.equal(
states.jobStatus(job.options.id),
"EXECUTING",
"patched doExpire must advance a RUNNING job to EXECUTING before the original runs"
);
} finally {
await limiter.disconnect();
}
});
test("unpatched Bottleneck doExpire throws on a RUNNING job (the leak the patch guards)", async () => {
// Lock the motivating bug: with Job.js:162 comparing options.id === "RUNNING"
// (always false) the job is never advanced, so _assertStatus("EXECUTING")
// throws and the job is stuck in RUNNING with a running-slot never freed.
// Proving this FAILS without the patch is what makes the patched test above
// a real signal rather than a vacuous pass-on-empty.
const { job, states } = buildParkedJob();
states.next(job.options.id); // -> RUNNING
assert.equal(states.jobStatus(job.options.id), "RUNNING");
assert.throws(
() => BottleneckJob.prototype.doExpire.call(job, () => true, () => {}, () => {}),
/expected EXECUTING/,
"the unpatched doExpire must throw when the job is RUNNING, or the bug is already fixed upstream and the patch is dead"
);
// Stays stuck in RUNNING (no advance leaked into a broken slot count).
assert.equal(states.jobStatus(job.options.id), "RUNNING");
});
test("patch does not affect jobs without expiration", async () => {
applyBottleneckDoExpirePatch();
const { default: Bottleneck } = await import("bottleneck");
const limiter = new Bottleneck({
id: "test-no-expiration",
maxConcurrent: 2,
minTime: 0,
});
// Job without expiration should work exactly as before
const result = await limiter.schedule(async () => {
return "no-expire";
});
assert.equal(result, "no-expire");
await limiter.disconnect();
});