Merge pull request #183 from levineuwirth/journey-gpu-directory-ratchet

test(journey): ratchet directory GPU bootstrap success
This commit is contained in:
Levi Neuwirth 2026-07-28 15:22:40 +00:00 committed by GitHub
commit 7fd646dcac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 339 additions and 111 deletions

View File

@ -1,6 +1,6 @@
# Active work — cross-machine resume ledger # Active work — cross-machine resume ledger
**Snapshot: 2026-07-26.** This file records volatile work that has not **Snapshot: 2026-07-28.** This file records volatile work that has not
landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
entries when their PR merges; do not let this become a second permanent entries when their PR merges; do not let this become a second permanent
backlog. backlog.
@ -27,19 +27,12 @@ landed regardless of what a lane says.
machine-local: `origin` may name this canonical URL, a release mirror, machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone. or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot: - Canonical base at this snapshot:
`githubsucks/main` @ `42025e4` (Lean 4 Stage 4b #181, atop the dired `githubsucks/main` @ `c2d56ff` (Journey Stage 1a #182, which
Stage 1 landed docs #169 and the PTY-terminate diagnostic #176, incorporated terminal configuration + copy mode #180, atop Lean 4
terminal copy mode #178, the GPU-terminal-input landed docs #168, Lean Stage 4b #181 and the previously recorded landed work; protocol v20).
4 Stage 4a #179, bottom-panel Stage 2A #177, the bottom-panel Stage 2 The previous snapshot named `42025e4`, and **the recovery floor
framing #175, terminal configuration Stage 1 #173, Lean 4 Stage 3b advances with it**: the check below now requires `c2d56ff` or newer,
#170, Stage 3a #167, the CRDT undo repro #157, the inline-math so a tree at `42025e4` no longer passes. That is
landed-doc refresh #172, the bottom-panel landed-doc refresh #156, the
inline-math slice #158, dired Stage 1 #165, the GPU terminal input fix
#166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163,
find-file #162, Lean 4 Stage 1 #160, and the minimap blank-slab fix
#159; protocol v20). The previous snapshot named `74301d1`, and **the
recovery floor advances with it**: the check below now requires
`42025e4` or newer, so a tree at `74301d1` no longer passes. That is
deliberate — the floor moves with the base, because a check that deliberate — the floor moves with the base, because a check that
accepts an older commit than the declared base passes on a tree the accepts an older commit than the declared base passes on a tree the
rest of this file does not describe. rest of this file does not describe.
@ -78,7 +71,7 @@ git worktree list
git status --short --branch git status --short --branch
``` ```
The `git log` command must expose `42025e4` — the base named above — or a The `git log` command must expose `c2d56ff` — the base named above — or a
newer intentional main. Keep this threshold and the canonical-base line in newer intentional main. Keep this threshold and the canonical-base line in
step: a recovery check that accepts an older commit than the base it step: a recovery check that accepts an older commit than the base it
declares canonical will pass on a tree the rest of this file does not declares canonical will pass on a tree the rest of this file does not
@ -178,50 +171,91 @@ If it does not, stop and repair the remote/fetch configuration.
to recur; the next occurrence carries its own evidence under whoever's to recur; the next occurrence carries its own evidence under whoever's
PR, and a Stage B framing follows then. PR, and a Stage B framing follows then.
## Journey Stage 1a — PR #182 OPEN, review round 1 closed ## Journey/GPU directory-target ratchet — PR #183 GATED ON PUBLIC PATH
- Framing `docs/journey-stage1a-framing.md` **rev 8** (four review - **Approved correction, not new product behavior.** GPU initial-target
rounds, two correction revisions found during implementation, one from framing Q#GT6 / acceptance 10 and Journey Stage 1a N2/N5 already make
review round 1 of PR #182). a directory target a success; `COHERENCE.md` §2 treats that path as a
Branch `journey-stage1a-directory-open`, based on `githubsucks/main` protected journey. The stale GPU integration test still listed `"."`
@ `42025e4` (rebased onto `74301d1`, then integrated `42025e4` and the among malformed/unloadable targets after #182 landed.
landed-docs work below by merge — the branch is under review, so its - Branch `journey-gpu-directory-ratchet`, based directly on canonical
history is no longer rewritten). `main` @ `c2d56ff`. Recovery: `git fetch githubsucks && git checkout
- Recovery: `git fetch githubsucks && git checkout journey-gpu-directory-ratchet`. Everything described here is
journey-stage1a-directory-open`. Everything below is committed and committed and pushed; nothing depends on the `/tmp` worktree.
pushed; nothing depends on a worktree or `/tmp`. - PR #183:
- **Ships:** the directory arm on `resolve_target_buffer`, <https://github.com/levineuwirth/pmacs/pull/183>. It is intentionally
`EditorState::open` rewritten as a caller of it (the unification), the open and unmerged pending user review.
`path.open-directory` chain + `pmacs.path.directory_handler` fallback - **Scope is one acceptance ratchet:** remove `"."` from the four
slot, `pmacs.window.commit_to` with its scoped frontend and preflight, genuinely invalid cases and drive the public `pmacs --gpu .` root
the nonconstructible destination userdata, the daemon bootstrap arm, broker through the real managed GPU connector. The positive requires
and `tests/journey_acceptance.rs` (**24 pins** as of rev 8 — a count, snapshot-first + `InitialTargetResult::Opened`, then consumes the
not a constant; re-read it rather than quoting this line). No protocol post-quiescence replacement snapshot and requires dired's canonical
change — still v20. header plus a known directory entry before proving the same daemon can
- **Doc updates ride the PR** per COHERENCE §25: §2 grade + step-3 open a following file target. The private display-less acceptance
verdict row, §20 Priority 1 + the arc list, the GPU initial-target probe now reports its snapshot count and final materialized text so
framing's Q#GT6 / acceptance 10 supersession, handoff §1. that public path is observable. No normal frontend/daemon behavior,
- **Bite results** (each mutation run against the full suite): scope protocol, framing decision, or coherence grade changes.
stops swapping `core.active_frontend` → N6a + P3 fail, nothing else; - **Review round 1: three findings, all real and corrected.** The
preflight moved after the callback → P1 + P2 fail, nothing else; drop first test stopped at the deliberately pre-existing bootstrap
the `ScopedFrontend` arm from `acting_frontend` → N4b fails, nothing document, so it did not pin the resolver's later dired commit. The
else. That last mutation is why N4b exists — it left N4 green. Stage 2 rev-5 recovery bullet named rev 4's framing branch. And the
Round 1 of PR #182 added two more: dired's `display` back to durable handoff still named pre-Journey `main` even though this ledger
`switch_buffer`, and `prev` read from the ambient window → each fails had advanced. The first is now a post-quiescence transport assertion;
**N4c** alone. **The scope pins the frontend, not the window** — every the latter two are corrected in this revision. The complete matrix
routing pin before N4c varied frontend identity and none varied the below is green on the corrected tree.
selected window within one frontend, so 23 green pins missed it. - **Live public-path check tightened that correction further.** A user
- Ordering: PR #177 MERGED (2026-07-26), so 1a was unblocked. 1a lands report that `pmacs --gpu .` differed from `pmacs .` did not reproduce:
before dired Stage 2. When 1a lands, Stage 2 must re-scout and revise the live default daemon delivered both snapshots, and a traced
its framing around the scoped `pmacs.window.commit_to` boundary before windowed invocation applied both and displayed dired. It nevertheless
its implementation branch is cut. That revision is a prerequisite, not exposed that the corrected acceptance still attached a raw protocol
a review-time discovery. client rather than invoking the public root broker and real GPU
- **Named deferrals carried out of this stage:** dired's *interactive* connector. The test now covers those surfaces and passes **15/15**;
paths (`C-x d`, tree descent, refresh) still rely on the ambient its full matrix is green at `34b8f28`.
frontend a tick later and are not migrated onto captured destinations; - **Review round 2 found and fixed one failure-path cleanup gap at
the stale startup scratch buffer is still not removed (only the false `dc2dc42`.** The public-path test waited directly for dired's second
doc comment is corrected); `resolve_target_buffer`'s directory arm has snapshot, but `ManagedProbe` only retained a spawned daemon's PID after
no picker, only the chain that leaves room for one. a successful wait. If the exact missing-snapshot regression recurred,
the timeout would therefore be unable to terminate that daemon. The
test now consumes the initial ready report first, retaining lifecycle
facts before it starts the post-quiescence assertion.
- **Review-round-2 gates are green:** `cargo fmt --check`; strict
workspace clippy; library **1,849 passed / 3 ignored**; CRDT library
**2,034 passed / 4 ignored**; focused public-path test **1/1** and full
GPU invocation suite **15/15**; M4 **121 passed / 3 ignored / 1
filtered**; required GPU package **202/202**; `git diff --check`.
The first in-sandbox GPU-package attempt reproduced the repository's
documented Unix-socket restriction as three attach failures plus a
blocked peer read; the authoritative out-of-sandbox rerun passed all
**202** tests in under one second.
- **Full gate matrix is green on the public-path revision at `34b8f28`:**
- `cargo fmt --check`;
- strict workspace clippy;
- library **1,849 passed / 3 ignored**;
- CRDT library **2,034 passed / 4 ignored**;
- touched GPU initial-target suite **15/15**;
- connected Journey/Dired/find-file/theme/bottom-panel acceptance
suites **125/125**;
- M4 **121 passed / 3 ignored / 1 filtered**;
- required GPU package **202/202**;
- `git diff --check`;
- isolated-config, one-invocation full workspace sweep green on its
final run.
- **Gate diagnostics retained:** initial fresh-worktree attempts without
the documented `pmacs-gpu` prerequisite and without out-of-sandbox
Unix-socket permission were setup failures, not product evidence.
During the review rerun, an in-sandbox library attempt reproduced the
latter setup failure as `EPERM` in three attach socket tests; the
authoritative out-of-sandbox rerun passed all **1,849** non-ignored
tests.
The first full-workspace attempt then exhausted the `/tmp` filesystem
quota while linking. Moving only the disposable Cargo target to disk
let the sweep run; its first completed pass exposed one transient
`pmacs-gpu` font-facts unit-test red after the standalone GPU gate had
passed. The exact test passed immediately against the identical build,
and the required complete one-invocation rerun was green, including
GPU **202/202**.
- This side quest lands before bottom-panel 2B-1 opens its PR. After it
merges, 2B-1 integrates the new `main` and reruns its full matrix.
## The CRDT half of the test corpus is dark in CI — NEEDS A LANE ## The CRDT half of the test corpus is dark in CI — NEEDS A LANE
@ -358,10 +392,43 @@ If it does not, stop and repair the remote/fetch configuration.
never been enforced. Any CI job that compiles the `crdt` targets has to never been enforced. Any CI job that compiles the `crdt` targets has to
fix them first or it will be red on arrival. fix them first or it will be red on arrival.
## Bottom-panel lane (Arc 7) — Stages 1, 2A + framing MERGED; 2B is next ## Bottom-panel lane (Arc 7) — 2B-1 GATED; waiting on ratchet side quest
Stage 1, the Stage 2 framing, and Stage 2A are all on `main`. **Stage 2B Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing
has not started.** revision 5's three-way split of 2B was explicitly approved on
2026-07-27. **Stage 2B-1 is implemented, integrated with canonical
`main`, and pushed at `b9123c2`; no PR is open.** Its own omissions found
by gating are corrected. The Journey/GPU ratchet lane above is its sole
remaining dependency.
- **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on
`githubsucks/main` @ `c2d56ff` by merge because review had begun.
Recovery: `git fetch githubsucks && git checkout
bottom-panel-stage2b`. Everything is committed and pushed; nothing
depends on a worktree or `/tmp`.
- **Ships only the v21 wire layer:** the four wire shapes, version bump,
shared cell-grid validator, and version-ladder move. It has no
producer, consumer, or capability change; `panel_capable` stays
`false`, so this slice changes no user-visible journey grade.
- **Review round 1 closed:** two P1s and one P2, all corrected at
`9b364ad`: `PanelFrame` now identifies its buffer, the transport
ratchet covers the actual attach path rather than a detached codec
assertion, and shared grid bounds have one validator.
- **The full gate found and corrected two further 2B-1 omissions at
`b9123c2`:** the statusline version ladder still pinned v20/rejected
v21, and Vterm Stage 3 pinned v20 both structurally and in its real
headless probe. Those ratchets now expect v21 and, where applicable,
reject v22.
- **Green evidence except for the main-side stale ratchet this side
quest owns:** formatting and strict Clippy; default/CRDT libraries;
every bottom-panel, folding, GPU-font, statusline, semantic, Vterm,
M4, and required-GPU gate; and the isolated-config full-workspace
sweep. The exact counts and the classified M8 timing rerun live at
`b9123c2`.
- **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1
lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine
belongs to 2B-2; the GPU band and negotiated capability flip belong
to 2B-3.
- **Stage 2A MERGED as #177** (`main` @ `0a3fcd1`, 2026-07-26, all twelve - **Stage 2A MERGED as #177** (`main` @ `0a3fcd1`, 2026-07-26, all twelve
checks green at `8424172`, three review rounds). Branch checks green at `8424172`, three review rounds). Branch
@ -374,19 +441,7 @@ has not started.**
`primary_document_window` returns `view.active` in every existing `primary_document_window` returns `view.active` in every existing
configuration, so this is seam adoption that becomes load-bearing in configuration, so this is seam adoption that becomes load-bearing in
2B. 2B.
- **Stage 2B is approved and unstarted.** It branches from `main`, **not - **Stage 2A verification on its merge result:** `cargo fmt --check` clean; strict
stacked on 2A**, per the framing §9. Scope: protocol v21, the daemon
panel projection, the GPU band, and the negotiated `panel_capable`
flip. `docs/bottom-panel-stage2-framing.md` §7.2 carries its five
acceptance criteria (A2B-1..5) plus the reassertion of parent
criterion 52, and §8 records **no open items**, so 2B needs no further
framing round. Its sharpest trap is §5.3's three-boundary split: the
GPU `text_area_bottom` is `status_band_top`,
`geometry_capacity_bottom` and `document_text_bottom` at once, and a
blanket rewrite moves the status chrome along with the document while
still satisfying an "everything moved" assertion — hence A2B-4's
contrast form.
- Verification on the merge result: `cargo fmt --check` clean; strict
workspace Clippy clean; **1,832 default + 2,015 CRDT** library tests; workspace Clippy clean; **1,832 default + 2,015 CRDT** library tests;
`bottom_panel_stage2a_acceptance` **17**; bottom-panel Stage 1 46; `bottom_panel_stage2a_acceptance` **17**; bottom-panel Stage 1 46;
statusline segments 8 CRDT; m11_5 semantic 2 CRDT; GPU initial target statusline segments 8 CRDT; m11_5 semantic 2 CRDT; GPU initial target
@ -442,26 +497,31 @@ has not started.**
`docs/agent-handoff.md` §1; the two round lessons are in §5. `docs/agent-handoff.md` §1; the two round lessons are in §5.
- Landed-docs follow-up merged as **#156** (`main` @ `d152120`, - Landed-docs follow-up merged as **#156** (`main` @ `d152120`,
2026-07-25). 2026-07-25).
- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 4**, - **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 5**
on branch `githubsucks/bottom-panel-stage2-framing` (three commits, is commit `56301ed` on branch `githubsucks/bottom-panel-stage2b`,
one per revision), worktree `../pmacs-bp-stage2`, based on worktree `../pmacs-bp-stage2b`. Revisions 1–4 remain on
`githubsucks/main` @ `ccf29e3`. Round 1 closed 2 blocking + 3 high; `githubsucks/bottom-panel-stage2-framing` (head `4fbd47f`, four
framing commits, revision 4 at `49757e5`). Round 1 closed 2 blocking +
3 high;
round 2 closed 1 blocking + 2 high + 1 medium and decided both open round 2 closed 1 blocking + 2 high + 1 medium and decided both open
items; round 3 closed 1 blocking + 1 high + 1 medium. No open items items; round 3 closed 1 blocking + 1 high + 1 medium. No open items
remain. The approved remain. Revision 5 adds no decision; it records the approved
2B-1/2B-2/2B-3 implementation split. The
parent framing `docs/bottom-panel-framing.md` (rev 4) remains parent framing `docs/bottom-panel-framing.md` (rev 4) remains
authoritative, **including its acceptance criteria 37–55**. authoritative, **including its acceptance criteria 37–55**.
- Retained, carrying nothing unmerged: branch `bottom-panel` and worktree - Retained, carrying nothing unmerged: branch `bottom-panel` and worktree
`../pmacs-bottom-panel`. `../pmacs-bottom-panel`.
- **Stage 2 ships as two serial slices**, 2A landing before 2B branches: - **Stage 2 ships as four serial implementation slices**, each landing
before the next branches:
**2A** = classified §1.3 census routing + `paint_frame` per-window **2A** = classified §1.3 census routing + `paint_frame` per-window
painter extraction (with the active-window auto-scroll preparation), no painter extraction (with the active-window auto-scroll preparation), no
protocol change; **2B** = protocol **v21** protocol change; **2B-1** = protocol **v21**
(`InstanceMessage::PanelFrame` plus (`InstanceMessage::PanelFrame` plus
`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`, `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`,
gated both directions, each extended enum byte-pinned on its own gated both directions, each extended enum byte-pinned on its own
previous final variant), daemon panel projection, the GPU band, and the previous final variant); **2B-2** = daemon panel projection and epoch
negotiated `panel_capable` flip. Stage 3 is the adopter default flip. machine; **2B-3** = the GPU band and negotiated `panel_capable` flip.
Stage 3 is the adopter default flip.
- **Correction — this entry previously mis-stated the census contract.** - **Correction — this entry previously mis-stated the census contract.**
It is **not** "route every consumer through `primary_document_window`". It is **not** "route every consumer through `primary_document_window`".
Q#BP14 classifies the 23 reads into four classes and routes only the Q#BP14 classifies the 23 reads into four classes and routes only the

