fix(daemon): contain failed target sessions

Shut down bootstrap sockets on every dispatcher-side failure and reject
frontend events whose session state was never installed. This prevents a
lingering failed client from reaching absent render/size state.

Track target-side CRDT upgrades independently from load/create status so a
deduplicated hidden buffer is published to every existing grid replica. Add
real-daemon regressions for both failure containment and replica publication.
This commit is contained in:
Levi Neuwirth 2026-07-24 10:10:31 -04:00
parent 65e500eba1
commit be8c67c30c
5 changed files with 215 additions and 62 deletions

View File

@ -63,19 +63,19 @@ If it does not, stop and repair the remote/fetch configuration.
- Original implementation base: canonical `githubsucks/main` @ `c49a8c7` - Original implementation base: canonical `githubsucks/main` @ `c49a8c7`
(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`; review-fix checkpoint `bef1c08`. - State: implementation checkpoint `2dd30ec`; first review-fix checkpoint
Integrated, smoke-tested, fully gated, and published on 2026-07-23 at `bef1c08`. Second-review fixes are complete locally, smoke-tested, and fully
protocol v20. PR #148 remains open for user review: gated at protocol v20; publication is next. PR #148 remains open for user
`https://github.com/levineuwirth/pmacs/pull/148`. review: `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.
- Post-review verification: formatting and strict Clippy; 1,800 default + 1,977 - Second-review verification: formatting and strict Clippy; 1,801 default +
CRDT library tests; target gate 1 default + 13 CRDT; M4 121; required GPU 152; 1,978 CRDT library tests; target gate 1 default + 14 CRDT; M4 121; required
Vterm Stage 3 5 default + 7 CRDT; isolated-config workspace sweep 3,269 across GPU 152; Vterm Stage 3 5 default + 7 CRDT; isolated-config workspace sweep
87 suites. Two concurrent real Wayland/Vulkan GPU windows remained on distinct 3,272 across 87 suites. The prior two-window Wayland/Vulkan isolation smoke
target buffers after the second attach. remains valid; this round changes only daemon failure/publication behavior.
- 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,12 +1,12 @@
# Agent handoff — cross-machine continuity # Agent handoff — cross-machine continuity
**Last updated: 2026-07-23, after GPU initial-target PR #148 review fixes and **Last updated: 2026-07-24, after GPU initial-target PR #148 second-review fixes
verification completed on branch `gpu-initial-target` (protocol v20), following and verification completed on branch `gpu-initial-target` (protocol v20),
one-command GPU invocation (#141), the documentation refresh (#140), Vterm following one-command GPU invocation (#141), the documentation refresh (#140),
Stage 3 (#135), tab-width rendering parity (#137), locals-query processing Vterm Stage 3 (#135), tab-width rendering parity (#137), locals-query
(#134), modeline detection (#132), mode system wiring (#129), config registry processing (#134), modeline detection (#132), mode system wiring (#129),
(#127), Vterm Stages 12 (#126/#130), and completed Themes Arc 4 config registry (#127), Vterm Stages 12 (#126/#130), and completed Themes
(#120/#124/#125).** 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`
@ -18,7 +18,7 @@ reads it the way you just did.
For volatile branches, checkpoints, verification, and recovery For volatile branches, checkpoints, verification, and recovery
commands, read `docs/active-work.md` immediately after this file. commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-23) ## 1. Where the project stands (2026-07-24)
- `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
@ -31,14 +31,16 @@ 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 grid hooks, upgrades the buffer for CRDT, and publishes every target-side CRDT
replicas, and sends the target snapshot before readiness. Semantic replicas upgrade to existing grid replicas before readiness. Semantic replicas receive
receive a publication only when displaying that buffer, so a second target a publication only when displaying that buffer, so a second target launch
launch cannot switch an existing GPU window; one dead peer cannot fail the cannot switch an existing GPU window; one dead peer cannot fail the new
new session. Failed bootstrap removes the provisional session and restores session. Failed bootstrap writes a bounded result, shuts down the socket,
the ambient active frontend without poisoning the daemon. Existing no-target removes provisional state, and restores the ambient active frontend. Any
managed launch, direct attach, TUI, and legacy protocol behavior remain stale event from an uninstalled session is dropped before state access.
intact. See `docs/active-work.md` for the portable checkpoint and verification. 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

@ -230,7 +230,8 @@ pub struct InitialTarget {
are the authority. are the authority.
- `cwd` and `path` are Unix path bytes, not text. This stage is the local Unix- - `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. socket GPU path; it does not claim a cross-platform/remote path protocol.
- Each field is bounded to 32 KiB before allocation/use. `path` must be - 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 nonempty, `cwd` must be nonempty and absolute, and embedded NUL is rejected
with a bootstrap failure. with a bootstrap failure.
@ -550,9 +551,11 @@ process behavior.
status/path identity, accepts an edit/save through the real session, and status/path identity, accepts an edit/save through the real session, and
creates the requested file under the launcher cwd—not the daemon cwd. creates the requested file under the launcher cwd—not the daemon cwd.
10. **Open error:** a directory/permission-denied target returns a specific 10. **Open error:** a directory/permission-denied target returns a specific
failure before ready/window creation and makes root fail. An existing daemon failure before ready/window creation and makes root fail. The daemon shuts
remains connectable; a pre-existing frontend's active buffer and contents down that failed session's socket; a client that lingers or sends another
remain unchanged. 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 11. **Dedup preserves unsaved edits:** frontend A opens and modifies a file
without saving; target-launch frontend B opens the same normalized path and without saving; target-launch frontend B opens the same normalized path and
receives A's authoritative unsaved text with the same `BufferId`, not disk receives A's authoritative unsaved text with the same `BufferId`, not disk
@ -561,10 +564,11 @@ process behavior.
target launches B and C open different files. Each result/snapshot pair 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 names its own view; a subsequent input/resize proof shows A, B, and C remain
independently usable on their original buffers. independently usable on their original buffers.
13. **Fresh-buffer publication:** keep replica A attached, then target-launch B 13. **Replica publication after target upgrade:** keep grid replica A attached,
onto a previously unknown file. A receives the new buffer snapshot before then target-launch B onto a previously unknown file. Repeat for a file that
any CRDT op for it; both replicas accept later operations without unknown- Lua loaded into a hidden, not-yet-CRDT-backed buffer before the target dedup.
buffer fallback or disconnect. 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; 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 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 shares that exact buffer and the select itself is a no-op; missing-file
@ -617,20 +621,20 @@ 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.
The named gate intentionally reuses the managed-lifecycle acceptance module, The named gate intentionally reuses the managed-lifecycle acceptance module,
so a workspace sweep executes those 13 CRDT cases under both test-binary 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 names. The duplicate runtime is retained to keep the approved named command
and the complete #141 lifecycle fixture coverage together. and the complete #141 lifecycle fixture coverage together.
Post-review verification on 2026-07-23: Post-second-review verification on 2026-07-24:
- `cargo fmt --check` and strict workspace Clippy passed. - `cargo fmt --check` and strict workspace Clippy passed.
- Library gates passed 1,800 default and 1,977 CRDT tests. - Library gates passed 1,801 default and 1,978 CRDT tests.
- The named initial-target gate passed 1 default and 13 CRDT tests; the - The named initial-target gate passed 1 default and 14 CRDT tests; the
underlying GPU invocation suite passed 13 CRDT tests. underlying GPU invocation suite passed 14 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 isolated-config workspace CRDT sweep passed 3,269 tests across 87 suites, - The isolated-config workspace CRDT sweep passed 3,272 tests across 87 suites,
with 29 ignored and the documented basedpyright case filtered. with 29 ignored and the documented basedpyright case filtered.
- A coherent release build launched two concurrent real Wayland/Vulkan GPU - A coherent release build launched two concurrent real Wayland/Vulkan GPU
windows on one daemon, targeting distinct `alpha` and `beta` files. Both windows on one daemon, targeting distinct `alpha` and `beta` files. Both

View File

@ -51,6 +51,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::{OsStr, OsString}; use std::ffi::{OsStr, OsString};
use std::io::ErrorKind; use std::io::ErrorKind;
use std::net::Shutdown;
use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -655,6 +656,7 @@ fn send_initial_target_failure(stream: &mut UnixStream, message: impl Into<Strin
message: bounded_initial_target_error(message.into()), message: bounded_initial_target_error(message.into()),
}; };
let _ = write_message(stream, &InstanceMessage::InitialTargetResult(result)); let _ = write_message(stream, &InstanceMessage::InitialTargetResult(result));
let _ = stream.shutdown(Shutdown::Both);
} }
fn validate_initial_target(target: &InitialTarget) -> Result<(), String> { fn validate_initial_target(target: &InitialTarget) -> Result<(), String> {
@ -1604,6 +1606,11 @@ struct OpenedInitialTarget {
publish_to_replicas: bool, publish_to_replicas: bool,
} }
struct InitialTargetSnapshot {
crdt_snapshot: Vec<u8>,
upgraded_to_crdt: bool,
}
fn resolve_initial_target(target: InitialTarget) -> PathBuf { fn resolve_initial_target(target: InitialTarget) -> PathBuf {
let cwd = PathBuf::from(OsString::from_vec(target.cwd)); let cwd = PathBuf::from(OsString::from_vec(target.cwd));
let path = PathBuf::from(OsString::from_vec(target.path)); let path = PathBuf::from(OsString::from_vec(target.path));
@ -1674,30 +1681,35 @@ fn open_initial_target(
fn initial_target_snapshot( fn initial_target_snapshot(
editor: &EditorState, editor: &EditorState,
buffer_id: crate::buffer::BufferId, buffer_id: crate::buffer::BufferId,
) -> Result<Vec<u8>, String> { ) -> Result<InitialTargetSnapshot, String> {
let core = editor.core.borrow(); let core = editor.core.borrow();
let mut registry = core.registry.borrow_mut(); let mut registry = core.registry.borrow_mut();
let buffer = registry let buffer = registry
.get_mut(buffer_id) .get_mut(buffer_id)
.map_err(|error| format!("initial target buffer disappeared: {error}"))?; .map_err(|error| format!("initial target buffer disappeared: {error}"))?;
if !buffer.is_crdt_backed() { let upgraded_to_crdt = !buffer.is_crdt_backed();
if upgraded_to_crdt {
let peer_id = crate::crdt::peer_id_from_frontend(FrontendId::LOCAL); let peer_id = crate::crdt::peer_id_from_frontend(FrontendId::LOCAL);
buffer buffer
.upgrade_to_crdt(peer_id) .upgrade_to_crdt(peer_id)
.map_err(|error| format!("initial target CRDT upgrade failed: {error:?}"))?; .map_err(|error| format!("initial target CRDT upgrade failed: {error:?}"))?;
} }
buffer let crdt_snapshot = buffer
.crdt_state() .crdt_state()
.ok_or_else(|| "initial target CRDT state is unavailable".to_owned())? .ok_or_else(|| "initial target CRDT state is unavailable".to_owned())?
.export_snapshot() .export_snapshot()
.map_err(|error| format!("initial target snapshot export failed: {error:?}")) .map_err(|error| format!("initial target snapshot export failed: {error:?}"))?;
Ok(InitialTargetSnapshot {
crdt_snapshot,
upgraded_to_crdt,
})
} }
#[cfg(not(feature = "crdt"))] #[cfg(not(feature = "crdt"))]
fn initial_target_snapshot( fn initial_target_snapshot(
_editor: &EditorState, _editor: &EditorState,
_buffer_id: crate::buffer::BufferId, _buffer_id: crate::buffer::BufferId,
) -> Result<Vec<u8>, String> { ) -> Result<InitialTargetSnapshot, String> {
Err("initial target requires a CRDT-enabled daemon".to_owned()) Err("initial target requires a CRDT-enabled daemon".to_owned())
} }
@ -1714,7 +1726,9 @@ fn cleanup_provisional_session(
) { ) {
render_states.remove(&frontend_id); render_states.remove(&frontend_id);
semantic_states.remove(&frontend_id); semantic_states.remove(&frontend_id);
streams.remove(&frontend_id); if let Some(stream) = streams.remove(&frontend_id) {
let _ = stream.shutdown(Shutdown::Both);
}
term_sizes.remove(&frontend_id); term_sizes.remove(&frontend_id);
last_active_buffer_sent.remove(&frontend_id); last_active_buffer_sent.remove(&frontend_id);
session_registry.unregister_session(frontend_id); session_registry.unregister_session(frontend_id);
@ -1770,7 +1784,7 @@ fn handle_session_established(
let negotiated_protocol_version = session_state.negotiated_protocol_version; let negotiated_protocol_version = session_state.negotiated_protocol_version;
if let Some(opened) = opened_target.as_ref() { if let Some(opened) = opened_target.as_ref() {
let snapshot = match initial_target_snapshot(editor, opened.buffer_id) { let target_snapshot = match initial_target_snapshot(editor, opened.buffer_id) {
Ok(snapshot) => snapshot, Ok(snapshot) => snapshot,
Err(message) => { Err(message) => {
send_initial_target_failure(&mut write_stream, message); send_initial_target_failure(&mut write_stream, message);
@ -1783,9 +1797,9 @@ fn handle_session_established(
}; };
let snapshot_message = InstanceMessage::BufferSnapshot { let snapshot_message = InstanceMessage::BufferSnapshot {
buffer_id: opened.buffer_id, buffer_id: opened.buffer_id,
crdt_snapshot: snapshot, crdt_snapshot: target_snapshot.crdt_snapshot,
}; };
if opened.publish_to_replicas { if opened.publish_to_replicas || target_snapshot.upgraded_to_crdt {
publish_buffer_snapshot_to_replicas( publish_buffer_snapshot_to_replicas(
editor, editor,
opened.buffer_id, opened.buffer_id,
@ -1796,6 +1810,7 @@ fn handle_session_established(
); );
} }
if write_message(&mut write_stream, &snapshot_message).is_err() { if write_message(&mut write_stream, &snapshot_message).is_err() {
let _ = write_stream.shutdown(Shutdown::Both);
editor editor
.core .core
.borrow_mut() .borrow_mut()
@ -1888,6 +1903,10 @@ fn handle_dispatcher_event(
); );
} }
DispatcherEvent::FrontendEvent { source, event } => { DispatcherEvent::FrontendEvent { source, event } => {
if session_registry.session_state(source).is_none() {
eprintln!("pmacs: dropping frontend event from uninstalled session {source:?}");
return;
}
match event { match event {
FrontendEvent::Detach(_) => { FrontendEvent::Detach(_) => {
// The per-attach thread will follow up with a // The per-attach thread will follow up with a
@ -2102,9 +2121,12 @@ fn handle_dispatcher_event(
} }
} }
_ => { _ => {
let term_size = *term_sizes let Some(&term_size) = term_sizes.get(&source) else {
.get(&source) eprintln!(
.expect("term_size present for source"); "pmacs: dropping frontend event without size state for {source:?}"
);
return;
};
let mut term_size = term_size; let mut term_size = term_size;
if let Some(render_state) = render_states.get_mut(&source) { if let Some(render_state) = render_states.get_mut(&source) {
apply_event(editor, source, event, &mut term_size, render_state); apply_event(editor, source, event, &mut term_size, render_state);
@ -2123,10 +2145,8 @@ fn handle_dispatcher_event(
// nothing before B1.) // nothing before B1.)
apply_semantic_input_event(editor, source, event, term_size); apply_semantic_input_event(editor, source, event, term_size);
} else { } else {
debug_assert!( eprintln!(
false, "pmacs: dropping frontend event without render state for {source:?}"
"fid with neither a render_state nor a semantic_state \
sent a frontend event"
); );
} }
} }
@ -3168,7 +3188,6 @@ 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);
@ -3263,6 +3282,44 @@ mod tests {
); );
} }
#[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] #[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()));
@ -3654,6 +3711,18 @@ mod tests {
let mut last_active_buffer_sent = HashMap::new(); let mut last_active_buffer_sent = HashMap::new();
let mut terminal_bell_baselines = HashMap::new(); let mut terminal_bell_baselines = HashMap::new();
let mut session_registry = SessionRegistry::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( handle_dispatcher_event(
DispatcherEvent::FrontendEvent { DispatcherEvent::FrontendEvent {

View File

@ -281,7 +281,11 @@ mod crdt {
} }
} }
fn request_raw_target(socket: &Path, cwd: Vec<u8>, path: Vec<u8>) -> Vec<InstanceMessage> { 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"); let mut stream = UnixStream::connect(socket).expect("connect raw target frontend");
stream stream
.set_read_timeout(Some(Duration::from_secs(5))) .set_read_timeout(Some(Duration::from_secs(5)))
@ -310,14 +314,19 @@ mod crdt {
.expect("send raw target"); .expect("send raw target");
let first = read_message::<InstanceMessage>(&mut stream).expect("raw target result"); let first = read_message::<InstanceMessage>(&mut stream).expect("raw target result");
if matches!(first, InstanceMessage::BufferSnapshot { .. }) { let messages = if matches!(first, InstanceMessage::BufferSnapshot { .. }) {
vec![ vec![
first, first,
read_message::<InstanceMessage>(&mut stream).expect("raw opened result"), read_message::<InstanceMessage>(&mut stream).expect("raw opened result"),
] ]
} else { } else {
vec![first] 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 { fn spawn_daemon(socket: &Path, envs: &[(&str, &str)]) -> Child {
@ -666,8 +675,8 @@ mod crdt {
(cwd.clone(), vec![b'x'; 32 * 1024 + 1]), (cwd.clone(), vec![b'x'; 32 * 1024 + 1]),
(cwd.clone(), b".".to_vec()), (cwd.clone(), b".".to_vec()),
]; ];
for (bad_cwd, bad_path) in invalid { for (index, (bad_cwd, bad_path)) in invalid.into_iter().enumerate() {
let messages = request_raw_target(&socket, bad_cwd, bad_path); let (frontend_id, mut stream, messages) = open_raw_target(&socket, bad_cwd, bad_path);
assert_eq!(messages.len(), 1, "failure must send no snapshot"); assert_eq!(messages.len(), 1, "failure must send no snapshot");
match &messages[0] { match &messages[0] {
InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message }) => { InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message }) => {
@ -676,8 +685,20 @@ mod crdt {
} }
other => panic!("expected bounded target failure, got {other:?}"), 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"); 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")); let survivor = attach_target(&socket, temp.path(), Path::new("still-alive.txt"));
assert_eq!(survivor.replica.materialize_string(), "alive\n"); assert_eq!(survivor.replica.materialize_string(), "alive\n");
@ -685,6 +706,63 @@ mod crdt {
assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success()); 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] #[test]
fn target_killed_by_hook_fails_closed_and_slow_hook_holds_the_ready_barrier() { fn target_killed_by_hook_fails_closed_and_slow_hook_holds_the_ready_barrier() {
let temp = secure_tempdir(); let temp = secure_tempdir();