diff --git a/README.md b/README.md index e1c2960..515c259 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ coroutine-based async surface are core primitives, not bolt-ons. The editor is partitioned into a long-lived **instance** (the daemon that owns buffers, processes, and language services) and thin -**frontends** that attach over a typed protocol (currently v19). Two +**frontends** that attach over a typed protocol (currently v20). Two frontends ship today: - a **TUI** (crossterm cell grid), attachable locally over a Unix @@ -32,7 +32,7 @@ the same buffers concurrently with live cursor/selection presence. **v1.0.0 --- stable core, active development.** The v1.0 gate (the instance/frontend partition, the Lua surface, and a REPL package audited to use zero direct Rust core access) shipped some time ago. Development -since has expanded the semantic frontend protocol from v6 through v19, +since has expanded the semantic frontend protocol from v6 through v20, brought the GPU frontend near input/render parity with the TUI, and completed the LSP, editing, persistence, themes, and terminal arcs. Recent work added major modes and modeline detection, a typed configuration @@ -102,16 +102,19 @@ pmacs [FILE] # TUI; -nw reserved for when a GUI default lands GPU frontend (one command; the root binary starts or reuses the daemon): ```sh -pmacs --gpu # default instance -pmacs --gpu --socket NAME # named instance; bare NAME → +pmacs --gpu # default instance; no initial file +pmacs --gpu README.md # default instance; open one file +pmacs --gpu --socket NAME FILE # named instance; bare NAME → # /pmacs/NAME.sock +pmacs --gpu -- --leading-dash # `--` ends option parsing ``` `pmacs --gpu` requires the root `pmacs` binary to be built with the `crdt` feature. It discovers a sibling `pmacs-gpu` binary first, then -falls back to `pmacs-gpu` on `PATH`. Closing the window detaches only -that frontend; the daemon remains available for later GPU or TUI -attaches. +falls back to `pmacs-gpu` on `PATH`. When `FILE` is present, the daemon +loads or creates it and completes startup hooks before the GPU window +appears. Closing the window detaches only that frontend; the daemon +remains available for later GPU or TUI attaches. Daemon + attached TUI frontends: @@ -148,7 +151,7 @@ Builds on the toolchain pinned in `rust-toolchain.toml` (Rust # the separate pmacs-gpu package feature-free while enabling CRDT in pmacs. cargo build --release --workspace --features pmacs/crdt -target/release/pmacs --gpu # one-command managed GPU launch +target/release/pmacs --gpu README.md # one-command managed GPU file launch cargo run --release -- --version # default-run selects the pmacs binary cargo test --workspace # unit + integration tests (all crates) cargo fmt --check diff --git a/docs/active-work.md b/docs/active-work.md index 161324c..a419388 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,6 +1,6 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-23.** This file records volatile work that has not +**Snapshot: 2026-07-24.** This file records volatile work that has not landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. @@ -14,9 +14,10 @@ backlog. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `6ed4fe9` (folding Stage 2 #149 atop the ledger - refresh #147, web grammars HTML+CSS #146, and the LaTeX Stage 1 #144 / - inline-math framing #145 pair; protocol v19). + `githubsucks/main` @ `b168dca` (folding Stage 2 landed-doc refresh #150 + atop folding Stage 2 #149, the ledger refresh #147, web grammars HTML+CSS + #146, and the LaTeX Stage 1 #144 / inline-math framing #145 pair; protocol + v19). - On the transfer source, `origin/main` named a release mirror at `d3fa632` and lagged badly. On the current destination, `origin` names the canonical URL. This difference is why all recovery begins by @@ -50,9 +51,46 @@ git worktree list git status --short --branch ``` -The first command must expose `6ed4fe9` or a newer intentional main. +The `git log` command must expose `b168dca` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Active lane: GPU initial target + +- Portable implementation branch: `githubsucks/gpu-initial-target` @ + `6c06815` (second-review fixes integrated with current canonical `main`); + worktree `../pmacs-gpu-initial-target`. +- Approved framing branch: `githubsucks/gpu-initial-target-framing`; + Revision 2 checkpoint `71039d1`. +- Original implementation base: canonical `githubsucks/main` @ `c49a8c7` + (folding Stage 1 #142); current canonical `main` @ `b168dca` is integrated + by merge `6c06815`. Protocol was v19 before this work. +- State: implementation checkpoint `2dd30ec`; first review-fix checkpoint + `bef1c08`; second-review checkpoint `be8c67c`; current-main integration + `6c06815`. Smoke-tested, fully gated, and published at protocol v20. PR #148 + remains open for user review: + `https://github.com/levineuwirth/pmacs/pull/148`. +- Scope delivered: one session-scoped `pmacs --gpu [--socket …] FILE` target, + protocol-v20 semantic bootstrap, launcher-owned tilde/cwd resolution, exact + Unix path transport, pre-window target readiness, replica coherence, and the + approved behavioral acceptance matrix. +- Post-integration verification: formatting and strict Clippy; 1,815 default + + 1,992 CRDT library tests; target and invocation gates 14 CRDT each; Folding + Stage 2 48 CRDT; M4 121; required GPU 152; Vterm Stage 3 5 default + 7 CRDT; + isolated-config workspace sweep 3,334 across 88 suites. The prior two-window + Wayland/Vulkan isolation smoke remains valid; this round changes only daemon + failure/publication behavior. +- Deferred unchanged: automatic GUI selection, multiple files, general + live-open commands, packaging, and remote GPU paths. + +Recovery worktree after the first push: + +```sh +git worktree add --track \ + -b gpu-initial-target \ + ../pmacs-gpu-initial-target \ + githubsucks/gpu-initial-target +``` + ## Folding lane (Arc 6) — Stages 1 and 2 MERGED; Stage 3 (GPU) is next Both shipped stages are on `main`; nothing in this arc is in flight. Stage 3 diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 696616e..da119bb 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,13 +1,13 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-24, after folding Stage 2 (#149, the grid/daemon -collapse) landed with its ledger refresh (#147), following web grammars HTML+CSS -(#146), the LaTeX Stage 1 / inline-math framing pair (#144/#145), folding -Stage 1 (#142, the headless fold engine), one-command GPU invocation (#141), the -documentation refresh (#140), Vterm Stage 3 (#135, protocol v19 and native GPU terminal), -tab-width rendering parity (#137), locals-query processing (#134), modeline -detection (#132), mode system wiring (#129), config registry (#127), Vterm -Stages 1–2 (#126/#130), and completed Themes Arc 4 (#120/#124/#125).** +**Last updated: 2026-07-24, after GPU initial-target PR #148 second-review +fixes were integrated with folding Stage 2 (#149) and its landed-doc refresh +(#150), following web grammars HTML+CSS (#146), the LaTeX Stage 1 / inline-math +framing pair (#144/#145), folding Stage 1 (#142), one-command GPU invocation +(#141), the documentation refresh (#140), Vterm Stage 3 (#135), tab-width +rendering parity (#137), locals-query processing (#134), modeline detection +(#132), mode system wiring (#129), config registry (#127), Vterm Stages 1–2 +(#126/#130), and completed Themes Arc 4 (#120/#124/#125).** This file is the bridge between development machines. If you are an agent reading this on a fresh clone: this document plus the `docs/*-framing.md` @@ -21,11 +21,29 @@ commands, read `docs/active-work.md` immediately after this file. ## 1. Where the project stands (2026-07-24) -- `main` @ `6ed4fe9` (folding Stage 2 #149 + ledger refresh #147 atop web - grammars #146, LaTeX Stage 1 #144 / inline-math framing #145, and folding - Stage 1 #142), protocol **v19** (`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, - v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal - frames/events). +- `main` @ `b168dca` (folding Stage 2 landed-doc refresh #150 atop folding + Stage 2 #149, ledger refresh #147, web grammars #146, LaTeX Stage 1 #144 / + inline-math framing #145, and folding Stage 1 #142), protocol **v19** + (`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 = + `StatuslineSegments`, v19 = terminal frames/events). +- **GPU INITIAL TARGET IMPLEMENTED — PR #148 under user review** + (`docs/gpu-initial-target-framing.md` rev 3; branch `gpu-initial-target`). + `pmacs --gpu [--socket NAME|PATH] FILE` now transports exact Unix path bytes + plus launcher cwd to the managed GPU client. Protocol v20 adds a + semantic-session `SessionBootstrapRequest` after `AttachRequest` and an + appended `InitialTargetResult` readiness barrier; v6–v19 wire encodings stay + pinned. The daemon resolves the path lexically, deduplicates or loads/creates + it in the authenticated frontend's view, runs the established load/switch + hooks, upgrades the buffer for CRDT, and publishes every target-side CRDT + upgrade to existing grid replicas before readiness. Semantic replicas receive + a publication only when displaying that buffer, so a second target launch + cannot switch an existing GPU window; one dead peer cannot fail the new + session. Failed bootstrap writes a bounded result, shuts down the socket, + removes provisional state, and restores the ambient active frontend. Any + stale event from an uninstalled session is dropped before state access. + Existing no-target managed launch, direct attach, TUI, and legacy protocol + behavior remain intact. See `docs/active-work.md` for the portable checkpoint + and verification. - **Folding Stage 1 (headless fold engine) LANDED — #142** (`docs/folding-framing.md` rev 5; merge `c49a8c7`; three review rounds, round 3 clean). Arc 6's engine — instance-side and headless; **no frontend @@ -613,13 +631,14 @@ and `range` are three INDEPENDENT capabilities — gate each. buffer owns a path's recovery slot; only recover/discard release unclaimed crash data; adopt clears the old owner's skip cache. -**Protocol** — encoding-breaking bumps are deliberate and versioned -(`SUPPORTED=[6..=19]`). v15 = `CompletionPopup` + -`StatusFacts.message`; v16 = `ThemeFacts`; v17 = `FontFacts`; v18 = -`StatuslineSegments`; v19 = the vterm terminal family. New wire surface ⇒ -bump + both-frontends support + acceptance. An APPENDED variant must be -guarded by a byte pin on the PREVIOUS final variant — its own round-trip -cannot detect a discriminant shift. +**Protocol** — encoding-breaking bumps are deliberate and versioned. Canonical +`main` remains `[6..=19]`; the active GPU initial-target branch is +`[6..=20]`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 = +`ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm +terminal family; v20 = semantic `SessionBootstrapRequest` plus appended +`InitialTargetResult`. New wire surface ⇒ bump + both-frontends support + +acceptance. An APPENDED variant must be guarded by a byte pin on the PREVIOUS +final variant — its own round-trip cannot detect a discriminant shift. **Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`, `rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation), diff --git a/docs/gpu-initial-target-framing.md b/docs/gpu-initial-target-framing.md new file mode 100644 index 0000000..c3b372c --- /dev/null +++ b/docs/gpu-initial-target-framing.md @@ -0,0 +1,670 @@ +# GPU initial target — session-scoped file opening framing + +**Revision 3 — implemented as built on branch `gpu-initial-target`, protocol +v20, 2026-07-23. Revision 2 was approved against canonical `main` @ `4daa1b8`; +the implementation base includes folding Stage 1 through `c49a8c7`.** + +Revision 3 records the completed implementation and verification. Revision 2 +pinned launcher-owned tilde expansion, required `after-switch` even when dedup +selects the view's existing buffer, failed bootstrap when a hook kills the +target, and kept slow pre-window bootstrap terminal-only (no graphical +progress surface). It also sharpened the observed argv panic and negotiated +protocol-version echo. + +One-command GPU startup landed in #141: + +```sh +pmacs --gpu +pmacs --gpu --socket research +``` + +The remaining daily-use gap is that the same path cannot open a file. This +stage makes one positional target honest: + +```sh +pmacs --gpu README.md +pmacs --gpu --socket research ../notes/today.md +pmacs --gpu -- --name-beginning-with-dash +``` + +“Honest” is load-bearing. The target belongs to the newly authenticated GPU +session, not to the daemon's ambient/local view or whichever frontend most +recently sent input. Relative paths resolve against the launcher's working +directory even when an existing daemon has a different cwd. A new or reused +file becomes the first buffer content the GPU can draw; scratch never flashes. +Open failures return before a window is shown and make the root command fail. + +This is one-file startup only. It does not make GUI the automatic default, add +a general client/server `open` command, widen direct `pmacs-gpu --attach`, or +solve installation, service management, remote GPU attach, or reconnect. + +## Ground truth + +### Root and GPU CLI + +- `src/main.rs` already parses one positional `FILE` for local TUI mode and + rejects multiple files. It accepts `--` before an option-like filename. +- `Mode::Gpu` currently carries only `socket`; `parse_args` rejects every file + paired with `--gpu` and points users to `C-x C-f`. +- The root broker invokes the sibling/PATH GPU binary as + `pmacs-gpu --managed-attach SOCKET DAEMON_EXE`, waits for its status, and + reflects success/failure. +- Both root and GPU parsers consume `std::env::args()` into `String`; Rust + panics when an argument is not valid Unicode. They cannot admit a + non-UTF-8 Unix filename or issue a useful parser error for one. +- The direct GPU CLI is intentionally strict. Public direct attach remains + `pmacs-gpu --attach RAW_SOCKET`; managed and headless forms are private + root/acceptance seams. + +### Handshake and session bootstrap + +- The instance sends `Hello`; the frontend sends `AttachRequest`; then the + per-attach thread immediately sends `DispatcherEvent::SessionEstablished` + and becomes the reader for `FrontendEvent`s. +- `AttachRequest` contains protocol version, capabilities, and initial cell + size. It has no target. `FrontendEvent` has no open-path request. +- The GPU copies `Hello.protocol_version` into `AttachRequest.protocol_version` + rather than sending its own maximum. Every old-daemon gate therefore keys on + the negotiated/echoed server version; changing that echo is not cleanup. +- `handle_session_established` creates the authenticated frontend's own + `FrontendView`, initially sharing the daemon-local active buffer, then sends + CRDT snapshots and installs grid or semantic render state. +- A semantic frontend follows whichever `BufferSnapshot` it most recently + applied. The attach-time snapshot sweep currently sends every CRDT-backed + buffer; a later tick repairs the “last snapshot was not my active buffer” + ambiguity by re-sending the session's active buffer. +- Managed GPU connect completes before winit creates the window, but normal + instance messages are read asynchronously after that connect call. Merely + adding an event after attach would race the first snapshot and first draw. + +### File and view semantics already present + +- Local `pmacs FILE` loads an existing file, or creates an empty path-backed + buffer with `[new file]` status on `NotFound`; other I/O errors fail startup + (`EditorState::open`). +- `EditorCore::get_or_load_buffer` and Lua `pmacs.buffer.find_or_open` already + establish the important dedup rule: an existing path-backed buffer wins, so + unsaved edits are not replaced by a disk reload. +- Buffer identity is an absolute, lexically normalized path. It deliberately + does not canonicalize symlinks, so a not-yet-created file has a stable + identity and the editor does not silently rewrite the user's spelling to a + filesystem target. +- `FrontendView`s are per-session. Switching one frontend's active window does + not switch any sibling frontend. +- Load/switch hooks require `EditorCore::active_frontend` to name the source + before Lua runs; otherwise `pmacs.window.*` resolves against the wrong view. + +### Replica composition constraint + +A target loaded during attach may be a brand-new, non-CRDT buffer. The new GPU +needs its snapshot, but every already-attached replica must also learn that +buffer before later CRDT operations can reference it. Calling the current +one-stream `send_buffer_snapshots` helper can upgrade the new buffer for the +new GPU while leaving existing replicas unaware: the later lazy-upgrade sweep +then sees an already-backed buffer and has nothing to broadcast. The initial- +target transaction must therefore use the same all-replica publication +invariant as any other mid-session buffer creation. + +## Invariants + +1. **Authenticated source owns the target.** No client-supplied frontend id, + daemon-local active view, or ambient “last frontend” selects the window. +2. **Launcher environment owns path resolution.** Root applies the existing + leading-tilde rule with the launcher's `$HOME`; any still-relative result + resolves against the launcher cwd. Daemon cwd/HOME are irrelevant. +3. **Path bytes survive defined resolution.** Apart from that deliberate + root-side tilde substitution, Unix argv → broker → GPU → wire → daemon file + I/O preserves exact `OsStr` bytes. Display text may be lossy; backing + identity and filesystem access may not be. +4. **One target, one buffer identity.** Reuse an already-open normalized path; + never discard unsaved edits by reloading it. +5. **Target before first draw.** The first buffer content eligible to render is + the requested target. The explicit terminal launcher may report the wait on + stderr; no editor window exists yet, and scratch content is never drawable. +6. **Ready means usable.** Success is reported only after the target buffer is + loaded/created, selected in the authenticated view, CRDT-backed, and its + matching snapshot has been written to the GPU. +7. **Failure is pre-window and fail-closed.** Non-`NotFound` I/O, malformed + bootstrap data, CRDT upgrade/export, or target snapshot failure produces no + successful session and no window. +8. **Existing sessions remain coherent.** A fresh target is published to all + negotiated replica sessions; another frontend's active window never moves. +9. **No legacy wire drift.** Protocols v6–v19 retain their exact + `AttachRequest`, `FrontendEvent`, and `InstanceMessage` encodings. +10. **No lifecycle regression.** Existing-daemon reuse, bounded daemon startup, + process-group isolation, named child reaping, and root exit propagation + stay owned by #141's managed path. + +## Decisions + +### Q#GT1 — Public grammar accepts exactly one GPU target + +The public shape becomes: + +```text +pmacs --gpu [--socket NAME|PATH] [--] [FILE] +``` + +- `FILE` is optional and may appear before or after `--gpu` / `--socket`, as the + existing single-pass parser already permits for local mode. +- `--` makes the next and only remaining operand a literal filename, including + one beginning with `-`. +- A second positional remains `multiple files not yet supported`. +- `--gpu FILE` remains mutually exclusive with `-nw` / `--no-window`, + `--daemon`, `--attach`, and `--daemon-attach` under the existing mode rules. +- `Mode::Gpu` gains `file: Option`; no-file behavior is byte-for-byte + the #141 path. +- README/help examples add `pmacs --gpu FILE`; bare `pmacs FILE` remains TUI. + +### Q#GT2 — Root parsing moves to `OsString` without weakening option grammar + +The root entry point uses `std::env::args_os`. Parsing distinguishes: + +- ASCII option names (`-nw`, `--gpu`, `--socket`, `--`, …), which must match + exactly; +- socket names/paths, whose existing resolver contract remains UTF-8 and + receives a targeted error when the operand is not UTF-8; +- local/managed `FILE`, stored as `PathBuf` with exact platform bytes; +- positional attach targets, which remain UTF-8 because their syntax is a + transport URI/hostname rather than a local filesystem path. + +Unit helpers may continue to construct UTF-8 `OsString`s for ordinary cases, +but one Unix-only test must pass an invalid-UTF-8 positional through the real +parser. No lossy conversion is permitted on the file path. + +### Q#GT3 — The private broker handoff carries target plus launcher cwd + +When `FILE` is present, root invokes the GPU child as: + +```text +pmacs-gpu --managed-attach SOCKET DAEMON_EXE \ + --initial-target LAUNCHER_CWD FILE +``` + +Before transport, root applies the existing identity seam's tilde rule using +the **launcher** environment: a valid-UTF-8 leading whole `~` or `~/…` expands +through `std::env::var_os("HOME")`; `~user`, a non-UTF-8 spelling, or an +unset `$HOME` remains unchanged. Expansion happens exactly once, before any +cwd join. This makes quoted `~/x` dedup with an already-open `$HOME/x` buffer +and prevents a long-lived daemon's different `$HOME` from changing identity. + +`LAUNCHER_CWD` is captured by root before spawn and must be absolute. The +post-expansion file and cwd are passed as `OsString`/`Path` operands, not +encoded into UTF-8, environment variables, JSON, or a delimiter-separated +string. The marker makes an option-like `FILE` unambiguous. The no-target +private argv remains unchanged. If `current_dir()` fails, root reports that +error and does not spawn the GPU; falling back to daemon cwd would violate the +target's authority. + +The GPU parser also moves to `args_os` for path operands. Public help continues +to advertise only the root command and advanced direct attach; the private +marker is not promoted as a supported standalone workflow. Headless managed +acceptance gets the same optional marker so it exercises production target +transport rather than a test-only side channel. + +### Q#GT4 — Protocol v20 adds a semantic-session bootstrap envelope + +Protocol increments **v19 → v20** and appends v20 to +`SUPPORTED_PROTOCOL_VERSIONS`; v6 remains the compatibility floor. + +`AttachRequest` stays byte-identical. After a v20 semantic frontend sends its +normal `AttachRequest`, it sends one additional framed handshake value: + +```rust +pub struct SessionBootstrapRequest { + pub initial_target: Option, +} + +pub struct InitialTarget { + pub cwd: Vec, + pub path: Vec, +} +``` + +- The extra handshake message is required for `protocol_version >= 20` **and** + negotiated `semantic_render`; `None` preserves ordinary `pmacs --gpu`. +- v6–v19 peers neither send nor read it. Non-semantic v20 grid/TUI sessions + keep the existing two-message handshake and do not wait on an irrelevant + semantic startup envelope. +- The target carries no `FrontendId`; the accepted stream and assigned session + are the authority. +- `cwd` and `path` are Unix path bytes, not text. This stage is the local Unix- + socket GPU path; it does not claim a cross-platform/remote path protocol. +- Postcard decodes each field under the transport's 16 MiB frame cap; daemon + validation then bounds each to 32 KiB before filesystem use. `path` must be + nonempty, `cwd` must be nonempty and absolute, and embedded NUL is rejected + with a bootstrap failure. + +A second message rather than an appended `AttachRequest` field keeps every +legacy postcard shape mechanically unchanged. A post-handshake +`FrontendEvent::OpenPath` is deliberately not used: it cannot precede session +bootstrap and therefore cannot guarantee first-draw ordering. + +### Q#GT5 — The daemon resolves and opens inside one dispatcher transaction + +The per-attach thread validates the v20 bootstrap envelope structurally, moves +it into `DispatcherEvent::SessionEstablished`, and starts its reader exactly +where it does today. It performs no filesystem or editor work. + +The dispatcher, which exclusively owns `EditorState`, performs this target +transaction without yielding to another event: + +1. Register the new frontend's view and set `active_frontend` to the + authenticated `FrontendId`. +2. Accept root's already tilde-expanded `path`; if it is still relative, join + it to the supplied launcher `cwd`, then lexically normalize without + canonicalizing symlinks. The daemon never consults or re-applies `$HOME`. +3. Reuse an existing buffer with that normalized backing path; otherwise load + the file; on `NotFound`, create an empty path-backed buffer and set + `[new file]`; on any other error, take the failure path below. +4. Select that buffer in only the new frontend's active window. +5. Fire `buffer.after-switch` exactly once on every dedup, including when the + fresh view already shares that `BufferId` and selection is a same-buffer + no-op. Fire `buffer.after-load` on a fresh disk load. Both run with the + authenticated frontend active; a newly created missing file matches local + startup and fires neither load nor switch hook. +6. After hooks, verify the target `BufferId` is still live. A listener that + killed it causes the fail-closed bootstrap path in Q#GT9. Otherwise reassert + the requested target so configuration can inspect the right session but + cannot make `pmacs --gpu FILE` acknowledge a different buffer. +7. Establish CRDT/snapshot coherence, then acknowledge readiness. + +The target-opening helper must be Rust/editor-core state, not synthetic keys +or a call through the user-facing Lua binding. Lua hooks remain policy +observers; they are not the transport implementation. + +### Q#GT6 — Dedup preserves edits; new-file behavior matches local startup + +- An already-open normalized path reuses its `BufferId`, current contents, + modified flag, and metadata. Disk is not read again. +- A disk file not already open is loaded once and receives its normalized + backing path and `FileMeta`. +- Any `NotFound` from the initial load creates an empty path-backed buffer, + including when a parent is currently absent; save-time errors remain + save-time errors, matching local `pmacs FILE`. +- `PermissionDenied`, `IsADirectory`, invalid path bytes at the OS boundary, + and other non-`NotFound` errors fail startup. +- The buffer display name may use `Path::display()` and therefore replacement + characters; this must never replace the raw backing path used for dedup, + load, or save. + +### Q#GT7 — Initial-target semantic bootstrap is active-buffer-only + +A v20 semantic session with `initial_target = Some` does **not** receive the +legacy “every buffer, then repair active on the next tick” sweep. It receives +exactly the requested active buffer's `BufferSnapshot` before readiness. +Semantic GPU state holds one active rope and already receives a fresh snapshot +when its daemon-side view switches; shipping unrelated buffers only creates a +last-snapshot ambiguity and avoidable work. + +If the target was newly created or newly loaded and not yet CRDT-backed: + +- upgrade it once using the daemon-owned CRDT peer; +- export one authoritative snapshot; +- publish that buffer/snapshot to every already-attached session that + negotiated `crdt_replica` before later operations can name it; +- write the same logical snapshot to the new GPU without reloading the file or + creating a second buffer. + +The implementation should avoid cloning snapshot bytes per peer beyond what +framed serialization/write ownership requires. Existing no-target attach and +non-semantic replica bootstrap remain unchanged. + +### Q#GT8 — A v20 result is the pre-window readiness barrier + +Append a v20-only `InstanceMessage::InitialTargetResult` variant with an +explicit result shape: + +```rust +pub enum InitialTargetResult { + Opened { buffer_id: BufferId }, + Failed { message: String }, +} +``` + +Only a v20 semantic session that requested `Some(initial_target)` may receive +it. + +Success ordering on the daemon write stream is: + +1. target `BufferSnapshot` written successfully; +2. new session state/render state/stream installed; +3. `InitialTargetResult::Opened` written; +4. normal per-tick messages/events begin. + +The GPU connector synchronously reads this bootstrap prefix before returning. +It retains the matching target snapshot as structured bootstrap state, +validates that `Opened.buffer_id` matches it, and only then starts the ordinary +reader thread. The winit path applies that snapshot before its first redraw. +It does not forward unrelated pre-ready buffer state through the event proxy. +Thus no target success can mean “window exists, load may still fail,” and no +scratch snapshot can become drawable. + +`Failed` becomes an `AttachClientError` containing the local display path and +daemon detail. Managed startup returns nonzero; root reflects that status. +The daemon itself remains alive, whether reused or newly spawned. + +Because the connector waits before winit creates a window, slow dispatcher +work has no graphical “Connecting…” surface. The managed GPU child waits +silently and reports a failure on stderr if bootstrap fails. There is no +second target timeout beyond #141's bounded daemon-start retry; file I/O and +user hooks may legitimately exceed five seconds, and timing out the client +would not cancel dispatcher work. Ctrl-C remains the escape hatch and still +cannot reach the isolated daemon process group. + +### Q#GT9 — Failure cleanup never creates a ghost session + +On any target failure before readiness, the dispatcher: + +- writes `InitialTargetResult::Failed` when the stream is usable; +- removes the provisional frontend view, render state, session-registry entry, + size/baseline entries, and stream entry if any were installed; +- shuts down the connection so the per-attach reader wakes and emits at most + idempotent detach cleanup; +- treats a target `BufferId` killed by `buffer.after-load` or + `buffer.after-switch` as a bootstrap failure, never reasserts a stale id, + and performs the same provisional-session cleanup; +- leaves every pre-existing buffer/view/session unchanged, except that a file + successfully loaded before a later CRDT/export failure may remain as an + ordinary daemon buffer. It must not become another frontend's active view. + +The result error string is bounded (4 KiB) and user-facing. It includes the +operation and OS error but never lossy-converts and then reuses the displayed +path for filesystem access. + +### Q#GT10 — Compatibility is directional and tested + +- New daemon + v6–v19 client: exact legacy handshake; no bootstrap read, no + v20 result, no new enum discriminant. +- New GPU without target + supported v6–v19 daemon: exact #141 behavior. +- New GPU **with** target + v6–v19 daemon: fail immediately after `Hello` with + “initial targets require protocol v20”; do not authorize/spawn a replacement + for the live daemon. +- New daemon + v20 non-semantic client: legacy handshake; the initial-target + envelope is a semantic-session contract. +- New daemon + v20 semantic client: bootstrap envelope required, including + `None`; missing/malformed bootstrap closes only that connection. +- `InitialTargetResult` is appended after all v19 `InstanceMessage` variants + and independently filtered from negotiated `< 20` streams. Existing + encoding pins for v6–v19 messages remain unchanged; new pins lock the v20 + discriminant and bootstrap round trip. + +No new capability bit is needed. Protocol version and negotiated +`semantic_render` jointly identify the state machine; a bit would add a second +source of truth for a mandatory v20 semantic handshake step. + +## Startup state machine + +```text +root parses FILE bytes, expands eligible `~` with launcher HOME, captures cwd + | + +-- spawn/await pmacs-gpu managed child + | + +-- connect existing socket or start/retry daemon (#141 unchanged) + | + +-- Hello < 20 and target? ----> fail; never replace live daemon + | + +-- Hello >= 20 + send AttachRequest + send SessionBootstrapRequest { initial_target } + | + +-- daemon dispatcher resolves/opens in source view + | | + | +-- error --> Failed + connection shutdown + | | + | +-- success + | publish fresh buffer to replicas + | write target BufferSnapshot + | install session + | write Opened { buffer_id } + | + connector validates snapshot/result pair + create GPU state/window + apply target snapshot before first redraw +``` + +## Rejected alternatives + +### Send `FrontendEvent::OpenPath` after attach + +Rejected for initial startup. The daemon has already established the scratch +view and may have sent snapshots before the event can arrive. Correlation ids, +async results, and live session commands will be appropriate for a future +general client/server open API, but they do not provide a pre-window barrier +without duplicating the bootstrap state machine. + +### Put the file only on the spawned daemon command line + +Rejected. It cannot affect an already-running daemon and would target the +daemon-local view rather than the authenticated GPU view. + +### Open globally, then declare a viewport for that buffer + +Rejected. `Viewport` is a rendering declaration whose buffer must already be +known to the replica. Treating it as an open command confuses state alignment +with filesystem authority and can switch the wrong view under races. + +### Drive `C-x C-f` / minibuffer with synthetic keys + +Rejected. It depends on user keymaps and prompts, loses raw path bytes, cannot +report a structured startup result, and visibly renders intermediate state. + +### Resolve relative paths in the daemon cwd + +Rejected. A long-lived daemon's cwd describes when it was started, not where a +later launcher invoked `pmacs --gpu FILE`. + +### UTF-8 path strings + +Rejected for the local Unix contract. They silently exclude valid filenames +and would make GPU startup weaker than `PathBuf`-based local editing. + +### General multi-file/open-command protocol now + +Rejected. Multiple targets need ordering, active-target choice, per-target +results, and behavior for an already-running client. This stage deliberately +pins the one initial target needed by `pmacs --gpu FILE`. + +## Scope and touch map + +As-built implementation surface: + +- `src/main.rs` + - `args_os` parser, `Mode::Gpu { file, socket }`, help/grammar, exact private + child argv, cwd capture, exit propagation. +- `pmacs-gpu/src/main.rs` + - strict `OsString` path parser, optional private initial-target operands, + managed/headless plumbing, pre-first-redraw bootstrap application. +- `pmacs-gpu/src/attach.rs` + - v20 semantic bootstrap send, target-version gate, synchronous + snapshot/result barrier, structured errors; #141 connector/start/reaper + policy otherwise unchanged. +- `pmacs-protocol/src/message.rs`, `pmacs-protocol/src/lib.rs` + - protocol v20, `SessionBootstrapRequest`, `InitialTarget`, result wire type, + appended `InstanceMessage` variant, bounds and documentation. +- `src/daemon.rs` + - conditional v20 bootstrap read, dispatcher payload, source-scoped target + transaction, active-only semantic bootstrap, all-replica publication, + result ordering, failure cleanup, `< 20` send filter. +- `src/editor_core.rs` and/or a small existing file-open helper + - cwd-aware lexical normalization, dedup/load/create without ambient-view + selection. Do not add a parallel path-identity convention. +- `src/protocol.rs`, `pmacs-protocol/src/transport.rs` + - version ladder, legacy placement pins, v20 round-trip/limit tests. +- `tests/gpu_initial_target_acceptance.rs` (new) and focused existing GPU + invocation/protocol tests + - real subprocess/session behavior below. +- `README.md`, this framing's as-built tail, `docs/agent-handoff.md`, and + `docs/active-work.md` per their update protocols after implementation works. + +Explicit non-touch unless evidence forces it: + +- no renderer/layout/font/shader changes; +- no `builtin/runtime` keymap or command changes; +- no socket resolver, daemon lock, process-group, retry, or reaper policy + changes; +- no package/install/service files; +- no remote attach transport changes. + +## Acceptance + +Behavioral acceptance uses real built subprocesses where CLI, cwd, socket, +handshake, process lifetime, or raw argv is the contract. Pure parser/path/wire +helpers receive unit tests; source-text assertions do not substitute for +process behavior. + +1. **Root grammar:** `pmacs --gpu FILE`, `pmacs FILE --gpu`, + `pmacs --gpu --socket research FILE`, and `pmacs --gpu -- -leading` select + one managed GPU target. Multiple files and every existing conflicting mode + exit 2 with the conflicting argument/mode named. No-file `pmacs --gpu` + remains accepted. +2. **Raw argv forwarding:** a Unix invalid-UTF-8 filename survives the real + root parser and reaches a fake GPU child byte-for-byte alongside the exact + launcher cwd. Invalid-UTF-8 option/socket/attach-target text fails with a + pointed usage error rather than panic or replacement characters. +3. **Private GPU grammar:** managed and headless modes accept either their + unchanged no-target arity or exactly `--initial-target CWD FILE`; missing, + trailing, or duplicated forms exit 2. An option-like `FILE` after the marker + remains literal. A relative `CWD` is syntactically accepted and then fails + the daemon's bootstrap validation before readiness. +4. **v20 wire and legacy pins:** bootstrap request/result round trips preserve + arbitrary Unix bytes and enforce bounds. Every pinned v6–v19 encoding stays + unchanged; the new result discriminant is appended. The supported ladder is + exactly `[6, …, 20]`. +5. **No-target compatibility:** rebuilt `pmacs --gpu` attaches to a v19 + fixture/daemon without sending the v20 bootstrap frame and retains #141's + existing/missing-daemon behavior. A v20 semantic attach sends `None` and + reaches its normal first snapshot. +6. **Old live daemon refusal:** target mode against a real/fake supported v19 + daemon fails with the protocol-v20 requirement, invokes no daemon spawner, + leaves the socket/process untouched, and creates no GPU window. +7. **Existing-daemon path authority:** start a real CRDT daemon from cwd/HOME + A; launch the real headless managed GPU target from cwd/HOME B with + `sub/file.txt`; require contents from B, not A. Repeat with a shell-quoted + `~/file.txt` after that `$HOME_B/file.txt` buffer is already open, and + require the same `BufferId`, proving root-side expansion and dedup. +8. **Missing-daemon target:** from no socket/lock, the production managed path + starts one daemon, opens the target, reaches ready, and leaves that daemon + connectable after the probe/window exits. Repeating reuses it and creates no + replacement. +9. **New file:** a nonexistent target produces an empty snapshot, `[new file]` + status/path identity, accepts an edit/save through the real session, and + creates the requested file under the launcher cwd—not the daemon cwd. +10. **Open error:** a directory/permission-denied target returns a specific + failure before ready/window creation and makes root fail. The daemon shuts + down that failed session's socket; a client that lingers or sends another + event cannot reach uninstalled session state. An existing daemon remains + connectable; a pre-existing frontend's active buffer and contents remain + unchanged. +11. **Dedup preserves unsaved edits:** frontend A opens and modifies a file + without saving; target-launch frontend B opens the same normalized path and + receives A's authoritative unsaved text with the same `BufferId`, not disk + contents or a duplicate buffer. +12. **Per-session isolation:** frontend A starts on buffer A while concurrent + target launches B and C open different files. Each result/snapshot pair + names its own view; a subsequent input/resize proof shows A, B, and C remain + independently usable on their original buffers. +13. **Replica publication after target upgrade:** keep grid replica A attached, + then target-launch B onto a previously unknown file. Repeat for a file that + Lua loaded into a hidden, not-yet-CRDT-backed buffer before the target dedup. + A receives each buffer snapshot before any CRDT op for it; both replicas + accept later operations without unknown-buffer fallback or disconnect. +14. **Hook context and count:** fresh disk load fires `buffer.after-load` once; + dedup fires `buffer.after-switch` once even when the fresh view already + shares that exact buffer and the select itself is a no-op; missing-file + creation fires neither hook. Each hook observes the authenticated frontend + and requested buffer, and the target remains selected afterward. A fixture + that kills the target inside either hook yields `Failed`, no ready phase, + and no stale-id reassertion. +15. **Pre-window barrier:** the headless seam records that the only initial + semantic `BufferSnapshot` is the target and precedes matching `Opened`. + Injected load/export failure records no ready phase. A window-path unit seam + proves bootstrap state is applied before the first redraw request. A held + dispatcher fixture proves the bounded stderr notice appears while no + window/ready result exists, then succeeds after release without a separate + target timeout. +16. **Concurrent same-target launch:** two target launchers racing an absent + daemon and the same file converge on one daemon, one buffer identity, two + ready sessions, and the existing #141 losing-daemon child is reaped. +17. **Existing direct/probe paths:** `pmacs-gpu --attach RAW_PATH`, the no-target + managed probe, Vterm Stage 3's headless probe, TUI attach, protocol/capability + mismatch preservation, Ctrl-C daemon isolation, and post-attach reaping + remain green. +18. **Visible smoke:** on the real Wayland/Vulkan workstation, run + `target/release/pmacs --gpu README.md`; the first editor content shown is + README (never scratch), editing works, closing the window leaves the daemon + alive, and a second invocation from another cwd opens that cwd's target in + a new session without moving an already-attached frontend. +19. **Executable documentation:** the coherent workspace build from README + still produces sibling binaries; all documented target commands parse; + no command requires spelling the resolved socket pathname. + +## Required implementation gates + +After the visible/behavioral path works, run the repository gates independently +as required by `AGENTS.md`: + +```sh +cargo fmt --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --lib +cargo test --lib --features crdt +cargo test --test gpu_initial_target_acceptance +cargo test --features crdt --test gpu_initial_target_acceptance +cargo test --test m4_acceptance -- --skip basedpyright +PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu +git diff --check +``` + +Also rerun the touched GPU invocation suite, protocol/transport tests, and +Vterm Stage 3 acceptance in default and CRDT configurations where the suite +supports both. The final full workspace sweep remains required before PR. + +The named gate intentionally reuses the managed-lifecycle acceptance module, +so a workspace sweep executes those 14 CRDT cases under both test-binary +names. The duplicate runtime is retained to keep the approved named command +and the complete #141 lifecycle fixture coverage together. + +Post-second-review verification on 2026-07-24: + +- `cargo fmt --check` and strict workspace Clippy passed. +- Library gates passed 1,815 default and 1,992 CRDT tests. +- The named initial-target gate passed 1 default and 14 CRDT tests; the + underlying GPU invocation suite passed 14 CRDT tests. +- Folding Stage 2 passed 48 CRDT tests after integrating current `main`. +- M4 passed 121 tests with the documented basedpyright skip; required real-GPU + tests passed 152. +- Vterm Stage 3 passed 5 default and 7 CRDT tests. +- The isolated-config workspace CRDT sweep passed 3,334 tests across 88 suites, + with 29 ignored and the documented basedpyright case filtered. +- A coherent release build launched two concurrent real Wayland/Vulkan GPU + windows on one daemon, targeting distinct `alpha` and `beta` files. Both + remained visible on their own buffer after the second target attached. + +## Deferred (named) + +- **Multiple initial files.** Needs result ordering, active choice, and partial + failure policy; do not turn `Option` into a vector casually. +- **General client/server open command.** A live frontend request needs request + ids, asynchronous per-target results, and clear behavior when no frontend is + waiting. It may reuse the file transaction, not the bootstrap wire phase. +- **GUI as automatic default.** After this stage and distribution are proven: + display detection, `PMACS_FRONTEND`, and `-nw` precedence can make bare + `pmacs FILE` select GPU. +- **Distribution/install bundles.** Ensure root and GPU binaries land together + before making GPU the default. +- **Automatic reconnect/resync.** Startup target success does not reconcile an + optimistic replica after a later disconnect. +- **Remote GPU paths.** Raw Unix path bytes and launcher cwd are intentionally + local. Remote transports need an explicit remote-cwd/path authority model. +- **Daemon services and idle shutdown.** Unchanged from #141's deferral. +- **Direct `pmacs-gpu` target syntax.** Normal users go through root; advanced + raw-socket attach stays attach-only. + +## Approval boundary + +Approval of this framing authorizes one implementation branch/PR for +`pmacs --gpu [--socket …] FILE`, protocol v20 semantic bootstrap, exact Unix +path/cwd transport, target-before-first-draw readiness, and the acceptance +matrix above. It does not authorize automatic GUI selection, multiple files, +or a general open-command protocol. diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 013aa15..7dd97a9 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -19,6 +19,7 @@ use std::collections::VecDeque; use std::fs; use std::io; +use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::FileTypeExt; use std::os::unix::net::UnixStream; use std::os::unix::process::CommandExt; @@ -30,14 +31,25 @@ use std::time::{Duration, Instant}; use pmacs_protocol::{ AttachRequest, BufferId, ByteRange, CellCoord, CellSize, CrdtOp, FrontendCapabilities, - FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, MouseKind, - PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS, TransportError, - is_supported_protocol_version, read_message, write_message, + FrontendEvent, FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceMessage, Key, + KeyEvent, Modifiers, MouseKind, PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS, + SessionBootstrapRequest, TransportError, is_supported_protocol_version, read_message, + write_message, }; use winit::event_loop::EventLoopProxy; use crate::AppEvent; +/// Private root-broker target operands, kept as exact Unix paths until the +/// protocol-v20 bootstrap frame is serialized. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InitialTargetPaths { + /// Absolute launcher working directory. + pub cwd: PathBuf, + /// Launcher-expanded target path. + pub path: PathBuf, +} + /// Errors the attach client surfaces. Kept narrow on purpose: the /// hello-world fallback is the right recovery for any of these in /// session 3, so the caller's only job is to log + drop back to the @@ -57,6 +69,12 @@ pub enum AttachClientError { /// `BufferSnapshot` ever arrives and the window sits on /// `(connecting...)` forever. We reject up front instead. CapabilityMismatch { missing: Vec<&'static str> }, + /// The requested target requires the protocol-v20 bootstrap envelope. + InitialTargetUnsupported { server: u32 }, + /// The daemon rejected the target before a window was created. + InitialTargetFailed { path: PathBuf, message: String }, + /// The daemon violated the target bootstrap ordering contract. + InitialTargetProtocol(String), } impl AttachClientError { @@ -89,6 +107,20 @@ impl std::fmt::Display for AttachClientError { built with the `crdt` feature (it advertises `crdt_replica` / `semantic_render` \ only on CRDT builds; without them no BufferSnapshot is ever sent)" ), + Self::InitialTargetUnsupported { server } => write!( + f, + "initial target requires daemon protocol v20, but the live daemon speaks v{server}" + ), + Self::InitialTargetFailed { path, message } => { + write!( + f, + "could not open initial target {}: {message}", + path.display() + ) + } + Self::InitialTargetProtocol(message) => { + write!(f, "invalid initial-target bootstrap: {message}") + } } } } @@ -395,6 +427,64 @@ impl Outbox { /// classified under rule (iii) as deferred — a structural answer /// belongs with Q#2's minimap variant or its own protocol thread, /// not session 3's attach loop. +fn read_initial_target_bootstrap( + stream: &mut UnixStream, + display_path: PathBuf, +) -> Result { + let mut snapshot = None; + loop { + let message: InstanceMessage = + read_message(stream).map_err(AttachClientError::Handshake)?; + match message { + candidate @ InstanceMessage::BufferSnapshot { .. } if snapshot.is_none() => { + snapshot = Some(candidate); + } + InstanceMessage::BufferSnapshot { .. } => { + return Err(AttachClientError::InitialTargetProtocol( + "received more than one target snapshot".to_owned(), + )); + } + InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { buffer_id }) => { + let Some(snapshot) = snapshot else { + return Err(AttachClientError::InitialTargetProtocol( + "Opened arrived before BufferSnapshot".to_owned(), + )); + }; + let InstanceMessage::BufferSnapshot { + buffer_id: snapshot_buffer, + .. + } = snapshot + else { + unreachable!("bootstrap snapshot variant checked above"); + }; + if snapshot_buffer != buffer_id { + return Err(AttachClientError::InitialTargetProtocol(format!( + "Opened named {buffer_id:?}, snapshot named {snapshot_buffer:?}" + ))); + } + return Ok(snapshot); + } + InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message }) => { + return Err(AttachClientError::InitialTargetFailed { + path: display_path, + message, + }); + } + InstanceMessage::Goodbye(reason) => { + return Err(AttachClientError::InitialTargetProtocol(format!( + "daemon closed bootstrap: {reason:?}" + ))); + } + other => { + return Err(AttachClientError::InitialTargetProtocol(format!( + "unexpected {} before target readiness", + crate::instance_message_label(&other) + ))); + } + } + } +} + pub fn connect( socket_path: &Path, proxy: EventLoopProxy, @@ -418,11 +508,16 @@ pub fn connect_with_sink( sink: impl Fn(AttachEvent) -> bool + Send + 'static, ) -> Result { let stream = UnixStream::connect(socket_path).map_err(AttachClientError::Connect)?; - connect_stream_with_sink(stream, sink) + connect_stream_with_sink(stream, None, sink) } +#[allow( + clippy::too_many_lines, + reason = "the synchronous handshake and thread startup remain one ordered transport transaction" +)] fn connect_stream_with_sink( stream: UnixStream, + initial_target: Option, sink: impl Fn(AttachEvent) -> bool + Send + 'static, ) -> Result { // Hello round-trip. @@ -453,6 +548,12 @@ fn connect_stream_with_sink( return Err(AttachClientError::CapabilityMismatch { missing }); } + if initial_target.is_some() && hello.protocol_version < 20 { + return Err(AttachClientError::InitialTargetUnsupported { + server: hello.protocol_version, + }); + } + // AttachRequest — declare the capabilities a semantic frontend // needs. `multi_frontend` is included because the existing daemon // gates `crdt_replica` behind it (M10.x dependency). @@ -477,6 +578,21 @@ fn connect_stream_with_sink( }; write_message(&mut handshake_stream, &req).map_err(AttachClientError::Handshake)?; + let target_display_path = initial_target.as_ref().map(|target| target.path.clone()); + if hello.protocol_version >= 20 { + let bootstrap = SessionBootstrapRequest { + initial_target: initial_target.map(|target| InitialTarget { + cwd: target.cwd.as_os_str().as_bytes().to_vec(), + path: target.path.as_os_str().as_bytes().to_vec(), + }), + }; + write_message(&mut handshake_stream, &bootstrap).map_err(AttachClientError::Handshake)?; + } + let initial_message = match target_display_path { + Some(path) => Some(read_initial_target_bootstrap(&mut handshake_stream, path)?), + None => None, + }; + // Split read/write halves for the reader thread + writer thread. // UnixStream clones share the underlying FD with independent // buffer state — safe to read on one clone while the other writes @@ -563,27 +679,33 @@ fn connect_stream_with_sink( shutdown_handle, frontend_id: hello.assigned_frontend_id, server_protocol_version: hello.protocol_version, + initial_message, }) } const MANAGED_STARTUP_TIMEOUT: Duration = Duration::from_secs(5); const MANAGED_RETRY_INTERVAL: Duration = Duration::from_millis(50); -/// Connect to an existing semantic daemon or start the supplied daemon first. -pub fn connect_managed( +/// Managed attach carrying an optional pre-window initial target. +pub fn connect_managed_with_target( socket_path: &Path, daemon_executable: &Path, + initial_target: Option, proxy: EventLoopProxy, ) -> Result { - connect_managed_with_sink(socket_path, daemon_executable, move |event| { - proxy.send_event(AppEvent::Attach(event)).is_ok() - }) + connect_managed_with_target_and_sink( + socket_path, + daemon_executable, + initial_target, + move |event| proxy.send_event(AppEvent::Attach(event)).is_ok(), + ) } -/// Managed attach with a caller-provided decoded-event sink. -pub fn connect_managed_with_sink( +/// Managed attach with both a target and caller-provided event sink. +pub fn connect_managed_with_target_and_sink( socket_path: &Path, daemon_executable: &Path, + initial_target: Option, sink: impl Fn(AttachEvent) -> bool + Send + 'static, ) -> Result { connect_managed_inner( @@ -593,6 +715,7 @@ pub fn connect_managed_with_sink( spawn_daemon, MANAGED_STARTUP_TIMEOUT, MANAGED_RETRY_INTERVAL, + initial_target, sink, ) } @@ -660,6 +783,7 @@ fn connect_managed_inner( spawner: S, timeout: Duration, retry_interval: Duration, + initial_target: Option, sink: F, ) -> Result where @@ -669,7 +793,7 @@ where { match connector(socket_path) { Ok(stream) => { - let client = connect_stream_with_sink(stream, sink)?; + let client = connect_stream_with_sink(stream, initial_target, sink)?; return Ok(ManagedAttach { client, daemon: ManagedDaemonFacts::existing(), @@ -695,7 +819,7 @@ where loop { match connector(socket_path) { Ok(stream) => { - let client = connect_stream_with_sink(stream, sink)?; + let client = connect_stream_with_sink(stream, initial_target, sink)?; return Ok(ManagedAttach { client, daemon }); } Err(error) => { @@ -739,6 +863,8 @@ pub struct AttachClient { /// than the daemon (e.g. `Pointer`, v5) must be gated on this — /// an older daemon hard-errors decoding an unknown variant. server_protocol_version: u32, + /// Target snapshot retained across the pre-window readiness barrier. + initial_message: Option, } impl AttachClient { @@ -747,6 +873,11 @@ impl AttachClient { self.frontend_id } + /// Take the target snapshot that must be applied before first redraw. + pub fn take_initial_message(&mut self) -> Option { + self.initial_message.take() + } + /// Send a `FrontendEvent::Viewport` to the daemon. The daemon's /// `SemanticRenderState::set_viewport` feeds the spans producer; /// without this call the daemon ships no `StyleSpans` for the @@ -912,6 +1043,9 @@ impl AttachClient { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + use pmacs_protocol::{InstanceCapabilities, InstanceIdentity, MouseButton}; fn caps( @@ -926,6 +1060,91 @@ mod tests { } } + fn hello(protocol_version: u32) -> Hello { + Hello { + protocol_version, + assigned_frontend_id: FrontendId(7), + instance_identity: InstanceIdentity { + pmacs_version: "test".to_owned(), + build_hash: None, + instance_name: None, + uptime_secs: 0, + working_directory: "/tmp".to_owned(), + }, + instance_capabilities: caps(true, true, true), + } + } + + #[test] + fn initial_target_bootstrap_is_synchronous_byte_exact_and_snapshot_first() { + let (client_stream, mut server_stream) = UnixStream::pair().expect("socketpair"); + let raw_path = OsString::from_vec(vec![b'n', b'o', b't', b'e', 0xff]); + let paths = InitialTargetPaths { + cwd: PathBuf::from("/launcher"), + path: PathBuf::from(&raw_path), + }; + let expected = paths.clone(); + let server = thread::spawn(move || { + write_message(&mut server_stream, &hello(PROTOCOL_VERSION)).expect("write Hello"); + let _: AttachRequest = read_message(&mut server_stream).expect("read AttachRequest"); + let bootstrap: SessionBootstrapRequest = + read_message(&mut server_stream).expect("read bootstrap"); + let target = bootstrap.initial_target.expect("initial target"); + assert_eq!(target.cwd, expected.cwd.as_os_str().as_bytes()); + assert_eq!(target.path, expected.path.as_os_str().as_bytes()); + + let buffer_id = BufferId::from_raw(41); + write_message( + &mut server_stream, + &InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot: vec![1, 2, 3], + }, + ) + .expect("write target snapshot"); + write_message( + &mut server_stream, + &InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { buffer_id }), + ) + .expect("write target result"); + }); + + let mut client = connect_stream_with_sink(client_stream, Some(paths), |_| true) + .expect("target bootstrap"); + assert!(matches!( + client.take_initial_message(), + Some(InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + }) if buffer_id == BufferId::from_raw(41) && crdt_snapshot == [1, 2, 3] + )); + assert!(client.take_initial_message().is_none()); + server.join().expect("bootstrap server"); + } + + #[test] + fn initial_target_fails_before_attach_on_legacy_protocol() { + let (client_stream, mut server_stream) = UnixStream::pair().expect("socketpair"); + let server = thread::spawn(move || { + write_message(&mut server_stream, &hello(19)).expect("write legacy Hello"); + }); + let Err(error) = connect_stream_with_sink( + client_stream, + Some(InitialTargetPaths { + cwd: PathBuf::from("/launcher"), + path: PathBuf::from("note"), + }), + |_| true, + ) else { + panic!("legacy target must fail"); + }; + assert!(matches!( + error, + AttachClientError::InitialTargetUnsupported { server: 19 } + )); + server.join().expect("legacy server"); + } + #[test] fn full_crdt_daemon_advertises_everything_required() { // A daemon built with `--features crdt` advertises all three — the @@ -1167,6 +1386,7 @@ mod tests { shutdown_handle: b, frontend_id: FrontendId::LOCAL, server_protocol_version: PROTOCOL_VERSION, + initial_message: None, }; // A send against the closed outbox fails *and* shuts the socket // down (F-008 fail-fast is now a real teardown, not just a flag). @@ -1245,6 +1465,7 @@ mod tests { |_, _| panic!("non-socket path must not spawn"), Duration::from_millis(1), Duration::from_millis(1), + None, |_| false, ); assert!(matches!( @@ -1269,6 +1490,7 @@ mod tests { |_, _| panic!("permission failure must not spawn"), Duration::from_millis(1), Duration::from_millis(1), + None, |_| false, ); assert!(matches!( @@ -1318,6 +1540,7 @@ mod tests { |_, _| Command::new("/bin/sh").args(["-c", "exit 0"]).spawn(), Duration::from_secs(1), Duration::ZERO, + None, |_| true, ) .expect("transient sequence must attach"); diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 98f08d5..29acbe3 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -26,6 +26,8 @@ mod attach; mod terminal; use std::collections::HashMap; +use std::ffi::OsString; +use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -54,7 +56,7 @@ use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::keyboard::{Key, NamedKey}; use winit::window::{Window, WindowId}; -use crate::attach::{AttachClient, AttachEvent}; +use crate::attach::{AttachClient, AttachEvent, InitialTargetPaths}; use crate::terminal::{TerminalPaintPlan, TerminalPalette}; /// Bundled font (SIL Open Font License 1.1 — see `fonts/OFL.txt`). @@ -556,6 +558,7 @@ enum Mode { ManagedAttach { socket: PathBuf, daemon_executable: PathBuf, + initial_target: Option, }, /// `pmacs-gpu --headless-probe `: attach through /// the real client, render real frames offscreen, and write a @@ -572,6 +575,7 @@ enum Mode { socket: PathBuf, report: PathBuf, daemon_executable: PathBuf, + initial_target: Option, }, } @@ -590,7 +594,7 @@ fn decimal_digits(mut n: usize) -> u32 { fn main() { env_logger::init(); - let mode = match parse_args(&std::env::args().skip(1).collect::>()) { + let mode = match parse_args(&std::env::args_os().skip(1).collect::>()) { Ok(mode) => mode, Err(error) => { eprintln!("pmacs-gpu: {error}\n\n{GPU_USAGE}"); @@ -617,11 +621,13 @@ fn main() { socket, report, daemon_executable, + initial_target, } => { std::process::exit(run_headless_managed_probe( socket, report, daemon_executable, + initial_target.clone(), )); } Mode::Attach { .. } | Mode::ManagedAttach { .. } => {} @@ -631,27 +637,40 @@ fn main() { .build() .expect("create winit event loop"); let proxy = event_loop.create_proxy(); - let attach_client = if let Mode::ManagedAttach { + let (attach_client, pending_events) = if let Mode::ManagedAttach { socket, daemon_executable, + initial_target, } = &mode { - match attach::connect_managed(socket, daemon_executable, proxy.clone()) { - Ok(managed) => Some(managed.client), + match attach::connect_managed_with_target( + socket, + daemon_executable, + initial_target.clone(), + proxy.clone(), + ) { + Ok(mut managed) => { + let pending = managed + .client + .take_initial_message() + .map(|message| vec![AppEvent::Attach(AttachEvent::Message(Box::new(message)))]) + .unwrap_or_default(); + (Some(managed.client), pending) + } Err(error) => { eprintln!("pmacs-gpu: managed attach failed: {error}"); std::process::exit(1); } } } else { - None + (None, Vec::new()) }; let mut app = App { mode, proxy: Some(proxy), state: None, attach_client, - pending_events: Vec::new(), + pending_events, modifiers: winit::keyboard::ModifiersState::empty(), }; event_loop @@ -833,15 +852,23 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { clippy::too_many_lines, reason = "one linear managed-connect and lifecycle observation probe" )] -fn run_headless_managed_probe(socket: &Path, report: &Path, daemon_executable: &Path) -> i32 { +fn run_headless_managed_probe( + socket: &Path, + report: &Path, + daemon_executable: &Path, + initial_target: Option, +) -> i32 { use std::io::Read as _; use std::sync::mpsc; use std::time::{Duration, Instant}; - let (event_tx, event_rx) = mpsc::channel::(); - let managed = match attach::connect_managed_with_sink(socket, daemon_executable, move |event| { - event_tx.send(event).is_ok() - }) { + let (connector_tx, event_rx) = mpsc::channel::(); + let managed = match attach::connect_managed_with_target_and_sink( + socket, + daemon_executable, + initial_target, + move |event| connector_tx.send(event).is_ok(), + ) { Ok(managed) => managed, Err(error) => { let contents = format!("phase=error\nerror={error}\n"); @@ -850,7 +877,11 @@ fn run_headless_managed_probe(socket: &Path, report: &Path, daemon_executable: & return 4; } }; - let client = managed.client; + let mut client = managed.client; + let initial_target_ready = matches!( + client.take_initial_message(), + Some(InstanceMessage::BufferSnapshot { .. }) + ); let daemon = managed.daemon; let protocol = client.server_protocol_version(); @@ -865,12 +896,22 @@ fn run_headless_managed_probe(socket: &Path, report: &Path, daemon_executable: & .expect("spawn managed probe stdin reader"); let deadline = Instant::now() + Duration::from_secs(20); - let mut ready = false; + let mut ready = initial_target_ready; let mut stdin_closed = false; let mut disconnect = String::new(); let mut last_reaped = false; let mut last_wait_result = None; let mut last_disconnect = String::new(); + if ready + && let Err(error) = + write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) + { + eprintln!( + "pmacs-gpu managed probe: writing {} failed: {error}", + report.display() + ); + return 5; + } loop { if stdin_rx.try_recv().is_ok() { stdin_closed = true; @@ -1019,7 +1060,7 @@ const GPU_USAGE: &str = "\ pmacs-gpu — GPU frontend for pmacs NORMAL STARTUP: - pmacs --gpu [--socket NAME|PATH] start or reuse a managed daemon + pmacs --gpu [--socket NAME|PATH] [FILE] start/reuse a daemon and open FILE ADVANCED DIRECT ATTACH: pmacs-gpu --attach attach to an existing daemon only @@ -1029,58 +1070,124 @@ OPTIONS: pmacs-gpu --version print package and protocol versions"; /// Strict parser for direct, managed, and headless GPU entry points. -fn parse_args(args: &[String]) -> Result { - if let [flag, operands @ ..] = args - && matches!( - flag.as_str(), - "--attach" | "--managed-attach" | "--headless-probe" | "--headless-managed-probe" - ) - && let Some(operand) = operands.iter().find(|operand| operand.starts_with('-')) +#[allow( + clippy::too_many_lines, + reason = "one exact-arity parser keeps private GPU entry points visibly fail-closed" +)] +fn parse_args(args: &[OsString]) -> Result { + fn option_like(value: &OsString) -> bool { + value.as_os_str().as_bytes().starts_with(b"-") + } + + fn reject_option_like(command: &str, operands: &[&OsString]) -> Result<(), String> { + if let Some(operand) = operands.iter().find(|operand| option_like(operand)) { + return Err(format!( + "{command} received option-like path operand {}; prefix it with ./ if it is a path", + operand.to_string_lossy() + )); + } + Ok(()) + } + + fn target(cwd: &OsString, path: &OsString) -> InitialTargetPaths { + InitialTargetPaths { + cwd: PathBuf::from(cwd), + path: PathBuf::from(path), + } + } + + if let Some(flag) = args.first() + && option_like(flag) + && flag.to_str().is_none() { - return Err(format!( - "{flag} received option-like path operand {operand}; prefix it with ./ if it is a path" - )); + return Err("option names must be valid UTF-8".to_owned()); } match args { [flag] if flag == "--help" || flag == "-h" => Ok(Mode::Help), [flag] if flag == "--version" || flag == "-V" => Ok(Mode::Version), - [flag, socket] if flag == "--attach" => Ok(Mode::Attach { - socket: PathBuf::from(socket), - }), + [flag, socket] if flag == "--attach" => { + reject_option_like("--attach", &[socket])?; + Ok(Mode::Attach { + socket: PathBuf::from(socket), + }) + } [flag, socket, daemon_executable] if flag == "--managed-attach" => { + reject_option_like("--managed-attach", &[socket, daemon_executable])?; Ok(Mode::ManagedAttach { socket: PathBuf::from(socket), daemon_executable: PathBuf::from(daemon_executable), + initial_target: None, + }) + } + [flag, socket, daemon_executable, marker, cwd, path] + if flag == "--managed-attach" && marker == "--initial-target" => + { + reject_option_like("--managed-attach", &[socket, daemon_executable, cwd])?; + Ok(Mode::ManagedAttach { + socket: PathBuf::from(socket), + daemon_executable: PathBuf::from(daemon_executable), + initial_target: Some(target(cwd, path)), + }) + } + [flag, socket, report] if flag == "--headless-probe" => { + reject_option_like("--headless-probe", &[socket, report])?; + Ok(Mode::HeadlessProbe { + socket: PathBuf::from(socket), + report: PathBuf::from(report), }) } - [flag, socket, report] if flag == "--headless-probe" => Ok(Mode::HeadlessProbe { - socket: PathBuf::from(socket), - report: PathBuf::from(report), - }), [flag, socket, report, daemon_executable] if flag == "--headless-managed-probe" => { + reject_option_like( + "--headless-managed-probe", + &[socket, report, daemon_executable], + )?; Ok(Mode::HeadlessManagedProbe { socket: PathBuf::from(socket), report: PathBuf::from(report), daemon_executable: PathBuf::from(daemon_executable), + initial_target: None, + }) + } + [flag, socket, report, daemon_executable, marker, cwd, path] + if flag == "--headless-managed-probe" && marker == "--initial-target" => + { + reject_option_like( + "--headless-managed-probe", + &[socket, report, daemon_executable, cwd], + )?; + Ok(Mode::HeadlessManagedProbe { + socket: PathBuf::from(socket), + report: PathBuf::from(report), + daemon_executable: PathBuf::from(daemon_executable), + initial_target: Some(target(cwd, path)), }) } [] => Err( "managed startup is provided by `pmacs --gpu`; direct use requires --attach " .to_owned(), ), - [flag, ..] if matches!(flag.as_str(), "--help" | "-h" | "--version" | "-V") => { - Err(format!("{flag} does not accept operands")) + [flag, ..] if flag == "--help" || flag == "-h" || flag == "--version" || flag == "-V" => { + Err(format!( + "{} does not accept operands", + flag.to_string_lossy() + )) } [flag, ..] - if matches!( - flag.as_str(), - "--attach" | "--managed-attach" | "--headless-probe" | "--headless-managed-probe" - ) => + if flag == "--attach" + || flag == "--managed-attach" + || flag == "--headless-probe" + || flag == "--headless-managed-probe" => { - Err(format!("{flag} received the wrong number of operands")) + Err(format!( + "{} received the wrong number of operands", + flag.to_string_lossy() + )) } - [other, ..] => Err(format!("unrecognized argument: {other}")), + [other, ..] => Err(format!( + "unrecognized argument: {}", + other.to_string_lossy() + )), } } @@ -8123,6 +8230,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::FontFacts { .. } => "FontFacts", InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments", InstanceMessage::TerminalFrame(_) => "TerminalFrame", + InstanceMessage::InitialTargetResult(_) => "InitialTargetResult", } } @@ -14263,12 +14371,7 @@ mod tests { } #[test] fn gpu_cli_accepts_only_explicit_exact_modes() { - let args = |values: &[&str]| { - values - .iter() - .map(|value| (*value).to_owned()) - .collect::>() - }; + let args = |values: &[&str]| values.iter().map(OsString::from).collect::>(); assert_eq!( parse_args(&args(&["--attach", "/tmp/pmacs.sock"])), Ok(Mode::Attach { @@ -14284,6 +14387,7 @@ mod tests { Ok(Mode::ManagedAttach { socket: PathBuf::from("/tmp/pmacs.sock"), daemon_executable: PathBuf::from("/bin/pmacs"), + initial_target: None, }) ); assert_eq!( @@ -14308,10 +14412,56 @@ mod tests { socket: PathBuf::from("/tmp/pmacs.sock"), report: PathBuf::from("/tmp/report"), daemon_executable: PathBuf::from("/bin/pmacs"), + initial_target: None, }) ); } + #[test] + fn gpu_private_target_marker_preserves_raw_file_bytes() { + use std::os::unix::ffi::OsStringExt; + + let raw_path = OsString::from_vec(vec![b'-', b'n', b'o', b't', b'e', 0xff]); + let argv = vec![ + OsString::from("--managed-attach"), + OsString::from("/tmp/pmacs.sock"), + OsString::from("/bin/pmacs"), + OsString::from("--initial-target"), + OsString::from("/launcher"), + raw_path.clone(), + ]; + assert_eq!( + parse_args(&argv), + Ok(Mode::ManagedAttach { + socket: PathBuf::from("/tmp/pmacs.sock"), + daemon_executable: PathBuf::from("/bin/pmacs"), + initial_target: Some(InitialTargetPaths { + cwd: PathBuf::from("/launcher"), + path: PathBuf::from(&raw_path), + }), + }) + ); + + let bad_cwd = [ + OsString::from("--managed-attach"), + OsString::from("/tmp/pmacs.sock"), + OsString::from("/bin/pmacs"), + OsString::from("--initial-target"), + OsString::from("--cwd"), + OsString::from("note"), + ]; + assert!( + parse_args(&bad_cwd) + .expect_err("option-like cwd must fail") + .contains("option-like") + ); + let bad_option = [OsString::from_vec(vec![b'-', 0xff])]; + assert_eq!( + parse_args(&bad_option).expect_err("non-UTF-8 option must fail"), + "option names must be valid UTF-8" + ); + } + #[test] fn gpu_cli_rejects_bare_missing_and_trailing_arguments() { let invalid = [ @@ -14339,16 +14489,13 @@ mod tests { vec!["research"], ]; for values in invalid { - let args = values - .iter() - .map(|value| (*value).to_owned()) - .collect::>(); + let args = values.iter().map(OsString::from).collect::>(); assert!( parse_args(&args).is_err(), "accepted invalid argv: {values:?}" ); } - let error = parse_args(&["--attach".to_owned(), "--help".to_owned()]) + let error = parse_args(&[OsString::from("--attach"), OsString::from("--help")]) .expect_err("option-like socket operand must fail"); assert!(error.contains("option-like path operand --help")); } @@ -14384,7 +14531,7 @@ mod tests { assert!(GPU_USAGE.contains("NORMAL STARTUP")); assert!(GPU_USAGE.contains("ADVANCED DIRECT ATTACH")); - let extra = ["--help", "extra"].map(str::to_owned); + let extra = ["--help", "extra"].map(OsString::from); let error = parse_args(&extra).expect_err("help operands must fail"); assert_eq!(error, "--help does not accept operands"); } diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index e7d6026..ce2d2c5 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -57,12 +57,13 @@ pub use ids::{BufferId, ByteRange, FrontendId, Position}; pub use message::{ AdornmentContent, AdornmentPlacement, AttachRequest, BUILTIN_PAIR_CHARS, BlockAdornment, CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment, - FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InlineAdornment, - InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent, - LineNumberMode, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, - MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, - MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, - PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, + FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InitialTarget, InitialTargetResult, + InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, + KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, + MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, MAX_STATUSLINE_PROVIDERS, + MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers, + MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, + ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities, }; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 79db7e7..971a78e 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -1137,6 +1137,12 @@ pub enum InstanceMessage { /// Appended after [`Self::StatuslineSegments`], the final v18 /// variant, so no existing postcard discriminant moves. TerminalFrame(crate::terminal::TerminalFrame), + /// GPU initial-target bootstrap result (protocol v20). Sent only to a + /// semantic session that supplied [`SessionBootstrapRequest::initial_target`]. + /// + /// Appended after [`Self::TerminalFrame`], the final v19 variant, so no + /// legacy postcard discriminant moves. + InitialTargetResult(InitialTargetResult), } /// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full @@ -1553,7 +1559,13 @@ pub enum ResourceBody { /// with no terminal surface at all. This is the first bump to gate in /// BOTH directions at once, which is why criterion 28 pins the two /// send filters independently. -pub const PROTOCOL_VERSION: u32 = 19; +/// +/// GPU initial target (Q#GT4): bumped 19 → 20 for the semantic-session +/// bootstrap envelope and [`InstanceMessage::InitialTargetResult`]. The +/// handshake extension is read only from v20 semantic sessions; the result is +/// sent only when such a session requested a target. v6–v19 handshakes and +/// message discriminants remain unchanged. +pub const PROTOCOL_VERSION: u32 = 20; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1627,8 +1639,12 @@ pub const PROTOCOL_VERSION: u32 = 19; /// additive in both directions — `TerminalFrame` is daemon-gated, /// `TerminalResize` / `TerminalPointer` are frontend-gated — so v18 and /// v19 binaries interoperate with terminal traffic simply absent. +/// +/// GPU initial target (Q#GT4): extended to `[6, ..., 20]`. v20 semantic +/// sessions send a bounded bootstrap envelope after `AttachRequest`; legacy +/// and non-semantic sessions retain their existing handshake shape. pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = - &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; + &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. @@ -2038,3 +2054,47 @@ pub struct AttachRequest { /// instance uses this for the initial full-grid render. pub initial_size: CellSize, } + +/// Maximum byte length of either raw Unix path in an initial-target request. +pub const MAX_INITIAL_TARGET_PATH_BYTES: usize = 32 * 1024; + +/// Maximum UTF-8 byte length of a daemon-produced initial-target error. +pub const MAX_INITIAL_TARGET_ERROR_BYTES: usize = 4 * 1024; + +/// Raw local paths for a semantic session's pre-window initial target. +/// +/// Both fields are Unix path bytes rather than display text. The daemon +/// validates the byte bounds, absolute `cwd`, nonempty fields, and embedded +/// NULs before constructing paths. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct InitialTarget { + /// Absolute launcher working-directory bytes. + pub cwd: Vec, + /// Launcher-expanded target path bytes, absolute or relative to `cwd`. + pub path: Vec, +} + +/// Protocol-v20 semantic-session bootstrap extension. +/// +/// A v20 semantic frontend sends this immediately after [`AttachRequest`]. +/// `None` preserves ordinary attach behavior. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SessionBootstrapRequest { + /// Optional file that must be ready before the frontend creates a window. + pub initial_target: Option, +} + +/// Pre-window outcome for a requested initial target. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum InitialTargetResult { + /// The target snapshot was written and the session is ready. + Opened { + /// Buffer identified by the immediately preceding target snapshot. + buffer_id: crate::BufferId, + }, + /// Bootstrap failed; the provisional session has been removed. + Failed { + /// Bounded user-facing daemon detail. + message: String, + }, +} diff --git a/src/daemon.rs b/src/daemon.rs index 141623b..98a688a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -49,7 +49,10 @@ //! - Write fails (broken pipe) → return; ungraceful disconnect. use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; use std::io::ErrorKind; +use std::net::Shutdown; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -64,9 +67,10 @@ use crate::lockfile::{self, LockError, LockHandle}; use crate::presence::{PresenceSnapshot, SessionRegistry}; use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm}; use crate::protocol::{ - AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InstanceCapabilities, - InstanceIdentity, InstanceMessage, InstanceSignal, PROTOCOL_VERSION, PointerKind, - SelectionSnapshot, + AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget, + InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, + MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PROTOCOL_VERSION, PointerKind, + SelectionSnapshot, SessionBootstrapRequest, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; use crate::transport::{read_message, write_message}; @@ -114,6 +118,8 @@ enum DispatcherEvent { frontend_id: FrontendId, session_state: crate::presence::SessionState, initial_size: CellSize, + /// Validated protocol-v20 semantic bootstrap target, if requested. + initial_target: Option, /// Write-half of the per-attach stream. The dispatcher owns /// this end; the per-attach reader thread keeps the /// read-half via `try_clone`. @@ -633,6 +639,48 @@ fn install_signal_handlers(shutdown: &Arc) -> Result<(), DaemonError Ok(()) } +fn bounded_initial_target_error(mut message: String) -> String { + if message.len() <= MAX_INITIAL_TARGET_ERROR_BYTES { + return message; + } + let mut end = MAX_INITIAL_TARGET_ERROR_BYTES; + while !message.is_char_boundary(end) { + end -= 1; + } + message.truncate(end); + message +} + +fn send_initial_target_failure(stream: &mut UnixStream, message: impl Into) { + let result = InitialTargetResult::Failed { + message: bounded_initial_target_error(message.into()), + }; + let _ = write_message(stream, &InstanceMessage::InitialTargetResult(result)); + let _ = stream.shutdown(Shutdown::Both); +} + +fn validate_initial_target(target: &InitialTarget) -> Result<(), String> { + if target.cwd.is_empty() { + return Err("initial target cwd is empty".to_owned()); + } + if target.path.is_empty() { + return Err("initial target path is empty".to_owned()); + } + if target.cwd.len() > MAX_INITIAL_TARGET_PATH_BYTES { + return Err("initial target cwd exceeds 32 KiB".to_owned()); + } + if target.path.len() > MAX_INITIAL_TARGET_PATH_BYTES { + return Err("initial target path exceeds 32 KiB".to_owned()); + } + if target.cwd.contains(&0) || target.path.contains(&0) { + return Err("initial target path contains an embedded NUL".to_owned()); + } + if !Path::new(OsStr::from_bytes(&target.cwd)).is_absolute() { + return Err("initial target cwd is not absolute".to_owned()); + } + Ok(()) +} + /// T M10.8 — per-attach thread. Runs handshake on a fresh thread for /// each accepted connection; on success, sends `SessionEstablished` /// to the dispatcher and transitions to reader behavior on the same @@ -644,7 +692,11 @@ fn install_signal_handlers(shutdown: &Arc) -> Result<(), DaemonError /// error) the thread writes a `Goodbye` variant and exits without /// notifying the dispatcher. The dispatcher never learns about /// failed handshakes. -#[allow(clippy::needless_pass_by_value)] +#[allow( + clippy::needless_pass_by_value, + clippy::too_many_lines, + reason = "the ordered handshake and bootstrap read stay on one per-connection thread" +)] fn per_attach_thread( mut stream: UnixStream, daemon_state: Arc, @@ -712,6 +764,28 @@ fn per_attach_thread( } }; + // Q#GT4 — v20 semantic sessions send one bootstrap envelope after + // AttachRequest. Legacy and non-semantic sessions retain the exact + // two-message handshake and therefore must not be read here. + let initial_target = if req.protocol_version >= 20 && negotiated_caps.semantic_render { + let bootstrap: SessionBootstrapRequest = match read_message(&mut stream) { + Ok(bootstrap) => bootstrap, + Err(e) => { + eprintln!("pmacs: read SessionBootstrapRequest failed: {e}"); + return; + } + }; + if let Some(target) = bootstrap.initial_target.as_ref() + && let Err(message) = validate_initial_target(target) + { + send_initial_target_failure(&mut stream, message); + return; + } + bootstrap.initial_target + } else { + None + }; + // T M10.8 Day 4 — Q5 non-multi-session admission control. let _non_multi_guard = if negotiated_caps.multi_frontend { None @@ -760,6 +834,7 @@ fn per_attach_thread( frontend_id, session_state, initial_size: req.initial_size, + initial_target, write_stream, }) .is_err() @@ -1012,12 +1087,12 @@ fn dispatcher_loop( .is_some_and(|s| s.negotiated_capabilities.crdt_replica) { // F29 — when a mid-session upgrade occurs, push a - // `BufferSnapshot` for the newly-CRDT-backed buffer - // to every currently-attached replica so their - // `BufferMirror`s gain an entry for it. Without - // this, replicas attached before the upgrade - // permanently fall back to v0.1 round-trip on that - // buffer. + // `BufferSnapshot` to every grid replica so its + // `BufferMirror` gains an entry for the buffer. A + // semantic replica receives it only when that replica + // is displaying this buffer: applying a foreign-buffer + // snapshot would switch the GPU window away from its + // own active view. if let Some(upgraded) = ensure_active_buffer_crdt_backed(editor, *fid) { broadcast_buffer_snapshot_to_replicas( editor, @@ -1526,8 +1601,146 @@ fn take_pending_terminal_bell( } } +struct OpenedInitialTarget { + buffer_id: crate::buffer::BufferId, + publish_to_replicas: bool, +} + +struct InitialTargetSnapshot { + crdt_snapshot: Vec, + upgraded_to_crdt: bool, +} + +fn resolve_initial_target(target: InitialTarget) -> PathBuf { + let cwd = PathBuf::from(OsString::from_vec(target.cwd)); + let path = PathBuf::from(OsString::from_vec(target.path)); + let absolute = if path.is_absolute() { + path + } else { + cwd.join(path) + }; + crate::editor_core::lexical_normalize(&absolute) +} + +fn open_initial_target( + editor: &mut EditorState, + frontend_id: FrontendId, + target: InitialTarget, +) -> Result { + let path = resolve_initial_target(target); + let display_path = path.display().to_string(); + let (buffer_id, newly_loaded, newly_created) = { + let mut core = editor.core.borrow_mut(); + core.active_frontend = frontend_id; + let (buffer_id, newly_loaded, newly_created) = match core.get_or_load_buffer(&path) { + Ok((buffer_id, newly_loaded)) => (buffer_id, newly_loaded, false), + Err(error) if error.kind() == ErrorKind::NotFound => { + let buffer_id = core.registry.borrow_mut().create(display_path.clone()); + core.set_buffer_path(buffer_id, Some(path.clone())); + "[new file]".clone_into(&mut core.status); + (buffer_id, false, true) + } + Err(error) => { + return Err(format!("cannot open {}: {error}", path.display())); + } + }; + core.switch_active_buffer_for(frontend_id, buffer_id) + .map_err(|error| format!("cannot select {}: {error}", path.display()))?; + (buffer_id, newly_loaded, newly_created) + }; + + if newly_loaded { + editor + .lua_host + .run_hook("buffer.after-load", mlua::MultiValue::new()); + } else if !newly_created { + // Dedup is a logical switch even when the fresh view already shares + // this BufferId; configuration must observe it exactly once. + editor + .lua_host + .run_hook("buffer.after-switch", mlua::MultiValue::new()); + } + + let mut core = editor.core.borrow_mut(); + core.active_frontend = frontend_id; + if !core.registry.borrow().contains(buffer_id) { + return Err(format!( + "initial target {} was removed by a startup hook", + path.display() + )); + } + core.switch_active_buffer_for(frontend_id, buffer_id) + .map_err(|error| format!("cannot reselect {}: {error}", path.display()))?; + Ok(OpenedInitialTarget { + buffer_id, + publish_to_replicas: newly_loaded || newly_created, + }) +} + +#[cfg(feature = "crdt")] +fn initial_target_snapshot( + editor: &EditorState, + buffer_id: crate::buffer::BufferId, +) -> Result { + let core = editor.core.borrow(); + let mut registry = core.registry.borrow_mut(); + let buffer = registry + .get_mut(buffer_id) + .map_err(|error| format!("initial target buffer disappeared: {error}"))?; + let upgraded_to_crdt = !buffer.is_crdt_backed(); + if upgraded_to_crdt { + let peer_id = crate::crdt::peer_id_from_frontend(FrontendId::LOCAL); + buffer + .upgrade_to_crdt(peer_id) + .map_err(|error| format!("initial target CRDT upgrade failed: {error:?}"))?; + } + let crdt_snapshot = buffer + .crdt_state() + .ok_or_else(|| "initial target CRDT state is unavailable".to_owned())? + .export_snapshot() + .map_err(|error| format!("initial target snapshot export failed: {error:?}"))?; + Ok(InitialTargetSnapshot { + crdt_snapshot, + upgraded_to_crdt, + }) +} + +#[cfg(not(feature = "crdt"))] +fn initial_target_snapshot( + _editor: &EditorState, + _buffer_id: crate::buffer::BufferId, +) -> Result { + Err("initial target requires a CRDT-enabled daemon".to_owned()) +} + +#[allow(clippy::too_many_arguments)] +fn cleanup_provisional_session( + editor: &mut EditorState, + render_states: &mut HashMap, + semantic_states: &mut HashMap, + streams: &mut HashMap, + term_sizes: &mut HashMap, + last_active_buffer_sent: &mut HashMap, + session_registry: &mut SessionRegistry, + frontend_id: FrontendId, +) { + render_states.remove(&frontend_id); + semantic_states.remove(&frontend_id); + if let Some(stream) = streams.remove(&frontend_id) { + let _ = stream.shutdown(Shutdown::Both); + } + term_sizes.remove(&frontend_id); + last_active_buffer_sent.remove(&frontend_id); + session_registry.unregister_session(frontend_id); + editor + .core + .borrow_mut() + .unregister_frontend_view(frontend_id); +} + #[allow( clippy::too_many_arguments, + clippy::too_many_lines, reason = "one session bootstrap transaction" )] fn handle_session_established( @@ -1536,10 +1749,12 @@ fn handle_session_established( semantic_states: &mut HashMap, streams: &mut HashMap, term_sizes: &mut HashMap, + last_active_buffer_sent: &mut HashMap, session_registry: &mut SessionRegistry, frontend_id: FrontendId, session_state: crate::presence::SessionState, initial_size: CellSize, + initial_target: Option, mut write_stream: UnixStream, ) { // Register the frontend's view (M10.8 Day 3: fresh scratch @@ -1551,49 +1766,78 @@ fn handle_session_established( // `RenderState` vs a `SemanticRenderState` below — a grid session // collapses folds, a semantic one keeps raw-line reckoning until // Stage 3. - let scratch_view = build_fresh_frontend_view( + let fresh_view = build_fresh_frontend_view( editor, !session_state.negotiated_capabilities.semantic_render, ); - editor - .core - .borrow_mut() - .register_frontend_view(frontend_id, scratch_view); + { + let mut core = editor.core.borrow_mut(); + core.register_frontend_view(frontend_id, fresh_view); + core.active_frontend = frontend_id; + } + + let opened_target = match initial_target { + Some(target) => match open_initial_target(editor, frontend_id, target) { + Ok(opened) => Some(opened), + Err(message) => { + send_initial_target_failure(&mut write_stream, message); + editor + .core + .borrow_mut() + .unregister_frontend_view(frontend_id); + return; + } + }, + None => None, + }; - // T M10.10: bootstrap the new frontend's `BufferMirror` by - // sending one `BufferSnapshot` per CRDT-backed buffer. Gated on - // the negotiated `crdt_replica` capability — v0.1 / non-replica - // frontends never receive the variant (postcard would hard-error - // on the unknown variant; see M10.10-FRAMING.md Refinement 3). - // Ordering: snapshots are sent BEFORE any CellDelta flows (the - // next per-tick render is the first CellDelta source), so the - // mirror is initialized before any local-edit path can reference - // it. let crdt_replica = session_state.negotiated_capabilities.crdt_replica; - // T M11.2 — a semantic session is always a text replica (the - // negotiation dependency rule guarantees `semantic_render ⇒ - // crdt_replica`), so the `BufferSnapshot` bootstrap below still - // fires: the semantic frontend holds the rope locally and the - // semantic frame ships no text. let semantic_render = session_state.negotiated_capabilities.semantic_render; - // Captured before `register_session` consumes the state: the - // semantic producer needs the peer's version (finding 3 below). let negotiated_protocol_version = session_state.negotiated_protocol_version; - if crdt_replica { + + if let Some(opened) = opened_target.as_ref() { + let target_snapshot = match initial_target_snapshot(editor, opened.buffer_id) { + Ok(snapshot) => snapshot, + Err(message) => { + send_initial_target_failure(&mut write_stream, message); + editor + .core + .borrow_mut() + .unregister_frontend_view(frontend_id); + return; + } + }; + let snapshot_message = InstanceMessage::BufferSnapshot { + buffer_id: opened.buffer_id, + crdt_snapshot: target_snapshot.crdt_snapshot, + }; + if opened.publish_to_replicas || target_snapshot.upgraded_to_crdt { + publish_buffer_snapshot_to_replicas( + editor, + opened.buffer_id, + &snapshot_message, + session_registry, + streams, + semantic_states, + ); + } + if write_message(&mut write_stream, &snapshot_message).is_err() { + let _ = write_stream.shutdown(Shutdown::Both); + editor + .core + .borrow_mut() + .unregister_frontend_view(frontend_id); + return; + } + } else if crdt_replica { + // Legacy no-target attach remains an all-buffer replica bootstrap. send_buffer_snapshots(editor, &mut write_stream); } - // Register the session in the registry (presence + capability - // filters). session_registry.register_session(frontend_id, session_state); - if semantic_render { semantic_states.insert( frontend_id, - // for_peer, not new (PR #120 round 1 finding 3): a v15 - // peer's producer must not resolve faces into the - // FileStyleSummary marks — that channel predates the v16 - // gate. crate::semantic_render::SemanticRenderState::for_peer( frontend_id, negotiated_protocol_version, @@ -1607,9 +1851,30 @@ fn handle_session_established( streams.insert(frontend_id, write_stream); term_sizes.insert(frontend_id, initial_size); - // Stamp active_frontend so the initial render's Lua statusline - // code sees the right fid. - editor.core.borrow_mut().active_frontend = frontend_id; + if let Some(opened) = opened_target { + last_active_buffer_sent.insert(frontend_id, opened.buffer_id); + let result = InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { + buffer_id: opened.buffer_id, + }); + let write_result = { + let stream = streams + .get_mut(&frontend_id) + .expect("new session stream installed"); + write_message(stream, &result) + }; + if write_result.is_err() { + cleanup_provisional_session( + editor, + render_states, + semantic_states, + streams, + term_sizes, + last_active_buffer_sent, + session_registry, + frontend_id, + ); + } + } } #[allow(clippy::too_many_arguments)] @@ -1631,6 +1896,7 @@ fn handle_dispatcher_event( frontend_id, session_state, initial_size, + initial_target, write_stream, } => { handle_session_established( @@ -1639,14 +1905,20 @@ fn handle_dispatcher_event( semantic_states, streams, term_sizes, + last_active_buffer_sent, session_registry, frontend_id, session_state, initial_size, + initial_target, write_stream, ); } DispatcherEvent::FrontendEvent { source, event } => { + if session_registry.session_state(source).is_none() { + eprintln!("pmacs: dropping frontend event from uninstalled session {source:?}"); + return; + } match event { FrontendEvent::Detach(_) => { // The per-attach thread will follow up with a @@ -1861,9 +2133,12 @@ fn handle_dispatcher_event( } } _ => { - let term_size = *term_sizes - .get(&source) - .expect("term_size present for source"); + let Some(&term_size) = term_sizes.get(&source) else { + eprintln!( + "pmacs: dropping frontend event without size state for {source:?}" + ); + return; + }; let mut term_size = term_size; if let Some(render_state) = render_states.get_mut(&source) { apply_event(editor, source, event, &mut term_size, render_state); @@ -1882,10 +2157,8 @@ fn handle_dispatcher_event( // nothing before B1.) apply_semantic_input_event(editor, source, event, term_size); } else { - debug_assert!( - false, - "fid with neither a render_state nor a semantic_state \ - sent a frontend event" + eprintln!( + "pmacs: dropping frontend event without render state for {source:?}" ); } } @@ -2148,28 +2421,56 @@ fn broadcast_buffer_snapshot_to_replicas( streams: &mut HashMap, semantic_states: &mut HashMap, ) { - let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else { + let Some(snapshot) = export_buffer_snapshot(editor, buffer_id) else { return; }; - let msg = InstanceMessage::BufferSnapshot { + let message = InstanceMessage::BufferSnapshot { buffer_id, - crdt_snapshot: snapshot_bytes, + crdt_snapshot: snapshot, }; - for (fid, stream) in streams.iter_mut() { - let is_replica = session_registry - .session_state(*fid) - .is_some_and(|s| s.negotiated_capabilities.crdt_replica); - if !is_replica { + publish_buffer_snapshot_to_replicas( + editor, + buffer_id, + &message, + session_registry, + streams, + semantic_states, + ); +} + +fn publish_buffer_snapshot_to_replicas( + editor: &EditorState, + buffer_id: crate::buffer::BufferId, + message: &InstanceMessage, + session_registry: &SessionRegistry, + streams: &mut HashMap, + semantic_states: &mut HashMap, +) { + for (peer_id, stream) in streams { + let Some(session) = session_registry.session_state(*peer_id) else { + continue; + }; + if !session.negotiated_capabilities.crdt_replica { continue; } - if let Err(e) = write_message(stream, &msg) { - eprintln!("pmacs: F29 send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}"); + if session.negotiated_capabilities.semantic_render { + let displays_buffer = editor + .core + .borrow() + .active_window_for(*peer_id) + .is_some_and(|window| window.buffer_id == buffer_id); + if !displays_buffer { + continue; + } } - // PR #120 round 2 — same reset contract as the follow path: - // the snapshot wiped this replica's buffer-scoped render - // state, so its emission baselines for the buffer die too. - if let Some(sem) = semantic_states.get_mut(fid) { - sem.on_buffer_snapshot_sent(buffer_id); + if let Err(error) = write_message(stream, message) { + eprintln!( + "pmacs: BufferSnapshot publish for {buffer_id:?} to {peer_id:?} failed: {error}" + ); + continue; + } + if let Some(semantic) = semantic_states.get_mut(peer_id) { + semantic.on_buffer_snapshot_sent(buffer_id); } } } @@ -2925,6 +3226,119 @@ mod tests { assert!(!peer_declared_terminal_support(®istry, FrontendId(99))); } + #[cfg(feature = "crdt")] + #[test] + fn snapshot_publication_skips_foreign_semantic_views_and_ignores_dead_peers() { + let mut editor = EditorState::new(); + let semantic_peer = FrontendId(20); + let live_grid_peer = FrontendId(21); + let dead_grid_peer = FrontendId(22); + let semantic_view = build_fresh_frontend_view(&mut editor, false); + editor + .core + .borrow_mut() + .register_frontend_view(semantic_peer, semantic_view); + + let semantic_caps = crate::protocol::NegotiatedCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + }; + let grid_caps = crate::protocol::NegotiatedCapabilities { + semantic_render: false, + ..semantic_caps + }; + let mut registry = SessionRegistry::new(); + registry.register_session( + semantic_peer, + crate::presence::SessionState::new(PROTOCOL_VERSION, semantic_caps, 0), + ); + registry.register_session( + live_grid_peer, + crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 1), + ); + registry.register_session( + dead_grid_peer, + crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 2), + ); + + let (semantic_server, mut semantic_client) = + UnixStream::pair().expect("semantic socketpair"); + let (live_grid_server, mut live_grid_client) = + UnixStream::pair().expect("live grid socketpair"); + let (dead_grid_server, dead_grid_client) = + UnixStream::pair().expect("dead grid socketpair"); + drop(dead_grid_client); + let mut streams = HashMap::from([ + (semantic_peer, semantic_server), + (live_grid_peer, live_grid_server), + (dead_grid_peer, dead_grid_server), + ]); + let published_buffer = crate::buffer::BufferId::from_raw(900); + let message = InstanceMessage::BufferSnapshot { + buffer_id: published_buffer, + crdt_snapshot: vec![1, 2, 3], + }; + + publish_buffer_snapshot_to_replicas( + &editor, + published_buffer, + &message, + ®istry, + &mut streams, + &mut HashMap::new(), + ); + + let delivered: InstanceMessage = + read_message(&mut live_grid_client).expect("live grid snapshot"); + assert_eq!(delivered, message); + semantic_client + .set_read_timeout(Some(Duration::from_millis(50))) + .expect("semantic timeout"); + assert!( + read_message::(&mut semantic_client).is_err(), + "a semantic peer displaying another buffer must receive no snapshot" + ); + } + + #[test] + fn frontend_events_from_uninstalled_sessions_are_dropped_without_state_access() { + let source = FrontendId(77); + let mut editor = EditorState::new(); + let mut render_states = HashMap::new(); + let mut semantic_states = HashMap::new(); + let mut streams = HashMap::new(); + let mut term_sizes = HashMap::new(); + let mut last_dispatch_idle_sent = HashMap::new(); + let mut last_active_buffer_sent = HashMap::new(); + let mut terminal_bell_baselines = HashMap::new(); + let mut session_registry = SessionRegistry::new(); + + handle_dispatcher_event( + DispatcherEvent::FrontendEvent { + source, + event: FrontendEvent::Key(crate::protocol::KeyEvent { + frontend_id: source, + key: crate::protocol::Key::Char('x'), + mods: crate::protocol::Modifiers::NONE, + timestamp_ns: 0, + }), + }, + &mut editor, + &mut render_states, + &mut semantic_states, + &mut streams, + &mut term_sizes, + &mut last_dispatch_idle_sent, + &mut last_active_buffer_sent, + &mut terminal_bell_baselines, + &mut session_registry, + ); + + assert_eq!(editor.core.borrow().active_frontend, FrontendId::LOCAL); + assert!(term_sizes.is_empty()); + } + #[test] fn build_identity_includes_version_and_uptime() { let s = DaemonState::new(Some("research".into())); @@ -3316,6 +3730,18 @@ mod tests { let mut last_active_buffer_sent = HashMap::new(); let mut terminal_bell_baselines = HashMap::new(); let mut session_registry = SessionRegistry::new(); + session_registry.register_session( + source, + crate::presence::SessionState::new( + PROTOCOL_VERSION, + crate::protocol::NegotiatedCapabilities { + multi_frontend: true, + crdt_replica: false, + semantic_render: false, + }, + 0, + ), + ); handle_dispatcher_event( DispatcherEvent::FrontendEvent { diff --git a/src/editor_core.rs b/src/editor_core.rs index 28200cd..37cdabf 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -595,6 +595,9 @@ impl EditorCore { /// closing a window left others intact). pub fn unregister_frontend_view(&mut self, fid: FrontendId) { self.views.remove(&fid); + if self.active_frontend == fid { + self.active_frontend = FrontendId::LOCAL; + } } /// [`BufferId`] of the active window's buffer. @@ -3374,7 +3377,7 @@ fn normalize_buffer_path(path: PathBuf) -> PathBuf { /// `~` becomes `$HOME`; `~/x` becomes `$HOME/x`. `~user` is left /// untouched (no passwd lookup). Returns the input unchanged if it /// has no leading `~`, isn't valid UTF-8, or `$HOME` is unset. -fn expand_tilde(path: PathBuf) -> PathBuf { +pub fn expand_tilde(path: PathBuf) -> PathBuf { let Some(s) = path.to_str() else { return path; }; @@ -3392,7 +3395,7 @@ fn expand_tilde(path: PathBuf) -> PathBuf { /// Fold `.` and `..` components without touching the filesystem. /// `..` pops a preceding normal segment; against the root (or a /// Windows prefix) it is dropped, since you cannot ascend past it. -fn lexical_normalize(path: &Path) -> PathBuf { +pub(crate) fn lexical_normalize(path: &Path) -> PathBuf { use std::path::Component; let mut stack: Vec = Vec::new(); for comp in path.components() { @@ -3874,13 +3877,16 @@ mod tests { // we don't need a fresh window allocation in this test. let local_view = s.views[&FrontendId::LOCAL].clone(); s.register_frontend_view(fid, local_view); + s.active_frontend = fid; assert!(s.active_window_for(fid).is_some()); // Unregister drops the entry; explicit lookup returns None. s.unregister_frontend_view(fid); assert!(s.active_window_for(fid).is_none()); - // LOCAL invariant survives unrelated register/unregister. + // Removing the selected frontend restores the always-registered + // LOCAL view as the ambient fallback. + assert_eq!(s.active_frontend, FrontendId::LOCAL); assert!(s.views.contains_key(&FrontendId::LOCAL)); } diff --git a/src/frontend.rs b/src/frontend.rs index 81e5a0f..8fcfb47 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -436,6 +436,9 @@ impl Frontend { // unexpected copy drops silently like the rest of the // family rather than being re-interpreted as cells. | InstanceMessage::TerminalFrame(_) + // Q#GT4 — this pre-window semantic bootstrap result cannot + // legitimately reach the grid TUI. + | InstanceMessage::InitialTargetResult(_) | InstanceMessage::ResourceOffer { .. } // T M11.6 — DispatchIdle is consumed by `attach.rs`'s // optimistic-apply gate; if any reaches this render path diff --git a/src/main.rs b/src/main.rs index 881b3da..cdb0b8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ //! //! ```text //! pmacs [-nw|--no-window] [--help] [--version] [FILE] -//! pmacs --gpu [--socket NAME|PATH] +//! pmacs --gpu [--socket NAME|PATH] [--] [FILE] //! pmacs --daemon [--socket NAME|PATH] //! pmacs --attach [--socket NAME|PATH] //! pmacs --attach @@ -24,6 +24,8 @@ //! //! Anything else is a usage error and exits 2. +use std::ffi::OsString; +use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -31,7 +33,7 @@ use pmacs::protocol::{AttachTarget, AttachTargetError}; const USAGE: &str = "\ usage: pmacs [-nw|--no-window] [--help] [--version] [FILE] - pmacs --gpu [--socket NAME|PATH] + pmacs --gpu [--socket NAME|PATH] [--] [FILE] pmacs --daemon [--socket NAME|PATH] pmacs --attach [--socket NAME|PATH] pmacs --attach @@ -43,6 +45,7 @@ usage: pmacs [-nw|--no-window] [--help] [--version] [FILE] the GUI and `-nw` will keep launching the TUI) --gpu start or reuse a CRDT daemon, then launch the separate pmacs-gpu frontend + When FILE is present, open it before the GPU window appears. --daemon run as a foreground daemon listening on a Unix socket; supervised by the user (systemd, tmux, `nohup &`, etc.) @@ -95,9 +98,12 @@ enum Mode { file: Option, frontend: FrontendChoice, }, - /// `pmacs --gpu [--socket ...]`: launch the separate GPU frontend, - /// starting a CRDT daemon on the resolved socket when absent. - Gpu { socket: Option }, + /// `pmacs --gpu [--socket ...] [FILE]`: launch the separate GPU + /// frontend, starting a CRDT daemon on the resolved socket when absent. + Gpu { + socket: Option, + file: Option, + }, /// `pmacs --daemon [--socket ...]`: run a foreground daemon on a /// Unix socket, supervised by the user. Daemon { socket: Option }, @@ -185,7 +191,7 @@ fn parse_attach_target_with_shorthand(s: &str) -> Result CliResult { +fn parse_args(args: &[OsString]) -> CliResult { let mut file: Option = None; let mut frontend = FrontendChoice::Auto; let mut daemon = false; @@ -195,38 +201,45 @@ fn parse_args(args: &[String]) -> CliResult { let mut socket: Option = None; let mut iter = args.iter(); while let Some(arg) = iter.next() { - match arg.as_str() { - "-nw" | "--no-window" => frontend = FrontendChoice::Tui, - "--gpu" => gpu = true, - "--daemon" => daemon = true, - "--attach" => attach = true, - "--daemon-attach" => daemon_attach = true, - "--socket" => match iter.next() { - Some(s) => socket = Some(s.clone()), + if arg.as_os_str().as_bytes().starts_with(b"-") && arg.to_str().is_none() { + return CliResult::Error("option names must be valid UTF-8".into()); + } + match arg.to_str() { + Some("-nw" | "--no-window") => frontend = FrontendChoice::Tui, + Some("--gpu") => gpu = true, + Some("--daemon") => daemon = true, + Some("--attach") => attach = true, + Some("--daemon-attach") => daemon_attach = true, + Some("--socket") => match iter.next() { + Some(value) => match value.to_str() { + Some(value) => socket = Some(value.to_owned()), + None => { + return CliResult::Error("--socket value must be valid UTF-8".into()); + } + }, None => return CliResult::Error("--socket requires a value".into()), }, - "-h" | "--help" => return CliResult::Help, - "-V" | "--version" => return CliResult::Version, - "--" => { - // Treat the rest as positional, even if they look like flags. - if let Some(p) = iter.next() { + Some("-h" | "--help") => return CliResult::Help, + Some("-V" | "--version") => return CliResult::Version, + Some("--") => { + if let Some(path) = iter.next() { if file.is_some() { return CliResult::Error("multiple files not yet supported".into()); } - file = Some(PathBuf::from(p)); + file = Some(PathBuf::from(path)); } if iter.next().is_some() { return CliResult::Error("multiple files not yet supported".into()); } } - flag if flag.starts_with('-') => { + Some(flag) if flag.starts_with('-') => { return CliResult::Error(format!("unknown option: {flag}")); } - path => { + Some(_) | None => { if file.is_some() { return CliResult::Error("multiple files not yet supported".into()); } - file = Some(PathBuf::from(path)); + file = Some(PathBuf::from(arg)); } } } @@ -237,16 +250,11 @@ fn parse_args(args: &[String]) -> CliResult { ); } if gpu { - if file.is_some() { - return CliResult::Error( - "--gpu does not yet accept FILE; open it from the GPU with C-x C-f".into(), - ); - } if frontend == FrontendChoice::Tui { return CliResult::Error("--gpu and --no-window are mutually exclusive".into()); } return CliResult::Run(CliArgs { - mode: Mode::Gpu { socket }, + mode: Mode::Gpu { socket, file }, }); } if daemon { @@ -307,7 +315,7 @@ fn gpu_binary(current_exe: &Path, override_bin: Option) -> (PathBuf, Pa (PathBuf::from("pmacs-gpu"), sibling) } -fn run_gpu(socket: Option<&str>) -> ExitCode { +fn run_gpu(socket: Option<&str>, file: Option<&Path>) -> ExitCode { if !cfg!(feature = "crdt") { eprintln!("pmacs: --gpu requires pmacs built with --features crdt"); return ExitCode::FAILURE; @@ -321,15 +329,32 @@ fn run_gpu(socket: Option<&str>) -> ExitCode { return ExitCode::FAILURE; } }; + let initial_target = match file { + Some(path) => { + let cwd = match std::env::current_dir() { + Ok(cwd) => cwd, + Err(error) => { + eprintln!("pmacs: cannot determine launcher working directory: {error}"); + return ExitCode::FAILURE; + } + }; + Some((cwd, pmacs::editor_core::expand_tilde(path.to_owned()))) + } + None => None, + }; let (gpu, sibling) = gpu_binary( ¤t_exe, std::env::var_os(PMACS_TEST_GPU_BIN).map(PathBuf::from), ); - let status = Command::new(&gpu) + let mut command = Command::new(&gpu); + command .arg("--managed-attach") .arg(&socket_path) - .arg(¤t_exe) - .status(); + .arg(¤t_exe); + if let Some((cwd, path)) = initial_target { + command.arg("--initial-target").arg(cwd).arg(path); + } + let status = command.status(); match status { Ok(status) if status.success() => ExitCode::SUCCESS, Ok(status) => { @@ -358,7 +383,7 @@ fn run_gpu(socket: Option<&str>) -> ExitCode { } fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); + let args: Vec = std::env::args_os().skip(1).collect(); match parse_args(&args) { CliResult::Help => { print!("{USAGE}"); @@ -388,7 +413,7 @@ fn main() -> ExitCode { ExitCode::FAILURE } }, - Mode::Gpu { socket } => run_gpu(socket.as_deref()), + Mode::Gpu { socket, file } => run_gpu(socket.as_deref(), file.as_deref()), Mode::Daemon { socket } => { let socket_path = pmacs::socket_path::resolve_socket_path(socket.as_deref()); // The user-provided NAME (no slashes) becomes the @@ -471,9 +496,10 @@ fn main() -> ExitCode { #[cfg(test)] mod tests { use super::*; + use std::os::unix::ffi::OsStringExt; - fn args(slice: &[&str]) -> Vec { - slice.iter().map(|s| (*s).to_string()).collect() + fn args(slice: &[&str]) -> Vec { + slice.iter().map(OsString::from).collect() } fn local_mode(parsed: CliArgs) -> (Option, FrontendChoice) { @@ -799,7 +825,7 @@ mod tests { vec!["--attach", "--daemon-attach"], vec!["--daemon", "--attach", "--daemon-attach"], ] { - let v: Vec = combo.iter().map(|s| (*s).to_string()).collect(); + let v: Vec = combo.iter().map(OsString::from).collect(); match parse_args(&v) { CliResult::Error(m) => assert!( m.contains("mutually exclusive"), @@ -810,29 +836,42 @@ mod tests { } } #[test] - fn gpu_flag_selects_managed_gpu_with_optional_socket() { - for (argv, expected) in [ - (vec!["--gpu"], None), - (vec!["--gpu", "--socket", "research"], Some("research")), + fn gpu_flag_accepts_one_optional_file_and_socket() { + for (argv, expected_socket, expected_file) in [ + (vec!["--gpu"], None, None), + ( + vec!["--gpu", "--socket", "research"], + Some("research"), + None, + ), + (vec!["--gpu", "README.md"], None, Some("README.md")), + ( + vec!["--gpu", "--socket", "research", "README.md"], + Some("research"), + Some("README.md"), + ), + (vec!["--gpu", "--", "-notes"], None, Some("-notes")), ] { - let argv = args(&argv); - match parse_args(&argv) { + match parse_args(&args(&argv)) { CliResult::Run(CliArgs { - mode: Mode::Gpu { socket }, - }) => assert_eq!(socket.as_deref(), expected), + mode: Mode::Gpu { socket, file }, + }) => { + assert_eq!(socket.as_deref(), expected_socket); + assert_eq!(file.as_deref(), expected_file.map(Path::new)); + } other => panic!("expected GPU mode; got {other:?}"), } } } #[test] - fn gpu_flag_rejects_files_tui_and_other_modes() { + fn gpu_flag_rejects_tui_other_modes_and_multiple_files() { for argv in [ - vec!["--gpu", "README.md"], vec!["--gpu", "-nw"], vec!["--gpu", "--daemon"], vec!["--gpu", "--attach"], vec!["--gpu", "--daemon-attach"], + vec!["--gpu", "one", "two"], ] { assert!( matches!(parse_args(&args(&argv)), CliResult::Error(_)), @@ -841,6 +880,34 @@ mod tests { } } + #[test] + fn gpu_file_keeps_non_utf8_bytes_and_launcher_tilde_expansion_is_exact() { + let raw = OsString::from_vec(vec![b'n', b'o', b't', b'e', 0xff]); + let parsed = parse_args(&[OsString::from("--gpu"), raw.clone()]); + match parsed { + CliResult::Run(CliArgs { + mode: Mode::Gpu { + file: Some(file), .. + }, + }) => assert_eq!(file.as_os_str().as_bytes(), raw.as_bytes()), + other => panic!("expected raw GPU file; got {other:?}"), + } + + let home = std::env::var_os("HOME").expect("test HOME"); + assert_eq!( + pmacs::editor_core::expand_tilde(PathBuf::from("~")), + PathBuf::from(&home) + ); + assert_eq!( + pmacs::editor_core::expand_tilde(PathBuf::from("~/notes")), + PathBuf::from(home).join("notes") + ); + assert_eq!( + pmacs::editor_core::expand_tilde(PathBuf::from("~other/notes")), + PathBuf::from("~other/notes") + ); + } + #[test] fn bare_socket_is_never_silently_ignored() { match parse_args(&args(&["--socket", "research"])) { diff --git a/src/protocol.rs b/src/protocol.rs index a86a9d1..df65863 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_nineteen_for_the_terminal_family() { + fn protocol_version_is_twenty_for_gpu_initial_targets() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1720,7 +1720,9 @@ mod tests { // frontend-gated — the first bump that gates in BOTH // directions; all three appended after their enum's final v18 // variant, see the placement pins). - assert_eq!(PROTOCOL_VERSION, 19); + // GPU initial targets bump 19→20 with a semantic-only + // SessionBootstrapRequest and appended InitialTargetResult. + assert_eq!(PROTOCOL_VERSION, 20); } #[test] @@ -1795,18 +1797,18 @@ mod tests { // regex/invalid), v11 (the context menu), v12 (the GUI // minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15 // (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`), - // v18 (`StatuslineSegments`), and v19 (the vterm terminal - // family) all interoperate. - for accepted in 6..=19 { + // v18 (`StatuslineSegments`), v19 (the vterm terminal family), + // and v20 (semantic initial-target bootstrap) all interoperate. + for accepted in 6..=20 { assert!( is_supported_protocol_version(accepted), "v{accepted} must be accepted" ); } - for rejected in [0, 1, 2, 3, 4, 5, 20, u32::MAX] { + for rejected in [0, 1, 2, 3, 4, 5, 21, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v19 binary" + "v{rejected} must be rejected by a v20 binary" ); } } @@ -2015,6 +2017,47 @@ mod tests { } } + #[test] + fn initial_target_bootstrap_round_trips_and_appends_after_the_v19_terminal_frame() { + let request = SessionBootstrapRequest { + initial_target: Some(InitialTarget { + cwd: b"/launcher".to_vec(), + path: vec![b'n', b'o', b't', b'e', 0xff], + }), + }; + let request_bytes = postcard::to_allocvec(&request).expect("encode bootstrap"); + let decoded: SessionBootstrapRequest = + postcard::from_bytes(&request_bytes).expect("decode bootstrap"); + assert_eq!(decoded, request); + let none = SessionBootstrapRequest::default(); + assert_eq!( + postcard::from_bytes::( + &postcard::to_allocvec(&none).expect("encode empty bootstrap") + ) + .expect("decode empty bootstrap"), + none + ); + + for result in [ + InitialTargetResult::Opened { + buffer_id: pmacs_protocol::BufferId::from_raw(9), + }, + InitialTargetResult::Failed { + message: "cannot load target".to_owned(), + }, + ] { + let message = InstanceMessage::InitialTargetResult(result); + let bytes = postcard::to_allocvec(&message).expect("encode target result"); + assert_eq!( + bytes.first(), + Some(&27), + "InitialTargetResult must be appended after v19 TerminalFrame" + ); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode result"); + assert_eq!(decoded, message); + } + } + #[test] fn font_facts_encoding_is_unchanged_by_the_v18_build() { let msg = InstanceMessage::FontFacts { diff --git a/tests/folding_stage2_acceptance.rs b/tests/folding_stage2_acceptance.rs index de03a74..cbef8cd 100644 --- a/tests/folding_stage2_acceptance.rs +++ b/tests/folding_stage2_acceptance.rs @@ -1443,19 +1443,14 @@ fn fold_range(s: &EditorState, start: u64, end: u64) { } // --------------------------------------------------------------------------- -// 11. No wire / protocol change (framing acceptance 11, Bet B6) +// 11. No Stage 2 wire requirement (framing acceptance 11, Bet B6) // --------------------------------------------------------------------------- #[test] -fn stage_2_bumps_no_protocol_version() { - assert_eq!( - pmacs::protocol::PROTOCOL_VERSION, - 19, - "Stage 2 is entirely daemon-side: the TUI collapse ships no new wire data" - ); - assert_eq!( - *pmacs::protocol::SUPPORTED_PROTOCOL_VERSIONS.last().unwrap(), - 19 +fn stage_2_preserves_protocol_v19_compatibility() { + assert!( + pmacs::protocol::SUPPORTED_PROTOCOL_VERSIONS.contains(&19), + "Stage 2 is entirely daemon-side: later protocol bumps must retain v19 compatibility" ); } diff --git a/tests/gpu_initial_target_acceptance.rs b/tests/gpu_initial_target_acceptance.rs new file mode 100644 index 0000000..a94af59 --- /dev/null +++ b/tests/gpu_initial_target_acceptance.rs @@ -0,0 +1,9 @@ +//! Named gate for the approved GPU initial-target framing. +//! +//! The target cases share the existing managed-lifecycle fixtures so every run +//! also proves the #141 reuse, spawn, isolation, and child-reaping invariants. + +#![cfg(unix)] + +#[path = "gpu_invocation_acceptance.rs"] +mod gpu_invocation_acceptance; diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 4fa9196..5371438 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -78,6 +78,8 @@ fn non_crdt_root_rejects_gpu_before_socket_io_discovery_or_spawn() { #[cfg(feature = "crdt")] mod crdt { use std::collections::HashMap; + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::process::CommandExt; use std::path::PathBuf; @@ -89,9 +91,11 @@ mod crdt { use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; use pmacs::cell::CellSize; + use pmacs::crdt::CrdtState; use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, - InstanceCapabilities, InstanceIdentity, InstanceMessage, PROTOCOL_VERSION, + AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InitialTarget, + InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, + PROTOCOL_VERSION, SessionBootstrapRequest, }; use pmacs::transport::{read_message, write_message}; @@ -212,6 +216,119 @@ mod crdt { (hello.assigned_frontend_id, stream) } + struct TargetSession { + frontend_id: FrontendId, + buffer_id: pmacs::buffer::BufferId, + replica: CrdtState, + stream: UnixStream, + } + + fn attach_target(socket: &Path, cwd: &Path, path: &Path) -> TargetSession { + use std::os::unix::ffi::OsStrExt; + + let mut stream = UnixStream::connect(socket).expect("connect target frontend"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set target frontend timeout"); + let hello: Hello = read_message(&mut stream).expect("target frontend Hello"); + assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + write_message( + &mut stream, + &AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: FrontendCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + ..FrontendCapabilities::default() + }, + initial_size: CellSize::new(24, 80), + }, + ) + .expect("attach target frontend"); + write_message( + &mut stream, + &SessionBootstrapRequest { + initial_target: Some(InitialTarget { + cwd: cwd.as_os_str().as_bytes().to_vec(), + path: path.as_os_str().as_bytes().to_vec(), + }), + }, + ) + .expect("send initial target"); + + let (buffer_id, snapshot) = + match read_message::(&mut stream).expect("target snapshot") { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!("expected target snapshot first, got {other:?}"), + }; + assert_eq!( + read_message::(&mut stream).expect("target result"), + InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { buffer_id }) + ); + let replica = CrdtState::new(hello.assigned_frontend_id.0).expect("target replica"); + replica + .import_snapshot(&snapshot) + .expect("import target snapshot"); + TargetSession { + frontend_id: hello.assigned_frontend_id, + buffer_id, + replica, + stream, + } + } + + fn open_raw_target( + socket: &Path, + cwd: Vec, + path: Vec, + ) -> (FrontendId, UnixStream, Vec) { + let mut stream = UnixStream::connect(socket).expect("connect raw target frontend"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set raw target timeout"); + let hello: Hello = read_message(&mut stream).expect("raw target Hello"); + write_message( + &mut stream, + &AttachRequest { + protocol_version: hello.protocol_version, + frontend_capabilities: FrontendCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + ..FrontendCapabilities::default() + }, + initial_size: CellSize::new(24, 80), + }, + ) + .expect("attach raw target frontend"); + write_message( + &mut stream, + &SessionBootstrapRequest { + initial_target: Some(InitialTarget { cwd, path }), + }, + ) + .expect("send raw target"); + + let first = read_message::(&mut stream).expect("raw target result"); + let messages = if matches!(first, InstanceMessage::BufferSnapshot { .. }) { + vec![ + first, + read_message::(&mut stream).expect("raw opened result"), + ] + } else { + vec![first] + }; + (hello.assigned_frontend_id, stream, messages) + } + + fn request_raw_target(socket: &Path, cwd: Vec, path: Vec) -> Vec { + open_raw_target(socket, cwd, path).2 + } + fn spawn_daemon(socket: &Path, envs: &[(&str, &str)]) -> Child { let home = socket.parent().expect("socket parent"); let mut command = Command::new(pmacs_binary()); @@ -243,12 +360,41 @@ mod crdt { Self::spawn_with_env(socket, report, daemon_executable, home, &[]) } + fn spawn_target( + socket: &Path, + report: &Path, + daemon_executable: &Path, + home: &Path, + cwd: &Path, + target: &Path, + ) -> Self { + Self::spawn_with_env_and_target( + socket, + report, + daemon_executable, + home, + &[], + Some((cwd, target)), + ) + } + fn spawn_with_env( socket: &Path, report: &Path, daemon_executable: &Path, home: &Path, envs: &[(&str, &Path)], + ) -> Self { + Self::spawn_with_env_and_target(socket, report, daemon_executable, home, envs, None) + } + + fn spawn_with_env_and_target( + socket: &Path, + report: &Path, + daemon_executable: &Path, + home: &Path, + envs: &[(&str, &Path)], + initial_target: Option<(&Path, &Path)>, ) -> Self { assert!( gpu_binary().is_file(), @@ -259,7 +405,11 @@ mod crdt { .args(["--headless-managed-probe"]) .arg(socket) .arg(report) - .arg(daemon_executable) + .arg(daemon_executable); + if let Some((cwd, path)) = initial_target { + command.arg("--initial-target").arg(cwd).arg(path); + } + command .env("HOME", home) .env("XDG_CONFIG_HOME", home) .stdin(Stdio::piped()) @@ -316,6 +466,10 @@ mod crdt { let fake_gpu = temp.path().join("fake-gpu"); let record = temp.path().join("argv"); let socket = temp.path().join("broker.sock"); + let launch_cwd = temp.path().join("launch"); + let launcher_home = temp.path().join("home"); + fs::create_dir(&launch_cwd).expect("create launcher cwd"); + fs::create_dir(&launcher_home).expect("create launcher home"); write_script( &fake_gpu, "printf '%s\\n' \"$@\" > \"$PMACS_TEST_RECORD\"\nexit \"$PMACS_TEST_EXIT\"", @@ -324,6 +478,9 @@ mod crdt { let success = Command::new(pmacs_binary()) .args(["--gpu", "--socket"]) .arg(&socket) + .arg("~/notes.txt") + .current_dir(&launch_cwd) + .env("HOME", &launcher_home) .env(TEST_GPU_OVERRIDE, &fake_gpu) .env("PMACS_TEST_RECORD", &record) .env("PMACS_TEST_EXIT", "0") @@ -339,6 +496,9 @@ mod crdt { assert_eq!(args[0], "--managed-attach"); assert_eq!(Path::new(args[1]), socket); assert_eq!(Path::new(args[2]), pmacs_binary()); + assert_eq!(args[3], "--initial-target"); + assert_eq!(Path::new(args[4]), launch_cwd); + assert_eq!(Path::new(args[5]), launcher_home.join("notes.txt")); let failure = Command::new(pmacs_binary()) .arg("--gpu") @@ -361,6 +521,336 @@ mod crdt { ); } + #[test] + fn one_command_root_broker_reaches_target_ready_through_the_real_gpu_connector() { + let temp = secure_tempdir(); + let cwd = temp.path().join("workspace"); + fs::create_dir(&cwd).expect("create workspace"); + fs::write(cwd.join("opened.txt"), "opened by root\n").expect("write target"); + let socket = temp.path().join("one-command.sock"); + let report = temp.path().join("one-command-report"); + let wrapper = temp.path().join("headless-gpu"); + write_script( + &wrapper, + "test \"$1\" = \"--managed-attach\"\n\ + socket=$2\n\ + daemon=$3\n\ + shift 3\n\ + exec \"$PMACS_REAL_GPU\" --headless-managed-probe \ + \"$socket\" \"$PMACS_TEST_REPORT\" \"$daemon\" \"$@\"", + ); + + let output = Command::new(pmacs_binary()) + .args(["--gpu", "--socket"]) + .arg(&socket) + .arg("opened.txt") + .current_dir(&cwd) + .env(TEST_GPU_OVERRIDE, &wrapper) + .env("PMACS_REAL_GPU", gpu_binary()) + .env("PMACS_TEST_REPORT", &report) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path()) + .output() + .expect("run one-command target flow"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let facts = parse_report(&report); + assert_eq!(facts.get("phase").map(String::as_str), Some("complete")); + assert_eq!( + facts + .get("server_protocol_version") + .and_then(|value| value.parse::().ok()), + Some(PROTOCOL_VERSION) + ); + assert_eq!( + facts.get("spawned_daemon").map(String::as_str), + Some("true") + ); + } + + #[test] + fn target_bootstrap_is_snapshot_first_deduplicated_and_identical_for_daemon_reuse_or_spawn() { + let temp = secure_tempdir(); + let cwd = temp.path().join("workspace"); + fs::create_dir(&cwd).expect("create workspace"); + fs::write(cwd.join("alpha.txt"), "alpha\n").expect("write alpha"); + fs::write(cwd.join("beta.txt"), "beta\n").expect("write beta"); + let raw_name = OsString::from_vec(vec![b'r', b'a', b'w', 0xff]); + fs::write(cwd.join(&raw_name), "raw\n").expect("write non-UTF-8 target"); + + let existing_socket = temp.path().join("existing-target.sock"); + let mut daemon = spawn_daemon(&existing_socket, &[]); + let mut alpha = attach_target(&existing_socket, &cwd, Path::new("alpha.txt")); + let mut same_alpha = attach_target(&existing_socket, &cwd, Path::new("./alpha.txt")); + let beta = attach_target(&existing_socket, &cwd, Path::new("nested/../beta.txt")); + assert_eq!(alpha.replica.materialize_string(), "alpha\n"); + assert_eq!(same_alpha.buffer_id, alpha.buffer_id); + assert_eq!(beta.replica.materialize_string(), "beta\n"); + assert_ne!(beta.buffer_id, alpha.buffer_id); + let raw = attach_target(&existing_socket, &cwd, Path::new(raw_name.as_os_str())); + assert_eq!(raw.replica.materialize_string(), "raw\n"); + let missing_path = Path::new("new-draft.txt"); + let missing = attach_target(&existing_socket, &cwd, missing_path); + assert_eq!(missing.replica.materialize_string(), ""); + assert!(!cwd.join(missing_path).exists()); + let same_missing = attach_target(&existing_socket, &cwd, Path::new("./new-draft.txt")); + assert_eq!(same_missing.buffer_id, missing.buffer_id); + + let version = alpha.replica.version(); + let alpha_len = alpha.replica.len_utf8(); + alpha + .replica + .insert(alpha_len, "unsaved") + .expect("optimistic alpha edit"); + let op_bytes = alpha + .replica + .export_updates_since(&version) + .expect("export alpha edit"); + write_message( + &mut alpha.stream, + &FrontendEvent::CrdtOp { + frontend_id: alpha.frontend_id, + buffer_id: alpha.buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: alpha.frontend_id.0, + bytes: op_bytes, + }, + }, + ) + .expect("send alpha edit"); + loop { + match read_message::(&mut same_alpha.stream) + .expect("read alpha broadcast") + { + InstanceMessage::CrdtOp { buffer_id, op } if buffer_id == alpha.buffer_id => { + same_alpha + .replica + .import_updates(&op.bytes) + .expect("import alpha broadcast"); + break; + } + _ => {} + } + } + assert_eq!(same_alpha.replica.materialize_string(), "alpha\nunsaved"); + + let reopened = attach_target(&existing_socket, &cwd, &cwd.join("alpha.txt")); + assert_eq!(reopened.buffer_id, alpha.buffer_id); + assert_eq!(reopened.replica.materialize_string(), "alpha\nunsaved"); + + signal_pid(daemon.id(), Signal::SIGTERM); + assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success()); + + let spawned_socket = temp.path().join("spawned-target.sock"); + let report = temp.path().join("target-report"); + let mut probe = ManagedProbe::spawn_target( + &spawned_socket, + &report, + &pmacs_binary(), + temp.path(), + &cwd, + Path::new("beta.txt"), + ); + let facts = probe.wait_ready(); + assert_eq!( + facts.get("spawned_daemon").map(String::as_str), + Some("true") + ); + assert!(probe.close().success()); + } + + #[test] + fn malformed_or_unloadable_targets_fail_closed_without_poisoning_the_daemon() { + let temp = secure_tempdir(); + let socket = temp.path().join("target-failure.sock"); + let mut daemon = spawn_daemon(&socket, &[]); + let cwd = temp.path().as_os_str().as_encoded_bytes().to_vec(); + let invalid = [ + (b"relative".to_vec(), b"note".to_vec()), + (cwd.clone(), Vec::new()), + (cwd.clone(), b"bad\0name".to_vec()), + (cwd.clone(), vec![b'x'; 32 * 1024 + 1]), + (cwd.clone(), b".".to_vec()), + ]; + for (index, (bad_cwd, bad_path)) in invalid.into_iter().enumerate() { + let (frontend_id, mut stream, messages) = open_raw_target(&socket, bad_cwd, bad_path); + assert_eq!(messages.len(), 1, "failure must send no snapshot"); + match &messages[0] { + InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message }) => { + assert!(!message.is_empty()); + assert!(message.len() <= 4 * 1024); + } + other => panic!("expected bounded target failure, got {other:?}"), + } + assert!( + read_message::(&mut stream).is_err(), + "failed bootstrap {index} must close its socket" + ); + let _ = write_message( + &mut stream, + &FrontendEvent::Key(pmacs::protocol::KeyEvent { + frontend_id, + key: pmacs::protocol::Key::Char('x'), + mods: pmacs::protocol::Modifiers::NONE, + timestamp_ns: 0, + }), + ); + } + fs::write(temp.path().join("still-alive.txt"), "alive\n").expect("write survivor"); + let survivor = attach_target(&socket, temp.path(), Path::new("still-alive.txt")); + assert_eq!(survivor.replica.materialize_string(), "alive\n"); + signal_pid(daemon.id(), Signal::SIGTERM); + assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success()); + } + + #[test] + fn dedup_upgrade_publishes_the_snapshot_to_preexisting_grid_replicas() { + let temp = secure_tempdir(); + let config_dir = temp.path().join("pmacs"); + fs::create_dir(&config_dir).expect("create config dir"); + let seed_path = temp.path().join("seed.txt"); + let hidden_path = temp.path().join("hidden.txt"); + fs::write(&seed_path, "seed\n").expect("write seed"); + fs::write(&hidden_path, "hidden\n").expect("write hidden"); + fs::write( + config_dir.join("init.lua"), + format!( + "local created_hidden = false\n\ + pmacs.hook.add('buffer.after-load', function()\n\ + if created_hidden then return end\n\ + created_hidden = true\n\ + pmacs.buffer.find_or_open({hidden_path:?})\n\ + end)\n" + ), + ) + .expect("write hidden-buffer hook"); + let socket = temp.path().join("dedup-upgrade.sock"); + let mut daemon = spawn_daemon(&socket, &[]); + let (_, mut grid) = attach_surviving_frontend(&socket); + + let seed = attach_target(&socket, temp.path(), Path::new("seed.txt")); + loop { + match read_message::(&mut grid).expect("grid seed publication") { + InstanceMessage::BufferSnapshot { buffer_id, .. } + if buffer_id == seed.buffer_id => + { + break; + } + _ => {} + } + } + + let hidden = attach_target(&socket, temp.path(), Path::new("hidden.txt")); + let hidden_snapshot = loop { + match read_message::(&mut grid).expect("grid hidden publication") { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } if buffer_id == hidden.buffer_id => break crdt_snapshot, + _ => {} + } + }; + let replica = CrdtState::new(900).expect("grid hidden replica"); + replica + .import_snapshot(&hidden_snapshot) + .expect("import hidden publication"); + assert_eq!(replica.materialize_string(), "hidden\n"); + + signal_pid(daemon.id(), Signal::SIGTERM); + assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success()); + } + + #[test] + fn target_killed_by_hook_fails_closed_and_slow_hook_holds_the_ready_barrier() { + let temp = secure_tempdir(); + + let kill_root = temp.path().join("kill-hook"); + fs::create_dir(&kill_root).expect("create kill hook root"); + fs::set_permissions(&kill_root, fs::Permissions::from_mode(0o700)) + .expect("chmod kill hook root"); + fs::create_dir(kill_root.join("pmacs")).expect("create kill config"); + fs::write( + kill_root.join("pmacs/init.lua"), + "pmacs.hook.add('buffer.after-load', function()\n\ + pmacs.buffer.kill(pmacs.window.buffer())\n\ + end)\n", + ) + .expect("write kill hook"); + let kill_target = kill_root.join("victim.txt"); + fs::write(&kill_target, "victim\n").expect("write victim"); + let kill_socket = kill_root.join("daemon.sock"); + let mut kill_daemon = spawn_daemon(&kill_socket, &[]); + let failed = request_raw_target( + &kill_socket, + kill_root.as_os_str().as_encoded_bytes().to_vec(), + b"victim.txt".to_vec(), + ); + assert!(matches!( + failed.as_slice(), + [InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message })] + if message.contains("removed by a startup hook") + )); + let _ = attach_surviving_frontend(&kill_socket); + signal_pid(kill_daemon.id(), Signal::SIGTERM); + assert!(wait_for_exit(&mut kill_daemon, Duration::from_secs(5)).success()); + + let slow_root = temp.path().join("slow-hook"); + fs::create_dir(&slow_root).expect("create slow hook root"); + fs::set_permissions(&slow_root, fs::Permissions::from_mode(0o700)) + .expect("chmod slow hook root"); + fs::create_dir(slow_root.join("pmacs")).expect("create slow config"); + let marker = slow_root.join("hook-started"); + fs::write( + slow_root.join("pmacs/init.lua"), + format!( + "pmacs.hook.add('buffer.after-load', function()\n\ + local f = assert(io.open({marker:?}, 'w')); f:write('started'); f:close()\n\ + os.execute('sleep 1')\n\ + end)\n" + ), + ) + .expect("write slow hook"); + fs::write(slow_root.join("slow.txt"), "slow\n").expect("write slow target"); + let slow_socket = slow_root.join("daemon.sock"); + let mut slow_daemon = spawn_daemon(&slow_socket, &[]); + let report = slow_root.join("report"); + let fake_daemon = slow_root.join("must-not-spawn"); + let mut probe = ManagedProbe::spawn_target( + &slow_socket, + &report, + &fake_daemon, + &slow_root, + &slow_root, + Path::new("slow.txt"), + ); + let marker_deadline = Instant::now() + Duration::from_secs(5); + while !marker.exists() { + assert!( + Instant::now() < marker_deadline, + "slow hook never reached marker" + ); + thread::sleep(Duration::from_millis(10)); + } + assert!( + !report.exists() + || parse_report(&report) + .get("phase") + .is_none_or(|phase| phase != "ready"), + "frontend reported ready while the startup hook was still blocked" + ); + let facts = probe.wait_ready(); + assert_eq!( + facts.get("spawned_daemon").map(String::as_str), + Some("false") + ); + assert!(probe.close().success()); + signal_pid(slow_daemon.id(), Signal::SIGTERM); + assert!(wait_for_exit(&mut slow_daemon, Duration::from_secs(5)).success()); + } + #[test] fn managed_attach_reuses_a_capable_daemon_without_spawning() { let temp = secure_tempdir(); diff --git a/tests/m11_5_semantic_acceptance.rs b/tests/m11_5_semantic_acceptance.rs index cdda17f..09a4e7e 100644 --- a/tests/m11_5_semantic_acceptance.rs +++ b/tests/m11_5_semantic_acceptance.rs @@ -32,7 +32,7 @@ use pmacs::cell::CellSize; use pmacs::editor::EditorState; use pmacs::protocol::{ AttachRequest, ByteRange, FrontendCapabilities, FrontendEvent, FrontendId, Hello, - InstanceMessage, + InstanceMessage, SessionBootstrapRequest, }; use pmacs::semantic_client::SemanticClient; use pmacs::semantic_render::SemanticRenderState; @@ -268,6 +268,7 @@ fn daemon_routes_semantic_family_to_semantic_session_only() { }, ) .expect("semantic write AttachRequest"); + write_message(&mut sem, &SessionBootstrapRequest::default()).expect("semantic write bootstrap"); // Learn a buffer id from the bootstrap snapshot, then declare a // viewport — the daemon emits nothing semantic until it does diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 2963fea..6a08c41 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -782,16 +782,16 @@ fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() { // (the drop arm itself is pinned beside Frontend::apply_message). #[test] fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() { - // Vterm Stage 3 appended the terminal family as v19. This - // acceptance owns the STATUSLINE variant's placement and gate, so - // it tracks the current wire version rather than pinning 18: the - // v18 floor it actually cares about is asserted below and in - // `peer_accepts_statusline_message`. - assert_eq!(PROTOCOL_VERSION, 19); - for version in 6..=19 { + // Vterm Stage 3 appended the terminal family as v19; GPU initial targets + // appended the semantic bootstrap family as v20. This acceptance owns the + // STATUSLINE variant's placement and gate, so it tracks the current wire + // version rather than pinning 18: the v18 floor it actually cares about is + // asserted below and in `peer_accepts_statusline_message`. + assert_eq!(PROTOCOL_VERSION, 20); + for version in 6..=20 { assert!(is_supported_protocol_version(version)); } - assert!(!is_supported_protocol_version(20)); + assert!(!is_supported_protocol_version(21)); let sample = InstanceMessage::StatuslineSegments { buffer_id: BufferId::from_raw(9), left: vec![StatuslineSegment { diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 8d7eb46..8bf44a5 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -713,8 +713,8 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { assert_eq!( facts.get("server_protocol_version").copied(), - Some("19"), - "the real daemon negotiated v19 with the real client: {text}" + Some("20"), + "the real daemon negotiated v20 with the real client: {text}" ); assert_eq!( facts.get("entered_terminal_mode").copied(), @@ -789,8 +789,8 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { #[test] fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, Hello, Key, KeyEvent, PROTOCOL_VERSION, read_message, - write_message, + AttachRequest, FrontendCapabilities, Hello, Key, KeyEvent, PROTOCOL_VERSION, + SessionBootstrapRequest, read_message, write_message, }; use std::os::unix::net::UnixStream; @@ -816,6 +816,10 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { initial_size: CellSize::new(24, 80), }; write_message(&mut stream, &req).expect("write AttachRequest"); + if semantic { + write_message(&mut stream, &SessionBootstrapRequest::default()) + .expect("write semantic bootstrap"); + } (hello, stream) } @@ -839,7 +843,7 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { panic!("timed out waiting for {what}"); } - assert_eq!(PROTOCOL_VERSION, 19); + assert_eq!(PROTOCOL_VERSION, 20); let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( &[ ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),