- commit
- 6b929a9d808c356035c07ebfee06102d8c396c47
- parent
- c62c853a7bbe99c8e3ac3b99ef48c3bad03247fb
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 37 | +++++++++++++++ |
| A | test/ | 72 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,37 @@
1+// One live relay operation owns repository authority across awaits. Async2+// context carries the owner token; the active lease grants admission. This3+// is neither a durable lock nor authority to collect retained Git objects.4+import { AsyncLocalStorage } from "node:async_hooks";5+6+type Kind = "git" | "control" | "alarm" | "event" | "teardown";7+type Lease = { kind: Kind; holders: number };8+9+export class RepositoryAccess {10+ private readonly context = new AsyncLocalStorage<Lease>();11+ private active?: Lease;12+13+ get busy() { return this.active !== undefined; }14+ get owned() { return this.active !== undefined && this.context.getStore() === this.active; }15+ get blocked() { return this.busy && !this.owned; }16+ get kind() { return this.active?.kind; }17+18+ async run<T>(kind: Kind, work: () => Promise<T> | T, refused: () => Promise<T> | T): Promise<T> {19+ if (this.blocked) return refused();20+ const lease = this.active ??= { kind, holders: 0 };21+ lease.holders++;22+ try { return await this.context.run(lease, work); }23+ finally { this.release(lease); }24+ }25+26+ sync<T>(kind: Kind, work: () => T, refused: () => T): T {27+ if (this.blocked) return refused();28+ const lease = this.active ??= { kind, holders: 0 };29+ lease.holders++;30+ try { return this.context.run(lease, work); }31+ finally { this.release(lease); }32+ }33+34+ private release(lease: Lease) {35+ if (--lease.holders === 0 && this.active === lease) this.active = undefined;36+ }37+}@@ -0,0 +1,72 @@
1+import { describe, expect, it } from "vitest";2+import { RepositoryAccess } from "../../src/repository-access.ts";3+4+const latch = () => {5+ let release!: () => void;6+ const promise = new Promise<void>((resolve) => { release = resolve; });7+ return { promise, release };8+};9+const refused = () => "refused";10+11+describe("Repository admission", () => {12+ it("keeps unrelated operations out across awaits and permits owned nested work", async () => {13+ const access = new RepositoryAccess();14+ const entered = latch();15+ const finish = latch();16+ const active = access.run("git", async () => {17+ expect(access.owned).toBe(true);18+ expect(access.sync("event", () => "nested", refused)).toBe("nested");19+ entered.release();20+ await finish.promise;21+ expect(await access.run("control", () => "nested async", refused)).toBe("nested async");22+ expect(access.kind).toBe("git");23+ return "done";24+ }, refused);25+ await entered.promise;26+ expect(access.blocked).toBe(true);27+ expect(access.sync("event", () => "unexpected", refused)).toBe("refused");28+ expect(await access.run("alarm", () => "unexpected", refused)).toBe("refused");29+ finish.release();30+ expect(await active).toBe("done");31+ expect(access.busy).toBe(false);32+ });33+34+ it("releases synchronous and asynchronous owners after failures", async () => {35+ const access = new RepositoryAccess();36+ expect(() => access.sync("event", () => { throw new Error("sync"); }, refused)).toThrow("sync");37+ expect(access.busy).toBe(false);38+ await expect(access.run("git", async () => { throw new Error("async"); }, refused)).rejects.toThrow("async");39+ expect(access.busy).toBe(false);40+ });41+42+ it("retains an entered child until it settles even when the parent returns", async () => {43+ const access = new RepositoryAccess();44+ const finish = latch();45+ let child!: Promise<string>;46+ await access.run("control", () => {47+ child = access.run("event", async () => { await finish.promise; return "child"; }, refused);48+ return "parent";49+ }, refused);50+ expect(access.blocked).toBe(true);51+ finish.release();52+ expect(await child).toBe("child");53+ expect(access.busy).toBe(false);54+ });55+56+ it("never lets a stale async context borrow a different active owner", async () => {57+ const access = new RepositoryAccess();58+ const resume = latch();59+ let stale!: Promise<string>;60+ await access.run("control", () => {61+ stale = resume.promise.then(() => access.run("event", () => "unexpected", refused));62+ return "original";63+ }, refused);64+ const finish = latch();65+ const active = access.run("git", async () => { await finish.promise; return "current"; }, refused);66+ resume.release();67+ expect(await stale).toBe("refused");68+ finish.release();69+ await active;70+ expect(access.busy).toBe(false);71+ });72+});