Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7a63ff2
feat(web-console): humanised activity view and connection health
Mearman Sep 30, 2026
019ca84
docs(web-console): reword the session events hook header without a sp…
Mearman Sep 30, 2026
76d614d
Merge branch 'feat/console-trust' into feat/console-activity
Mearman Sep 30, 2026
8c1b308
test(web-console): open the raw frame log before counting handshakes …
Mearman Sep 30, 2026
637caec
test(web-console): make the unresponsive-node probe test independent …
Mearman Sep 30, 2026
8e32ee9
Merge branch 'feat/console-trust' into feat/console-activity
Mearman Sep 30, 2026
50bd628
fix(core): reject an unanswered ping with a typed PingTimeoutError
Mearman Sep 30, 2026
eb2edc6
fix(web-console): count only unanswered pings as lost, not probes cut…
Mearman Sep 30, 2026
abbca2f
fix(web-console): report each peer as seen once in the activity view
Mearman Sep 30, 2026
9f440a6
fix(web-console): draw a dot for every measured round trip so a lone …
Mearman Sep 30, 2026
f09bb43
Merge branch 'feat/console-trust' into feat/console-activity
Mearman Sep 30, 2026
b3cf905
Merge branch 'feat/console-trust' into feat/console-activity
Mearman Oct 1, 2026
c7ee15e
test(web-console): pass the clock to the certificate trust hook in th…
Mearman Oct 1, 2026
f80ebe6
test(web-console): pin the reconnect countdown to the attempt's own b…
Mearman Oct 1, 2026
29ef608
docs(core): split the sendPingMeasureRtt contract into paragraphs wit…
Mearman Oct 1, 2026
0b1c465
fix(web-console): label the directory time as when the peer advertise…
Mearman Oct 1, 2026
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
12 changes: 11 additions & 1 deletion ts/packages/core/src/domain/mesh-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,17 @@ export interface MeshSession {
readonly coordinatorFrames: AsyncIterable<CoordinatorFrame>;
connect: (address: string, localDomains: readonly string[]) => Promise<void>;
sendPing: () => Promise<void>;
/** Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives -- FIFO-paired against this call's own ping, since ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's own promise resolves against the Nth pong received after it, never matched by any other means. Rejects if the connection closes, or (when timeoutMs is given) if no pong arrives within timeoutMs, rather than resolving a sentinel value the way sendManageRequest's own timeout does -- there is no natural "no answer" value for a bare millisecond count to double as. Unlike sendPing (fire-and-forget, answered by nothing on an ordinary peer connection), this is answered only by a peer that replies to ping with pong -- today, relay-hub's own frame handling (wire-mesh#181) -- so calling this against a connection to a plain peer that never sends pong hangs until timeoutMs (if given) or forever. Exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time this over the same connection a relayed manage-request travelled, then subtract it from path.trace's own end-to-end RTT to recover the hub-to-target leg. */
/**
* Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives.
*
* The pairing is FIFO because ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's promise resolves against the Nth pong received after it, never matched by any other means.
*
* Rejects with a plain Error if the connection closes, and with a PingTimeoutError (from ping-round-trips.ts) when `timeoutMs` is given and no pong arrives within it, so a caller can tell an unanswered ping from a dropped connection. It never resolves a sentinel the way sendManageRequest's own timeout does, because there is no natural "no answer" value for a bare millisecond count to double as.
*
* Unlike sendPing (fire-and-forget, answered by nothing on an ordinary peer connection), this is answered only by a peer that replies to ping with pong, which today means relay-hub's own frame handling (wire-mesh#181). Called against a plain peer that never sends pong it hangs until `timeoutMs` if given, or forever.
*
* It exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time it over the same connection a relayed manage-request travelled, then subtract it from path.trace's own end-to-end RTT to recover the hub-to-target leg.
*/
sendPingMeasureRtt: (timeoutMs?: number) => Promise<number>;
/** Attaches this token to every `manage-request` sent from now on. */
setToken: (token: CapabilityToken) => void;
Expand Down
12 changes: 10 additions & 2 deletions ts/packages/core/src/domain/ping-round-trips.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
// FIFO pairing between sendPingMeasureRtt calls and the pong-frames that answer them (wire-mesh#181) -- ping-frame/pong-frame carry no correlation id of their own (spec/transport.cddl), so the Nth outstanding call is paired against the Nth pong a connection receives. Extracted out of mesh-session.ts the same way relay-pairing.ts already extracts its own relay-pairing bookkeeping, so mesh-session.ts's own applyFrame/sendPingMeasureRtt stay thin wiring rather than owning this queue's mechanics directly.
// FIFO pairing between sendPingMeasureRtt calls and the pong-frames that answer them (wire-mesh#181). ping-frame and pong-frame carry no correlation id of their own (spec/transport.cddl), so the Nth outstanding call is paired against the Nth pong a connection receives. Extracted out of mesh-session.ts the same way relay-pairing.ts already extracts its own relay-pairing bookkeeping, so mesh-session.ts's own applyFrame/sendPingMeasureRtt stay thin wiring rather than owning this queue's mechanics directly.

/** Why a ping was given up on: no pong arrived within the caller's `timeoutMs`. A call rejected for any other reason (the connection dropped or closed first) rejects with a plain Error, so a caller counting unanswered pings tells the two apart by this class rather than by message text. */
export class PingTimeoutError extends Error {
constructor() {
super("timed out waiting for pong");
this.name = "PingTimeoutError";
}
}

interface PendingPingRoundTrip {
sentAt: number;
Expand Down Expand Up @@ -58,7 +66,7 @@ export function createPingRoundTrips(): PingRoundTrips {
new Promise<number>((_resolve, reject) => {
setTimeout(() => {
if (cancel(sentAt)) {
reject(new Error("timed out waiting for pong"));
reject(new PingTimeoutError());
}
}, timeoutMs);
}),
Expand Down
4 changes: 3 additions & 1 deletion ts/packages/core/test/mesh-session-ping-pong.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { Clock } from "../src/ports/clock.js";
import { createMeshSession } from "../src/domain/mesh-session.js";
import { PingTimeoutError } from "../src/domain/ping-round-trips.js";
import {
fakeTransport,
testIdentity,
Expand Down Expand Up @@ -82,6 +83,7 @@ describe("sendPingMeasureRtt", () => {
const pending = session.sendPingMeasureRtt();
connection.fail(new Error("dropped"));
await expect(pending).rejects.toThrow("disconnected before a pong arrived");
await expect(pending).rejects.not.toBeInstanceOf(PingTimeoutError);
});

it("times out rather than hanging forever when no pong ever arrives", async () => {
Expand All @@ -97,7 +99,7 @@ describe("sendPingMeasureRtt", () => {
.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(PING_ROUND_TRIP_TIMEOUT_MS);
const outcome = await pending;
expect(outcome).toBeInstanceOf(Error);
expect(outcome).toBeInstanceOf(PingTimeoutError);
expect((outcome as Error).message).toBe("timed out waiting for pong");
await session.close();
} finally {
Expand Down
2 changes: 1 addition & 1 deletion ts/packages/web-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The browser client for wire-mesh: a client of *any* node, not just the hub. It c
- **Identity** — the console holds a persisted Web Crypto (P-256) identity (`src/adapters/web-crypto-identity.ts`'s `createPersistedWebCryptoIdentity`, backed by `src/adapters/indexeddb-storage.ts`), stable across reloads and shared across every connection the console makes.
- **Capability tokens** — `MeshSession.setToken` accepts a capability token to present on subsequent `manage-request`s (`sendManageRequest`), gating anything the connected node or a relayed peer requires authorization for.
- **Reconnect** — a dropped connection retries with backoff (opt-in `ReconnectPolicy`; `main.ts` wires a default exponential backoff, capped, for every session it creates), surfaced as an explicit `"reconnecting"` status rather than silently going dark.
- **Peer directory** — every `gossip` frame the node sends is folded into a directory table (device, addresses, snapshot time), latest advert per device winning.
- **Peer directory**: every `gossip` frame the node sends is folded into a directory table (device, addresses, and when the peer advertised itself by its own clock), latest advert per device winning.
- **Frame log** — a live, ordered feed of every frame that crossed the connection in either direction, plus a *Send ping* button.

## How it maps onto core's ports
Expand Down
25 changes: 6 additions & 19 deletions ts/packages/web-console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@ import {
Title,
} from "@mantine/core";
import { createMeshSession } from "wire-mesh-core/domain/mesh-session";
import type {
MeshSession,
ReconnectPolicy,
} from "wire-mesh-core/domain/mesh-session";
import type { MeshSession } from "wire-mesh-core/domain/mesh-session";
import { createGossipExpansion } from "wire-mesh-core/domain/gossip-expansion";
import type {
GossipExpansion,
Expand All @@ -26,6 +23,7 @@ import type { Connection } from "wire-mesh-core/ports/transport";
import type { DeviceId } from "wire-mesh-core/generated/protocol";
import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
import { dmRoomPath } from "wire-mesh-core/domain/room-path";
import { reconnectPolicy } from "./reconnect-policy.js";
import { createDialTransport } from "./adapters/dial-transport.js";
import type { CertificateMemory } from "./certificate-memory.js";
import { DEFAULT_ICE_SERVERS } from "./ice-servers.js";
Expand Down Expand Up @@ -72,20 +70,6 @@ const DEFAULT_ADDRESS = "ws://localhost:8787";
const AVAILABLE_DOMAINS = ["core/management", "core/exec", "core/data"];
const DEFAULT_DOMAINS = ["core/management", "core/data"];

// Exponential backoff, capped at 30s, giving up after 5 attempts -- reasonable defaults for a browser console reconnecting to a relay that may just be restarting, without retrying forever against one that is genuinely gone.
const RECONNECT_MAX_ATTEMPTS = 5;
const RECONNECT_BASE_DELAY_MS = 1000;
const RECONNECT_MAX_DELAY_MS = 30_000;
const RECONNECT_BACKOFF_BASE = 2;
const reconnectPolicy: ReconnectPolicy = {
maxAttempts: RECONNECT_MAX_ATTEMPTS,
delayMs: (attempt) =>
Math.min(
RECONNECT_BASE_DELAY_MS * RECONNECT_BACKOFF_BASE ** (attempt - 1),
RECONNECT_MAX_DELAY_MS,
),
};

/** A direct connection attempt that did not open, kept on screen until dismissed so a refusal or a failure to negotiate is never silent. The conversation itself carries on through the hub. */
interface ConnectionFailure {
key: string;
Expand Down Expand Up @@ -121,7 +105,7 @@ export function App({
const [connections, setConnections] = useState<ConnectionEntry[]>([]);
const [discovered, setDiscovered] = useState<PendingExpansion[]>([]);
const [failures, setFailures] = useState<ConnectionFailure[]>([]);
const trust = useCertificateTrust(certificateMemory);
const trust = useCertificateTrust(certificateMemory, clock);
const roomMessaging = useRoomMessaging(identity, clock, messageStore);
const [selectedPath, setSelectedPath] = useState<string | undefined>();
// The conversation shown is the one the user picked, falling back to the first while nothing is picked or the picked one no longer exists.
Expand Down Expand Up @@ -449,6 +433,9 @@ export function App({
key={entry.address}
address={entry.address}
session={entry.session}
clock={clock}
reconnectPolicy={reconnectPolicy}
certificateChanges={trust.changes}
onClose={() => {
handleClose(entry);
}}
Expand Down
220 changes: 220 additions & 0 deletions ts/packages/web-console/src/activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// The console's human reading of a session: what a person wants to know happened ("connected", "pairing established", "peer seen", "certificate changed"), derived from the session's state changes and the frames that crossed it. The raw frames stay available beside it for debugging. Pure, so the wording and the change detection can be tested without a session.

import type { SessionEvent } from "wire-mesh-core/domain/mesh-session";
import type { DeviceId, Frame } from "wire-mesh-core/generated/protocol";
import { deviceIdToHex } from "wire-mesh-core/domain/device-id";

export type ActivityKind =
"connection" | "peer" | "pairing" | "relay" | "certificate";

export interface ActivityEntry {
key: string;
/** When the console observed it, from the injected clock. */
at: number;
kind: ActivityKind;
/** The sentence, which ends where the peer's name goes when `peer` is set. */
summary: string;
/** Hex device-id of the peer the entry is about, named by the console's naming convention when shown. */
peer: string | undefined;
}

/** What has already been turned into entries, so the next event yields only what is new. */
export interface ActivityTracker {
/** How many frame-log entries have been read. The log only ever grows. */
frames: number;
/** The connection stage last reported: the status, with the attempt number while reconnecting, so each failed retry is a change and a repeated event is not. */
stage: string | undefined;
handshake: string | undefined;
/** Sequence for entry keys. */
issued: number;
/** Hex device-ids already reported as seen. A directory is gossiped again and again with the same peers, so each is reported the first time only; otherwise a busy directory would push every other entry out of the capped activity view. */
seen: ReadonlySet<string>;
}

export const initialTracker: ActivityTracker = {
frames: 0,
stage: undefined,
handshake: undefined,
issued: 0,
seen: new Set(),
};

type Described = Pick<ActivityEntry, "kind" | "summary" | "peer">;

function hex(device: DeviceId): string {
return deviceIdToHex(device);
}

/** What one frame means to a person, or nothing for plumbing (pings, handshakes and the like have their own views or are only for the raw log). */
export function describeFrame(
direction: "sent" | "received",
frame: Readonly<Frame>,
): Described[] {
if (frame.type === "gossip") {
return direction === "received"
? frame.peers.map((advert) => ({
kind: "peer",
summary: "Peer seen:",
peer: hex(advert.device),
}))
: [
{
kind: "peer",
summary: "Advertised this console to the node",
peer: undefined,
},
];
}
if (frame.type === "relay-connect") {
return [
{
kind: "pairing",
summary: "Asked the node to pair this console with",
peer: hex(frame["target-device"]),
},
];
}
if (frame.type === "relay-inbound") {
return [
{
kind: "pairing",
summary: "Pairing established with",
peer: hex(frame["source-device"]),
},
];
}
if (frame.type === "relay-data") {
const other =
direction === "sent" ? frame["to-device"] : frame["from-device"];
return [
{
kind: "relay",
summary:
direction === "sent"
? "Relayed a message to"
: "Relayed message received from",
peer: other === undefined ? undefined : hex(other),
},
];
}
if (frame.type === "close") {
return [
{
kind: "connection",
summary: "The node closed the connection",
peer: undefined,
},
];
}
return [];
}

function connectionStage(state: Readonly<SessionEvent["state"]>): string {
return state.status === "reconnecting"
? `reconnecting:${String(state.attempt)}`
: state.status;
}

function describeState(
previous: Readonly<ActivityTracker>,
state: Readonly<SessionEvent["state"]>,
handshake: string | undefined,
): Described[] {
const entries: Described[] = [];
const connection = (summary: string): void => {
entries.push({ kind: "connection", summary, peer: undefined });
};
if (connectionStage(state) !== previous.stage) {
switch (state.status) {
case "connecting":
connection(`Connecting to ${state.address}`);
break;
case "connected":
connection(`Connected to ${state.address}`);
break;
case "reconnecting":
connection(
`${previous.stage?.startsWith("reconnecting") === true ? "Retry failed" : "Connection lost"} (${state.reason}); retrying, attempt ${String(state.attempt)}`,
);
break;
case "closed":
connection(`Closed (${state.reason})`);
break;
case "idle":
break;
}
}
if (state.status === "connected" && handshake !== previous.handshake) {
if (state.handshake.status === "negotiated") {
connection(
`Handshake negotiated: ${state.handshake.sharedDomains.join(", ")}`,
);
} else if (state.handshake.status === "unanswered") {
connection("The node does not answer handshakes (a relay-only node)");
} else if (state.handshake.status === "rejected") {
connection(`Handshake rejected (${state.handshake.reason})`);
}
}
return entries;
}

/** The handshake stage of a state, or undefined when not connected. */
function handshakeStage(
state: Readonly<SessionEvent["state"]>,
): string | undefined {
return state.status === "connected" ? state.handshake.status : undefined;
}

/** The entries an event adds beyond what `tracker` has already seen, and the tracker to use for the next event. */
export function advanceActivity(
tracker: Readonly<ActivityTracker>,
event: Readonly<SessionEvent>,
at: number,
): { tracker: ActivityTracker; entries: ActivityEntry[] } {
const handshake = handshakeStage(event.state);
const seen = new Set(tracker.seen);
const described: Described[] = [
...describeState(tracker, event.state, handshake),
...event.frameLog
.slice(tracker.frames)
.flatMap((entry) => describeFrame(entry.direction, entry.frame))
.filter((entry) => {
if (entry.kind !== "peer" || entry.peer === undefined) {
return true;
}
if (seen.has(entry.peer)) {
return false;
}
seen.add(entry.peer);
return true;
}),
];
const entries = described.map((entry, index): ActivityEntry => ({
...entry,
key: `activity-${String(tracker.issued + index)}`,
at,
}));
return {
tracker: {
frames: event.frameLog.length,
stage: connectionStage(event.state),
handshake,
issued: tracker.issued + entries.length,
seen,
},
entries,
};
}

/** The activity entries for certificate changes announced by a node, for merging into that node's connection activity. */
export function certificateActivity(
changes: readonly { key: string; at: number; node: string }[],
): ActivityEntry[] {
return changes.map((change) => ({
key: `certificate-${change.key}`,
at: change.at,
kind: "certificate",
summary: `Certificate changed: ${change.node} announced certificates unlike the remembered ones`,
peer: undefined,
}));
}
Loading
Loading