RDP frontend phase 5: lifecycle and CI¶
Master plan: PLAN-rdp-frontend.md.
Planning effort: high. The master plan's guidance plans
phases 3 to 6 at medium effort "except that phase 5's reconnect
work should be planned at high effort if it touches the session
orchestrator". It does: Mikal chose SPICE auto-reconnect for
--rdp (decision 1), which no server mode has today, and
takeover needs the RDP server's connection handling rebuilt
(decision 5). Review steps 1, 3 and 4 at high effort too.
Prompt¶
Read the master plan's Prompt first; everything there applies. For this phase in particular:
- The RDP side is still IronRDP 0.13.0 (
ironrdp-server0.13.0,ironrdp-acceptor0.10.0).make fetchputs its source under.cargo-cache/registry/src/index.crates.io-*/in the worktree, and every IronRDP file and line cited below is relative toironrdp-server-0.13.0/there. Read it; never build or run anything from inside it. Re-check every IronRDP claim a brief relies on before relying on it. - The host's FreeRDP is 3.15.0 (
xfreerdp3). Debian 13, which the CI runners and the smoke image use, packages the same series; the lane prints the version it ran. - Builds run in Docker through the Makefile, never with a host toolchain. QEMU, Xvfb and FreeRDP for the smoke lane run in a container of their own (decision 7), never installed on the host by a step.
- The test guest for the lane is the UEFI latency guest
(
make test-qemu'stestdata/uefi-latency-guest.qcow2, 640 KiB): any keypress advances a fixed eight-colour cycle, one step of which is black (docs/development.md:477).
Scope¶
In scope:
- SPICE auto-reconnect in
--rdp: while the SPICE session is down the RDP listener and any connected client stay up, the client sees the last frame darkened, and ryll retries with the GUI's backoff and ticket rules; it exits as today once retrying is forbidden or exhausted. - Takeover: a new authenticated RDP connection to the same session disconnects the one being served (open question 4).
- RDP client disconnect and reconnect, which already works (phase 4's manual test), proved by the loopback test.
- Ctrl+C, which already shuts
--rdpdown cleanly (survey 4), kept working through this phase's restructure and proved with a client connected. - An
rdp-smokelane: FreeRDP under Xvfb against the UEFI latency guest, in CI as an advisory workflow and locally asmake rdp-smoke. - Documentation of the user-visible behaviour and of the lane.
Out of scope:
- SPICE reconnect in
--weband--headless, which leave a dead session up or exit today. The shared retry rules this phase moves (decision 3) make it cheaper later; recorded in Future work. - A text overlay explaining the outage (decision 2).
- RDP auto-reconnect cookies (master plan Future work).
- Joining the smoke tier, and turning the
rdpfeature on in release builds (open question 8). Both wait for the lane's flake record (decision 9). - The operator guide and parity column, which are phase 6's.
- mstsc: no Windows machine is available, as in phases 2 to 4.
What the survey found¶
Checked on 2026-10-26 against the tree at 9e9e82b and
ironrdp-server 0.13.0. The master plan's phase 5 section is
right about what is wanted and wrong about what exists: items
1, 2, 6 and 7 below correct it. This planning commit makes those
corrections at their source, in the master plan's phase 5
section and Execution row and in the docs/plans/index.md row,
so a later step need not.
Ryll side¶
- No server mode reconnects SPICE.
--rdpends when the SPICE connection task returns (ryll/src/rdp.rs:359-366): it stops the RDP server, which drops the client and closes the port.--webkeeps serving HTTP over a dead session (ryll/src/main.rs:931-990); its "reaper" (ryll/src/web/lifecycle.rs) reaps WebRTC bridges, not SPICE sessions, so there is no--webreconnect pattern to follow.--headlessexits. - The GUI's retry rules are GUI-only.
ryll/src/app/reconnect.rsholds the backoff (RECONNECT_BACKOFF_SECS = [1, 4, 16],:13), the ticket rules (ReconnectPolicy::forbid_retry,:95: single-use tickets andticket-valid-until), the five-minute flap rule and the pureReconnectState::on_disconnecttransition. All arepub(super)insidemod app, which is compiled only with theguifeature (ryll/src/main.rs:1-2), andModalVariantcarries GUI wording. - Each
run_connectioncall consumes its channels.rdp.rs:283movesevent_tx,session_state,input_rx,usb_rx,webdav_rx,resize_rx,virtual_disks,share_dirandplaybackinto the call. The RDP handles hold the other ends (SessionHandles.input_tx,resize_tx, andmouse_mode, a receiver of the firstSessionState's watch), so a second attempt needs per-attempt channels behind handles that stay fixed. The event bus, mirror, cursor relay,MemoryClipboardandAudioRelayare not consumed and can outlive an attempt. - Ctrl+C already works.
rdp.rs:270-274bridgesSHUTDOWN_REQUESTEDto the cancel flag, and theselect!at:359stops the RDP server first (server.shutdown(),:373, which sendsServerEvent::Quitand setsstoppingso no new client is accepted), joins it, then cancels the renderer and the control socket. What is missing is proof with a client connected, and keeping it working once the server is restructured. - The mirror does not reset between sessions.
SurfaceMirror::apply_eventignoresSessionInitialized(shakenfist-spice-renderer/src/surface_mirror.rs:480-493). A new session'sSurfaceCreatedfor the primary surface replaces it, but secondary surfaces of the old session would linger.
IronRDP side¶
RdpServer::runserves one connection at a time and does not watch the listener meanwhile. Its loopselect!s on the event channel andlistener.accept(), then awaitsself.run_connection(stream)inline (server.rs:936-990). A second client sits in the listen backlog (1024,server.rs:48) until the first leaves, so takeover cannot be built onrun.run_connectionis public (server.rs:719), takes anyAsyncRead + AsyncWritestream, and resets its own per-connection state, so a caller can own the listener.ConnectionHandler::on_acceptandon_disconnectedare called only byrun, not byrun_connection.- The display handler's
updates()is the first call after authentication in both security modes.client_loopcalls it first (server.rs:1236), after the acceptor has finished CredSSP (NLA) or the credential validator (TLS). Only the firstsize()comes before any credential;request_initial_size()and the encoder'ssize()follow authentication but precedeupdates(). (Corrected by step 1, which also found thatupdates()is called again after every reactivation, so only its first call marks authentication, and that input the acceptor queued reaches the input handler just before it.) This correctsSessionProvider's doc comment (shakenfist-spice-rdp/src/lib.rs:150-156), which says IronRDP offers no hook after CredSSP: there is none for credentials, butupdates()marks the moment. ServerEvent::Quitduring a connection ends that connection only. Indispatch_server_eventsit returnsRunState::Disconnect(server.rs:1102-1105); the server stops only whenrun's ownselect!receives it.- Each
RdpServerhas its own event channel. The clipboard and sound factories get its sender once (set_sender), so with one server per connection a message queued by a leaving connection's channel backend dies with that connection's server. That closes phase 4's stale-wave hazard (its Future work: a wave or clipboard message queued as one connection ends can drop a client waiting in the backlog), which takeover would otherwise have made common.
CI side¶
- No CI lane runs a QEMU guest.
web-smoke(tools/web-smoke.sh, run bybuild-linuxinci.yml:179-183and the merge tier's Linux leg) startsryll --webagainst a stub.vvpointing at127.0.0.1:1and checks only that it binds and exits on SIGTERM. The master plan's "modelled onweb-smoke... against the local QEMU test server" therefore asks for somethingweb-smokedoes not do. The nearest functional lane isproxmox-functional.yml: advisory, path-filtered, its own workflow, driven by a Python standard-library script whose oracles are ryll's log lines (tools/proxmox-smoke.py). - No build target makes an
rdpbinary.make buildandmake releaseleave therdpfeature off (Makefile:196-222); onlytestandlintturn it on. The lane needs a target that builds-p ryll --features rdp. - The devcontainer has no QEMU, OVMF, Xvfb or FreeRDP
(
.devcontainer/Dockerfile:27-46). Whether the self-hosted runners expose/dev/kvmto a container is unknown.
Nothing else in the master plan's section was found wrong.
Decisions¶
--rdpreconnects SPICE automatically (Mikal's choice at planning time). After a session has been established and then lost, ryll retries with the GUI's rules: backoff of 1, 4 and 16 seconds, no retry for a single-use ticket or pastticket-valid-until, and no retry budget for a session that lost its connection again within five minutes of exhausting one. When retrying is forbidden or exhausted,--rdpexits as it does today, with the last error. A first attempt that never reached a session also exits as today, matching the GUI'sConnectFailed, which does not retry either: it is usually a mistyped port.- During an outage the client keeps its connection and sees the last frame at half brightness. The display handler sends one whole-surface update of the darkened frame when the session goes down, and the next real frame replaces it. A frozen frame alone looks like a hang. A text overlay would need a font rasteriser in the RDP crate, which is not worth one message. Input and resize requests made during the outage are dropped and counted, not queued for a session that does not exist yet. The clipboard and audio relays outlive the attempt, so clipboard text set during the outage reaches the guest's agent once it reconnects.
- The retry rules move out of the GUI with no behaviour
change. The backoff, the ticket check and the flap rule
become a module outside
mod appthat both the GUI and--rdpcall. The GUI keeps its modal and its wording. The moved tests move unchanged, so a diff of the test bodies shows the GUI is untouched. - Takeover is per session, at authentication. A connection
takes over when its display handler's
updates()is first called (survey 7): that is after authentication in both security modes, and before the new client is sent any pixels. It takes over only the connection serving the sameSessionHandles(byArc::ptr_eq), so a provider serving several sessions does not let one user kick another. The old connection is sentServerEvent::Quit, its held keys are released as on any disconnect, and from the moment of takeover it no longer forwards input or takes damage from the mirror. This answers open question 4 as the master plan recommended. - One IronRDP server per connection, behind our own accept
loop. This is the decision a reviewer is most likely to
question, since it rebuilds phase 2's server.
RdpServer::runcannot accept a second client while one is connected (survey 6), so takeover is impossible on it. The alternative that keepsrun, disconnecting the current client whenever a new TCP connection arrives, would let anyone who can reach the port kick the user off without a password. Soshakenfist-spice-rdpbinds the listener itself (keeping the port fallback,SO_REUSEADDRand readiness behaviourservehas now), builds anRdpServerfor each accepted stream, and runs it withrun_connectionon the server thread'sLocalSet.ConnectionLog's work moves into that loop, becauserun_connectiondoes not call the connection handler (survey 6). Per-connection servers also give each connection its own event channel, which closes phase 4's stale-wave hazard (survey 9). - Bounded pre-authentication work. Besides the connection
being served, at most one unauthenticated connection runs its
handshake at a time. Others wait in the listen backlog,
unaccepted, as every client waits today. A connection that
has not reached
updates()within 30 seconds of accept is dropped. Without these, a stream of half-open connections would each hold a TLS handshake and an encoder. - The smoke lane is an advisory workflow of its own. A new
.github/workflows/rdp-smoke.ymlruns nightly againstdevelop, which builds the flake record, and onworkflow_dispatch. Itspull_requesttrigger is path-filtered to the workflow file alone, so it runs on the pull request that adds it and on later edits to the lane, but not on pull requests that touch the RDP code (Mikal's choice at the back brief, to keeplrunners free). A new workflow cannot be dispatched or scheduled until it is ondevelop, so without that trigger its first run would come after merge. It is advisory for the reasondocs/ci.mdgives forproxmox-functional.yml: a required check that is path-filtered never reports on other pull requests. It runs on[self-hosted, vm, debian-13-docker, l]and inside an image of its own (tools/rdp-smoke/Dockerfile: Debian 13 withqemu-system-x86,ovmf,freerdp3-x11,xvfb,xdotool,x11-appsforxwd, andpython3), so the build devcontainer stays lean. QEMU starts with-accel kvm -accel tcgso it works with or without/dev/kvm.make rdp-smokeruns the same image locally. - The lane's oracle is the guest's colour, seen by
FreeRDP. The driver,
tools/rdp-smoke.py, uses only the Python standard library, astools/proxmox-smoke.pydoes, and reads the screen withxwd. It checks, in order: - ryll authenticates FreeRDP (ryll's log line, with a fixed password from the driver);
- a frame arrives: the FreeRDP window's centre turns non-black within a deadline;
- input reaches the guest: an
xdotool keychanges the centre colour. This covers the whole path, client to ryll to SPICE to guest and back, so no ryll log line can make it pass; - client reconnect: FreeRDP is killed and restarted, and check 3 passes again;
- takeover: a second FreeRDP connects, the first exits, and check 3 passes through the second;
- SPICE reconnect: QEMU is stopped and restarted on the same port; the client stays connected, the centre goes dark, and check 3 passes again within the backoff budget;
- Ctrl+C: SIGINT to ryll with the client connected; ryll exits 0 within 5 seconds and FreeRDP is disconnected.
As in proxmox-smoke.py, each check prints a PASS or FAIL
summary line, and a failure caused by the environment (QEMU
not booting, Xvfb not starting) is reported as a harness
failure, not a ryll defect.
9. Joining the smoke tier waits for a flake record, and the
criterion is set now: fourteen consecutive green nightly
runs, with no rerun needed. Moving the lane into ci.yml's
smoke tier, and open question 8's release-build question,
are then proposed in a later pull request. Writing the
criterion down now stops "once it has a flake record" from
meaning whatever is convenient later.
10. No new command-line flags. The retry budget is the GUI's
and is not configurable. A user who wants --rdp to exit on
the first SPICE loss can use a single-use ticket. Add a flag
if an operator asks.
11. SessionHandles gains one field for decision 2: a
watch::Receiver<bool> saying whether the SPICE session is
up. It is filled at every construction site, as phase 4's
fields were. A library user with no reconnect passes a
receiver that is always true.
Step plan¶
Steps 1 and 2 touch different crates and could run in parallel,
but both run make test in the shared tree, so they run in
order. Step 3 needs step 1's server; step 4 needs steps 2 and
3; step 5 needs step 4. Each step is one commit, with the subject
given.
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 1 | high | opus | none | Takeover: our own accept loop, one RdpServer per connection, per-session takeover at updates(), bounded pre-auth work, Ctrl+C kept; loopback tests for reconnect and takeover. Commit: "Let a new RDP client take over the session." See brief 1. |
| 2 | medium | sonnet | none | Move the GUI's retry rules out of mod app with no behaviour change. Commit: "Share the SPICE retry rules beyond the GUI." See brief 2. |
| 3 | high | opus | none | SessionHandles outage field, and the display handler darkening the frame while SPICE is down. Needs step 1. Commit: "Darken the RDP client's frame while SPICE is down." See brief 3. |
| 4 | high | opus | none | --rdp SPICE auto-reconnect in ryll/src/rdp.rs: per-attempt channels behind fixed handles, mirror reset, the shared retry rules. Needs steps 2 and 3. Commit: "Reconnect SPICE under a connected RDP client." See brief 4. |
| 5 | high | opus | none | The rdp-smoke lane: image, driver, Makefile targets, workflow, docs/ci.md. Needs step 4. Commit: "Add an RDP smoke lane with FreeRDP." See brief 5. |
| 6 | - | - | - | Management session: make rdp-smoke ten times in a row, the lane's first CI run, and a short visual check with Mikal watching. Recorded in Results. Needs step 5. No commit of its own. See Step 6. |
| 7 | medium | sonnet | none | Documentation and the master plan. Needs step 6's results. Commit: "Document RDP takeover, reconnect and smoke lane." See brief 7. |
Every brief also carries the implement-phase rules: leave the
work uncommitted and report which files belong to the step; use
the scratch directory the management session names; run mutants
on copies under timeout, counting a timeout as killed, and
never restore with git checkout; run make test, make lint,
make check-windows and pre-commit run --all-files (after
git add -N on new files); no plan, phase or decision citations
in code or in docs outside docs/plans; report in under 400
words.
Brief 1: takeover¶
Files: shakenfist-spice-rdp/src/server.rs (serve,
serve_preferring, run_server, build_server,
ConnectionLog), src/display.rs (updates), src/input.rs,
src/lib.rs (SessionSlot, the SessionProvider doc comment),
tests/loopback.rs.
- Re-read
server.rs:900-990(run),:700-800(run_connection) and:1222-1240(client_loop) inironrdp-server0.13.0, and confirm survey items 6 to 9 before building on them. In particular, check whatrunsets thatrun_connectionrelies on (local_addr, the static channel set) and how the factories' senders are installed. - Bind the listener in
run_serverwith the optionsrunuses (SO_REUSEADDRon Unix, the same backlog), keepingserve's port fallback and its "ready once listening" contract, soRdpServerHandle::local_addr,shutdown,join,stoppedandis_finishedbehave as now. - For each accepted stream, build an
RdpServerwithbuild_server's handlers and runrun_connectionon the thread'sLocalSet.ConnectionLog's work (logging, client size reset, held-input release, emptying the slot in TLS mode, refusing while stopping) moves to the loop. Decide whether each connection gets its ownSessionSlot; with a provider serving several sessions, two connections may serve different ones. - Takeover per decision 4, bounds per decision 6. The old
connection must stop forwarding input and taking damage at the
moment of takeover, not when its
Quitis processed: use a per-session generation or an equivalent that the display and input handlers check. shutdown()sendsQuitto every live connection and stops the accept loop.- Fix the
SessionProviderdoc comment per survey 7. - Loopback tests, each with IronRDP's client as in
tests/loopback.rs: - a client disconnects, a second connects and is served;
- a second client with the right password takes over: the first sees its connection end, the second gets frames and its input arrives;
- a second client with the wrong password does not disturb the first, which still gets frames;
- a connection that never authenticates is dropped after the deadline (make the deadline injectable so the test is fast);
shutdown()with a client connected ends it andjoinreturns within its timeout.- Mutations, each killed by a named test: no takeover (the old
connection keeps running); takeover on accept rather than at
updates(); takeover ignoring which session it is; no pre-auth deadline; the old connection still forwarding input after takeover;shutdownnot reaching live connections.
Brief 2: shared retry rules¶
Files: ryll/src/app/reconnect.rs, a new module outside
mod app (for example ryll/src/reconnect_policy.rs, declared
in main.rs without a gui gate), and the GUI's call sites.
- Move
RECONNECT_BACKOFF_SECS,MAX_RECONNECT_ATTEMPTS, the ticket rule (ReconnectPolicyandforbid_retry) and the five-minute flap rule into the new module, returning a GUI-free reason (single-use ticket, ticket expired at T) that the GUI maps to itsModalVariant. LeaveReconnectState,ModalVariantand the modal wording inmod app. - The moved tests move with their bodies unchanged apart from paths; say so in the report and list any that had to change.
make buildmust still build the GUI, andmake lintandmake testpass with therdpfeature and withoutgui(cargo build --no-default-features -p ryllthrough the devcontainer, asdocs/development.mddescribes for the slim build).- Mutations: the backoff values; the single-use rule; the expiry rule. Each must be killed by a test in the new module.
Brief 3: outage frame¶
Files: shakenfist-spice-rdp/src/lib.rs (SessionHandles),
src/display.rs, every place a SessionHandles is built
(grep -rn 'SessionHandles {' --include=*.rs), including tests and
ryll/src/rdp.rs (pass an always-true receiver there for now;
step 4 wires it).
- Add the field per decision 11, documented in the struct's style.
- When it turns
false, the display handler sends one whole-surface bitmap of the current primary surface at half brightness, without changing the mirror. When it turnstrue, the next update is the whole surface, as after connect, so no darkened pixels survive. Handle a resize arriving with the new session, which phase 2's guest-driven resize path already turns intoDisplayUpdate::Resize. - Unit tests: darkening halves each channel and leaves alpha
alone; a
falseproduces exactly one darkened update;trueproduces a whole-surface update from the mirror; a client connecting during an outage is shown the darkened frame. - Mutations: no darkening; darkening written into the mirror; no whole-surface update on recovery.
Brief 4: SPICE auto-reconnect¶
Files: ryll/src/rdp.rs; the renderer's SurfaceMirror if it
needs a reset method; nothing in ryll/src/app/.
- Read
docs/session-lifecycle.md(Reconnection, Auto- reconnect with backoff, Connection attempts that fail before a session exists) and step 2's module first. - Restructure
run_rdpso a supervisor task owns the SPICE attempts. Each attempt gets freshinput,resize,usb,webdavchannels and a freshSessionState; long-lived relays forward the RDP handles'input_txandresize_txinto the current attempt, dropping and counting events while none is up, and forward each attempt's mouse-mode and agent watches into the receivers the RDP handles and the control socket hold. The event bus keeps onempscreceiver; each attempt gets a clone of its sender.virtual_disksandshare_dirare cloned per attempt. The clipboard, audio relay, cursor relay and mirror outlive attempts. - Before an attempt's session starts, reset the mirror so no surface of the old session survives (survey 5), and publish "down" then "up" on decision 11's watch at the right moments: down when the session is lost, up when the new session's first primary surface arrives.
- Retry per decision 1 using step 2's rules, logging each
attempt and its outcome. Exit as today when retrying is
forbidden or exhausted, or when the first attempt fails. The
control socket's
statusverb reports SPICE as down during an outage. - Ctrl+C during an outage or a backoff wait exits promptly.
- Make the supervisor testable without a SPICE server: inject the function that runs one attempt, and unit-test the sequence (lost, backoff, success; lost three times then exit; single-use ticket exits at once; first-attempt failure exits; Ctrl+C during a wait).
- Mutations: no mirror reset; input forwarded to a dead
attempt instead of dropped; the watch never returning to
true; retrying a single-use ticket; retrying after a first-attempt failure.
Brief 5: the smoke lane¶
Files: new tools/rdp-smoke/Dockerfile, tools/rdp-smoke.py,
.github/workflows/rdp-smoke.yml; Makefile; docs/ci.md.
- Read
tools/proxmox-smoke.py,tools/web-smoke.sh,.github/workflows/proxmox-functional.ymlanddocs/ci.md's The Proxmox lane and Build network isolation first, and follow their patterns: summary lines, harness-versus-ryll failures, oracles named next to the source line they must stay in step with, the workflow's comments explaining why it is advisory. - Makefile: a target that builds
ryllwith therdpfeature through the devcontainer offline, asreleasedoes; a target that builds the smoke image; andrdp-smoke, which runs the driver in the smoke image with the built binary, the guest image and the OVMF files mounted. Add them to.PHONYand the help text. - Driver checks per decision 8, in that order, with QEMU per
decision 7 (the guest from
make test-qemu's command line, SPICE on a port the driver picks, its own PID and QMP paths), ryll--rdp --rdp-port 0 --rdp-password <fixed> --direct, andxfreerdp3 /cert:ignoreunder Xvfb. The driver must clean up every process it started, on success and on failure, and must never leave QEMU running. - Workflow per decision 7: nightly at an hour the other
scheduled workflows do not use (check every
cron:under.github/workflows/),workflow_dispatch, andpull_requestfiltered to.github/workflows/rdp-smoke.ymlonly. Upload the driver's logs. Do not add the job toci.ymlor any gate. docs/ci.md: a section The RDP smoke lane beside The Proxmox lane, covering what it checks, why it is advisory, decision 9's criterion for joining the smoke tier, and a row in Workflow inventory.- Prove each check can fail: run the lane against a ryll built with a deliberate break (no input forwarding; no takeover; no SPICE retry) and confirm the right check fails. Record the commands and outcomes; mutated binaries are built from a copy of the tree.
Brief 7: documentation and the master plan¶
Files: docs/session-lifecycle.md, docs/configuration.md's
RDP section, shakenfist-spice-rdp/README.md if its usage
example builds SessionHandles, docs/plans/PLAN-rdp-frontend.md,
this plan.
docs/session-lifecycle.md: how--rdpbehaves when SPICE is lost (what the client sees, the retry rules shared with the GUI, when it exits), takeover, and Ctrl+C. Say that--weband--headlessdo not reconnect.docs/configuration.md: one paragraph on takeover and reconnect where the RDP flags are described.- Master plan: open question 4 gets an Answer (phase 5) citing Results; the phase 5 section notes what shipped.
- This plan: Results and Execution notes from the management session's records; tick the definition of done only for items the management session has checked.
Step 6: the management session's checks¶
make rdp-smoketen times in a row on the Kasm host, each run undertimeout. Record passes, failures and run times in Results. Any failure is diagnosed before the pull request opens.- The lane's first CI run on the pull request: link, result,
run time, whether the runner had
/dev/kvm. - With Mikal watching, FreeRDP against the desktop guest
(
make test-qemu-desktopwith its own PID, QMP and OVMF variables paths, as phase 4 did): stop and restart the guest's QEMU and confirm the darkened frame and the recovery; connect a second FreeRDP and confirm the takeover; reconnect. Record what he saw.
Risks and mitigations¶
| Risk | Mitigation |
|---|---|
run_connection relies on state run sets up, so a server that never calls run misbehaves subtly. |
Brief 1 re-reads both functions before building on them; the loopback tests cover connect, reconnect, takeover and shutdown; the management session reviews the diff against server.rs:900-990. |
| Two connections to one mirror split its damage, and the new client misses a region. | Takeover stops the old connection taking damage at once (decision 4), and a new connection starts with the whole surface; brief 1's mutation of "old connection still active" must be killed. |
| A relay misses a watch update after reconnect, leaving the RDP side in the wrong mouse mode. | Brief 4's relays forward every value; the smoke lane's check 6 exercises input after a SPICE reconnect; the management session checks a mouse click after reconnect in step 6. |
| Moving the retry rules changes the GUI's behaviour. | Moved tests keep their bodies (brief 2), and the management session diffs them. |
| The lane is flaky (Xvfb start-up, FreeRDP timing, TCG speed). | Generous deadlines, harness failures classified apart from ryll failures, ten local runs before the PR, and decision 9's criterion before it gates anything. |
The runners have no /dev/kvm and TCG makes the guest too slow. |
-accel kvm -accel tcg; the guest is a 640 KiB UEFI app. Step 6 records which the runner used and the run time. |
| Debian 13's FreeRDP differs from the host's 3.15.0. | The lane prints its FreeRDP version; step 6 compares. |
| A half-open connection flood exhausts the server. | Decision 6's bounds and the pre-auth deadline, with a loopback test. |
Definition of done¶
-
grep -n 'server.run()' shakenfist-spice-rdp/src/server.rsfinds nothing, andgrep -n 'run_connection'there finds the per-connection call. - The loopback tests for reconnect, takeover, a
wrong-password second client, the pre-auth deadline and
shutdown with a client connected pass under
make test. -
grep -rn 'RECONNECT_BACKOFF_SECS' ryll/srcfinds the constant outsideryll/src/app/, andcargo build --no-default-features -p ryll(through the devcontainer) compiles that module. -
ryll/src/rdp.rs's supervisor has unit tests for each sequence in brief 4, and they pass. - Every mutation listed in briefs 1 to 4 is killed, and every deliberate break in brief 5 fails the check it should; the scripts' paths and counts are in Execution notes.
-
make rdp-smokepassed ten times in a row on the Kasm host, recorded in Results. -
rdp-smoke.ymlran green on the pull request, with its link in Results, and is in no gate'sneeds:list (grep -n 'rdp' .github/workflows/ci.ymlfinds no job). -
docs/ci.mdlistsrdp-smoke.ymlin Workflow inventory and states decision 9's criterion. - Open question 4 has an answer citing Results.
-
make build,make test,make lint,make check-windows,make package-checkandpre-commit run --all-filespass. -
git grep -n -i 'phase 5\|decision [0-9]\|survey item' -- shakenfist-spice-rdp shakenfist-spice-renderer/src ryll/src tools .github docs ':!docs/plans'finds nothing.
Results¶
Local smoke runs, 2026-10-26 (AEDT), Kasm host with /dev/kvm.
make rdp-smoke ran ten times in a row, each under timeout
1200, at HEAD 9d178c0 with the image and build warm. All ten
passed 7/7, in 32 or 33 s each; the driver itself took 18.0 to
18.4 s. FreeRDP 3.15.0, QEMU 10.0.13 (Debian 13). A typical
run's notes: takeover 4.1 s, the old client exiting as the new
one authenticated; spice-reconnect 4.2 s, the centre darkening
from #960000 to #490000 0.6 s after QEMU stopped and the new
guest's frame arriving 1.4 s after it restarted, after one SPICE
attempt; Ctrl+C exiting 0 in 0.11 s. Nothing was left running
after the ten runs.
First CI run. rdp-smoke.yml on pull request #640 at
0d6061d, run
40379796107:
green, 7/7. The job waited about 33 minutes for an l runner
and then took 12 minutes, most of it the release build; the
driver ran 18.4 s. The runner had /dev/kvm (the guest booted
in 1.3 s under KVM), with the same FreeRDP 3.15.0 and QEMU
10.0.13 as the local runs. The frame darkened 0.6 s after QEMU
stopped, and the new guest's frame arrived 1.8 s after it
restarted.
Visual check with Mikal, 2026-10-27 (AEDT). FreeRDP 3 on
Mikal's desktop against ryll --rdp (port 3391) against the
XFCE desktop guest (make test-qemu-desktop with SPICE on 5931
and private PID, QMP and OVMF variables paths). He confirmed:
the desktop and input worked in client A; when the guest's QEMU
was stopped the frame went to half brightness and A stayed
connected; once QEMU was restarted, ryll's fourth attempt (after
the 1, 4 and 16 s waits, two refused) reconnected and the new
guest appeared at full brightness with input working; ryll
logged 221 input events dropped during the outage; a second
client B took over, A's window closed and B worked; after B was
closed cleanly a third client C connected and worked; SIGINT to
ryll with C connected stopped ryll in 0.10 s and closed C.
Execution notes¶
- Back brief, 2026-10-26 (AEDT). Mikal confirmed decisions 5
and 2, asked to be pinged for step 6's visual check, and gave
permission to push and open the pull request once the
definition of done passes. He chose a nightly-only lane over
running it on every pull request that touches the RDP code;
decision 7 and brief 5 are amended to match, keeping a
pull_requesttrigger on the workflow file alone so the lane can run before it merges. - Step 1 (Opus, high effort) corrected survey item 7 at its
source (above). It gave each connection its own
SessionSlot, so a client still authenticating cannot overwrite or clear the served client's session, and made takeover clear the old connection's slot, which its display and input handlers check on every use, rather than add a generation counter. The pre-auth deadline is a new publicRdpConfig::handshake_timeoutfield with an exportedDEFAULT_HANDSHAKE_TIMEOUTof 30 s. It also correcteddocs/configuration.md's "a second client waits" sentence, which takeover made false. Six mutants, all killed:/tmp/claude-1000/-srv-kasm-profiles-mikal-vscode-src-shakenfist-ryll/a49e0447-bf0e-486b-a6d9-a7d93594be3f/scratchpad/p05/step1/mutate.py(session scratchpad, not kept). - Step 2 (Sonnet, medium effort) put the rules in
ryll/src/reconnect_policy.rs:next_step()returns a retry delay or a reason to give up, and the GUI'sReconnectState::on_disconnectnow calls it. The management session checked the state machine case by case against the old code. Eight mutants, all killed (.../p05/step2/mutate.sh). - Step 3 (Opus, high effort) named the field
SessionHandles.spice_up. A closed watch keeps its last value, so a caller with no reconnect drops the sender of atruewatch. Recovery resets the stream on any change that ends attrue, so an outage too short for a poll to see still resends the whole surface. For step 4 it noted that a surface appearing in the mirror while the watch isfalseis sent darkened, so the mirror reset belongs just before the new session starts. Eight mutants, all killed (.../p05/step3/mutate.py). - Step 4 (Opus, high effort) gave each attempt its own
channels and connected them to the fixed ones the RDP server
and control socket hold through a
SessionLink. Input and resize requests are dropped and counted while no session exists, rather than queued. The first attempt is routed to from the start, as before, so a client's early desktop-size request still reaches the guest. It went outside the brief to fixrun_connectionin the renderer: an early return, or dropping its future, left the spawned channel tasks running. The GUI never noticed, because it drops each attempt's runtime;--rdpruns every attempt on one runtime. A drop guard now aborts them, and has its own test. The management session's review made running out of retries always exit non-zero, sincerun_connectionusually returnsOkand the run would otherwise exit 0 after failing to reconnect. Twelve mutants, all killed (.../p05/step4/mutate.py). A live check against QEMU on port 5921 saw a loss, a refused retry, a reconnect with a new surface, and Ctrl+C exiting in 0.03 s. It also found thatmake test-qemutakes its port and paths as make arguments, not environment variables, which steps 5 and 6 rely on. - Step 5 (Opus, high effort) scheduled the lane at 14:40 UTC.
It took OVMF from the smoke image rather than mounting the
host's, since runners need not have it. It added two oracles
beyond decision 8: every colour FreeRDP shows must match the
guest's own QMP
screendump, which also tells a guest that never booted (a harness failure) from a ryll defect; and the lane fails if ryll prints the password anywhere but its launch line. Both kept. There is no fake ryll to run the driver against, astools/test-proxmox-smoke.shhas, because there is no cheap stand-in for an X server and an RDP client; insteadtools/test-rdp-smoke.pyunit-tests its pure parts from pre-commit.release-rdpsharestarget/releasewithreleaseand copies its binary out; every local consumer oftarget/release/ryllrebuilds throughreleasefirst, so the sharing is harmless. Deliberate breaks, each built from a copy of the tree: dropping every input event failedinput; removing the old connection'slose()failedtakeover; a supervisor that gives up after any session failedspice-reconnect. Each time every earlier check passed (.../p05/step5/breaks.py). Local runs took 32 to 38 s warm, and one without/dev/kvmalso passed. - Step 7 (Sonnet, medium effort) ran alongside step 6
rather than after it: the user-facing docs did not depend on
the run record, and the management session wrote Results and
ticked the definition of done itself. It found and corrected a
sentence in
docs/session-lifecycle.mdsaying ryll never auto-reconnects, already wrong for the GUI's backoff. - Step 6 was run by the management session; see Results. The visual check needed the desktop guest image downloaded again, as phase 4's worktree had gone.
- Review round 1 (automated, 0 fix, 2 document, 5 consider)
arrived after Mikal had queued #640, so what it took landed in
a follow-up pull request rather than on #640. Taken: the
smoke lane's spice-reconnect check now reports a QEMU restart
that misses ryll's retry window as a harness
failure, not a ryll one, and its darkening bound is shortened
to leave room inside that window;
rdp-smoke.ymlcancels in progress only for pull requests, so a dispatch ondevelopcannot cancel a nightly; the back brief and Results above. Declined: limiting pre-authentication connections per peer (one stalled peer can hold the only handshake slot for 30 s at a time); a cap on reconnects to a server that accepts and drops every session (the GUI retries the same way); and an in-band mirror reset (the clear relies on the backoff of at least 1 s outlasting the mirror task's lag). The first two are in Future work.
Future work¶
- Limiting how long one peer can hold
--rdp's only pre-authentication slot, for example per-address limits, for a listener on a non-loopback address. - A cap on
--rdpreconnects to a SPICE server that accepts the link and drops every session at once; unattended, it retries every second for ever. - Reporting a red nightly of the RDP smoke lane, as
fuzz.ymlfiles an issue for its failures; today the run history is the only record. -
Sending an RDP client's resize request made during an outage again once SPICE is back; it is dropped today.
-
SPICE reconnect for
--weband--headless, on the retry rules this phase shares. - A text overlay saying why the frame is dark, if users find the darkened frame unclear.
- Promoting the lane into the smoke tier, and open question 8, once decision 9's criterion is met.
Back brief¶
Before executing any step of this plan, back brief the operator as to your understanding of the plan and how the work you intend to do aligns with it. Gates that need Mikal's answer before the steps that depend on them:
- Decision 5 (one IronRDP server per connection, our own accept loop) and decision 2 (a darkened frame, no overlay); confirm before step 1.
- Decision 7: a new advisory workflow that books an
lrunner on each pull request touching the RDP code or the renderer, and nightly. (Answered: nightly only, with apull_requesttrigger on the workflow file alone; see Execution notes.) - Mikal available to watch step 6's visual check.
- Whether this session may push and open the pull request once the definition of done passes.