Follow felis to 105b0899: the 1.0 wire reset, minor 10, and a daemon-free socket path #1

Merged
natsukium merged 3 commits from follow-felis-105b0899 into main 2026-09-07 21:29:39 +09:00
Owner

Follows felis from d563c5e5 (2026-08-15) to 105b089979369fd310757375af3df7900ae7e523.
felis reset its wire-break baseline to 1.0 (f56f5529) and has since carried the protocol
minor to 10, so this is a rename-and-remove bump, not an additive one.

What felis changed, and what it forced here

felis change adaptation
ShadowGridShadowScreen; the grid is reached through screen() and the hyperlink anchor table now lives on that screen pane.rs / keys.rs / compositor.rs renamed; RowAnsiOptions.links fed from grid.hyperlink_table(), and the "the anchor table is the shadow's, not the grid's" caveat deleted with the thing it warned about
Encoding negotiation left the wire — codec::decode takes the body alone, and FrameWriter::send frames, gates and flushes in one call Pane no longer carries an Encoding or hand-rolls a Frame; send is one line
Every send is gated on the connection's effective minor (d3633c9a) nothing to add — the gate rides the writer the connector hands over, so an addition this build knows and the daemon does not is refused here instead of dropped silently on the far side. Documented at the one send
Capabilities bitset → typed Offer panes offer Offer::window(false) (a host terminal has no vsync to pace a pull against, so the daemon eager-pushes); the roster peek offers Offer::ops()
Attach carries an intent, and live_only refuses a corpse (MINOR_LIVE_ONLY_ATTACH) first_session picked the row itself, so the attach is AttachIntent::Automatic
Session-roster absence semantics (e6a9d86b, 88ee1b0c) first_session filters !exited && idle_seconds.is_some(): an absent idle_seconds means attached elsewhere, exited means a shell that will never answer
Create became one atomic round trip (MINOR_ATOMIC_CREATE) and carries env_base (MINOR_ENV_BASE) create_with(env_base::fill_for_local(SpawnArgs::default(), false)), mirroring felis-client-core::dial — the daemon may have been auto-spawned from a login long past, and its own environment names the wrong SSH_AUTH_SOCK
InputMsg::Resize takes a RequestedDims at wire width apply_resize widens to u32; the daemon clamps
ImageShadow::apply returns a Result; virtual_placements() returns a slice handled at the call sites
default_socket_path moved to felis-transport, wrapped by felis-client-core::local_socket::resolve_local_socket the felis-daemon dependency is dropped, not repointed (the manifest's TODO(dep-trim) is resolved). Resolving through local_socket also reads the FELIS_SOCKET stamp, so a felis-tui launched inside a felis window now reaches the daemon that spawned it rather than the bare platform default
felis's docs moved into Diátaxis quadrants every felis doc path this repo cites re-pointed and re-checked against the pinned rev

Typed build identity (5c22e3fe), operation ids, and the OpsSwitch scope fields need no
adaptation: felis-tui neither reports a build nor drives a switch.

Also in here

  • fix(verify): scope the isolated daemon to its own socket path — a standalone fix ahead
    of the bump, and the reason it is first. scripts/pty_verify.py inherited FELIS_SOCKET,
    which the socket resolver reads ahead of the XDG_RUNTIME_DIR default, so a run started
    inside a felis window attached to the user's own daemon despite the fresh runtime dir. Its
    cleanup then filtered felis-daemon processes on the basename daemon.sock — which every
    daemon's argv contains — and SIGTERMed them. Together that killed this host's production
    daemon three times before it was caught. Now the stamp is popped and the filter matches the
    full isolated path.
  • A docs pass: ShadowGrid in the prose, the moved felis doc paths, and the cross-host note
    that claimed local image bytes travel over shm. They do not — felis's IPC boundary is the
    socket, with no shared memory by design; shm is a producer→daemon Kitty transfer mode. That
    contradiction is felis's own and is filed as natsukium/felis#194.

Verified

All four CI gates, through the flake dev shell, exactly as .forgejo/workflows/ci.yml runs them:

nix develop -c cargo fmt --all --check                              # pass
nix develop -c cargo clippy --all-targets --all-features -- -D warnings   # pass
nix develop -c cargo test --all-features                            # pass, 23 tests
nix develop -c cargo deny check                                     # advisories/bans/licenses/sources ok

End-to-end against a real felis-daemon built at the pinned rev
(felis-daemon 0.1.0 (105b0899…), protocol 1.10), on an isolated socket, never the user's:

PATH=<pinned felis bin>:$PATH python3 scripts/pty_verify.py "$PWD/target/debug"

15/15 checks pass — status bar, pane labels, both separators, true-colour passthrough, OSC 8
hyperlinks, curly underline + underline colour, OSC 66 sized text, Kitty image transmit and
clipped placement, Unicode-placeholder placement, and sentinel suppression.

Three roster paths were then exercised by hand against that same isolated daemon, because the
harness only ever covers the cold-start create:

  1. create — empty roster → create_with → one session, idle_seconds: 0.
  2. re-attach a parked sessionfelis sessions spawn, then felis-tui: it attached the
    existing row (the session's rows went 24 → 23 as apply_resize landed on it) and created
    nothing new.
  3. skip an exited sessionfelis sessions send <id> --key ctrl+d, roster shows
    "exited": true; felis-tui skipped that row and created a fresh live session, which is the
    !exited filter doing its job.

The isolated daemon was stopped with felis --socket <path> daemon stop; the host's own
daemons were confirmed alive before and after.

Open / not in this PR

  • No packages.default yet: the TODO(package) in flake.nix still stands, because a
    buildRustPackage needs a cargoLock.outputHashes entry per felis rev and the rev still
    moves.
  • felis-tui ignores Push frames (Reattach, RetargetHost, Evicted). SessionExited needs
    no handling — a pane whose reader task ends is already pruned, and the last one quits — but a
    felis switch aimed at a felis-tui pane is silently a no-op. Out of scope here; it is a
    feature, not a break.
  • Against a daemon older than protocol minor 8 a create is refused with MinorTooOld rather
    than degraded. That is felis's own designed behaviour (create_with is atomic-create only),
    shared with felis's GUI client, and is worth knowing if you point this build at a stale daemon.
Follows felis from `d563c5e5` (2026-08-15) to `105b089979369fd310757375af3df7900ae7e523`. felis reset its wire-break baseline to 1.0 (`f56f5529`) and has since carried the protocol minor to 10, so this is a rename-and-remove bump, not an additive one. ## What felis changed, and what it forced here | felis change | adaptation | | --- | --- | | `ShadowGrid` → `ShadowScreen`; the grid is reached through `screen()` and the hyperlink anchor table now lives **on** that screen | `pane.rs` / `keys.rs` / `compositor.rs` renamed; `RowAnsiOptions.links` fed from `grid.hyperlink_table()`, and the "the anchor table is the shadow's, not the grid's" caveat deleted with the thing it warned about | | Encoding negotiation left the wire — `codec::decode` takes the body alone, and `FrameWriter::send` frames, gates and flushes in one call | `Pane` no longer carries an `Encoding` or hand-rolls a `Frame`; `send` is one line | | Every send is gated on the connection's effective minor (`d3633c9a`) | nothing to add — the gate rides the writer the connector hands over, so an addition this build knows and the daemon does not is refused here instead of dropped silently on the far side. Documented at the one `send` | | `Capabilities` bitset → typed `Offer` | panes offer `Offer::window(false)` (a host terminal has no vsync to pace a pull against, so the daemon eager-pushes); the roster peek offers `Offer::ops()` | | Attach carries an intent, and `live_only` refuses a corpse (`MINOR_LIVE_ONLY_ATTACH`) | `first_session` picked the row itself, so the attach is `AttachIntent::Automatic` | | Session-roster absence semantics (`e6a9d86b`, `88ee1b0c`) | `first_session` filters `!exited && idle_seconds.is_some()`: an absent `idle_seconds` means attached elsewhere, `exited` means a shell that will never answer | | Create became one atomic round trip (`MINOR_ATOMIC_CREATE`) and carries `env_base` (`MINOR_ENV_BASE`) | `create_with(env_base::fill_for_local(SpawnArgs::default(), false))`, mirroring `felis-client-core::dial` — the daemon may have been auto-spawned from a login long past, and its own environment names the wrong `SSH_AUTH_SOCK` | | `InputMsg::Resize` takes a `RequestedDims` at wire width | `apply_resize` widens to `u32`; the daemon clamps | | `ImageShadow::apply` returns a `Result`; `virtual_placements()` returns a slice | handled at the call sites | | `default_socket_path` moved to `felis-transport`, wrapped by `felis-client-core::local_socket::resolve_local_socket` | **the `felis-daemon` dependency is dropped, not repointed** (the manifest's `TODO(dep-trim)` is resolved). Resolving through `local_socket` also reads the `FELIS_SOCKET` stamp, so a felis-tui launched inside a felis window now reaches the daemon that spawned it rather than the bare platform default | | felis's docs moved into Diátaxis quadrants | every felis doc path this repo cites re-pointed and re-checked against the pinned rev | Typed build identity (`5c22e3fe`), operation ids, and the `OpsSwitch` scope fields need no adaptation: felis-tui neither reports a build nor drives a switch. ## Also in here - **`fix(verify): scope the isolated daemon to its own socket path`** — a standalone fix ahead of the bump, and the reason it is first. `scripts/pty_verify.py` inherited `FELIS_SOCKET`, which the socket resolver reads *ahead* of the `XDG_RUNTIME_DIR` default, so a run started inside a felis window attached to the user's own daemon despite the fresh runtime dir. Its cleanup then filtered `felis-daemon` processes on the basename `daemon.sock` — which every daemon's argv contains — and `SIGTERM`ed them. Together that killed this host's production daemon three times before it was caught. Now the stamp is popped and the filter matches the full isolated path. - A docs pass: `ShadowGrid` in the prose, the moved felis doc paths, and the cross-host note that claimed local image bytes travel over shm. They do not — felis's IPC boundary is the socket, with no shared memory by design; shm is a producer→daemon Kitty transfer mode. That contradiction is felis's own and is filed as **natsukium/felis#194**. ## Verified All four CI gates, through the flake dev shell, exactly as `.forgejo/workflows/ci.yml` runs them: ``` nix develop -c cargo fmt --all --check # pass nix develop -c cargo clippy --all-targets --all-features -- -D warnings # pass nix develop -c cargo test --all-features # pass, 23 tests nix develop -c cargo deny check # advisories/bans/licenses/sources ok ``` End-to-end against a **real felis-daemon built at the pinned rev** (`felis-daemon 0.1.0 (105b0899…)`, protocol `1.10`), on an isolated socket, never the user's: ``` PATH=<pinned felis bin>:$PATH python3 scripts/pty_verify.py "$PWD/target/debug" ``` 15/15 checks pass — status bar, pane labels, both separators, true-colour passthrough, OSC 8 hyperlinks, curly underline + underline colour, OSC 66 sized text, Kitty image transmit and clipped placement, Unicode-placeholder placement, and sentinel suppression. Three roster paths were then exercised by hand against that same isolated daemon, because the harness only ever covers the cold-start create: 1. **create** — empty roster → `create_with` → one session, `idle_seconds: 0`. 2. **re-attach a parked session** — `felis sessions spawn`, then felis-tui: it attached the existing row (the session's `rows` went 24 → 23 as `apply_resize` landed on it) and created nothing new. 3. **skip an exited session** — `felis sessions send <id> --key ctrl+d`, roster shows `"exited": true`; felis-tui skipped that row and created a fresh live session, which is the `!exited` filter doing its job. The isolated daemon was stopped with `felis --socket <path> daemon stop`; the host's own daemons were confirmed alive before and after. ## Open / not in this PR - No `packages.default` yet: the `TODO(package)` in `flake.nix` still stands, because a `buildRustPackage` needs a `cargoLock.outputHashes` entry per felis rev and the rev still moves. - felis-tui ignores `Push` frames (`Reattach`, `RetargetHost`, `Evicted`). `SessionExited` needs no handling — a pane whose reader task ends is already pruned, and the last one quits — but a `felis switch` aimed at a felis-tui pane is silently a no-op. Out of scope here; it is a feature, not a break. - Against a daemon older than protocol minor 8 a create is refused with `MinorTooOld` rather than degraded. That is felis's own designed behaviour (`create_with` is atomic-create only), shared with felis's GUI client, and is worth knowing if you point this build at a stale daemon.
The harness intended to run against a throwaway daemon, but two leaks let
it reach the user's production daemon instead, and then SIGTERM it.

FELIS_SOCKET was inherited: the socket resolver reads it ahead of the
XDG_RUNTIME_DIR-derived platform default, so a run started from inside a
felis window attached to the user's own daemon despite the fresh runtime
dir. Pop it.

The cleanup filter then matched any felis-daemon whose argv contained the
basename `daemon.sock` — which every daemon's argv does — so the reap
loop killed the host's daemon. Filter on the full isolated path instead.
felis reset its wire-break baseline to 1.0 (felis f56f5529) and has since
raised the protocol minor to 10, so this is a rename-and-remove bump
rather than an additive one. Move every felis crate to the new rev
together, as the manifest header requires.

The shadow type is now `ShadowScreen` and hands out its screen through
`screen()`, with the hyperlink anchor table living on that screen rather
than beside it — the compositor's separate-message caveat is gone with it.

Encoding negotiation left the wire: `codec::decode` takes the body alone
and `FrameWriter::send` frames, gates, and flushes in one call, so the
pane no longer carries an `Encoding` or hand-rolls a `Frame`. Every send
now passes the connection's effective-minor gate, which turns a field
this build knows and the daemon does not into a refusal here instead of a
silent drop on the far side.

The handshake takes a typed `Offer` in place of a capability bitset;
panes offer `window(false)` because a host terminal has no vsync to pace
a pull against, and the roster peek offers `ops()`. Attaches now carry an
intent: `first_session` picked the row itself, so it attaches
`Automatic`, whose live-only form the daemon refuses against a corpse.
The roster peek filters on that same lifecycle — an absent `idle_seconds`
means the row is attached elsewhere and `exited` means its shell will
never answer.

Creates are one round trip through `create_with`, and carry the base
environment: the daemon may have been auto-spawned from a login long
past, and its own environment names the wrong `SSH_AUTH_SOCK`.

`default_socket_path` moved to felis-transport, and client-core's
`local_socket::resolve_local_socket` wraps it with the `FELIS_SOCKET`
stamp a daemon writes into every child. Reading that stamp is what lets a
felis-tui launched inside a felis window reach the daemon that spawned
it, so the felis-daemon dependency is dropped rather than replaced.
docs(tui): follow felis's renamed shadow type and Diátaxis doc tree
All checks were successful
ci / cargo fmt / clippy / test / deny (pull_request) Successful in 53s
8390c41648
The prose named `ShadowGrid`, which no longer exists, and pointed at
felis doc paths that moved when felis reorganised into Diátaxis
quadrants: a reader following either lands on nothing.

The cross-host note also claimed local image bytes travel over shm. They
do not — felis's IPC boundary is the socket, with no shared memory by
design (felis `docs/explanation/architecture/overview.md` "No shared
memory"); shm is a producer-to-daemon Kitty transfer mode, not the
daemon-to-client wire. State the fact that actually motivates the
suppression bit instead: felis gates it on a measured cross-host saving,
which makes felis-tui over SSH the measurement, and cites the explanation
doc that owns the argument rather than the reference twin.

The startup line said "idle session". The roster's lifecycle vocabulary
distinguishes parked from attached-elsewhere and from exited, and only a
parked session is what the first pane takes.
natsukium deleted branch follow-felis-105b0899 2026-09-07 21:29:39 +09:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
natsukium/felis-tui!1
No description provided.