/// /** * What a refused `fetchProgram` takeover leaves behind. * * A cache entry says who is resolving a program, and a replica that finds one * older than the staleness bound takes it over, because nothing reports whether * the replica that made it is still there. The takeover's own claim rides the * transaction that stages the request, so a refused commit leaves the entry * exactly as the other replica wrote it — and that replica may still be * resolving. The ending reads the entry and steps around anything that is not * idle, so a resolution running elsewhere keeps its entry and reaches its * result. * * Two replicas on one memory server are what make that arrangement real. The * holder keeps a program request open; the taker is the one whose commit is * refused, by the caveat `builtin-abandoned-request.test.ts` uses. Only the * taker enforces, so only the taker's commit is refused, and both run the same * pattern over the same argument and result cells, which is what puts their two * nodes on one cache document. */ import { expect } from "@std/expect"; import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { Identity } from "@commonfabric/identity"; import * as MemoryV2Server from "@commonfabric/memory/v2/server"; import { waitForCellValue } from "@commonfabric/integration/wait-for-cell-value"; import { getPatternEnvironment, setPatternEnvironment } from "../src/env.ts"; import { Runtime } from "../src/runtime.ts"; import { MAX_ENFORCEMENT_CFC_OPTIONS } from "../src/runtime-presets.ts"; import { EmulatedStorageManager } from "../src/storage/v2-emulate.ts"; import { TEST_MEMORY_SERVER_AUTH } from "./memory-v2-test-utils.ts"; import { createTrustedBuilder } from "./support/trusted-builder.ts"; const signer = await Identity.fromPassphrase("test operator"); const space = signer.did(); const PROMPT_INFLUENCE = { type: "https://commonfabric.org/cfc/atom/Caveat", kind: "https://commonfabric.org/cfc/concepts/prompt-influence", source: "of:hostile", } as const; // Well past the bound `fetchProgram` treats a claim as stale after, so the // taker's run finds an entry it is entitled to take over. const PAST_THE_STALENESS_BOUND = 60_000; const PROGRAM_URL = "http://mock-test-server.local/held-program.ts"; interface Deferred { promise: Promise; resolve: (value: T) => void; } function deferred(): Deferred { let resolve!: (value: T) => void; const promise = new Promise((res) => resolve = res); return { promise, resolve }; } function moduleResponse(): Response { return new Response("export const value = 1;\n", { status: 200, headers: { "Content-Type": "text/plain" }, }); } describe("a refused fetchProgram takeover", () => { let server: MemoryV2Server.Server; let holderStorage: EmulatedStorageManager; let takerStorage: EmulatedStorageManager; let holder: Runtime; let taker: Runtime; let originalFetch: typeof globalThis.fetch; let originalPatternEnvironment: ReturnType; let released = false; let issued: Deferred; let held: Deferred; beforeEach(() => { originalPatternEnvironment = getPatternEnvironment(); setPatternEnvironment({ apiUrl: new URL("http://mock-test-server.local") }); originalFetch = globalThis.fetch; // Program resolution reaches for the global rather than the runtime's own // fetch. Only the holder's request is ever issued: the taker's commit is // refused before its own would go out. released = false; issued = deferred(); held = deferred(); globalThis.fetch = () => { issued.resolve(); // The holder's first request is the one that is held; once it is // released, the resolver's remaining requests are answered directly. return released ? Promise.resolve(moduleResponse()) : held.promise; }; server = new MemoryV2Server.Server(TEST_MEMORY_SERVER_AUTH); holderStorage = EmulatedStorageManager.connectTo(server, { as: signer }); takerStorage = EmulatedStorageManager.connectTo(server, { as: signer }); holder = new Runtime({ apiUrl: new URL(import.meta.url), storageManager: holderStorage, }); taker = new Runtime({ apiUrl: new URL(import.meta.url), storageManager: takerStorage, ...MAX_ENFORCEMENT_CFC_OPTIONS, cfcEnforcementMode: "enforce-strict", }); }); afterEach(async () => { globalThis.fetch = originalFetch; setPatternEnvironment(originalPatternEnvironment); released = true; held.resolve(moduleResponse()); await taker.dispose(); await holder.dispose(); await takerStorage.close(); await holderStorage.close(); }); /** Start the shared pattern on `runtime`, over the shared argument cell. */ async function runOn(runtime: Runtime) { const { pattern, fetchProgram } = createTrustedBuilder(runtime) .commonfabric; const testPattern = pattern<{ url: string }>(({ url }) => // deno-lint-ignore no-explicit-any fetchProgram({ url } as any) ); const tx = runtime.edit(); const inputs = runtime.getCell<{ url: string }>( space, "takeover-inputs", undefined, tx, ); const resultCell = runtime.getCell( space, "takeover-result", testPattern.resultSchema, tx, ); await inputs.sync(); const result = runtime.run(tx, testPattern, inputs, resultCell); runtime.prepareTxForCommit(tx); return { result, committed: tx.commit().settled }; } it("leaves the running replica's claim alone", async () => { // A plain url to begin with: the holder claims the entry under it, and the // taker's first run has to see that claim, which means its own cache // document has to be loaded before the run that stages a takeover. The // builtin loads that document from inside its first run, so the taker runs // once here against a claim that is not yet stale — it stages nothing, and // what it leaves behind is a warm cache view. const seedTx = holder.edit(); const seedInputs = holder.getCell<{ url: string }>( space, "takeover-inputs", undefined, seedTx, ); seedInputs.set({ url: PROGRAM_URL }); holder.prepareTxForCommit(seedTx); const seeded = await seedTx.commit().settled; if (seeded.error) throw seeded.error; const holderRun = await runOn(holder); await holderRun.committed; await holderRun.result.pull(); await issued.promise; const takerRun = await runOn(taker); await takerRun.committed; await takerRun.result.pull(); await clock.settle(); // The taker is reading the holder's claim rather than an empty cache, so // the run below decides against a claim that is really there. expect(takerRun.result.withTx().key("pending").get()).toBe(true); const refused = deferred(); taker.scheduler.onError((error: Error) => { if (error.message.includes("CFC enforcement rejected commit")) { refused.resolve(); } }); // Age the holder's claim past the bound before anything re-runs the // taker, so the run the slot change below triggers is entitled to take // the claim over and therefore stages a request at all. A run against a // claim still inside the bound stages nothing and leaves the node clean, // and aging the claim afterwards re-runs nothing. await clock.tick(PAST_THE_STALENESS_BOUND); // Point the url slot at a caveated cell holding the same url. The request // is the same request — same hash, same cache entry — and the read that // builds it now carries a confidentiality the taker's result store does // not declare, so the transaction staging it is refused. const caveatTx = taker.edit(); const hostileUrl = taker.getCell( space, "takeover-hostile-url", { type: "string", ifc: { confidentiality: [PROMPT_INFLUENCE] } }, caveatTx, ); hostileUrl.set(PROGRAM_URL); const takerInputs = taker.getCell<{ url: string }>( space, "takeover-inputs", undefined, caveatTx, ); // deno-lint-ignore no-explicit-any takerInputs.withTx(caveatTx).key("url").set(hostileUrl as any); taker.prepareTxForCommit(caveatTx); const caveated = await caveatTx.commit().settled; if (caveated.error) throw caveated.error; await takerRun.result.pull(); await clock.settle(); await refused.promise; await taker.settled(); // Nothing of the ending landed: the entry is still the holder's, so the // taker reports no refusal over a resolution that is still running. expect(takerRun.result.withTx().key("error").get()).toBeUndefined(); expect(takerRun.result.withTx().key("pending").get()).toBe(true); // And the holder's resolution still reaches its result. released = true; held.resolve(moduleResponse()); const resolved = await waitForCellValue<{ result?: { main?: string } }>( holder, holderRun.result, (value) => value?.result !== undefined, ); expect(resolved.result?.main).toBe("/held-program.ts"); }); });