Merge pull request #148 from levineuwirth/gpu-initial-target

feat(gpu): open an initial file before window startup
This commit is contained in:
Levi Neuwirth 2026-07-24 15:18:03 +00:00 committed by GitHub
commit 0dd16a56e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 2460 additions and 255 deletions

View File

@ -11,7 +11,7 @@ coroutine-based async surface are core primitives, not bolt-ons.
The editor is partitioned into a long-lived **instance** (the daemon
that owns buffers, processes, and language services) and thin
**frontends** that attach over a typed protocol (currently v19). Two
**frontends** that attach over a typed protocol (currently v20). Two
frontends ship today:
- a **TUI** (crossterm cell grid), attachable locally over a Unix
@ -32,7 +32,7 @@ the same buffers concurrently with live cursor/selection presence.
**v1.0.0 --- stable core, active development.** The v1.0 gate (the
instance/frontend partition, the Lua surface, and a REPL package audited
to use zero direct Rust core access) shipped some time ago. Development
since has expanded the semantic frontend protocol from v6 through v19,
since has expanded the semantic frontend protocol from v6 through v20,
brought the GPU frontend near input/render parity with the TUI, and
completed the LSP, editing, persistence, themes, and terminal arcs. Recent
work added major modes and modeline detection, a typed configuration
@ -102,16 +102,19 @@ pmacs [FILE] # TUI; -nw reserved for when a GUI default lands
GPU frontend (one command; the root binary starts or reuses the daemon):
```sh
pmacs --gpu # default instance
pmacs --gpu --socket NAME # named instance; bare NAME →
pmacs --gpu # default instance; no initial file
pmacs --gpu README.md # default instance; open one file
pmacs --gpu --socket NAME FILE # named instance; bare NAME →
# <runtime>/pmacs/NAME.sock
pmacs --gpu -- --leading-dash # `--` ends option parsing
```
`pmacs --gpu` requires the root `pmacs` binary to be built with the
`crdt` feature. It discovers a sibling `pmacs-gpu` binary first, then
falls back to `pmacs-gpu` on `PATH`. Closing the window detaches only
that frontend; the daemon remains available for later GPU or TUI
attaches.
falls back to `pmacs-gpu` on `PATH`. When `FILE` is present, the daemon
loads or creates it and completes startup hooks before the GPU window
appears. Closing the window detaches only that frontend; the daemon
remains available for later GPU or TUI attaches.
Daemon + attached TUI frontends:
@ -148,7 +151,7 @@ Builds on the toolchain pinned in `rust-toolchain.toml` (Rust
# the separate pmacs-gpu package feature-free while enabling CRDT in pmacs.
cargo build --release --workspace --features pmacs/crdt
target/release/pmacs --gpu # one-command managed GPU launch
target/release/pmacs --gpu README.md # one-command managed GPU file launch
cargo run --release -- --version # default-run selects the pmacs binary
cargo test --workspace # unit + integration tests (all crates)
cargo fmt --check

View File

@ -1,6 +1,6 @@
# Active work — cross-machine resume ledger
**Snapshot: 2026-07-23.** This file records volatile work that has not
**Snapshot: 2026-07-24.** This file records volatile work that has not
landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
entries when their PR merges; do not let this become a second permanent
backlog.
@ -14,9 +14,10 @@ backlog.
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot:
`githubsucks/main` @ `6ed4fe9` (folding Stage 2 #149 atop the ledger
refresh #147, web grammars HTML+CSS #146, and the LaTeX Stage 1 #144 /
inline-math framing #145 pair; protocol v19).
`githubsucks/main` @ `b168dca` (folding Stage 2 landed-doc refresh #150
atop folding Stage 2 #149, the ledger refresh #147, web grammars HTML+CSS
#146, and the LaTeX Stage 1 #144 / inline-math framing #145 pair; protocol
v19).
- On the transfer source, `origin/main` named a release mirror at
`d3fa632` and lagged badly. On the current destination, `origin` names
the canonical URL. This difference is why all recovery begins by
@ -50,9 +51,46 @@ git worktree list
git status --short --branch
```
The first command must expose `6ed4fe9` or a newer intentional main.
The `git log` command must expose `b168dca` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration.
## Active lane: GPU initial target
- Portable implementation branch: `githubsucks/gpu-initial-target` @
`6c06815` (second-review fixes integrated with current canonical `main`);
worktree `../pmacs-gpu-initial-target`.
- Approved framing branch: `githubsucks/gpu-initial-target-framing`;
Revision 2 checkpoint `71039d1`.
- Original implementation base: canonical `githubsucks/main` @ `c49a8c7`
(folding Stage 1 #142); current canonical `main` @ `b168dca` is integrated
by merge `6c06815`. Protocol was v19 before this work.
- State: implementation checkpoint `2dd30ec`; first review-fix checkpoint
`bef1c08`; second-review checkpoint `be8c67c`; current-main integration
`6c06815`. Smoke-tested, fully gated, and published at protocol v20. PR #148
remains open for user review:
`https://github.com/levineuwirth/pmacs/pull/148`.
- Scope delivered: one session-scoped `pmacs --gpu [--socket …] FILE` target,
protocol-v20 semantic bootstrap, launcher-owned tilde/cwd resolution, exact
Unix path transport, pre-window target readiness, replica coherence, and the
approved behavioral acceptance matrix.
- Post-integration verification: formatting and strict Clippy; 1,815 default +
1,992 CRDT library tests; target and invocation gates 14 CRDT each; Folding
Stage 2 48 CRDT; M4 121; required GPU 152; Vterm Stage 3 5 default + 7 CRDT;
isolated-config workspace sweep 3,334 across 88 suites. The prior two-window
Wayland/Vulkan isolation smoke remains valid; this round changes only daemon
failure/publication behavior.
- Deferred unchanged: automatic GUI selection, multiple files, general
live-open commands, packaging, and remote GPU paths.
Recovery worktree after the first push:
```sh
git worktree add --track \
-b gpu-initial-target \
../pmacs-gpu-initial-target \
githubsucks/gpu-initial-target
```
## Folding lane (Arc 6) — Stages 1 and 2 MERGED; Stage 3 (GPU) is next
Both shipped stages are on `main`; nothing in this arc is in flight. Stage 3

View File

@ -1,13 +1,13 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-24, after folding Stage 2 (#149, the grid/daemon
collapse) landed with its ledger refresh (#147), following web grammars HTML+CSS
(#146), the LaTeX Stage 1 / inline-math framing pair (#144/#145), folding
Stage 1 (#142, the headless fold engine), one-command GPU invocation (#141), the
documentation refresh (#140), Vterm Stage 3 (#135, protocol v19 and native GPU terminal),
tab-width rendering parity (#137), locals-query processing (#134), modeline
detection (#132), mode system wiring (#129), config registry (#127), Vterm
Stages 12 (#126/#130), and completed Themes Arc 4 (#120/#124/#125).**
**Last updated: 2026-07-24, after GPU initial-target PR #148 second-review
fixes were integrated with folding Stage 2 (#149) and its landed-doc refresh
(#150), following web grammars HTML+CSS (#146), the LaTeX Stage 1 / inline-math
framing pair (#144/#145), folding Stage 1 (#142), one-command GPU invocation
(#141), the documentation refresh (#140), Vterm Stage 3 (#135), tab-width
rendering parity (#137), locals-query processing (#134), modeline detection
(#132), mode system wiring (#129), config registry (#127), Vterm Stages 12
(#126/#130), and completed Themes Arc 4 (#120/#124/#125).**
This file is the
bridge between development machines. If you are an agent reading
this on a fresh clone: this document plus the `docs/*-framing.md`
@ -21,11 +21,29 @@ commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-24)
- `main` @ `6ed4fe9` (folding Stage 2 #149 + ledger refresh #147 atop web
grammars #146, LaTeX Stage 1 #144 / inline-math framing #145, and folding
Stage 1 #142), protocol **v19** (`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`,
v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal
frames/events).
- `main` @ `b168dca` (folding Stage 2 landed-doc refresh #150 atop folding
Stage 2 #149, ledger refresh #147, web grammars #146, LaTeX Stage 1 #144 /
inline-math framing #145, and folding Stage 1 #142), protocol **v19**
(`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 =
`StatuslineSegments`, v19 = terminal frames/events).
- **GPU INITIAL TARGET IMPLEMENTED — PR #148 under user review**
(`docs/gpu-initial-target-framing.md` rev 3; branch `gpu-initial-target`).
`pmacs --gpu [--socket NAME|PATH] FILE` now transports exact Unix path bytes
plus launcher cwd to the managed GPU client. Protocol v20 adds a
semantic-session `SessionBootstrapRequest` after `AttachRequest` and an
appended `InitialTargetResult` readiness barrier; v6v19 wire encodings stay
pinned. The daemon resolves the path lexically, deduplicates or loads/creates
it in the authenticated frontend's view, runs the established load/switch
hooks, upgrades the buffer for CRDT, and publishes every target-side CRDT
upgrade to existing grid replicas before readiness. Semantic replicas receive
a publication only when displaying that buffer, so a second target launch
cannot switch an existing GPU window; one dead peer cannot fail the new
session. Failed bootstrap writes a bounded result, shuts down the socket,
removes provisional state, and restores the ambient active frontend. Any
stale event from an uninstalled session is dropped before state access.
Existing no-target managed launch, direct attach, TUI, and legacy protocol
behavior remain intact. See `docs/active-work.md` for the portable checkpoint
and verification.
- **Folding Stage 1 (headless fold engine) LANDED — #142**
(`docs/folding-framing.md` rev 5; merge `c49a8c7`; three review rounds,
round 3 clean). Arc 6's engine — instance-side and headless; **no frontend
@ -613,13 +631,14 @@ and `range` are three INDEPENDENT capabilities — gate each.
buffer owns a path's recovery slot; only recover/discard release
unclaimed crash data; adopt clears the old owner's skip cache.
**Protocol** — encoding-breaking bumps are deliberate and versioned
(`SUPPORTED=[6..=19]`). v15 = `CompletionPopup` +
`StatusFacts.message`; v16 = `ThemeFacts`; v17 = `FontFacts`; v18 =
`StatuslineSegments`; v19 = the vterm terminal family. New wire surface ⇒
bump + both-frontends support + acceptance. An APPENDED variant must be
guarded by a byte pin on the PREVIOUS final variant — its own round-trip
cannot detect a discriminant shift.
**Protocol** — encoding-breaking bumps are deliberate and versioned. Canonical
`main` remains `[6..=19]`; the active GPU initial-target branch is
`[6..=20]`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 =
`ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm
terminal family; v20 = semantic `SessionBootstrapRequest` plus appended
`InitialTargetResult`. New wire surface ⇒ bump + both-frontends support +
acceptance. An APPENDED variant must be guarded by a byte pin on the PREVIOUS
final variant — its own round-trip cannot detect a discriminant shift.
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),

View File

@ -0,0 +1,670 @@
# GPU initial target — session-scoped file opening framing
**Revision 3 — implemented as built on branch `gpu-initial-target`, protocol
v20, 2026-07-23. Revision 2 was approved against canonical `main` @ `4daa1b8`;
the implementation base includes folding Stage 1 through `c49a8c7`.**
Revision 3 records the completed implementation and verification. Revision 2
pinned launcher-owned tilde expansion, required `after-switch` even when dedup
selects the view's existing buffer, failed bootstrap when a hook kills the
target, and kept slow pre-window bootstrap terminal-only (no graphical
progress surface). It also sharpened the observed argv panic and negotiated
protocol-version echo.
One-command GPU startup landed in #141:
```sh
pmacs --gpu
pmacs --gpu --socket research
```
The remaining daily-use gap is that the same path cannot open a file. This
stage makes one positional target honest:
```sh
pmacs --gpu README.md
pmacs --gpu --socket research ../notes/today.md
pmacs --gpu -- --name-beginning-with-dash
```
“Honest” is load-bearing. The target belongs to the newly authenticated GPU
session, not to the daemon's ambient/local view or whichever frontend most
recently sent input. Relative paths resolve against the launcher's working
directory even when an existing daemon has a different cwd. A new or reused
file becomes the first buffer content the GPU can draw; scratch never flashes.
Open failures return before a window is shown and make the root command fail.
This is one-file startup only. It does not make GUI the automatic default, add
a general client/server `open` command, widen direct `pmacs-gpu --attach`, or
solve installation, service management, remote GPU attach, or reconnect.
## Ground truth
### Root and GPU CLI
- `src/main.rs` already parses one positional `FILE` for local TUI mode and
rejects multiple files. It accepts `--` before an option-like filename.
- `Mode::Gpu` currently carries only `socket`; `parse_args` rejects every file
paired with `--gpu` and points users to `C-x C-f`.
- The root broker invokes the sibling/PATH GPU binary as
`pmacs-gpu --managed-attach SOCKET DAEMON_EXE`, waits for its status, and
reflects success/failure.
- Both root and GPU parsers consume `std::env::args()` into `String`; Rust
panics when an argument is not valid Unicode. They cannot admit a
non-UTF-8 Unix filename or issue a useful parser error for one.
- The direct GPU CLI is intentionally strict. Public direct attach remains
`pmacs-gpu --attach RAW_SOCKET`; managed and headless forms are private
root/acceptance seams.
### Handshake and session bootstrap
- The instance sends `Hello`; the frontend sends `AttachRequest`; then the
per-attach thread immediately sends `DispatcherEvent::SessionEstablished`
and becomes the reader for `FrontendEvent`s.
- `AttachRequest` contains protocol version, capabilities, and initial cell
size. It has no target. `FrontendEvent` has no open-path request.
- The GPU copies `Hello.protocol_version` into `AttachRequest.protocol_version`
rather than sending its own maximum. Every old-daemon gate therefore keys on
the negotiated/echoed server version; changing that echo is not cleanup.
- `handle_session_established` creates the authenticated frontend's own
`FrontendView`, initially sharing the daemon-local active buffer, then sends
CRDT snapshots and installs grid or semantic render state.
- A semantic frontend follows whichever `BufferSnapshot` it most recently
applied. The attach-time snapshot sweep currently sends every CRDT-backed
buffer; a later tick repairs the “last snapshot was not my active buffer”
ambiguity by re-sending the session's active buffer.
- Managed GPU connect completes before winit creates the window, but normal
instance messages are read asynchronously after that connect call. Merely
adding an event after attach would race the first snapshot and first draw.
### File and view semantics already present
- Local `pmacs FILE` loads an existing file, or creates an empty path-backed
buffer with `[new file]` status on `NotFound`; other I/O errors fail startup
(`EditorState::open`).
- `EditorCore::get_or_load_buffer` and Lua `pmacs.buffer.find_or_open` already
establish the important dedup rule: an existing path-backed buffer wins, so
unsaved edits are not replaced by a disk reload.
- Buffer identity is an absolute, lexically normalized path. It deliberately
does not canonicalize symlinks, so a not-yet-created file has a stable
identity and the editor does not silently rewrite the user's spelling to a
filesystem target.
- `FrontendView`s are per-session. Switching one frontend's active window does
not switch any sibling frontend.
- Load/switch hooks require `EditorCore::active_frontend` to name the source
before Lua runs; otherwise `pmacs.window.*` resolves against the wrong view.
### Replica composition constraint
A target loaded during attach may be a brand-new, non-CRDT buffer. The new GPU
needs its snapshot, but every already-attached replica must also learn that
buffer before later CRDT operations can reference it. Calling the current
one-stream `send_buffer_snapshots` helper can upgrade the new buffer for the
new GPU while leaving existing replicas unaware: the later lazy-upgrade sweep
then sees an already-backed buffer and has nothing to broadcast. The initial-
target transaction must therefore use the same all-replica publication
invariant as any other mid-session buffer creation.
## Invariants
1. **Authenticated source owns the target.** No client-supplied frontend id,
daemon-local active view, or ambient “last frontend” selects the window.
2. **Launcher environment owns path resolution.** Root applies the existing
leading-tilde rule with the launcher's `$HOME`; any still-relative result
resolves against the launcher cwd. Daemon cwd/HOME are irrelevant.
3. **Path bytes survive defined resolution.** Apart from that deliberate
root-side tilde substitution, Unix argv → broker → GPU → wire → daemon file
I/O preserves exact `OsStr` bytes. Display text may be lossy; backing
identity and filesystem access may not be.
4. **One target, one buffer identity.** Reuse an already-open normalized path;
never discard unsaved edits by reloading it.
5. **Target before first draw.** The first buffer content eligible to render is
the requested target. The explicit terminal launcher may report the wait on
stderr; no editor window exists yet, and scratch content is never drawable.
6. **Ready means usable.** Success is reported only after the target buffer is
loaded/created, selected in the authenticated view, CRDT-backed, and its
matching snapshot has been written to the GPU.
7. **Failure is pre-window and fail-closed.** Non-`NotFound` I/O, malformed
bootstrap data, CRDT upgrade/export, or target snapshot failure produces no
successful session and no window.
8. **Existing sessions remain coherent.** A fresh target is published to all
negotiated replica sessions; another frontend's active window never moves.
9. **No legacy wire drift.** Protocols v6v19 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<PathBuf>`; no-file behavior is byte-for-byte
the #141 path.
- README/help examples add `pmacs --gpu FILE`; bare `pmacs FILE` remains TUI.
### Q#GT2 — Root parsing moves to `OsString` without weakening option grammar
The root entry point uses `std::env::args_os`. Parsing distinguishes:
- ASCII option names (`-nw`, `--gpu`, `--socket`, `--`, …), which must match
exactly;
- socket names/paths, whose existing resolver contract remains UTF-8 and
receives a targeted error when the operand is not UTF-8;
- local/managed `FILE`, stored as `PathBuf` with exact platform bytes;
- positional attach targets, which remain UTF-8 because their syntax is a
transport URI/hostname rather than a local filesystem path.
Unit helpers may continue to construct UTF-8 `OsString`s for ordinary cases,
but one Unix-only test must pass an invalid-UTF-8 positional through the real
parser. No lossy conversion is permitted on the file path.
### Q#GT3 — The private broker handoff carries target plus launcher cwd
When `FILE` is present, root invokes the GPU child as:
```text
pmacs-gpu --managed-attach SOCKET DAEMON_EXE \
--initial-target LAUNCHER_CWD FILE
```
Before transport, root applies the existing identity seam's tilde rule using
the **launcher** environment: a valid-UTF-8 leading whole `~` or `~/…` expands
through `std::env::var_os("HOME")`; `~user`, a non-UTF-8 spelling, or an
unset `$HOME` remains unchanged. Expansion happens exactly once, before any
cwd join. This makes quoted `~/x` dedup with an already-open `$HOME/x` buffer
and prevents a long-lived daemon's different `$HOME` from changing identity.
`LAUNCHER_CWD` is captured by root before spawn and must be absolute. The
post-expansion file and cwd are passed as `OsString`/`Path` operands, not
encoded into UTF-8, environment variables, JSON, or a delimiter-separated
string. The marker makes an option-like `FILE` unambiguous. The no-target
private argv remains unchanged. If `current_dir()` fails, root reports that
error and does not spawn the GPU; falling back to daemon cwd would violate the
target's authority.
The GPU parser also moves to `args_os` for path operands. Public help continues
to advertise only the root command and advanced direct attach; the private
marker is not promoted as a supported standalone workflow. Headless managed
acceptance gets the same optional marker so it exercises production target
transport rather than a test-only side channel.
### Q#GT4 — Protocol v20 adds a semantic-session bootstrap envelope
Protocol increments **v19 → v20** and appends v20 to
`SUPPORTED_PROTOCOL_VERSIONS`; v6 remains the compatibility floor.
`AttachRequest` stays byte-identical. After a v20 semantic frontend sends its
normal `AttachRequest`, it sends one additional framed handshake value:
```rust
pub struct SessionBootstrapRequest {
pub initial_target: Option<InitialTarget>,
}
pub struct InitialTarget {
pub cwd: Vec<u8>,
pub path: Vec<u8>,
}
```
- The extra handshake message is required for `protocol_version >= 20` **and**
negotiated `semantic_render`; `None` preserves ordinary `pmacs --gpu`.
- v6v19 peers neither send nor read it. Non-semantic v20 grid/TUI sessions
keep the existing two-message handshake and do not wait on an irrelevant
semantic startup envelope.
- The target carries no `FrontendId`; the accepted stream and assigned session
are the authority.
- `cwd` and `path` are Unix path bytes, not text. This stage is the local Unix-
socket GPU path; it does not claim a cross-platform/remote path protocol.
- Postcard decodes each field under the transport's 16 MiB frame cap; daemon
validation then bounds each to 32 KiB before filesystem use. `path` must be
nonempty, `cwd` must be nonempty and absolute, and embedded NUL is rejected
with a bootstrap failure.
A second message rather than an appended `AttachRequest` field keeps every
legacy postcard shape mechanically unchanged. A post-handshake
`FrontendEvent::OpenPath` is deliberately not used: it cannot precede session
bootstrap and therefore cannot guarantee first-draw ordering.
### Q#GT5 — The daemon resolves and opens inside one dispatcher transaction
The per-attach thread validates the v20 bootstrap envelope structurally, moves
it into `DispatcherEvent::SessionEstablished`, and starts its reader exactly
where it does today. It performs no filesystem or editor work.
The dispatcher, which exclusively owns `EditorState`, performs this target
transaction without yielding to another event:
1. Register the new frontend's view and set `active_frontend` to the
authenticated `FrontendId`.
2. Accept root's already tilde-expanded `path`; if it is still relative, join
it to the supplied launcher `cwd`, then lexically normalize without
canonicalizing symlinks. The daemon never consults or re-applies `$HOME`.
3. Reuse an existing buffer with that normalized backing path; otherwise load
the file; on `NotFound`, create an empty path-backed buffer and set
`[new file]`; on any other error, take the failure path below.
4. Select that buffer in only the new frontend's active window.
5. Fire `buffer.after-switch` exactly once on every dedup, including when the
fresh view already shares that `BufferId` and selection is a same-buffer
no-op. Fire `buffer.after-load` on a fresh disk load. Both run with the
authenticated frontend active; a newly created missing file matches local
startup and fires neither load nor switch hook.
6. After hooks, verify the target `BufferId` is still live. A listener that
killed it causes the fail-closed bootstrap path in Q#GT9. Otherwise reassert
the requested target so configuration can inspect the right session but
cannot make `pmacs --gpu FILE` acknowledge a different buffer.
7. Establish CRDT/snapshot coherence, then acknowledge readiness.
The target-opening helper must be Rust/editor-core state, not synthetic keys
or a call through the user-facing Lua binding. Lua hooks remain policy
observers; they are not the transport implementation.
### Q#GT6 — Dedup preserves edits; new-file behavior matches local startup
- An already-open normalized path reuses its `BufferId`, current contents,
modified flag, and metadata. Disk is not read again.
- A disk file not already open is loaded once and receives its normalized
backing path and `FileMeta`.
- Any `NotFound` from the initial load creates an empty path-backed buffer,
including when a parent is currently absent; save-time errors remain
save-time errors, matching local `pmacs FILE`.
- `PermissionDenied`, `IsADirectory`, invalid path bytes at the OS boundary,
and other non-`NotFound` errors fail startup.
- The buffer display name may use `Path::display()` and therefore replacement
characters; this must never replace the raw backing path used for dedup,
load, or save.
### Q#GT7 — Initial-target semantic bootstrap is active-buffer-only
A v20 semantic session with `initial_target = Some` does **not** receive the
legacy “every buffer, then repair active on the next tick” sweep. It receives
exactly the requested active buffer's `BufferSnapshot` before readiness.
Semantic GPU state holds one active rope and already receives a fresh snapshot
when its daemon-side view switches; shipping unrelated buffers only creates a
last-snapshot ambiguity and avoidable work.
If the target was newly created or newly loaded and not yet CRDT-backed:
- upgrade it once using the daemon-owned CRDT peer;
- export one authoritative snapshot;
- publish that buffer/snapshot to every already-attached session that
negotiated `crdt_replica` before later operations can name it;
- write the same logical snapshot to the new GPU without reloading the file or
creating a second buffer.
The implementation should avoid cloning snapshot bytes per peer beyond what
framed serialization/write ownership requires. Existing no-target attach and
non-semantic replica bootstrap remain unchanged.
### Q#GT8 — A v20 result is the pre-window readiness barrier
Append a v20-only `InstanceMessage::InitialTargetResult` variant with an
explicit result shape:
```rust
pub enum InitialTargetResult {
Opened { buffer_id: BufferId },
Failed { message: String },
}
```
Only a v20 semantic session that requested `Some(initial_target)` may receive
it.
Success ordering on the daemon write stream is:
1. target `BufferSnapshot` written successfully;
2. new session state/render state/stream installed;
3. `InitialTargetResult::Opened` written;
4. normal per-tick messages/events begin.
The GPU connector synchronously reads this bootstrap prefix before returning.
It retains the matching target snapshot as structured bootstrap state,
validates that `Opened.buffer_id` matches it, and only then starts the ordinary
reader thread. The winit path applies that snapshot before its first redraw.
It does not forward unrelated pre-ready buffer state through the event proxy.
Thus no target success can mean “window exists, load may still fail,” and no
scratch snapshot can become drawable.
`Failed` becomes an `AttachClientError` containing the local display path and
daemon detail. Managed startup returns nonzero; root reflects that status.
The daemon itself remains alive, whether reused or newly spawned.
Because the connector waits before winit creates a window, slow dispatcher
work has no graphical “Connecting…” surface. The managed GPU child waits
silently and reports a failure on stderr if bootstrap fails. There is no
second target timeout beyond #141's bounded daemon-start retry; file I/O and
user hooks may legitimately exceed five seconds, and timing out the client
would not cancel dispatcher work. Ctrl-C remains the escape hatch and still
cannot reach the isolated daemon process group.
### Q#GT9 — Failure cleanup never creates a ghost session
On any target failure before readiness, the dispatcher:
- writes `InitialTargetResult::Failed` when the stream is usable;
- removes the provisional frontend view, render state, session-registry entry,
size/baseline entries, and stream entry if any were installed;
- shuts down the connection so the per-attach reader wakes and emits at most
idempotent detach cleanup;
- treats a target `BufferId` killed by `buffer.after-load` or
`buffer.after-switch` as a bootstrap failure, never reasserts a stale id,
and performs the same provisional-session cleanup;
- leaves every pre-existing buffer/view/session unchanged, except that a file
successfully loaded before a later CRDT/export failure may remain as an
ordinary daemon buffer. It must not become another frontend's active view.
The result error string is bounded (4 KiB) and user-facing. It includes the
operation and OS error but never lossy-converts and then reuses the displayed
path for filesystem access.
### Q#GT10 — Compatibility is directional and tested
- New daemon + v6v19 client: exact legacy handshake; no bootstrap read, no
v20 result, no new enum discriminant.
- New GPU without target + supported v6v19 daemon: exact #141 behavior.
- New GPU **with** target + v6v19 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 v6v19 messages remain unchanged; new pins lock the v20
discriminant and bootstrap round trip.
No new capability bit is needed. Protocol version and negotiated
`semantic_render` jointly identify the state machine; a bit would add a second
source of truth for a mandatory v20 semantic handshake step.
## Startup state machine
```text
root parses FILE bytes, expands eligible `~` with launcher HOME, captures cwd
|
+-- spawn/await pmacs-gpu managed child
|
+-- connect existing socket or start/retry daemon (#141 unchanged)
|
+-- Hello < 20 and target? ----> fail; never replace live daemon
|
+-- Hello >= 20
send AttachRequest
send SessionBootstrapRequest { initial_target }
|
+-- daemon dispatcher resolves/opens in source view
| |
| +-- error --> Failed + connection shutdown
| |
| +-- success
| publish fresh buffer to replicas
| write target BufferSnapshot
| install session
| write Opened { buffer_id }
|
connector validates snapshot/result pair
create GPU state/window
apply target snapshot before first redraw
```
## Rejected alternatives
### Send `FrontendEvent::OpenPath` after attach
Rejected for initial startup. The daemon has already established the scratch
view and may have sent snapshots before the event can arrive. Correlation ids,
async results, and live session commands will be appropriate for a future
general client/server open API, but they do not provide a pre-window barrier
without duplicating the bootstrap state machine.
### Put the file only on the spawned daemon command line
Rejected. It cannot affect an already-running daemon and would target the
daemon-local view rather than the authenticated GPU view.
### Open globally, then declare a viewport for that buffer
Rejected. `Viewport` is a rendering declaration whose buffer must already be
known to the replica. Treating it as an open command confuses state alignment
with filesystem authority and can switch the wrong view under races.
### Drive `C-x C-f` / minibuffer with synthetic keys
Rejected. It depends on user keymaps and prompts, loses raw path bytes, cannot
report a structured startup result, and visibly renders intermediate state.
### Resolve relative paths in the daemon cwd
Rejected. A long-lived daemon's cwd describes when it was started, not where a
later launcher invoked `pmacs --gpu FILE`.
### UTF-8 path strings
Rejected for the local Unix contract. They silently exclude valid filenames
and would make GPU startup weaker than `PathBuf`-based local editing.
### General multi-file/open-command protocol now
Rejected. Multiple targets need ordering, active-target choice, per-target
results, and behavior for an already-running client. This stage deliberately
pins the one initial target needed by `pmacs --gpu FILE`.
## Scope and touch map
As-built implementation surface:
- `src/main.rs`
- `args_os` parser, `Mode::Gpu { file, socket }`, help/grammar, exact private
child argv, cwd capture, exit propagation.
- `pmacs-gpu/src/main.rs`
- strict `OsString` path parser, optional private initial-target operands,
managed/headless plumbing, pre-first-redraw bootstrap application.
- `pmacs-gpu/src/attach.rs`
- v20 semantic bootstrap send, target-version gate, synchronous
snapshot/result barrier, structured errors; #141 connector/start/reaper
policy otherwise unchanged.
- `pmacs-protocol/src/message.rs`, `pmacs-protocol/src/lib.rs`
- protocol v20, `SessionBootstrapRequest`, `InitialTarget`, result wire type,
appended `InstanceMessage` variant, bounds and documentation.
- `src/daemon.rs`
- conditional v20 bootstrap read, dispatcher payload, source-scoped target
transaction, active-only semantic bootstrap, all-replica publication,
result ordering, failure cleanup, `< 20` send filter.
- `src/editor_core.rs` and/or a small existing file-open helper
- cwd-aware lexical normalization, dedup/load/create without ambient-view
selection. Do not add a parallel path-identity convention.
- `src/protocol.rs`, `pmacs-protocol/src/transport.rs`
- version ladder, legacy placement pins, v20 round-trip/limit tests.
- `tests/gpu_initial_target_acceptance.rs` (new) and focused existing GPU
invocation/protocol tests
- real subprocess/session behavior below.
- `README.md`, this framing's as-built tail, `docs/agent-handoff.md`, and
`docs/active-work.md` per their update protocols after implementation works.
Explicit non-touch unless evidence forces it:
- no renderer/layout/font/shader changes;
- no `builtin/runtime` keymap or command changes;
- no socket resolver, daemon lock, process-group, retry, or reaper policy
changes;
- no package/install/service files;
- no remote attach transport changes.
## Acceptance
Behavioral acceptance uses real built subprocesses where CLI, cwd, socket,
handshake, process lifetime, or raw argv is the contract. Pure parser/path/wire
helpers receive unit tests; source-text assertions do not substitute for
process behavior.
1. **Root grammar:** `pmacs --gpu FILE`, `pmacs FILE --gpu`,
`pmacs --gpu --socket research FILE`, and `pmacs --gpu -- -leading` select
one managed GPU target. Multiple files and every existing conflicting mode
exit 2 with the conflicting argument/mode named. No-file `pmacs --gpu`
remains accepted.
2. **Raw argv forwarding:** a Unix invalid-UTF-8 filename survives the real
root parser and reaches a fake GPU child byte-for-byte alongside the exact
launcher cwd. Invalid-UTF-8 option/socket/attach-target text fails with a
pointed usage error rather than panic or replacement characters.
3. **Private GPU grammar:** managed and headless modes accept either their
unchanged no-target arity or exactly `--initial-target CWD FILE`; missing,
trailing, or duplicated forms exit 2. An option-like `FILE` after the marker
remains literal. A relative `CWD` is syntactically accepted and then fails
the daemon's bootstrap validation before readiness.
4. **v20 wire and legacy pins:** bootstrap request/result round trips preserve
arbitrary Unix bytes and enforce bounds. Every pinned v6v19 encoding stays
unchanged; the new result discriminant is appended. The supported ladder is
exactly `[6, …, 20]`.
5. **No-target compatibility:** rebuilt `pmacs --gpu` attaches to a v19
fixture/daemon without sending the v20 bootstrap frame and retains #141's
existing/missing-daemon behavior. A v20 semantic attach sends `None` and
reaches its normal first snapshot.
6. **Old live daemon refusal:** target mode against a real/fake supported v19
daemon fails with the protocol-v20 requirement, invokes no daemon spawner,
leaves the socket/process untouched, and creates no GPU window.
7. **Existing-daemon path authority:** start a real CRDT daemon from cwd/HOME
A; launch the real headless managed GPU target from cwd/HOME B with
`sub/file.txt`; require contents from B, not A. Repeat with a shell-quoted
`~/file.txt` after that `$HOME_B/file.txt` buffer is already open, and
require the same `BufferId`, proving root-side expansion and dedup.
8. **Missing-daemon target:** from no socket/lock, the production managed path
starts one daemon, opens the target, reaches ready, and leaves that daemon
connectable after the probe/window exits. Repeating reuses it and creates no
replacement.
9. **New file:** a nonexistent target produces an empty snapshot, `[new file]`
status/path identity, accepts an edit/save through the real session, and
creates the requested file under the launcher cwd—not the daemon cwd.
10. **Open error:** a directory/permission-denied target returns a specific
failure before ready/window creation and makes root fail. The daemon shuts
down that failed session's socket; a client that lingers or sends another
event cannot reach uninstalled session state. An existing daemon remains
connectable; a pre-existing frontend's active buffer and contents remain
unchanged.
11. **Dedup preserves unsaved edits:** frontend A opens and modifies a file
without saving; target-launch frontend B opens the same normalized path and
receives A's authoritative unsaved text with the same `BufferId`, not disk
contents or a duplicate buffer.
12. **Per-session isolation:** frontend A starts on buffer A while concurrent
target launches B and C open different files. Each result/snapshot pair
names its own view; a subsequent input/resize proof shows A, B, and C remain
independently usable on their original buffers.
13. **Replica publication after target upgrade:** keep grid replica A attached,
then target-launch B onto a previously unknown file. Repeat for a file that
Lua loaded into a hidden, not-yet-CRDT-backed buffer before the target dedup.
A receives each buffer snapshot before any CRDT op for it; both replicas
accept later operations without unknown-buffer fallback or disconnect.
14. **Hook context and count:** fresh disk load fires `buffer.after-load` once;
dedup fires `buffer.after-switch` once even when the fresh view already
shares that exact buffer and the select itself is a no-op; missing-file
creation fires neither hook. Each hook observes the authenticated frontend
and requested buffer, and the target remains selected afterward. A fixture
that kills the target inside either hook yields `Failed`, no ready phase,
and no stale-id reassertion.
15. **Pre-window barrier:** the headless seam records that the only initial
semantic `BufferSnapshot` is the target and precedes matching `Opened`.
Injected load/export failure records no ready phase. A window-path unit seam
proves bootstrap state is applied before the first redraw request. A held
dispatcher fixture proves the bounded stderr notice appears while no
window/ready result exists, then succeeds after release without a separate
target timeout.
16. **Concurrent same-target launch:** two target launchers racing an absent
daemon and the same file converge on one daemon, one buffer identity, two
ready sessions, and the existing #141 losing-daemon child is reaped.
17. **Existing direct/probe paths:** `pmacs-gpu --attach RAW_PATH`, the no-target
managed probe, Vterm Stage 3's headless probe, TUI attach, protocol/capability
mismatch preservation, Ctrl-C daemon isolation, and post-attach reaping
remain green.
18. **Visible smoke:** on the real Wayland/Vulkan workstation, run
`target/release/pmacs --gpu README.md`; the first editor content shown is
README (never scratch), editing works, closing the window leaves the daemon
alive, and a second invocation from another cwd opens that cwd's target in
a new session without moving an already-attached frontend.
19. **Executable documentation:** the coherent workspace build from README
still produces sibling binaries; all documented target commands parse;
no command requires spelling the resolved socket pathname.
## Required implementation gates
After the visible/behavioral path works, run the repository gates independently
as required by `AGENTS.md`:
```sh
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --lib
cargo test --lib --features crdt
cargo test --test gpu_initial_target_acceptance
cargo test --features crdt --test gpu_initial_target_acceptance
cargo test --test m4_acceptance -- --skip basedpyright
PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu
git diff --check
```
Also rerun the touched GPU invocation suite, protocol/transport tests, and
Vterm Stage 3 acceptance in default and CRDT configurations where the suite
supports both. The final full workspace sweep remains required before PR.
The named gate intentionally reuses the managed-lifecycle acceptance module,
so a workspace sweep executes those 14 CRDT cases under both test-binary
names. The duplicate runtime is retained to keep the approved named command
and the complete #141 lifecycle fixture coverage together.
Post-second-review verification on 2026-07-24:
- `cargo fmt --check` and strict workspace Clippy passed.
- Library gates passed 1,815 default and 1,992 CRDT tests.
- The named initial-target gate passed 1 default and 14 CRDT tests; the
underlying GPU invocation suite passed 14 CRDT tests.
- Folding Stage 2 passed 48 CRDT tests after integrating current `main`.
- M4 passed 121 tests with the documented basedpyright skip; required real-GPU
tests passed 152.
- Vterm Stage 3 passed 5 default and 7 CRDT tests.
- The isolated-config workspace CRDT sweep passed 3,334 tests across 88 suites,
with 29 ignored and the documented basedpyright case filtered.
- A coherent release build launched two concurrent real Wayland/Vulkan GPU
windows on one daemon, targeting distinct `alpha` and `beta` files. Both
remained visible on their own buffer after the second target attached.
## Deferred (named)
- **Multiple initial files.** Needs result ordering, active choice, and partial
failure policy; do not turn `Option<InitialTarget>` 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.

View File

@ -19,6 +19,7 @@
use std::collections::VecDeque;
use std::fs;
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::FileTypeExt;
use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt;
@ -30,14 +31,25 @@ use std::time::{Duration, Instant};
use pmacs_protocol::{
AttachRequest, BufferId, ByteRange, CellCoord, CellSize, CrdtOp, FrontendCapabilities,
FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, MouseKind,
PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS, TransportError,
is_supported_protocol_version, read_message, write_message,
FrontendEvent, FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceMessage, Key,
KeyEvent, Modifiers, MouseKind, PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS,
SessionBootstrapRequest, TransportError, is_supported_protocol_version, read_message,
write_message,
};
use winit::event_loop::EventLoopProxy;
use crate::AppEvent;
/// Private root-broker target operands, kept as exact Unix paths until the
/// protocol-v20 bootstrap frame is serialized.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InitialTargetPaths {
/// Absolute launcher working directory.
pub cwd: PathBuf,
/// Launcher-expanded target path.
pub path: PathBuf,
}
/// Errors the attach client surfaces. Kept narrow on purpose: the
/// hello-world fallback is the right recovery for any of these in
/// session 3, so the caller's only job is to log + drop back to the
@ -57,6 +69,12 @@ pub enum AttachClientError {
/// `BufferSnapshot` ever arrives and the window sits on
/// `(connecting...)` forever. We reject up front instead.
CapabilityMismatch { missing: Vec<&'static str> },
/// The requested target requires the protocol-v20 bootstrap envelope.
InitialTargetUnsupported { server: u32 },
/// The daemon rejected the target before a window was created.
InitialTargetFailed { path: PathBuf, message: String },
/// The daemon violated the target bootstrap ordering contract.
InitialTargetProtocol(String),
}
impl AttachClientError {
@ -89,6 +107,20 @@ impl std::fmt::Display for AttachClientError {
built with the `crdt` feature (it advertises `crdt_replica` / `semantic_render` \
only on CRDT builds; without them no BufferSnapshot is ever sent)"
),
Self::InitialTargetUnsupported { server } => write!(
f,
"initial target requires daemon protocol v20, but the live daemon speaks v{server}"
),
Self::InitialTargetFailed { path, message } => {
write!(
f,
"could not open initial target {}: {message}",
path.display()
)
}
Self::InitialTargetProtocol(message) => {
write!(f, "invalid initial-target bootstrap: {message}")
}
}
}
}
@ -395,6 +427,64 @@ impl Outbox {
/// classified under rule (iii) as deferred — a structural answer
/// belongs with Q#2's minimap variant or its own protocol thread,
/// not session 3's attach loop.
fn read_initial_target_bootstrap(
stream: &mut UnixStream,
display_path: PathBuf,
) -> Result<InstanceMessage, AttachClientError> {
let mut snapshot = None;
loop {
let message: InstanceMessage =
read_message(stream).map_err(AttachClientError::Handshake)?;
match message {
candidate @ InstanceMessage::BufferSnapshot { .. } if snapshot.is_none() => {
snapshot = Some(candidate);
}
InstanceMessage::BufferSnapshot { .. } => {
return Err(AttachClientError::InitialTargetProtocol(
"received more than one target snapshot".to_owned(),
));
}
InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { buffer_id }) => {
let Some(snapshot) = snapshot else {
return Err(AttachClientError::InitialTargetProtocol(
"Opened arrived before BufferSnapshot".to_owned(),
));
};
let InstanceMessage::BufferSnapshot {
buffer_id: snapshot_buffer,
..
} = snapshot
else {
unreachable!("bootstrap snapshot variant checked above");
};
if snapshot_buffer != buffer_id {
return Err(AttachClientError::InitialTargetProtocol(format!(
"Opened named {buffer_id:?}, snapshot named {snapshot_buffer:?}"
)));
}
return Ok(snapshot);
}
InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message }) => {
return Err(AttachClientError::InitialTargetFailed {
path: display_path,
message,
});
}
InstanceMessage::Goodbye(reason) => {
return Err(AttachClientError::InitialTargetProtocol(format!(
"daemon closed bootstrap: {reason:?}"
)));
}
other => {
return Err(AttachClientError::InitialTargetProtocol(format!(
"unexpected {} before target readiness",
crate::instance_message_label(&other)
)));
}
}
}
}
pub fn connect(
socket_path: &Path,
proxy: EventLoopProxy<AppEvent>,
@ -418,11 +508,16 @@ pub fn connect_with_sink(
sink: impl Fn(AttachEvent) -> bool + Send + 'static,
) -> Result<AttachClient, AttachClientError> {
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<InitialTargetPaths>,
sink: impl Fn(AttachEvent) -> bool + Send + 'static,
) -> Result<AttachClient, AttachClientError> {
// Hello round-trip.
@ -453,6 +548,12 @@ fn connect_stream_with_sink(
return Err(AttachClientError::CapabilityMismatch { missing });
}
if initial_target.is_some() && hello.protocol_version < 20 {
return Err(AttachClientError::InitialTargetUnsupported {
server: hello.protocol_version,
});
}
// AttachRequest — declare the capabilities a semantic frontend
// needs. `multi_frontend` is included because the existing daemon
// gates `crdt_replica` behind it (M10.x dependency).
@ -477,6 +578,21 @@ fn connect_stream_with_sink(
};
write_message(&mut handshake_stream, &req).map_err(AttachClientError::Handshake)?;
let target_display_path = initial_target.as_ref().map(|target| target.path.clone());
if hello.protocol_version >= 20 {
let bootstrap = SessionBootstrapRequest {
initial_target: initial_target.map(|target| InitialTarget {
cwd: target.cwd.as_os_str().as_bytes().to_vec(),
path: target.path.as_os_str().as_bytes().to_vec(),
}),
};
write_message(&mut handshake_stream, &bootstrap).map_err(AttachClientError::Handshake)?;
}
let initial_message = match target_display_path {
Some(path) => Some(read_initial_target_bootstrap(&mut handshake_stream, path)?),
None => None,
};
// Split read/write halves for the reader thread + writer thread.
// UnixStream clones share the underlying FD with independent
// buffer state — safe to read on one clone while the other writes
@ -563,27 +679,33 @@ fn connect_stream_with_sink(
shutdown_handle,
frontend_id: hello.assigned_frontend_id,
server_protocol_version: hello.protocol_version,
initial_message,
})
}
const MANAGED_STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
const MANAGED_RETRY_INTERVAL: Duration = Duration::from_millis(50);
/// Connect to an existing semantic daemon or start the supplied daemon first.
pub fn connect_managed(
/// Managed attach carrying an optional pre-window initial target.
pub fn connect_managed_with_target(
socket_path: &Path,
daemon_executable: &Path,
initial_target: Option<InitialTargetPaths>,
proxy: EventLoopProxy<AppEvent>,
) -> Result<ManagedAttach, ManagedAttachError> {
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<InitialTargetPaths>,
sink: impl Fn(AttachEvent) -> bool + Send + 'static,
) -> Result<ManagedAttach, ManagedAttachError> {
connect_managed_inner(
@ -593,6 +715,7 @@ pub fn connect_managed_with_sink(
spawn_daemon,
MANAGED_STARTUP_TIMEOUT,
MANAGED_RETRY_INTERVAL,
initial_target,
sink,
)
}
@ -660,6 +783,7 @@ fn connect_managed_inner<C, S, F>(
spawner: S,
timeout: Duration,
retry_interval: Duration,
initial_target: Option<InitialTargetPaths>,
sink: F,
) -> Result<ManagedAttach, ManagedAttachError>
where
@ -669,7 +793,7 @@ where
{
match connector(socket_path) {
Ok(stream) => {
let client = connect_stream_with_sink(stream, sink)?;
let client = connect_stream_with_sink(stream, initial_target, sink)?;
return Ok(ManagedAttach {
client,
daemon: ManagedDaemonFacts::existing(),
@ -695,7 +819,7 @@ where
loop {
match connector(socket_path) {
Ok(stream) => {
let client = connect_stream_with_sink(stream, sink)?;
let client = connect_stream_with_sink(stream, initial_target, sink)?;
return Ok(ManagedAttach { client, daemon });
}
Err(error) => {
@ -739,6 +863,8 @@ pub struct AttachClient {
/// than the daemon (e.g. `Pointer`, v5) must be gated on this —
/// an older daemon hard-errors decoding an unknown variant.
server_protocol_version: u32,
/// Target snapshot retained across the pre-window readiness barrier.
initial_message: Option<InstanceMessage>,
}
impl AttachClient {
@ -747,6 +873,11 @@ impl AttachClient {
self.frontend_id
}
/// Take the target snapshot that must be applied before first redraw.
pub fn take_initial_message(&mut self) -> Option<InstanceMessage> {
self.initial_message.take()
}
/// Send a `FrontendEvent::Viewport` to the daemon. The daemon's
/// `SemanticRenderState::set_viewport` feeds the spans producer;
/// without this call the daemon ships no `StyleSpans` for the
@ -912,6 +1043,9 @@ impl AttachClient {
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use pmacs_protocol::{InstanceCapabilities, InstanceIdentity, MouseButton};
fn caps(
@ -926,6 +1060,91 @@ mod tests {
}
}
fn hello(protocol_version: u32) -> Hello {
Hello {
protocol_version,
assigned_frontend_id: FrontendId(7),
instance_identity: InstanceIdentity {
pmacs_version: "test".to_owned(),
build_hash: None,
instance_name: None,
uptime_secs: 0,
working_directory: "/tmp".to_owned(),
},
instance_capabilities: caps(true, true, true),
}
}
#[test]
fn initial_target_bootstrap_is_synchronous_byte_exact_and_snapshot_first() {
let (client_stream, mut server_stream) = UnixStream::pair().expect("socketpair");
let raw_path = OsString::from_vec(vec![b'n', b'o', b't', b'e', 0xff]);
let paths = InitialTargetPaths {
cwd: PathBuf::from("/launcher"),
path: PathBuf::from(&raw_path),
};
let expected = paths.clone();
let server = thread::spawn(move || {
write_message(&mut server_stream, &hello(PROTOCOL_VERSION)).expect("write Hello");
let _: AttachRequest = read_message(&mut server_stream).expect("read AttachRequest");
let bootstrap: SessionBootstrapRequest =
read_message(&mut server_stream).expect("read bootstrap");
let target = bootstrap.initial_target.expect("initial target");
assert_eq!(target.cwd, expected.cwd.as_os_str().as_bytes());
assert_eq!(target.path, expected.path.as_os_str().as_bytes());
let buffer_id = BufferId::from_raw(41);
write_message(
&mut server_stream,
&InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot: vec![1, 2, 3],
},
)
.expect("write target snapshot");
write_message(
&mut server_stream,
&InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { buffer_id }),
)
.expect("write target result");
});
let mut client = connect_stream_with_sink(client_stream, Some(paths), |_| true)
.expect("target bootstrap");
assert!(matches!(
client.take_initial_message(),
Some(InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot,
}) if buffer_id == BufferId::from_raw(41) && crdt_snapshot == [1, 2, 3]
));
assert!(client.take_initial_message().is_none());
server.join().expect("bootstrap server");
}
#[test]
fn initial_target_fails_before_attach_on_legacy_protocol() {
let (client_stream, mut server_stream) = UnixStream::pair().expect("socketpair");
let server = thread::spawn(move || {
write_message(&mut server_stream, &hello(19)).expect("write legacy Hello");
});
let Err(error) = connect_stream_with_sink(
client_stream,
Some(InitialTargetPaths {
cwd: PathBuf::from("/launcher"),
path: PathBuf::from("note"),
}),
|_| true,
) else {
panic!("legacy target must fail");
};
assert!(matches!(
error,
AttachClientError::InitialTargetUnsupported { server: 19 }
));
server.join().expect("legacy server");
}
#[test]
fn full_crdt_daemon_advertises_everything_required() {
// A daemon built with `--features crdt` advertises all three — the
@ -1167,6 +1386,7 @@ mod tests {
shutdown_handle: b,
frontend_id: FrontendId::LOCAL,
server_protocol_version: PROTOCOL_VERSION,
initial_message: None,
};
// A send against the closed outbox fails *and* shuts the socket
// down (F-008 fail-fast is now a real teardown, not just a flag).
@ -1245,6 +1465,7 @@ mod tests {
|_, _| panic!("non-socket path must not spawn"),
Duration::from_millis(1),
Duration::from_millis(1),
None,
|_| false,
);
assert!(matches!(
@ -1269,6 +1490,7 @@ mod tests {
|_, _| panic!("permission failure must not spawn"),
Duration::from_millis(1),
Duration::from_millis(1),
None,
|_| false,
);
assert!(matches!(
@ -1318,6 +1540,7 @@ mod tests {
|_, _| Command::new("/bin/sh").args(["-c", "exit 0"]).spawn(),
Duration::from_secs(1),
Duration::ZERO,
None,
|_| true,
)
.expect("transient sequence must attach");

View File

@ -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<InitialTargetPaths>,
},
/// `pmacs-gpu --headless-probe <socket> <report>`: 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<InitialTargetPaths>,
},
}
@ -590,7 +594,7 @@ fn decimal_digits(mut n: usize) -> u32 {
fn main() {
env_logger::init();
let mode = match parse_args(&std::env::args().skip(1).collect::<Vec<_>>()) {
let mode = match parse_args(&std::env::args_os().skip(1).collect::<Vec<_>>()) {
Ok(mode) => mode,
Err(error) => {
eprintln!("pmacs-gpu: {error}\n\n{GPU_USAGE}");
@ -617,11 +621,13 @@ fn main() {
socket,
report,
daemon_executable,
initial_target,
} => {
std::process::exit(run_headless_managed_probe(
socket,
report,
daemon_executable,
initial_target.clone(),
));
}
Mode::Attach { .. } | Mode::ManagedAttach { .. } => {}
@ -631,27 +637,40 @@ fn main() {
.build()
.expect("create winit event loop");
let proxy = event_loop.create_proxy();
let attach_client = if let Mode::ManagedAttach {
let (attach_client, pending_events) = if let Mode::ManagedAttach {
socket,
daemon_executable,
initial_target,
} = &mode
{
match attach::connect_managed(socket, daemon_executable, proxy.clone()) {
Ok(managed) => Some(managed.client),
match attach::connect_managed_with_target(
socket,
daemon_executable,
initial_target.clone(),
proxy.clone(),
) {
Ok(mut managed) => {
let pending = managed
.client
.take_initial_message()
.map(|message| vec![AppEvent::Attach(AttachEvent::Message(Box::new(message)))])
.unwrap_or_default();
(Some(managed.client), pending)
}
Err(error) => {
eprintln!("pmacs-gpu: managed attach failed: {error}");
std::process::exit(1);
}
}
} else {
None
(None, Vec::new())
};
let mut app = App {
mode,
proxy: Some(proxy),
state: None,
attach_client,
pending_events: Vec::new(),
pending_events,
modifiers: winit::keyboard::ModifiersState::empty(),
};
event_loop
@ -833,15 +852,23 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
clippy::too_many_lines,
reason = "one linear managed-connect and lifecycle observation probe"
)]
fn run_headless_managed_probe(socket: &Path, report: &Path, daemon_executable: &Path) -> i32 {
fn run_headless_managed_probe(
socket: &Path,
report: &Path,
daemon_executable: &Path,
initial_target: Option<InitialTargetPaths>,
) -> i32 {
use std::io::Read as _;
use std::sync::mpsc;
use std::time::{Duration, Instant};
let (event_tx, event_rx) = mpsc::channel::<AttachEvent>();
let managed = match attach::connect_managed_with_sink(socket, daemon_executable, move |event| {
event_tx.send(event).is_ok()
}) {
let (connector_tx, event_rx) = mpsc::channel::<AttachEvent>();
let managed = match attach::connect_managed_with_target_and_sink(
socket,
daemon_executable,
initial_target,
move |event| connector_tx.send(event).is_ok(),
) {
Ok(managed) => managed,
Err(error) => {
let contents = format!("phase=error\nerror={error}\n");
@ -850,7 +877,11 @@ fn run_headless_managed_probe(socket: &Path, report: &Path, daemon_executable: &
return 4;
}
};
let client = managed.client;
let mut client = managed.client;
let initial_target_ready = matches!(
client.take_initial_message(),
Some(InstanceMessage::BufferSnapshot { .. })
);
let daemon = managed.daemon;
let protocol = client.server_protocol_version();
@ -865,12 +896,22 @@ fn run_headless_managed_probe(socket: &Path, report: &Path, daemon_executable: &
.expect("spawn managed probe stdin reader");
let deadline = Instant::now() + Duration::from_secs(20);
let mut ready = false;
let mut ready = initial_target_ready;
let mut stdin_closed = false;
let mut disconnect = String::new();
let mut last_reaped = false;
let mut last_wait_result = None;
let mut last_disconnect = String::new();
if ready
&& let Err(error) =
write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect)
{
eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}",
report.display()
);
return 5;
}
loop {
if stdin_rx.try_recv().is_ok() {
stdin_closed = true;
@ -1019,7 +1060,7 @@ const GPU_USAGE: &str = "\
pmacs-gpu GPU frontend for pmacs
NORMAL STARTUP:
pmacs --gpu [--socket NAME|PATH] start or reuse a managed daemon
pmacs --gpu [--socket NAME|PATH] [FILE] start/reuse a daemon and open FILE
ADVANCED DIRECT ATTACH:
pmacs-gpu --attach <socket> attach to an existing daemon only
@ -1029,58 +1070,124 @@ OPTIONS:
pmacs-gpu --version print package and protocol versions";
/// Strict parser for direct, managed, and headless GPU entry points.
fn parse_args(args: &[String]) -> Result<Mode, String> {
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<Mode, String> {
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 <socket>"
.to_owned(),
),
[flag, ..] if matches!(flag.as_str(), "--help" | "-h" | "--version" | "-V") => {
Err(format!("{flag} does not accept operands"))
[flag, ..] if flag == "--help" || flag == "-h" || flag == "--version" || flag == "-V" => {
Err(format!(
"{} does not accept operands",
flag.to_string_lossy()
))
}
[flag, ..]
if matches!(
flag.as_str(),
"--attach" | "--managed-attach" | "--headless-probe" | "--headless-managed-probe"
) =>
if flag == "--attach"
|| flag == "--managed-attach"
|| flag == "--headless-probe"
|| flag == "--headless-managed-probe" =>
{
Err(format!("{flag} received the wrong number of operands"))
Err(format!(
"{} received the wrong number of operands",
flag.to_string_lossy()
))
}
[other, ..] => Err(format!("unrecognized argument: {other}")),
[other, ..] => Err(format!(
"unrecognized argument: {}",
other.to_string_lossy()
)),
}
}
@ -8123,6 +8230,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::FontFacts { .. } => "FontFacts",
InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments",
InstanceMessage::TerminalFrame(_) => "TerminalFrame",
InstanceMessage::InitialTargetResult(_) => "InitialTargetResult",
}
}
@ -14263,12 +14371,7 @@ mod tests {
}
#[test]
fn gpu_cli_accepts_only_explicit_exact_modes() {
let args = |values: &[&str]| {
values
.iter()
.map(|value| (*value).to_owned())
.collect::<Vec<_>>()
};
let args = |values: &[&str]| values.iter().map(OsString::from).collect::<Vec<_>>();
assert_eq!(
parse_args(&args(&["--attach", "/tmp/pmacs.sock"])),
Ok(Mode::Attach {
@ -14284,6 +14387,7 @@ mod tests {
Ok(Mode::ManagedAttach {
socket: PathBuf::from("/tmp/pmacs.sock"),
daemon_executable: PathBuf::from("/bin/pmacs"),
initial_target: None,
})
);
assert_eq!(
@ -14308,10 +14412,56 @@ mod tests {
socket: PathBuf::from("/tmp/pmacs.sock"),
report: PathBuf::from("/tmp/report"),
daemon_executable: PathBuf::from("/bin/pmacs"),
initial_target: None,
})
);
}
#[test]
fn gpu_private_target_marker_preserves_raw_file_bytes() {
use std::os::unix::ffi::OsStringExt;
let raw_path = OsString::from_vec(vec![b'-', b'n', b'o', b't', b'e', 0xff]);
let argv = vec![
OsString::from("--managed-attach"),
OsString::from("/tmp/pmacs.sock"),
OsString::from("/bin/pmacs"),
OsString::from("--initial-target"),
OsString::from("/launcher"),
raw_path.clone(),
];
assert_eq!(
parse_args(&argv),
Ok(Mode::ManagedAttach {
socket: PathBuf::from("/tmp/pmacs.sock"),
daemon_executable: PathBuf::from("/bin/pmacs"),
initial_target: Some(InitialTargetPaths {
cwd: PathBuf::from("/launcher"),
path: PathBuf::from(&raw_path),
}),
})
);
let bad_cwd = [
OsString::from("--managed-attach"),
OsString::from("/tmp/pmacs.sock"),
OsString::from("/bin/pmacs"),
OsString::from("--initial-target"),
OsString::from("--cwd"),
OsString::from("note"),
];
assert!(
parse_args(&bad_cwd)
.expect_err("option-like cwd must fail")
.contains("option-like")
);
let bad_option = [OsString::from_vec(vec![b'-', 0xff])];
assert_eq!(
parse_args(&bad_option).expect_err("non-UTF-8 option must fail"),
"option names must be valid UTF-8"
);
}
#[test]
fn gpu_cli_rejects_bare_missing_and_trailing_arguments() {
let invalid = [
@ -14339,16 +14489,13 @@ mod tests {
vec!["research"],
];
for values in invalid {
let args = values
.iter()
.map(|value| (*value).to_owned())
.collect::<Vec<_>>();
let args = values.iter().map(OsString::from).collect::<Vec<_>>();
assert!(
parse_args(&args).is_err(),
"accepted invalid argv: {values:?}"
);
}
let error = parse_args(&["--attach".to_owned(), "--help".to_owned()])
let error = parse_args(&[OsString::from("--attach"), OsString::from("--help")])
.expect_err("option-like socket operand must fail");
assert!(error.contains("option-like path operand --help"));
}
@ -14384,7 +14531,7 @@ mod tests {
assert!(GPU_USAGE.contains("NORMAL STARTUP"));
assert!(GPU_USAGE.contains("ADVANCED DIRECT ATTACH"));
let extra = ["--help", "extra"].map(str::to_owned);
let extra = ["--help", "extra"].map(OsString::from);
let error = parse_args(&extra).expect_err("help operands must fail");
assert_eq!(error, "--help does not accept operands");
}

View File

@ -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,
};

View File

@ -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. v6v19 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<u8>,
/// Launcher-expanded target path bytes, absolute or relative to `cwd`.
pub path: Vec<u8>,
}
/// 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<InitialTarget>,
}
/// 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,
},
}

View File

@ -49,7 +49,10 @@
//! - Write fails (broken pipe) → return; ungraceful disconnect.
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::io::ErrorKind;
use std::net::Shutdown;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@ -64,9 +67,10 @@ use crate::lockfile::{self, LockError, LockHandle};
use crate::presence::{PresenceSnapshot, SessionRegistry};
use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm};
use crate::protocol::{
AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InstanceCapabilities,
InstanceIdentity, InstanceMessage, InstanceSignal, PROTOCOL_VERSION, PointerKind,
SelectionSnapshot,
AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget,
InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal,
MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PROTOCOL_VERSION, PointerKind,
SelectionSnapshot, SessionBootstrapRequest,
};
use crate::socket_path::{SocketPathError, ensure_runtime_subdir};
use crate::transport::{read_message, write_message};
@ -114,6 +118,8 @@ enum DispatcherEvent {
frontend_id: FrontendId,
session_state: crate::presence::SessionState,
initial_size: CellSize,
/// Validated protocol-v20 semantic bootstrap target, if requested.
initial_target: Option<InitialTarget>,
/// Write-half of the per-attach stream. The dispatcher owns
/// this end; the per-attach reader thread keeps the
/// read-half via `try_clone`.
@ -633,6 +639,48 @@ fn install_signal_handlers(shutdown: &Arc<AtomicBool>) -> 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<String>) {
let result = InitialTargetResult::Failed {
message: bounded_initial_target_error(message.into()),
};
let _ = write_message(stream, &InstanceMessage::InitialTargetResult(result));
let _ = stream.shutdown(Shutdown::Both);
}
fn validate_initial_target(target: &InitialTarget) -> Result<(), String> {
if target.cwd.is_empty() {
return Err("initial target cwd is empty".to_owned());
}
if target.path.is_empty() {
return Err("initial target path is empty".to_owned());
}
if target.cwd.len() > MAX_INITIAL_TARGET_PATH_BYTES {
return Err("initial target cwd exceeds 32 KiB".to_owned());
}
if target.path.len() > MAX_INITIAL_TARGET_PATH_BYTES {
return Err("initial target path exceeds 32 KiB".to_owned());
}
if target.cwd.contains(&0) || target.path.contains(&0) {
return Err("initial target path contains an embedded NUL".to_owned());
}
if !Path::new(OsStr::from_bytes(&target.cwd)).is_absolute() {
return Err("initial target cwd is not absolute".to_owned());
}
Ok(())
}
/// T M10.8 — per-attach thread. Runs handshake on a fresh thread for
/// each accepted connection; on success, sends `SessionEstablished`
/// to the dispatcher and transitions to reader behavior on the same
@ -644,7 +692,11 @@ fn install_signal_handlers(shutdown: &Arc<AtomicBool>) -> 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<DaemonState>,
@ -712,6 +764,28 @@ fn per_attach_thread(
}
};
// Q#GT4 — v20 semantic sessions send one bootstrap envelope after
// AttachRequest. Legacy and non-semantic sessions retain the exact
// two-message handshake and therefore must not be read here.
let initial_target = if req.protocol_version >= 20 && negotiated_caps.semantic_render {
let bootstrap: SessionBootstrapRequest = match read_message(&mut stream) {
Ok(bootstrap) => bootstrap,
Err(e) => {
eprintln!("pmacs: read SessionBootstrapRequest failed: {e}");
return;
}
};
if let Some(target) = bootstrap.initial_target.as_ref()
&& let Err(message) = validate_initial_target(target)
{
send_initial_target_failure(&mut stream, message);
return;
}
bootstrap.initial_target
} else {
None
};
// T M10.8 Day 4 — Q5 non-multi-session admission control.
let _non_multi_guard = if negotiated_caps.multi_frontend {
None
@ -760,6 +834,7 @@ fn per_attach_thread(
frontend_id,
session_state,
initial_size: req.initial_size,
initial_target,
write_stream,
})
.is_err()
@ -1012,12 +1087,12 @@ fn dispatcher_loop(
.is_some_and(|s| s.negotiated_capabilities.crdt_replica)
{
// F29 — when a mid-session upgrade occurs, push a
// `BufferSnapshot` for the newly-CRDT-backed buffer
// to every currently-attached replica so their
// `BufferMirror`s gain an entry for it. Without
// this, replicas attached before the upgrade
// permanently fall back to v0.1 round-trip on that
// buffer.
// `BufferSnapshot` to every grid replica so its
// `BufferMirror` gains an entry for the buffer. A
// semantic replica receives it only when that replica
// is displaying this buffer: applying a foreign-buffer
// snapshot would switch the GPU window away from its
// own active view.
if let Some(upgraded) = ensure_active_buffer_crdt_backed(editor, *fid) {
broadcast_buffer_snapshot_to_replicas(
editor,
@ -1526,8 +1601,146 @@ fn take_pending_terminal_bell(
}
}
struct OpenedInitialTarget {
buffer_id: crate::buffer::BufferId,
publish_to_replicas: bool,
}
struct InitialTargetSnapshot {
crdt_snapshot: Vec<u8>,
upgraded_to_crdt: bool,
}
fn resolve_initial_target(target: InitialTarget) -> PathBuf {
let cwd = PathBuf::from(OsString::from_vec(target.cwd));
let path = PathBuf::from(OsString::from_vec(target.path));
let absolute = if path.is_absolute() {
path
} else {
cwd.join(path)
};
crate::editor_core::lexical_normalize(&absolute)
}
fn open_initial_target(
editor: &mut EditorState,
frontend_id: FrontendId,
target: InitialTarget,
) -> Result<OpenedInitialTarget, String> {
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<InitialTargetSnapshot, 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}"))?;
let upgraded_to_crdt = !buffer.is_crdt_backed();
if upgraded_to_crdt {
let peer_id = crate::crdt::peer_id_from_frontend(FrontendId::LOCAL);
buffer
.upgrade_to_crdt(peer_id)
.map_err(|error| format!("initial target CRDT upgrade failed: {error:?}"))?;
}
let crdt_snapshot = buffer
.crdt_state()
.ok_or_else(|| "initial target CRDT state is unavailable".to_owned())?
.export_snapshot()
.map_err(|error| format!("initial target snapshot export failed: {error:?}"))?;
Ok(InitialTargetSnapshot {
crdt_snapshot,
upgraded_to_crdt,
})
}
#[cfg(not(feature = "crdt"))]
fn initial_target_snapshot(
_editor: &EditorState,
_buffer_id: crate::buffer::BufferId,
) -> Result<InitialTargetSnapshot, 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<FrontendId, RenderState>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
streams: &mut HashMap<FrontendId, UnixStream>,
term_sizes: &mut HashMap<FrontendId, CellSize>,
last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
session_registry: &mut SessionRegistry,
frontend_id: FrontendId,
) {
render_states.remove(&frontend_id);
semantic_states.remove(&frontend_id);
if let Some(stream) = streams.remove(&frontend_id) {
let _ = stream.shutdown(Shutdown::Both);
}
term_sizes.remove(&frontend_id);
last_active_buffer_sent.remove(&frontend_id);
session_registry.unregister_session(frontend_id);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
}
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
reason = "one session bootstrap transaction"
)]
fn handle_session_established(
@ -1536,10 +1749,12 @@ fn handle_session_established(
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
streams: &mut HashMap<FrontendId, UnixStream>,
term_sizes: &mut HashMap<FrontendId, CellSize>,
last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
session_registry: &mut SessionRegistry,
frontend_id: FrontendId,
session_state: crate::presence::SessionState,
initial_size: CellSize,
initial_target: Option<InitialTarget>,
mut write_stream: UnixStream,
) {
// Register the frontend's view (M10.8 Day 3: fresh scratch
@ -1551,49 +1766,78 @@ fn handle_session_established(
// `RenderState` vs a `SemanticRenderState` below — a grid session
// collapses folds, a semantic one keeps raw-line reckoning until
// Stage 3.
let scratch_view = build_fresh_frontend_view(
let fresh_view = build_fresh_frontend_view(
editor,
!session_state.negotiated_capabilities.semantic_render,
);
editor
.core
.borrow_mut()
.register_frontend_view(frontend_id, scratch_view);
{
let mut core = editor.core.borrow_mut();
core.register_frontend_view(frontend_id, fresh_view);
core.active_frontend = frontend_id;
}
let opened_target = match initial_target {
Some(target) => match open_initial_target(editor, frontend_id, target) {
Ok(opened) => Some(opened),
Err(message) => {
send_initial_target_failure(&mut write_stream, message);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
return;
}
},
None => None,
};
// T M10.10: bootstrap the new frontend's `BufferMirror` by
// sending one `BufferSnapshot` per CRDT-backed buffer. Gated on
// the negotiated `crdt_replica` capability — v0.1 / non-replica
// frontends never receive the variant (postcard would hard-error
// on the unknown variant; see M10.10-FRAMING.md Refinement 3).
// Ordering: snapshots are sent BEFORE any CellDelta flows (the
// next per-tick render is the first CellDelta source), so the
// mirror is initialized before any local-edit path can reference
// it.
let crdt_replica = session_state.negotiated_capabilities.crdt_replica;
// T M11.2 — a semantic session is always a text replica (the
// negotiation dependency rule guarantees `semantic_render ⇒
// crdt_replica`), so the `BufferSnapshot` bootstrap below still
// fires: the semantic frontend holds the rope locally and the
// semantic frame ships no text.
let semantic_render = session_state.negotiated_capabilities.semantic_render;
// Captured before `register_session` consumes the state: the
// semantic producer needs the peer's version (finding 3 below).
let negotiated_protocol_version = session_state.negotiated_protocol_version;
if crdt_replica {
if let Some(opened) = opened_target.as_ref() {
let target_snapshot = match initial_target_snapshot(editor, opened.buffer_id) {
Ok(snapshot) => snapshot,
Err(message) => {
send_initial_target_failure(&mut write_stream, message);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
return;
}
};
let snapshot_message = InstanceMessage::BufferSnapshot {
buffer_id: opened.buffer_id,
crdt_snapshot: target_snapshot.crdt_snapshot,
};
if opened.publish_to_replicas || target_snapshot.upgraded_to_crdt {
publish_buffer_snapshot_to_replicas(
editor,
opened.buffer_id,
&snapshot_message,
session_registry,
streams,
semantic_states,
);
}
if write_message(&mut write_stream, &snapshot_message).is_err() {
let _ = write_stream.shutdown(Shutdown::Both);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
return;
}
} else if crdt_replica {
// Legacy no-target attach remains an all-buffer replica bootstrap.
send_buffer_snapshots(editor, &mut write_stream);
}
// Register the session in the registry (presence + capability
// filters).
session_registry.register_session(frontend_id, session_state);
if semantic_render {
semantic_states.insert(
frontend_id,
// for_peer, not new (PR #120 round 1 finding 3): a v15
// peer's producer must not resolve faces into the
// FileStyleSummary marks — that channel predates the v16
// gate.
crate::semantic_render::SemanticRenderState::for_peer(
frontend_id,
negotiated_protocol_version,
@ -1607,9 +1851,30 @@ fn handle_session_established(
streams.insert(frontend_id, write_stream);
term_sizes.insert(frontend_id, initial_size);
// Stamp active_frontend so the initial render's Lua statusline
// code sees the right fid.
editor.core.borrow_mut().active_frontend = frontend_id;
if let Some(opened) = opened_target {
last_active_buffer_sent.insert(frontend_id, opened.buffer_id);
let result = InstanceMessage::InitialTargetResult(InitialTargetResult::Opened {
buffer_id: opened.buffer_id,
});
let write_result = {
let stream = streams
.get_mut(&frontend_id)
.expect("new session stream installed");
write_message(stream, &result)
};
if write_result.is_err() {
cleanup_provisional_session(
editor,
render_states,
semantic_states,
streams,
term_sizes,
last_active_buffer_sent,
session_registry,
frontend_id,
);
}
}
}
#[allow(clippy::too_many_arguments)]
@ -1631,6 +1896,7 @@ fn handle_dispatcher_event(
frontend_id,
session_state,
initial_size,
initial_target,
write_stream,
} => {
handle_session_established(
@ -1639,14 +1905,20 @@ fn handle_dispatcher_event(
semantic_states,
streams,
term_sizes,
last_active_buffer_sent,
session_registry,
frontend_id,
session_state,
initial_size,
initial_target,
write_stream,
);
}
DispatcherEvent::FrontendEvent { source, event } => {
if session_registry.session_state(source).is_none() {
eprintln!("pmacs: dropping frontend event from uninstalled session {source:?}");
return;
}
match event {
FrontendEvent::Detach(_) => {
// The per-attach thread will follow up with a
@ -1861,9 +2133,12 @@ fn handle_dispatcher_event(
}
}
_ => {
let term_size = *term_sizes
.get(&source)
.expect("term_size present for source");
let Some(&term_size) = term_sizes.get(&source) else {
eprintln!(
"pmacs: dropping frontend event without size state for {source:?}"
);
return;
};
let mut term_size = term_size;
if let Some(render_state) = render_states.get_mut(&source) {
apply_event(editor, source, event, &mut term_size, render_state);
@ -1882,10 +2157,8 @@ fn handle_dispatcher_event(
// nothing before B1.)
apply_semantic_input_event(editor, source, event, term_size);
} else {
debug_assert!(
false,
"fid with neither a render_state nor a semantic_state \
sent a frontend event"
eprintln!(
"pmacs: dropping frontend event without render state for {source:?}"
);
}
}
@ -2148,28 +2421,56 @@ fn broadcast_buffer_snapshot_to_replicas(
streams: &mut HashMap<FrontendId, UnixStream>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
) {
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<FrontendId, UnixStream>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
) {
for (peer_id, stream) in streams {
let Some(session) = session_registry.session_state(*peer_id) else {
continue;
};
if !session.negotiated_capabilities.crdt_replica {
continue;
}
if let Err(e) = write_message(stream, &msg) {
eprintln!("pmacs: F29 send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}");
if session.negotiated_capabilities.semantic_render {
let displays_buffer = editor
.core
.borrow()
.active_window_for(*peer_id)
.is_some_and(|window| window.buffer_id == buffer_id);
if !displays_buffer {
continue;
}
}
// PR #120 round 2 — same reset contract as the follow path:
// the snapshot wiped this replica's buffer-scoped render
// state, so its emission baselines for the buffer die too.
if let Some(sem) = semantic_states.get_mut(fid) {
sem.on_buffer_snapshot_sent(buffer_id);
if let Err(error) = write_message(stream, message) {
eprintln!(
"pmacs: BufferSnapshot publish for {buffer_id:?} to {peer_id:?} failed: {error}"
);
continue;
}
if let Some(semantic) = semantic_states.get_mut(peer_id) {
semantic.on_buffer_snapshot_sent(buffer_id);
}
}
}
@ -2925,6 +3226,119 @@ mod tests {
assert!(!peer_declared_terminal_support(&registry, FrontendId(99)));
}
#[cfg(feature = "crdt")]
#[test]
fn snapshot_publication_skips_foreign_semantic_views_and_ignores_dead_peers() {
let mut editor = EditorState::new();
let semantic_peer = FrontendId(20);
let live_grid_peer = FrontendId(21);
let dead_grid_peer = FrontendId(22);
let semantic_view = build_fresh_frontend_view(&mut editor, false);
editor
.core
.borrow_mut()
.register_frontend_view(semantic_peer, semantic_view);
let semantic_caps = crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
};
let grid_caps = crate::protocol::NegotiatedCapabilities {
semantic_render: false,
..semantic_caps
};
let mut registry = SessionRegistry::new();
registry.register_session(
semantic_peer,
crate::presence::SessionState::new(PROTOCOL_VERSION, semantic_caps, 0),
);
registry.register_session(
live_grid_peer,
crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 1),
);
registry.register_session(
dead_grid_peer,
crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 2),
);
let (semantic_server, mut semantic_client) =
UnixStream::pair().expect("semantic socketpair");
let (live_grid_server, mut live_grid_client) =
UnixStream::pair().expect("live grid socketpair");
let (dead_grid_server, dead_grid_client) =
UnixStream::pair().expect("dead grid socketpair");
drop(dead_grid_client);
let mut streams = HashMap::from([
(semantic_peer, semantic_server),
(live_grid_peer, live_grid_server),
(dead_grid_peer, dead_grid_server),
]);
let published_buffer = crate::buffer::BufferId::from_raw(900);
let message = InstanceMessage::BufferSnapshot {
buffer_id: published_buffer,
crdt_snapshot: vec![1, 2, 3],
};
publish_buffer_snapshot_to_replicas(
&editor,
published_buffer,
&message,
&registry,
&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::<InstanceMessage>(&mut semantic_client).is_err(),
"a semantic peer displaying another buffer must receive no snapshot"
);
}
#[test]
fn frontend_events_from_uninstalled_sessions_are_dropped_without_state_access() {
let source = FrontendId(77);
let mut editor = EditorState::new();
let mut render_states = HashMap::new();
let mut semantic_states = HashMap::new();
let mut streams = HashMap::new();
let mut term_sizes = HashMap::new();
let mut last_dispatch_idle_sent = HashMap::new();
let mut last_active_buffer_sent = HashMap::new();
let mut terminal_bell_baselines = HashMap::new();
let mut session_registry = SessionRegistry::new();
handle_dispatcher_event(
DispatcherEvent::FrontendEvent {
source,
event: FrontendEvent::Key(crate::protocol::KeyEvent {
frontend_id: source,
key: crate::protocol::Key::Char('x'),
mods: crate::protocol::Modifiers::NONE,
timestamp_ns: 0,
}),
},
&mut editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut last_dispatch_idle_sent,
&mut last_active_buffer_sent,
&mut terminal_bell_baselines,
&mut session_registry,
);
assert_eq!(editor.core.borrow().active_frontend, FrontendId::LOCAL);
assert!(term_sizes.is_empty());
}
#[test]
fn build_identity_includes_version_and_uptime() {
let s = DaemonState::new(Some("research".into()));
@ -3316,6 +3730,18 @@ mod tests {
let mut last_active_buffer_sent = HashMap::new();
let mut terminal_bell_baselines = HashMap::new();
let mut session_registry = SessionRegistry::new();
session_registry.register_session(
source,
crate::presence::SessionState::new(
PROTOCOL_VERSION,
crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: false,
semantic_render: false,
},
0,
),
);
handle_dispatcher_event(
DispatcherEvent::FrontendEvent {

View File

@ -595,6 +595,9 @@ impl EditorCore {
/// closing a window left others intact).
pub fn unregister_frontend_view(&mut self, fid: FrontendId) {
self.views.remove(&fid);
if self.active_frontend == fid {
self.active_frontend = FrontendId::LOCAL;
}
}
/// [`BufferId`] of the active window's buffer.
@ -3374,7 +3377,7 @@ fn normalize_buffer_path(path: PathBuf) -> PathBuf {
/// `~` becomes `$HOME`; `~/x` becomes `$HOME/x`. `~user` is left
/// untouched (no passwd lookup). Returns the input unchanged if it
/// has no leading `~`, isn't valid UTF-8, or `$HOME` is unset.
fn expand_tilde(path: PathBuf) -> PathBuf {
pub fn expand_tilde(path: PathBuf) -> PathBuf {
let Some(s) = path.to_str() else {
return path;
};
@ -3392,7 +3395,7 @@ fn expand_tilde(path: PathBuf) -> PathBuf {
/// Fold `.` and `..` components without touching the filesystem.
/// `..` pops a preceding normal segment; against the root (or a
/// Windows prefix) it is dropped, since you cannot ascend past it.
fn lexical_normalize(path: &Path) -> PathBuf {
pub(crate) fn lexical_normalize(path: &Path) -> PathBuf {
use std::path::Component;
let mut stack: Vec<Component> = Vec::new();
for comp in path.components() {
@ -3874,13 +3877,16 @@ mod tests {
// we don't need a fresh window allocation in this test.
let local_view = s.views[&FrontendId::LOCAL].clone();
s.register_frontend_view(fid, local_view);
s.active_frontend = fid;
assert!(s.active_window_for(fid).is_some());
// Unregister drops the entry; explicit lookup returns None.
s.unregister_frontend_view(fid);
assert!(s.active_window_for(fid).is_none());
// LOCAL invariant survives unrelated register/unregister.
// Removing the selected frontend restores the always-registered
// LOCAL view as the ambient fallback.
assert_eq!(s.active_frontend, FrontendId::LOCAL);
assert!(s.views.contains_key(&FrontendId::LOCAL));
}

View File

@ -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

View File

@ -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 <target>
@ -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 <target>
@ -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<PathBuf>,
frontend: FrontendChoice,
},
/// `pmacs --gpu [--socket ...]`: launch the separate GPU frontend,
/// starting a CRDT daemon on the resolved socket when absent.
Gpu { socket: Option<String> },
/// `pmacs --gpu [--socket ...] [FILE]`: launch the separate GPU
/// frontend, starting a CRDT daemon on the resolved socket when absent.
Gpu {
socket: Option<String>,
file: Option<PathBuf>,
},
/// `pmacs --daemon [--socket ...]`: run a foreground daemon on a
/// Unix socket, supervised by the user.
Daemon { socket: Option<String> },
@ -185,7 +191,7 @@ fn parse_attach_target_with_shorthand(s: &str) -> Result<AttachTarget, AttachTar
clippy::too_many_lines,
reason = "single-pass parser keeps mutually exclusive CLI modes explicit"
)]
fn parse_args(args: &[String]) -> CliResult {
fn parse_args(args: &[OsString]) -> CliResult {
let mut file: Option<PathBuf> = None;
let mut frontend = FrontendChoice::Auto;
let mut daemon = false;
@ -195,38 +201,45 @@ fn parse_args(args: &[String]) -> CliResult {
let mut socket: Option<String> = None;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"-nw" | "--no-window" => frontend = FrontendChoice::Tui,
"--gpu" => gpu = true,
"--daemon" => daemon = true,
"--attach" => attach = true,
"--daemon-attach" => daemon_attach = true,
"--socket" => match iter.next() {
Some(s) => socket = Some(s.clone()),
if arg.as_os_str().as_bytes().starts_with(b"-") && arg.to_str().is_none() {
return CliResult::Error("option names must be valid UTF-8".into());
}
match arg.to_str() {
Some("-nw" | "--no-window") => frontend = FrontendChoice::Tui,
Some("--gpu") => gpu = true,
Some("--daemon") => daemon = true,
Some("--attach") => attach = true,
Some("--daemon-attach") => daemon_attach = true,
Some("--socket") => match iter.next() {
Some(value) => match value.to_str() {
Some(value) => socket = Some(value.to_owned()),
None => {
return CliResult::Error("--socket value must be valid UTF-8".into());
}
},
None => return CliResult::Error("--socket requires a value".into()),
},
"-h" | "--help" => return CliResult::Help,
"-V" | "--version" => return CliResult::Version,
"--" => {
// Treat the rest as positional, even if they look like flags.
if let Some(p) = iter.next() {
Some("-h" | "--help") => return CliResult::Help,
Some("-V" | "--version") => return CliResult::Version,
Some("--") => {
if let Some(path) = iter.next() {
if file.is_some() {
return CliResult::Error("multiple files not yet supported".into());
}
file = Some(PathBuf::from(p));
file = Some(PathBuf::from(path));
}
if iter.next().is_some() {
return CliResult::Error("multiple files not yet supported".into());
}
}
flag if flag.starts_with('-') => {
Some(flag) if flag.starts_with('-') => {
return CliResult::Error(format!("unknown option: {flag}"));
}
path => {
Some(_) | None => {
if file.is_some() {
return CliResult::Error("multiple files not yet supported".into());
}
file = Some(PathBuf::from(path));
file = Some(PathBuf::from(arg));
}
}
}
@ -237,16 +250,11 @@ fn parse_args(args: &[String]) -> CliResult {
);
}
if gpu {
if file.is_some() {
return CliResult::Error(
"--gpu does not yet accept FILE; open it from the GPU with C-x C-f".into(),
);
}
if frontend == FrontendChoice::Tui {
return CliResult::Error("--gpu and --no-window are mutually exclusive".into());
}
return CliResult::Run(CliArgs {
mode: Mode::Gpu { socket },
mode: Mode::Gpu { socket, file },
});
}
if daemon {
@ -307,7 +315,7 @@ fn gpu_binary(current_exe: &Path, override_bin: Option<PathBuf>) -> (PathBuf, Pa
(PathBuf::from("pmacs-gpu"), sibling)
}
fn run_gpu(socket: Option<&str>) -> ExitCode {
fn run_gpu(socket: Option<&str>, file: Option<&Path>) -> ExitCode {
if !cfg!(feature = "crdt") {
eprintln!("pmacs: --gpu requires pmacs built with --features crdt");
return ExitCode::FAILURE;
@ -321,15 +329,32 @@ fn run_gpu(socket: Option<&str>) -> ExitCode {
return ExitCode::FAILURE;
}
};
let initial_target = match file {
Some(path) => {
let cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(error) => {
eprintln!("pmacs: cannot determine launcher working directory: {error}");
return ExitCode::FAILURE;
}
};
Some((cwd, pmacs::editor_core::expand_tilde(path.to_owned())))
}
None => None,
};
let (gpu, sibling) = gpu_binary(
&current_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(&current_exe)
.status();
.arg(&current_exe);
if let Some((cwd, path)) = initial_target {
command.arg("--initial-target").arg(cwd).arg(path);
}
let status = command.status();
match status {
Ok(status) if status.success() => ExitCode::SUCCESS,
Ok(status) => {
@ -358,7 +383,7 @@ fn run_gpu(socket: Option<&str>) -> ExitCode {
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
match parse_args(&args) {
CliResult::Help => {
print!("{USAGE}");
@ -388,7 +413,7 @@ fn main() -> ExitCode {
ExitCode::FAILURE
}
},
Mode::Gpu { socket } => run_gpu(socket.as_deref()),
Mode::Gpu { socket, file } => run_gpu(socket.as_deref(), file.as_deref()),
Mode::Daemon { socket } => {
let socket_path = pmacs::socket_path::resolve_socket_path(socket.as_deref());
// The user-provided NAME (no slashes) becomes the
@ -471,9 +496,10 @@ fn main() -> ExitCode {
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::ffi::OsStringExt;
fn args(slice: &[&str]) -> Vec<String> {
slice.iter().map(|s| (*s).to_string()).collect()
fn args(slice: &[&str]) -> Vec<OsString> {
slice.iter().map(OsString::from).collect()
}
fn local_mode(parsed: CliArgs) -> (Option<PathBuf>, FrontendChoice) {
@ -799,7 +825,7 @@ mod tests {
vec!["--attach", "--daemon-attach"],
vec!["--daemon", "--attach", "--daemon-attach"],
] {
let v: Vec<String> = combo.iter().map(|s| (*s).to_string()).collect();
let v: Vec<OsString> = combo.iter().map(OsString::from).collect();
match parse_args(&v) {
CliResult::Error(m) => assert!(
m.contains("mutually exclusive"),
@ -810,29 +836,42 @@ mod tests {
}
}
#[test]
fn gpu_flag_selects_managed_gpu_with_optional_socket() {
for (argv, expected) in [
(vec!["--gpu"], None),
(vec!["--gpu", "--socket", "research"], Some("research")),
fn gpu_flag_accepts_one_optional_file_and_socket() {
for (argv, expected_socket, expected_file) in [
(vec!["--gpu"], None, None),
(
vec!["--gpu", "--socket", "research"],
Some("research"),
None,
),
(vec!["--gpu", "README.md"], None, Some("README.md")),
(
vec!["--gpu", "--socket", "research", "README.md"],
Some("research"),
Some("README.md"),
),
(vec!["--gpu", "--", "-notes"], None, Some("-notes")),
] {
let argv = args(&argv);
match parse_args(&argv) {
match parse_args(&args(&argv)) {
CliResult::Run(CliArgs {
mode: Mode::Gpu { socket },
}) => assert_eq!(socket.as_deref(), expected),
mode: Mode::Gpu { socket, file },
}) => {
assert_eq!(socket.as_deref(), expected_socket);
assert_eq!(file.as_deref(), expected_file.map(Path::new));
}
other => panic!("expected GPU mode; got {other:?}"),
}
}
}
#[test]
fn gpu_flag_rejects_files_tui_and_other_modes() {
fn gpu_flag_rejects_tui_other_modes_and_multiple_files() {
for argv in [
vec!["--gpu", "README.md"],
vec!["--gpu", "-nw"],
vec!["--gpu", "--daemon"],
vec!["--gpu", "--attach"],
vec!["--gpu", "--daemon-attach"],
vec!["--gpu", "one", "two"],
] {
assert!(
matches!(parse_args(&args(&argv)), CliResult::Error(_)),
@ -841,6 +880,34 @@ mod tests {
}
}
#[test]
fn gpu_file_keeps_non_utf8_bytes_and_launcher_tilde_expansion_is_exact() {
let raw = OsString::from_vec(vec![b'n', b'o', b't', b'e', 0xff]);
let parsed = parse_args(&[OsString::from("--gpu"), raw.clone()]);
match parsed {
CliResult::Run(CliArgs {
mode: Mode::Gpu {
file: Some(file), ..
},
}) => assert_eq!(file.as_os_str().as_bytes(), raw.as_bytes()),
other => panic!("expected raw GPU file; got {other:?}"),
}
let home = std::env::var_os("HOME").expect("test HOME");
assert_eq!(
pmacs::editor_core::expand_tilde(PathBuf::from("~")),
PathBuf::from(&home)
);
assert_eq!(
pmacs::editor_core::expand_tilde(PathBuf::from("~/notes")),
PathBuf::from(home).join("notes")
);
assert_eq!(
pmacs::editor_core::expand_tilde(PathBuf::from("~other/notes")),
PathBuf::from("~other/notes")
);
}
#[test]
fn bare_socket_is_never_silently_ignored() {
match parse_args(&args(&["--socket", "research"])) {

View File

@ -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::<SessionBootstrapRequest>(
&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 {

View File

@ -1443,19 +1443,14 @@ fn fold_range(s: &EditorState, start: u64, end: u64) {
}
// ---------------------------------------------------------------------------
// 11. No wire / protocol change (framing acceptance 11, Bet B6)
// 11. No Stage 2 wire requirement (framing acceptance 11, Bet B6)
// ---------------------------------------------------------------------------
#[test]
fn stage_2_bumps_no_protocol_version() {
assert_eq!(
pmacs::protocol::PROTOCOL_VERSION,
19,
"Stage 2 is entirely daemon-side: the TUI collapse ships no new wire data"
);
assert_eq!(
*pmacs::protocol::SUPPORTED_PROTOCOL_VERSIONS.last().unwrap(),
19
fn stage_2_preserves_protocol_v19_compatibility() {
assert!(
pmacs::protocol::SUPPORTED_PROTOCOL_VERSIONS.contains(&19),
"Stage 2 is entirely daemon-side: later protocol bumps must retain v19 compatibility"
);
}

View File

@ -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;

View File

@ -78,6 +78,8 @@ fn non_crdt_root_rejects_gpu_before_socket_io_discovery_or_spawn() {
#[cfg(feature = "crdt")]
mod crdt {
use std::collections::HashMap;
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
@ -89,9 +91,11 @@ mod crdt {
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use pmacs::cell::CellSize;
use pmacs::crdt::CrdtState;
use pmacs::protocol::{
AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello,
InstanceCapabilities, InstanceIdentity, InstanceMessage, PROTOCOL_VERSION,
AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InitialTarget,
InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage,
PROTOCOL_VERSION, SessionBootstrapRequest,
};
use pmacs::transport::{read_message, write_message};
@ -212,6 +216,119 @@ mod crdt {
(hello.assigned_frontend_id, stream)
}
struct TargetSession {
frontend_id: FrontendId,
buffer_id: pmacs::buffer::BufferId,
replica: CrdtState,
stream: UnixStream,
}
fn attach_target(socket: &Path, cwd: &Path, path: &Path) -> TargetSession {
use std::os::unix::ffi::OsStrExt;
let mut stream = UnixStream::connect(socket).expect("connect target frontend");
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set target frontend timeout");
let hello: Hello = read_message(&mut stream).expect("target frontend Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
write_message(
&mut stream,
&AttachRequest {
protocol_version: PROTOCOL_VERSION,
frontend_capabilities: FrontendCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
..FrontendCapabilities::default()
},
initial_size: CellSize::new(24, 80),
},
)
.expect("attach target frontend");
write_message(
&mut stream,
&SessionBootstrapRequest {
initial_target: Some(InitialTarget {
cwd: cwd.as_os_str().as_bytes().to_vec(),
path: path.as_os_str().as_bytes().to_vec(),
}),
},
)
.expect("send initial target");
let (buffer_id, snapshot) =
match read_message::<InstanceMessage>(&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::<InstanceMessage>(&mut stream).expect("target result"),
InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { buffer_id })
);
let replica = CrdtState::new(hello.assigned_frontend_id.0).expect("target replica");
replica
.import_snapshot(&snapshot)
.expect("import target snapshot");
TargetSession {
frontend_id: hello.assigned_frontend_id,
buffer_id,
replica,
stream,
}
}
fn open_raw_target(
socket: &Path,
cwd: Vec<u8>,
path: Vec<u8>,
) -> (FrontendId, UnixStream, Vec<InstanceMessage>) {
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::<InstanceMessage>(&mut stream).expect("raw target result");
let messages = if matches!(first, InstanceMessage::BufferSnapshot { .. }) {
vec![
first,
read_message::<InstanceMessage>(&mut stream).expect("raw opened result"),
]
} else {
vec![first]
};
(hello.assigned_frontend_id, stream, messages)
}
fn request_raw_target(socket: &Path, cwd: Vec<u8>, path: Vec<u8>) -> Vec<InstanceMessage> {
open_raw_target(socket, cwd, path).2
}
fn spawn_daemon(socket: &Path, envs: &[(&str, &str)]) -> Child {
let home = socket.parent().expect("socket parent");
let mut command = Command::new(pmacs_binary());
@ -243,12 +360,41 @@ mod crdt {
Self::spawn_with_env(socket, report, daemon_executable, home, &[])
}
fn spawn_target(
socket: &Path,
report: &Path,
daemon_executable: &Path,
home: &Path,
cwd: &Path,
target: &Path,
) -> Self {
Self::spawn_with_env_and_target(
socket,
report,
daemon_executable,
home,
&[],
Some((cwd, target)),
)
}
fn spawn_with_env(
socket: &Path,
report: &Path,
daemon_executable: &Path,
home: &Path,
envs: &[(&str, &Path)],
) -> Self {
Self::spawn_with_env_and_target(socket, report, daemon_executable, home, envs, None)
}
fn spawn_with_env_and_target(
socket: &Path,
report: &Path,
daemon_executable: &Path,
home: &Path,
envs: &[(&str, &Path)],
initial_target: Option<(&Path, &Path)>,
) -> Self {
assert!(
gpu_binary().is_file(),
@ -259,7 +405,11 @@ mod crdt {
.args(["--headless-managed-probe"])
.arg(socket)
.arg(report)
.arg(daemon_executable)
.arg(daemon_executable);
if let Some((cwd, path)) = initial_target {
command.arg("--initial-target").arg(cwd).arg(path);
}
command
.env("HOME", home)
.env("XDG_CONFIG_HOME", home)
.stdin(Stdio::piped())
@ -316,6 +466,10 @@ mod crdt {
let fake_gpu = temp.path().join("fake-gpu");
let record = temp.path().join("argv");
let socket = temp.path().join("broker.sock");
let launch_cwd = temp.path().join("launch");
let launcher_home = temp.path().join("home");
fs::create_dir(&launch_cwd).expect("create launcher cwd");
fs::create_dir(&launcher_home).expect("create launcher home");
write_script(
&fake_gpu,
"printf '%s\\n' \"$@\" > \"$PMACS_TEST_RECORD\"\nexit \"$PMACS_TEST_EXIT\"",
@ -324,6 +478,9 @@ mod crdt {
let success = Command::new(pmacs_binary())
.args(["--gpu", "--socket"])
.arg(&socket)
.arg("~/notes.txt")
.current_dir(&launch_cwd)
.env("HOME", &launcher_home)
.env(TEST_GPU_OVERRIDE, &fake_gpu)
.env("PMACS_TEST_RECORD", &record)
.env("PMACS_TEST_EXIT", "0")
@ -339,6 +496,9 @@ mod crdt {
assert_eq!(args[0], "--managed-attach");
assert_eq!(Path::new(args[1]), socket);
assert_eq!(Path::new(args[2]), pmacs_binary());
assert_eq!(args[3], "--initial-target");
assert_eq!(Path::new(args[4]), launch_cwd);
assert_eq!(Path::new(args[5]), launcher_home.join("notes.txt"));
let failure = Command::new(pmacs_binary())
.arg("--gpu")
@ -361,6 +521,336 @@ mod crdt {
);
}
#[test]
fn one_command_root_broker_reaches_target_ready_through_the_real_gpu_connector() {
let temp = secure_tempdir();
let cwd = temp.path().join("workspace");
fs::create_dir(&cwd).expect("create workspace");
fs::write(cwd.join("opened.txt"), "opened by root\n").expect("write target");
let socket = temp.path().join("one-command.sock");
let report = temp.path().join("one-command-report");
let wrapper = temp.path().join("headless-gpu");
write_script(
&wrapper,
"test \"$1\" = \"--managed-attach\"\n\
socket=$2\n\
daemon=$3\n\
shift 3\n\
exec \"$PMACS_REAL_GPU\" --headless-managed-probe \
\"$socket\" \"$PMACS_TEST_REPORT\" \"$daemon\" \"$@\"",
);
let output = Command::new(pmacs_binary())
.args(["--gpu", "--socket"])
.arg(&socket)
.arg("opened.txt")
.current_dir(&cwd)
.env(TEST_GPU_OVERRIDE, &wrapper)
.env("PMACS_REAL_GPU", gpu_binary())
.env("PMACS_TEST_REPORT", &report)
.env("HOME", temp.path())
.env("XDG_CONFIG_HOME", temp.path())
.output()
.expect("run one-command target flow");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let facts = parse_report(&report);
assert_eq!(facts.get("phase").map(String::as_str), Some("complete"));
assert_eq!(
facts
.get("server_protocol_version")
.and_then(|value| value.parse::<u32>().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::<InstanceMessage>(&mut same_alpha.stream)
.expect("read alpha broadcast")
{
InstanceMessage::CrdtOp { buffer_id, op } if buffer_id == alpha.buffer_id => {
same_alpha
.replica
.import_updates(&op.bytes)
.expect("import alpha broadcast");
break;
}
_ => {}
}
}
assert_eq!(same_alpha.replica.materialize_string(), "alpha\nunsaved");
let reopened = attach_target(&existing_socket, &cwd, &cwd.join("alpha.txt"));
assert_eq!(reopened.buffer_id, alpha.buffer_id);
assert_eq!(reopened.replica.materialize_string(), "alpha\nunsaved");
signal_pid(daemon.id(), Signal::SIGTERM);
assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success());
let spawned_socket = temp.path().join("spawned-target.sock");
let report = temp.path().join("target-report");
let mut probe = ManagedProbe::spawn_target(
&spawned_socket,
&report,
&pmacs_binary(),
temp.path(),
&cwd,
Path::new("beta.txt"),
);
let facts = probe.wait_ready();
assert_eq!(
facts.get("spawned_daemon").map(String::as_str),
Some("true")
);
assert!(probe.close().success());
}
#[test]
fn malformed_or_unloadable_targets_fail_closed_without_poisoning_the_daemon() {
let temp = secure_tempdir();
let socket = temp.path().join("target-failure.sock");
let mut daemon = spawn_daemon(&socket, &[]);
let cwd = temp.path().as_os_str().as_encoded_bytes().to_vec();
let invalid = [
(b"relative".to_vec(), b"note".to_vec()),
(cwd.clone(), Vec::new()),
(cwd.clone(), b"bad\0name".to_vec()),
(cwd.clone(), vec![b'x'; 32 * 1024 + 1]),
(cwd.clone(), b".".to_vec()),
];
for (index, (bad_cwd, bad_path)) in invalid.into_iter().enumerate() {
let (frontend_id, mut stream, messages) = open_raw_target(&socket, bad_cwd, bad_path);
assert_eq!(messages.len(), 1, "failure must send no snapshot");
match &messages[0] {
InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message }) => {
assert!(!message.is_empty());
assert!(message.len() <= 4 * 1024);
}
other => panic!("expected bounded target failure, got {other:?}"),
}
assert!(
read_message::<InstanceMessage>(&mut stream).is_err(),
"failed bootstrap {index} must close its socket"
);
let _ = write_message(
&mut stream,
&FrontendEvent::Key(pmacs::protocol::KeyEvent {
frontend_id,
key: pmacs::protocol::Key::Char('x'),
mods: pmacs::protocol::Modifiers::NONE,
timestamp_ns: 0,
}),
);
}
fs::write(temp.path().join("still-alive.txt"), "alive\n").expect("write survivor");
let survivor = attach_target(&socket, temp.path(), Path::new("still-alive.txt"));
assert_eq!(survivor.replica.materialize_string(), "alive\n");
signal_pid(daemon.id(), Signal::SIGTERM);
assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success());
}
#[test]
fn dedup_upgrade_publishes_the_snapshot_to_preexisting_grid_replicas() {
let temp = secure_tempdir();
let config_dir = temp.path().join("pmacs");
fs::create_dir(&config_dir).expect("create config dir");
let seed_path = temp.path().join("seed.txt");
let hidden_path = temp.path().join("hidden.txt");
fs::write(&seed_path, "seed\n").expect("write seed");
fs::write(&hidden_path, "hidden\n").expect("write hidden");
fs::write(
config_dir.join("init.lua"),
format!(
"local created_hidden = false\n\
pmacs.hook.add('buffer.after-load', function()\n\
if created_hidden then return end\n\
created_hidden = true\n\
pmacs.buffer.find_or_open({hidden_path:?})\n\
end)\n"
),
)
.expect("write hidden-buffer hook");
let socket = temp.path().join("dedup-upgrade.sock");
let mut daemon = spawn_daemon(&socket, &[]);
let (_, mut grid) = attach_surviving_frontend(&socket);
let seed = attach_target(&socket, temp.path(), Path::new("seed.txt"));
loop {
match read_message::<InstanceMessage>(&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::<InstanceMessage>(&mut grid).expect("grid hidden publication") {
InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot,
} if buffer_id == hidden.buffer_id => break crdt_snapshot,
_ => {}
}
};
let replica = CrdtState::new(900).expect("grid hidden replica");
replica
.import_snapshot(&hidden_snapshot)
.expect("import hidden publication");
assert_eq!(replica.materialize_string(), "hidden\n");
signal_pid(daemon.id(), Signal::SIGTERM);
assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success());
}
#[test]
fn target_killed_by_hook_fails_closed_and_slow_hook_holds_the_ready_barrier() {
let temp = secure_tempdir();
let kill_root = temp.path().join("kill-hook");
fs::create_dir(&kill_root).expect("create kill hook root");
fs::set_permissions(&kill_root, fs::Permissions::from_mode(0o700))
.expect("chmod kill hook root");
fs::create_dir(kill_root.join("pmacs")).expect("create kill config");
fs::write(
kill_root.join("pmacs/init.lua"),
"pmacs.hook.add('buffer.after-load', function()\n\
pmacs.buffer.kill(pmacs.window.buffer())\n\
end)\n",
)
.expect("write kill hook");
let kill_target = kill_root.join("victim.txt");
fs::write(&kill_target, "victim\n").expect("write victim");
let kill_socket = kill_root.join("daemon.sock");
let mut kill_daemon = spawn_daemon(&kill_socket, &[]);
let failed = request_raw_target(
&kill_socket,
kill_root.as_os_str().as_encoded_bytes().to_vec(),
b"victim.txt".to_vec(),
);
assert!(matches!(
failed.as_slice(),
[InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message })]
if message.contains("removed by a startup hook")
));
let _ = attach_surviving_frontend(&kill_socket);
signal_pid(kill_daemon.id(), Signal::SIGTERM);
assert!(wait_for_exit(&mut kill_daemon, Duration::from_secs(5)).success());
let slow_root = temp.path().join("slow-hook");
fs::create_dir(&slow_root).expect("create slow hook root");
fs::set_permissions(&slow_root, fs::Permissions::from_mode(0o700))
.expect("chmod slow hook root");
fs::create_dir(slow_root.join("pmacs")).expect("create slow config");
let marker = slow_root.join("hook-started");
fs::write(
slow_root.join("pmacs/init.lua"),
format!(
"pmacs.hook.add('buffer.after-load', function()\n\
local f = assert(io.open({marker:?}, 'w')); f:write('started'); f:close()\n\
os.execute('sleep 1')\n\
end)\n"
),
)
.expect("write slow hook");
fs::write(slow_root.join("slow.txt"), "slow\n").expect("write slow target");
let slow_socket = slow_root.join("daemon.sock");
let mut slow_daemon = spawn_daemon(&slow_socket, &[]);
let report = slow_root.join("report");
let fake_daemon = slow_root.join("must-not-spawn");
let mut probe = ManagedProbe::spawn_target(
&slow_socket,
&report,
&fake_daemon,
&slow_root,
&slow_root,
Path::new("slow.txt"),
);
let marker_deadline = Instant::now() + Duration::from_secs(5);
while !marker.exists() {
assert!(
Instant::now() < marker_deadline,
"slow hook never reached marker"
);
thread::sleep(Duration::from_millis(10));
}
assert!(
!report.exists()
|| parse_report(&report)
.get("phase")
.is_none_or(|phase| phase != "ready"),
"frontend reported ready while the startup hook was still blocked"
);
let facts = probe.wait_ready();
assert_eq!(
facts.get("spawned_daemon").map(String::as_str),
Some("false")
);
assert!(probe.close().success());
signal_pid(slow_daemon.id(), Signal::SIGTERM);
assert!(wait_for_exit(&mut slow_daemon, Duration::from_secs(5)).success());
}
#[test]
fn managed_attach_reuses_a_capable_daemon_without_spawning() {
let temp = secure_tempdir();

View File

@ -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

View File

@ -782,16 +782,16 @@ fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() {
// (the drop arm itself is pinned beside Frontend::apply_message).
#[test]
fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
// Vterm Stage 3 appended the terminal family as v19. This
// acceptance owns the STATUSLINE variant's placement and gate, so
// it tracks the current wire version rather than pinning 18: the
// v18 floor it actually cares about is asserted below and in
// `peer_accepts_statusline_message`.
assert_eq!(PROTOCOL_VERSION, 19);
for version in 6..=19 {
// Vterm Stage 3 appended the terminal family as v19; GPU initial targets
// appended the semantic bootstrap family as v20. This acceptance owns the
// STATUSLINE variant's placement and gate, so it tracks the current wire
// version rather than pinning 18: the v18 floor it actually cares about is
// asserted below and in `peer_accepts_statusline_message`.
assert_eq!(PROTOCOL_VERSION, 20);
for version in 6..=20 {
assert!(is_supported_protocol_version(version));
}
assert!(!is_supported_protocol_version(20));
assert!(!is_supported_protocol_version(21));
let sample = InstanceMessage::StatuslineSegments {
buffer_id: BufferId::from_raw(9),
left: vec![StatuslineSegment {

View File

@ -713,8 +713,8 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
assert_eq!(
facts.get("server_protocol_version").copied(),
Some("19"),
"the real daemon negotiated v19 with the real client: {text}"
Some("20"),
"the real daemon negotiated v20 with the real client: {text}"
);
assert_eq!(
facts.get("entered_terminal_mode").copied(),
@ -789,8 +789,8 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
#[test]
fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
use pmacs::protocol::{
AttachRequest, FrontendCapabilities, Hello, Key, KeyEvent, PROTOCOL_VERSION, read_message,
write_message,
AttachRequest, FrontendCapabilities, Hello, Key, KeyEvent, PROTOCOL_VERSION,
SessionBootstrapRequest, read_message, write_message,
};
use std::os::unix::net::UnixStream;
@ -816,6 +816,10 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
initial_size: CellSize::new(24, 80),
};
write_message(&mut stream, &req).expect("write AttachRequest");
if semantic {
write_message(&mut stream, &SessionBootstrapRequest::default())
.expect("write semantic bootstrap");
}
(hello, stream)
}
@ -839,7 +843,7 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
panic!("timed out waiting for {what}");
}
assert_eq!(PROTOCOL_VERSION, 19);
assert_eq!(PROTOCOL_VERSION, 20);
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
&[
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),