diff --git a/src/preflight.ts b/src/preflight.ts index 5390cacbc..7675c92ed 100644 --- a/src/preflight.ts +++ b/src/preflight.ts @@ -95,6 +95,35 @@ export function estimateCoreMessages(messages: CoreMessage[]): number { return tokens; } +// #554: side requests bypass the pipeline (#388) and are forwarded VERBATIM, +// so their fit decision must be made on the RAW client body — the kernel view +// is empty on that path (processedMessages: []). Walks every string leaf of +// the parsed body through the same CJK-aware defaultCountTokens; binary- +// carrying fields (base64 image data, data-URLs) are excluded because +// imageTokensInParsedBody charges those separately. Slightly overcounts (ids, +// roles, structural strings) — a conservative bias is right for a guard that +// fails closed. +const NON_TEXT_BODY_KEYS = new Set(["data", "url", "b64_json", "file_data"]); + +export function estimateRawBodyTokens(parsed: unknown): number { + let tokens = 0; + const walk = (value: unknown, key?: string): void => { + if (typeof value === "string") { + if (!key || !NON_TEXT_BODY_KEYS.has(key)) tokens += defaultCountTokens(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) walk(item, key); + return; + } + if (value && typeof value === "object") { + for (const [k, v] of Object.entries(value as Record)) walk(v, k); + } + }; + walk(parsed); + return tokens; +} + function rangeChars(messages: CoreMessage[], startIdx: number, endIdx: number): number { let chars = 0; for (let i = startIdx; i <= endIdx && i < messages.length; i++) { diff --git a/src/server.ts b/src/server.ts index a63362af1..329bad4c8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,7 +45,7 @@ import { getSession, listSessions, type Session, initSessions, markDirty, flushA import { COMPRESS_TOOL, ACP_TOOLS_ANTHROPIC, ACP_TOOLS_OPENAI, ACP_TOOLS_RESPONSES, ACP_READONLY_TOOLS_RESPONSES, COMPRESS_TOOL_NAME, buildCompressSystemPrompt, buildCompressHybridSystemPrompt, withStagedCompressGuidance } from "./compress-tool.js"; import { rewriteJsonResponse, type RewriteCtx } from "./stream.js"; import { applyRanges } from "./stream.js"; -import { preflightCompress, estimateCoreMessages } from "./preflight.js"; +import { preflightCompress, estimateCoreMessages, estimateRawBodyTokens } from "./preflight.js"; import { imageTokensInRawBody, imageTokensInParsedBody } from "./image-tokens.js"; import { renderUI, handleConfigGet, handleConfigPut } from "./web/index.js"; import { reapOrphanBlocks } from "./orphan-gc.js"; @@ -670,6 +670,38 @@ export function restoreOutputBudget( } } +// defaultCountTokens counts CJK per-char but real tokenizers encode CJK at +// ~0.6-0.75 tokens/char, so CJK-heavy raw bodies over-estimate by up to ~1.6x. +// Everywhere else that bias is safe (it only compresses earlier); here it +// would hard-deny a payload that really fits (retryable: false), so tolerate +// 15% over the window — borderline payloads forward, and a real overflow 400 +// still teaches the learned limit. +const SIDE_REQUEST_GUARD_TOLERANCE = 1.15; + +/** #554: side requests are forwarded VERBATIM (no pipeline, #388), so a payload + * over the upstream window is a guaranteed 400 that the learned self-heal can + * never fix from this path — the gate fires before the self-heal read and + * never consults reqConfig.modelContextLimit. Block here instead of forwarding: + * estimate the RAW body (CJK-aware text + image tokens) against the effective + * window = resolved ∩ learned (learned only ever shrinks) minus the output + * reservation on wires where output counts against the window. blocked=false + * with limit<=0 means "window unknown — forward as before". blocked requires + * estimate >= limit x SIDE_REQUEST_GUARD_TOLERANCE (estimator bias). */ +export function sideRequestGuard( + parsed: unknown, + protocol: WireProtocol, + modelContextLimit: number, + learnedLimit: number | undefined, +): { blocked: boolean; estimate: number; limit: number } { + let limit = modelContextLimit; + if (typeof learnedLimit === "number" && learnedLimit > 0 && learnedLimit < limit) limit = learnedLimit; + const field = outputBudgetField(parsed); + const maxOut = field ? ((parsed as Record)[field] as number) : 0; + if (limit > 0 && shouldReserveOutputHeadroom(protocol)) limit = reserveOutputHeadroom(limit, maxOut); + const estimate = estimateRawBodyTokens(parsed) + imageTokensInParsedBody(protocol, parsed); + return { blocked: limit > 0 && estimate >= limit * SIDE_REQUEST_GUARD_TOLERANCE, estimate, limit }; +} + function isTrustedAdminOrigin(origin: string | undefined, host: string | undefined, trustedHosts: Set): boolean { // Host must be one of OUR listen identities regardless of whether an // Origin header is present. A same-origin browser GET/fetch (the DNS @@ -1344,6 +1376,32 @@ async function handle( // preflight / fake-completion retry / the loop / usage sniffing are all // skipped. processedMessages stays empty so the loop can never engage. if (!countTokens && !responsesCompact && protocol !== null && isSideRequest(parsed)) { + // #554: the passthrough below skips EVERY input-side guard by design + // (#388) — a full-history side request over the window is a + // guaranteed upstream 400 (and title-gen/probe clients re-issue it, + // hammering the upstream). Gate on the raw body estimate and fail + // fast locally instead of forwarding (#301 precedent). + const reqModel = (parsed as { model?: string }).model; + const learnedMap = session.metadata.learnedContextLimits as Record | undefined; + const learnedLimit = + (reqModel && learnedMap ? learnedMap[reqModel] : undefined) ?? + (session.metadata.learnedContextLimit as number | undefined); + const guard = sideRequestGuard(parsed, protocol, reqConfig.modelContextLimit, learnedLimit); + if (guard.blocked) { + log("warn", `[${session.id}] side request (~${guard.estimate} tokens) ≥ effective window ${guard.limit} (model=${reqModel ?? "?"}) — NOT forwarded: guaranteed upstream 400 (side requests bypass preflight by design, #388)`); + if (!res.headersSent && !res.writableEnded && !res.destroyed) { + res.writeHead(413, { "content-type": "application/json" }); + res.end(JSON.stringify({ + error: { + type: "server_error", + code: "side_request_payload_too_large", + message: `side request payload ~${guard.estimate} tokens reaches the effective context window ${guard.limit} (model=${reqModel ?? "unknown"}); NOT forwarded — side requests (max_tokens<=${SIDE_REQUEST_MAX_TOKENS}) bypass compression by design (#388). Shrink the conversation or raise the model's context window.`, + retryable: false, + }, + })); + } + return; + } log("info", `[${session.id}] side request (max_tokens<=${SIDE_REQUEST_MAX_TOKENS}) → passthrough + tag strip only, kernel state untouched`); const sidePrepared: Prepared = { body: bodyBuffer, @@ -2818,9 +2876,10 @@ async function forward( // limit from another model must not cap this one). let reqModel: string | undefined; let rejectedImageTokens = 0; + let parsedBody: Record | undefined; try { const rawBody = typeof prepared.body === "string" ? prepared.body : prepared.body.toString("utf8"); - const parsedBody = JSON.parse(rawBody) as Record; + parsedBody = JSON.parse(rawBody) as Record; reqModel = typeof parsedBody.model === "string" ? parsedBody.model : undefined; // #488: the rejected payload's size must include its images (they were forwarded verbatim). rejectedImageTokens = imageTokensInParsedBody(prepared.protocol, parsedBody); @@ -2844,7 +2903,11 @@ async function forward( // so the next turn re-centers the limit and the pre-flight // compresses below it. Only shrink, never grow: a previously // learned (smaller) value is the tighter bound. - const payloadEstimate = estimateCoreMessages(prepared.processedMessages) + rejectedImageTokens; + // #554: side passthrough has an EMPTY kernel view (processedMessages=[]), + // so estimate from the raw body — exactly what was forwarded verbatim. + const payloadEstimate = (prepared.processedMessages.length > 0 + ? estimateCoreMessages(prepared.processedMessages) + : estimateRawBodyTokens(parsedBody)) + rejectedImageTokens; const prev = (reqModel ? learnedMap[reqModel] : undefined) ?? (s.metadata.learnedContextLimit as number | undefined); if (payloadEstimate >= 1000 && (prev === undefined || payloadEstimate < prev)) { if (reqModel) learnedMap[reqModel] = payloadEstimate; diff --git a/src/util.ts b/src/util.ts index 44f506622..6b96cb17e 100644 --- a/src/util.ts +++ b/src/util.ts @@ -120,6 +120,9 @@ const CONTEXT_OVERFLOW_PATTERNS: RegExp[] = [ /prompt_is_too_long/i, /request_too_large/i, /token limit exceeded/i, + // #554: llama.cpp-family "exceed_context_size_error (A / B > W)" — carried by + // side requests that bypass preflight; without it the learned channel learns nothing. + /exceed[_\s]?context[_\s]?size/i, ]; function toTokenNumber(s: string): number | undefined { @@ -136,6 +139,11 @@ function parseOverflowWindow(text: string): number | undefined { // "130000 tokens > 128000 maximum" (Anthropic) → the maximum, not the total. let m = text.match(/>\s*(\d[\d,]*)\s*maximum/i); if (m) return toTokenNumber(m[1]); + // #554: "exceed_context_size_error (198,277 / 198,661 > 150,528)" (llama.cpp + // family) — A/B are payload sizes; only the number after ">" inside the + // parens is the limit. + m = text.match(/\(\s*\d[\d,]*\s*\/\s*\d[\d,]*\s*>\s*(\d[\d,]+)\s*\)/); + if (m) return toTokenNumber(m[1]); m = text.match(/maximum context length is (\d[\d,]*)/i) ?? text.match(/maximum context length of (\d[\d,]*)/i) ?? diff --git a/tests/side-request-isolation.test.ts b/tests/side-request-isolation.test.ts index 1be74cec6..867494fd5 100644 --- a/tests/side-request-isolation.test.ts +++ b/tests/side-request-isolation.test.ts @@ -2,11 +2,13 @@ import test from "node:test"; import assert from "node:assert/strict"; import http from "node:http"; import { once } from "node:events"; -import { defaultConfig, createInitialState } from "acp-kernel"; -import { startServer, type ProxyOptions, isSideRequest, outputBudgetField, restoreOutputBudget } from "../src/server.ts"; +import { defaultConfig, createInitialState, defaultCountTokens } from "acp-kernel"; +import { startServer, type ProxyOptions, isSideRequest, outputBudgetField, restoreOutputBudget, sideRequestGuard } from "../src/server.ts"; +import { estimateRawBodyTokens } from "../src/preflight.ts"; +import { inspectContextOverflow } from "../src/util.ts"; import { SessionStore, _setStoreForTest } from "../src/persist.ts"; import { _setForTest as setRegistryForTest } from "../src/registry.ts"; -import { getSession } from "../src/session.ts"; +import { getSession, _resetSessionsForTest } from "../src/session.ts"; // #388: side requests (title-gen / small utility calls) share the main session // key but must not touch kernel state. The proxy routes them as pure passthrough @@ -86,6 +88,86 @@ const SESSION = "side-iso-sess"; const MAIN_INPUT_TOKENS = 50_000; const SIDE_INPUT_TOKENS = 56; +test("estimateRawBodyTokens: counts string leaves, skips binary-carrying keys (#554)", () => { + const txt = "z".repeat(800); + const body = { model: MODEL, max_tokens: 100, messages: [{ role: "user", content: txt }] }; + assert.equal( + estimateRawBodyTokens(body), + defaultCountTokens(MODEL) + defaultCountTokens("user") + defaultCountTokens(txt), + "every counted string leaf goes through the CJK-aware estimator", + ); + // Binary-carrying fields are excluded (image-tokens charges them separately). + const data = "A".repeat(8000); + const imgBody = { model: MODEL, max_tokens: 100, messages: [{ role: "user", content: [ + { type: "text", text: txt }, + { type: "image", source: { type: "base64", media_type: "image/png", data } }, + ] }] }; + assert.equal( + estimateRawBodyTokens(imgBody), + defaultCountTokens(MODEL) + defaultCountTokens("user") + + defaultCountTokens("text") + defaultCountTokens(txt) + + defaultCountTokens("image") + defaultCountTokens("base64") + defaultCountTokens("image/png"), + "data field excluded, structural strings still counted", + ); + assert.equal(estimateRawBodyTokens({ url: "http://x/y".repeat(1000) }), 0, "url field excluded"); + assert.equal(estimateRawBodyTokens({ b64_json: "A".repeat(10_000) }), 0, "b64_json field excluded"); + assert.equal(estimateRawBodyTokens({ file_data: "data:application/pdf;base64,".padEnd(10_000, "A") }), 0, "file_data data-URL excluded"); + assert.equal(estimateRawBodyTokens(null), 0, "null body"); + assert.equal(estimateRawBodyTokens(42), 0, "non-object body"); + // CJK must not be undercounted by the chars/4 fast path. + assert.ok(estimateRawBodyTokens({ content: "汉".repeat(100) }) >= 100, "CJK counted per-char"); +}); + +test("inspectContextOverflow: exceed_context_size_error pattern + (A / B > W) window parse (#554)", () => { + const llama = JSON.stringify({ error: { message: "exceed_context_size_error (198,277 / 198,661 > 150,528)" } }); + const hit = inspectContextOverflow(400, llama); + assert.equal(hit.isOverflow, true, "llama.cpp-family marker recognized"); + assert.equal(hit.window, 150_528, "window is the limit after '>' inside the parens, not A or B"); + assert.equal(inspectContextOverflow(200, llama).isOverflow, false, "status gate: 200 is not an overflow"); + assert.equal(inspectContextOverflow(418, llama).isOverflow, false, "status gate: only 400/413 count"); + // Existing markers still work (regression). + assert.equal(inspectContextOverflow(400, JSON.stringify({ error: { message: "context_length_exceeded" } })).isOverflow, true); + const openai = inspectContextOverflow(400, JSON.stringify({ error: { message: "maximum context length is 131072 tokens" } })); + assert.equal(openai.isOverflow, true); + assert.equal(openai.window, 131_072); +}); + +test("sideRequestGuard: raw-body fit against resolved ∩ learned window minus output headroom (#554)", () => { + const txt = "z".repeat(8000); + const body = { model: MODEL, max_tokens: 100, stream: true, messages: [{ role: "user", content: txt }] }; + const est = estimateRawBodyTokens(body); + assert.ok(est > 0); + assert.equal(sideRequestGuard(body, "anthropic", 0, undefined).blocked, false, "unknown window → forward as before"); + assert.equal(sideRequestGuard(body, "anthropic", est + 1, undefined).blocked, false, "fits"); + assert.equal(sideRequestGuard(body, "anthropic", Math.floor(est / 1.15), undefined).blocked, true, "boundary: estimate == limit x 1.15 blocks"); + assert.equal(sideRequestGuard(body, "anthropic", Math.floor(est / 1.10), undefined).blocked, false, "within the 15% estimator tolerance → forward"); + assert.equal(sideRequestGuard(body, "anthropic", 1_000_000, Math.floor(est / 1.15)).blocked, true, "learned smaller (beyond tolerance) → blocks"); + assert.equal(sideRequestGuard(body, "anthropic", est + 1, 1_000_000).blocked, false, "learned larger than resolved is ignored"); + // OpenAI wire: the output budget counts against the window → headroom reserved. + const oa = { model: MODEL, max_completion_tokens: 2_000, stream: true, messages: [{ role: "user", content: txt }] }; + const oaEst = estimateRawBodyTokens(oa); + const oaLimit = Math.floor(oaEst / 1.15); + const g = sideRequestGuard(oa, "openai", oaLimit + 2_000, undefined); + assert.equal(g.limit, oaLimit, "limit reduced by max_completion_tokens"); + assert.equal(g.blocked, true, "boundary after reservation (with tolerance) blocks"); + assert.equal(sideRequestGuard(oa, "openai", oaEst + 2_001, undefined).blocked, false); + // Image tokens count toward the estimate. + const imgBody = { model: MODEL, max_tokens: 100, messages: [{ role: "user", content: [ + { type: "text", text: "z".repeat(4000) }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "A".repeat(8000) } }, + ] }] }; + const imgEst = estimateRawBodyTokens(imgBody) + Math.ceil(8000 / 4); + assert.equal(sideRequestGuard(imgBody, "anthropic", Math.floor(imgEst / 1.15), undefined).blocked, true, "image cost included at boundary"); + // CJK estimator bias: defaultCountTokens counts CJK per-char (~1.6x real), + // so a CJK-heavy payload estimated at ~110% of the window must forward — + // the upstream's real overflow 400 teaches the learned limit. + const cjkBody = { model: MODEL, max_tokens: 100, stream: true, messages: [{ role: "user", content: "汉".repeat(4000) }] }; + const cjkEst = estimateRawBodyTokens(cjkBody); + assert.ok(cjkEst >= 4000, "CJK counted per-char"); + assert.equal(sideRequestGuard(cjkBody, "anthropic", Math.floor(cjkEst / 1.10), undefined).blocked, false, "CJK over-estimation absorbed by tolerance"); + assert.equal(sideRequestGuard(cjkBody, "anthropic", Math.floor(cjkEst / 1.20), undefined).blocked, true, "genuinely oversized CJK still blocks"); +}); + function okSse(inputTokens: number): string { return ( `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "m1", role: "assistant", usage: { input_tokens: inputTokens } } })}\n\n` + @@ -115,10 +197,21 @@ interface Rig { sideScript: string | null; /** Last request body received by the upstream (for wire assertions). */ lastBody: Record | null; + /** Total requests received by the upstream (hit-count assertions). */ + upstreamHits: number; + /** When set, side requests get this status + JSON body instead of okSse. */ + sideErrorStatus: number | null; + sideErrorBody: string | null; } -async function startRig(): Promise { - const rig: Rig = { proxyPort: 0, upstreamPort: 0, proxy: null as unknown as http.Server, upstream: null as unknown as http.Server, sideScript: null, lastBody: null }; +// modelContextLimit alone is NOT enough to shrink the effective window: per- +// request resolution re-resolves it from the registry/static table (claude- +// sonnet-4-5 → 200k) unless the operator explicitly tunes +// compress.modelContextLimit, which outranks everything (#344). The rig exposes +// both so tests can pin the exact window the guard sees. +async function startRig(opts?: { modelContextLimit?: number; compressModelContextLimit?: number }): Promise { + const modelContextLimit = opts?.modelContextLimit ?? 200_000; + const rig: Rig = { proxyPort: 0, upstreamPort: 0, proxy: null as unknown as http.Server, upstream: null as unknown as http.Server, sideScript: null, lastBody: null, upstreamHits: 0, sideErrorStatus: null, sideErrorBody: null }; const upstream = http.createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", (c: Buffer) => chunks.push(c)); @@ -127,10 +220,16 @@ async function startRig(): Promise { let parsed: { max_tokens?: number } = {}; try { parsed = JSON.parse(raw); } catch { /* keep {} */ } rig.lastBody = parsed as Record; + rig.upstreamHits++; // Side requests (tiny max_tokens) report a TINY context; main requests // report a large one. The proxy must NOT capture the side request's // usage — that is exactly the pollution this regression guards. const isSide = typeof parsed.max_tokens === "number" && parsed.max_tokens <= 200; + if (isSide && rig.sideErrorStatus !== null) { + res.writeHead(rig.sideErrorStatus, { "content-type": "application/json" }); + res.end(rig.sideErrorBody ?? "{}"); + return; + } res.writeHead(200, { "content-type": "text/event-stream" }); res.end(isSide && rig.sideScript ? rig.sideScript : okSse(isSide ? SIDE_INPUT_TOKENS : MAIN_INPUT_TOKENS)); }); @@ -141,14 +240,15 @@ async function startRig(): Promise { _setStoreForTest(new SessionStore({ enabled: false })); setRegistryForTest({}); + _resetSessionsForTest(); const proxy = await startServer({ port: 0, host: "127.0.0.1", upstream: "http://127.0.0.1", routes: { [`http://127.0.0.1:${upstreamPort}`]: {} }, - modelContextLimit: 200_000, - kernelConfig: defaultConfig(200_000), - compress: { injectTool: true, injectNudge: true }, + modelContextLimit, + kernelConfig: defaultConfig(modelContextLimit), + compress: { injectTool: true, injectNudge: true, ...(opts?.compressModelContextLimit !== undefined ? { modelContextLimit: opts.compressModelContextLimit } : {}) }, promptCache: { routing: "auto" }, sessionHeader: "x-acp-session", log: false, @@ -295,3 +395,69 @@ test("e2e: starved tool-carrying main request re-enters pipeline at restored bud await closeRig(rig); } }); + +test("e2e: oversized side request is blocked locally (413), never reaches the upstream (#554)", async () => { + const rig = await startRig({ modelContextLimit: 4_000, compressModelContextLimit: 4_000 }); + try { + const url = `http://127.0.0.1:${rig.proxyPort}/bili/http://127.0.0.1:${rig.upstreamPort}/v1/messages`; + const headers: Record = { "content-type": "application/json", "x-acp-session": SESSION }; + + // ~40 × ~130 tokens ≈ 5k+ > 4_000 window → guaranteed upstream 400 if forwarded. + const r = await fetch(url, { method: "POST", headers, body: JSON.stringify({ model: MODEL, max_tokens: 100, stream: true, messages: mainConversation(40) }) }); + assert.equal(r.status, 413); + const j = (await r.json()) as { error: { type: string; code: string; retryable: boolean; message: string } }; + assert.equal(j.error.type, "server_error"); + assert.equal(j.error.code, "side_request_payload_too_large"); + assert.equal(j.error.retryable, false); + assert.match(j.error.message, /NOT forwarded/); + assert.equal(rig.upstreamHits, 0, "oversized side request must NOT reach the upstream"); + + // A fitting side request on the same rig still passes through verbatim. + const r2 = await fetch(url, { method: "POST", headers, body: JSON.stringify({ model: MODEL, max_tokens: 100, stream: true, messages: [{ role: "user", content: "Generate a short title." }] }) }); + assert.equal(r2.status, 200); + await r2.text(); + assert.equal(rig.upstreamHits, 1, "fitting side request reaches the upstream"); + assert.equal(rig.lastBody?.max_tokens, 100, "body untouched (verbatim passthrough preserved)"); + + const s = getSession(SESSION); + assert.ok(s, "session row exists (created before the gate)"); + assert.equal(JSON.stringify(s.state), JSON.stringify(createInitialState()), "kernel state untouched by side requests (blocked or not)"); + assert.equal(s.stats.requests, 0, "side requests do not count as main requests"); + } finally { + await closeRig(rig); + } +}); + +test("e2e: overflow 400 on a side request learns the real window; next one is blocked locally (#554)", async () => { + const rig = await startRig(); // 200_000 configured window + try { + const url = `http://127.0.0.1:${rig.proxyPort}/bili/http://127.0.0.1:${rig.upstreamPort}/v1/messages`; + const headers: Record = { "content-type": "application/json", "x-acp-session": SESSION }; + + // ~1200 × ~130 ≈ 155k tokens: below the 200k configured window (so the + // first attempt forwards) but above the real 120,000 window the upstream + // reports in its overflow marker (ratio ~1.29 > the 15% guard tolerance, + // so the learned window still blocks the second attempt locally). + const big = mainConversation(1200); + rig.sideErrorStatus = 400; + rig.sideErrorBody = JSON.stringify({ error: { message: "exceed_context_size_error (198,277 / 198,661 > 120,000)" } }); + + const r1 = await fetch(url, { method: "POST", headers, body: JSON.stringify({ model: MODEL, max_tokens: 100, stream: true, messages: big }) }); + assert.equal(r1.status, 400, "first overflow surfaces to the client"); + await r1.text(); + const s1 = getSession(SESSION); + assert.ok(s1); + assert.equal((s1.metadata.learnedContextLimits as Record)[MODEL], 120_000, "real window learned from the overflow marker"); + assert.equal(s1.stats.lastInputTokens, 120_000, "emergency shrink armed at the learned window"); + + // Identical second request: now blocked locally — no second upstream hit. + const r2 = await fetch(url, { method: "POST", headers, body: JSON.stringify({ model: MODEL, max_tokens: 100, stream: true, messages: big }) }); + assert.equal(r2.status, 413); + const j = (await r2.json()) as { error: { code: string } }; + assert.equal(j.error.code, "side_request_payload_too_large"); + assert.equal(rig.upstreamHits, 1, "second oversized side request must NOT reach the upstream again"); + assert.equal(JSON.stringify(getSession(SESSION).state), JSON.stringify(createInitialState()), "kernel state untouched throughout"); + } finally { + await closeRig(rig); + } +});