mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
The four telemetry gaps (rows on commits, chord.host, microsecond times, a read-only store) and: pages record and send span batches over their link to the server's ingest object (checked, capped, rate-limited, stamped browser and with the link's principal); workers send theirs too, so the server is the store's only writer; principalAttributes lets an app choose what a span records about its principal. The tutorial shows a real page's click traced to the worker, with the trace viewer.
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry";
|
|
import { describe, expect, it } from "vitest";
|
|
import { BACKGROUND_CONTEXT as context, withAbortSignal, withTelemetry } from "../../src/context/index.ts";
|
|
import { int, local, method, remote, str } from "../../src/contracts/index.ts";
|
|
import { withLinkedAbortSignal } from "../../src/host/ctx.ts";
|
|
import type { ChordHost } from "../../src/host/host.ts";
|
|
import type { Ctx, Host, Reference } from "../../src/host/index.ts";
|
|
import { constant } from "../../src/state/index.ts";
|
|
import {
|
|
flushed,
|
|
incarnationOf,
|
|
linked,
|
|
loaded,
|
|
rawParent,
|
|
rejection,
|
|
Server,
|
|
type TestPrincipal,
|
|
} from "../support.ts";
|
|
|
|
const Item = local("app.item", { version: 1, params: { id: int() }, members: { id: int() } });
|
|
const Desk = local("app.desk", {
|
|
version: 1,
|
|
params: {},
|
|
members: { use: method(undefined, int()), leak: method(), late: method(), who: method(undefined, str()) },
|
|
});
|
|
|
|
/** A desk whose handlers use `ctx.get`; what they got and their contexts are recorded. */
|
|
async function desk() {
|
|
const seen: { ctx?: Ctx; item?: Reference<typeof Item>; late?: Promise<unknown>; factories: number } = {
|
|
factories: 0,
|
|
};
|
|
const { host } = await loaded((h) => {
|
|
h.serve(Item, ({ id }) => {
|
|
seen.factories++;
|
|
return { id: constant(id) };
|
|
});
|
|
h.serve(Desk, () => ({
|
|
use: async (ctx) => {
|
|
const item = await ctx.get(Item, { id: 1 });
|
|
return item.id.value;
|
|
},
|
|
leak: async (ctx) => {
|
|
seen.ctx = ctx;
|
|
seen.item = await ctx.get(Item, { id: 2 });
|
|
return null;
|
|
},
|
|
late: async (ctx) => {
|
|
seen.late = ctx.get(Item, { id: 3 }).catch((error: unknown) => error);
|
|
return null;
|
|
},
|
|
who: async (ctx) => `${ctx.principal?.kind ?? "none"} ${ctx.incarnation}`,
|
|
}));
|
|
});
|
|
return { host, seen, desk: await host.get(Desk, {}, context) };
|
|
}
|
|
|
|
describe("ctx", () => {
|
|
it("ctx.get references are released when the handler settles", async () => {
|
|
const { desk: ref, seen } = await desk();
|
|
expect(await ref.use(context)).toBe(1);
|
|
await ref.leak(context);
|
|
expect(() => seen.item!.id.value).toThrow("app.item: released");
|
|
// The object ended with its last reference: a new get creates it anew.
|
|
await ref.use(context);
|
|
expect(seen.factories).toBe(3);
|
|
});
|
|
|
|
it("ctx.get after the handler settled throws released", async () => {
|
|
const { desk: ref, seen } = await desk();
|
|
await ref.leak(context);
|
|
expect(await rejection(seen.ctx!.get(Item, { id: 1 }))).toEqual({
|
|
code: "released",
|
|
reason: "settled",
|
|
message: "app.item: ctx.get after its call settled",
|
|
});
|
|
});
|
|
|
|
it("ctx.get of a handler whose call was aborted checks the signal first: nothing is created", async () => {
|
|
let factories = 0;
|
|
let got: Promise<unknown> | undefined;
|
|
const gate = Promise.withResolvers<void>();
|
|
const { host } = await loaded((h) => {
|
|
h.serve(Item, ({ id }) => {
|
|
factories++;
|
|
return { id: constant(id) };
|
|
});
|
|
h.serve(Desk, () => ({
|
|
use: async (ctx) => {
|
|
await gate.promise;
|
|
got = ctx.get(Item, { id: 1 }).catch((error: unknown) => error);
|
|
return 0;
|
|
},
|
|
leak: async () => null,
|
|
late: async () => null,
|
|
who: async () => "",
|
|
}));
|
|
});
|
|
const ref = await host.get(Desk, {}, context);
|
|
const caller = new AbortController();
|
|
const call = ref.use(withAbortSignal(caller.signal, context)).catch(() => {});
|
|
await flushed();
|
|
caller.abort(new Error("caller gave up"));
|
|
gate.resolve();
|
|
await call;
|
|
await flushed();
|
|
expect(await got).toEqual(new Error("caller gave up"));
|
|
expect(factories).toBe(0);
|
|
});
|
|
|
|
it("a ctx.get finishing after its handler settled is released at once (race 11)", async () => {
|
|
const { desk: ref, seen } = await desk();
|
|
await ref.late(context);
|
|
expect(await seen.late).toMatchObject({
|
|
code: "released",
|
|
reason: "settled",
|
|
message: "app.item: ctx.get after its call settled",
|
|
});
|
|
await flushed();
|
|
// The late reference's object ended with it.
|
|
expect(seen.factories).toBe(1);
|
|
});
|
|
|
|
it("a ctx.get still waiting when its handler settles gives up: its reference is released at once", async () => {
|
|
const Far = remote("app.far", {
|
|
version: 1,
|
|
params: {},
|
|
hostAddress: () => Server(),
|
|
variants: { v: { state: { n: int() } } },
|
|
});
|
|
const Starter = local("app.starter", { version: 1, params: {}, members: { start: method() } });
|
|
let pending: Promise<unknown> | undefined;
|
|
const { host } = await loaded((h) =>
|
|
h.serve(Starter, () => ({
|
|
start: async (ctx) => {
|
|
pending = ctx.get(Far, {}).catch((error: unknown) => error);
|
|
return null;
|
|
},
|
|
})),
|
|
);
|
|
// A host of any principal type takes a parent link.
|
|
const parent = await rawParent(host as Host<TestPrincipal>);
|
|
await (await host.get(Starter, {}, context)).start(context);
|
|
const { hold } = await parent.next("hold");
|
|
expect(await parent.next("release")).toMatchObject({ hold });
|
|
expect(await pending).toMatchObject({
|
|
code: "released",
|
|
reason: "settled",
|
|
message: "app.far: ctx.get after its call settled",
|
|
});
|
|
});
|
|
|
|
it("a local handler's ctx.principal is the host's, its incarnation this host's", async () => {
|
|
const { desk: ref, host } = await desk();
|
|
expect(await ref.who(context)).toBe(`none ${(host as ChordHost).incarnation}`);
|
|
});
|
|
});
|
|
|
|
describe("local handlers act as the host (§4, audit)", () => {
|
|
it("a local object called from a remote handler's ctx.get runs with this host's principal and incarnation", async () => {
|
|
const Store = local("app.local-store", { version: 1, params: {}, members: { who: method(undefined, str()) } });
|
|
const Gate = remote("app.gate", {
|
|
version: 1,
|
|
params: {},
|
|
hostAddress: () => Server(),
|
|
variants: { v: { methods: { who: method(undefined, str()), through: method(undefined, str()) } } },
|
|
});
|
|
const who = (ctx: Ctx<TestPrincipal>) => `${ctx.principal?.kind ?? "none"} ${ctx.incarnation}`;
|
|
const { server, client } = await linked((h) => {
|
|
h.serve(Store, () => ({ who: async (ctx) => who(ctx) }));
|
|
h.serve(Gate, () => ({
|
|
variant: "v",
|
|
members: {
|
|
who: async (ctx) => who(ctx),
|
|
through: async (ctx) => (await ctx.get(Store, {})).who(ctx),
|
|
},
|
|
}));
|
|
});
|
|
const gate = await client.get(Gate, {}, context);
|
|
// The remote handler acts as its caller; the local object it reaches acts as this host, both halves.
|
|
expect(await gate.who(context)).toBe(`owner ${incarnationOf(client)}`);
|
|
expect(await gate.through(context)).toBe(`server ${incarnationOf(server)}`);
|
|
});
|
|
});
|
|
|
|
describe("linked abort signals", () => {
|
|
it("abort with the caller's signal or the call's, the caller's reason first", () => {
|
|
for (const first of ["caller", "call"] as const) {
|
|
const [caller, call] = [new AbortController(), new AbortController()];
|
|
const { context: linked } = withLinkedAbortSignal(call.signal, withAbortSignal(caller.signal, context));
|
|
(first === "caller" ? caller : call).abort(first);
|
|
expect(linked.abortSignal?.reason).toBe(first);
|
|
}
|
|
const [caller, call] = [new AbortController(), new AbortController()];
|
|
call.abort("call");
|
|
caller.abort("caller");
|
|
const { context: linked } = withLinkedAbortSignal(call.signal, withAbortSignal(caller.signal, context));
|
|
expect(linked.abortSignal?.reason).toBe("caller");
|
|
});
|
|
|
|
it("detach from both sources on release: a later abort reaches nothing", () => {
|
|
const [caller, call] = [new AbortController(), new AbortController()];
|
|
const linked = withLinkedAbortSignal(call.signal, withAbortSignal(caller.signal, context));
|
|
linked.release();
|
|
caller.abort("late");
|
|
call.abort("late");
|
|
expect(linked.context.abortSignal?.aborted).toBe(false);
|
|
});
|
|
|
|
it("is the call's own signal without a caller's", () => {
|
|
const call = new AbortController();
|
|
const linked = withLinkedAbortSignal(call.signal, context);
|
|
expect(linked.context.abortSignal).toBe(call.signal);
|
|
linked.release();
|
|
});
|
|
});
|
|
|
|
describe("ctx.trace (C11, slice 9)", () => {
|
|
const Traced = local("app.traced", {
|
|
version: 1,
|
|
params: {},
|
|
members: { work: method(undefined, int()), failing: method() },
|
|
});
|
|
|
|
/** A host recording to `telemetry`, serving `Traced`, whose `work` runs two child spans and gets an item in one. */
|
|
async function traced(telemetry: InMemoryTelemetryContext | undefined) {
|
|
const seen: { inner?: Ctx; outer?: Ctx; item?: Reference<typeof Item> } = {};
|
|
const { host } = await loaded(
|
|
(h) => {
|
|
h.serve(Item, ({ id }) => ({ id: constant(id) }));
|
|
h.serve(Traced, () => ({
|
|
work: async (ctx) => {
|
|
seen.outer = ctx;
|
|
return ctx.trace("app.step", { n: 1 }, async (inner) => {
|
|
seen.inner = inner;
|
|
seen.item = await inner.get(Item, { id: 3 });
|
|
return inner.trace("app.deeper", {}, async () => seen.item!.id.value);
|
|
});
|
|
},
|
|
failing: async (ctx) => {
|
|
await ctx.trace("app.fails", {}, async () => {
|
|
throw new Error("broke");
|
|
});
|
|
return null;
|
|
},
|
|
}));
|
|
},
|
|
{ telemetry },
|
|
);
|
|
return { host, seen };
|
|
}
|
|
|
|
it("runs fn in a child span of the handler's, with a child ctx: the same principal, signal and references", async () => {
|
|
const telemetry = new InMemoryTelemetryContext();
|
|
const { host, seen } = await traced(telemetry);
|
|
const ref = await host.get(Traced, {}, context);
|
|
await telemetry.startSpan({ name: "caller" }, async (span) => {
|
|
expect(await ref.work(withTelemetry(span, context))).toBe(3);
|
|
});
|
|
const spans = telemetry.getSpans();
|
|
const byName = (name: string) => spans.find((span) => span.name === name)!;
|
|
expect(byName("app.step").parentId).toBe(byName("chord.method").id);
|
|
expect(byName("app.step").attributes).toEqual({ "chord.host": "client", n: 1 });
|
|
expect(byName("app.deeper").parentId).toBe(byName("app.step").id);
|
|
expect(seen.inner).not.toBe(seen.outer);
|
|
expect(seen.inner!.principal).toBe(seen.outer!.principal);
|
|
expect(seen.inner!.incarnation).toBe(seen.outer!.incarnation);
|
|
// The child's references are the handler's: released when the handler settled.
|
|
expect(seen.item!.state.value.kind).toBe("ended");
|
|
await expect(seen.inner!.get(Item, { id: 3 })).rejects.toMatchObject({ code: "released" });
|
|
});
|
|
|
|
it("marks its span failed (`trace_failed`) and rethrows", async () => {
|
|
const telemetry = new InMemoryTelemetryContext();
|
|
const { host } = await traced(telemetry);
|
|
const ref = await host.get(Traced, {}, context);
|
|
await telemetry.startSpan({ name: "caller" }, async (span) => {
|
|
await expect(ref.failing(withTelemetry(span, context))).rejects.toThrow("broke");
|
|
});
|
|
const failed = telemetry.getSpans().find(({ name }) => name === "app.fails")!;
|
|
expect(failed.status).toEqual({ status: "error", error: { name: "trace_failed", message: "broke" } });
|
|
});
|
|
|
|
it("rejects, never throws, when fn throws synchronously, recorded or not (review L5)", async () => {
|
|
for (const telemetry of [new InMemoryTelemetryContext(), undefined]) {
|
|
const { host } = await loaded(
|
|
(h) =>
|
|
h.serve(Traced, () => ({
|
|
work: async (ctx) => {
|
|
const traced = ctx.trace("app.sync", {}, (() => {
|
|
throw new Error("sync");
|
|
}) as () => Promise<number>);
|
|
return traced.catch(() => 7);
|
|
},
|
|
failing: async () => null,
|
|
})),
|
|
{ telemetry },
|
|
);
|
|
const ref = await host.get(Traced, {}, context);
|
|
const work = (caller: typeof context) => ref.work(caller);
|
|
const result =
|
|
telemetry === undefined
|
|
? await work(context)
|
|
: await telemetry.startSpan({ name: "caller" }, (span) => work(withTelemetry(span, context)));
|
|
expect(result).toBe(7);
|
|
if (telemetry !== undefined) expect(telemetry.getSpans().map(({ name }) => name)).toContain("app.sync");
|
|
}
|
|
});
|
|
|
|
it("gives fn the handler's own ctx when nothing records", async () => {
|
|
const { host, seen } = await traced(undefined);
|
|
const ref = await host.get(Traced, {}, context);
|
|
expect(await ref.work(context)).toBe(3);
|
|
expect(seen.inner).toBe(seen.outer);
|
|
});
|
|
});
|