feat(HAI-016): complete Step 1 — create shared AgentSemaphore
This commit is contained in:
161
packages/engine/src/concurrency.test.ts
Normal file
161
packages/engine/src/concurrency.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AgentSemaphore } from "./concurrency.js";
|
||||
|
||||
describe("AgentSemaphore", () => {
|
||||
it("allows immediate acquire when under limit", async () => {
|
||||
const sem = new AgentSemaphore(2);
|
||||
await sem.acquire();
|
||||
expect(sem.activeCount).toBe(1);
|
||||
expect(sem.availableCount).toBe(1);
|
||||
sem.release();
|
||||
expect(sem.activeCount).toBe(0);
|
||||
expect(sem.availableCount).toBe(2);
|
||||
});
|
||||
|
||||
it("queues waiters when at capacity and unblocks FIFO", async () => {
|
||||
const sem = new AgentSemaphore(1);
|
||||
await sem.acquire(); // slot taken
|
||||
|
||||
const order: number[] = [];
|
||||
|
||||
const p1 = sem.acquire().then(() => order.push(1));
|
||||
const p2 = sem.acquire().then(() => order.push(2));
|
||||
|
||||
// Both should be waiting
|
||||
expect(sem.activeCount).toBe(1);
|
||||
|
||||
// Release — first waiter should be unblocked
|
||||
sem.release();
|
||||
await p1;
|
||||
expect(order).toEqual([1]);
|
||||
expect(sem.activeCount).toBe(1);
|
||||
|
||||
// Release again — second waiter
|
||||
sem.release();
|
||||
await p2;
|
||||
expect(order).toEqual([1, 2]);
|
||||
expect(sem.activeCount).toBe(1);
|
||||
|
||||
sem.release();
|
||||
expect(sem.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("run() releases on success", async () => {
|
||||
const sem = new AgentSemaphore(1);
|
||||
const result = await sem.run(async () => {
|
||||
expect(sem.activeCount).toBe(1);
|
||||
return 42;
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
expect(sem.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("run() releases on error", async () => {
|
||||
const sem = new AgentSemaphore(1);
|
||||
await expect(
|
||||
sem.run(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
expect(sem.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("respects dynamic limit changes on next acquire", async () => {
|
||||
let limit = 2;
|
||||
const sem = new AgentSemaphore(() => limit);
|
||||
|
||||
await sem.acquire();
|
||||
await sem.acquire();
|
||||
expect(sem.activeCount).toBe(2);
|
||||
expect(sem.availableCount).toBe(0);
|
||||
|
||||
// Increase the limit — next acquire should succeed immediately
|
||||
limit = 3;
|
||||
expect(sem.availableCount).toBe(1);
|
||||
await sem.acquire();
|
||||
expect(sem.activeCount).toBe(3);
|
||||
|
||||
sem.release();
|
||||
sem.release();
|
||||
sem.release();
|
||||
});
|
||||
|
||||
it("blocks new acquires when limit is reduced below activeCount", async () => {
|
||||
let limit = 3;
|
||||
const sem = new AgentSemaphore(() => limit);
|
||||
|
||||
await sem.acquire();
|
||||
await sem.acquire();
|
||||
expect(sem.activeCount).toBe(2);
|
||||
|
||||
// Reduce limit below current active count
|
||||
limit = 1;
|
||||
expect(sem.availableCount).toBe(0);
|
||||
|
||||
let acquired = false;
|
||||
const p = sem.acquire().then(() => {
|
||||
acquired = true;
|
||||
});
|
||||
|
||||
// Should not have acquired yet
|
||||
await Promise.resolve(); // tick
|
||||
expect(acquired).toBe(false);
|
||||
|
||||
// Release one slot — active goes from 2 to 1, still >= limit (1), so still blocked
|
||||
sem.release();
|
||||
await Promise.resolve();
|
||||
expect(acquired).toBe(false);
|
||||
|
||||
// Release again — active drops to 0, which is < limit (1), so waiter unblocks
|
||||
sem.release();
|
||||
await p;
|
||||
expect(acquired).toBe(true);
|
||||
expect(sem.activeCount).toBe(1);
|
||||
|
||||
sem.release();
|
||||
});
|
||||
|
||||
it("activeCount and availableCount are accurate under load", async () => {
|
||||
const sem = new AgentSemaphore(3);
|
||||
expect(sem.activeCount).toBe(0);
|
||||
expect(sem.availableCount).toBe(3);
|
||||
expect(sem.limit).toBe(3);
|
||||
|
||||
await sem.acquire();
|
||||
expect(sem.activeCount).toBe(1);
|
||||
expect(sem.availableCount).toBe(2);
|
||||
|
||||
await sem.acquire();
|
||||
await sem.acquire();
|
||||
expect(sem.activeCount).toBe(3);
|
||||
expect(sem.availableCount).toBe(0);
|
||||
|
||||
sem.release();
|
||||
expect(sem.activeCount).toBe(2);
|
||||
expect(sem.availableCount).toBe(1);
|
||||
|
||||
sem.release();
|
||||
sem.release();
|
||||
expect(sem.activeCount).toBe(0);
|
||||
expect(sem.availableCount).toBe(3);
|
||||
});
|
||||
|
||||
it("run() gates concurrent calls", async () => {
|
||||
const sem = new AgentSemaphore(2);
|
||||
let concurrent = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
const task = () =>
|
||||
sem.run(async () => {
|
||||
concurrent++;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||
// Yield to allow other tasks to attempt to run
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
concurrent--;
|
||||
});
|
||||
|
||||
await Promise.all([task(), task(), task(), task(), task()]);
|
||||
expect(maxConcurrent).toBe(2);
|
||||
expect(sem.activeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user