View File

@ -1,6 +1,9 @@
# Agent handoff — cross-machine continuity # Agent handoff — cross-machine continuity
**Last updated: 2026-07-26, after terminal copy mode (#178) — `C-c C-t` **Last updated: 2026-07-28, after Journey Stage 1a (#182), which made
directory startup one coherent local/daemon/GPU path and incorporated
the terminal configuration + copy mode landed-doc work (#180); following
terminal copy mode (#178) — `C-c C-t`
materializes a terminal's whole retained range into an ordinary buffer, materializes a terminal's whole retained range into an ordinary buffer,
plus `Buffer::set_generated_contents`, the first genuinely immutable plus `Buffer::set_generated_contents`, the first genuinely immutable
generated-buffer write path — and its landed-doc pair (#168); following generated-buffer write path — and its landed-doc pair (#168); following
@ -39,10 +42,12 @@ 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-26) ## 1. Where the project stands (2026-07-28)
- `main` @ `42025e4` (Lean 4 Stage 4b #181, atop the dired Stage 1 - `main` @ `c2d56ff` (Journey Stage 1a #182, incorporating terminal
landed docs #169 and the PTY-terminate diagnostic #176, terminal copy configuration + copy mode landed docs #180, atop Lean 4 Stage 4b #181,
the dired Stage 1 landed docs #169 and the PTY-terminate diagnostic
#176, terminal copy
mode #178, the GPU-terminal-input landed docs #168, Lean 4 Stage 4a mode #178, the GPU-terminal-input landed docs #168, Lean 4 Stage 4a
#179, bottom-panel Stage 2A #179, bottom-panel Stage 2A
#177, the bottom-panel Stage 2 framing #175, terminal configuration #177, the bottom-panel Stage 2 framing #175, terminal configuration

View File

@ -936,10 +936,20 @@ fn run_headless_managed_probe(
} }
}; };
let mut client = managed.client; let mut client = managed.client;
let initial_message = client.take_initial_message();
let initial_target_ready = matches!( let initial_target_ready = matches!(
client.take_initial_message(), initial_message.as_ref(),
Some(InstanceMessage::BufferSnapshot { .. }) Some(InstanceMessage::BufferSnapshot { .. })
); );
let mut buffer_facts = ManagedProbeBufferFacts::default();
if let Some(message) = initial_message.as_ref()
&& let Err(error) = buffer_facts.observe(message)
{
let contents = format!("phase=error\nerror={error}\n");
let _ = write_probe_report(report, &contents);
eprintln!("pmacs-gpu managed probe: {error}");
return 7;
}
let daemon = managed.daemon; let daemon = managed.daemon;
let protocol = client.server_protocol_version(); let protocol = client.server_protocol_version();
@ -961,8 +971,14 @@ fn run_headless_managed_probe(
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 if ready
&& let Err(error) = && let Err(error) = write_managed_probe_report(
write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) report,
"ready",
protocol,
&daemon,
&buffer_facts,
&disconnect,
)
{ {
eprintln!( eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}", "pmacs-gpu managed probe: writing {} failed: {error}",
@ -976,11 +992,23 @@ fn run_headless_managed_probe(
} }
match event_rx.recv_timeout(Duration::from_millis(50)) { match event_rx.recv_timeout(Duration::from_millis(50)) {
Ok(AttachEvent::Message(message)) => { Ok(AttachEvent::Message(message)) => {
if matches!(*message, InstanceMessage::BufferSnapshot { .. }) && !ready { let is_snapshot = matches!(*message, InstanceMessage::BufferSnapshot { .. });
if let Err(error) = buffer_facts.observe(&message) {
let contents = format!("phase=error\nerror={error}\n");
let _ = write_probe_report(report, &contents);
eprintln!("pmacs-gpu managed probe: {error}");
return 7;
}
if is_snapshot {
ready = true; ready = true;
if let Err(error) = if let Err(error) = write_managed_probe_report(
write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) report,
{ "ready",
protocol,
&daemon,
&buffer_facts,
&disconnect,
) {
eprintln!( eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}", "pmacs-gpu managed probe: writing {} failed: {error}",
report.display() report.display()
@ -1006,9 +1034,14 @@ fn run_headless_managed_probe(
|| wait_result != last_wait_result || wait_result != last_wait_result
|| disconnect != last_disconnect) || disconnect != last_disconnect)
{ {
if let Err(error) = if let Err(error) = write_managed_probe_report(
write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) report,
{ "ready",
protocol,
&daemon,
&buffer_facts,
&disconnect,
) {
eprintln!( eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}", "pmacs-gpu managed probe: writing {} failed: {error}",
report.display() report.display()
@ -1021,9 +1054,14 @@ fn run_headless_managed_probe(
} }
if ready && stdin_closed { if ready && stdin_closed {
if let Err(error) = if let Err(error) = write_managed_probe_report(
write_managed_probe_report(report, "complete", protocol, &daemon, &disconnect) report,
{ "complete",
protocol,
&daemon,
&buffer_facts,
&disconnect,
) {
eprintln!( eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}", "pmacs-gpu managed probe: writing {} failed: {error}",
report.display() report.display()
@ -1043,11 +1081,42 @@ fn run_headless_managed_probe(
} }
} }
#[derive(Default)]
struct ManagedProbeBufferFacts {
snapshots: u32,
last_snapshot_text: String,
}
impl ManagedProbeBufferFacts {
fn observe(&mut self, message: &InstanceMessage) -> Result<(), String> {
let InstanceMessage::BufferSnapshot { crdt_snapshot, .. } = message else {
return Ok(());
};
let doc = loro::LoroDoc::new();
doc.import(crdt_snapshot)
.map_err(|error| format!("BufferSnapshot import failed: {error:?}"))?;
self.snapshots += 1;
self.last_snapshot_text = doc.get_text(LORO_TEXT_CONTAINER).to_string();
Ok(())
}
}
fn hex_bytes(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(encoded, "{byte:02x}");
}
encoded
}
fn write_managed_probe_report( fn write_managed_probe_report(
report: &Path, report: &Path,
phase: &str, phase: &str,
protocol: u32, protocol: u32,
daemon: &attach::ManagedDaemonFacts, daemon: &attach::ManagedDaemonFacts,
buffer_facts: &ManagedProbeBufferFacts,
disconnect: &str, disconnect: &str,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
use std::fmt::Write as _; use std::fmt::Write as _;
@ -1056,6 +1125,12 @@ fn write_managed_probe_report(
let _ = writeln!(out, "phase={phase}"); let _ = writeln!(out, "phase={phase}");
let _ = writeln!(out, "server_protocol_version={protocol}"); let _ = writeln!(out, "server_protocol_version={protocol}");
let _ = writeln!(out, "buffer_snapshot=true"); let _ = writeln!(out, "buffer_snapshot=true");
let _ = writeln!(out, "buffer_snapshots={}", buffer_facts.snapshots);
let _ = writeln!(
out,
"last_snapshot_hex={}",
hex_bytes(buffer_facts.last_snapshot_text.as_bytes())
);
let _ = writeln!(out, "spawned_daemon={}", daemon.spawned_daemon()); let _ = writeln!(out, "spawned_daemon={}", daemon.spawned_daemon());
let _ = writeln!( let _ = writeln!(
out, out,

View File

@ -121,6 +121,19 @@ mod crdt {
.collect() .collect()
} }
fn decode_hex(encoded: &str) -> String {
assert_eq!(encoded.len() % 2, 0, "hex payload must have even length");
let bytes = encoded
.as_bytes()
.chunks_exact(2)
.map(|pair| {
let pair = std::str::from_utf8(pair).expect("hex pair is UTF-8");
u8::from_str_radix(pair, 16).expect("decode hex pair")
})
.collect::<Vec<_>>();
String::from_utf8(bytes).expect("snapshot text is UTF-8")
}
fn wait_for_fact( fn wait_for_fact(
report: &Path, report: &Path,
key: &str, key: &str,
@ -356,6 +369,16 @@ mod crdt {
} }
impl ManagedProbe { impl ManagedProbe {
fn from_child(mut child: Child, report: &Path) -> Self {
let stdin = child.stdin.take().expect("probe stdin");
Self {
child,
stdin: Some(stdin),
report: report.to_owned(),
daemon_pid: None,
}
}
fn spawn(socket: &Path, report: &Path, daemon_executable: &Path, home: &Path) -> Self { fn spawn(socket: &Path, report: &Path, daemon_executable: &Path, home: &Path) -> Self {
Self::spawn_with_env(socket, report, daemon_executable, home, &[]) Self::spawn_with_env(socket, report, daemon_executable, home, &[])
} }
@ -418,18 +441,11 @@ mod crdt {
for (key, value) in envs { for (key, value) in envs {
command.env(key, value); command.env(key, value);
} }
let mut child = command.spawn().expect("spawn managed probe"); Self::from_child(command.spawn().expect("spawn managed probe"), report)
let stdin = child.stdin.take().expect("probe stdin");
Self {
child,
stdin: Some(stdin),
report: report.to_owned(),
daemon_pid: None,
}
} }
fn wait_ready(&mut self) -> HashMap<String, String> { fn wait_for(&mut self, key: &str, expected: &str) -> HashMap<String, String> {
let facts = wait_for_fact(&self.report, "phase", "ready", Duration::from_secs(10)); let facts = wait_for_fact(&self.report, key, expected, Duration::from_secs(10));
if facts if facts
.get("spawned_daemon") .get("spawned_daemon")
.is_some_and(|value| value == "true") .is_some_and(|value| value == "true")
@ -439,6 +455,10 @@ mod crdt {
facts facts
} }
fn wait_ready(&mut self) -> HashMap<String, String> {
self.wait_for("phase", "ready")
}
fn close(mut self) -> std::process::ExitStatus { fn close(mut self) -> std::process::ExitStatus {
self.stdin.take(); self.stdin.take();
wait_for_fact(&self.report, "phase", "complete", Duration::from_secs(5)); wait_for_fact(&self.report, "phase", "complete", Duration::from_secs(5));
@ -662,6 +682,75 @@ mod crdt {
assert!(probe.close().success()); assert!(probe.close().success());
} }
#[test]
fn public_gpu_directory_target_reaches_dired_and_leaves_the_daemon_usable() {
let temp = secure_tempdir();
let socket = temp.path().join("directory-target.sock");
let report = temp.path().join("directory-target-report");
let wrapper = temp.path().join("headless-gpu");
let listed_name = "listed-before-bootstrap.txt";
fs::write(temp.path().join(listed_name), "listed\n").expect("write listed file");
write_script(
&wrapper,
"test \"$1\" = \"--managed-attach\"\n\
socket=$2\n\
daemon=$3\n\
shift 3\n\
exec \"$PMACS_REAL_GPU\" --headless-managed-probe \
\"$socket\" \"$PMACS_TEST_REPORT\" \"$daemon\" \"$@\"",
);
let mut command = Command::new(pmacs_binary());
command
.args(["--gpu", "--socket"])
.arg(&socket)
.arg(".")
.current_dir(temp.path())
.env(TEST_GPU_OVERRIDE, &wrapper)
.env("PMACS_REAL_GPU", gpu_binary())
.env("PMACS_TEST_REPORT", &report)
.env("HOME", temp.path())
.env("XDG_CONFIG_HOME", temp.path())
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut directory =
ManagedProbe::from_child(command.spawn().expect("spawn public GPU command"), &report);
// The public root broker and real managed GPU connector must stay
// alive through Journey N2's asynchronous dired commit. Snapshot
// one is the deliberately pre-existing bootstrap document; snapshot
// two is the post-quiescence directory surface.
// Capture the spawned daemon's PID from the ready report first so
// ManagedProbe::drop can terminate it if snapshot two never arrives.
let ready = directory.wait_ready();
assert_eq!(
ready.get("spawned_daemon").map(String::as_str),
Some("true")
);
let facts = directory.wait_for("buffer_snapshots", "2");
let listing = decode_hex(&facts["last_snapshot_hex"]);
let canonical = fs::canonicalize(temp.path()).expect("canonical directory");
let mut lines = listing.lines();
let expected_header = format!("{}:", canonical.display());
assert_eq!(
lines.next(),
Some(expected_header.as_str()),
"the replacement snapshot must be dired's directory surface:\n{listing}"
);
assert!(
lines.any(|line| line.trim_end().ends_with(listed_name)),
"dired must list the file that existed before bootstrap:\n{listing}"
);
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"));
assert_eq!(survivor.replica.materialize_string(), "alive\n");
drop(survivor);
assert!(directory.close().success());
}
#[test] #[test]
fn malformed_or_unloadable_targets_fail_closed_without_poisoning_the_daemon() { fn malformed_or_unloadable_targets_fail_closed_without_poisoning_the_daemon() {
let temp = secure_tempdir(); let temp = secure_tempdir();
@ -673,7 +762,6 @@ mod crdt {
(cwd.clone(), Vec::new()), (cwd.clone(), Vec::new()),
(cwd.clone(), b"bad\0name".to_vec()), (cwd.clone(), b"bad\0name".to_vec()),
(cwd.clone(), vec![b'x'; 32 * 1024 + 1]), (cwd.clone(), vec![b'x'; 32 * 1024 + 1]),
(cwd.clone(), b".".to_vec()),
]; ];
for (index, (bad_cwd, bad_path)) in invalid.into_iter().enumerate() { for (index, (bad_cwd, bad_path)) in invalid.into_iter().enumerate() {
let (frontend_id, mut stream, messages) = open_raw_target(&socket, bad_cwd, bad_path); let (frontend_id, mut stream, messages) = open_raw_target(&socket, bad_cwd, bad_path);