In practice6 min read

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

bash
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 --json

Capturing 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:

CommandTouches X11Returns
session captureyesA new frame, with a new ID, appended to history
session latestnoThe newest frame already retained
session frame IDnoThat 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:

PathMeanTotal
Standalone, a process per capture15.7 ms313 ms
Persistent session14.4 ms288 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:

OperationMean at 640×480Touches X11
session diff9.0 msno
session latest38.1 msno, but still encodes what it returns
session capture46.0 msyes

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.

eensh ping --json
{"protocol_version": 1, "sessions": 2, "uptime_ms": 48213}

Next: what a comparison actually computes — comparing two moments.