Orbit C++ → Rust · Post 25

One Timeline, Many Machines

Post 22 was the plan: one service as the hub, every other machine streaming into it, a clock fit per link so their events share an axis. This is the build. Two flags, one small protocol, a byte of the pid, and a least-squares line through the fastest round trips. The remote does nothing it did not do before.

2flags: --join dials in, --connect dials out
1 byteof every pid and tid names the machine
±87 µsworst alignment, one box, debug build, loopback
0changes to the ring, the renderer, the frames

The request in docs/TODO.md was for one viewer that shows many machines, with clocks lined up well enough that a scope on one box can be read against a scope on another. Post 22 worked out how, and ended on five decisions. This post takes the first of them, pack the machine into the top byte of the pid, as decided by the plan's own recommendation, and builds the core: the link, the identity, the clocks, the fan-out, and the rail. The transports the plan added around that core, ssh with install-on-demand, a token on the API, the Machines panel, stay in the plan; the last section says exactly which parts are not here.

The shape: a hub and its remotes

Nothing is configured in advance and there is no distributed build. Any orbit-service is a hub the moment another machine is linked to it, and a remote is an ordinary service doing ordinary work: it captures what it always captured and hands the frames it would give a browser to the hub instead. The hub is the service whose URL you open. Browsers never talk to a remote.

Browsers talk to the hub; remotes reach it by dialing in or by being dialed; the hub merges one stream BROWSERSHUB · the service you openedREMOTES · stock services your laptop's tab a colleague's tab /ws one ring, one time axis machine table · 0 = itself, 1..64 = links pid, tid, string ids rewritten at ingest a clock fit per link, applied at ingest Record fans out: start / stop to every link the renderer, the reports and the bundle never learn ci-runner--join hub:44766 dials in render-01hub: --connect render-01 dialed build-macPOST /api/peers/connect
Who dials does not matter. A remote behind a firewall dials the hub with --join; a machine the hub can reach is dialed with --connect or added at run time through the API. Either way one WebSocket carries the link and the hub runs the same code over it.

The plan chose the hub over the browser for four reasons, and the decisive one still holds: aligning two hosts needs round-trip timing at microsecond resolution, and a browser coarsens performance.now() on purpose. Service to service, both ends read the capture clock, CLOCK_MONOTONIC on Unix and QueryPerformanceCounter on Windows, the clocks the events are stamped with. The hub exposes that clock to the server crate through one hook, LiveService::set_clock, and the same hook now stamps POST /api/events, which on Windows had been reading a different clock than the capture.

A link is one WebSocket. The hub side runs hub_role, the remote side remote_role, and neither knows which WebSocket crate carries the bytes or who opened the socket: each socket is pumped into a pair of channels, a Link, by a twenty-line adapter per crate (axum for an accepted connection, tokio-tungstenite for a dialed one, and a pair of in-process channels for the tests). The roles are plain async loops over those channels. That is what lets a remote dial in and still be a remote: the hub accepts the socket on /api/join and runs the hub role over it, exactly as it does over a socket it dialed to a remote's /api/peer.

The link protocol: hello and welcome, a burst of clock probes, then frames one way and start and stop the other hub role remote role {"hello": {host, cores, version}} {"welcome": {machine: 1, host}} the remote then re-sends every name it already knows {"ping": {t1}} {"pong": {t1, t2, t3}} 40 probes at 50 ms, then one a second · the hub stamps t4 on receipt binary: the remote's own /ws frames, untouched event batches, interned strings, thread and process names · nothing else {"processes": [...]} every 2 s {"start": {pid}} · {"stop": true} what the hub's Record and Stop become on every link; pid 0 is a capture with no target held frames: event batches that arrive before the first pong wait, bounded, and are filed once a fit exists
Six message kinds. Text messages are one JSON object with one key. Binary messages are the wire frames the remote's own viewers get, forwarded by a type-byte check without decoding: the remote spends nothing on the link that it did not already spend on a browser.

Pings are their own tiny messages, never appended to a batch, because a probe queued behind a 64 KB batch measures the batch. The remote answers from inside its select loop; if that loop is busy forwarding a burst the answer is late, the round trip is long, and the filter in the next section throws the sample away. A dropped link is redialed every two seconds by whichever side dialed, and the remote resubscribes to its own broadcast before saying hello so nothing between the two is missed.

Identity: a byte and a word

The viewer keys everything on a 32-bit pid, and two machines will each have a pid 1234. The plan's recommendation was to pack the machine into the top byte at hub ingest, and that is what is built: the hub's own ids keep a zero top byte and read exactly as before; a remote's pid and tid become machine << 24 | id. Linux's pid_max ceiling is 222, so 24 bits hold any real id on any of the three platforms.

A 32-bit pid split into an 8-bit machine index and a 24-bit pid on that machine LiveEvent.pid AND LiveEvent.tid · 32 BITS · UNCHANGED SIZE machine8 bits · 0 = the hub itself · 1..64 pid on that machine24 bits · Linux pid_max is 2²² · macOS 99,998 · Windows fits 3124230 0 stays 0, so the idle thread and "no target" keep their meaning · an id that needs more than 24 bits is dropped and counted 64 is far under the top bytes of the synthetic ids (the agent's 0xA6E7_0000, the viewer's 0x5E1F_0002), so those never read as a machine
The demo's "spoofed remote pids" convention, made real. MachineId::from_pid in the viewer now reads the top byte and returns Peer(n); the rail's grouping, dragging and collapsing, which were already written for the demo's two-valued enum, carry over unchanged.

Three more collisions are handled the same way, at ingest, per link. Interned string ids are a per-service sequence, so the hub interns each remote string into its own table and keeps a map from the remote's id to its own; a name it has never seen falls back to ? rather than to a wrong label. A remote's process name gets @host appended, which is both the label on its process row and, as the next-but-one section shows, how the viewer learns the machine's name without a new frame type. And scheduler slices carry a core id in a byte: a remote's cores are shifted past the hub's and past every earlier link's, so machine 1's core 3 draws as lane 15 on a 12-core hub. The Scheduler track is still one track; 255 lanes is its ceiling and a slice past it is dropped and counted.

Clocks: trust the fast round trips

A probe is the NTP exchange: t1 as the hub sends, t2 and t3 as the remote receives and answers, t4 as the hub receives. Offset and round trip fall out of the four, and the error of any single sample is at most half its round trip, under the one assumption that both legs took the same time. The method is about making that assumption true: keep a window, find the fastest round trip in it, and keep only the samples within 1.5× of it plus 30 µs of scheduler slack. Those are the probes that met no queue on either leg.

Four timestamps per probe; a window of probes filtered by round trip; a line fitted through the ones that met no queue ONE PROBE hub remote t1 t2 t3 t4 offset = ((t2 − t1) + (t3 − t4)) / 2 rtt = (t4 − t1) − (t3 − t2) |error| ≤ rtt / 2 THE WINDOW · offset against hub time hub time · last 120 s, up to 256 probes offset queued on one leg: long rtt, wrong offset, out within 1.5× the best rtt: in offset(t) = a + b·t · b is the rate, kept once the window spans 10 s
Offset and rate. With at least four clean probes over ten seconds the fit is a least-squares line, and its slope is the rate difference between the two crystals; a slope above a part per thousand is a bad window, not a rate, and is ignored. Before that the fit is the mean of the clean probes' offsets. Each remote event is moved at ingest by the offset evaluated at its own time.

The fit does what the plan said and no more. It is applied once, at ingest; events already in the ring are not moved when a later probe refines it. The raw probes are not yet written into the bundle, so a saved capture cannot be re-fitted offline; that is one of the deferred items. What the unit test proves is the estimator: a remote 12.345 ms ahead and 30 ppm fast, probed through jitter that queues one leg in four for up to 2 ms, comes back with the rate within 3 ppm and a mid-window conversion within 60 µs.

What the e2e measured is the other half, the one with a real socket in it. Two services on one Windows box, debug build, Windows loopback: the true offset is zero, so every microsecond the fit reports is error.

RunBest round tripOffset the fit reportedShift of 20 spans crossing the link
1566 µs46 µs−80 µs
2841 µs145 µs−75 µs
3411 µs83 µs−87 µs

Every shift is inside the half-round-trip band the fit itself promises, and every one is under 90 µs. It is also plainly a debug build on a loopback that is slow by Unix standards; the plan expected 20–60 µs round trips on one box and a wired LAN at 100–300 µs, and those measurements are still to be taken with a release build and two machines. The viewer does not yet draw the band; GET /api/peers reports it, per link, as the best round trip and the probe count, and the manual says to read it before trusting a cross-machine delta.

