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:
parent
65e500eba1
commit
be8c67c30c
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 1–2 (#126/#130), and completed Themes Arc 4
|
config registry (#127), Vterm Stages 1–2 (#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; v6–v19 wire encodings stay
|
appended `InitialTargetResult` readiness barrier; v6–v19 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]`;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
103
src/daemon.rs
103
src/daemon.rs
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue