fix(gpu): preserve session isolation during target publish

Keep foreign BufferSnapshot publications out of existing semantic GPU
sessions while retaining grid-replica coherence. Treat dead peer writes as
peer-local failures, restore active-frontend cleanup, deterministic probe
readiness, GPU logging, shared tilde expansion, and accurate docs.

Add focused publication and cleanup coverage and record the two-window
Wayland/Vulkan smoke plus the complete post-review gate results.
This commit is contained in:
Levi Neuwirth 2026-07-23 21:25:33 -04:00
parent 4f1651c8f0
commit bef1c08133
9 changed files with 214 additions and 109 deletions

View File

@ -107,6 +107,7 @@ 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

View File

@ -64,17 +64,18 @@ If it does not, stop and repair the remote/fetch configuration.
(folding Stage 1 #142); current canonical `main` @ `47581f4` is integrated
conflict-free by merge `d6d4be6`. Protocol was v19 before this work.
- State: implementation checkpoint `2dd30ec`; integrated, smoke-tested, fully
gated, and published on 2026-07-23 at protocol v20. PR #148 is open for user
review: `https://github.com/levineuwirth/pmacs/pull/148`.
gated, and published on 2026-07-23 at protocol v20. PR #148 review fixes are
complete locally and awaiting publication; PR:
`https://github.com/levineuwirth/pmacs/pull/148`.
- Scope delivered: one session-scoped `pmacs --gpu [--socket …] FILE` target,
protocol-v20 semantic bootstrap, launcher-owned tilde/cwd resolution, exact
Unix path transport, pre-window target readiness, replica coherence, and the
approved behavioral acceptance matrix.
- Verification after current-main integration: formatting and strict Clippy;
1,800 default + 1,976 CRDT library tests; target gate 1 default + 13 CRDT;
M4 121; required GPU 152; Vterm Stage 3 5 default + 7 CRDT; workspace sweep
3,268 across 87 suites.
A coherent release launch displayed `README.md` first at protocol v20.
- Post-review verification: formatting and strict Clippy; 1,800 default + 1,977
CRDT library tests; target gate 1 default + 13 CRDT; M4 121; required GPU 152;
Vterm Stage 3 5 default + 7 CRDT; isolated-config workspace sweep 3,269 across
87 suites. Two concurrent real Wayland/Vulkan GPU windows remained on distinct
target buffers after the second attach.
- Deferred unchanged: automatic GUI selection, multiple files, general
live-open commands, packaging, and remote GPU paths.

View File

@ -1,11 +1,12 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-23, after GPU initial-target implementation completed
on branch `gpu-initial-target` (protocol v20, PR pending), following one-command
GPU invocation (#141), the documentation refresh (#140), Vterm Stage 3 (#135),
tab-width rendering parity (#137), locals-query processing (#134), modeline
detection (#132), mode system wiring (#129), config registry (#127), Vterm
Stages 12 (#126/#130), and completed Themes Arc 4 (#120/#124/#125).**
**Last updated: 2026-07-23, after GPU initial-target PR #148 review fixes and
verification completed on branch `gpu-initial-target` (protocol v20), following
one-command GPU invocation (#141), the documentation refresh (#140), Vterm
Stage 3 (#135), tab-width rendering parity (#137), locals-query processing
(#134), modeline detection (#132), mode system wiring (#129), config registry
(#127), Vterm Stages 12 (#126/#130), and completed Themes Arc 4
(#120/#124/#125).**
This file is the
bridge between development machines. If you are an agent reading
this on a fresh clone: this document plus the `docs/*-framing.md`
@ -22,7 +23,7 @@ commands, read `docs/active-work.md` immediately after this file.
- `main` @ `47581f4` (web grammars #146 atop folding Stage 1 #142,
inline-math framing #145, and one-command GPU invocation #141), protocol
**v19** (`SUPPORTED=[6..=19]`; v19 = terminal frames/events).
- **GPU INITIAL TARGET IMPLEMENTED — PR pending**
- **GPU INITIAL TARGET IMPLEMENTED — PR #148 under user review**
(`docs/gpu-initial-target-framing.md` rev 3; branch `gpu-initial-target`).
`pmacs --gpu [--socket NAME|PATH] FILE` now transports exact Unix path bytes
plus launcher cwd to the managed GPU client. Protocol v20 adds a
@ -30,12 +31,14 @@ commands, read `docs/active-work.md` immediately after this file.
appended `InitialTargetResult` readiness barrier; v6v19 wire encodings stay
pinned. The daemon resolves the path lexically, deduplicates or loads/creates
it in the authenticated frontend's view, runs the established load/switch
hooks, upgrades the buffer for CRDT, publishes fresh buffers to existing
replicas, and sends the target snapshot before readiness. Failed bootstrap
removes the provisional session without poisoning the daemon. Existing
no-target managed launch, direct attach, TUI, and legacy protocol behavior
remain intact. See `docs/active-work.md` for the portable checkpoint and
verification.
hooks, upgrades the buffer for CRDT, publishes fresh buffers to existing grid
replicas, and sends the target snapshot before readiness. Semantic replicas
receive a publication only when displaying that buffer, so a second target
launch cannot switch an existing GPU window; one dead peer cannot fail the
new session. Failed bootstrap removes the provisional session and restores
the ambient active frontend without poisoning the daemon. Existing no-target
managed launch, direct attach, TUI, and legacy protocol behavior remain
intact. See `docs/active-work.md` for the portable checkpoint and verification.
- **One-command GPU invocation LANDED — #141**
(`docs/gpu-invocation-framing.md` rev 6; merge `63fbc66`; two implementation
reviews). The additive public path is `pmacs --gpu [--socket NAME|PATH]`;

View File

@ -7,8 +7,8 @@ the implementation base includes folding Stage 1 through `c49a8c7`.**
Revision 3 records the completed implementation and verification. Revision 2
pinned launcher-owned tilde expansion, required `after-switch` even when dedup
selects the view's existing buffer, failed bootstrap when a hook kills the
target, and recorded the deliberate stderr-only wait during slow pre-window
bootstrap. It also sharpened the observed argv panic and negotiated
target, and kept slow pre-window bootstrap terminal-only (no graphical
progress surface). It also sharpened the observed argv panic and negotiated
protocol-version echo.
One-command GPU startup landed in #141:
@ -344,13 +344,12 @@ daemon detail. Managed startup returns nonzero; root reflects that status.
The daemon itself remains alive, whether reused or newly spawned.
Because the connector waits before winit creates a window, slow dispatcher
work has no graphical “Connecting…” surface. This is deliberate for the
explicit terminal command: before blocking, `pmacs-gpu` writes one bounded,
lossy-display-only `opening …` notice to stderr. There is no second target
timeout beyond #141's bounded daemon-start retry; file I/O and user hooks may
legitimately exceed five seconds, and timing out the client would not cancel
dispatcher work. Ctrl-C remains the escape hatch and still cannot reach the
isolated daemon process group.
work has no graphical “Connecting…” surface. The managed GPU child waits
silently and reports a failure on stderr if bootstrap fails. There is no
second target timeout beyond #141's bounded daemon-start retry; file I/O and
user hooks may legitimately exceed five seconds, and timing out the client
would not cancel dispatcher work. Ctrl-C remains the escape hatch and still
cannot reach the isolated daemon process group.
### Q#GT9 — Failure cleanup never creates a ghost session
@ -524,8 +523,9 @@ process behavior.
pointed usage error rather than panic or replacement characters.
3. **Private GPU grammar:** managed and headless modes accept either their
unchanged no-target arity or exactly `--initial-target CWD FILE`; missing,
trailing, duplicated, or relative-cwd forms exit 2. An option-like `FILE`
after the marker remains literal.
trailing, or duplicated forms exit 2. An option-like `FILE` after the marker
remains literal. A relative `CWD` is syntactically accepted and then fails
the daemon's bootstrap validation before readiness.
4. **v20 wire and legacy pins:** bootstrap request/result round trips preserve
arbitrary Unix bytes and enforce bounds. Every pinned v6v19 encoding stays
unchanged; the new result discriminant is appended. The supported ladder is
@ -616,20 +616,25 @@ Also rerun the touched GPU invocation suite, protocol/transport tests, and
Vterm Stage 3 acceptance in default and CRDT configurations where the suite
supports both. The final full workspace sweep remains required before PR.
As-built verification on 2026-07-23:
The named gate intentionally reuses the managed-lifecycle acceptance module,
so a workspace sweep executes those 13 CRDT cases under both test-binary
names. The duplicate runtime is retained to keep the approved named command
and the complete #141 lifecycle fixture coverage together.
Post-review verification on 2026-07-23:
- `cargo fmt --check` and strict workspace Clippy passed.
- Library gates passed 1,800 default and 1,976 CRDT tests.
- Library gates passed 1,800 default and 1,977 CRDT tests.
- The named initial-target gate passed 1 default and 13 CRDT tests; the
underlying GPU invocation suite passed 13 CRDT tests.
- M4 passed 121 tests with the documented basedpyright skip; required real-GPU
tests passed 152.
- Vterm Stage 3 passed 5 default and 7 CRDT tests.
- The workspace CRDT sweep passed 3,268 tests across 87 suites, with 29 ignored
and the documented basedpyright case filtered.
- A coherent release build launched `target/release/pmacs --gpu --socket
initial-target-smoke README.md` on the real Wayland/Vulkan workstation,
attached at protocol v20, and displayed README rather than scratch.
- The isolated-config workspace CRDT sweep passed 3,269 tests across 87 suites,
with 29 ignored and the documented basedpyright case filtered.
- A coherent release build launched two concurrent real Wayland/Vulkan GPU
windows on one daemon, targeting distinct `alpha` and `beta` files. Both
remained visible on their own buffer after the second target attached.
## Deferred (named)

View File

@ -478,10 +478,7 @@ fn read_initial_target_bootstrap(
other => {
return Err(AttachClientError::InitialTargetProtocol(format!(
"unexpected {} before target readiness",
match other {
InstanceMessage::InitialTargetResult(_) => "InitialTargetResult",
_ => "instance message",
}
crate::instance_message_label(&other)
)));
}
}

View File

@ -593,6 +593,7 @@ fn decimal_digits(mut n: usize) -> u32 {
}
fn main() {
env_logger::init();
let mode = match parse_args(&std::env::args_os().skip(1).collect::<Vec<_>>()) {
Ok(mode) => mode,
Err(error) => {
@ -861,8 +862,7 @@ fn run_headless_managed_probe(
use std::sync::mpsc;
use std::time::{Duration, Instant};
let (event_tx, event_rx) = mpsc::channel::<AttachEvent>();
let connector_tx = event_tx.clone();
let (connector_tx, event_rx) = mpsc::channel::<AttachEvent>();
let managed = match attach::connect_managed_with_target_and_sink(
socket,
daemon_executable,
@ -878,9 +878,10 @@ fn run_headless_managed_probe(
}
};
let mut client = managed.client;
if let Some(message) = client.take_initial_message() {
let _ = event_tx.send(AttachEvent::Message(Box::new(message)));
}
let initial_target_ready = matches!(
client.take_initial_message(),
Some(InstanceMessage::BufferSnapshot { .. })
);
let daemon = managed.daemon;
let protocol = client.server_protocol_version();
@ -895,12 +896,22 @@ fn run_headless_managed_probe(
.expect("spawn managed probe stdin reader");
let deadline = Instant::now() + Duration::from_secs(20);
let mut ready = false;
let mut ready = initial_target_ready;
let mut stdin_closed = false;
let mut disconnect = String::new();
let mut last_reaped = false;
let mut last_wait_result = None;
let mut last_disconnect = String::new();
if ready
&& let Err(error) =
write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect)
{
eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}",
report.display()
);
return 5;
}
loop {
if stdin_rx.try_recv().is_ok() {
stdin_closed = true;

View File

@ -1085,12 +1085,12 @@ fn dispatcher_loop(
.is_some_and(|s| s.negotiated_capabilities.crdt_replica)
{
// F29 — when a mid-session upgrade occurs, push a
// `BufferSnapshot` for the newly-CRDT-backed buffer
// to every currently-attached replica so their
// `BufferMirror`s gain an entry for it. Without
// this, replicas attached before the upgrade
// permanently fall back to v0.1 round-trip on that
// buffer.
// `BufferSnapshot` to every grid replica so its
// `BufferMirror` gains an entry for the buffer. A
// semantic replica receives it only when that replica
// is displaying this buffer: applying a foreign-buffer
// snapshot would switch the GPU window away from its
// own active view.
if let Some(upgraded) = ensure_active_buffer_crdt_backed(editor, *fid) {
broadcast_buffer_snapshot_to_replicas(
editor,
@ -1786,25 +1786,14 @@ fn handle_session_established(
crdt_snapshot: snapshot,
};
if opened.publish_to_replicas {
for (peer_id, peer_stream) in streams.iter_mut() {
let is_replica = session_registry
.session_state(*peer_id)
.is_some_and(|state| state.negotiated_capabilities.crdt_replica);
if is_replica && let Err(error) = write_message(peer_stream, &snapshot_message) {
send_initial_target_failure(
&mut write_stream,
format!("cannot publish initial target snapshot to {peer_id:?}: {error}"),
);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
return;
}
if is_replica && let Some(state) = semantic_states.get_mut(peer_id) {
state.on_buffer_snapshot_sent(opened.buffer_id);
}
}
publish_buffer_snapshot_to_replicas(
editor,
opened.buffer_id,
&snapshot_message,
session_registry,
streams,
semantic_states,
);
}
if write_message(&mut write_stream, &snapshot_message).is_err() {
editor
@ -2400,28 +2389,56 @@ fn broadcast_buffer_snapshot_to_replicas(
streams: &mut HashMap<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);
}
}
}
@ -3151,6 +3168,7 @@ mod tests {
let semantic = crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
};
let old_peer = FrontendId(2);
@ -3170,6 +3188,81 @@ mod tests {
assert!(!peer_declared_terminal_support(&registry, FrontendId(99)));
}
#[cfg(feature = "crdt")]
#[test]
fn snapshot_publication_skips_foreign_semantic_views_and_ignores_dead_peers() {
let mut editor = EditorState::new();
let semantic_peer = FrontendId(20);
let live_grid_peer = FrontendId(21);
let dead_grid_peer = FrontendId(22);
let semantic_view = build_fresh_frontend_view(&mut editor);
editor
.core
.borrow_mut()
.register_frontend_view(semantic_peer, semantic_view);
let semantic_caps = crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
};
let grid_caps = crate::protocol::NegotiatedCapabilities {
semantic_render: false,
..semantic_caps
};
let mut registry = SessionRegistry::new();
registry.register_session(
semantic_peer,
crate::presence::SessionState::new(PROTOCOL_VERSION, semantic_caps, 0),
);
registry.register_session(
live_grid_peer,
crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 1),
);
registry.register_session(
dead_grid_peer,
crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 2),
);
let (semantic_server, mut semantic_client) =
UnixStream::pair().expect("semantic socketpair");
let (live_grid_server, mut live_grid_client) =
UnixStream::pair().expect("live grid socketpair");
let (dead_grid_server, dead_grid_client) =
UnixStream::pair().expect("dead grid socketpair");
drop(dead_grid_client);
let mut streams = HashMap::from([
(semantic_peer, semantic_server),
(live_grid_peer, live_grid_server),
(dead_grid_peer, dead_grid_server),
]);
let published_buffer = crate::buffer::BufferId::from_raw(900);
let message = InstanceMessage::BufferSnapshot {
buffer_id: published_buffer,
crdt_snapshot: vec![1, 2, 3],
};
publish_buffer_snapshot_to_replicas(
&editor,
published_buffer,
&message,
&registry,
&mut streams,
&mut HashMap::new(),
);
let delivered: InstanceMessage =
read_message(&mut live_grid_client).expect("live grid snapshot");
assert_eq!(delivered, message);
semantic_client
.set_read_timeout(Some(Duration::from_millis(50)))
.expect("semantic timeout");
assert!(
read_message::<InstanceMessage>(&mut semantic_client).is_err(),
"a semantic peer displaying another buffer must receive no snapshot"
);
}
#[test]
fn build_identity_includes_version_and_uptime() {
let s = DaemonState::new(Some("research".into()));

View File

@ -548,6 +548,9 @@ impl EditorCore {
/// closing a window left others intact).
pub fn unregister_frontend_view(&mut self, fid: FrontendId) {
self.views.remove(&fid);
if self.active_frontend == fid {
self.active_frontend = FrontendId::LOCAL;
}
}
/// [`BufferId`] of the active window's buffer.
@ -3214,7 +3217,7 @@ fn normalize_buffer_path(path: PathBuf) -> PathBuf {
/// `~` becomes `$HOME`; `~/x` becomes `$HOME/x`. `~user` is left
/// untouched (no passwd lookup). Returns the input unchanged if it
/// has no leading `~`, isn't valid UTF-8, or `$HOME` is unset.
fn expand_tilde(path: PathBuf) -> PathBuf {
pub fn expand_tilde(path: PathBuf) -> PathBuf {
let Some(s) = path.to_str() else {
return path;
};
@ -3713,13 +3716,16 @@ mod tests {
// we don't need a fresh window allocation in this test.
let local_view = s.views[&FrontendId::LOCAL].clone();
s.register_frontend_view(fid, local_view);
s.active_frontend = fid;
assert!(s.active_window_for(fid).is_some());
// Unregister drops the entry; explicit lookup returns None.
s.unregister_frontend_view(fid);
assert!(s.active_window_for(fid).is_none());
// LOCAL invariant survives unrelated register/unregister.
// Removing the selected frontend restores the always-registered
// LOCAL view as the ambient fallback.
assert_eq!(s.active_frontend, FrontendId::LOCAL);
assert!(s.views.contains_key(&FrontendId::LOCAL));
}

View File

@ -315,21 +315,6 @@ fn gpu_binary(current_exe: &Path, override_bin: Option<PathBuf>) -> (PathBuf, Pa
(PathBuf::from("pmacs-gpu"), sibling)
}
fn expand_launcher_tilde(path: &Path) -> PathBuf {
let Some(path_text) = path.to_str() else {
return path.to_owned();
};
if path_text == "~" {
return std::env::var_os("HOME").map_or_else(|| path.to_owned(), PathBuf::from);
}
if let Some(rest) = path_text.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return Path::new(&home).join(rest);
}
path.to_owned()
}
fn run_gpu(socket: Option<&str>, file: Option<&Path>) -> ExitCode {
if !cfg!(feature = "crdt") {
eprintln!("pmacs: --gpu requires pmacs built with --features crdt");
@ -353,7 +338,7 @@ fn run_gpu(socket: Option<&str>, file: Option<&Path>) -> ExitCode {
return ExitCode::FAILURE;
}
};
Some((cwd, expand_launcher_tilde(path)))
Some((cwd, pmacs::editor_core::expand_tilde(path.to_owned())))
}
None => None,
};
@ -909,13 +894,16 @@ mod tests {
}
let home = std::env::var_os("HOME").expect("test HOME");
assert_eq!(expand_launcher_tilde(Path::new("~")), PathBuf::from(&home));
assert_eq!(
expand_launcher_tilde(Path::new("~/notes")),
pmacs::editor_core::expand_tilde(PathBuf::from("~")),
PathBuf::from(&home)
);
assert_eq!(
pmacs::editor_core::expand_tilde(PathBuf::from("~/notes")),
PathBuf::from(home).join("notes")
);
assert_eq!(
expand_launcher_tilde(Path::new("~other/notes")),
pmacs::editor_core::expand_tilde(PathBuf::from("~other/notes")),
PathBuf::from("~other/notes")
);
}