From a57083942dbe40a48679a1471e9bf11f3bd33291 Mon Sep 17 00:00:00 2001 From: Kinflou <149606337+Kinflou@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:44:28 +0800 Subject: [PATCH] feat(sim): the frame inspector + the handshake-refusal path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 1e (docs: design/playground-simulation.md) — completes Phase 1. - framedecode.ts — read a raw frame back with the same framing / handshake decoders the runtime uses, plus a JSON decode of the sub-frames. Resolves a datagram call id to a function name; reverses the handshake's wire-format / framing name hashes. - framelog.ts — rows are now
: direction, kind, function, Δ since the previous frame, byte length; expand for the decoded envelope (params / ok / err), a hex toggle, and the framing name. Handshake and unknown frames read distinctly; a "clear" button; a refusal row. - version-skew / handshake refusal. Each instance snapshots the schema's `ir_hash` when placed; `rebuild` leaves it alone, so editing a schema and returning keeps a surviving instance on the old IR. `resyncInstance` (a button in the inspector, shown only when the snapshot is stale) snaps it forward. `engine.connect` handshakes each end with its own instance's hash and no longer throws on a mismatch — it returns `LiveConnection.error = "handshake"`; the wire goes red, the call form hides, and the log gets a "connection refused · handshake" row. - a per-connection latency knob (ms) in the inspector. - view.test.ts: the 1d call test now also checks the labelled handshake frames and an expanded request frame's decoded params; a new test drives the whole refusal demo (connect on V1 → edit to V2 → resync only the server → refused). 15/15 sim tests pass. --- app/src/sim/engine.ts | 71 +++++++++++++----- app/src/sim/framedecode.ts | 107 +++++++++++++++++++++++++++ app/src/sim/model.ts | 21 +++++- app/src/sim/ui/framelog.ts | 147 ++++++++++++++++++++++++++++--------- app/src/sim/ui/view.ts | 56 ++++++++++++-- app/src/sim/view.test.ts | 54 +++++++++++++- app/src/style.css | 100 +++++++++++++++++++++++++ 7 files changed, 494 insertions(+), 62 deletions(-) create mode 100644 app/src/sim/framedecode.ts diff --git a/app/src/sim/engine.ts b/app/src/sim/engine.ts index 22997fe..5bc3872 100644 --- a/app/src/sim/engine.ts +++ b/app/src/sim/engine.ts @@ -1,17 +1,19 @@ /// Turn a `Connection` into a running wire: a `GenericDispatch` server bound to -/// the client instance's behaviours, a `GenericClient` on the other end, both -/// over a tapped `duplex()`. Behaviours can be swapped without reconnecting. +/// the server instance's behaviours, a `GenericClient` on the other end, over a +/// tapped `duplex()`. Each end handshakes with its own instance's `irHash`, so a +/// version-skewed pair is refused for real. Behaviours swap without reconnecting. import { BEHAVIORS } from "./behavior.ts"; import { GenericClient, GenericDispatch, type Behavior, type BehaviorMap } from "./generic.ts"; import { instance, type Connection, type Session } from "./model.ts"; import { + Client, DatagramFraming, Handshake, JsonCodec, JsonRpcFraming, + RuntimeError, Server, - Client, type Framing, } from "./runtime/index.ts"; import { findProtocol } from "./shape.ts"; @@ -19,10 +21,19 @@ import { wire, type Tap } from "./transport.ts"; export interface LiveConnection { tap: Tap; - /** Invoke a function from the client side. */ + /** `null` when connected; otherwise why the connection was refused + * (`"handshake"` for a version / framing / wire-format mismatch). */ + error: string | null; + clientName: string; + serverName: string; + framing: "datagram" | "jsonrpc"; + /** Invoke a function from the client side. Rejects if `error` is set. */ call(fnName: string, params: unknown): Promise; /** Swap a server behaviour; takes effect on the next call. */ - setBehavior(fnName: string, setting: { kind: keyof typeof BEHAVIORS; config: Record }): void; + setBehavior( + fnName: string, + setting: { kind: keyof typeof BEHAVIORS; config: Record }, + ): void; /** Drop both ends and end the serve loop. */ close(): void; } @@ -30,7 +41,8 @@ export interface LiveConnection { const framingFor = (name: "datagram" | "jsonrpc"): (() => Framing) => name === "jsonrpc" ? () => new JsonRpcFraming() : () => new DatagramFraming(); -/** Bind a `Connection` to real transports and run its handshake. */ +/** Bind a `Connection` to real transports and run its handshake. Never throws — + * a refused handshake comes back as `LiveConnection.error`. */ export async function connect(session: Session, conn: Connection): Promise { const client = instance(session, conn.clientId); const server = instance(session, conn.serverId); @@ -42,34 +54,57 @@ export async function connect(session: Session, conn: Connection): Promise - new Handshake({ irHash: BigInt(schema.ir_hash), wireFormat: codec.name, framing: makeFraming().name }); + const handshake = (irHash: string) => + new Handshake({ irHash: BigInt(irHash), wireFormat: codec.name, framing: makeFraming().name }); // A mutable map so `setBehavior` can swap an entry live. const behaviors: BehaviorMap = {}; - const build = (fnName: string): Behavior => { + const buildBehavior = (fnName: string): Behavior => { const fn = protocol.functions.find((f) => f.name === fnName)!; const setting = server.behaviors[fnName] ?? { kind: "reply" as const, config: {} }; return BEHAVIORS[setting.kind].make(setting.config, fn, schema); }; - for (const fn of protocol.functions) behaviors[fn.name] = build(fn.name); + for (const fn of protocol.functions) behaviors[fn.name] = buildBehavior(fn.name); + + const w = wire(client.name, server.name, session.latencyMs); + const base = { + tap: w.tap, + clientName: client.name, + serverName: server.name, + framing: protocol.framing, + close: () => w.close(), + }; - const w = wire(client.name, server.name); const rpcServer = new Server(new GenericDispatch(protocol, behaviors), codec, makeFraming()); - void rpcServer.serveHandshaked(w.b, handshake()); - const rpcClient = new GenericClient( - await Client.connect(w.a, codec, handshake(), makeFraming()), - protocol, - ); + // The serve loop rejects on a handshake mismatch too — swallow it; the client + // side reports the refusal. + void rpcServer.serveHandshaked(w.b, handshake(server.irHash)).catch(() => {}); + + let rpcClient: GenericClient; + try { + rpcClient = new GenericClient( + await Client.connect(w.a, codec, handshake(client.irHash), makeFraming()), + protocol, + ); + } catch (e) { + w.close(); + const kind = e instanceof RuntimeError ? e.kind : "handshake"; + return { + ...base, + error: kind, + call: () => Promise.reject(new Error(`connection refused · ${kind}`)), + setBehavior: () => {}, + }; + } return { - tap: w.tap, + ...base, + error: null, call: (fnName, params) => rpcClient.call(fnName, params), setBehavior: (fnName, setting) => { const fn = protocol.functions.find((f) => f.name === fnName); if (!fn) throw new Error(`setBehavior: no function ${fnName}`); behaviors[fnName] = BEHAVIORS[setting.kind].make(setting.config, fn, schema); }, - close: () => w.close(), }; } diff --git a/app/src/sim/framedecode.ts b/app/src/sim/framedecode.ts new file mode 100644 index 0000000..d31422e --- /dev/null +++ b/app/src/sim/framedecode.ts @@ -0,0 +1,107 @@ +/// Read a raw frame back into something the inspector can show — the same +/// framing / handshake decoders the runtime uses, plus a JSON decode of the +/// sub-frames (Phase 1's codec is always JSON). + +import { + DatagramFraming, + FRAMING_DATAGRAM, + Handshake, + JsonRpcFraming, + nameHash, + type Framing, +} from "./runtime/index.ts"; +import type { Frame } from "./transport.ts"; + +export interface FrameDetail { + kind: "handshake" | "request" | "response" | "unknown"; + framing: string; + /** request: the function name (resolved from the call address). */ + fn?: string; + requestId?: string; + /** request: the decoded params. */ + params?: unknown; + /** response: the decoded ok body. */ + ok?: unknown; + /** response: a raised error. */ + err?: { ordinal: number; body: unknown }; + /** handshake: the three fields it carries (names resolved where known). */ + handshake?: { irHash: string; wireFormat: string; framing: string; caps: number }; +} + +export interface DecodeCtx { + clientName: string; + serverName: string; + framing: "datagram" | "jsonrpc"; + /** function names in protocol order — resolves a datagram request's call id. */ + fnNames: string[]; +} + +// FNV-1a name hashes → readable names, for the handshake's wire-format / framing. +const NAME_BY_HASH = new Map([ + [nameHash("json"), "json"], + [nameHash(FRAMING_DATAGRAM), FRAMING_DATAGRAM], + [nameHash("jsonrpc-2.0"), "jsonrpc-2.0"], +]); +const nameOf = (h: bigint) => NAME_BY_HASH.get(h) ?? `0x${h.toString(16)}`; + +const framingFor = (name: "datagram" | "jsonrpc"): Framing => + name === "jsonrpc" ? new JsonRpcFraming() : new DatagramFraming(); + +function jsonOf(bytes: Uint8Array): unknown { + if (bytes.length === 0) return null; + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return `<${bytes.length} bytes>`; + } +} + +export function describeFrame(frame: Frame, ctx: DecodeCtx): FrameDetail { + const bytes = frame.bytes; + const framingName = ctx.framing === "jsonrpc" ? "jsonrpc-2.0" : FRAMING_DATAGRAM; + + const hs = Handshake.decode(bytes); + if (hs && bytes.length === 31) { + return { + kind: "handshake", + framing: framingName, + handshake: { + irHash: `0x${hs.irHash.toString(16).padStart(16, "0")}`, + wireFormat: nameOf(hs.wireFormat), + framing: nameOf(hs.framing), + caps: hs.capabilities, + }, + }; + } + + const framing = framingFor(ctx.framing); + + if (frame.from === ctx.clientName) { + const req = framing.decodeRequest(bytes); + if (!req) return { kind: "unknown", framing: framingName }; + const fn = + "name" in req.call ? req.call.name : (ctx.fnNames[req.call.id] ?? `#${req.call.id}`); + return { + kind: "request", + framing: framingName, + fn, + requestId: req.requestId.toString(), + params: jsonOf(req.params), + }; + } + + const res = framing.decodeResponse(bytes); + if (!res) return { kind: "unknown", framing: framingName }; + const detail: FrameDetail = { + kind: "response", + framing: framingName, + requestId: res.requestId.toString(), + }; + if ("ok" in res.envelope) detail.ok = jsonOf(res.envelope.ok); + else detail.err = { ordinal: res.envelope.err.id, body: jsonOf(res.envelope.err.body) }; + return detail; +} + +export function toHex(bytes: Uint8Array): string { + return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(" "); +} diff --git a/app/src/sim/model.ts b/app/src/sim/model.ts index 544b438..aa9a449 100644 --- a/app/src/sim/model.ts +++ b/app/src/sim/model.ts @@ -22,6 +22,11 @@ export interface Instance { protocol: string; /** Server only: one behaviour per function name. Empty for a client. */ behaviors: Record; + /** The schema's `ir_hash` when this instance was placed or last resynced. + * A `rebuild` does NOT touch it — so editing a schema and returning leaves + * a surviving instance built against the old IR, which the handshake then + * rejects (the version-skew demo). `resyncInstance` snaps it forward. */ + irHash: string; /** Canvas position (the UI owns it; the engine ignores it). */ x: number; y: number; @@ -36,13 +41,15 @@ export interface Session { shape: ProjectShape; instances: Instance[]; connection: Connection | null; + /** Fixed per-frame delivery delay for the wire, ms. */ + latencyMs: number; } let counter = 0; const nextId = () => `i${++counter}`; export function emptySession(shape: ProjectShape): Session { - return { shape, instances: [], connection: null }; + return { shape, instances: [], connection: null, latencyMs: 0 }; } /** Seed a server's per-function behaviour map from the protocol shape. */ @@ -72,6 +79,7 @@ export function addInstance( schemaNs: spec.schemaNs, protocol: spec.protocol, behaviors: spec.role === "server" ? seedBehaviors(session, spec.schemaNs, spec.protocol) : {}, + irHash: findProtocol(session.shape, spec.schemaNs, spec.protocol)?.schema.ir_hash ?? "0x0", x: spec.x ?? 0, y: spec.y ?? 0, }; @@ -124,7 +132,8 @@ export function setBehavior( /** Re-point the session at a freshly compiled shape. An instance survives if * its `schemaNs::protocol` still exists; its behaviour map keeps the configs - * of functions that remain and gains defaults for new ones. */ + * of functions that remain and gains defaults for new ones. Its `irHash` + * snapshot is deliberately left as-is — see `Instance.irHash`. */ export function rebuild(session: Session, shape: ProjectShape): void { session.shape = shape; const kept: Instance[] = []; @@ -152,3 +161,11 @@ export function rebuild(session: Session, shape: ProjectShape): void { session.connection = null; } } + +/** Snap an instance's `irHash` forward to the currently-compiled schema, so a + * connection built after a schema edit handshakes cleanly again. */ +export function resyncInstance(session: Session, id: string): void { + const inst = instance(session, id); + const found = inst && findProtocol(session.shape, inst.schemaNs, inst.protocol); + if (inst && found) inst.irHash = found.schema.ir_hash; +} diff --git a/app/src/sim/ui/framelog.ts b/app/src/sim/ui/framelog.ts index 39fee82..72ff895 100644 --- a/app/src/sim/ui/framelog.ts +++ b/app/src/sim/ui/framelog.ts @@ -1,13 +1,16 @@ -/// The frame list under the canvas. Subscribes to a `Tap` and appends a row -/// per frame. 1d keeps it plain — seq, direction, kind, byte length; 1e turns -/// rows into expandable envelope inspectors. +/// The frame inspector under the canvas. A row per frame — direction, kind, +/// function, Δ since the previous frame, byte length — expanding to the decoded +/// envelope, the raw hex, and the framing name. Handshake frames read +/// distinctly; a refused connection gets its own row. +import { describeFrame, toHex, type DecodeCtx } from "../framedecode.ts"; import type { Frame, Tap } from "../transport.ts"; export interface FrameLog { el: HTMLElement; - /** Point at a new tap (a fresh connection); clears the list. */ - attach(tap: Tap | null): void; + /** Point at a new connection's tap. `ctx` decodes the frames; `error` (e.g. + * `"handshake"`) appends a refusal row. Clears the list. */ + attach(tap: Tap | null, ctx?: DecodeCtx, error?: string | null): void; } export function frameLog(): FrameLog { @@ -16,59 +19,135 @@ export function frameLog(): FrameLog { const head = document.createElement("div"); head.className = "sim-frames-head"; - head.textContent = "frames"; + const title = document.createElement("span"); + title.textContent = "frames"; + const clear = document.createElement("button"); + clear.className = "icon-btn"; + clear.textContent = "clear"; + head.append(title, clear); const list = document.createElement("div"); - list.className = "sim-frames-list mono"; - - const empty = document.createElement("p"); - empty.className = "muted pad"; - empty.textContent = "no connection"; - list.append(empty); + list.className = "sim-frames-list"; el.append(head, list); let unsub: (() => void) | null = null; + let ctx: DecodeCtx | null = null; + let prevAt = 0; + let first = true; - const row = (f: Frame) => { - const r = document.createElement("div"); - r.className = `frame-row frame-${f.kind}`; - r.append( - span("frame-seq", String(f.seq).padStart(3, "0")), - span("frame-dir", `${f.from} → ${f.to}`), - span("frame-kind", f.kind), - span("frame-len", `${f.bytes.length} B`), - ); - return r; + const empty = () => { + const p = document.createElement("p"); + p.className = "muted pad"; + p.textContent = "no connection"; + return p; }; + function addRow(f: Frame) { + const detail = ctx ? describeFrame(f, ctx) : { kind: f.kind, framing: "?" }; + const delta = first ? 0 : Math.round(f.at - prevAt); + prevAt = f.at; + first = false; + + const row = document.createElement("details"); + row.className = `frame-row frame-${detail.kind}`; + + const summary = document.createElement("summary"); + summary.append( + cell("frame-seq", String(f.seq).padStart(3, "0")), + cell("frame-dir", `${f.from} → ${f.to}`), + cell("frame-kind", detail.kind), + cell("frame-fn", detail.fn ?? (detail.err ? `err ${detail.err.ordinal}` : "")), + cell("frame-delta", delta ? `+${delta} ms` : ""), + cell("frame-len", `${f.bytes.length} B`), + ); + row.append(summary); + + const body = document.createElement("div"); + body.className = "frame-body mono"; + body.append(kv("framing", detail.framing)); + if (detail.handshake) { + body.append( + kv("ir_hash", detail.handshake.irHash), + kv("wire_format", detail.handshake.wireFormat), + kv("framing_name", detail.handshake.framing), + ); + } + if (detail.requestId !== undefined) body.append(kv("request_id", detail.requestId)); + if ("params" in detail) body.append(json("params", detail.params)); + if ("ok" in detail) body.append(json("ok", detail.ok)); + if (detail.err) body.append(json(`err · ordinal ${detail.err.ordinal}`, detail.err.body)); + + const hexToggle = document.createElement("button"); + hexToggle.className = "hex-toggle"; + hexToggle.textContent = "hex"; + const hex = document.createElement("pre"); + hex.className = "frame-hex"; + hex.hidden = true; + hex.textContent = toHex(f.bytes); + hexToggle.addEventListener("click", (e) => { + e.preventDefault(); + hex.hidden = !hex.hidden; + }); + body.append(hexToggle, hex); + + row.append(body); + list.append(row); + list.scrollTop = list.scrollHeight; + } + + function refusedRow(reason: string) { + const r = document.createElement("div"); + r.className = "frame-row frame-refused"; + r.textContent = `connection refused · ${reason}`; + list.append(r); + } + + clear.addEventListener("click", () => { + list.replaceChildren(); + prevAt = 0; + first = true; + }); + return { el, - attach(tap) { + attach(tap, decodeCtx, error) { unsub?.(); unsub = null; + ctx = decodeCtx ?? null; + prevAt = 0; + first = true; list.replaceChildren(); if (!tap) { - list.append(empty); + list.append(empty()); return; } - for (const f of tap.frames) list.append(row(f)); - scroll(list); - unsub = tap.on((f) => { - list.append(row(f)); - scroll(list); - }); + for (const f of tap.frames) addRow(f); + if (error) refusedRow(error); + unsub = tap.on(addRow); }, }; } -function span(cls: string, text: string): HTMLSpanElement { +function cell(cls: string, text: string): HTMLSpanElement { const s = document.createElement("span"); s.className = cls; s.textContent = text; return s; } - -function scroll(el: HTMLElement): void { - el.scrollTop = el.scrollHeight; +function kv(k: string, v: string): HTMLElement { + const d = document.createElement("div"); + d.className = "frame-kv"; + d.append(cell("frame-k", k), cell("frame-v", v)); + return d; +} +function json(k: string, v: unknown): HTMLElement { + const d = document.createElement("div"); + d.className = "frame-kv"; + const kk = cell("frame-k", k); + const pre = document.createElement("pre"); + pre.className = "frame-json"; + pre.textContent = JSON.stringify(v, null, 2); + d.append(kk, pre); + return d; } diff --git a/app/src/sim/ui/view.ts b/app/src/sim/ui/view.ts index d86c3ce..8959538 100644 --- a/app/src/sim/ui/view.ts +++ b/app/src/sim/ui/view.ts @@ -12,6 +12,7 @@ import { instance, rebuild, removeInstance, + resyncInstance, setBehavior, setConnection, type Instance, @@ -19,6 +20,7 @@ import { type Session, } from "../model.ts"; import { findProtocol, type ProjectShape } from "../shape.ts"; +import type { DecodeCtx } from "../framedecode.ts"; import { argsForm, type ArgsForm } from "./argsform.ts"; import { frameLog } from "./framelog.ts"; @@ -162,7 +164,7 @@ export function createSim(): SimView { line.setAttribute("y1", String(a.y)); line.setAttribute("x2", String(b.x)); line.setAttribute("y2", String(b.y)); - line.setAttribute("class", live ? "wire-live" : "wire-pending"); + line.setAttribute("class", live?.error ? "wire-refused" : live ? "wire-live" : "wire-pending"); wireSvg.append(line); } @@ -181,7 +183,23 @@ export function createSim(): SimView { flashInspectorError((e as Error).message); } } - flog.attach(live?.tap ?? null); + let ctx: DecodeCtx | undefined; + if (live && session) { + const found = findProtocol( + session.shape, + instance(session, session.connection!.serverId)!.schemaNs, + instance(session, session.connection!.serverId)!.protocol, + ); + if (found) { + ctx = { + clientName: live.clientName, + serverName: live.serverName, + framing: live.framing, + fnNames: found.protocol.functions.map((f) => f.name), + }; + } + } + flog.attach(live?.tap ?? null, ctx, live?.error ?? null); renderCanvas(); renderInspector(); } @@ -228,10 +246,18 @@ export function createSim(): SimView { ); const hash = document.createElement("button"); hash.className = "hash-copy mono"; - hash.textContent = found.schema.ir_hash; + hash.textContent = sel.irHash; hash.title = "copy ir_hash"; - hash.addEventListener("click", () => void navigator.clipboard?.writeText(found.schema.ir_hash)); + hash.addEventListener("click", () => void navigator.clipboard?.writeText(sel.irHash)); inspectorEl.append(row("ir_hash", hash)); + if (sel.irHash !== found.schema.ir_hash) { + const resync = button("resync — schema changed", "danger", () => { + resyncInstance(session!, sel.id); + void reconnect(); + renderAll(); + }); + inspectorEl.append(resync); + } // remove const rm = button("remove instance", "danger", () => { @@ -265,7 +291,23 @@ export function createSim(): SimView { void reconnect(); }); inspectorEl.append(section("connection"), row("partner", connectSel)); - if (session.connection && live) inspectorEl.append(muted("● live", "ok")); + + const latency = document.createElement("input"); + latency.type = "number"; + latency.min = "0"; + latency.step = "10"; + latency.value = String(session.latencyMs); + latency.addEventListener("change", () => { + session!.latencyMs = Math.max(0, Number(latency.value) || 0); + void reconnect(); + }); + inspectorEl.append(row("latency ms", latency)); + + if (session.connection && live?.error) { + inspectorEl.append(muted(`connection refused · ${live.error}`, "err")); + } else if (session.connection && live) { + inspectorEl.append(muted("● live", "ok")); + } // server: per-function behaviours if (sel.role === "server") { @@ -276,7 +318,7 @@ export function createSim(): SimView { } // client + live: the call form - if (sel.role === "client" && live && connectedPartnerId(sel)) { + if (sel.role === "client" && live && !live.error && connectedPartnerId(sel)) { inspectorEl.append(renderCallForm(sel)); } } @@ -308,7 +350,7 @@ export function createSim(): SimView { wrap.append(cfg); const applyLive = () => { - if (live && connectedPartnerId(inst)) live.setBehavior(fnName, inst.behaviors[fnName]); + if (live && !live.error && connectedPartnerId(inst)) live.setBehavior(fnName, inst.behaviors[fnName]); }; sel.addEventListener("change", () => { setBehavior(session!, inst.id, fnName, selValue(sel) as BehaviorKind); diff --git a/app/src/sim/view.test.ts b/app/src/sim/view.test.ts index 3ab2096..b7866ac 100644 --- a/app/src/sim/view.test.ts +++ b/app/src/sim/view.test.ts @@ -107,8 +107,60 @@ test("1d — place, connect, call, and see the reply and frames", async () => { assert.match(out.textContent!, /"body": "HI"/); assert.ok(out.classList.contains("ok")); - const frames = sim.el.querySelectorAll(".sim-frames-list .frame-row"); + const frames = [...sim.el.querySelectorAll(".sim-frames-list .frame-row")] as HTMLElement[]; assert.ok(frames.length >= 4, `expected handshake + call + reply frames, got ${frames.length}`); + assert.ok( + frames.some((r) => r.classList.contains("frame-handshake")), + "handshake frames are labelled", + ); + + // expand the request frame — its decoded params are shown + const reqRow = frames.find((r) => r.querySelector(".frame-fn")?.textContent === "send")!; + (reqRow as HTMLDetailsElement).open = true; + fire(reqRow, "toggle"); + assert.match(reqRow.querySelector(".frame-body")!.textContent!, /"text": "hello"/); + assert.match(reqRow.querySelector(".frame-body")!.textContent!, /comline\.datagram/); + + sim.destroy(); +}); + +test("1e — resyncing only the server after a schema edit refuses the handshake", async () => { + const V1 = CHAT; + const V2 = CHAT.replace("seq: u64", "seq: u64\n tag: string"); // IR changes + + const sim = createSim(); + document.body.append(sim.el); + sim.setShape(describe_project([{ path: "chat.ids", source: V1 }]) as ProjectShape); + + const canvas = sim.el.querySelector(".sim-canvas")!; + drop(canvas, { schemaNs: "chat", protocol: "Chat", role: "server" }); + drop(canvas, { schemaNs: "chat", protocol: "Chat", role: "client" }); + const nodes = [...sim.el.querySelectorAll(".sim-node")] as HTMLElement[]; + const server = nodes.find((n) => n.classList.contains("role-server"))!; + const client = nodes.find((n) => n.classList.contains("role-client"))!; + + fire(client, "click"); + pick(sim.el.querySelector(".connect-sel") as HTMLSelectElement, server.dataset.id!); + await tick(); + assert.ok(sim.el.querySelector(".sim-wire .wire-live"), "connected on V1"); + + // edit the schema and return to simulate — both instances keep their V1 hash + sim.setShape(describe_project([{ path: "chat.ids", source: V2 }]) as ProjectShape); + await tick(); + assert.ok(sim.el.querySelector(".sim-wire .wire-live"), "still fine — neither end resynced"); + + // resync ONLY the server, then it and the (still-V1) client disagree + fire(sim.el.querySelector(`.sim-node[data-id="${server.dataset.id}"]`)!, "click"); + const resyncBtn = [...sim.el.querySelectorAll(".sim-inspector .sim-btn")].find((b) => + b.textContent!.startsWith("resync"), + ) as HTMLButtonElement; + assert.ok(resyncBtn, "a resync button is offered for the stale instance"); + fire(resyncBtn, "click"); + await tick(); + + assert.ok(sim.el.querySelector(".sim-wire .wire-refused"), "the wire shows refused"); + assert.ok(sim.el.querySelector(".sim-frames-list .frame-refused"), "a refusal row is logged"); + assert.match(sim.el.querySelector(".sim-inspector")!.textContent!, /connection refused · handshake/); sim.destroy(); }); diff --git a/app/src/style.css b/app/src/style.css index eb2dfe1..d07c28e 100644 --- a/app/src/style.css +++ b/app/src/style.css @@ -757,3 +757,103 @@ body.mode-simulate #gen-controls { .mono { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; } + +/* ── frame inspector (1e) ─────────────────────────────────────────────── */ +.sim-frames-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.sim-frames-head .icon-btn { + font-size: 0.68rem; +} +.sim-wire .wire-refused { + stroke: var(--err); + stroke-dasharray: 3 3; +} +.frame-row { + border-bottom: 1px solid var(--border); + font-size: 0.72rem; +} +details.frame-row > summary { + display: grid; + grid-template-columns: 3ch 1fr 5.5rem 5rem 5rem 4rem; + gap: 0.5rem; + padding: 0.2rem 0.4rem; + cursor: pointer; + list-style: none; +} +details.frame-row > summary::-webkit-details-marker { + display: none; +} +.frame-row .frame-seq { + color: var(--muted); +} +.frame-row .frame-kind { + color: var(--accent); +} +.frame-row .frame-fn { + color: var(--fg); +} +.frame-row .frame-delta, +.frame-row .frame-len { + color: var(--muted); + text-align: right; +} +.frame-handshake .frame-kind, +.frame-unknown .frame-kind { + color: var(--muted); +} +.frame-refused { + padding: 0.3rem 0.4rem; + color: var(--err); +} +.frame-body { + padding: 0.3rem 0.6rem 0.5rem; + background: var(--bg); + border-top: 1px solid var(--border); +} +.frame-kv { + display: flex; + gap: 0.6rem; + margin: 0.15rem 0; +} +.frame-k { + flex: 0 0 6rem; + color: var(--muted); +} +.frame-json { + margin: 0; + white-space: pre-wrap; + color: var(--fg); + max-height: 8rem; + overflow: auto; +} +.hex-toggle { + font: inherit; + font-size: 0.68rem; + color: var(--muted); + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + padding: 0 0.35rem; + margin-top: 0.3rem; + cursor: pointer; +} +.frame-hex { + margin: 0.3rem 0 0; + white-space: pre-wrap; + word-break: break-all; + color: var(--muted); + font-size: 0.68rem; +} +.sim-inspector input[type="number"] { + font: inherit; + font-size: 0.75rem; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.15rem 0.4rem; + width: 5rem; +}