# Live sessions and `mime.live/1` A live session runs a built experiment while you watch it: it steps the run in chunks of one fixed size, hands each chunk's state to its subscribers as a frame, takes the controller's live inputs between ticks, and records the stage on request. In a script or a notebook it is `built.live()`; in a process of its own, for a viewer such as MICROROBOTICA, it is `mime serve`, which speaks the protocol below. ## In a script ```python from mime import Experiment from mime.units import ms built = Experiment.load("a0_saved").build() session = built.live(chunk=ms(16.5), pace=1.0, out="a0_live") # 33 base steps a chunk, at real time session.subscribe(lambda frame: print(frame.step, frame.values["umr.pose"][:3])) session.step(10) # ten chunks now, on this thread session.start() # or step on a thread of its own, paced, until pause() or stop() session.pause() session.record(True) # the recording layer, from the state as it stands session.step(60) session.stop() # the run folder: a0_live/ ``` - **One chunk size for the session.** Each new length of a compiled run compiles again, so a session keeps one: about sixty chunks a simulated second when `chunk=` is not given, rounded to whole base steps, and a whole number of control periods when a controller is called back. - **Frames.** A frame is the state after a chunk: its number in the session (from zero), the base steps taken, the time, and the fields its schema names (`select=`, `.`; by default every pose and vector the stage binding moves, every per-step output of an observation, and every flag). Frame zero is the state the session starts from. A frame's values are the run's own, in its own float type: a batch run of the same steps gives the same bits. - **Pace.** `pace=` is a factor of real time (the default holds the loop to real time), and `pace=None` runs it as fast as it runs. A session that falls more than a chunk behind its pace (its first chunk compiles; a slow machine) takes the time it is at as its new start, rather than running ahead to make the time up. So pacing guarantees one thing on every machine, which a test holds: no frame is ever ahead of real time. Keeping up with real time is the machine's, measured by `scripts/live_pace_benchmark.py` and never a CI check: on a laptop's i7-12850HX (JAX on its CPU), timed from the first frame after the compile, A0 through `mime serve` ran at 3.59 times real time unpaced and at 1.005 paced at real time. A slow subscriber is its own concern: the session never waits on one. - **Live inputs are the controller's only.** A controller declares them beside its reads and writes (`Controller.live`, each a `LiveInput` with its units, shape, meaning and `default`, the value at which it changes nothing the step does: zeros unless declared); in the callback mode, `session.set(label, value)` hands one to its step under its label from the next tick on. A session hands every live input to the step from its first tick, at its default until it is set, so that a step compiled once (a `jax.jit`, as `ArmTracking`'s is) compiles at the session's first chunk with every input it will read, each a float of its declared shape, and **no `set` compiles it again**. A session that sets nothing is a batch run (which calls the step with its reads alone) bit for bit, as a test holds for A0. A physical setting is never a live input: changing one is a rebuild, a new session from the saved folder. `ArmTracking` declares `offset`, which moves the point it holds the magnet above along its axis. - **Every command is logged with its step** (`commands.jsonl` in the run folder), so a session replays: `mime run --commands /commands.jsonl --out ` steps the same chunks, sets the same inputs and records at the same steps, and writes the same run folder. The log is written as the session goes: its header when the session opens, each command before it acts, and after each chunk the step it reached (`{"reached": }`, before that chunk's frame is sent), each line flushed. A session that dies (a crash, a kill) leaves a log that replays to the last step it reached, every frame a client received included. `--out` is refused when it is the folder the session or the replay reads, inside it, or holds it (`run.out-over-source`). - **Every session and every run starts from the declared inputs.** A live input lasts as long as its session: it is cleared when a session opens and when it stops, and a batch run of the same built starts with none set. - **Stop always stops**, in every state: ready, running, paused, or taking a step (on another thread, or in `mime serve`'s loop), within one chunk. It sends a last frame marked final and writes the run folder. `pause` ends a step in hand after its chunk, as it stops a running session. - **A chunk in flight holds back only what changes the run.** A command that changes what a run does (`set`, `record`, `pace`, `start`, `step`, `pause`, `stop`) waits for the chunk running on another thread and acts at its end; `status()` and `hello()` answer at once, the status's `activity` saying what the session is doing: `compiling` while its first chunk runs (which compiles the run's programs, unless this process compiled them for an earlier session of the same built experiment), `stepping` while a later one runs, `idle` between chunks, and its `step` the one the chunk started at. ## The run folder Every live session that is given `out=` writes a minimal run folder when it stops (`mime serve` always does): | File | What it holds | |---|---| | `experiment/` | the saved folder the session was loaded from, copied as it is (or the built experiment saved) | | `commands.jsonl` | its header (the protocol, the chunk, the experiment's input hash), then one line per command with its step and one per chunk with the step it reached, written as the session goes | | `outputs.npz`, `outputs.json` | the state at the end and its manifest, as `RunResult.read` reads them | | `run.json` | the session's record: steps, frames, wall time, the environment | | `topology.json` | the built graph in MIME's names (`built.topology()`) | | `report.json` | the build report's summary (`built.report_summary()`, below), as `RunResult.read` reads it | | `recording.usdc` | the recording layer, when recording was on ([recordings](recordings.md)) | `mime run`'s run folders (`run_to`) hold `report.json` too. A run folder is self-contained: it carries its own stage, and nothing outside it. A folder saved by an earlier stage writer (before chains' frames, visuals and vectors were drawn) loads and verifies as it was written, and a live session of one whose stage lacks a prim the binding moves is refused (`live.stage-writer`): save it again with this MIME. ## `mime serve` ```sh mime serve a0_saved # commands on tcp://127.0.0.1:5555, frames on tcp://127.0.0.1:5556 mime serve a0_saved --commands tcp://127.0.0.1:0 --frames tcp://127.0.0.1:0 --ready-file ready.json mime serve a0_saved --start --unpaced --out runs/a0_live mime serve a0_saved --idle-timeout 15min --max-wall 2h --watch umr.pose[0:3] ``` It loads and builds the saved folder, opens its session and serves it until a `stop` command, SIGTERM or SIGINT, all of which stop it cleanly and write its run folder. **It does not outlive the process that launched it**: when that process is gone (a viewer that crashed, a test runner killed), the session stops as on SIGTERM, writes its run folder and the server exits. On Linux the kernel signals it (`PR_SET_PDEATHSIG`); on every platform the loop also looks between chunks at whether its launcher is alive (on POSIX its parent's pid changes when it is adopted; on Windows the launcher's process handle is signalled). `--outlive-launcher` serves on regardless, for a server started to run detached (with `nohup`, say). **It binds to localhost.** A session has no authentication of its own: reach a remote one through an SSH tunnel, which authenticates and encrypts; `--remote` binds another address all the same. ```sh ssh -L 5555:localhost:5555 -L 5556:localhost:5556 # then connect to localhost as if it ran here ``` ### The ready file `--ready-file ` is how a program that starts `mime serve` learns where to connect, whatever the server prints: it is written as one JSON object once the sockets listen. Its format is part of `mime.live/1` and follows the protocol's rule below, that fields may be added and a reader ignores what it does not know. | Key | What it holds | |---|---| | `protocol` | `mime.live/1` | | `commands` | the endpoint the commands are sent to, `tcp://:` (the port that was bound, when any free one was asked for) | | `frames` | the endpoint the frames are received on | | `out` | the run folder the session writes when it stops | **A key that is absent means what it meant before it existed.** The transport's security is the first: an absent `mechanism` means NULL, the mechanism a session speaks today (no authentication; reach a remote one through an SSH tunnel). Two keys are **reserved and not written yet**, for a later session that serves with CurveZMQ, where the server proves who it is and a client is known by its key: `mechanism`, which is `curve` there, and `server_key`, the server's long-term public key as Z85 text of 40 characters (as `zmq_curve_keypair` writes it and a libzmq client takes it), present only when `mechanism` is `curve`. A client that finds a `mechanism` it cannot speak refuses; it never falls back to connecting without the security the server asked for. The key travels in this file, or in a client's own settings for an address it was given by hand, and never in `hello`, which goes over the connection the key secures. ### What the terminal shows On a terminal, `mime serve` draws a live view on standard error (anywhere else it prints what it always printed: the load report, the line when it listens and the line when it stops, on standard output, byte for byte; `--plain`, `NO_COLOR` and `TERM=dumb` ask for the same). It is a strip of a few lines: the state in a word (`READY`, `COMPILING`, `RUNNING`, `PAUSED`, `STOPPING`), the step, the simulated time against the wall time, the speed as a multiple of real time and the frames a second (over the last few seconds), the clients, whether it is recording and the flags seen raised, and how to stop. Above it the scrollback keeps an event for each client that connects or leaves and each command with what came of it (a connection that is refused before its handshake is counted and said once with the others, not one by one). It is drawn about four times a second whatever the frame rate: the loop that answers clients only stores into a plain record of the session's state, and the view reads it. **A terminal that is not read never keeps the server from stopping**: serve writes to it through a writer of its own that waits a quarter of a second for it once and then not at all, and drops what the terminal does not take (the view says how many writes it dropped, if the terminal takes that), so `--max-wall`, `--idle-timeout`, SIGTERM and Ctrl+C each write the run folder and exit whatever the terminal does (Ctrl+S, a stalled ssh session, a launcher that never drains its pseudo-terminal). There are no keys; a client is how a session is steered (a command is logged and replays like any other). Under the strip is a table of values: by default each scalar output of an observation, each flag, and the position, the first three numbers of the pose, of each body the stage binds; at most twelve rows, fewer in a short window, the rest said in one line. `--watch ` (repeatable) names the rows instead: a field of the frames, with an index where it is not one number, `.[:]`, one integer or slice for each leading dimension of the field; `all` adds every scalar and three-number field; `none` hides the table. A name the frames do not carry is refused (`live.watch`) with the nearest name, and **`--watch` adds nothing to the frames and changes none**: they are what `--select` makes them. A row of one number shows its unit as the schema states it (or says it declares none), its value now, its **minimum and maximum over every frame the server published** (a frame is the state at a chunk's end, so these are not the extremes over steps), and the last ten seconds as a sparkline of the values drawn. A row of two or three numbers shows them and no extremes. At a narrow width the sparkline goes first, then the minimum and maximum, then the unit, and no number is ever wrapped. `--idle-timeout ` stops the session when no client has been connected for that long (counted from when it listens, and again from the last client to leave), and `--max-wall ` when its wall time reaches it (counted from when the session opened, as `wall_time` in the status is, so the loading and building before it are not). Each stops it as SIGTERM does, writing the run folder, and says why on standard output (`mime serve: no client has been connected for s (--idle-timeout); stopping`). A duration is written as `mime run`'s `--duration` is, with a unit or as seconds; one that is not above zero is refused (`live.timeout`). **Neither limit stops a session at the second**: as SIGTERM does, it finishes the chunk in hand and stops at its end, so the stop comes at the end of the chunk in hand, later than the limit by what is left of that chunk (the first chunk compiles, which can take many seconds, and a limit that comes while it is in hand is passed by them). A session that is not stepping (ready, paused) has no chunk in hand and stops at the limit. Both limits are for a session that nobody is watching: a forgotten one, or one on a machine that is billed by the second. **It never goes silent.** Each chunk runs on a thread of its own while the server's loop moves bytes and answers, so a client is answered while a chunk compiles or steps: - `hello` and `status` are answered at once, with the status as the chunk runs (`activity` `compiling` or `stepping`, `step` the chunk's start); - `stop` and `pause` are answered at once too, `ok` with the status as the chunk runs and the command in `pending`, and act at the chunk's end, as between chunks. A `stop` taken this way is *pending*, not done: its reply has `taken: true`, a `message` that says "stop pending", the command in `pending`, and no `folder` (the run folder does not exist yet; the reply of a `stop` between chunks, which has taken effect, has it). The session's last frame, marked final, or the closed connection says it has stopped; a `status` sent after that is never answered, since the server has exited; - every other command is answered at the chunk's end, where it acts. The commands taken while a chunk runs act at its end in the order they came, so the command log holds them in that order at the chunk's end step. Measured by `scripts/live_responsiveness.py` on a laptop's i7-12850HX (JAX on its CPU), A0 with its controller called back, three runs: while its first chunk compiled (3.3 s), the server answered 252 to 265 `status` requests, the median in 0.4 ms and the slowest in 82 to 97 ms, so within 0.1 s; the step after its first `set` took 3.5 to 3.9 ms, as any later step does. Earlier in development the first `set` compiled the controller again: in a script on the same laptop, A0's apparatus closed by `ArmTracking` took 1.9 s for the chunk after it. These are a machine's measurements, never a CI check; a test holds the server answering `status`, `hello` and `pause` while a first chunk is held open, and its control, a server stepping on its loop's own thread, answering none. The transport is ZMQ: a REP socket for commands and a PUB socket for frames. MIME speaks ZMQ's wire protocol itself (ZMTP with the NULL mechanism), so `mime serve` needs no extra package, and any ZMQ library connects to it as it would to libzmq's own sockets: a REQ (or a DEALER) for commands, a SUB for frames. `mime.live.LiveClient` is a Python client. ## The protocol: `mime.live/1` The messages are written independent of their transport: a WebSocket or a WebRTC data channel could carry them as they are. Within `mime.live/1` fields may be added (a new schema id; a client ignores what it does not know); any other change is a new major version. ### Requests and replies A request is a JSON object, `{"command": "", ...}` (with `"protocol": "mime.live/1"`, optional). A reply is a JSON object with `protocol`, `ok` and `command`; on success the session's status (`state`, `step`, `time`, `frames`, `pace`, `recording`, `non_finite`; `stepping`, the chunks a step in hand has still to take; `activity`, what the session is doing, `compiling`, `stepping` or `idle`; and `pending`, the commands taken while a chunk runs that act at its end, in order; and three optional keys below); on a refusal, `errors`, each in the shape of [MIME's errors](reading_errors.md): `code`, `class`, `message`, `why`, `action`, `where` and `page`. A request a client gets wrong is refused with its code (an argument of the wrong type included: `live.step`, `live.input-value`, `live.record`, `live.pace`), and the session goes on; an error of MIME's own inside a command is answered in the same shape, the session going on. The server never stops for one peer's bytes: a peer that does not speak ZMTP, does not finish its handshake in time, sends a message over its socket's limit, or sends requests without reading their replies is closed, and the others are served on (the limits are `mime.live._zmtp`'s constants). **Three optional keys of the status**, which `mime serve` always sends and a client that does not read them is unaffected by: | Key | What it holds | |---|---| | `wall_time` | the seconds since the session opened (the loading and building before it are not counted) | | `clients` | `{"commands": n, "frames": n}`: the requesters and the subscribers connected now, whose handshake finished | | `recent` | the last five commands the session took, oldest first, each `{"command", "arguments", "step"}`: its name, its arguments as the command log holds them and the step it took effect at | They carry **no address of a peer and no text of a peer's but what the session accepted**: `recent` is read from the session's command log, which holds only commands that were accepted, so a request that was refused is not in it, and a connection is a count. A session that has no server (`built.live()` in a script) has no `clients`. A watcher that renders the view from `status` and the frames shows what these keys let it, and no more than a server tells every client. | Command | Arguments | What it does | |---|---|---| | `hello` | | describes the session (below) | | `status` | | the session's status | | `start` | | starts or resumes stepping, paced | | `pause` | | stops stepping after the chunk in hand (taken while a chunk runs: answered at once, `pending`) | | `step` | `chunks`, a whole number above zero | steps that many chunks now, from a session that is not running; answered when they are stepped, or when a `stop` or `pause` from another client ends the step (the server takes other requests between its chunks) | | `stop` | | stops in every state, sends the last frame, writes the run folder (between chunks its reply holds the run folder's path; taken while a chunk runs it is answered at once as pending, with no `folder`: the final frame or the closed connection says it took effect) | | `set` | `values`, live inputs by label | sets the controller's live inputs from its next tick | | `record` | `on`, true or false | turns the recording layer on or off | | `pace` | `rtf`, a factor or null | the real-time factor, or unpaced | `hello` returns MIME's version, the experiment's name and its hashes, the base step and the chunk in base steps, the controller (its entry, mode, period and latency) and its live inputs (each with its units, shape, meaning and `default`), the build report's summary (`report`, below), the path of the stage the session serves (`stage`), what the asset cache draws of its visuals (`visuals`: the overlay to add over the stage, the visual sets it lacks with the command that fetches each, `fetch`, and every set's notice, its credit and terms, whether its meshes are drawn or not: see [recordings](recordings.md)), the stage binding (`binding`), the topology, the frame schema (`schema`), the commands, and the status. `stage` is the path on the machine that serves the session: a client on another machine opens its own copy of the saved folder (the one it gave `mime serve`, or a `mime pull` of it) and checks it is the same experiment by `hashes`, the lock's. ### Frames Each frame is one message on the PUB socket: binary by default, JSON with `--json-frames`. A binary frame is a header, then each field of the schema in its order, its bytes little-endian in the run's own type: ```text offset size field 0 4 magic "MIMF" 4 2 version, u16 (1) 6 2 flags, u16 (bit 0: the session's last frame) 8 4 schema id, u32 (the first four bytes of the sha256 of the schema's text) 12 4 frame number, u32 (from 0, one per frame sent: a gap is a dropped frame) 16 8 step, u64 (base steps taken) 24 8 time, f64 (seconds) 32 ... the fields, in the schema's order ``` The schema (in `hello`) lists each field: `path` (`.`), `node`, `field`, `shape`, `dtype` as NumPy spells it (`