Skip to content

refactor(cairn): give the effect runner a Runtime, and its lifecycle a test - #207

Merged
zoza1982 merged 1 commit into
mainfrom
refactor/runtime-struct
Sep 9, 2026
Merged

zoza1982 merged 1 commit into
mainfrom
refactor/runtime-struct

Conversation

@zoza1982

@zoza1982 zoza1982 commented Sep 9, 2026

Copy link
Copy Markdown
Owner

What

event_loop's ten loose control locals become one Runtime; the five things effects read become RuntimeCtx; dispatch goes from 16 arguments to 3. The pre-update cleanup chain becomes Runtime::reap, which is testable.

Why

The argument count was the symptom. The actual cost: the runtime's lifecycle invariants — which event releases which control entry, and in what order relative to update — lived as a hand-maintained chain of eight if let Msg::Event(..) arms inside the loop body. That body needs a real DefaultTerminal, so none of it could be tested. grep 'event_loop\|dispatch(' in the test module returned nothing.

Each invariant leaks something real when it is wrong:

Missed Consequence
transfer_controls entry holds a concurrency slot forever
open_connection_in_flight marker every later open of that connection is blocked
remote_edit_temps entry the temp file stays on disk

And one of them is subtle in the other direction: WriteBackConflict must not reap. That flow continues — the overlay is open and KeepEditing/SaveAs re-use the same temp file — so reaping it would delete the file out from under the user's next choice. That was a comment. It is now a test.

Closes #

How

struct Runtime { /* the ten tables */ }
impl Runtime { fn reap(&mut self, msg: &Msg) { /* the eight arms, as a match */ } }
struct RuntimeCtx<'a> { registry, event_tx, shell_action_defs, vault_ctx, descriptor_map }

fn dispatch(effect: AppEffect, rt: &mut Runtime, ctx: &RuntimeCtx<'_>)

The body of dispatch is unchanged. Destructuring both structs at the top rebinds every existing name, so this is genuinely a move rather than a rewrite — which is what makes it reviewable at this size.

The id families are deliberately not collapsed into a single TaskId, despite that being the obvious tidy-up and what LLD §5 originally sketched. A transfer, a log stream, a pager and a session are signalled differently and end differently; separate keys keep that honest, and the review that raised this agreed. next_archive_conn_id also stays separate for a stated reason: unlike the maps, no event can ever reclaim an archive id, so it only grows.

Testing

cargo test --workspace --all-features   # 1185 passed, 0 failed
cargo clippy --workspace --all-targets --all-features -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features

Three tests where there were none, two mutation-checked:

Mutant Killed by
add WriteBackConflict to the reap arm a_writeback_conflict_keeps_the_remote_edit_temp
drop the ConnectionOpened reap every_terminal_event_releases_its_control_entry

One #[allow(clippy::too_many_arguments)] removed (7 remain elsewhere, untouched).

Screenshots / output

No behaviour change, no UI change, no snapshot changed.

Checklist

  • PR title follows Conventional Commits
  • Branched off main
  • cargo fmt, cargo clippy -D warnings, cargo test, cargo doc pass locally
  • Tests added (3; two mutation-checked) — the point of the change
  • Docs updated — rustdoc on Runtime, reap (including why the ordering matters) and RuntimeCtx; the stale dispatch doc describing the old parameters is replaced
  • CHANGELOG.mdintentionally skipped: no user-facing change
  • Security review — n/a
  • No secrets, credentials, or generated artifacts committed

Deferred (the rest of the review's track F)

  • event_loop still takes 11 arguments. Most are genuinely per-run (terminal, state, the two receivers, input_gate) and folding them into a struct would obscure rather than clarify. Worth revisiting only if it grows.
  • Four near-identical *Guard types (TransferDoneGuard, SessionDoneGuard, ConnectionOpenGuard, ConnectionTestGuard) share an armed/try_send/Drop shape and want one generic. Separate change — it touches every effect runner.
  • update.rs (13k lines) is untouched here. Its seams are real (overlay handlers, transfer, pager, session, connection form) but splitting it is a much larger diff and deserves its own PR rather than riding along with this one.

Risk & rollback

  • Pure refactor: the effect bodies and the reap arms are moved verbatim, and the full suite passes unchanged. The risk is a missed arm during the move — which is precisely what the new tests now cover, and why they exist rather than being deferred.
  • Runtime::new() sets next_archive_conn_id to ARCHIVE_CONN_ID_BASE rather than Default's 0; a test pins that.
  • Rollback: revert the single commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SM3dDrioC5KntqjB6WDqjW

…a test

`event_loop` owned ten loose control locals — transfer_controls, ai_cancel,
log_viewer_controls, pager_controls, session_controls, size_calc_cancel, two
in-flight HashSets, an id counter and remote_edit_temps — and threaded every
one of them through a sixteen-argument `dispatch` under
`#[allow(clippy::too_many_arguments)]`.

The cost was not the argument count. It was that the runtime's *lifecycle
invariants* — which event releases which entry, and in what order relative to
`update` — lived as a hand-maintained chain of eight `if let Msg::Event(..)`
arms in the loop body, which needs a real `DefaultTerminal` and so could not
be tested at all. Each one leaks something real when it is wrong: a transfer
entry never removed holds a concurrency slot forever, an in-flight marker
never cleared blocks every later open of that connection, a remote-edit temp
never dropped leaves the file on disk.

Now `Runtime` holds the tables and `Runtime::reap(&msg)` is that chain as
ordinary code, with `RuntimeCtx` carrying the five things effects read and
never mutate. `dispatch` takes three arguments. The body is unchanged —
destructuring both structs at the top keeps every existing name bound, so
this is genuinely a move, not a rewrite.

Three tests, two of them mutation-checked, covering the invariants that had
none: every terminal event releases its entry, and `WriteBackConflict`
deliberately does *not* — that flow continues, and reaping it would delete
the temp file out from under the user's next choice. That subtlety was a
comment; it is now a test.

The id families are deliberately not collapsed into one `TaskId`: a transfer,
a log stream, a pager and a session are signalled differently and end
differently, and separate keys keep that honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SM3dDrioC5KntqjB6WDqjW
@zoza1982
zoza1982 force-pushed the refactor/runtime-struct branch from 4d5b5a6 to 179da67 Compare September 9, 2026 21:32
@zoza1982
zoza1982 merged commit 6519b82 into main Sep 9, 2026
11 checks passed
@zoza1982
zoza1982 deleted the refactor/runtime-struct branch September 9, 2026 21:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant