Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 53 additions & 18 deletions app/src/sim/engine.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,48 @@
/// 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";
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<unknown>;
/** Swap a server behaviour; takes effect on the next call. */
setBehavior(fnName: string, setting: { kind: keyof typeof BEHAVIORS; config: Record<string, unknown> }): void;
setBehavior(
fnName: string,
setting: { kind: keyof typeof BEHAVIORS; config: Record<string, unknown> },
): void;
/** Drop both ends and end the serve loop. */
close(): void;
}

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<LiveConnection> {
const client = instance(session, conn.clientId);
const server = instance(session, conn.serverId);
Expand All @@ -42,34 +54,57 @@ export async function connect(session: Session, conn: Connection): Promise<LiveC

const codec = new JsonCodec();
const makeFraming = framingFor(protocol.framing);
const handshake = () =>
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(),
};
}
107 changes: 107 additions & 0 deletions app/src/sim/framedecode.ts
Original file line number Diff line number Diff line change
@@ -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<bigint, string>([
[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(" ");
}
21 changes: 19 additions & 2 deletions app/src/sim/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export interface Instance {
protocol: string;
/** Server only: one behaviour per function name. Empty for a client. */
behaviors: Record<string, BehaviorSetting>;
/** 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;
Expand All @@ -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. */
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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;
}
Loading
Loading