diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index 4e928e04fc..8c629422a6 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -523,6 +523,12 @@ exhausts its bounds of `10,000` visited nodes or depth `12`. Each process uses a process-local guard to reserve limited heavyweight capacity before retaining and parsing a large request body. A heavyweight lease remains held for the lifetime of an SSE response. + +When capacity is busy, a heavyweight request first waits up to +`OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` (default `5000`, `0` disables the wait) for a slot to free up +before answering the retryable `503`. The bounded wait exists so agent-style clients +(OpenCode, Claude Code, Cursor) that fan out heavy sub-requests concurrently serialize the burst +instead of burning their whole retry budget on immediate rejections and dying mid-task. Current heavyweight lease occupancy is not surfaced in the dashboard. Settings → Resilience → Request Queue → Concurrent Requests does not control this; that setting governs a separate provider request-queue mechanism. @@ -530,13 +536,18 @@ governs a separate provider request-queue mechanism. **Fix:** 1. Retry first. Clients should honor `Retry-After` and use backoff rather than immediately - repeating the request. + repeating the request. Note that with the default `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=5000` + a heavy request already waited up to 5 seconds before the `503`, so a client retry loop should + back off beyond that instead of hammering. 2. If normal deployment traffic repeatedly exhausts the guard, you can cautiously raise `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` from its default of `1`. Increase it one step at a time, restart OmniRoute after each change, and observe memory headroom under representative load. Every additional heavyweight request can increase concurrent V8 heap use and container or host OOM risk. No value is safe for every deployment; validate the setting against your own traffic and memory limits rather than assuming that `2` is universally safe. +3. Prefer widening the wait (`OMNIROUTE_CHAT_ADMISSION_QUEUE_MS`) over raising the in-flight + limit when bursts are short: waiting costs latency, while an extra concurrent heavyweight + request costs heap residency for the whole request lifetime. See the [environment-variable reference](../reference/ENVIRONMENT.md#4-security--authentication) for the authoritative admission settings. Loosening the heavyweight classification thresholds diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 161b9f1252..3fad2a7ef2 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -190,7 +190,8 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. | | `OMNIROUTE_CHAT_LARGE_BODY_BYTES` | `262144` (256 KB) | `src/shared/middleware/chatBodyAdmission.ts` | Actual request bodies at or above this threshold require an atomic process-local heavyweight admission lease before JSON parsing. | | `OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES` | `52428800` (50 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Chat-route hard cap enforced against bytes read during bounded ingestion, including requests with missing, invalid, or dishonest `Content-Length`; excess receives `413`. | -| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | `1` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum heavyweight chat requests admitted concurrently in one process. When capacity is unavailable, OmniRoute returns retryable `503` with `Retry-After`. | +| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | `1` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum heavyweight chat requests admitted concurrently in one process. When capacity is unavailable, OmniRoute waits up to `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` for a slot, then returns retryable `503` with `Retry-After`. | +| `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` | `5000` | `src/shared/middleware/chatBodyAdmission.ts` | How long a heavyweight chat request waits for an admission slot before the retryable `503`. A bounded wait serializes agent bursts (OpenCode, Claude Code, Cursor sub-requests) that would otherwise burn their client retry budget on immediate rejections; `0` restores the legacy immediate-reject behaviour. | | `OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT` | `200` | `src/shared/middleware/chatBodyAdmission.ts` | Message count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_TOOL_COUNT` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Tool count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. | diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index 4c8f16331a..e61ec10603 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -17,6 +17,7 @@ import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveTh import { admitChatRequest, admitChatStructure, + CHAT_ADMISSION_QUEUE_MAX_MS, releaseChatAdmissionAfterHandler, releaseChatAdmissionWhenDone, } from "@/shared/middleware/chatBodyAdmission"; @@ -99,7 +100,9 @@ export async function POST(request) { // Reserve heavyweight capacity atomically and ingest the body with a hard byte bound // BEFORE JSON parsing. Missing or dishonest Content-Length values cannot bypass // the actual-byte limit. Capacity exhaustion is retryable rather than process-fatal. - const admissionResult = await admitChatRequest(request); + const admissionResult = await admitChatRequest(request, { + queueMs: CHAT_ADMISSION_QUEUE_MAX_MS, + }); if (admissionResult.admit === false) return admissionResult.response; const admission = admissionResult; request = admission.request; @@ -142,7 +145,9 @@ export async function POST(request) { } } - const structuralAdmission = admitChatStructure(parsedBody, admission.lease); + const structuralAdmission = await admitChatStructure(parsedBody, admission.lease, { + queueMs: CHAT_ADMISSION_QUEUE_MAX_MS, + }); if (structuralAdmission.admit === false) { admission.lease?.release(); return finishAdmission(structuralAdmission.response); diff --git a/src/shared/middleware/chatBodyAdmission.ts b/src/shared/middleware/chatBodyAdmission.ts index 5219ddf776..4dfdf36bd7 100644 --- a/src/shared/middleware/chatBodyAdmission.ts +++ b/src/shared/middleware/chatBodyAdmission.ts @@ -15,6 +15,11 @@ function parsePositiveInt(value: string | undefined, fallback: number): number { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; } +function parseNonNegativeInt(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(String(value), 10); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + export const CHAT_LARGE_BODY_BYTES = parsePositiveInt( process.env.OMNIROUTE_CHAT_LARGE_BODY_BYTES, 256 * 1024 @@ -30,6 +35,18 @@ const CHAT_MAX_HEAVY_IN_FLIGHT = parsePositiveInt( 1 ); +/** + * How long a heavy request waits for heavyweight capacity before giving up with a + * retryable 503. Agent loops (OpenCode, Claude Code, Cursor…) fan out sub-requests + * that routinely land on the admission gate together; an immediate 503 makes the + * client burn its retry budget in seconds and the agent dies mid-task. A short + * bounded wait serializes the burst instead. `0` (legacy) rejects immediately. + */ +export const CHAT_ADMISSION_QUEUE_MAX_MS = parseNonNegativeInt( + process.env.OMNIROUTE_CHAT_ADMISSION_QUEUE_MS, + 5000 +); + export const CHAT_HEAVY_MESSAGE_COUNT = parsePositiveInt( process.env.OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT, 200 @@ -71,10 +88,13 @@ export interface ChatAdmissionLease { /** * Process-local heavyweight reservation. The capacity check and increment execute in one * synchronous JavaScript turn, making acquisition atomic within an OmniRoute process. - * Queueing is intentionally separate: unavailable capacity is a retryable 503. + * Unavailable capacity is a bounded wait (see `acquireHeavyWithin`) and only then a + * retryable 503, so short agent bursts serialize instead of killing the client's + * retry budget. */ export class ChatAdmissionController { #activeHeavy = 0; + #waiters: Array<() => void> = []; constructor(readonly maxHeavyInFlight = 1) { if (!Number.isSafeInteger(maxHeavyInFlight) || maxHeavyInFlight < 1) { @@ -98,9 +118,40 @@ export class ChatAdmissionController { if (released) return; released = true; this.#activeHeavy = Math.max(0, this.#activeHeavy - 1); + this.#waiters.shift()?.(); }, }; } + + /** + * Wait up to `timeoutMs` for heavyweight capacity, retrying atomically on each + * release. Resolves `null` when the deadline expires with no capacity freed, in + * which case the caller answers the retryable 503. `timeoutMs <= 0` is the + * legacy immediate-reject path. Waiters are served FIFO. + */ + async acquireHeavyWithin(timeoutMs: number): Promise { + const deadline = Date.now() + Math.max(0, Math.floor(timeoutMs)); + for (;;) { + const lease = this.tryAcquireHeavy(); + if (lease) return lease; + const remaining = deadline - Date.now(); + if (remaining <= 0) return null; + let resolver: (() => void) | null = null; + const released = new Promise((resolve) => { + resolver = () => resolve(); + this.#waiters.push(resolver); + }); + const timedOut = await Promise.race([ + released.then(() => false), + new Promise((resolve) => setTimeout(() => resolve(true), remaining)), + ]); + if (resolver) { + const index = this.#waiters.indexOf(resolver); + if (index >= 0) this.#waiters.splice(index, 1); + } + if (timedOut) return null; + } + } } const defaultAdmissionController = new ChatAdmissionController(CHAT_MAX_HEAVY_IN_FLIGHT); @@ -208,7 +259,7 @@ function estimateStructureTokens(value: unknown, limit: number): TokenEstimate { return { tokens, exhausted: stack.length > 0 && tokens < limit }; } -export function admitChatStructure( +export async function admitChatStructure( body: unknown, lease: ChatAdmissionLease | null, options: { @@ -217,8 +268,9 @@ export function admitChatStructure( heavyMessages?: number; heavyTools?: number; heavyTokens?: number; + queueMs?: number; } = {} -): ChatStructureAdmission { +): Promise { if (!body || typeof body !== "object" || Array.isArray(body)) return { admit: true, lease }; const record = body as Record; @@ -249,7 +301,9 @@ export function admitChatStructure( estimatedTokens >= heavyTokens; if (!heavy || lease) return { admit: true, lease }; - const acquired = (options.controller ?? defaultAdmissionController).tryAcquireHeavy(); + const acquired = await (options.controller ?? defaultAdmissionController).acquireHeavyWithin( + options.queueMs ?? 0 + ); return acquired ? { admit: true, lease: acquired } : { admit: false, response: structuralRejectionResponse(503, maxMessages) }; @@ -361,11 +415,13 @@ export async function admitChatRequest( controller?: ChatAdmissionController; largeBodyBytes?: number; hardMaxBytes?: number; + queueMs?: number; } = {} ): Promise { const controller = options.controller ?? defaultAdmissionController; const largeBodyBytes = options.largeBodyBytes ?? CHAT_LARGE_BODY_BYTES; const hardMaxBytes = options.hardMaxBytes ?? CHAT_HARD_MAX_BODY_BYTES; + const queueMs = options.queueMs ?? 0; const internalBypass = isInternalAdmissionBypass(request); const contentLength = parseContentLength(request.headers.get("content-length")); @@ -411,15 +467,15 @@ export async function admitChatRequest( } let lease: ChatAdmissionLease | null = null; - const reserve = (): boolean => { + const reserve = async (): Promise => { if (lease) return true; - lease = controller.tryAcquireHeavy(); + lease = await controller.acquireHeavyWithin(queueMs); return lease !== null; }; // A known-large declaration can reserve before ingestion. Unknown lengths are boundedly // sniffed below; this avoids consuming scarce heavyweight capacity for small chunked bodies. - if (contentLength !== null && contentLength >= largeBodyBytes && !reserve()) { + if (contentLength !== null && contentLength >= largeBodyBytes && !(await reserve())) { return { admit: false, response: rejectionResponse(503, hardMaxBytes) }; } @@ -438,7 +494,7 @@ export async function admitChatRequest( lease?.release(); return { admit: false, response: rejectionResponse(413, hardMaxBytes) }; } - if (totalBytes >= largeBodyBytes && !reserve()) { + if (totalBytes >= largeBodyBytes && !(await reserve())) { await reader.cancel("chat admission capacity unavailable").catch(() => undefined); return { admit: false, response: rejectionResponse(503, hardMaxBytes) }; } diff --git a/tests/unit/chat-body-admission.test.ts b/tests/unit/chat-body-admission.test.ts index e17156a8e3..5ecb5111b6 100644 --- a/tests/unit/chat-body-admission.test.ts +++ b/tests/unit/chat-body-admission.test.ts @@ -60,7 +60,7 @@ test("small known body is admitted without consuming heavyweight capacity", asyn test("a byte-light request above the message threshold acquires heavyweight capacity", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [ { role: "user", content: "one" }, @@ -82,7 +82,7 @@ test("a byte-light request above the tool threshold is rejected when heavy capac const occupied = controller.tryAcquireHeavy(); assert.ok(occupied); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [], tools: [{ type: "function" }, { type: "function" }] }, null, { controller, maxMessages: 10, heavyMessages: 10, heavyTools: 2, heavyTokens: 10_000 } @@ -98,7 +98,7 @@ test("a byte-light request above the tool threshold is rejected when heavy capac test("an opt-in history cap still returns the structured compact-required 413", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: Array.from({ length: 3 }, () => ({ role: "user", content: "x" })) }, null, { controller, maxMessages: 2, heavyMessages: 1, heavyTools: 10, heavyTokens: 10_000 } @@ -120,7 +120,7 @@ test("no history cap is enforced by default; long conversations are admitted", a assert.equal(CHAT_HARD_MAX_MESSAGES, 0, "the shipped default must not cap history"); const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: Array.from({ length: 5_000 }, () => ({ role: "user", content: "x" })) }, null, { controller, heavyMessages: 200, heavyTools: 64, heavyTokens: 32_000 } @@ -137,7 +137,7 @@ test("an uncapped oversized conversation still yields to occupied heavyweight ca const occupied = controller.tryAcquireHeavy(); assert.ok(occupied); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: Array.from({ length: 5_000 }, () => ({ role: "user", content: "x" })) }, null, { controller, maxMessages: 0, heavyMessages: 200, heavyTools: 64, heavyTokens: 32_000 } @@ -153,9 +153,9 @@ test("an uncapped oversized conversation still yields to occupied heavyweight ca occupied.release(); }); -test("maxMessages: 0 explicitly disables the history cap", () => { +test("maxMessages: 0 explicitly disables the history cap", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: Array.from({ length: 3 }, () => ({ role: "user", content: "x" })) }, null, { controller, maxMessages: 0, heavyMessages: 1, heavyTools: 10, heavyTokens: 10_000 } @@ -165,9 +165,9 @@ test("maxMessages: 0 explicitly disables the history cap", () => { if (result.admit) result.lease?.release(); }); -test("a conservative token estimate classifies string messages and tool schemas as heavy", () => { +test("a conservative token estimate classifies string messages and tool schemas as heavy", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [{ role: "user", content: "abcdefgh" }], tools: [{ type: "function", function: { name: "tool", description: "abcdefgh" } }], @@ -181,9 +181,9 @@ test("a conservative token estimate classifies string messages and tool schemas if (result.admit) result.lease?.release(); }); -test("exhausting the bounded structural inspection is conservatively heavyweight", () => { +test("exhausting the bounded structural inspection is conservatively heavyweight", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [ { @@ -201,12 +201,12 @@ test("exhausting the bounded structural inspection is conservatively heavyweight if (result.admit) result.lease?.release(); }); -test("tool-schema property names contribute to the conservative token estimate", () => { +test("tool-schema property names contribute to the conservative token estimate", async () => { const controller = new ChatAdmissionController(1); const properties = Object.fromEntries( Array.from({ length: 5 }, (_, index) => [`${index}${"k".repeat(99)}`, {}]) ); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [], tools: [{ function: { parameters: { properties } } }] }, null, { controller, maxMessages: 10, heavyMessages: 10, heavyTools: 10, heavyTokens: 100 } @@ -217,9 +217,9 @@ test("tool-schema property names contribute to the conservative token estimate", if (result.admit) result.lease?.release(); }); -test("non-ASCII strings use a conservative UTF-8 token estimate", () => { +test("non-ASCII strings use a conservative UTF-8 token estimate", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [{ role: "user", content: "漢".repeat(100) }] }, null, { controller, maxMessages: 10, heavyMessages: 10, heavyTools: 10, heavyTokens: 100 } @@ -230,10 +230,10 @@ test("non-ASCII strings use a conservative UTF-8 token estimate", () => { if (result.admit) result.lease?.release(); }); -test("wide objects exhaust bounded inspection without materializing all property values", () => { +test("wide objects exhaust bounded inspection without materializing all property values", async () => { const controller = new ChatAdmissionController(1); const wide = Object.fromEntries(Array.from({ length: 10_001 }, (_, index) => [`k${index}`, 0])); - const result = admitChatStructure({ messages: [{ role: "user", content: wide }] }, null, { + const result = await admitChatStructure({ messages: [{ role: "user", content: wide }] }, null, { controller, maxMessages: 10, heavyMessages: 10, @@ -246,12 +246,12 @@ test("wide objects exhaust bounded inspection without materializing all property if (result.admit) result.lease?.release(); }); -test("an existing byte-heavy lease is reused for structure-heavy admission", () => { +test("an existing byte-heavy lease is reused for structure-heavy admission", async () => { const controller = new ChatAdmissionController(1); const lease = controller.tryAcquireHeavy(); assert.ok(lease); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [ { role: "user", content: "one" }, @@ -637,7 +637,7 @@ test("bypass describe call passes the structural stage while the parent holds th // Structural stage (the route's admitChatStructure(parsedBody, admission.lease)): // the sentinel lease must prevent the heavy body from re-acquiring → no 503. - const structural = admitChatStructure(JSON.parse(body), admission.lease, { controller }); + const structural = await admitChatStructure(JSON.parse(body), admission.lease, { controller }); assert.equal(structural.admit, true); assert.equal(controller.activeHeavy, 1); @@ -813,3 +813,168 @@ test("sk_omniroute sentinel is rejected once an env key is configured (REQUIRE_A restore(); } }); + +test("a heavy structural request waits for capacity instead of failing immediately", async () => { + const controller = new ChatAdmissionController(1); + const held = controller.tryAcquireHeavy(); + assert.ok(held); + + const pending = admitChatStructure( + { + messages: [ + { role: "user", content: "one" }, + { role: "user", content: "two" }, + ], + }, + null, + { + controller, + maxMessages: 10, + heavyMessages: 2, + heavyTools: 10, + heavyTokens: 10_000, + queueMs: 500, + } + ); + + // Capacity is still busy: the request must not have resolved (admit/reject) yet. + let settled = false; + void pending.then(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(settled, false, "must wait while capacity is busy"); + + held.release(); + const result = await pending; + assert.equal(result.admit, true); + if (result.admit) { + assert.equal(controller.activeHeavy, 1, "waiting request acquires the freed lease"); + result.lease?.release(); + } + assert.equal(controller.activeHeavy, 0); +}); + +test("waiting for admission times out into a retryable 503", async () => { + const controller = new ChatAdmissionController(1); + const held = controller.tryAcquireHeavy(); + assert.ok(held); + + const started = Date.now(); + const result = await admitChatStructure( + { + messages: [ + { role: "user", content: "one" }, + { role: "user", content: "two" }, + ], + }, + null, + { + controller, + maxMessages: 10, + heavyMessages: 2, + heavyTools: 10, + heavyTokens: 10_000, + queueMs: 50, + } + ); + + assert.equal(result.admit, false); + if (!result.admit) { + assert.equal(result.response.status, 503); + assert.equal(result.response.headers.get("retry-after"), "1"); + assert.equal((await result.response.json()).error.code, "chat_admission_busy"); + } + assert.ok(Date.now() - started >= 40, "must wait for the queue deadline before rejecting"); + assert.equal(controller.activeHeavy, 1, "the holder keeps its lease"); + held.release(); + assert.equal(controller.activeHeavy, 0); +}); + +test("byte-heavy admission waits for capacity when queueMs is set", async () => { + const controller = new ChatAdmissionController(1); + const body = JSON.stringify({ messages: [{ role: "user", content: "x".repeat(40) }] }); + const options = { controller, largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 500 }; + + const first = await admitChatRequest(chatRequest(body), options); + assert.equal(first.admit, true); + if (!first.admit) return; + + const second = admitChatRequest(chatRequest(body), options); + let secondSettled = false; + void second.then(() => { + secondSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal( + secondSettled, + false, + "second heavy request must queue while the first holds capacity" + ); + + first.lease?.release(); + const secondResult = await second; + assert.equal(secondResult.admit, true, "second request acquires capacity after release"); + if (secondResult.admit) secondResult.lease?.release(); + assert.equal(controller.activeHeavy, 0); +}); + +test("expired admission queue keeps the legacy immediate 503 behaviour", async () => { + const controller = new ChatAdmissionController(1); + const held = controller.tryAcquireHeavy(); + assert.ok(held); + + const result = await admitChatStructure( + { + messages: [ + { role: "user", content: "one" }, + { role: "user", content: "two" }, + ], + }, + null, + { + controller, + maxMessages: 10, + heavyMessages: 2, + heavyTools: 10, + heavyTokens: 10_000, + queueMs: 0, + } + ); + + assert.equal(result.admit, false); + if (!result.admit) assert.equal(result.response.status, 503); + held.release(); +}); + +test("admission waiters are served FIFO as capacity frees", async () => { + const controller = new ChatAdmissionController(1); + const held = controller.tryAcquireHeavy(); + assert.ok(held); + + const body = { + messages: [ + { role: "user", content: "one" }, + { role: "user", content: "two" }, + ], + }; + const options = { + controller, + maxMessages: 10, + heavyMessages: 2, + heavyTools: 10, + heavyTokens: 10_000, + queueMs: 500, + }; + const first = admitChatStructure(body, null, options); + const second = admitChatStructure(body, null, options); + + held.release(); + const firstResult = await first; + assert.equal(firstResult.admit, true); + if (firstResult.admit) firstResult.lease?.release(); + const secondResult = await second; + assert.equal(secondResult.admit, true); + if (secondResult.admit) secondResult.lease?.release(); + assert.equal(controller.activeHeavy, 0); +});