Refine GPU initial-target framing after review

Pin launcher-owned tilde expansion, require same-buffer dedup hooks, fail
closed when hooks kill the target, and document stderr feedback during the
pre-window bootstrap wait. Record the observed protocol-version echo and
non-Unicode argv panic.
This commit is contained in:
Levi Neuwirth 2026-07-23 15:07:06 -04:00
parent ec0e40117b
commit 71039d1699
1 changed files with 76 additions and 34 deletions

View File

@ -1,7 +1,13 @@
# GPU initial target — session-scoped file opening framing # GPU initial target — session-scoped file opening framing
**Revision 1 — proposed framing for user review. Ground truth: canonical **Revision 2 — user-review findings resolved. Ground truth: canonical `main`
`main` @ `4daa1b8`, protocol v19, 2026-07-23. No implementation yet.** @ `4daa1b8`, protocol v19, 2026-07-23. No implementation yet.**
Revision 2 pins launcher-owned tilde expansion, requires `after-switch` even
when dedup selects the view's existing buffer, fails bootstrap when a hook
kills the target, and records the deliberate stderr-only wait during slow
pre-window bootstrap. It also sharpens the observed argv panic and negotiated
protocol-version echo.
One-command GPU startup landed in #141: One-command GPU startup landed in #141:
@ -41,9 +47,9 @@ solve installation, service management, remote GPU attach, or reconnect.
- The root broker invokes the sibling/PATH GPU binary as - The root broker invokes the sibling/PATH GPU binary as
`pmacs-gpu --managed-attach SOCKET DAEMON_EXE`, waits for its status, and `pmacs-gpu --managed-attach SOCKET DAEMON_EXE`, waits for its status, and
reflects success/failure. reflects success/failure.
- Both root and GPU parsers consume `std::env::args()` into `String`. That - Both root and GPU parsers consume `std::env::args()` into `String`; Rust
cannot represent a non-UTF-8 Unix filename and may terminate before the panics when an argument is not valid Unicode. They cannot admit a
parser can issue a useful error. non-UTF-8 Unix filename or issue a useful parser error for one.
- The direct GPU CLI is intentionally strict. Public direct attach remains - The direct GPU CLI is intentionally strict. Public direct attach remains
`pmacs-gpu --attach RAW_SOCKET`; managed and headless forms are private `pmacs-gpu --attach RAW_SOCKET`; managed and headless forms are private
root/acceptance seams. root/acceptance seams.
@ -55,6 +61,9 @@ solve installation, service management, remote GPU attach, or reconnect.
and becomes the reader for `FrontendEvent`s. and becomes the reader for `FrontendEvent`s.
- `AttachRequest` contains protocol version, capabilities, and initial cell - `AttachRequest` contains protocol version, capabilities, and initial cell
size. It has no target. `FrontendEvent` has no open-path request. 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 - `handle_session_established` creates the authenticated frontend's own
`FrontendView`, initially sharing the daemon-local active buffer, then sends `FrontendView`, initially sharing the daemon-local active buffer, then sends
CRDT snapshots and installs grid or semantic render state. CRDT snapshots and installs grid or semantic render state.
@ -98,15 +107,18 @@ invariant as any other mid-session buffer creation.
1. **Authenticated source owns the target.** No client-supplied frontend id, 1. **Authenticated source owns the target.** No client-supplied frontend id,
daemon-local active view, or ambient “last frontend” selects the window. daemon-local active view, or ambient “last frontend” selects the window.
2. **Launcher cwd owns relative resolution.** The daemon's cwd is irrelevant, 2. **Launcher environment owns path resolution.** Root applies the existing
including when the daemon predates the launcher. leading-tilde rule with the launcher's `$HOME`; any still-relative result
3. **Path bytes survive.** On Unix, argv → broker → GPU → wire → daemon file resolves against the launcher cwd. Daemon cwd/HOME are irrelevant.
I/O preserves the exact `OsStr` bytes. Display text may be lossy; backing 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. identity and filesystem access may not be.
4. **One target, one buffer identity.** Reuse an already-open normalized path; 4. **One target, one buffer identity.** Reuse an already-open normalized path;
never discard unsaved edits by reloading it. never discard unsaved edits by reloading it.
5. **Target before first draw.** The first buffer content eligible to render is 5. **Target before first draw.** The first buffer content eligible to render is
the requested target. “Connecting…” is acceptable; scratch content is not. the requested target. The explicit terminal launcher may report the wait on
stderr; no editor window exists yet, and scratch content is never drawable.
6. **Ready means usable.** Success is reported only after the target buffer is 6. **Ready means usable.** Success is reported only after the target buffer is
loaded/created, selected in the authenticated view, CRDT-backed, and its loaded/created, selected in the authenticated view, CRDT-backed, and its
matching snapshot has been written to the GPU. matching snapshot has been written to the GPU.
@ -167,13 +179,20 @@ pmacs-gpu --managed-attach SOCKET DAEMON_EXE \
--initial-target LAUNCHER_CWD FILE --initial-target LAUNCHER_CWD FILE
``` ```
`LAUNCHER_CWD` is captured by root before spawn and must be absolute. Both cwd Before transport, root applies the existing identity seam's tilde rule using
and file are passed as `OsString`/`Path` operands, not encoded into UTF-8, the **launcher** environment: a valid-UTF-8 leading whole `~` or `~/…` expands
environment variables, JSON, or a delimiter-separated string. The marker through `std::env::var_os("HOME")`; `~user`, a non-UTF-8 spelling, or an
makes an option-like `FILE` unambiguous. The no-target private argv remains unset `$HOME` remains unchanged. Expansion happens exactly once, before any
unchanged. cwd join. This makes quoted `~/x` dedup with an already-open `$HOME/x` buffer
If `current_dir()` fails, root reports that error and does not spawn the GPU; and prevents a long-lived daemon's different `$HOME` from changing identity.
falling back to the daemon cwd would violate the target's authority.
`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 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 to advertise only the root command and advanced direct attach; the private
@ -229,18 +248,22 @@ transaction without yielding to another event:
1. Register the new frontend's view and set `active_frontend` to the 1. Register the new frontend's view and set `active_frontend` to the
authenticated `FrontendId`. authenticated `FrontendId`.
2. Resolve a relative `path` against the supplied launcher `cwd`, then 2. Accept root's already tilde-expanded `path`; if it is still relative, join
lexically normalize the result without canonicalizing symlinks. 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 3. Reuse an existing buffer with that normalized backing path; otherwise load
the file; on `NotFound`, create an empty path-backed buffer and set the file; on `NotFound`, create an empty path-backed buffer and set
`[new file]`; on any other error, take the failure path below. `[new file]`; on any other error, take the failure path below.
4. Select that buffer in only the new frontend's active window. 4. Select that buffer in only the new frontend's active window.
5. Fire `buffer.after-switch` on dedup or `buffer.after-load` on a fresh disk 5. Fire `buffer.after-switch` exactly once on every dedup, including when the
load, with the authenticated frontend active. A newly created missing file fresh view already shares that `BufferId` and selection is a same-buffer
matches local startup and does not fire `after-load`. no-op. Fire `buffer.after-load` on a fresh disk load. Both run with the
6. Reassert the requested target after hooks so startup configuration can authenticated frontend active; a newly created missing file matches local
inspect the right session but cannot accidentally make `pmacs --gpu FILE` startup and fires neither load nor switch hook.
acknowledge a different buffer. 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. 7. Establish CRDT/snapshot coherence, then acknowledge readiness.
The target-opening helper must be Rust/editor-core state, not synthetic keys The target-opening helper must be Rust/editor-core state, not synthetic keys
@ -318,6 +341,15 @@ scratch snapshot can become drawable.
daemon detail. Managed startup returns nonzero; root reflects that status. daemon detail. Managed startup returns nonzero; root reflects that status.
The daemon itself remains alive, whether reused or newly spawned. The daemon itself remains alive, whether reused or newly spawned.
Because the connector waits before winit creates a window, slow dispatcher
work has no graphical “Connecting…” surface. This is deliberate for the
explicit terminal command: before blocking, `pmacs-gpu` writes one bounded,
lossy-display-only `opening …` notice to stderr. There is no second target
timeout beyond #141's bounded daemon-start retry; file I/O and user hooks may
legitimately exceed five seconds, and timing out the client would not cancel
dispatcher work. Ctrl-C remains the escape hatch and still cannot reach the
isolated daemon process group.
### Q#GT9 — Failure cleanup never creates a ghost session ### Q#GT9 — Failure cleanup never creates a ghost session
On any target failure before readiness, the dispatcher: On any target failure before readiness, the dispatcher:
@ -327,6 +359,9 @@ On any target failure before readiness, the dispatcher:
size/baseline entries, and stream entry if any were installed; size/baseline entries, and stream entry if any were installed;
- shuts down the connection so the per-attach reader wakes and emits at most - shuts down the connection so the per-attach reader wakes and emits at most
idempotent detach cleanup; 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 - leaves every pre-existing buffer/view/session unchanged, except that a file
successfully loaded before a later CRDT/export failure may remain as an successfully loaded before a later CRDT/export failure may remain as an
ordinary daemon buffer. It must not become another frontend's active view. ordinary daemon buffer. It must not become another frontend's active view.
@ -359,7 +394,7 @@ source of truth for a mandatory v20 semantic handshake step.
## Startup state machine ## Startup state machine
```text ```text
root parses FILE bytes + cwd bytes root parses FILE bytes, expands eligible `~` with launcher HOME, captures cwd
| |
+-- spawn/await pmacs-gpu managed child +-- spawn/await pmacs-gpu managed child
| |
@ -500,10 +535,11 @@ process behavior.
6. **Old live daemon refusal:** target mode against a real/fake supported v19 6. **Old live daemon refusal:** target mode against a real/fake supported v19
daemon fails with the protocol-v20 requirement, invokes no daemon spawner, daemon fails with the protocol-v20 requirement, invokes no daemon spawner,
leaves the socket/process untouched, and creates no GPU window. leaves the socket/process untouched, and creates no GPU window.
7. **Existing-daemon relative path:** start a real CRDT daemon from cwd A; 7. **Existing-daemon path authority:** start a real CRDT daemon from cwd/HOME
launch the real headless managed GPU target from cwd B with `sub/file.txt`; A; launch the real headless managed GPU target from cwd/HOME B with
require the snapshot contents from B, not A, and a ready buffer matching the `sub/file.txt`; require contents from B, not A. Repeat with a shell-quoted
result. `~/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 8. **Missing-daemon target:** from no socket/lock, the production managed path
starts one daemon, opens the target, reaches ready, and leaves that daemon starts one daemon, opens the target, reaches ready, and leaves that daemon
connectable after the probe/window exits. Repeating reuses it and creates no connectable after the probe/window exits. Repeating reuses it and creates no
@ -528,13 +564,19 @@ process behavior.
any CRDT op for it; both replicas accept later operations without unknown- any CRDT op for it; both replicas accept later operations without unknown-
buffer fallback or disconnect. buffer fallback or disconnect.
14. **Hook context and count:** fresh disk load fires `buffer.after-load` once; 14. **Hook context and count:** fresh disk load fires `buffer.after-load` once;
dedup fires `buffer.after-switch` once; missing-file creation fires neither dedup fires `buffer.after-switch` once even when the fresh view already
load hook. Each hook observes the authenticated frontend and requested shares that exact buffer and the select itself is a no-op; missing-file
buffer, and the target remains selected afterward. 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 15. **Pre-window barrier:** the headless seam records that the only initial
semantic `BufferSnapshot` is the target and precedes matching `Opened`. semantic `BufferSnapshot` is the target and precedes matching `Opened`.
Injected load/export failure records no ready phase. A window-path unit seam Injected load/export failure records no ready phase. A window-path unit seam
proves bootstrap state is applied before the first redraw request. proves bootstrap state is applied before the first redraw request. A held
dispatcher fixture proves the bounded stderr notice appears while no
window/ready result exists, then succeeds after release without a separate
target timeout.
16. **Concurrent same-target launch:** two target launchers racing an absent 16. **Concurrent same-target launch:** two target launchers racing an absent
daemon and the same file converge on one daemon, one buffer identity, two daemon and the same file converge on one daemon, one buffer identity, two
ready sessions, and the existing #141 losing-daemon child is reaped. ready sessions, and the existing #141 losing-daemon child is reaped.