Merge pull request #148 from levineuwirth/gpu-initial-target
feat(gpu): open an initial file before window startup
This commit is contained in:
commit
0dd16a56e8
19
README.md
19
README.md
|
|
@ -11,7 +11,7 @@ coroutine-based async surface are core primitives, not bolt-ons.
|
|||
|
||||
The editor is partitioned into a long-lived **instance** (the daemon
|
||||
that owns buffers, processes, and language services) and thin
|
||||
**frontends** that attach over a typed protocol (currently v19). Two
|
||||
**frontends** that attach over a typed protocol (currently v20). Two
|
||||
frontends ship today:
|
||||
|
||||
- a **TUI** (crossterm cell grid), attachable locally over a Unix
|
||||
|
|
@ -32,7 +32,7 @@ the same buffers concurrently with live cursor/selection presence.
|
|||
**v1.0.0 --- stable core, active development.** The v1.0 gate (the
|
||||
instance/frontend partition, the Lua surface, and a REPL package audited
|
||||
to use zero direct Rust core access) shipped some time ago. Development
|
||||
since has expanded the semantic frontend protocol from v6 through v19,
|
||||
since has expanded the semantic frontend protocol from v6 through v20,
|
||||
brought the GPU frontend near input/render parity with the TUI, and
|
||||
completed the LSP, editing, persistence, themes, and terminal arcs. Recent
|
||||
work added major modes and modeline detection, a typed configuration
|
||||
|
|
@ -102,16 +102,19 @@ pmacs [FILE] # TUI; -nw reserved for when a GUI default lands
|
|||
GPU frontend (one command; the root binary starts or reuses the daemon):
|
||||
|
||||
```sh
|
||||
pmacs --gpu # default instance
|
||||
pmacs --gpu --socket NAME # named instance; bare NAME →
|
||||
pmacs --gpu # default instance; no initial file
|
||||
pmacs --gpu README.md # default instance; open one file
|
||||
pmacs --gpu --socket NAME FILE # named instance; bare NAME →
|
||||
# <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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
# Agent handoff — cross-machine continuity
|
||||
|
||||
**Last updated: 2026-07-24, after folding Stage 2 (#149, the grid/daemon
|
||||
collapse) landed with its ledger refresh (#147), following web grammars HTML+CSS
|
||||
(#146), the LaTeX Stage 1 / inline-math framing pair (#144/#145), folding
|
||||
Stage 1 (#142, the headless fold engine), one-command GPU invocation (#141), the
|
||||
documentation refresh (#140), Vterm Stage 3 (#135, protocol v19 and native GPU terminal),
|
||||
tab-width rendering parity (#137), locals-query processing (#134), modeline
|
||||
detection (#132), mode system wiring (#129), config registry (#127), Vterm
|
||||
Stages 1–2 (#126/#130), and completed Themes Arc 4 (#120/#124/#125).**
|
||||
**Last updated: 2026-07-24, after GPU initial-target PR #148 second-review
|
||||
fixes were integrated with folding Stage 2 (#149) and its landed-doc refresh
|
||||
(#150), following web grammars HTML+CSS (#146), the LaTeX Stage 1 / inline-math
|
||||
framing pair (#144/#145), folding Stage 1 (#142), one-command GPU invocation
|
||||
(#141), the documentation refresh (#140), Vterm Stage 3 (#135), tab-width
|
||||
rendering parity (#137), locals-query processing (#134), modeline detection
|
||||
(#132), mode system wiring (#129), config registry (#127), Vterm Stages 1–2
|
||||
(#126/#130), and completed Themes Arc 4 (#120/#124/#125).**
|
||||
This file is the
|
||||
bridge between development machines. If you are an agent reading
|
||||
this on a fresh clone: this document plus the `docs/*-framing.md`
|
||||
|
|
@ -21,11 +21,29 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
|
||||
## 1. Where the project stands (2026-07-24)
|
||||
|
||||
- `main` @ `6ed4fe9` (folding Stage 2 #149 + ledger refresh #147 atop web
|
||||
grammars #146, LaTeX Stage 1 #144 / inline-math framing #145, and folding
|
||||
Stage 1 #142), protocol **v19** (`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`,
|
||||
v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal
|
||||
frames/events).
|
||||
- `main` @ `b168dca` (folding Stage 2 landed-doc refresh #150 atop folding
|
||||
Stage 2 #149, ledger refresh #147, web grammars #146, LaTeX Stage 1 #144 /
|
||||
inline-math framing #145, and folding Stage 1 #142), protocol **v19**
|
||||
(`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 =
|
||||
`StatuslineSegments`, v19 = terminal frames/events).
|
||||
- **GPU INITIAL TARGET IMPLEMENTED — PR #148 under user review**
|
||||
(`docs/gpu-initial-target-framing.md` rev 3; branch `gpu-initial-target`).
|
||||
`pmacs --gpu [--socket NAME|PATH] FILE` now transports exact Unix path bytes
|
||||
plus launcher cwd to the managed GPU client. Protocol v20 adds a
|
||||
semantic-session `SessionBootstrapRequest` after `AttachRequest` and an
|
||||
appended `InitialTargetResult` readiness barrier; v6–v19 wire encodings stay
|
||||
pinned. The daemon resolves the path lexically, deduplicates or loads/creates
|
||||
it in the authenticated frontend's view, runs the established load/switch
|
||||
hooks, upgrades the buffer for CRDT, and publishes every target-side CRDT
|
||||
upgrade to existing grid replicas before readiness. Semantic replicas receive
|
||||
a publication only when displaying that buffer, so a second target launch
|
||||
cannot switch an existing GPU window; one dead peer cannot fail the new
|
||||
session. Failed bootstrap writes a bounded result, shuts down the socket,
|
||||
removes provisional state, and restores the ambient active frontend. Any
|
||||
stale event from an uninstalled session is dropped before state access.
|
||||
Existing no-target managed launch, direct attach, TUI, and legacy protocol
|
||||
behavior remain intact. See `docs/active-work.md` for the portable checkpoint
|
||||
and verification.
|
||||
- **Folding Stage 1 (headless fold engine) LANDED — #142**
|
||||
(`docs/folding-framing.md` rev 5; merge `c49a8c7`; three review rounds,
|
||||
round 3 clean). Arc 6's engine — instance-side and headless; **no frontend
|
||||
|
|
@ -613,13 +631,14 @@ and `range` are three INDEPENDENT capabilities — gate each.
|
|||
buffer owns a path's recovery slot; only recover/discard release
|
||||
unclaimed crash data; adopt clears the old owner's skip cache.
|
||||
|
||||
**Protocol** — encoding-breaking bumps are deliberate and versioned
|
||||
(`SUPPORTED=[6..=19]`). v15 = `CompletionPopup` +
|
||||
`StatusFacts.message`; v16 = `ThemeFacts`; v17 = `FontFacts`; v18 =
|
||||
`StatuslineSegments`; v19 = the vterm terminal family. New wire surface ⇒
|
||||
bump + both-frontends support + acceptance. An APPENDED variant must be
|
||||
guarded by a byte pin on the PREVIOUS final variant — its own round-trip
|
||||
cannot detect a discriminant shift.
|
||||
**Protocol** — encoding-breaking bumps are deliberate and versioned. Canonical
|
||||
`main` remains `[6..=19]`; the active GPU initial-target branch is
|
||||
`[6..=20]`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 =
|
||||
`ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm
|
||||
terminal family; v20 = semantic `SessionBootstrapRequest` plus appended
|
||||
`InitialTargetResult`. New wire surface ⇒ bump + both-frontends support +
|
||||
acceptance. An APPENDED variant must be guarded by a byte pin on the PREVIOUS
|
||||
final variant — its own round-trip cannot detect a discriminant shift.
|
||||
|
||||
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
|
||||
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,670 @@
|
|||
# GPU initial target — session-scoped file opening framing
|
||||
|
||||
**Revision 3 — implemented as built on branch `gpu-initial-target`, protocol
|
||||
v20, 2026-07-23. Revision 2 was approved against canonical `main` @ `4daa1b8`;
|
||||
the implementation base includes folding Stage 1 through `c49a8c7`.**
|
||||
|
||||
Revision 3 records the completed implementation and verification. Revision 2
|
||||
pinned launcher-owned tilde expansion, required `after-switch` even when dedup
|
||||
selects the view's existing buffer, failed bootstrap when a hook kills the
|
||||
target, and kept slow pre-window bootstrap terminal-only (no graphical
|
||||
progress surface). It also sharpened the observed argv panic and negotiated
|
||||
protocol-version echo.
|
||||
|
||||
One-command GPU startup landed in #141:
|
||||
|
||||
```sh
|
||||
pmacs --gpu
|
||||
pmacs --gpu --socket research
|
||||
```
|
||||
|
||||
The remaining daily-use gap is that the same path cannot open a file. This
|
||||
stage makes one positional target honest:
|
||||
|
||||
```sh
|
||||
pmacs --gpu README.md
|
||||
pmacs --gpu --socket research ../notes/today.md
|
||||
pmacs --gpu -- --name-beginning-with-dash
|
||||
```
|
||||
|
||||
“Honest” is load-bearing. The target belongs to the newly authenticated GPU
|
||||
session, not to the daemon's ambient/local view or whichever frontend most
|
||||
recently sent input. Relative paths resolve against the launcher's working
|
||||
directory even when an existing daemon has a different cwd. A new or reused
|
||||
file becomes the first buffer content the GPU can draw; scratch never flashes.
|
||||
Open failures return before a window is shown and make the root command fail.
|
||||
|
||||
This is one-file startup only. It does not make GUI the automatic default, add
|
||||
a general client/server `open` command, widen direct `pmacs-gpu --attach`, or
|
||||
solve installation, service management, remote GPU attach, or reconnect.
|
||||
|
||||
## Ground truth
|
||||
|
||||
### Root and GPU CLI
|
||||
|
||||
- `src/main.rs` already parses one positional `FILE` for local TUI mode and
|
||||
rejects multiple files. It accepts `--` before an option-like filename.
|
||||
- `Mode::Gpu` currently carries only `socket`; `parse_args` rejects every file
|
||||
paired with `--gpu` and points users to `C-x C-f`.
|
||||
- The root broker invokes the sibling/PATH GPU binary as
|
||||
`pmacs-gpu --managed-attach SOCKET DAEMON_EXE`, waits for its status, and
|
||||
reflects success/failure.
|
||||
- Both root and GPU parsers consume `std::env::args()` into `String`; Rust
|
||||
panics when an argument is not valid Unicode. They cannot admit a
|
||||
non-UTF-8 Unix filename or issue a useful parser error for one.
|
||||
- The direct GPU CLI is intentionally strict. Public direct attach remains
|
||||
`pmacs-gpu --attach RAW_SOCKET`; managed and headless forms are private
|
||||
root/acceptance seams.
|
||||
|
||||
### Handshake and session bootstrap
|
||||
|
||||
- The instance sends `Hello`; the frontend sends `AttachRequest`; then the
|
||||
per-attach thread immediately sends `DispatcherEvent::SessionEstablished`
|
||||
and becomes the reader for `FrontendEvent`s.
|
||||
- `AttachRequest` contains protocol version, capabilities, and initial cell
|
||||
size. It has no target. `FrontendEvent` has no open-path request.
|
||||
- The GPU copies `Hello.protocol_version` into `AttachRequest.protocol_version`
|
||||
rather than sending its own maximum. Every old-daemon gate therefore keys on
|
||||
the negotiated/echoed server version; changing that echo is not cleanup.
|
||||
- `handle_session_established` creates the authenticated frontend's own
|
||||
`FrontendView`, initially sharing the daemon-local active buffer, then sends
|
||||
CRDT snapshots and installs grid or semantic render state.
|
||||
- A semantic frontend follows whichever `BufferSnapshot` it most recently
|
||||
applied. The attach-time snapshot sweep currently sends every CRDT-backed
|
||||
buffer; a later tick repairs the “last snapshot was not my active buffer”
|
||||
ambiguity by re-sending the session's active buffer.
|
||||
- Managed GPU connect completes before winit creates the window, but normal
|
||||
instance messages are read asynchronously after that connect call. Merely
|
||||
adding an event after attach would race the first snapshot and first draw.
|
||||
|
||||
### File and view semantics already present
|
||||
|
||||
- Local `pmacs FILE` loads an existing file, or creates an empty path-backed
|
||||
buffer with `[new file]` status on `NotFound`; other I/O errors fail startup
|
||||
(`EditorState::open`).
|
||||
- `EditorCore::get_or_load_buffer` and Lua `pmacs.buffer.find_or_open` already
|
||||
establish the important dedup rule: an existing path-backed buffer wins, so
|
||||
unsaved edits are not replaced by a disk reload.
|
||||
- Buffer identity is an absolute, lexically normalized path. It deliberately
|
||||
does not canonicalize symlinks, so a not-yet-created file has a stable
|
||||
identity and the editor does not silently rewrite the user's spelling to a
|
||||
filesystem target.
|
||||
- `FrontendView`s are per-session. Switching one frontend's active window does
|
||||
not switch any sibling frontend.
|
||||
- Load/switch hooks require `EditorCore::active_frontend` to name the source
|
||||
before Lua runs; otherwise `pmacs.window.*` resolves against the wrong view.
|
||||
|
||||
### Replica composition constraint
|
||||
|
||||
A target loaded during attach may be a brand-new, non-CRDT buffer. The new GPU
|
||||
needs its snapshot, but every already-attached replica must also learn that
|
||||
buffer before later CRDT operations can reference it. Calling the current
|
||||
one-stream `send_buffer_snapshots` helper can upgrade the new buffer for the
|
||||
new GPU while leaving existing replicas unaware: the later lazy-upgrade sweep
|
||||
then sees an already-backed buffer and has nothing to broadcast. The initial-
|
||||
target transaction must therefore use the same all-replica publication
|
||||
invariant as any other mid-session buffer creation.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. **Authenticated source owns the target.** No client-supplied frontend id,
|
||||
daemon-local active view, or ambient “last frontend” selects the window.
|
||||
2. **Launcher environment owns path resolution.** Root applies the existing
|
||||
leading-tilde rule with the launcher's `$HOME`; any still-relative result
|
||||
resolves against the launcher cwd. Daemon cwd/HOME are irrelevant.
|
||||
3. **Path bytes survive defined resolution.** Apart from that deliberate
|
||||
root-side tilde substitution, Unix argv → broker → GPU → wire → daemon file
|
||||
I/O preserves exact `OsStr` bytes. Display text may be lossy; backing
|
||||
identity and filesystem access may not be.
|
||||
4. **One target, one buffer identity.** Reuse an already-open normalized path;
|
||||
never discard unsaved edits by reloading it.
|
||||
5. **Target before first draw.** The first buffer content eligible to render is
|
||||
the requested target. The explicit terminal launcher may report the wait on
|
||||
stderr; no editor window exists yet, and scratch content is never drawable.
|
||||
6. **Ready means usable.** Success is reported only after the target buffer is
|
||||
loaded/created, selected in the authenticated view, CRDT-backed, and its
|
||||
matching snapshot has been written to the GPU.
|
||||
7. **Failure is pre-window and fail-closed.** Non-`NotFound` I/O, malformed
|
||||
bootstrap data, CRDT upgrade/export, or target snapshot failure produces no
|
||||
successful session and no window.
|
||||
8. **Existing sessions remain coherent.** A fresh target is published to all
|
||||
negotiated replica sessions; another frontend's active window never moves.
|
||||
9. **No legacy wire drift.** Protocols v6–v19 retain their exact
|
||||
`AttachRequest`, `FrontendEvent`, and `InstanceMessage` encodings.
|
||||
10. **No lifecycle regression.** Existing-daemon reuse, bounded daemon startup,
|
||||
process-group isolation, named child reaping, and root exit propagation
|
||||
stay owned by #141's managed path.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Q#GT1 — Public grammar accepts exactly one GPU target
|
||||
|
||||
The public shape becomes:
|
||||
|
||||
```text
|
||||
pmacs --gpu [--socket NAME|PATH] [--] [FILE]
|
||||
```
|
||||
|
||||
- `FILE` is optional and may appear before or after `--gpu` / `--socket`, as the
|
||||
existing single-pass parser already permits for local mode.
|
||||
- `--` makes the next and only remaining operand a literal filename, including
|
||||
one beginning with `-`.
|
||||
- A second positional remains `multiple files not yet supported`.
|
||||
- `--gpu FILE` remains mutually exclusive with `-nw` / `--no-window`,
|
||||
`--daemon`, `--attach`, and `--daemon-attach` under the existing mode rules.
|
||||
- `Mode::Gpu` gains `file: Option<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`.
|
||||
- v6–v19 peers neither send nor read it. Non-semantic v20 grid/TUI sessions
|
||||
keep the existing two-message handshake and do not wait on an irrelevant
|
||||
semantic startup envelope.
|
||||
- The target carries no `FrontendId`; the accepted stream and assigned session
|
||||
are the authority.
|
||||
- `cwd` and `path` are Unix path bytes, not text. This stage is the local Unix-
|
||||
socket GPU path; it does not claim a cross-platform/remote path protocol.
|
||||
- Postcard decodes each field under the transport's 16 MiB frame cap; daemon
|
||||
validation then bounds each to 32 KiB before filesystem use. `path` must be
|
||||
nonempty, `cwd` must be nonempty and absolute, and embedded NUL is rejected
|
||||
with a bootstrap failure.
|
||||
|
||||
A second message rather than an appended `AttachRequest` field keeps every
|
||||
legacy postcard shape mechanically unchanged. A post-handshake
|
||||
`FrontendEvent::OpenPath` is deliberately not used: it cannot precede session
|
||||
bootstrap and therefore cannot guarantee first-draw ordering.
|
||||
|
||||
### Q#GT5 — The daemon resolves and opens inside one dispatcher transaction
|
||||
|
||||
The per-attach thread validates the v20 bootstrap envelope structurally, moves
|
||||
it into `DispatcherEvent::SessionEstablished`, and starts its reader exactly
|
||||
where it does today. It performs no filesystem or editor work.
|
||||
|
||||
The dispatcher, which exclusively owns `EditorState`, performs this target
|
||||
transaction without yielding to another event:
|
||||
|
||||
1. Register the new frontend's view and set `active_frontend` to the
|
||||
authenticated `FrontendId`.
|
||||
2. Accept root's already tilde-expanded `path`; if it is still relative, join
|
||||
it to the supplied launcher `cwd`, then lexically normalize without
|
||||
canonicalizing symlinks. The daemon never consults or re-applies `$HOME`.
|
||||
3. Reuse an existing buffer with that normalized backing path; otherwise load
|
||||
the file; on `NotFound`, create an empty path-backed buffer and set
|
||||
`[new file]`; on any other error, take the failure path below.
|
||||
4. Select that buffer in only the new frontend's active window.
|
||||
5. Fire `buffer.after-switch` exactly once on every dedup, including when the
|
||||
fresh view already shares that `BufferId` and selection is a same-buffer
|
||||
no-op. Fire `buffer.after-load` on a fresh disk load. Both run with the
|
||||
authenticated frontend active; a newly created missing file matches local
|
||||
startup and fires neither load nor switch hook.
|
||||
6. After hooks, verify the target `BufferId` is still live. A listener that
|
||||
killed it causes the fail-closed bootstrap path in Q#GT9. Otherwise reassert
|
||||
the requested target so configuration can inspect the right session but
|
||||
cannot make `pmacs --gpu FILE` acknowledge a different buffer.
|
||||
7. Establish CRDT/snapshot coherence, then acknowledge readiness.
|
||||
|
||||
The target-opening helper must be Rust/editor-core state, not synthetic keys
|
||||
or a call through the user-facing Lua binding. Lua hooks remain policy
|
||||
observers; they are not the transport implementation.
|
||||
|
||||
### Q#GT6 — Dedup preserves edits; new-file behavior matches local startup
|
||||
|
||||
- An already-open normalized path reuses its `BufferId`, current contents,
|
||||
modified flag, and metadata. Disk is not read again.
|
||||
- A disk file not already open is loaded once and receives its normalized
|
||||
backing path and `FileMeta`.
|
||||
- Any `NotFound` from the initial load creates an empty path-backed buffer,
|
||||
including when a parent is currently absent; save-time errors remain
|
||||
save-time errors, matching local `pmacs FILE`.
|
||||
- `PermissionDenied`, `IsADirectory`, invalid path bytes at the OS boundary,
|
||||
and other non-`NotFound` errors fail startup.
|
||||
- The buffer display name may use `Path::display()` and therefore replacement
|
||||
characters; this must never replace the raw backing path used for dedup,
|
||||
load, or save.
|
||||
|
||||
### Q#GT7 — Initial-target semantic bootstrap is active-buffer-only
|
||||
|
||||
A v20 semantic session with `initial_target = Some` does **not** receive the
|
||||
legacy “every buffer, then repair active on the next tick” sweep. It receives
|
||||
exactly the requested active buffer's `BufferSnapshot` before readiness.
|
||||
Semantic GPU state holds one active rope and already receives a fresh snapshot
|
||||
when its daemon-side view switches; shipping unrelated buffers only creates a
|
||||
last-snapshot ambiguity and avoidable work.
|
||||
|
||||
If the target was newly created or newly loaded and not yet CRDT-backed:
|
||||
|
||||
- upgrade it once using the daemon-owned CRDT peer;
|
||||
- export one authoritative snapshot;
|
||||
- publish that buffer/snapshot to every already-attached session that
|
||||
negotiated `crdt_replica` before later operations can name it;
|
||||
- write the same logical snapshot to the new GPU without reloading the file or
|
||||
creating a second buffer.
|
||||
|
||||
The implementation should avoid cloning snapshot bytes per peer beyond what
|
||||
framed serialization/write ownership requires. Existing no-target attach and
|
||||
non-semantic replica bootstrap remain unchanged.
|
||||
|
||||
### Q#GT8 — A v20 result is the pre-window readiness barrier
|
||||
|
||||
Append a v20-only `InstanceMessage::InitialTargetResult` variant with an
|
||||
explicit result shape:
|
||||
|
||||
```rust
|
||||
pub enum InitialTargetResult {
|
||||
Opened { buffer_id: BufferId },
|
||||
Failed { message: String },
|
||||
}
|
||||
```
|
||||
|
||||
Only a v20 semantic session that requested `Some(initial_target)` may receive
|
||||
it.
|
||||
|
||||
Success ordering on the daemon write stream is:
|
||||
|
||||
1. target `BufferSnapshot` written successfully;
|
||||
2. new session state/render state/stream installed;
|
||||
3. `InitialTargetResult::Opened` written;
|
||||
4. normal per-tick messages/events begin.
|
||||
|
||||
The GPU connector synchronously reads this bootstrap prefix before returning.
|
||||
It retains the matching target snapshot as structured bootstrap state,
|
||||
validates that `Opened.buffer_id` matches it, and only then starts the ordinary
|
||||
reader thread. The winit path applies that snapshot before its first redraw.
|
||||
It does not forward unrelated pre-ready buffer state through the event proxy.
|
||||
Thus no target success can mean “window exists, load may still fail,” and no
|
||||
scratch snapshot can become drawable.
|
||||
|
||||
`Failed` becomes an `AttachClientError` containing the local display path and
|
||||
daemon detail. Managed startup returns nonzero; root reflects that status.
|
||||
The daemon itself remains alive, whether reused or newly spawned.
|
||||
|
||||
Because the connector waits before winit creates a window, slow dispatcher
|
||||
work has no graphical “Connecting…” surface. The managed GPU child waits
|
||||
silently and reports a failure on stderr if bootstrap fails. There is no
|
||||
second target timeout beyond #141's bounded daemon-start retry; file I/O and
|
||||
user hooks may legitimately exceed five seconds, and timing out the client
|
||||
would not cancel dispatcher work. Ctrl-C remains the escape hatch and still
|
||||
cannot reach the isolated daemon process group.
|
||||
|
||||
### Q#GT9 — Failure cleanup never creates a ghost session
|
||||
|
||||
On any target failure before readiness, the dispatcher:
|
||||
|
||||
- writes `InitialTargetResult::Failed` when the stream is usable;
|
||||
- removes the provisional frontend view, render state, session-registry entry,
|
||||
size/baseline entries, and stream entry if any were installed;
|
||||
- shuts down the connection so the per-attach reader wakes and emits at most
|
||||
idempotent detach cleanup;
|
||||
- treats a target `BufferId` killed by `buffer.after-load` or
|
||||
`buffer.after-switch` as a bootstrap failure, never reasserts a stale id,
|
||||
and performs the same provisional-session cleanup;
|
||||
- leaves every pre-existing buffer/view/session unchanged, except that a file
|
||||
successfully loaded before a later CRDT/export failure may remain as an
|
||||
ordinary daemon buffer. It must not become another frontend's active view.
|
||||
|
||||
The result error string is bounded (4 KiB) and user-facing. It includes the
|
||||
operation and OS error but never lossy-converts and then reuses the displayed
|
||||
path for filesystem access.
|
||||
|
||||
### Q#GT10 — Compatibility is directional and tested
|
||||
|
||||
- New daemon + v6–v19 client: exact legacy handshake; no bootstrap read, no
|
||||
v20 result, no new enum discriminant.
|
||||
- New GPU without target + supported v6–v19 daemon: exact #141 behavior.
|
||||
- New GPU **with** target + v6–v19 daemon: fail immediately after `Hello` with
|
||||
“initial targets require protocol v20”; do not authorize/spawn a replacement
|
||||
for the live daemon.
|
||||
- New daemon + v20 non-semantic client: legacy handshake; the initial-target
|
||||
envelope is a semantic-session contract.
|
||||
- New daemon + v20 semantic client: bootstrap envelope required, including
|
||||
`None`; missing/malformed bootstrap closes only that connection.
|
||||
- `InitialTargetResult` is appended after all v19 `InstanceMessage` variants
|
||||
and independently filtered from negotiated `< 20` streams. Existing
|
||||
encoding pins for v6–v19 messages remain unchanged; new pins lock the v20
|
||||
discriminant and bootstrap round trip.
|
||||
|
||||
No new capability bit is needed. Protocol version and negotiated
|
||||
`semantic_render` jointly identify the state machine; a bit would add a second
|
||||
source of truth for a mandatory v20 semantic handshake step.
|
||||
|
||||
## Startup state machine
|
||||
|
||||
```text
|
||||
root parses FILE bytes, expands eligible `~` with launcher HOME, captures cwd
|
||||
|
|
||||
+-- spawn/await pmacs-gpu managed child
|
||||
|
|
||||
+-- connect existing socket or start/retry daemon (#141 unchanged)
|
||||
|
|
||||
+-- Hello < 20 and target? ----> fail; never replace live daemon
|
||||
|
|
||||
+-- Hello >= 20
|
||||
send AttachRequest
|
||||
send SessionBootstrapRequest { initial_target }
|
||||
|
|
||||
+-- daemon dispatcher resolves/opens in source view
|
||||
| |
|
||||
| +-- error --> Failed + connection shutdown
|
||||
| |
|
||||
| +-- success
|
||||
| publish fresh buffer to replicas
|
||||
| write target BufferSnapshot
|
||||
| install session
|
||||
| write Opened { buffer_id }
|
||||
|
|
||||
connector validates snapshot/result pair
|
||||
create GPU state/window
|
||||
apply target snapshot before first redraw
|
||||
```
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
### Send `FrontendEvent::OpenPath` after attach
|
||||
|
||||
Rejected for initial startup. The daemon has already established the scratch
|
||||
view and may have sent snapshots before the event can arrive. Correlation ids,
|
||||
async results, and live session commands will be appropriate for a future
|
||||
general client/server open API, but they do not provide a pre-window barrier
|
||||
without duplicating the bootstrap state machine.
|
||||
|
||||
### Put the file only on the spawned daemon command line
|
||||
|
||||
Rejected. It cannot affect an already-running daemon and would target the
|
||||
daemon-local view rather than the authenticated GPU view.
|
||||
|
||||
### Open globally, then declare a viewport for that buffer
|
||||
|
||||
Rejected. `Viewport` is a rendering declaration whose buffer must already be
|
||||
known to the replica. Treating it as an open command confuses state alignment
|
||||
with filesystem authority and can switch the wrong view under races.
|
||||
|
||||
### Drive `C-x C-f` / minibuffer with synthetic keys
|
||||
|
||||
Rejected. It depends on user keymaps and prompts, loses raw path bytes, cannot
|
||||
report a structured startup result, and visibly renders intermediate state.
|
||||
|
||||
### Resolve relative paths in the daemon cwd
|
||||
|
||||
Rejected. A long-lived daemon's cwd describes when it was started, not where a
|
||||
later launcher invoked `pmacs --gpu FILE`.
|
||||
|
||||
### UTF-8 path strings
|
||||
|
||||
Rejected for the local Unix contract. They silently exclude valid filenames
|
||||
and would make GPU startup weaker than `PathBuf`-based local editing.
|
||||
|
||||
### General multi-file/open-command protocol now
|
||||
|
||||
Rejected. Multiple targets need ordering, active-target choice, per-target
|
||||
results, and behavior for an already-running client. This stage deliberately
|
||||
pins the one initial target needed by `pmacs --gpu FILE`.
|
||||
|
||||
## Scope and touch map
|
||||
|
||||
As-built implementation surface:
|
||||
|
||||
- `src/main.rs`
|
||||
- `args_os` parser, `Mode::Gpu { file, socket }`, help/grammar, exact private
|
||||
child argv, cwd capture, exit propagation.
|
||||
- `pmacs-gpu/src/main.rs`
|
||||
- strict `OsString` path parser, optional private initial-target operands,
|
||||
managed/headless plumbing, pre-first-redraw bootstrap application.
|
||||
- `pmacs-gpu/src/attach.rs`
|
||||
- v20 semantic bootstrap send, target-version gate, synchronous
|
||||
snapshot/result barrier, structured errors; #141 connector/start/reaper
|
||||
policy otherwise unchanged.
|
||||
- `pmacs-protocol/src/message.rs`, `pmacs-protocol/src/lib.rs`
|
||||
- protocol v20, `SessionBootstrapRequest`, `InitialTarget`, result wire type,
|
||||
appended `InstanceMessage` variant, bounds and documentation.
|
||||
- `src/daemon.rs`
|
||||
- conditional v20 bootstrap read, dispatcher payload, source-scoped target
|
||||
transaction, active-only semantic bootstrap, all-replica publication,
|
||||
result ordering, failure cleanup, `< 20` send filter.
|
||||
- `src/editor_core.rs` and/or a small existing file-open helper
|
||||
- cwd-aware lexical normalization, dedup/load/create without ambient-view
|
||||
selection. Do not add a parallel path-identity convention.
|
||||
- `src/protocol.rs`, `pmacs-protocol/src/transport.rs`
|
||||
- version ladder, legacy placement pins, v20 round-trip/limit tests.
|
||||
- `tests/gpu_initial_target_acceptance.rs` (new) and focused existing GPU
|
||||
invocation/protocol tests
|
||||
- real subprocess/session behavior below.
|
||||
- `README.md`, this framing's as-built tail, `docs/agent-handoff.md`, and
|
||||
`docs/active-work.md` per their update protocols after implementation works.
|
||||
|
||||
Explicit non-touch unless evidence forces it:
|
||||
|
||||
- no renderer/layout/font/shader changes;
|
||||
- no `builtin/runtime` keymap or command changes;
|
||||
- no socket resolver, daemon lock, process-group, retry, or reaper policy
|
||||
changes;
|
||||
- no package/install/service files;
|
||||
- no remote attach transport changes.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Behavioral acceptance uses real built subprocesses where CLI, cwd, socket,
|
||||
handshake, process lifetime, or raw argv is the contract. Pure parser/path/wire
|
||||
helpers receive unit tests; source-text assertions do not substitute for
|
||||
process behavior.
|
||||
|
||||
1. **Root grammar:** `pmacs --gpu FILE`, `pmacs FILE --gpu`,
|
||||
`pmacs --gpu --socket research FILE`, and `pmacs --gpu -- -leading` select
|
||||
one managed GPU target. Multiple files and every existing conflicting mode
|
||||
exit 2 with the conflicting argument/mode named. No-file `pmacs --gpu`
|
||||
remains accepted.
|
||||
2. **Raw argv forwarding:** a Unix invalid-UTF-8 filename survives the real
|
||||
root parser and reaches a fake GPU child byte-for-byte alongside the exact
|
||||
launcher cwd. Invalid-UTF-8 option/socket/attach-target text fails with a
|
||||
pointed usage error rather than panic or replacement characters.
|
||||
3. **Private GPU grammar:** managed and headless modes accept either their
|
||||
unchanged no-target arity or exactly `--initial-target CWD FILE`; missing,
|
||||
trailing, or duplicated forms exit 2. An option-like `FILE` after the marker
|
||||
remains literal. A relative `CWD` is syntactically accepted and then fails
|
||||
the daemon's bootstrap validation before readiness.
|
||||
4. **v20 wire and legacy pins:** bootstrap request/result round trips preserve
|
||||
arbitrary Unix bytes and enforce bounds. Every pinned v6–v19 encoding stays
|
||||
unchanged; the new result discriminant is appended. The supported ladder is
|
||||
exactly `[6, …, 20]`.
|
||||
5. **No-target compatibility:** rebuilt `pmacs --gpu` attaches to a v19
|
||||
fixture/daemon without sending the v20 bootstrap frame and retains #141's
|
||||
existing/missing-daemon behavior. A v20 semantic attach sends `None` and
|
||||
reaches its normal first snapshot.
|
||||
6. **Old live daemon refusal:** target mode against a real/fake supported v19
|
||||
daemon fails with the protocol-v20 requirement, invokes no daemon spawner,
|
||||
leaves the socket/process untouched, and creates no GPU window.
|
||||
7. **Existing-daemon path authority:** start a real CRDT daemon from cwd/HOME
|
||||
A; launch the real headless managed GPU target from cwd/HOME B with
|
||||
`sub/file.txt`; require contents from B, not A. Repeat with a shell-quoted
|
||||
`~/file.txt` after that `$HOME_B/file.txt` buffer is already open, and
|
||||
require the same `BufferId`, proving root-side expansion and dedup.
|
||||
8. **Missing-daemon target:** from no socket/lock, the production managed path
|
||||
starts one daemon, opens the target, reaches ready, and leaves that daemon
|
||||
connectable after the probe/window exits. Repeating reuses it and creates no
|
||||
replacement.
|
||||
9. **New file:** a nonexistent target produces an empty snapshot, `[new file]`
|
||||
status/path identity, accepts an edit/save through the real session, and
|
||||
creates the requested file under the launcher cwd—not the daemon cwd.
|
||||
10. **Open error:** a directory/permission-denied target returns a specific
|
||||
failure before ready/window creation and makes root fail. The daemon shuts
|
||||
down that failed session's socket; a client that lingers or sends another
|
||||
event cannot reach uninstalled session state. An existing daemon remains
|
||||
connectable; a pre-existing frontend's active buffer and contents remain
|
||||
unchanged.
|
||||
11. **Dedup preserves unsaved edits:** frontend A opens and modifies a file
|
||||
without saving; target-launch frontend B opens the same normalized path and
|
||||
receives A's authoritative unsaved text with the same `BufferId`, not disk
|
||||
contents or a duplicate buffer.
|
||||
12. **Per-session isolation:** frontend A starts on buffer A while concurrent
|
||||
target launches B and C open different files. Each result/snapshot pair
|
||||
names its own view; a subsequent input/resize proof shows A, B, and C remain
|
||||
independently usable on their original buffers.
|
||||
13. **Replica publication after target upgrade:** keep grid replica A attached,
|
||||
then target-launch B onto a previously unknown file. Repeat for a file that
|
||||
Lua loaded into a hidden, not-yet-CRDT-backed buffer before the target dedup.
|
||||
A receives each buffer snapshot before any CRDT op for it; both replicas
|
||||
accept later operations without unknown-buffer fallback or disconnect.
|
||||
14. **Hook context and count:** fresh disk load fires `buffer.after-load` once;
|
||||
dedup fires `buffer.after-switch` once even when the fresh view already
|
||||
shares that exact buffer and the select itself is a no-op; missing-file
|
||||
creation fires neither hook. Each hook observes the authenticated frontend
|
||||
and requested buffer, and the target remains selected afterward. A fixture
|
||||
that kills the target inside either hook yields `Failed`, no ready phase,
|
||||
and no stale-id reassertion.
|
||||
15. **Pre-window barrier:** the headless seam records that the only initial
|
||||
semantic `BufferSnapshot` is the target and precedes matching `Opened`.
|
||||
Injected load/export failure records no ready phase. A window-path unit seam
|
||||
proves bootstrap state is applied before the first redraw request. A held
|
||||
dispatcher fixture proves the bounded stderr notice appears while no
|
||||
window/ready result exists, then succeeds after release without a separate
|
||||
target timeout.
|
||||
16. **Concurrent same-target launch:** two target launchers racing an absent
|
||||
daemon and the same file converge on one daemon, one buffer identity, two
|
||||
ready sessions, and the existing #141 losing-daemon child is reaped.
|
||||
17. **Existing direct/probe paths:** `pmacs-gpu --attach RAW_PATH`, the no-target
|
||||
managed probe, Vterm Stage 3's headless probe, TUI attach, protocol/capability
|
||||
mismatch preservation, Ctrl-C daemon isolation, and post-attach reaping
|
||||
remain green.
|
||||
18. **Visible smoke:** on the real Wayland/Vulkan workstation, run
|
||||
`target/release/pmacs --gpu README.md`; the first editor content shown is
|
||||
README (never scratch), editing works, closing the window leaves the daemon
|
||||
alive, and a second invocation from another cwd opens that cwd's target in
|
||||
a new session without moving an already-attached frontend.
|
||||
19. **Executable documentation:** the coherent workspace build from README
|
||||
still produces sibling binaries; all documented target commands parse;
|
||||
no command requires spelling the resolved socket pathname.
|
||||
|
||||
## Required implementation gates
|
||||
|
||||
After the visible/behavioral path works, run the repository gates independently
|
||||
as required by `AGENTS.md`:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --lib
|
||||
cargo test --lib --features crdt
|
||||
cargo test --test gpu_initial_target_acceptance
|
||||
cargo test --features crdt --test gpu_initial_target_acceptance
|
||||
cargo test --test m4_acceptance -- --skip basedpyright
|
||||
PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Also rerun the touched GPU invocation suite, protocol/transport tests, and
|
||||
Vterm Stage 3 acceptance in default and CRDT configurations where the suite
|
||||
supports both. The final full workspace sweep remains required before PR.
|
||||
|
||||
The named gate intentionally reuses the managed-lifecycle acceptance module,
|
||||
so a workspace sweep executes those 14 CRDT cases under both test-binary
|
||||
names. The duplicate runtime is retained to keep the approved named command
|
||||
and the complete #141 lifecycle fixture coverage together.
|
||||
|
||||
Post-second-review verification on 2026-07-24:
|
||||
|
||||
- `cargo fmt --check` and strict workspace Clippy passed.
|
||||
- Library gates passed 1,815 default and 1,992 CRDT tests.
|
||||
- The named initial-target gate passed 1 default and 14 CRDT tests; the
|
||||
underlying GPU invocation suite passed 14 CRDT tests.
|
||||
- Folding Stage 2 passed 48 CRDT tests after integrating current `main`.
|
||||
- M4 passed 121 tests with the documented basedpyright skip; required real-GPU
|
||||
tests passed 152.
|
||||
- Vterm Stage 3 passed 5 default and 7 CRDT tests.
|
||||
- The isolated-config workspace CRDT sweep passed 3,334 tests across 88 suites,
|
||||
with 29 ignored and the documented basedpyright case filtered.
|
||||
- A coherent release build launched two concurrent real Wayland/Vulkan GPU
|
||||
windows on one daemon, targeting distinct `alpha` and `beta` files. Both
|
||||
remained visible on their own buffer after the second target attached.
|
||||
|
||||
## Deferred (named)
|
||||
|
||||
- **Multiple initial files.** Needs result ordering, active choice, and partial
|
||||
failure policy; do not turn `Option<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.
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1137,6 +1137,12 @@ pub enum InstanceMessage {
|
|||
/// Appended after [`Self::StatuslineSegments`], the final v18
|
||||
/// variant, so no existing postcard discriminant moves.
|
||||
TerminalFrame(crate::terminal::TerminalFrame),
|
||||
/// GPU initial-target bootstrap result (protocol v20). Sent only to a
|
||||
/// semantic session that supplied [`SessionBootstrapRequest::initial_target`].
|
||||
///
|
||||
/// Appended after [`Self::TerminalFrame`], the final v19 variant, so no
|
||||
/// legacy postcard discriminant moves.
|
||||
InitialTargetResult(InitialTargetResult),
|
||||
}
|
||||
|
||||
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
|
||||
|
|
@ -1553,7 +1559,13 @@ pub enum ResourceBody {
|
|||
/// with no terminal surface at all. This is the first bump to gate in
|
||||
/// BOTH directions at once, which is why criterion 28 pins the two
|
||||
/// send filters independently.
|
||||
pub const PROTOCOL_VERSION: u32 = 19;
|
||||
///
|
||||
/// GPU initial target (Q#GT4): bumped 19 → 20 for the semantic-session
|
||||
/// bootstrap envelope and [`InstanceMessage::InitialTargetResult`]. The
|
||||
/// handshake extension is read only from v20 semantic sessions; the result is
|
||||
/// sent only when such a session requested a target. v6–v19 handshakes and
|
||||
/// message discriminants remain unchanged.
|
||||
pub const PROTOCOL_VERSION: u32 = 20;
|
||||
|
||||
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
|
||||
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
|
||||
|
|
@ -1627,8 +1639,12 @@ pub const PROTOCOL_VERSION: u32 = 19;
|
|||
/// additive in both directions — `TerminalFrame` is daemon-gated,
|
||||
/// `TerminalResize` / `TerminalPointer` are frontend-gated — so v18 and
|
||||
/// v19 binaries interoperate with terminal traffic simply absent.
|
||||
///
|
||||
/// GPU initial target (Q#GT4): extended to `[6, ..., 20]`. v20 semantic
|
||||
/// sessions send a bounded bootstrap envelope after `AttachRequest`; legacy
|
||||
/// and non-semantic sessions retain their existing handshake shape.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] =
|
||||
&[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
||||
&[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
|
||||
|
||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
||||
|
|
@ -2038,3 +2054,47 @@ pub struct AttachRequest {
|
|||
/// instance uses this for the initial full-grid render.
|
||||
pub initial_size: CellSize,
|
||||
}
|
||||
|
||||
/// Maximum byte length of either raw Unix path in an initial-target request.
|
||||
pub const MAX_INITIAL_TARGET_PATH_BYTES: usize = 32 * 1024;
|
||||
|
||||
/// Maximum UTF-8 byte length of a daemon-produced initial-target error.
|
||||
pub const MAX_INITIAL_TARGET_ERROR_BYTES: usize = 4 * 1024;
|
||||
|
||||
/// Raw local paths for a semantic session's pre-window initial target.
|
||||
///
|
||||
/// Both fields are Unix path bytes rather than display text. The daemon
|
||||
/// validates the byte bounds, absolute `cwd`, nonempty fields, and embedded
|
||||
/// NULs before constructing paths.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct InitialTarget {
|
||||
/// Absolute launcher working-directory bytes.
|
||||
pub cwd: Vec<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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
554
src/daemon.rs
554
src/daemon.rs
|
|
@ -49,7 +49,10 @@
|
|||
//! - Write fails (broken pipe) → return; ungraceful disconnect.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::io::ErrorKind;
|
||||
use std::net::Shutdown;
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
|
@ -64,9 +67,10 @@ use crate::lockfile::{self, LockError, LockHandle};
|
|||
use crate::presence::{PresenceSnapshot, SessionRegistry};
|
||||
use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm};
|
||||
use crate::protocol::{
|
||||
AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InstanceCapabilities,
|
||||
InstanceIdentity, InstanceMessage, InstanceSignal, PROTOCOL_VERSION, PointerKind,
|
||||
SelectionSnapshot,
|
||||
AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget,
|
||||
InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal,
|
||||
MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PROTOCOL_VERSION, PointerKind,
|
||||
SelectionSnapshot, SessionBootstrapRequest,
|
||||
};
|
||||
use crate::socket_path::{SocketPathError, ensure_runtime_subdir};
|
||||
use crate::transport::{read_message, write_message};
|
||||
|
|
@ -114,6 +118,8 @@ enum DispatcherEvent {
|
|||
frontend_id: FrontendId,
|
||||
session_state: crate::presence::SessionState,
|
||||
initial_size: CellSize,
|
||||
/// Validated protocol-v20 semantic bootstrap target, if requested.
|
||||
initial_target: Option<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(®istry, FrontendId(99)));
|
||||
}
|
||||
|
||||
#[cfg(feature = "crdt")]
|
||||
#[test]
|
||||
fn snapshot_publication_skips_foreign_semantic_views_and_ignores_dead_peers() {
|
||||
let mut editor = EditorState::new();
|
||||
let semantic_peer = FrontendId(20);
|
||||
let live_grid_peer = FrontendId(21);
|
||||
let dead_grid_peer = FrontendId(22);
|
||||
let semantic_view = build_fresh_frontend_view(&mut editor, false);
|
||||
editor
|
||||
.core
|
||||
.borrow_mut()
|
||||
.register_frontend_view(semantic_peer, semantic_view);
|
||||
|
||||
let semantic_caps = crate::protocol::NegotiatedCapabilities {
|
||||
multi_frontend: true,
|
||||
crdt_replica: true,
|
||||
semantic_render: true,
|
||||
};
|
||||
let grid_caps = crate::protocol::NegotiatedCapabilities {
|
||||
semantic_render: false,
|
||||
..semantic_caps
|
||||
};
|
||||
let mut registry = SessionRegistry::new();
|
||||
registry.register_session(
|
||||
semantic_peer,
|
||||
crate::presence::SessionState::new(PROTOCOL_VERSION, semantic_caps, 0),
|
||||
);
|
||||
registry.register_session(
|
||||
live_grid_peer,
|
||||
crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 1),
|
||||
);
|
||||
registry.register_session(
|
||||
dead_grid_peer,
|
||||
crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 2),
|
||||
);
|
||||
|
||||
let (semantic_server, mut semantic_client) =
|
||||
UnixStream::pair().expect("semantic socketpair");
|
||||
let (live_grid_server, mut live_grid_client) =
|
||||
UnixStream::pair().expect("live grid socketpair");
|
||||
let (dead_grid_server, dead_grid_client) =
|
||||
UnixStream::pair().expect("dead grid socketpair");
|
||||
drop(dead_grid_client);
|
||||
let mut streams = HashMap::from([
|
||||
(semantic_peer, semantic_server),
|
||||
(live_grid_peer, live_grid_server),
|
||||
(dead_grid_peer, dead_grid_server),
|
||||
]);
|
||||
let published_buffer = crate::buffer::BufferId::from_raw(900);
|
||||
let message = InstanceMessage::BufferSnapshot {
|
||||
buffer_id: published_buffer,
|
||||
crdt_snapshot: vec![1, 2, 3],
|
||||
};
|
||||
|
||||
publish_buffer_snapshot_to_replicas(
|
||||
&editor,
|
||||
published_buffer,
|
||||
&message,
|
||||
®istry,
|
||||
&mut streams,
|
||||
&mut HashMap::new(),
|
||||
);
|
||||
|
||||
let delivered: InstanceMessage =
|
||||
read_message(&mut live_grid_client).expect("live grid snapshot");
|
||||
assert_eq!(delivered, message);
|
||||
semantic_client
|
||||
.set_read_timeout(Some(Duration::from_millis(50)))
|
||||
.expect("semantic timeout");
|
||||
assert!(
|
||||
read_message::<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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
163
src/main.rs
163
src/main.rs
|
|
@ -9,7 +9,7 @@
|
|||
//!
|
||||
//! ```text
|
||||
//! pmacs [-nw|--no-window] [--help] [--version] [FILE]
|
||||
//! pmacs --gpu [--socket NAME|PATH]
|
||||
//! pmacs --gpu [--socket NAME|PATH] [--] [FILE]
|
||||
//! pmacs --daemon [--socket NAME|PATH]
|
||||
//! pmacs --attach [--socket NAME|PATH]
|
||||
//! pmacs --attach <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(
|
||||
¤t_exe,
|
||||
std::env::var_os(PMACS_TEST_GPU_BIN).map(PathBuf::from),
|
||||
);
|
||||
let status = Command::new(&gpu)
|
||||
let mut command = Command::new(&gpu);
|
||||
command
|
||||
.arg("--managed-attach")
|
||||
.arg(&socket_path)
|
||||
.arg(¤t_exe)
|
||||
.status();
|
||||
.arg(¤t_exe);
|
||||
if let Some((cwd, path)) = initial_target {
|
||||
command.arg("--initial-target").arg(cwd).arg(path);
|
||||
}
|
||||
let status = command.status();
|
||||
match status {
|
||||
Ok(status) if status.success() => ExitCode::SUCCESS,
|
||||
Ok(status) => {
|
||||
|
|
@ -358,7 +383,7 @@ fn run_gpu(socket: Option<&str>) -> ExitCode {
|
|||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<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"])) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue