Skip to content
Merged
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
273 changes: 273 additions & 0 deletions apps/cloud/src/mcp/session-build-semaphore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import { describe, expect, it, beforeEach } from "@effect/vitest";

import {
acquireBuildSlot,
releaseBuildSlot,
resetBuildSlotsForTest,
currentActiveBuildsForTest,
currentQueueLengthForTest,
} from "./session-build-semaphore";

describe("session-build-semaphore", () => {
beforeEach(() => {
resetBuildSlotsForTest();
});

it("grants up to the cap immediately, with no wait", async () => {
const results = await Promise.all([
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
]);
expect(results).toEqual([
{ acquired: true, waitMs: 0, timedOut: false },
{ acquired: true, waitMs: 0, timedOut: false },
{ acquired: true, waitMs: 0, timedOut: false },
{ acquired: true, waitMs: 0, timedOut: false },
]);
expect(currentActiveBuildsForTest()).toBe(4);
expect(currentQueueLengthForTest()).toBe(0);
});

it("queues a build past the cap until a slot is released", async () => {
await Promise.all([
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
]);
expect(currentActiveBuildsForTest()).toBe(4);

let fifthResolved = false;
const fifth = acquireBuildSlot().promise.then((result) => {
fifthResolved = true;
return result;
});

expect(currentQueueLengthForTest()).toBe(1);
// Nothing releases the pending fifth build without an explicit release —
// it must not resolve on its own.
await Promise.resolve();
await Promise.resolve();
expect(fifthResolved).toBe(false);

releaseBuildSlot();
const result = await fifth;
expect(fifthResolved).toBe(true);
expect(result.acquired).toBe(true);
expect(result.timedOut).toBe(false);
expect(result.waitMs).toBeGreaterThanOrEqual(0);
// The freed slot went straight to the waiter — total active stays at cap.
expect(currentActiveBuildsForTest()).toBe(4);
expect(currentQueueLengthForTest()).toBe(0);
});

it("releases queued waiters in FIFO order", async () => {
await Promise.all([
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
]);

const order: number[] = [];
const second = acquireBuildSlot().promise.then(() => order.push(2));
const third = acquireBuildSlot().promise.then(() => order.push(3));
const fourth = acquireBuildSlot().promise.then(() => order.push(4));
expect(currentQueueLengthForTest()).toBe(3);

releaseBuildSlot();
await second;
releaseBuildSlot();
await third;
releaseBuildSlot();
await fourth;

expect(order).toEqual([2, 3, 4]);
});

it("never deadlocks: releasing a slot always makes forward progress for the next waiter", async () => {
await Promise.all([
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
]);

// 6 more builds arrive while all 4 slots are held — all 6 queue.
const queued = Array.from({ length: 6 }, () => acquireBuildSlot().promise);
expect(currentQueueLengthForTest()).toBe(6);

// The 4 in-flight builds finish one at a time; each release must hand its
// slot straight to the next queued waiter rather than sitting idle.
for (let i = 0; i < 4; i++) releaseBuildSlot();
await Promise.all(queued.slice(0, 4));
expect(currentActiveBuildsForTest()).toBe(4);
expect(currentQueueLengthForTest()).toBe(2);

// Those 4 finish too, freeing the last 2 queued waiters.
for (let i = 0; i < 4; i++) releaseBuildSlot();
await Promise.all(queued.slice(4));
expect(currentActiveBuildsForTest()).toBe(2);
expect(currentQueueLengthForTest()).toBe(0);

// And the last 2 finish, draining the semaphore completely.
releaseBuildSlot();
releaseBuildSlot();
expect(currentActiveBuildsForTest()).toBe(0);
expect(currentQueueLengthForTest()).toBe(0);
});

it("does not go negative when released more times than acquired", () => {
releaseBuildSlot();
releaseBuildSlot();
expect(currentActiveBuildsForTest()).toBe(0);
});

it("cancelling a queued waiter dequeues it, and a subsequent release goes to the next waiter", async () => {
await Promise.all([
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
]);
expect(currentActiveBuildsForTest()).toBe(4);

const cancelled = acquireBuildSlot();
let cancelledSettled = false;
void cancelled.promise.then(() => {
cancelledSettled = true;
});

const next = acquireBuildSlot();
let nextResult: Awaited<typeof next.promise> | undefined;
void next.promise.then((r) => {
nextResult = r;
});

expect(currentQueueLengthForTest()).toBe(2);

// Interrupted before it was ever granted a slot (e.g. client disconnect
// while queued) — this must not hand it a slot later, and must not block
// the waiter behind it.
cancelled.cancel();
expect(currentQueueLengthForTest()).toBe(1);

releaseBuildSlot();
await next.promise;

// The cancelled waiter's promise never resolves and never gets a slot;
// the freed slot went straight to the next waiter instead.
await Promise.resolve();
await Promise.resolve();
expect(cancelledSettled).toBe(false);
expect(nextResult).toEqual({ acquired: true, waitMs: expect.any(Number), timedOut: false });
expect(currentActiveBuildsForTest()).toBe(4);
expect(currentQueueLengthForTest()).toBe(0);
});

it("cancel-after-grant releases the slot instead of leaking it", async () => {
await Promise.all([
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
]);
expect(currentActiveBuildsForTest()).toBe(4);

// Queues, then gets granted a slot by the release below.
const grantedThenAbandoned = acquireBuildSlot();
releaseBuildSlot();
const result = await grantedThenAbandoned.promise;
expect(result.acquired).toBe(true);
// Still at the cap: the freed slot was handed straight to this waiter.
expect(currentActiveBuildsForTest()).toBe(4);

// The caller never gets to consume the grant (e.g. its fiber was
// interrupted in the same tick the grant happened) and cancels instead —
// this must give the slot back rather than leaking it forever.
grantedThenAbandoned.cancel();
expect(currentActiveBuildsForTest()).toBe(3);

// Idempotent: a second cancel must not release it again.
grantedThenAbandoned.cancel();
expect(currentActiveBuildsForTest()).toBe(3);

// And the freed slot is usable by a fresh acquire.
const fresh = await acquireBuildSlot().promise;
expect(fresh).toEqual({ acquired: true, waitMs: 0, timedOut: false });
expect(currentActiveBuildsForTest()).toBe(4);
});

it("cancelling an immediately-granted (never-queued) slot releases it exactly once", async () => {
const handle = acquireBuildSlot();
await handle.promise;
expect(currentActiveBuildsForTest()).toBe(1);

handle.cancel();
expect(currentActiveBuildsForTest()).toBe(0);

// Idempotent.
handle.cancel();
expect(currentActiveBuildsForTest()).toBe(0);
});

it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => {
await Promise.all([
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
]);
expect(currentActiveBuildsForTest()).toBe(4);

const timedOutHandle = acquireBuildSlot(10);
const result = await timedOutHandle.promise;

expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true });
expect(result.waitMs).toBeGreaterThanOrEqual(10);
// A timed-out waiter never counted against the cap.
expect(currentActiveBuildsForTest()).toBe(4);
expect(currentQueueLengthForTest()).toBe(0);

// A timed-out waiter never held a slot, so cancelling it must not
// release one that was never granted.
timedOutHandle.cancel();
expect(currentActiveBuildsForTest()).toBe(4);

// The 4 originally-held slots are still releasable normally.
releaseBuildSlot();
releaseBuildSlot();
releaseBuildSlot();
releaseBuildSlot();
expect(currentActiveBuildsForTest()).toBe(0);
});

it("a waiter that times out is removed from the queue and does not block waiters behind it", async () => {
await Promise.all([
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
acquireBuildSlot().promise,
]);

const timesOut = acquireBuildSlot(10);
const staysQueued = acquireBuildSlot();
expect(currentQueueLengthForTest()).toBe(2);

const timedOutResult = await timesOut.promise;
expect(timedOutResult.timedOut).toBe(true);
// Only the timed-out waiter left the queue; the other is still waiting.
expect(currentQueueLengthForTest()).toBe(1);

releaseBuildSlot();
const staysQueuedResult = await staysQueued.promise;
expect(staysQueuedResult).toEqual({
acquired: true,
waitMs: expect.any(Number),
timedOut: false,
});
expect(currentQueueLengthForTest()).toBe(0);
});
});
Loading
Loading