Persistent sessions
Keep a display connection open and a bounded history of raw frames, so later requests do not re-capture the screen.
Every standalone command opens a display, does its work, and exits. That is the right shape for one capture and the wrong shape for a loop, where the connection handshake is paid again on every frame.
A session holds the display open and keeps a bounded history of recent raw frames, so retrieval and comparison become local memory operations.
Starting, using, and closing a session
eensh serve --socket /run/user/1000/eensh.sock &
SESSION=$(eensh session create --display :1 --json | jq -r .session_id)
eensh session capture $SESSION --json --base64 # frame 1, touches X11
eensh session capture $SESSION --json # frame 2, touches X11
eensh session latest $SESSION --json # frame 2, no capture
eensh session frame $SESSION 1 --json # frame 1, no capture
eensh session diff $SESSION 1 2 --json # compares two retained frames
eensh session observe $SESSION --json --base64 # observe through the session
eensh session list --json
eensh session info $SESSION --json
eensh session close $SESSION --jsonCapturing a new frame, or reading one already held
These are deliberately distinct. Conflating them would make freshness unpredictable, which is the one property a caller most needs to rely on:
| Command | Touches X11 | Returns |
|---|---|---|
| session capture | yes | A new frame, with a new ID, appended to history |
| session latest | no | The newest frame already retained |
| session frame ID | no | That specific retained frame, or frame_not_available |
If you want the newest frame and it must be from the display, use capture. If you want what you already have, use latest and save a round trip.
Frame IDs, the history limit, and eviction
Every capture receives a monotonically increasing ID starting at 1. History keeps the most recent --history N frames — default 8, maximum 256 — and older frames are evicted.
Asking for an evicted frame fails explicitly with frame_not_available (exit 20) rather than silently substituting a different one. A silent substitution would be a correctness bug in any caller that trusted the ID it asked for.
What two requests on one session may do at the same time
Within one session, a capture arriving during an observation is serialized, not refused: it is served at the next sample boundary. Only the temporal operations are mutually exclusive.
A second observe while one is running is refused immediately with session_busy (exit 18), in either direction. A queued observation would be stale before it started, so it is refused rather than parked — and the refusal is immediate, with no waiting on a lock.
close during an observation is refused with the same code, and the session is left exactly as it was. The alternative would be cancelling a running state machine, and an explicit refusal is easier to reason about than a silent cancellation.
Different sessions never contend. There is no global lock, so an observation in one session does not block a capture in another.
What a session actually saves, measured
It is worth being honest here, because the saving is smaller than the name suggests. Measured over 20 captures at 640×480 on Xvfb:
| Path | Mean | Total |
|---|---|---|
| Standalone, a process per capture | 15.7 ms | 313 ms |
| Persistent session | 14.4 ms | 288 ms |
Both paths still pay for a client process on every capture, because the CLI is one process per invocation. A session removes only the X11 connection setup, which is a small part of a round trip dominated by process start and the capture itself. A caller speaking the protocol directly — see the Rust client — avoids the process start too, and sees a larger difference.
What a session delivers without qualification is the second half:
| Operation | Mean at 640×480 | Touches X11 |
|---|---|---|
| session diff | 9.0 ms | no |
| session latest | 38.1 ms | no, but still encodes what it returns |
| session capture | 46.0 ms | yes |
What the retained history costs in memory
History trades memory for it. A raw frame is three bytes per pixel, so 1920×1080 is about 6.2 MB, and a default eight-frame history is about 50 MB per session. It is bounded by capacity, so the cost is predictable rather than open-ended.
Where the socket lives
$EENSH_SOCKET first, then $XDG_RUNTIME_DIR/eensh.sock, then a user-scoped temporary path. It is created 0600 and removed on clean shutdown.
A stale socket left by a crashed service is reclaimed on the next start. A pre-existing file that is not a socket is refused rather than overwritten — deleting a file named as a socket is a good way to lose something you wanted.
Next: what a comparison actually computes — comparing two moments.