feat(supplier,gateway,buyer): N-slot chat suppliers + entry-ticket settle mode

Two structural fixes for demo concurrency (3 single-slot suppliers thrashed
under Open WebUI's ~3 conversations per message):

Multi-slot suppliers (MAX_CHAT_SESSIONS, default 1):
- SupplierState is now capacity-aware: a Set of active escrow refs with
  free-until-full status semantics, ref-scoped release(ref) (also fixes the
  latent bug where handler catch-alls released another request's slot), and
  a walletMutex serializing every wallet-spending chain op — Claim, Submit,
  and wallet-health consolidation queue instead of racing the single
  working/collateral UTxO. /status gains active_sessions/max_sessions.

Entry-ticket settle mode (CHAT_SETTLE_MODE=ticket, default full, both sides):
- The buyer's escrow post at session open is the ONLY chain op. The supplier
  verifies the Open escrow as an entry ticket but never Claims/Submits
  (/v1/chat/start → {status:"ticket"}; /v1/chat/end → receipt-less
  {status:"ended"}); no hard-cap timer, so sessions may outlive deliver_by.
  Used tickets can't reopen sessions (records retained; 409 session_exists).
- Buyer SDK: startChat/endChat are settle-mode aware, driven by the
  supplier's response — mixed-mode rollout windows settle correctly.
- Gateway: ticket closes bill cost 0; the sweeper Reclaims Open tickets
  after deliver_by WITHOUT closing live sessions (the conversation and its
  affinity mirror survive their reclaimed ticket), gates reclaims on
  deliver_by, and keeps an in-process settled-refs memory so terminal
  indexer rows aren't retried every sweep forever.

Deployment (kimi-chat trio + gateway): ticket mode, 8 slots per supplier,
idle policy gateway 10 min / supplier 15 min (fixes the inversion where the
supplier idle-closed before the gateway janitor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
david
2026-07-09 20:48:52 +02:00
parent 9a4f9f0699
commit 2f7b0194a5
34 changed files with 1451 additions and 235 deletions

View File

@@ -0,0 +1,118 @@
/**
* buyer-sdk-chat-session.test.ts — settle-mode handling in startChat/endChat.
*
* Ticket-mode suppliers answer /v1/chat/start with {status:"ticket"} and
* /v1/chat/end with {status:"ended", settle_mode:"ticket"}. The SDK must map
* those to settleMode:"ticket" — and, at end, skip receipt verification AND
* the Accept tx entirely (zero chain calls). Full-shaped responses keep the
* existing behavior.
*/
import { describe, it, expect, vi } from "vitest";
import { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { encodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
import type { AdvertDatum } from "../../packages/shared/src/cbor/types.js";
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
const ADVERT_TX_HASH = "b".repeat(64);
const ESCROW_REF = { txHash: "f".repeat(64), index: 0 };
const ESCROW_REF_STR = `${ESCROW_REF.txHash}#0`;
function advertDatum(): AdvertDatum {
return {
supplier_pkh: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef01",
capability_id: "llm.chat.v1",
model: "kimi",
max_output_tokens: 512,
max_processing_ms: 1_800_000,
price_lovelace: 200_000n,
supplier_bond_lovelace: 1_000_000n,
buyer_bond_lovelace: 1_000_000n,
endpoint_url: "http://supplier.test",
detail_uri: "ipfs://QmTest",
detail_hash: "a".repeat(64),
advertised_at: 1_745_500_000_000,
status: "Active",
};
}
/** fetch stub answering the supplier's /status, /v1/chat/start, /v1/chat/end. */
function supplierFetch(responses: { start?: unknown; end?: unknown }) {
return vi.fn(async (url: RequestInfo | URL) => {
const u = String(url);
if (u.includes("/status")) {
return new Response(JSON.stringify({ status: "free" }), { status: 200 });
}
if (u.includes("/v1/chat/start")) {
return new Response(JSON.stringify(responses.start ?? { status: "claimed" }), { status: 200 });
}
if (u.includes("/v1/chat/end")) {
return new Response(JSON.stringify(responses.end ?? {}), { status: 200 });
}
if (u.includes("/capability")) {
return new Response(JSON.stringify({ supplier_pkh: "", model: "kimi" }), { status: 200 });
}
return new Response("{}", { status: 200 });
}) as unknown as typeof globalThis.fetch;
}
function makeMp(fetchImpl: typeof globalThis.fetch, chain = new MockChainProvider()) {
chain.seed({
ref: { txHash: ADVERT_TX_HASH, index: 0 },
address: "addr_test1wfakeadvert",
lovelace: 2_000_000n,
assets: {},
datumHex: encodeAdvertDatum(advertDatum()),
scriptRef: null,
});
return { mp: new Marketplace({
chain,
indexerUrl: "http://indexer.test",
walletKey: buildBuyerWalletKey(),
networkParams: { networkId: 0 },
_fetch: fetchImpl,
}), chain };
}
describe("Marketplace.startChat — settle-mode mapping", () => {
it("maps {status:'ticket'} to settleMode ticket", async () => {
const { mp } = makeMp(supplierFetch({ start: { status: "ticket", settle_mode: "ticket" } }));
const result = await mp.startChat({ advertRef: { txHash: ADVERT_TX_HASH, index: 0 }, payment_lovelace: 200_000n });
expect(result.settleMode).toBe("ticket");
});
it("maps {status:'claimed'} (and legacy responses) to settleMode full", async () => {
const { mp } = makeMp(supplierFetch({ start: { status: "claimed" } }));
const result = await mp.startChat({ advertRef: { txHash: ADVERT_TX_HASH, index: 0 }, payment_lovelace: 200_000n });
expect(result.settleMode).toBe("full");
});
});
describe("Marketplace.endChat — ticket branch", () => {
it("returns the ticket result with zero chain calls and no receipt verification", async () => {
const chain = new MockChainProvider();
const submitSpy = vi.spyOn(chain, "submitTx");
const { mp } = makeMp(
supplierFetch({ end: { status: "ended", escrow_ref: ESCROW_REF_STR, settle_mode: "ticket" } }),
chain,
);
const result = await mp.endChat({
escrowRef: ESCROW_REF,
sessionNonce: "nonce-abc",
supplierBaseUrl: "http://supplier.test",
});
expect(result.settleMode).toBe("ticket");
expect("receipt" in result).toBe(false);
expect(submitSpy).not.toHaveBeenCalled(); // no Accept tx
});
it("still rejects a receipt-less response that does not declare ticket mode", async () => {
const { mp } = makeMp(supplierFetch({ end: { status: "submitted" } }));
await expect(
mp.endChat({ escrowRef: ESCROW_REF, sessionNonce: "nonce-abc", supplierBaseUrl: "http://supplier.test" }),
).rejects.toThrow(/receipt/);
});
});

View File

@@ -0,0 +1,277 @@
/**
* supplier-chat-session-ticket.test.ts — entry-ticket settle mode + N slots.
*
* Ticket mode (CHAT_SETTLE_MODE=ticket): /v1/chat/start verifies the Open
* escrow but never Claims (zero chain txs), arms no hard-cap timer, and
* /v1/chat/end returns a receipt-less {status:"ended"}. A used escrow ref can
* never open a second session (records are retained). MAX_CHAT_SESSIONS
* admits N concurrent sessions and the N+1th start gets 409 supplier_busy.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import request from "supertest";
import { createHash } from "crypto";
import type { Application } from "express";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { canonicalize } from "../../packages/shared/src/cbor/canonical.js";
import { encodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
import { encodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js";
import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/types.js";
import { chatSessionPromptHash } from "../../packages/shared/src/tx/escrow/postChatEscrow.js";
import type { SupplierConfig } from "../../supplier/src/config.js";
import { SupplierState } from "../../supplier/src/state.js";
import { JobStore } from "../../supplier/src/jobs.js";
import { ChatSessionStore } from "../../supplier/src/chatSession.js";
import { createApp } from "../../supplier/src/server.js";
import { buildSampleConfig, SAMPLE_ADVERT_TX_HASH, SAMPLE_ADVERT_INDEX } from "../fixtures/supplier-side/sample-config.js";
import { buildSupplierWalletKey, SUPPLIER_PKH } from "../fixtures/supplier-side/wallet-keys.js";
const MODEL = "kimi";
const MAX_TOKENS = 512;
const CAPABILITY = "llm.chat.v1";
const NONCE = "nonce-abc";
const POSTED_AT = 1_745_500_000_000;
const DELIVER_BY = POSTED_AT + 90_000;
function sha256Hex(s: string): string {
return createHash("sha256").update(s, "utf8").digest("hex");
}
function advertDatum(): AdvertDatum {
return {
supplier_pkh: SUPPLIER_PKH,
capability_id: CAPABILITY,
model: MODEL,
max_output_tokens: MAX_TOKENS,
max_processing_ms: 1_800_000,
price_lovelace: 200_000n,
supplier_bond_lovelace: 1_000_000n,
buyer_bond_lovelace: 1_000_000n,
endpoint_url: "http://supplier.example:8080",
detail_uri: "ipfs://QmTest",
detail_hash: "a".repeat(64),
advertised_at: POSTED_AT,
status: "Active",
};
}
function escrowDatum(nonce: string): EscrowDatum {
return {
buyer_pkh: "1234567890abcdef1234567890abcdef1234567890abcdef12345678",
supplier_pkh: SUPPLIER_PKH,
advert_ref: { txHash: SAMPLE_ADVERT_TX_HASH, index: SAMPLE_ADVERT_INDEX },
capability_id: CAPABILITY,
request_spec_hash: sha256Hex(canonicalize({ capability_id: CAPABILITY, max_output_tokens: MAX_TOKENS, model: MODEL })),
prompt_hash: chatSessionPromptHash({ session_nonce: nonce }),
payment_lovelace: 200_000n,
buyer_bond_lovelace: 1_000_000n,
supplier_bond_lovelace: 1_000_000n,
deliver_by: DELIVER_BY,
posted_at: POSTED_AT,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
};
}
interface Harness {
app: Application;
chain: MockChainProvider;
state: SupplierState;
chatSessions: ChatSessionStore;
seedEscrow: (txHashChar: string, nonce?: string) => string;
}
function makeHarness(overrides?: Partial<SupplierConfig>): Harness {
const chain = new MockChainProvider();
chain.seed({
ref: { txHash: SAMPLE_ADVERT_TX_HASH, index: SAMPLE_ADVERT_INDEX },
address: "addr_test1wfakeadvertaddress",
lovelace: 2_000_000n,
assets: {},
datumHex: encodeAdvertDatum(advertDatum()),
scriptRef: null,
});
const config: SupplierConfig = {
...buildSampleConfig(),
capabilityKind: "chat-session",
llmBackend: "openai",
ollamaUrl: "",
openaiBaseUrl: "http://up",
openaiApiKey: "",
openaiTimeoutMs: 5_000,
openaiMaxTokens: 0,
openaiReasoningDisabled: false,
chatIdleTimeoutMs: 60_000,
chatSettleMode: "ticket",
maxChatSessions: 1,
...overrides,
} as SupplierConfig;
const state = new SupplierState(config.maxChatSessions);
const chatSessions = new ChatSessionStore();
const app = createApp({
chain,
state,
config,
supplierKey: buildSupplierWalletKey(),
jobs: new JobStore(),
chatSessions,
});
const seedEscrow = (txHashChar: string, nonce = NONCE): string => {
const txHash = txHashChar.repeat(64);
chain.seed({
ref: { txHash, index: 0 },
address: "addr_test1wfakeescrowaddress",
lovelace: 2_200_000n,
assets: {},
datumHex: encodeEscrowDatum(escrowDatum(nonce)),
scriptRef: null,
});
return `${txHash}#0`;
};
return { app, chain, state, chatSessions, seedEscrow };
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("chat-session ticket mode — /v1/chat/start", () => {
it("verifies the escrow, skips Claim, and arms no hard-cap timer", async () => {
const h = makeHarness();
const submitSpy = vi.spyOn(h.chain, "submitTx");
const ref = h.seedEscrow("e");
const res = await request(h.app)
.post("/v1/chat/start")
.set("X-Escrow-Ref", ref)
.send({ session_nonce: NONCE });
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "ticket", escrow_ref: ref, settle_mode: "ticket" });
expect(submitSpy).not.toHaveBeenCalled(); // zero chain txs
const record = h.chatSessions.get(ref)!;
expect(record.settleMode).toBe("ticket");
expect(record.claimedRef).toBeUndefined();
expect(record.idleTimer).toBeDefined();
expect(record.hardCapTimer).toBeUndefined();
expect(h.state.snapshot().activeSessions).toBe(1);
});
it("still rejects a foreign-supplier escrow", async () => {
const h = makeHarness();
const txHash = "c".repeat(64);
h.chain.seed({
ref: { txHash, index: 0 },
address: "addr_test1wfakeescrowaddress",
lovelace: 2_200_000n,
assets: {},
datumHex: encodeEscrowDatum({ ...escrowDatum(NONCE), supplier_pkh: "9".repeat(56) }),
scriptRef: null,
});
const res = await request(h.app)
.post("/v1/chat/start")
.set("X-Escrow-Ref", `${txHash}#0`)
.send({ session_nonce: NONCE });
expect(res.status).toBe(403);
expect(res.body.reason).toBe("wrong_supplier");
});
it("rejects reusing an escrow ref while the session is active AND after it ended", async () => {
const h = makeHarness();
const ref = h.seedEscrow("e");
const first = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", ref).send({ session_nonce: NONCE });
expect(first.status).toBe(200);
const whileActive = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", ref).send({ session_nonce: NONCE });
expect(whileActive.status).toBe(409);
expect(whileActive.body.reason).toBe("session_exists");
await request(h.app).post("/v1/chat/end").set("X-Escrow-Ref", ref).send({});
// The escrow is still Open on-chain (never Claimed) — the retained record
// is the only thing standing between a used ticket and a free session.
const afterEnd = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", ref).send({ session_nonce: NONCE });
expect(afterEnd.status).toBe(409);
expect(afterEnd.body.reason).toBe("session_exists");
});
});
describe("chat-session ticket mode — /v1/chat/end", () => {
it("ends without a receipt, frees the slot, and is idempotent", async () => {
const h = makeHarness();
const submitSpy = vi.spyOn(h.chain, "submitTx");
const ref = h.seedEscrow("e");
await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", ref).send({ session_nonce: NONCE });
const end = await request(h.app).post("/v1/chat/end").set("X-Escrow-Ref", ref).send({});
expect(end.status).toBe(200);
expect(end.body).toEqual({ status: "ended", escrow_ref: ref, settle_mode: "ticket" });
expect(end.body.receipt).toBeUndefined();
expect(submitSpy).not.toHaveBeenCalled();
expect(h.state.snapshot().activeSessions).toBe(0);
const again = await request(h.app).post("/v1/chat/end").set("X-Escrow-Ref", ref).send({});
expect(again.status).toBe(200);
expect(again.body.status).toBe("ended");
});
});
describe("chat-session slots — MAX_CHAT_SESSIONS", () => {
it("admits N concurrent sessions and 409s the N+1th", async () => {
const h = makeHarness({ maxChatSessions: 2 });
// NB: "b".repeat(64) is the advert tx hash — do not reuse it for escrows.
const refA = h.seedEscrow("d");
const refB = h.seedEscrow("e");
const refC = h.seedEscrow("f");
const a = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refA).send({ session_nonce: NONCE });
const b = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refB).send({ session_nonce: NONCE });
expect(a.status).toBe(200);
expect(b.status).toBe(200);
const status = await request(h.app).get("/status");
expect(status.body.status).toBe("working");
expect(status.body.active_sessions).toBe(2);
expect(status.body.max_sessions).toBe(2);
const c = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refC).send({ session_nonce: NONCE });
expect(c.status).toBe(409);
expect(c.body.reason).toBe("supplier_busy");
// Ending one session readmits the third.
await request(h.app).post("/v1/chat/end").set("X-Escrow-Ref", refA).send({});
const retry = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refC).send({ session_nonce: NONCE });
expect(retry.status).toBe(200);
});
it("serves concurrent message turns on distinct sessions", async () => {
const h = makeHarness({ maxChatSessions: 2 });
const refA = h.seedEscrow("d");
const refB = h.seedEscrow("e");
await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refA).send({ session_nonce: NONCE });
await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refB).send({ session_nonce: NONCE });
const stream = [
`data: ${JSON.stringify({ choices: [{ delta: { content: "Hello" }, finish_reason: null }] })}\n\n`,
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 3, completion_tokens: 2 } })}\n\n`,
"data: [DONE]\n\n",
].join("");
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async () =>
new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } })),
);
const [ta, tb] = await Promise.all([
request(h.app).post("/v1/chat/message").set("X-Escrow-Ref", refA).send({ messages: [{ role: "user", content: "hi" }] }),
request(h.app).post("/v1/chat/message").set("X-Escrow-Ref", refB).send({ messages: [{ role: "user", content: "yo" }] }),
]);
expect(ta.text).toContain('"type":"done"');
expect(tb.text).toContain('"type":"done"');
expect(h.chatSessions.get(refA)!.transcript).toHaveLength(2);
expect(h.chatSessions.get(refB)!.transcript).toHaveLength(2);
vi.unstubAllGlobals();
});
});

View File

@@ -218,3 +218,39 @@ describe("parseAdvertRef()", () => {
expect(() => parseAdvertRef(`${"a".repeat(64)}#-1`)).toThrow();
});
});
// ─── loadConfig — chat-session concurrency + settle mode ─────────────────────
describe("loadConfig() — MAX_CHAT_SESSIONS / CHAT_SETTLE_MODE", () => {
const chatSessionEnv = () => ({
...buildSampleEnv(),
CAPABILITY_KIND: "chat-session",
LLM_BACKEND: "openai",
OPENAI_BASE_URL: "http://up",
});
it("defaults to 1 slot and full settle mode", () => {
const cfg = loadConfig(buildSampleEnv());
expect(cfg.maxChatSessions).toBe(1);
expect(cfg.chatSettleMode).toBe("full");
});
it("parses MAX_CHAT_SESSIONS and CHAT_SETTLE_MODE for chat-session kind", () => {
const cfg = loadConfig({ ...chatSessionEnv(), MAX_CHAT_SESSIONS: "8", CHAT_SETTLE_MODE: "ticket" });
expect(cfg.maxChatSessions).toBe(8);
expect(cfg.chatSettleMode).toBe("ticket");
});
it("rejects MAX_CHAT_SESSIONS > 1 for non-chat-session kinds", () => {
expect(() => loadConfig({ ...buildSampleEnv(), MAX_CHAT_SESSIONS: "4" })).toThrow();
});
it("rejects CHAT_SETTLE_MODE=ticket for non-chat-session kinds", () => {
expect(() => loadConfig({ ...buildSampleEnv(), CHAT_SETTLE_MODE: "ticket" })).toThrow();
});
it("rejects invalid values", () => {
expect(() => loadConfig({ ...chatSessionEnv(), MAX_CHAT_SESSIONS: "0" })).toThrow();
expect(() => loadConfig({ ...chatSessionEnv(), CHAT_SETTLE_MODE: "nope" })).toThrow();
});
});

View File

@@ -117,6 +117,25 @@ describe("GET /status — response body when offline", () => {
});
});
describe("GET /status — session slot fields", () => {
it("reports active_sessions/max_sessions when free", async () => {
const state = new SupplierState();
const res = await request(makeApp(state)).get("/status");
expect(res.body.active_sessions).toBe(0);
expect(res.body.max_sessions).toBe(1);
});
it("stays free with slots remaining and reports the active count", async () => {
const state = new SupplierState(3);
state.tryAcquire(ESCROW_REF);
const res = await request(makeApp(state)).get("/status");
expect(res.body.status).toBe("free");
expect(res.body.active_sessions).toBe(1);
expect(res.body.max_sessions).toBe(3);
expect(res.body.current_escrow_ref).toBeUndefined();
});
});
describe("GET /status — does not touch chain", () => {
it("returns 200 even when chain has no UTxOs seeded", async () => {
// If the handler incorrectly touches chain, it would throw because

View File

@@ -1,11 +1,13 @@
/**
* supplier-state.test.ts — RED phase tests for supplier/src/state.ts
* supplier-state.test.ts — tests for supplier/src/state.ts
*
* Tests the SupplierState in-process slot lock:
* - tryAcquire(escrowRef): boolean — atomic test-and-set
* - release()
* Tests the SupplierState session-slot admission (capacity-aware) + the
* wallet mutex:
* - tryAcquire(escrowRef): boolean — slot admission (default capacity 1)
* - release(escrowRef) — frees exactly that ref's slot
* - markOffline()
* - snapshot(): { status, currentEscrowRef?, lastSeenIso }
* - snapshot(): { status, currentEscrowRef?, activeSessions, maxSessions, ... }
* - walletMutex.run — strict serialization of wallet chain ops
*/
import { describe, it, expect, beforeEach } from "vitest";
@@ -13,6 +15,7 @@ import { SupplierState } from "../../supplier/src/state.js";
const ESCROW_REF_A = `${"f".repeat(64)}#0`;
const ESCROW_REF_B = `${"e".repeat(64)}#1`;
const ESCROW_REF_C = `${"d".repeat(64)}#2`;
describe("SupplierState — initial state", () => {
it("starts in free state", () => {
@@ -102,25 +105,25 @@ describe("SupplierState.release()", () => {
it("transitions working → free", () => {
state.tryAcquire(ESCROW_REF_A);
state.release();
state.release(ESCROW_REF_A);
expect(state.snapshot().status).toBe("free");
});
it("clears currentEscrowRef on release", () => {
state.tryAcquire(ESCROW_REF_A);
state.release();
state.release(ESCROW_REF_A);
expect(state.snapshot().currentEscrowRef).toBeUndefined();
});
it("allows re-acquire after release", () => {
state.tryAcquire(ESCROW_REF_A);
state.release();
state.release(ESCROW_REF_A);
expect(state.tryAcquire(ESCROW_REF_B)).toBe(true);
});
it("re-acquire after release sets new currentEscrowRef", () => {
state.tryAcquire(ESCROW_REF_A);
state.release();
state.release(ESCROW_REF_A);
state.tryAcquire(ESCROW_REF_B);
expect(state.snapshot().currentEscrowRef).toBe(ESCROW_REF_B);
});
@@ -128,7 +131,7 @@ describe("SupplierState.release()", () => {
it("updates lastSeenIso on release", () => {
state.tryAcquire(ESCROW_REF_A);
const beforeRelease = state.snapshot().lastSeenIso;
state.release();
state.release(ESCROW_REF_A);
const afterRelease = state.snapshot().lastSeenIso;
expect(new Date(afterRelease).getTime()).not.toBeNaN();
expect(afterRelease >= beforeRelease).toBe(true);
@@ -199,4 +202,94 @@ describe("SupplierState.snapshot()", () => {
// State must remain working
expect(state.snapshot().status).toBe("working");
});
it("reports active/max session counts", () => {
const state = new SupplierState();
expect(state.snapshot().activeSessions).toBe(0);
expect(state.snapshot().maxSessions).toBe(1);
state.tryAcquire(ESCROW_REF_A);
expect(state.snapshot().activeSessions).toBe(1);
expect(state.snapshot().activeEscrowRefs).toEqual([ESCROW_REF_A]);
});
});
describe("SupplierState — capacity > 1", () => {
let state: SupplierState;
beforeEach(() => {
state = new SupplierState(2);
});
it("stays free until every slot is taken (free-until-full)", () => {
expect(state.tryAcquire(ESCROW_REF_A)).toBe(true);
expect(state.snapshot().status).toBe("free");
expect(state.snapshot().activeSessions).toBe(1);
expect("currentEscrowRef" in state.snapshot()).toBe(false);
expect(state.tryAcquire(ESCROW_REF_B)).toBe(true);
expect(state.snapshot().status).toBe("working");
expect(state.snapshot().currentEscrowRef).toBe(ESCROW_REF_A); // oldest
});
it("rejects the N+1th acquire", () => {
state.tryAcquire(ESCROW_REF_A);
state.tryAcquire(ESCROW_REF_B);
expect(state.tryAcquire(ESCROW_REF_C)).toBe(false);
});
it("rejects a duplicate ref while held", () => {
state.tryAcquire(ESCROW_REF_A);
expect(state.tryAcquire(ESCROW_REF_A)).toBe(false);
});
it("release frees exactly that ref's slot", () => {
state.tryAcquire(ESCROW_REF_A);
state.tryAcquire(ESCROW_REF_B);
state.release(ESCROW_REF_A);
expect(state.snapshot().status).toBe("free");
expect(state.snapshot().activeEscrowRefs).toEqual([ESCROW_REF_B]);
expect(state.tryAcquire(ESCROW_REF_C)).toBe(true);
});
it("release of an unheld ref is a no-op", () => {
state.tryAcquire(ESCROW_REF_A);
state.release(ESCROW_REF_B);
expect(state.snapshot().activeSessions).toBe(1);
});
it("markOffline clears all slots and refuses new acquires", () => {
state.tryAcquire(ESCROW_REF_A);
state.tryAcquire(ESCROW_REF_B);
state.markOffline();
expect(state.snapshot().status).toBe("offline");
expect(state.snapshot().activeSessions).toBe(0);
expect(state.tryAcquire(ESCROW_REF_C)).toBe(false);
});
it("rejects a non-positive capacity", () => {
expect(() => new SupplierState(0)).toThrow();
});
});
describe("SupplierState.walletMutex", () => {
it("serializes runs in submission order and survives rejection", async () => {
const state = new SupplierState(4);
const order: number[] = [];
const p1 = state.walletMutex.run(async () => {
await new Promise((r) => setTimeout(r, 20));
order.push(1);
});
const p2 = state.walletMutex.run(async () => {
order.push(2);
});
await Promise.all([p1, p2]);
expect(order).toEqual([1, 2]);
await state.walletMutex
.run(async () => {
throw new Error("boom");
})
.catch(() => undefined);
expect(await state.walletMutex.run(async () => 42)).toBe(42);
});
});

View File

@@ -4,11 +4,11 @@
*
* Coverage:
* - Ticker calls consolidate when idle, logs already-healthy
* - Ticker skips when the supplier state is busy
* - Ticker releases the lock around runConsolidateWallet (success + failure)
* - Consolidation runs even while session slots are held (wallet ops don't
* consume session slots) but queues behind a held walletMutex
* - Failure paths never leave the walletMutex poisoned
* - On-failure trigger fires consolidate the first time
* - On-failure trigger debounces back-to-back invocations
* - On-failure trigger respects the state lock
*
* The consolidate function is injected via deps so the test never touches
* a real Ogmios.
@@ -91,7 +91,7 @@ describe("walletHealth — ticker", () => {
ticker.stop();
});
it("skips when supplier state is busy", async () => {
it("runs even while a session slot is held (wallet ops don't consume slots)", async () => {
state.tryAcquire(ESCROW_REF);
consolidate.mockResolvedValue(healthyResult);
@@ -101,16 +101,39 @@ describe("walletHealth — ticker", () => {
);
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(0);
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
expect(consolidate).not.toHaveBeenCalled();
expect(logLines).toContain("skip: supplier busy");
// The held session slot is untouched.
expect(state.snapshot().status).toBe("working");
expect(state.snapshot().activeEscrowRefs).toEqual([ESCROW_REF]);
ticker.stop();
});
it("releases the lock after a successful consolidate", async () => {
it("queues behind a held walletMutex instead of racing it", async () => {
consolidate.mockResolvedValue(healthyResult);
let releaseChainOp: () => void = () => undefined;
const chainOp = state.walletMutex.run(
() => new Promise<void>((resolve) => { releaseChainOp = resolve; }),
);
const ticker = startWalletHealthTicker(
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
{ intervalMs: 1_000, log },
);
await vi.advanceTimersByTimeAsync(1_000);
// The mutex is held by the fake chain op — consolidate must wait.
expect(consolidate).not.toHaveBeenCalled();
releaseChainOp();
await chainOp;
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
ticker.stop();
});
it("logs success after a real consolidate", async () => {
consolidate.mockResolvedValue(consolidatedResult);
const ticker = startWalletHealthTicker(
@@ -127,7 +150,7 @@ describe("walletHealth — ticker", () => {
ticker.stop();
});
it("releases the lock when consolidate throws", async () => {
it("does not poison the walletMutex when consolidate throws", async () => {
consolidate.mockRejectedValue(new Error("ogmios unreachable"));
const ticker = startWalletHealthTicker(
@@ -138,8 +161,9 @@ describe("walletHealth — ticker", () => {
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
expect(state.snapshot().status).toBe("free");
expect(logLines.some((l) => l.includes("consolidate failed"))).toBe(true);
// The mutex still serves the next wallet op.
expect(await state.walletMutex.run(async () => "ok")).toBe("ok");
ticker.stop();
});
@@ -202,7 +226,7 @@ describe("walletHealth — triggerOnFailureConsolidate", () => {
expect(consolidate).toHaveBeenCalledTimes(1);
});
it("does not throw when supplier is busy — just skips", async () => {
it("runs even when session slots are held (queues on the wallet mutex)", async () => {
state.tryAcquire(ESCROW_REF);
consolidate.mockResolvedValue(healthyResult);
@@ -213,9 +237,8 @@ describe("walletHealth — triggerOnFailureConsolidate", () => {
),
).not.toThrow();
await Promise.resolve();
expect(consolidate).not.toHaveBeenCalled();
expect(logLines).toContain("skip: supplier busy");
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
// The held session slot is untouched.
expect(state.snapshot().status).toBe("working");
});
});