From 60ac7fab4667131dc501be0ec54ff5ca0fe101cc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 14:31:24 -0400 Subject: [PATCH 01/13] Frame session-scoped GPU initial targets Define the pmacs --gpu FILE contract, protocol-v20 semantic bootstrap, raw Unix path and launcher-cwd transport, pre-window readiness barrier, replica coherence requirements, and behavioral acceptance matrix. --- docs/gpu-initial-target-framing.md | 601 +++++++++++++++++++++++++++++ 1 file changed, 601 insertions(+) create mode 100644 docs/gpu-initial-target-framing.md diff --git a/docs/gpu-initial-target-framing.md b/docs/gpu-initial-target-framing.md new file mode 100644 index 0000000..85bdb67 --- /dev/null +++ b/docs/gpu-initial-target-framing.md @@ -0,0 +1,601 @@ +# GPU initial target — session-scoped file opening framing + +**Revision 1 — proposed framing for user review. Ground truth: canonical +`main` @ `4daa1b8`, protocol v19, 2026-07-23. No implementation yet.** + +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`. That + cannot represent a non-UTF-8 Unix filename and may terminate before the + parser can issue a useful error. +- 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. +- `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 cwd owns relative resolution.** The daemon's cwd is irrelevant, + including when the daemon predates the launcher. +3. **Path bytes survive.** On Unix, argv → broker → GPU → wire → daemon file + I/O preserves the 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. “Connecting…” is acceptable; scratch content is not. +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 +``` + +`LAUNCHER_CWD` is captured by root before spawn and must be absolute. Both cwd +and file 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 the 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. +- Each field is bounded to 32 KiB before allocation/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. Resolve a relative `path` against the supplied launcher `cwd`, then + lexically normalize the result without canonicalizing symlinks. +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` on dedup or `buffer.after-load` on a fresh disk + load, with the authenticated frontend active. A newly created missing file + matches local startup and does not fire `after-load`. +6. Reassert the requested target after hooks so startup configuration can + inspect the right session but cannot accidentally 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. + +### 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; +- 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 + cwd bytes + | + +-- 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 + +Expected 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, duplicated, or relative-cwd forms exit 2. An option-like `FILE` + after the marker remains literal. +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 relative path:** start a real CRDT daemon from cwd A; + launch the real headless managed GPU target from cwd B with `sub/file.txt`; + require the snapshot contents from B, not A, and a ready buffer matching the + result. +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. 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. **Fresh-buffer publication:** keep replica A attached, then target-launch B + onto a previously unknown file. A receives the new 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; missing-file creation fires neither + load hook. Each hook observes the authenticated frontend and requested + buffer, and the target remains selected afterward. +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. +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. + +## 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. From ec0e40117bbf1c108b6bbc453c3e1180076356b7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 14:32:44 -0400 Subject: [PATCH 02/13] Record GPU initial-target framing lane Advance the volatile ledger to the current canonical base and record the portable Revision 1 framing checkpoint, scope, recovery command, and approval boundary. --- docs/active-work.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index d2715d3..81bcc6e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -14,8 +14,8 @@ 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` @ `63fbc66` (one-command GPU invocation #141 atop - documentation refresh #140; protocol v19). + `githubsucks/main` @ `4daa1b8` (inline-math framing #145 atop one-command + GPU invocation #141 and its landed-state handoff #143; 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 @@ -49,9 +49,31 @@ git worktree list git status --short --branch ``` -The first command must expose `63fbc66` or a newer intentional main. +The `git log` command must expose `4daa1b8` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Active lane: GPU initial target framing + +- Portable branch: `githubsucks/gpu-initial-target-framing` +- Framing checkpoint: `60ac7fa` (Revision 1; 2026-07-23). +- Base: `githubsucks/main` @ `4daa1b8`; protocol v19. +- State: framing only, proposed for user review; no implementation and no PR. +- Scope: one session-scoped `pmacs --gpu [--socket …] FILE` target, + protocol-v20 semantic bootstrap, exact Unix path + launcher-cwd transport, + pre-window target readiness, replica coherence, and behavioral acceptance. +- Next: user review/approval. Only then create the implementation branch from + this checkpoint; automatic GUI selection, multiple files, general live-open + commands, packaging, and remote GPU paths remain explicitly deferred. + +Recovery worktree: + +```sh +git worktree add --track \ + -b gpu-initial-target-framing \ + ../pmacs-gpu-initial-target-framing \ + githubsucks/gpu-initial-target-framing +``` + ## Parked lane: kill-ring browser + persistence - Portable branch: `githubsucks/kill-ring-browser` From 71039d1699dc3db3b65877d7c31c3de6a3e42bea Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 15:07:06 -0400 Subject: [PATCH 03/13] Refine GPU initial-target framing after review Pin launcher-owned tilde expansion, require same-buffer dedup hooks, fail closed when hooks kill the target, and document stderr feedback during the pre-window bootstrap wait. Record the observed protocol-version echo and non-Unicode argv panic. --- docs/gpu-initial-target-framing.md | 110 ++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 34 deletions(-) diff --git a/docs/gpu-initial-target-framing.md b/docs/gpu-initial-target-framing.md index 85bdb67..9029bd5 100644 --- a/docs/gpu-initial-target-framing.md +++ b/docs/gpu-initial-target-framing.md @@ -1,7 +1,13 @@ # GPU initial target — session-scoped file opening framing -**Revision 1 — proposed framing for user review. Ground truth: canonical -`main` @ `4daa1b8`, protocol v19, 2026-07-23. No implementation yet.** +**Revision 2 — user-review findings resolved. Ground truth: canonical `main` +@ `4daa1b8`, protocol v19, 2026-07-23. No implementation yet.** + +Revision 2 pins launcher-owned tilde expansion, requires `after-switch` even +when dedup selects the view's existing buffer, fails bootstrap when a hook +kills the target, and records the deliberate stderr-only wait during slow +pre-window bootstrap. It also sharpens the observed argv panic and negotiated +protocol-version echo. One-command GPU startup landed in #141: @@ -41,9 +47,9 @@ solve installation, service management, remote GPU attach, or reconnect. - 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`. That - cannot represent a non-UTF-8 Unix filename and may terminate before the - parser can issue a useful error. +- 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. @@ -55,6 +61,9 @@ solve installation, service management, remote GPU attach, or reconnect. 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. @@ -98,15 +107,18 @@ invariant as any other mid-session buffer creation. 1. **Authenticated source owns the target.** No client-supplied frontend id, daemon-local active view, or ambient “last frontend” selects the window. -2. **Launcher cwd owns relative resolution.** The daemon's cwd is irrelevant, - including when the daemon predates the launcher. -3. **Path bytes survive.** On Unix, argv → broker → GPU → wire → daemon file - I/O preserves the exact `OsStr` bytes. Display text may be lossy; backing +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. “Connecting…” is acceptable; scratch content is not. + 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. @@ -167,13 +179,20 @@ pmacs-gpu --managed-attach SOCKET DAEMON_EXE \ --initial-target LAUNCHER_CWD FILE ``` -`LAUNCHER_CWD` is captured by root before spawn and must be absolute. Both cwd -and file 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 the daemon cwd would violate the target's authority. +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 @@ -229,18 +248,22 @@ transaction without yielding to another event: 1. Register the new frontend's view and set `active_frontend` to the authenticated `FrontendId`. -2. Resolve a relative `path` against the supplied launcher `cwd`, then - lexically normalize the result without canonicalizing symlinks. +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` on dedup or `buffer.after-load` on a fresh disk - load, with the authenticated frontend active. A newly created missing file - matches local startup and does not fire `after-load`. -6. Reassert the requested target after hooks so startup configuration can - inspect the right session but cannot accidentally make `pmacs --gpu FILE` - acknowledge a different buffer. +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 @@ -318,6 +341,15 @@ scratch snapshot can become drawable. 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. This is deliberate for the +explicit terminal command: before blocking, `pmacs-gpu` writes one bounded, +lossy-display-only `opening …` notice to stderr. 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: @@ -327,6 +359,9 @@ On any target failure before readiness, the dispatcher: 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. @@ -359,7 +394,7 @@ source of truth for a mandatory v20 semantic handshake step. ## Startup state machine ```text -root parses FILE bytes + cwd bytes +root parses FILE bytes, expands eligible `~` with launcher HOME, captures cwd | +-- spawn/await pmacs-gpu managed child | @@ -500,10 +535,11 @@ process behavior. 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 relative path:** start a real CRDT daemon from cwd A; - launch the real headless managed GPU target from cwd B with `sub/file.txt`; - require the snapshot contents from B, not A, and a ready buffer matching the - result. +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 @@ -528,13 +564,19 @@ process behavior. 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; missing-file creation fires neither - load hook. Each hook observes the authenticated frontend and requested - buffer, and the target remains selected afterward. + 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. + 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. From 244de001cb990e90dd3f917d87a8a920f3444254 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 15:09:05 -0400 Subject: [PATCH 04/13] Record reviewed GPU initial-target framing Advance the active lane to Revision 2 and record closure of all four non-structural framing findings. Keep implementation gated on explicit user approval. --- docs/active-work.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 81bcc6e..9c14a25 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -55,15 +55,17 @@ If it does not, stop and repair the remote/fetch configuration. ## Active lane: GPU initial target framing - Portable branch: `githubsucks/gpu-initial-target-framing` -- Framing checkpoint: `60ac7fa` (Revision 1; 2026-07-23). +- Framing checkpoint: `71039d1` (Revision 2; 2026-07-23). - Base: `githubsucks/main` @ `4daa1b8`; protocol v19. -- State: framing only, proposed for user review; no implementation and no PR. +- State: framing only; user review completed and all four non-structural + findings resolved; no implementation and no PR. - Scope: one session-scoped `pmacs --gpu [--socket …] FILE` target, - protocol-v20 semantic bootstrap, exact Unix path + launcher-cwd transport, - pre-window target readiness, replica coherence, and behavioral acceptance. -- Next: user review/approval. Only then create the implementation branch from - this checkpoint; automatic GUI selection, multiple files, general live-open - commands, packaging, and remote GPU paths remain explicitly deferred. + protocol-v20 semantic bootstrap, launcher-owned tilde/cwd resolution, exact + Unix path transport, pre-window target readiness, replica coherence, and + behavioral acceptance. +- Next: explicit user approval. Only then create the implementation branch + from this checkpoint; automatic GUI selection, multiple files, general + live-open commands, packaging, and remote GPU paths remain deferred. Recovery worktree: From 2dd30ec730f8d2de60e9f7283cac3cffec2ff909 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 19:03:25 -0400 Subject: [PATCH 05/13] Implement session-scoped GPU initial targets Add protocol-v20 semantic bootstrap and readiness result framing so `pmacs --gpu FILE` opens the requested path before the GPU window becomes ready. Keep target identity scoped to the authenticated frontend, preserve legacy/no-target attach behavior, and publish fresh buffers coherently to existing replicas. Carry Unix path bytes and launcher cwd through the root broker, resolve paths lexically in the daemon, reuse or create buffers without ambient-view state, and preserve the managed daemon lifecycle from #141. Add focused parser, wire, lifecycle, hook, isolation, and real-connector acceptance coverage. --- README.md | 20 +- docs/active-work.md | 19 +- docs/agent-handoff.md | 41 ++- docs/gpu-initial-target-framing.md | 31 +- pmacs-gpu/src/attach.rs | 252 +++++++++++++- pmacs-gpu/src/main.rs | 238 +++++++++++--- pmacs-protocol/src/lib.rs | 13 +- pmacs-protocol/src/message.rs | 64 +++- src/daemon.rs | 339 ++++++++++++++++--- src/editor_core.rs | 2 +- src/frontend.rs | 3 + src/main.rs | 175 +++++++--- src/protocol.rs | 57 +++- tests/gpu_initial_target_acceptance.rs | 9 + tests/gpu_invocation_acceptance.rs | 418 +++++++++++++++++++++++- tests/m11_5_semantic_acceptance.rs | 3 +- tests/statusline_segments_acceptance.rs | 16 +- tests/vterm_stage3_acceptance.rs | 14 +- 18 files changed, 1494 insertions(+), 220 deletions(-) create mode 100644 tests/gpu_initial_target_acceptance.rs diff --git a/README.md b/README.md index e1c2960..bf72a53 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,18 @@ 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 +150,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 70df3e5..758ab3d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -61,14 +61,19 @@ If it does not, stop and repair the remote/fetch configuration. Revision 2 checkpoint `71039d1`. - Implementation base: canonical `githubsucks/main` @ `c49a8c7` (folding Stage 1 #142 merged after the framing base); protocol v19 before this work. -- State: framing approved 2026-07-23; implementation started; no PR. -- Scope: one session-scoped `pmacs --gpu [--socket …] FILE` target, +- State: implementation complete and smoke-tested on 2026-07-23; protocol v20; + no PR yet. The first portable checkpoint and integration with current + canonical `main` are next. +- 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. -- Next: implement and smoke-test the approved contract. Automatic GUI - selection, multiple files, general live-open commands, packaging, and - remote GPU paths remain deferred. + Unix path transport, pre-window target readiness, replica coherence, and the + approved behavioral acceptance matrix. +- Verification: formatting and strict Clippy; 1,792 default + 1,968 CRDT + library tests; target gate 1 default + 13 CRDT; M4 121; required GPU 152; + Vterm Stage 3 5 default + 7 CRDT; workspace sweep 3,260 across 87 suites. + A coherent release launch displayed `README.md` first at protocol v20. +- Deferred unchanged: automatic GUI selection, multiple files, general + live-open commands, packaging, and remote GPU paths. Recovery worktree after the first push: diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index bc8163c..bb7335b 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,11 +1,11 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-23, after one-command GPU invocation (#141) landed, -following 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-23, after GPU initial-target implementation completed +on branch `gpu-initial-target` (protocol v20, PR pending), following 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` @@ -22,6 +22,20 @@ commands, read `docs/active-work.md` immediately after this file. - `main` @ `63fbc66` (one-command GPU invocation #141 atop documentation refresh #140), protocol **v19** (`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events). +- **GPU INITIAL TARGET IMPLEMENTED — PR pending** + (`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, publishes fresh buffers to existing + replicas, and sends the target snapshot before readiness. Failed bootstrap + removes the provisional session without poisoning the daemon. 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. - **One-command GPU invocation LANDED — #141** (`docs/gpu-invocation-framing.md` rev 6; merge `63fbc66`; two implementation reviews). The additive public path is `pmacs --gpu [--socket NAME|PATH]`; @@ -536,13 +550,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 index 9029bd5..713a374 100644 --- a/docs/gpu-initial-target-framing.md +++ b/docs/gpu-initial-target-framing.md @@ -1,12 +1,14 @@ # GPU initial target — session-scoped file opening framing -**Revision 2 — user-review findings resolved. Ground truth: canonical `main` -@ `4daa1b8`, protocol v19, 2026-07-23. No implementation yet.** +**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 2 pins launcher-owned tilde expansion, requires `after-switch` even -when dedup selects the view's existing buffer, fails bootstrap when a hook -kills the target, and records the deliberate stderr-only wait during slow -pre-window bootstrap. It also sharpens the observed argv panic and negotiated +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 recorded the deliberate stderr-only wait during slow pre-window +bootstrap. It also sharpened the observed argv panic and negotiated protocol-version echo. One-command GPU startup landed in #141: @@ -465,7 +467,7 @@ pins the one initial target needed by `pmacs --gpu FILE`. ## Scope and touch map -Expected implementation surface: +As-built implementation surface: - `src/main.rs` - `args_os` parser, `Mode::Gpu { file, socket }`, help/grammar, exact private @@ -614,6 +616,21 @@ 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. +As-built verification on 2026-07-23: + +- `cargo fmt --check` and strict workspace Clippy passed. +- Library gates passed 1,792 default and 1,968 CRDT tests. +- The named initial-target gate passed 1 default and 13 CRDT tests; the + underlying GPU invocation suite passed 13 CRDT tests. +- 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 workspace CRDT sweep passed 3,260 tests across 87 suites, with 29 ignored + and the documented basedpyright case filtered. +- A coherent release build launched `target/release/pmacs --gpu --socket + initial-target-smoke README.md` on the real Wayland/Vulkan workstation, + attached at protocol v20, and displayed README rather than scratch. + ## Deferred (named) - **Multiple initial files.** Needs result ordering, active choice, and partial diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 013aa15..d28994b 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,67 @@ 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", + match other { + InstanceMessage::InitialTargetResult(_) => "InitialTargetResult", + _ => "instance message", + } + ))); + } + } + } +} + pub fn connect( socket_path: &Path, proxy: EventLoopProxy, @@ -418,11 +511,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 +551,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 +581,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 +682,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 +718,7 @@ pub fn connect_managed_with_sink( spawn_daemon, MANAGED_STARTUP_TIMEOUT, MANAGED_RETRY_INTERVAL, + initial_target, sink, ) } @@ -660,6 +786,7 @@ fn connect_managed_inner( spawner: S, timeout: Duration, retry_interval: Duration, + initial_target: Option, sink: F, ) -> Result where @@ -669,7 +796,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 +822,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 +866,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 +876,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 +1046,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 +1063,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 +1389,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 +1468,7 @@ mod tests { |_, _| panic!("non-socket path must not spawn"), Duration::from_millis(1), Duration::from_millis(1), + None, |_| false, ); assert!(matches!( @@ -1269,6 +1493,7 @@ mod tests { |_, _| panic!("permission failure must not spawn"), Duration::from_millis(1), Duration::from_millis(1), + None, |_| false, ); assert!(matches!( @@ -1318,6 +1543,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..a640947 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, }, } @@ -589,8 +593,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 +620,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 +636,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 +851,24 @@ 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_tx.clone(); + 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,10 @@ fn run_headless_managed_probe(socket: &Path, report: &Path, daemon_executable: & return 4; } }; - let client = managed.client; + let mut client = managed.client; + if let Some(message) = client.take_initial_message() { + let _ = event_tx.send(AttachEvent::Message(Box::new(message))); + } let daemon = managed.daemon; let protocol = client.server_protocol_version(); @@ -1019,7 +1049,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 +1059,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 +8219,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::FontFacts { .. } => "FontFacts", InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments", InstanceMessage::TerminalFrame(_) => "TerminalFrame", + InstanceMessage::InitialTargetResult(_) => "InitialTargetResult", } } @@ -14263,12 +14360,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 +14376,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 +14401,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 +14478,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 +14520,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 fe28112..baf1e35 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -49,7 +49,9 @@ //! - Write fails (broken pipe) → return; ungraceful disconnect. use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; use std::io::ErrorKind; +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 +66,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 +117,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 +638,47 @@ 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)); +} + +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 +690,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 +762,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 +832,7 @@ fn per_attach_thread( frontend_id, session_state, initial_size: req.initial_size, + initial_target, write_stream, }) .is_err() @@ -1526,8 +1599,134 @@ fn take_pending_terminal_bell( } } +struct OpenedInitialTarget { + buffer_id: crate::buffer::BufferId, + publish_to_replicas: 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, String> { + 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}"))?; + if !buffer.is_crdt_backed() { + 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:?}"))?; + } + 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:?}")) +} + +#[cfg(not(feature = "crdt"))] +fn initial_target_snapshot( + _editor: &EditorState, + _buffer_id: crate::buffer::BufferId, +) -> Result, String> { + 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); + streams.remove(&frontend_id); + 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,55 +1735,93 @@ 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 - // buffer view; future milestones may clone LOCAL's view or - // take an explicit initial-buffer argument). - let scratch_view = build_fresh_frontend_view(editor); - editor - .core - .borrow_mut() - .register_frontend_view(frontend_id, scratch_view); + let fresh_view = build_fresh_frontend_view(editor); + { + 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 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: snapshot, + }; + if opened.publish_to_replicas { + for (peer_id, peer_stream) in streams.iter_mut() { + let is_replica = session_registry + .session_state(*peer_id) + .is_some_and(|state| state.negotiated_capabilities.crdt_replica); + if is_replica && let Err(error) = write_message(peer_stream, &snapshot_message) { + send_initial_target_failure( + &mut write_stream, + format!("cannot publish initial target snapshot to {peer_id:?}: {error}"), + ); + editor + .core + .borrow_mut() + .unregister_frontend_view(frontend_id); + return; + } + if is_replica && let Some(state) = semantic_states.get_mut(peer_id) { + state.on_buffer_snapshot_sent(opened.buffer_id); + } + } + } + if write_message(&mut write_stream, &snapshot_message).is_err() { + 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, @@ -1598,9 +1835,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)] @@ -1622,6 +1880,7 @@ fn handle_dispatcher_event( frontend_id, session_state, initial_size, + initial_target, write_stream, } => { handle_session_established( @@ -1630,10 +1889,12 @@ 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, ); } diff --git a/src/editor_core.rs b/src/editor_core.rs index 5afbe90..fbcf3ae 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -3232,7 +3232,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() { 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..aba1e32 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,22 @@ fn gpu_binary(current_exe: &Path, override_bin: Option) -> (PathBuf, Pa (PathBuf::from("pmacs-gpu"), sibling) } -fn run_gpu(socket: Option<&str>) -> ExitCode { +fn expand_launcher_tilde(path: &Path) -> PathBuf { + let Some(path_text) = path.to_str() else { + return path.to_owned(); + }; + if path_text == "~" { + return std::env::var_os("HOME").map_or_else(|| path.to_owned(), PathBuf::from); + } + if let Some(rest) = path_text.strip_prefix("~/") + && let Some(home) = std::env::var_os("HOME") + { + return Path::new(&home).join(rest); + } + path.to_owned() +} + +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 +344,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, expand_launcher_tilde(path))) + } + 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 +398,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 +428,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 +511,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 +840,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 +851,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 +895,31 @@ 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!(expand_launcher_tilde(Path::new("~")), PathBuf::from(&home)); + assert_eq!( + expand_launcher_tilde(Path::new("~/notes")), + PathBuf::from(home).join("notes") + ); + assert_eq!( + expand_launcher_tilde(Path::new("~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/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..267c330 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,110 @@ 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 request_raw_target(socket: &Path, cwd: Vec, path: Vec) -> 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"); + if matches!(first, InstanceMessage::BufferSnapshot { .. }) { + vec![ + first, + read_message::(&mut stream).expect("raw opened result"), + ] + } else { + vec![first] + } + } + 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 +351,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 +396,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 +457,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 +469,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 +487,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 +512,267 @@ 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 (bad_cwd, bad_path) in invalid { + let messages = request_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:?}"), + } + } + + 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 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 8d6f1d2..58cd5f2 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -781,16 +781,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 4a170af..0efaa0e 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -712,8 +712,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(), @@ -788,8 +788,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; @@ -815,6 +815,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) } @@ -838,7 +842,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"), From 19674a47ea3a980efe66c898c478f4e515b4114b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 19:13:34 -0400 Subject: [PATCH 06/13] Record integrated GPU target verification Update the framing, durable handoff, and active-work ledger after integrating current canonical main and completing the required gates and real GPU smoke. --- docs/active-work.md | 25 ++++++++++++++----------- docs/agent-handoff.md | 6 +++--- docs/gpu-initial-target-framing.md | 4 ++-- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 758ab3d..8e8c445 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -14,8 +14,9 @@ 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` @ `4daa1b8` (inline-math framing #145 atop one-command - GPU invocation #141 and its landed-state handoff #143; protocol v19). + `githubsucks/main` @ `47581f4` (web grammars #146 atop folding Stage 1 + #142, inline-math framing #145, and one-command GPU invocation #141; + 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 @@ -49,7 +50,7 @@ git worktree list git status --short --branch ``` -The `git log` command must expose `4daa1b8` or a newer intentional main. +The `git log` command must expose `47581f4` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. ## Active lane: GPU initial target @@ -59,18 +60,20 @@ If it does not, stop and repair the remote/fetch configuration. `../pmacs-gpu-initial-target`. - Approved framing branch: `githubsucks/gpu-initial-target-framing`; Revision 2 checkpoint `71039d1`. -- Implementation base: canonical `githubsucks/main` @ `c49a8c7` (folding - Stage 1 #142 merged after the framing base); protocol v19 before this work. -- State: implementation complete and smoke-tested on 2026-07-23; protocol v20; - no PR yet. The first portable checkpoint and integration with current - canonical `main` are next. +- Original implementation base: canonical `githubsucks/main` @ `c49a8c7` + (folding Stage 1 #142); current canonical `main` @ `47581f4` is integrated + conflict-free by merge `d6d4be6`. Protocol was v19 before this work. +- State: implementation checkpoint `2dd30ec`; integrated, smoke-tested, and + fully gated on 2026-07-23 at protocol v20; no PR yet. Publishing the branch + is next. - 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. -- Verification: formatting and strict Clippy; 1,792 default + 1,968 CRDT - library tests; target gate 1 default + 13 CRDT; M4 121; required GPU 152; - Vterm Stage 3 5 default + 7 CRDT; workspace sweep 3,260 across 87 suites. +- Verification after current-main integration: formatting and strict Clippy; + 1,800 default + 1,976 CRDT library tests; target gate 1 default + 13 CRDT; + M4 121; required GPU 152; Vterm Stage 3 5 default + 7 CRDT; workspace sweep + 3,268 across 87 suites. A coherent release launch displayed `README.md` first at protocol v20. - Deferred unchanged: automatic GUI selection, multiple files, general live-open commands, packaging, and remote GPU paths. diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index bb7335b..b4c52bf 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -19,9 +19,9 @@ commands, read `docs/active-work.md` immediately after this file. ## 1. Where the project stands (2026-07-23) -- `main` @ `63fbc66` (one-command GPU invocation #141 atop documentation - refresh #140), protocol **v19** (`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, - v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events). +- `main` @ `47581f4` (web grammars #146 atop folding Stage 1 #142, + inline-math framing #145, and one-command GPU invocation #141), protocol + **v19** (`SUPPORTED=[6..=19]`; v19 = terminal frames/events). - **GPU INITIAL TARGET IMPLEMENTED — PR pending** (`docs/gpu-initial-target-framing.md` rev 3; branch `gpu-initial-target`). `pmacs --gpu [--socket NAME|PATH] FILE` now transports exact Unix path bytes diff --git a/docs/gpu-initial-target-framing.md b/docs/gpu-initial-target-framing.md index 713a374..c93ad68 100644 --- a/docs/gpu-initial-target-framing.md +++ b/docs/gpu-initial-target-framing.md @@ -619,13 +619,13 @@ supports both. The final full workspace sweep remains required before PR. As-built verification on 2026-07-23: - `cargo fmt --check` and strict workspace Clippy passed. -- Library gates passed 1,792 default and 1,968 CRDT tests. +- Library gates passed 1,800 default and 1,976 CRDT tests. - The named initial-target gate passed 1 default and 13 CRDT tests; the underlying GPU invocation suite passed 13 CRDT tests. - 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 workspace CRDT sweep passed 3,260 tests across 87 suites, with 29 ignored +- The workspace CRDT sweep passed 3,268 tests across 87 suites, with 29 ignored and the documented basedpyright case filtered. - A coherent release build launched `target/release/pmacs --gpu --socket initial-target-smoke README.md` on the real Wayland/Vulkan workstation, From 010a4b52ebcdc347f56a38fca2fe419b561251b1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 19:15:05 -0400 Subject: [PATCH 07/13] Record portable GPU target checkpoint Pin the published implementation branch and leave PR creation as the next recoverable action. --- docs/active-work.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 8e8c445..7afe150 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -55,16 +55,16 @@ If it does not, stop and repair the remote/fetch configuration. ## Active lane: GPU initial target -- Portable implementation branch: `gpu-initial-target` (publish to - `githubsucks/gpu-initial-target` at the first verified checkpoint); worktree +- Portable implementation branch: `githubsucks/gpu-initial-target` @ + `19674a4` (verified code, integration, and as-built documentation); 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` @ `47581f4` is integrated conflict-free by merge `d6d4be6`. Protocol was v19 before this work. -- State: implementation checkpoint `2dd30ec`; integrated, smoke-tested, and - fully gated on 2026-07-23 at protocol v20; no PR yet. Publishing the branch +- State: implementation checkpoint `2dd30ec`; integrated, smoke-tested, fully + gated, and published on 2026-07-23 at protocol v20; no PR yet. Opening the PR is next. - Scope delivered: one session-scoped `pmacs --gpu [--socket …] FILE` target, protocol-v20 semantic bootstrap, launcher-owned tilde/cwd resolution, exact From 4f1651c8f0eab38dfa60b195e4bd390cd774819a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 19:17:11 -0400 Subject: [PATCH 08/13] Record GPU initial target PR Update the active-work recovery ledger with PR #148 and the published pre-PR checkpoint. --- docs/active-work.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 7afe150..057c0d8 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -56,7 +56,7 @@ If it does not, stop and repair the remote/fetch configuration. ## Active lane: GPU initial target - Portable implementation branch: `githubsucks/gpu-initial-target` @ - `19674a4` (verified code, integration, and as-built documentation); worktree + `010a4b5` (verified implementation plus portable ledger); worktree `../pmacs-gpu-initial-target`. - Approved framing branch: `githubsucks/gpu-initial-target-framing`; Revision 2 checkpoint `71039d1`. @@ -64,8 +64,8 @@ If it does not, stop and repair the remote/fetch configuration. (folding Stage 1 #142); current canonical `main` @ `47581f4` is integrated conflict-free by merge `d6d4be6`. Protocol was v19 before this work. - State: implementation checkpoint `2dd30ec`; integrated, smoke-tested, fully - gated, and published on 2026-07-23 at protocol v20; no PR yet. Opening the PR - is next. + gated, and published on 2026-07-23 at protocol v20. PR #148 is 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 From bef1c08133bb9f8ceeb8140ee437fae6d7f152ca Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 21:25:33 -0400 Subject: [PATCH 09/13] fix(gpu): preserve session isolation during target publish Keep foreign BufferSnapshot publications out of existing semantic GPU sessions while retaining grid-replica coherence. Treat dead peer writes as peer-local failures, restore active-frontend cleanup, deterministic probe readiness, GPU logging, shared tilde expansion, and accurate docs. Add focused publication and cleanup coverage and record the two-window Wayland/Vulkan smoke plus the complete post-review gate results. --- README.md | 1 + docs/active-work.md | 15 +-- docs/agent-handoff.md | 29 ++--- docs/gpu-initial-target-framing.md | 41 ++++--- pmacs-gpu/src/attach.rs | 5 +- pmacs-gpu/src/main.rs | 23 +++- src/daemon.rs | 173 ++++++++++++++++++++++------- src/editor_core.rs | 10 +- src/main.rs | 26 ++--- 9 files changed, 214 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index bf72a53..515c259 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ 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 diff --git a/docs/active-work.md b/docs/active-work.md index 057c0d8..4baa59c 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -64,17 +64,18 @@ If it does not, stop and repair the remote/fetch configuration. (folding Stage 1 #142); current canonical `main` @ `47581f4` is integrated conflict-free by merge `d6d4be6`. Protocol was v19 before this work. - State: implementation checkpoint `2dd30ec`; integrated, smoke-tested, fully - gated, and published on 2026-07-23 at protocol v20. PR #148 is open for user - review: `https://github.com/levineuwirth/pmacs/pull/148`. + gated, and published on 2026-07-23 at protocol v20. PR #148 review fixes are + complete locally and awaiting publication; PR: + `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. -- Verification after current-main integration: formatting and strict Clippy; - 1,800 default + 1,976 CRDT library tests; target gate 1 default + 13 CRDT; - M4 121; required GPU 152; Vterm Stage 3 5 default + 7 CRDT; workspace sweep - 3,268 across 87 suites. - A coherent release launch displayed `README.md` first at protocol v20. +- Post-review verification: formatting and strict Clippy; 1,800 default + 1,977 + CRDT library tests; target gate 1 default + 13 CRDT; M4 121; required GPU 152; + Vterm Stage 3 5 default + 7 CRDT; isolated-config workspace sweep 3,269 across + 87 suites. Two concurrent real Wayland/Vulkan GPU windows remained on distinct + target buffers after the second attach. - Deferred unchanged: automatic GUI selection, multiple files, general live-open commands, packaging, and remote GPU paths. diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index b4c52bf..4a01325 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,11 +1,12 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-23, after GPU initial-target implementation completed -on branch `gpu-initial-target` (protocol v20, PR pending), following 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).** +**Last updated: 2026-07-23, after GPU initial-target PR #148 review fixes and +verification completed on branch `gpu-initial-target` (protocol v20), following +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` @@ -22,7 +23,7 @@ commands, read `docs/active-work.md` immediately after this file. - `main` @ `47581f4` (web grammars #146 atop folding Stage 1 #142, inline-math framing #145, and one-command GPU invocation #141), protocol **v19** (`SUPPORTED=[6..=19]`; v19 = terminal frames/events). -- **GPU INITIAL TARGET IMPLEMENTED — PR pending** +- **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 @@ -30,12 +31,14 @@ commands, read `docs/active-work.md` immediately after this file. 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, publishes fresh buffers to existing - replicas, and sends the target snapshot before readiness. Failed bootstrap - removes the provisional session without poisoning the daemon. 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. + hooks, upgrades the buffer for CRDT, publishes fresh buffers to existing grid + replicas, and sends the target snapshot 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 removes the provisional session and restores + the ambient active frontend without poisoning the daemon. 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. - **One-command GPU invocation LANDED — #141** (`docs/gpu-invocation-framing.md` rev 6; merge `63fbc66`; two implementation reviews). The additive public path is `pmacs --gpu [--socket NAME|PATH]`; diff --git a/docs/gpu-initial-target-framing.md b/docs/gpu-initial-target-framing.md index c93ad68..96ecd9a 100644 --- a/docs/gpu-initial-target-framing.md +++ b/docs/gpu-initial-target-framing.md @@ -7,8 +7,8 @@ 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 recorded the deliberate stderr-only wait during slow pre-window -bootstrap. It also sharpened the observed argv panic and negotiated +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: @@ -344,13 +344,12 @@ 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. This is deliberate for the -explicit terminal command: before blocking, `pmacs-gpu` writes one bounded, -lossy-display-only `opening …` notice to stderr. 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. +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 @@ -524,8 +523,9 @@ process behavior. 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, duplicated, or relative-cwd forms exit 2. An option-like `FILE` - after the marker remains literal. + 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 @@ -616,20 +616,25 @@ 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. -As-built verification on 2026-07-23: +The named gate intentionally reuses the managed-lifecycle acceptance module, +so a workspace sweep executes those 13 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-review verification on 2026-07-23: - `cargo fmt --check` and strict workspace Clippy passed. -- Library gates passed 1,800 default and 1,976 CRDT tests. +- Library gates passed 1,800 default and 1,977 CRDT tests. - The named initial-target gate passed 1 default and 13 CRDT tests; the underlying GPU invocation suite passed 13 CRDT tests. - 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 workspace CRDT sweep passed 3,268 tests across 87 suites, with 29 ignored - and the documented basedpyright case filtered. -- A coherent release build launched `target/release/pmacs --gpu --socket - initial-target-smoke README.md` on the real Wayland/Vulkan workstation, - attached at protocol v20, and displayed README rather than scratch. +- The isolated-config workspace CRDT sweep passed 3,269 tests across 87 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) diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index d28994b..7dd97a9 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -478,10 +478,7 @@ fn read_initial_target_bootstrap( other => { return Err(AttachClientError::InitialTargetProtocol(format!( "unexpected {} before target readiness", - match other { - InstanceMessage::InitialTargetResult(_) => "InitialTargetResult", - _ => "instance message", - } + crate::instance_message_label(&other) ))); } } diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a640947..29acbe3 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -593,6 +593,7 @@ fn decimal_digits(mut n: usize) -> u32 { } fn main() { + env_logger::init(); let mode = match parse_args(&std::env::args_os().skip(1).collect::>()) { Ok(mode) => mode, Err(error) => { @@ -861,8 +862,7 @@ fn run_headless_managed_probe( use std::sync::mpsc; use std::time::{Duration, Instant}; - let (event_tx, event_rx) = mpsc::channel::(); - let connector_tx = event_tx.clone(); + let (connector_tx, event_rx) = mpsc::channel::(); let managed = match attach::connect_managed_with_target_and_sink( socket, daemon_executable, @@ -878,9 +878,10 @@ fn run_headless_managed_probe( } }; let mut client = managed.client; - if let Some(message) = client.take_initial_message() { - let _ = event_tx.send(AttachEvent::Message(Box::new(message))); - } + let initial_target_ready = matches!( + client.take_initial_message(), + Some(InstanceMessage::BufferSnapshot { .. }) + ); let daemon = managed.daemon; let protocol = client.server_protocol_version(); @@ -895,12 +896,22 @@ fn run_headless_managed_probe( .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; diff --git a/src/daemon.rs b/src/daemon.rs index baf1e35..e114464 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1085,12 +1085,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, @@ -1786,25 +1786,14 @@ fn handle_session_established( crdt_snapshot: snapshot, }; if opened.publish_to_replicas { - for (peer_id, peer_stream) in streams.iter_mut() { - let is_replica = session_registry - .session_state(*peer_id) - .is_some_and(|state| state.negotiated_capabilities.crdt_replica); - if is_replica && let Err(error) = write_message(peer_stream, &snapshot_message) { - send_initial_target_failure( - &mut write_stream, - format!("cannot publish initial target snapshot to {peer_id:?}: {error}"), - ); - editor - .core - .borrow_mut() - .unregister_frontend_view(frontend_id); - return; - } - if is_replica && let Some(state) = semantic_states.get_mut(peer_id) { - state.on_buffer_snapshot_sent(opened.buffer_id); - } - } + 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() { editor @@ -2400,28 +2389,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); } } } @@ -3151,6 +3168,7 @@ mod tests { let semantic = crate::protocol::NegotiatedCapabilities { multi_frontend: true, crdt_replica: true, + semantic_render: true, }; let old_peer = FrontendId(2); @@ -3170,6 +3188,81 @@ 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); + 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 build_identity_includes_version_and_uptime() { let s = DaemonState::new(Some("research".into())); diff --git a/src/editor_core.rs b/src/editor_core.rs index fbcf3ae..47a7597 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -548,6 +548,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. @@ -3214,7 +3217,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; }; @@ -3713,13 +3716,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/main.rs b/src/main.rs index aba1e32..cdb0b8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -315,21 +315,6 @@ fn gpu_binary(current_exe: &Path, override_bin: Option) -> (PathBuf, Pa (PathBuf::from("pmacs-gpu"), sibling) } -fn expand_launcher_tilde(path: &Path) -> PathBuf { - let Some(path_text) = path.to_str() else { - return path.to_owned(); - }; - if path_text == "~" { - return std::env::var_os("HOME").map_or_else(|| path.to_owned(), PathBuf::from); - } - if let Some(rest) = path_text.strip_prefix("~/") - && let Some(home) = std::env::var_os("HOME") - { - return Path::new(&home).join(rest); - } - path.to_owned() -} - fn run_gpu(socket: Option<&str>, file: Option<&Path>) -> ExitCode { if !cfg!(feature = "crdt") { eprintln!("pmacs: --gpu requires pmacs built with --features crdt"); @@ -353,7 +338,7 @@ fn run_gpu(socket: Option<&str>, file: Option<&Path>) -> ExitCode { return ExitCode::FAILURE; } }; - Some((cwd, expand_launcher_tilde(path))) + Some((cwd, pmacs::editor_core::expand_tilde(path.to_owned()))) } None => None, }; @@ -909,13 +894,16 @@ mod tests { } let home = std::env::var_os("HOME").expect("test HOME"); - assert_eq!(expand_launcher_tilde(Path::new("~")), PathBuf::from(&home)); assert_eq!( - expand_launcher_tilde(Path::new("~/notes")), + 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!( - expand_launcher_tilde(Path::new("~other/notes")), + pmacs::editor_core::expand_tilde(PathBuf::from("~other/notes")), PathBuf::from("~other/notes") ); } From 65e500eba18d14ec223e1fc0c0fcd313e838f89e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 21:26:35 -0400 Subject: [PATCH 10/13] docs: record published GPU review fixes Point the active-work recovery ledger at the verified review-fix checkpoint and record that PR #148 remains open for user review. --- docs/active-work.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 4baa59c..25550f4 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -56,16 +56,16 @@ If it does not, stop and repair the remote/fetch configuration. ## Active lane: GPU initial target - Portable implementation branch: `githubsucks/gpu-initial-target` @ - `010a4b5` (verified implementation plus portable ledger); worktree + `bef1c08` (PR review fixes plus complete post-review verification); 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` @ `47581f4` is integrated conflict-free by merge `d6d4be6`. Protocol was v19 before this work. -- State: implementation checkpoint `2dd30ec`; integrated, smoke-tested, fully - gated, and published on 2026-07-23 at protocol v20. PR #148 review fixes are - complete locally and awaiting publication; PR: +- State: implementation checkpoint `2dd30ec`; review-fix checkpoint `bef1c08`. + Integrated, smoke-tested, fully gated, and published on 2026-07-23 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 From be8c67c30cae4ae874a2b134ab4b442620c314c8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 10:10:31 -0400 Subject: [PATCH 11/13] fix(daemon): contain failed target sessions Shut down bootstrap sockets on every dispatcher-side failure and reject frontend events whose session state was never installed. This prevents a lingering failed client from reaching absent render/size state. Track target-side CRDT upgrades independently from load/create status so a deduplicated hidden buffer is published to every existing grid replica. Add real-daemon regressions for both failure containment and replica publication. --- docs/active-work.md | 18 ++--- docs/agent-handoff.md | 34 +++++----- docs/gpu-initial-target-framing.md | 32 +++++---- src/daemon.rs | 103 ++++++++++++++++++++++++----- tests/gpu_invocation_acceptance.rs | 90 +++++++++++++++++++++++-- 5 files changed, 215 insertions(+), 62 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 25550f4..abb2554 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -63,19 +63,19 @@ If it does not, stop and repair the remote/fetch configuration. - Original implementation base: canonical `githubsucks/main` @ `c49a8c7` (folding Stage 1 #142); current canonical `main` @ `47581f4` is integrated conflict-free by merge `d6d4be6`. Protocol was v19 before this work. -- State: implementation checkpoint `2dd30ec`; review-fix checkpoint `bef1c08`. - Integrated, smoke-tested, fully gated, and published on 2026-07-23 at - protocol v20. PR #148 remains open for user review: - `https://github.com/levineuwirth/pmacs/pull/148`. +- State: implementation checkpoint `2dd30ec`; first review-fix checkpoint + `bef1c08`. Second-review fixes are complete locally, smoke-tested, and fully + gated at protocol v20; publication is next. 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-review verification: formatting and strict Clippy; 1,800 default + 1,977 - CRDT library tests; target gate 1 default + 13 CRDT; M4 121; required GPU 152; - Vterm Stage 3 5 default + 7 CRDT; isolated-config workspace sweep 3,269 across - 87 suites. Two concurrent real Wayland/Vulkan GPU windows remained on distinct - target buffers after the second attach. +- Second-review verification: formatting and strict Clippy; 1,801 default + + 1,978 CRDT library tests; target gate 1 default + 14 CRDT; M4 121; required + GPU 152; Vterm Stage 3 5 default + 7 CRDT; isolated-config workspace sweep + 3,272 across 87 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. diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 4a01325..742e093 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,12 +1,12 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-23, after GPU initial-target PR #148 review fixes and -verification completed on branch `gpu-initial-target` (protocol v20), following -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).** +**Last updated: 2026-07-24, after GPU initial-target PR #148 second-review fixes +and verification completed on branch `gpu-initial-target` (protocol v20), +following 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` @@ -18,7 +18,7 @@ reads it the way you just did. For volatile branches, checkpoints, verification, and recovery commands, read `docs/active-work.md` immediately after this file. -## 1. Where the project stands (2026-07-23) +## 1. Where the project stands (2026-07-24) - `main` @ `47581f4` (web grammars #146 atop folding Stage 1 #142, inline-math framing #145, and one-command GPU invocation #141), protocol @@ -31,14 +31,16 @@ commands, read `docs/active-work.md` immediately after this file. 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, publishes fresh buffers to existing grid - replicas, and sends the target snapshot 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 removes the provisional session and restores - the ambient active frontend without poisoning the daemon. 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. + 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. - **One-command GPU invocation LANDED — #141** (`docs/gpu-invocation-framing.md` rev 6; merge `63fbc66`; two implementation reviews). The additive public path is `pmacs --gpu [--socket NAME|PATH]`; diff --git a/docs/gpu-initial-target-framing.md b/docs/gpu-initial-target-framing.md index 96ecd9a..e326671 100644 --- a/docs/gpu-initial-target-framing.md +++ b/docs/gpu-initial-target-framing.md @@ -230,7 +230,8 @@ pub struct InitialTarget { 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. -- Each field is bounded to 32 KiB before allocation/use. `path` must be +- 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. @@ -550,9 +551,11 @@ process behavior. 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. An existing daemon - remains connectable; a pre-existing frontend's active buffer and contents - remain unchanged. + 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 @@ -561,10 +564,11 @@ process behavior. 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. **Fresh-buffer publication:** keep replica A attached, then target-launch B - onto a previously unknown file. A receives the new buffer snapshot before - any CRDT op for it; both replicas accept later operations without unknown- - buffer fallback or disconnect. +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 @@ -617,20 +621,20 @@ 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 13 CRDT cases under both test-binary +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-review verification on 2026-07-23: +Post-second-review verification on 2026-07-24: - `cargo fmt --check` and strict workspace Clippy passed. -- Library gates passed 1,800 default and 1,977 CRDT tests. -- The named initial-target gate passed 1 default and 13 CRDT tests; the - underlying GPU invocation suite passed 13 CRDT tests. +- Library gates passed 1,801 default and 1,978 CRDT tests. +- The named initial-target gate passed 1 default and 14 CRDT tests; the + underlying GPU invocation suite passed 14 CRDT tests. - 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,269 tests across 87 suites, +- The isolated-config workspace CRDT sweep passed 3,272 tests across 87 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 diff --git a/src/daemon.rs b/src/daemon.rs index e114464..7421bde 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -51,6 +51,7 @@ 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}; @@ -655,6 +656,7 @@ fn send_initial_target_failure(stream: &mut UnixStream, message: impl Into Result<(), String> { @@ -1604,6 +1606,11 @@ struct OpenedInitialTarget { 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)); @@ -1674,30 +1681,35 @@ fn open_initial_target( fn initial_target_snapshot( editor: &EditorState, buffer_id: crate::buffer::BufferId, -) -> Result, String> { +) -> 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}"))?; - if !buffer.is_crdt_backed() { + 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:?}"))?; } - buffer + 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:?}")) + .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, String> { +) -> Result { Err("initial target requires a CRDT-enabled daemon".to_owned()) } @@ -1714,7 +1726,9 @@ fn cleanup_provisional_session( ) { render_states.remove(&frontend_id); semantic_states.remove(&frontend_id); - streams.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); @@ -1770,7 +1784,7 @@ fn handle_session_established( let negotiated_protocol_version = session_state.negotiated_protocol_version; if let Some(opened) = opened_target.as_ref() { - let snapshot = match initial_target_snapshot(editor, opened.buffer_id) { + let target_snapshot = match initial_target_snapshot(editor, opened.buffer_id) { Ok(snapshot) => snapshot, Err(message) => { send_initial_target_failure(&mut write_stream, message); @@ -1783,9 +1797,9 @@ fn handle_session_established( }; let snapshot_message = InstanceMessage::BufferSnapshot { buffer_id: opened.buffer_id, - crdt_snapshot: snapshot, + crdt_snapshot: target_snapshot.crdt_snapshot, }; - if opened.publish_to_replicas { + if opened.publish_to_replicas || target_snapshot.upgraded_to_crdt { publish_buffer_snapshot_to_replicas( editor, opened.buffer_id, @@ -1796,6 +1810,7 @@ fn handle_session_established( ); } if write_message(&mut write_stream, &snapshot_message).is_err() { + let _ = write_stream.shutdown(Shutdown::Both); editor .core .borrow_mut() @@ -1888,6 +1903,10 @@ fn handle_dispatcher_event( ); } 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 @@ -2102,9 +2121,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); @@ -2123,10 +2145,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:?}" ); } } @@ -3168,7 +3188,6 @@ mod tests { let semantic = crate::protocol::NegotiatedCapabilities { multi_frontend: true, crdt_replica: true, - semantic_render: true, }; let old_peer = FrontendId(2); @@ -3263,6 +3282,44 @@ mod tests { ); } + #[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())); @@ -3654,6 +3711,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/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 267c330..5371438 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -281,7 +281,11 @@ mod crdt { } } - fn request_raw_target(socket: &Path, cwd: Vec, path: Vec) -> Vec { + 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))) @@ -310,14 +314,19 @@ mod crdt { .expect("send raw target"); let first = read_message::(&mut stream).expect("raw target result"); - if matches!(first, InstanceMessage::BufferSnapshot { .. }) { + 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 { @@ -666,8 +675,8 @@ mod crdt { (cwd.clone(), vec![b'x'; 32 * 1024 + 1]), (cwd.clone(), b".".to_vec()), ]; - for (bad_cwd, bad_path) in invalid { - let messages = request_raw_target(&socket, bad_cwd, bad_path); + 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 }) => { @@ -676,8 +685,20 @@ mod crdt { } 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"); @@ -685,6 +706,63 @@ mod crdt { 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(); From b3ed40cf6082930c07322146d4e1adda925a4246 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 10:11:24 -0400 Subject: [PATCH 12/13] docs: record published second-review fixes Point the active-work ledger at the failure-containment and dedup-publication checkpoint while leaving PR #148 open for user review. --- docs/active-work.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index abb2554..87e243f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -56,7 +56,7 @@ If it does not, stop and repair the remote/fetch configuration. ## Active lane: GPU initial target - Portable implementation branch: `githubsucks/gpu-initial-target` @ - `bef1c08` (PR review fixes plus complete post-review verification); worktree + `be8c67c` (second-review fixes plus complete verification); worktree `../pmacs-gpu-initial-target`. - Approved framing branch: `githubsucks/gpu-initial-target-framing`; Revision 2 checkpoint `71039d1`. @@ -64,8 +64,8 @@ If it does not, stop and repair the remote/fetch configuration. (folding Stage 1 #142); current canonical `main` @ `47581f4` is integrated conflict-free by merge `d6d4be6`. Protocol was v19 before this work. - State: implementation checkpoint `2dd30ec`; first review-fix checkpoint - `bef1c08`. Second-review fixes are complete locally, smoke-tested, and fully - gated at protocol v20; publication is next. PR #148 remains open for user + `bef1c08`; second-review checkpoint `be8c67c`. Integrated, 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 From 907e4d87fccc041be8f0267c38c04febe5f78c7b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 10:22:20 -0400 Subject: [PATCH 13/13] docs: record current-main integration Point the active-work recovery ledger at the published merge of folding Stage 2 and the GPU initial-target second-review fixes. --- docs/active-work.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 8d20354..a419388 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -57,18 +57,17 @@ If it does not, stop and repair the remote/fetch configuration. ## Active lane: GPU initial target - Portable implementation branch: `githubsucks/gpu-initial-target` @ - `be8c67c` (second-review fixes plus complete verification); worktree - `../pmacs-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 - before publication of the second-review merge. Protocol was v19 before this - work. + by merge `6c06815`. Protocol was v19 before this work. - State: implementation checkpoint `2dd30ec`; first review-fix checkpoint - `bef1c08`; second-review checkpoint `be8c67c`. Current `main` is integrated, - smoke-tested, and fully gated at protocol v20; merge publication is next. - PR #148 remains open for user review: + `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