Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,21 @@ const config: KnipConfig = {
project: "**/*.{ts,tsx,css}",
},

// achievement-hunter ships a maintainer-only hardware probe on top of the
// standard plugin entries. It is run by hand on a device
// (`bun plugins/achievement-hunter/scripts/probe-achievements.ts`) to
// measure Steam's real service surface — batch caps, response shapes —
// and is never imported by the plugin itself.
"plugins/achievement-hunter": {
entry: [
"app.tsx",
"backend.ts",
"**/*.{test,spec}.{ts,tsx}",
"scripts/*.ts",
],
project: "**/*.{ts,tsx,css}",
},

// recomp additionally ships dynamic-loaded game/mod setup modules and
// operator scripts on top of the standard plugin entries.
"plugins/recomp": {
Expand Down
11 changes: 11 additions & 0 deletions packages/game-facts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@loadout/game-facts",
"version": "0.0.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "BSD-3-Clause",
"dependencies": {
"@loadout/external-cache": "workspace:*"
}
}
63 changes: 63 additions & 0 deletions packages/game-facts/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Batched, throttled, cached resolution of per-game facts.
*
* A "fact" is one datum about one game that needs I/O: achievement
* completion, HowLongToBeat time, ProtonDB tier, is-a-friend-playing.
* This package owns the plumbing every such fact needs — the three-state
* value type, staleness policy, batch planning, and the sweep engine that
* paces requests and backs off when a source complains — so each consumer
* only writes the part that is actually specific to its source.
*
* Consumers:
* - `plugins/achievement-hunter` — one resolver, batched over Steam's
* authenticated CM transport.
* - `plugins/library-tabs` — six planned resolvers feeding its rule
* engine, which specified this interface before it was extracted
* (see that plugin's `PLAN.md`, "async facts").
*
* See `types.ts` for why `FactValue` has three states and why
* `FactResolver` is generic over its key.
*/

export {
allUnavailable,
isOk,
missing,
unavailable,
type FactResolver,
type FactRow,
type FactValue,
type SweepProgress,
} from "./types";

export {
classify,
countFresh,
fixedTtl,
type Staleness,
type StalenessPolicy,
} from "./staleness";

export {
planBatches,
planCost,
type PlanBatchesOptions,
} from "./plan";

export {
createFactStore,
type CreateFactStoreOptions,
type FactStore,
type FactStoreSnapshot,
} from "./store";

export { createLimiter, type Limiter } from "./limiter";

export {
createSweeper,
FactFetchError,
type BackoffState,
type BackoffStore,
type Sweeper,
type SweeperOptions,
} from "./sweeper";
109 changes: 109 additions & 0 deletions packages/game-facts/src/limiter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it } from "bun:test";

import { createLimiter } from "./limiter";

/** A promise plus its resolver, so a test can hold tasks open deliberately. */
function deferred<T = void>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

describe("createLimiter", () => {
it("rejects a nonsensical cap", () => {
expect(() => createLimiter(0)).toThrow(/max/);
expect(() => createLimiter(-1)).toThrow(/max/);
expect(() => createLimiter(NaN)).toThrow(/max/);
});

it("runs a task and returns its value", async () => {
const limit = createLimiter(2);
expect(await limit(async () => 42)).toBe(42);
});

it("never exceeds the cap", async () => {
const limit = createLimiter(2);
let active = 0;
let peak = 0;
const gates = Array.from({ length: 6 }, () => deferred());

const tasks = gates.map((gate, i) =>
limit(async () => {
active++;
peak = Math.max(peak, active);
await gate.promise;
active--;
return i;
}),
);

// Let the first wave enter.
await Promise.resolve();
await Promise.resolve();
expect(peak).toBe(2);

for (const gate of gates) gate.resolve();
expect(await Promise.all(tasks)).toEqual([0, 1, 2, 3, 4, 5]);
expect(peak).toBe(2);
expect(active).toBe(0);
});

it("admits queued tasks in call order (FIFO, not LIFO)", async () => {
const limit = createLimiter(1);
const started: number[] = [];
const gates = [deferred(), deferred(), deferred()];

const tasks = gates.map((gate, i) =>
limit(async () => {
started.push(i);
await gate.promise;
}),
);

// Release one at a time so admission order is observable.
gates[0]!.resolve();
await Promise.resolve();
await Promise.resolve();
gates[1]!.resolve();
await Promise.resolve();
await Promise.resolve();
gates[2]!.resolve();
await Promise.all(tasks);

expect(started).toEqual([0, 1, 2]);
});

it("releases the slot when a task throws", async () => {
// The bug this guards: a rejection that leaks its slot permanently
// shrinks the pool, and enough of them deadlock every later caller.
const limit = createLimiter(1);

await expect(
limit(async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");

// The pool must be whole again.
expect(await limit(async () => "ok")).toBe("ok");
expect(await limit(async () => "ok again")).toBe("ok again");
});

it("survives a run of failures without deadlocking", async () => {
const limit = createLimiter(2);
const results = await Promise.allSettled(
Array.from({ length: 10 }, (_, i) =>
limit(async () => {
if (i % 2 === 0) throw new Error(`fail ${i}`);
return i;
}),
),
);
expect(results.filter((r) => r.status === "rejected").length).toBe(5);
expect(await limit(async () => "still alive")).toBe("still alive");
});
});
52 changes: 52 additions & 0 deletions packages/game-facts/src/limiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* A FIFO concurrency limiter.
*
* Extracted because two shipped plugins already hand-roll it —
* `plugins/protondb-badges/backend.ts` (cap 4) and `plugins/hltb/backend.ts`
* (cap 3) are near-identical copies — and library-tabs' planned
* HTTP-backed fact resolvers need a third. Achievement Hunter's own sweep
* runs at concurrency 1, where a limiter is redundant; it lives here for the
* consumers that need N > 1, and so those two copies have somewhere to
* migrate onto.
*
* Deliberately not a rate limiter. This caps *simultaneity*, not requests
* per second. Pacing is the sweeper's job, because the right inter-request
* gap depends on the source and belongs next to the backoff policy.
*/

/** Run `fn` when a slot is free. Resolves/rejects with `fn`'s result. */
export type Limiter = <T>(fn: () => Promise<T>) => Promise<T>;

/**
* Create a limiter allowing `max` concurrent tasks.
*
* Queued tasks start in call order. A task that throws still releases its
* slot — without that, one rejection permanently shrinks the pool and a
* few of them deadlock the caller.
*/
export function createLimiter(max: number): Limiter {
if (!Number.isFinite(max) || max < 1) {
throw new Error(`createLimiter: max must be >= 1, got ${max}`);
}

let active = 0;
const queue: Array<() => void> = [];

return async function withSlot<T>(fn: () => Promise<T>): Promise<T> {
if (active >= max) {
await new Promise<void>((resolve) => {
queue.push(resolve);
});
}
active++;
try {
return await fn();
} finally {
active--;
// Hand the slot to the longest-waiting caller. `shift` is what makes
// this FIFO; a `pop` here would starve early callers under load.
const next = queue.shift();
if (next) next();
}
};
}
Loading
Loading