Session lifecycle¶
A ryll session has to shut down without losing buffered work, and survive a dropped connection without leaking the threads and sockets of the attempt it replaced. This page covers the graceful shutdown path and the reconnection machinery: what is recreated, what survives, how superseded attempts are cancelled, and when reconnection happens automatically rather than through a modal.
Graceful Shutdown¶
Ryll installs a SIGINT handler (via libc::signal) in main.rs that sets a
global AtomicBool flag (SHUTDOWN_REQUESTED). This allows Ctrl+C to trigger
a clean shutdown instead of killing the process immediately.
- GUI mode:
RyllApp::logicinryll/src/app/checks the flag on every pass and callsctx.send_viewport_cmd(ViewportCommand::Close)when set, which lets eframe run its normal teardown path and finalize the capture session. eframe callslogiceven while the window is hidden, so Ctrl+C works on a minimised window too. - Headless mode: The tokio
select!loop polls the flag alongside channel events and breaks out cleanly when shutdown is requested. However the loop ends,run_headlessthen winds the connection task down and joins it (aborting it if it has not stopped within two seconds), so a connection error is always logged and returned, and a Ctrl+C shutdown still exits 0.
Unbuffered capture I/O¶
The pcap channel writers (PcapChannelWriter in capture.rs) write directly
to File without BufWriter. This means every packet is persisted to disk
immediately, so pcap data is never lost if the process is interrupted by
SIGINT or any other signal.
These writes happen on a dedicated pcap writer thread, fed
by a bounded channel from the channel handlers. The hot path on each channel
is a non-blocking try_send; queue-full means the packet is dropped and
the per-channel writer_dropped_count counter is bumped. Slow disk no
longer back-pressures the SPICE socket.
The MP4 video writer also uses unbuffered File I/O. The encoder runs
on a dedicated thread and the egui frame loop enqueues frames via
try_send. MP4 finalisation (write_end for the moov atom) runs on
that thread after the sender drops. Every exit path in main (GUI,
headless and web) calls CaptureSession::close_and_wait, which blocks
until both writer threads have drained, so a clean exit (window close
or SIGINT) leaves a playable MP4. The wait gives up after five seconds,
so a wedged encoder or a stalled disk cannot stop the process exiting,
at the cost of that MP4. A process that dies without returning through
main (SIGKILL, a crash) can also leave it unfinalised. See
the troubleshooting guide for the trade-off.
Reconnection¶
When the SPICE main channel closes or any secondary channel
reports an unrecoverable error, ryll surfaces a "Disconnected"
dialog with two buttons: Close and Reconnect. The Reconnect
path is implemented in
RyllApp::reconnect (ryll/src/app/) and is a user gesture
— ryll never auto-reconnects.
What is recreated¶
Every reconnect allocates a fresh copy of the per-session machinery. This is what makes a reconnect equivalent to a clean session against the same target rather than a "resume":
- All five mpsc channels (
event,input,usb,webdav,resize). - A new
tokio::runtime::Runtimeinside a freshly spawnedstd::thread::spawn, with its own repaint-bridge task. - A new
Arc<Notify>for repaint wake-ups, a newByteCounter, newTrafficBuffers, newChannelSnapshots, a newBandwidthTracker, and a newVolumeControl.
Per-session UI state is reset in place: surfaces, cursor position / visibility / image / texture, the cached surface rectangle, statistics, last-cadence-key timestamp, mouse mode, mouse-button state, modifier state, last-sent resize, pending resize, USB connection state (channel-ready, connecting / disconnecting flags, error message, device description, connected-at), WebDAV connection state (channel-ready, shared-dir, sharing flag, connected-at, error message), and the disconnect dialog itself.
What survives¶
A reader investigating "did my settings carry over?" wants this list first. The reconnect path does not touch:
- The parsed CLI configuration (target host, port, TLS, monitor count).
- The configured virtual-disk list and the configured
shared folder. Both are stashed in
RyllApp::reconnect_virtual_disksandreconnect_share_dirat construction so they survive the reset. - The paste-as-keystrokes toggle and inter-character delay.
- The "Obey guest size hints" toggle. It is a
session-level preference (set via the hamburger menu
or
--no-obey-guest-size) and is not touched by the reconnect path, so a reconnect inherits whatever value the user last left. - The in-app notification store (history of past
notifications). The store is an
Arc<Mutex<…>>and the sameArcis handed to the new connection. - The egui
Context, which means window position and size, dock layouts, and any open side panels survive the reconnect — the Reconnect button feels like the same window resuming, not a new one. - The active capture session, if any.
Anything not in either list above is unintentional and
should be considered a documentation bug; cross-check
against RyllApp::reconnect if in doubt.
Threading and runtime lifecycle¶
Each reconnect spawns a fresh OS thread with a fresh
tokio::runtime::Runtime. The previous attempt is
explicitly cancelled before the new thread spawns:
RyllApp holds an Arc<AtomicBool> per attempt
(connection_cancel); reconnect() raises the previous
flag via prev.store(true, Ordering::Relaxed), allocates
a fresh flag, and passes it to the new run_connection.
Inside run_connection, a small cancel-watcher task
polls the flag every 100 ms and calls abort() on the
AbortHandle of every channel JoinHandle once the flag
is set. The wait loop sees the channel tasks complete
with cancelled JoinErrors, returns from
run_connection, the spawned thread's block_on
returns, and the tokio runtime is dropped. End to end,
a superseded attempt exits within roughly 100 ms of the
flag being raised, regardless of whether the underlying
TCP socket is responsive — well inside human reaction
time, so spamming Reconnect no longer accumulates threads
or sockets.
This mirrors the cooperative-cancel pattern used for
SHUTDOWN_REQUESTED (the global SIGINT flag). Both
signals share the same 100 ms poll cadence; both rely on
the runtime drop to release the underlying sockets.
Auto-reconnect with backoff¶
When a critical channel (Main, Display, Inputs) goes down
mid-session, ryll attempts to recover transparently rather
than presenting a modal immediately. The ReconnectState
enum (ryll/src/app/reconnect.rs), held by RyllApp, drives
the flow:
Idle— connected normally, or never disconnected.Pending { attempt, next_at, latest_error }— retry scheduled.attempt∈ 1..=3;next_atis the wall time the nextreconnect()call should fire.Modal(ModalVariant)— auto-retry has given up and the user has to intervene. The variant determines copy and available buttons.
Backoff is [1s, 4s, 16s] — short first attempt for blip
recovery, longer windows for server restarts; worst-case
~21 s before the modal pops. After three failures the state
machine lands in Modal(Generic { latest_error }). The
latest_error field carries the most recent attempt's
failure string into the modal body.
A last_modal_at: Option<Instant> field tracks when the
modal last opened; further disconnects within 5 minutes
skip the retry budget and go straight back to Modal — a
flapping server cannot make ryll bang away forever. The
manual Reconnect button clears last_modal_at so a
user-initiated retry re-arms the full 3-attempt budget.
The transition function ReconnectState::on_disconnect()
is pure — it takes the current state, an awaiting_outcome
bool, the cluster-reset timestamp, the wall clock, a
ReconnectPolicy, and the latest error string, and returns
the next state (or None if the event should be ignored as
a channel-storm duplicate). Side effects — pushing
notifications, bumping auto_reconnect_count, writing the
disconnect snapshot — live at the call site in
RyllApp::handle_critical_disconnect. This keeps the state
machine unit-testable without spinning up the full app.
The awaiting_outcome flag distinguishes "the in-flight
reconnect attempt just failed" (advance the attempt
counter) from "another channel in the storm just
disconnected" (no-op). It is set to true when the
GUI-tick poll calls reconnect() from a Pending state
and cleared on the next event (success or failure).
Connection attempts that fail before a session exists¶
run_connection returns an error only when it could not set a session
up: the dial, TLS, link or authentication failed or timed out (each
stage is bounded, see DIAL_TIMEOUT and HANDSHAKE_TIMEOUT in
shakenfist-spice-protocol/src/client.rs), or a secondary channel
would not open. Once a session exists, channel failures arrive as
ChannelEvent::Error instead. The GUI's connection thread sends that
error to the UI on a per-attempt oneshot, connection_failure_rx,
which reconnect() replaces just as it replaces event_rx. A
superseded attempt's failure therefore goes nowhere.
process_events acts on the failure only once event_rx is empty.
The failure travels on its own channel, so it can arrive before events
the attempt sent ahead of it, and a SessionInitialized among those
decides how the failure is handled.
RyllApp::handle_connection_failed then routes the failure in one of
two ways:
- If a session was established, or the attempt was an auto-retry, the
failure goes to
handle_critical_disconnectlike any other disconnect. The ticket policy and the retry budget apply. - Otherwise this was a first attempt or a manual Reconnect that never
reached a session, so it lands in
Modal(ConnectFailed). It does not retry in the background. Withdelete-this-file=1it also does not claim that a session which never existed consumed the ticket. These failures are usually a mistyped port or a server that is down, and there is no traffic to capture. They therefore write a disconnect snapshot only when--bug-report-diror--captureis set, whereas the first branch always writes one, as any disconnect does.
Modal variants and console.vv ticket keys¶
The Modal variant carries one of four discriminants:
Generic { latest_error }— auto-reconnect budget exhausted on a reusable ticket. Buttons: Reconnect, Close.OneShotConsumed— the .vv file setdelete-this-file=1, which ryll interprets as a single-use ticket signal. The first link consumed the ticket; auto-reconnect skipsPendingentirely. Buttons: Close only.TicketExpired { expired_at }— the .vv file setticket-valid-until=<unix-ts>and that wall time has passed. Auto-reconnect is suppressed and the modal shows the expiry time. Buttons: Close only.ConnectFailed { error }— a first attempt or a manual Reconnect failed before it reached a session (see above). Buttons: Reconnect, Close, whatever the ticket policy says, since nothing was consumed that ryll knows of.
ReconnectPolicy::forbid_retry(now_wall) consults the two
ticket-related Config fields and returns the appropriate
ModalVariant when retry would be doomed. The state-machine
transition consults it first, so a single-use ticket
disconnects directly to Modal without burning the budget.
The GUI tick also re-checks the policy at every Pending
fire — a long Pending window can outlive
ticket-valid-until, and there is no point firing a
reconnect we know the server will reject. A pre-expiry
notification ("Session ticket expires in 30 seconds.")
fires once at T-30s, latched via ticket_expiry_warned.
The non-spec delete-this-file=1 interpretation and the new
ticket-valid-until extension key are documented for
producers (Kerbside, oVirt, custom gateways) in
console-vv-extensions.md
in the kerbside-wt-docs repository.
auto_reconnect_count: u32 on AppSnapshot (serialized into
the bug-report session.json) increments on every entry into
Pending, so a future zip shows how rocky the session was.