The data path at ingest

A remote's broadcast, filtered by frame type, crosses the link, is decoded, rewritten in four ways and pushed into the hub's ring REMOTEHUB capture looppush_events broadcast(as to a browser) type byte6 of 10 pass link decode_allLiveFrame rewrite, per event pid, tid ← machine << 24 | id name_id ← map[remote id] core ← core + base start_ns ← to_hub(start_ns) push_events → ring, every browser strings and names take the same path: intern into the hub's table, record the id map, process name + " @host" · the remote never decodes, re-encodes or stamps anything dropped and counted: ids over 24 bits, cores over 255, frames that will not decode
Four rewrites and a push. Everything the hub does is in one function, Ingest::frame, on the already-decoded frame; the batch then enters the ring by the same push_events the hub's own capture loop uses, so every connected browser sees a remote's events exactly as it sees local ones.

Record fans out

The hub's process list is its own list plus every link's, each remote entry under its packed pid and named name @host, with a machine field for anything that wants to group. Pick one and press Record: the start handler reads the top byte, sends that machine start with the real pid, sends every other link start with pid 0, and starts its own capture with no target. Pick a local process and every link gets pid 0. Stop goes to every link. A capture with no target is what it has always been, the scheduler, the service, and every process on that machine that instruments itself, so a remote that was not chosen still contributes its manual scopes and its core lanes.

The hub remembers the packed pid as the capture's target, which is what makes the viewer pin that remote process's track the way it pins a local target. Hook lists are not sent across: a remote target captures with the default settings, and /api/symbols/load or a function search on a packed pid fails with a plain error, because the symbols live on the machine that owns the process and the proxying the plan describes is not built.

In the viewer

The rail already drew a machine tree per MachineId for the demo's fake remote. The enum gained Peer(u8), from_pid reads the top byte, and the header that read MACHINE REMOTE now looks for a process of that machine whose name ends in @host and prints the host; the hub's own rail prints the service's machine name from /api/status. That is the whole viewer change, and the wasm pack was rebuilt with it. There is no Machines panel yet: the links are made from the command line or POST /api/peers/connect, and their state is read from GET /api/peers.

How to use it

On the machine you will watch from, the hub, start the service as usual and open its page. On every other machine:

orbit-service --join <hub-host>:44766

Or, from the hub, reach out to machines it can see:

orbit-service --connect render-01,build-mac:44766
curl -X POST http://127.0.0.1:44766/api/peers/connect -d '{"target":"ci-runner"}'

The remotes' processes appear in the hub's process list as name @host. Record on the hub records everywhere; GET /api/peers shows each link's offset, rate, best round trip and probe count.

What is not there

Against post 22, these are the parts that stay in the plan, so a reader of that post knows what to expect. No ssh transport and no install-on-demand: a remote needs a running service and a reachable port in one direction. No token on the API: linking a machine is as open as the API was, which the manual says in so many words. No Machines panel in the viewer, no alignment band drawn on the rail, no amber lag glyph. Reports, trees, function search, disassembly and hooks on a remote process are not proxied; the timeline, the names and the manual scopes are what cross. The bundle gets the packed pids and the @host names but no machines entry and no clocks table, so a saved capture cannot be re-fitted. One Scheduler track with the remotes' cores appended, not a Scheduler per machine. And the numbers above are one box, debug build, loopback: the LAN and WAN rows of the plan's precision table are still expectations.

How it is checked

Nine unit tests in peers.rs: the pid packing and its guards, the probe arithmetic, the fit through queued probes, the offset-only fit, target parsing, the frame filter, the machine table's indices and core lanes, the ingest of a remote's frames onto the hub's clock, and two services linked by in-process channels, hello to fit to start to stop. One e2e, multi_machine_smoke.py, with two real services on two ports: the link comes up with a fit, twenty spans posted to the remote's /api/events land on the hub under machine 1 with their stamps moved by less than the band, OrbitTestRust started beside the remote appears in the hub's list as OrbitTestRust.exe @WINBOX, a Record on the hub for that pid starts the remote and streams 10,374 of its scopes back under packed thread ids, and Stop stops both. On Windows the remote and its target get a scope directory of their own, so the hub can only see the target through the link. The smoke runs on the Windows, macOS and Linux workflows.