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 → pmacs --gpu --socket NAME FILE # named instance; bare NAME →
# <runtime>/pmacs/NAME.sock # <runtime>/pmacs/NAME.sock
pmacs --gpu -- --leading-dash # `--` ends option parsing pmacs --gpu -- --leading-dash # `--` ends option parsing
```
`pmacs --gpu` requires the root `pmacs` binary to be built with the `pmacs --gpu` requires the root `pmacs` binary to be built with the
`crdt` feature. It discovers a sibling `pmacs-gpu` binary first, then `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 (folding Stage 1 #142); current canonical `main` @ `47581f4` is integrated
conflict-free by merge `d6d4be6`. Protocol was v19 before this work. conflict-free by merge `d6d4be6`. Protocol was v19 before this work.
- State: implementation checkpoint `2dd30ec`; integrated, smoke-tested, fully - State: implementation checkpoint `2dd30ec`; integrated, smoke-tested, fully
gated, and published on 2026-07-23 at protocol v20. PR #148 is open for user gated, and published on 2026-07-23 at protocol v20. PR #148 review fixes are
review: `https://github.com/levineuwirth/pmacs/pull/148`. complete locally and awaiting publication; PR:
`https://github.com/levineuwirth/pmacs/pull/148`.
- Scope delivered: one session-scoped `pmacs --gpu [--socket …] FILE` target, - Scope delivered: one session-scoped `pmacs --gpu [--socket …] FILE` target,
protocol-v20 semantic bootstrap, launcher-owned tilde/cwd resolution, exact protocol-v20 semantic bootstrap, launcher-owned tilde/cwd resolution, exact
Unix path transport, pre-window target readiness, replica coherence, and the Unix path transport, pre-window target readiness, replica coherence, and the
approved behavioral acceptance matrix. approved behavioral acceptance matrix.
- Verification after current-main integration: formatting and strict Clippy; - Post-review verification: formatting and strict Clippy; 1,800 default + 1,977
1,800 default + 1,976 CRDT library tests; target gate 1 default + 13 CRDT; CRDT library tests; target gate 1 default + 13 CRDT; M4 121; required GPU 152;
M4 121; required GPU 152; Vterm Stage 3 5 default + 7 CRDT; workspace sweep Vterm Stage 3 5 default + 7 CRDT; isolated-config workspace sweep 3,269 across
3,268 across 87 suites. 87 suites. Two concurrent real Wayland/Vulkan GPU windows remained on distinct
A coherent release launch displayed `README.md` first at protocol v20. target buffers after the second attach.
- Deferred unchanged: automatic GUI selection, multiple files, general - Deferred unchanged: automatic GUI selection, multiple files, general
live-open commands, packaging, and remote GPU paths. live-open commands, packaging, and remote GPU paths.

View File

@ -1,11 +1,12 @@
# Agent handoff — cross-machine continuity # Agent handoff — cross-machine continuity
**Last updated: 2026-07-23, after GPU initial-target implementation completed **Last updated: 2026-07-23, after GPU initial-target PR #148 review fixes and
on branch `gpu-initial-target` (protocol v20, PR pending), following one-command verification completed on branch `gpu-initial-target` (protocol v20), following
GPU invocation (#141), the documentation refresh (#140), Vterm Stage 3 (#135), one-command GPU invocation (#141), the documentation refresh (#140), Vterm
tab-width rendering parity (#137), locals-query processing (#134), modeline Stage 3 (#135), tab-width rendering parity (#137), locals-query processing
detection (#132), mode system wiring (#129), config registry (#127), Vterm (#134), modeline detection (#132), mode system wiring (#129), config registry
Stages 12 (#126/#130), and completed Themes Arc 4 (#120/#124/#125).** (#127), Vterm Stages 12 (#126/#130), and completed Themes Arc 4
(#120/#124/#125).**
This file is the This file is the
bridge between development machines. If you are an agent reading bridge between development machines. If you are an agent reading
this on a fresh clone: this document plus the `docs/*-framing.md` 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, - `main` @ `47581f4` (web grammars #146 atop folding Stage 1 #142,
inline-math framing #145, and one-command GPU invocation #141), protocol inline-math framing #145, and one-command GPU invocation #141), protocol
**v19** (`SUPPORTED=[6..=19]`; v19 = terminal frames/events). **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`). (`docs/gpu-initial-target-framing.md` rev 3; branch `gpu-initial-target`).
`pmacs --gpu [--socket NAME|PATH] FILE` now transports exact Unix path bytes `pmacs --gpu [--socket NAME|PATH] FILE` now transports exact Unix path bytes
plus launcher cwd to the managed GPU client. Protocol v20 adds a 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 appended `InitialTargetResult` readiness barrier; v6v19 wire encodings stay
pinned. The daemon resolves the path lexically, deduplicates or loads/creates pinned. The daemon resolves the path lexically, deduplicates or loads/creates
it in the authenticated frontend's view, runs the established load/switch it in the authenticated frontend's view, runs the established load/switch
hooks, upgrades the buffer for CRDT, publishes fresh buffers to existing hooks, upgrades the buffer for CRDT, publishes fresh buffers to existing grid
replicas, and sends the target snapshot before readiness. Failed bootstrap replicas, and sends the target snapshot before readiness. Semantic replicas
removes the provisional session without poisoning the daemon. Existing receive a publication only when displaying that buffer, so a second target
no-target managed launch, direct attach, TUI, and legacy protocol behavior launch cannot switch an existing GPU window; one dead peer cannot fail the
remain intact. See `docs/active-work.md` for the portable checkpoint and new session. Failed bootstrap removes the provisional session and restores
verification. 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** - **One-command GPU invocation LANDED — #141**
(`docs/gpu-invocation-framing.md` rev 6; merge `63fbc66`; two implementation (`docs/gpu-invocation-framing.md` rev 6; merge `63fbc66`; two implementation
reviews). The additive public path is `pmacs --gpu [--socket NAME|PATH]`; 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 Revision 3 records the completed implementation and verification. Revision 2
pinned launcher-owned tilde expansion, required `after-switch` even when dedup pinned launcher-owned tilde expansion, required `after-switch` even when dedup
selects the view's existing buffer, failed bootstrap when a hook kills the 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 target, and kept slow pre-window bootstrap terminal-only (no graphical
bootstrap. It also sharpened the observed argv panic and negotiated progress surface). It also sharpened the observed argv panic and negotiated
protocol-version echo. protocol-version echo.
One-command GPU startup landed in #141: 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. The daemon itself remains alive, whether reused or newly spawned.
Because the connector waits before winit creates a window, slow dispatcher Because the connector waits before winit creates a window, slow dispatcher
work has no graphical “Connecting…” surface. This is deliberate for the work has no graphical “Connecting…” surface. The managed GPU child waits
explicit terminal command: before blocking, `pmacs-gpu` writes one bounded, silently and reports a failure on stderr if bootstrap fails. There is no
lossy-display-only `opening …` notice to stderr. There is no second target second target timeout beyond #141's bounded daemon-start retry; file I/O and
timeout beyond #141's bounded daemon-start retry; file I/O and user hooks may user hooks may legitimately exceed five seconds, and timing out the client
legitimately exceed five seconds, and timing out the client would not cancel would not cancel dispatcher work. Ctrl-C remains the escape hatch and still
dispatcher work. Ctrl-C remains the escape hatch and still cannot reach the cannot reach the isolated daemon process group.
isolated daemon process group.
### Q#GT9 — Failure cleanup never creates a ghost session ### Q#GT9 — Failure cleanup never creates a ghost session
@ -524,8 +523,9 @@ process behavior.
pointed usage error rather than panic or replacement characters. pointed usage error rather than panic or replacement characters.
3. **Private GPU grammar:** managed and headless modes accept either their 3. **Private GPU grammar:** managed and headless modes accept either their
unchanged no-target arity or exactly `--initial-target CWD FILE`; missing, unchanged no-target arity or exactly `--initial-target CWD FILE`; missing,
trailing, duplicated, or relative-cwd forms exit 2. An option-like `FILE` trailing, or duplicated forms exit 2. An option-like `FILE` after the marker
after the marker remains literal. 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 4. **v20 wire and legacy pins:** bootstrap request/result round trips preserve
arbitrary Unix bytes and enforce bounds. Every pinned v6v19 encoding stays arbitrary Unix bytes and enforce bounds. Every pinned v6v19 encoding stays
unchanged; the new result discriminant is appended. The supported ladder is 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 Vterm Stage 3 acceptance in default and CRDT configurations where the suite
supports both. The final full workspace sweep remains required before PR. 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. - `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 - The named initial-target gate passed 1 default and 13 CRDT tests; the
underlying GPU invocation suite passed 13 CRDT tests. underlying GPU invocation suite passed 13 CRDT tests.
- M4 passed 121 tests with the documented basedpyright skip; required real-GPU - M4 passed 121 tests with the documented basedpyright skip; required real-GPU
tests passed 152. tests passed 152.
- Vterm Stage 3 passed 5 default and 7 CRDT tests. - Vterm Stage 3 passed 5 default and 7 CRDT tests.
- The workspace CRDT sweep passed 3,268 tests across 87 suites, with 29 ignored - The isolated-config workspace CRDT sweep passed 3,269 tests across 87 suites,
and the documented basedpyright case filtered. with 29 ignored and the documented basedpyright case filtered.
- A coherent release build launched `target/release/pmacs --gpu --socket - A coherent release build launched two concurrent real Wayland/Vulkan GPU
initial-target-smoke README.md` on the real Wayland/Vulkan workstation, windows on one daemon, targeting distinct `alpha` and `beta` files. Both
attached at protocol v20, and displayed README rather than scratch. remained visible on their own buffer after the second target attached.
## Deferred (named) ## Deferred (named)

View File

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

View File

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

View File

@ -1085,12 +1085,12 @@ fn dispatcher_loop(
.is_some_and(|s| s.negotiated_capabilities.crdt_replica) .is_some_and(|s| s.negotiated_capabilities.crdt_replica)
{ {
// F29 — when a mid-session upgrade occurs, push a // F29 — when a mid-session upgrade occurs, push a
// `BufferSnapshot` for the newly-CRDT-backed buffer // `BufferSnapshot` to every grid replica so its
// to every currently-attached replica so their // `BufferMirror` gains an entry for the buffer. A
// `BufferMirror`s gain an entry for it. Without // semantic replica receives it only when that replica
// this, replicas attached before the upgrade // is displaying this buffer: applying a foreign-buffer
// permanently fall back to v0.1 round-trip on that // snapshot would switch the GPU window away from its
// buffer. // own active view.
if let Some(upgraded) = ensure_active_buffer_crdt_backed(editor, *fid) { if let Some(upgraded) = ensure_active_buffer_crdt_backed(editor, *fid) {
broadcast_buffer_snapshot_to_replicas( broadcast_buffer_snapshot_to_replicas(
editor, editor,
@ -1786,25 +1786,14 @@ fn handle_session_established(
crdt_snapshot: snapshot, crdt_snapshot: snapshot,
}; };
if opened.publish_to_replicas { if opened.publish_to_replicas {
for (peer_id, peer_stream) in streams.iter_mut() { publish_buffer_snapshot_to_replicas(
let is_replica = session_registry editor,
.session_state(*peer_id) opened.buffer_id,
.is_some_and(|state| state.negotiated_capabilities.crdt_replica); &snapshot_message,
if is_replica && let Err(error) = write_message(peer_stream, &snapshot_message) { session_registry,
send_initial_target_failure( streams,
&mut write_stream, semantic_states,
format!("cannot publish initial target snapshot to {peer_id:?}: {error}"), );
);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
return;
}
if is_replica && let Some(state) = semantic_states.get_mut(peer_id) {
state.on_buffer_snapshot_sent(opened.buffer_id);
}
}
} }
if write_message(&mut write_stream, &snapshot_message).is_err() { if write_message(&mut write_stream, &snapshot_message).is_err() {
editor editor
@ -2400,28 +2389,56 @@ fn broadcast_buffer_snapshot_to_replicas(
streams: &mut HashMap<FrontendId, UnixStream>, streams: &mut HashMap<FrontendId, UnixStream>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>, 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; return;
}; };
let msg = InstanceMessage::BufferSnapshot { let message = InstanceMessage::BufferSnapshot {
buffer_id, buffer_id,
crdt_snapshot: snapshot_bytes, crdt_snapshot: snapshot,
}; };
for (fid, stream) in streams.iter_mut() { publish_buffer_snapshot_to_replicas(
let is_replica = session_registry editor,
.session_state(*fid) buffer_id,
.is_some_and(|s| s.negotiated_capabilities.crdt_replica); &message,
if !is_replica { 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; continue;
} }
if let Err(e) = write_message(stream, &msg) { if session.negotiated_capabilities.semantic_render {
eprintln!("pmacs: F29 send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}"); 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: if let Err(error) = write_message(stream, message) {
// the snapshot wiped this replica's buffer-scoped render eprintln!(
// state, so its emission baselines for the buffer die too. "pmacs: BufferSnapshot publish for {buffer_id:?} to {peer_id:?} failed: {error}"
if let Some(sem) = semantic_states.get_mut(fid) { );
sem.on_buffer_snapshot_sent(buffer_id); 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 { let semantic = crate::protocol::NegotiatedCapabilities {
multi_frontend: true, multi_frontend: true,
crdt_replica: true, crdt_replica: true,
semantic_render: true, semantic_render: true,
}; };
let old_peer = FrontendId(2); let old_peer = FrontendId(2);
@ -3170,6 +3188,81 @@ mod tests {
assert!(!peer_declared_terminal_support(&registry, FrontendId(99))); 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] #[test]
fn build_identity_includes_version_and_uptime() { fn build_identity_includes_version_and_uptime() {
let s = DaemonState::new(Some("research".into())); let s = DaemonState::new(Some("research".into()));

View File

@ -548,6 +548,9 @@ impl EditorCore {
/// closing a window left others intact). /// closing a window left others intact).
pub fn unregister_frontend_view(&mut self, fid: FrontendId) { pub fn unregister_frontend_view(&mut self, fid: FrontendId) {
self.views.remove(&fid); self.views.remove(&fid);
if self.active_frontend == fid {
self.active_frontend = FrontendId::LOCAL;
}
} }
/// [`BufferId`] of the active window's buffer. /// [`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 /// `~` becomes `$HOME`; `~/x` becomes `$HOME/x`. `~user` is left
/// untouched (no passwd lookup). Returns the input unchanged if it /// untouched (no passwd lookup). Returns the input unchanged if it
/// has no leading `~`, isn't valid UTF-8, or `$HOME` is unset. /// 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 { let Some(s) = path.to_str() else {
return path; return path;
}; };
@ -3713,13 +3716,16 @@ mod tests {
// we don't need a fresh window allocation in this test. // we don't need a fresh window allocation in this test.
let local_view = s.views[&FrontendId::LOCAL].clone(); let local_view = s.views[&FrontendId::LOCAL].clone();
s.register_frontend_view(fid, local_view); s.register_frontend_view(fid, local_view);
s.active_frontend = fid;
assert!(s.active_window_for(fid).is_some()); assert!(s.active_window_for(fid).is_some());
// Unregister drops the entry; explicit lookup returns None. // Unregister drops the entry; explicit lookup returns None.
s.unregister_frontend_view(fid); s.unregister_frontend_view(fid);
assert!(s.active_window_for(fid).is_none()); 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)); 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) (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 { fn run_gpu(socket: Option<&str>, file: Option<&Path>) -> ExitCode {
if !cfg!(feature = "crdt") { if !cfg!(feature = "crdt") {
eprintln!("pmacs: --gpu requires pmacs built with --features 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; return ExitCode::FAILURE;
} }
}; };
Some((cwd, expand_launcher_tilde(path))) Some((cwd, pmacs::editor_core::expand_tilde(path.to_owned())))
} }
None => None, None => None,
}; };
@ -909,13 +894,16 @@ mod tests {
} }
let home = std::env::var_os("HOME").expect("test HOME"); let home = std::env::var_os("HOME").expect("test HOME");
assert_eq!(expand_launcher_tilde(Path::new("~")), PathBuf::from(&home));
assert_eq!( 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") PathBuf::from(home).join("notes")
); );
assert_eq!( assert_eq!(
expand_launcher_tilde(Path::new("~other/notes")), pmacs::editor_core::expand_tilde(PathBuf::from("~other/notes")),
PathBuf::from("~other/notes") PathBuf::from("~other/notes")
); );
} }