M10.11: adversarial two-laptop acceptance + jitter; the M10 arc verified

The M10 acceptance milestone (two-laptop edit). Framing-pass review
reframed it from confirmatory to **adversarial** verification: the
verification-milestone premise check (M10.11's own discipline,
extracted at M10.10 Day-4) caught its own first-draft framing
asserting "the architecture is complete; this is the verification
milestone" — M10.10's initial verdict was wrong and took six
post-audit rounds, so the M10 arc's correctness is not safely
assumable. M10.11 actively tries to break the arc rather than
confirm it.

Implementation (src/daemon.rs, tests/m10_11_acceptance.rs,
tests/m10_11_perf.rs; prior-pass synthesis/PTY-doubled/Drop-guard
fixtures landed in 05fbbd9's tree, completed here):

- Jitter seam: PMACS_INSTANCE_LATENCY_JITTER_MS + _SEED (SplitMix64,
  no-unsafe/no-dep, default 0xC0FFEE). Q6's "no new injection seams"
  preserved — one sleep-site; jitter-mode delays CellDelta|CrdtOp,
  fixed-latency mode stays CellDelta-only so criterion-1 behavior is
  byte-identical. No drops (Tension B: "packet loss" = latency
  variation only).
- Q13 adversarial scenarios: cat-1 (concurrent same-position
  inserts → deterministic peer-id tiebreak, pinned "A1B1"), cat-2
  (per-frontend undo under causally-pending delayed delivery → B's
  no-op undo doesn't reach A's ops; converge "12"), cat-3 narrowed
  (CRDT state converges across reattach via BufferSnapshot, pinned
  "a1b1"; undo-across-reattach deliberately NOT asserted per
  Finding 4).
- Q8 convergence-under-jitter (seed-pinned; delivery-order-
  independent, pinned "aAbB").
- cat-1/cat-2 pass clean — the arc holds under attack at runtime.

Five findings, all pre-embed (framing-time / Day-1 grep / Day-2
implementation), zero post-audit revision rounds (audit/framing/
prereq docs are gitignored internal-only; this message is the sole
version-controlled record):

- F1 (framing-time): verification-milestone premise check caught its
  own reframe — third arc instance of a discipline addition catching
  a contemporaneous failure.
- F2 (Day-1): framing cited stale fixture locations (β
  framing-pass-time incompleteness, not α temporal drift); Q3
  promotion already done by 05fbbd9's DRY refactor.
- F3 (Day-1): adversarial layer empirically absent in prior
  implementation — validates the reframe (everything confirmatory
  existed, nothing adversarial did).
- F4 (Day-1, M5.8-inherited): reconnect issues a fresh FrontendId
  (no handle_reattach), orphaning per-frontend undo across reattach.
  Classified C; v1.0 action B-i (MANUAL-TEST-CHECKLIST Scenario 4
  documents the limitation honestly + workaround) + B-ii
  (V0.2-PREREQUISITES: SO_PEERCRED-min / token-extended paths).
  Fourth end-to-end-exercise case; first extending the pattern
  beyond M10.8 to a second prior milestone (M5.8).
- F5 (Day-2): Q6×Q8 composition miss — jitter target (CellDelta) ≠
  criterion-3 assertion target (CrdtOp); caught pre-embed by the
  composition-consistency discipline; resolved (B). M10.11-internal
  composition miss (M10.10 Finding-2/4 shape), not inherited.

Scorecard (Option C dual): layer (a) 6/8 milestones-not-findings
(M10.11 joins M10.10 via F5's composition cluster) / 1/8
findings-as-failures; layer (c) 6/8 (M5.8 joins M10.8 via F4;
two clusters — CRDT-pipeline {F1,F3,F5a-M10.8}, reconnect-identity
{F4-M5.8}). Dual-value: layer (a) prediction failed on F5;
pause-point value held (caught pre-embed). M10.11's 5-finding
density empirically validates M10.10's predictive-density model —
property-(b)-at-max, no (a)/(c) → moderate, all pre-embed, zero
post-audit rounds. First validation of the model M10.10 produced.

Verification (clean checkout): lib luajit+crdt 1364/1364, luajit
1211/1211; m5_5 crdt 36/36 (criterion-1 byte-preserved through the
latency-site restructure) + non-crdt 15/15; m10_11 CI-default 5/5
(3 PTY-doubled #[ignore]d, operator-invoked pre-tag); clippy 0
both lanes; fmt clean.

The M10 arc is verified. v1.0 ships after M10.12 (release tag +
TRANSITION-M10.md + collaboration user guide, which inherits the
Scenario-4 honest wording).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-15 20:51:00 -04:00
parent 05fbbd9919
commit b6c07cb840
3 changed files with 1717 additions and 1 deletions

View File

@ -213,6 +213,47 @@ struct DaemonState {
/// signal. v0.2+ work on per-frontend latency injection would
/// move the sleep into a per-frontend writer thread.
injected_render_latency_ms: u64,
/// T M10.11 Q6/Q8 — test-only jitter on top of the fixed latency.
/// Read once at startup from `PMACS_INSTANCE_LATENCY_JITTER_MS`.
/// When `> 0`, each `CellDelta` write is delayed by
/// `injected_render_latency_ms + rand(0..jitter)` instead of the
/// fixed value, simulating variable network latency. No actual
/// drops — TCP/UDS never drops application bytes and loro has no
/// dropped-op recovery (Tension B / Q6: "packet loss" is
/// interpreted as latency variation only). Production leaves
/// this 0.
injected_render_latency_jitter_ms: u64,
/// T M10.11 Q8 — seed for the jitter PRNG. Read from
/// `PMACS_INSTANCE_LATENCY_JITTER_SEED` (default `0xC0FFEE`,
/// matching M10.1's microbench-seed convention) so
/// convergence-under-jitter scenarios are deterministically
/// reproducible. A flake's seed is the one to re-run.
jitter_seed: u64,
}
/// T M10.11 Q8 — `SplitMix64` PRNG for deterministic jitter.
///
/// Chosen because it is six lines of pure wrapping arithmetic: no
/// `unsafe`, no new dependency (the project is `forbid(unsafe_code)`
/// and the `rand` crate would be a production dep pulled in for a
/// test-only seam). Statistically adequate for "uniform-ish delay in
/// `[0, jitter)`"; the jitter scenario asserts CRDT convergence
/// regardless of delay ordering, not a distribution property, so PRNG
/// quality is not load-bearing — only reproducibility (seed) is.
struct SplitMix64(u64);
impl SplitMix64 {
const fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
}
/// T M10.8 Day 4 — RAII guard for the non-multi-session slot.
@ -256,6 +297,18 @@ impl DaemonState {
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// T M10.11 Q6/Q8 — jitter magnitude + PRNG seed, same
// read-once-at-startup discipline. Production leaves both
// unset (jitter 0; seed defaults but unused when jitter 0).
let injected_render_latency_jitter_ms: u64 =
std::env::var("PMACS_INSTANCE_LATENCY_JITTER_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let jitter_seed: u64 = std::env::var("PMACS_INSTANCE_LATENCY_JITTER_SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0x00C0_FFEE);
Self {
instance_name,
started: Instant::now(),
@ -265,6 +318,8 @@ impl DaemonState {
non_multi_session_count: AtomicU64::new(0),
color_registry: std::sync::Mutex::new(HashMap::new()),
injected_render_latency_ms,
injected_render_latency_jitter_ms,
jitter_seed,
}
}
@ -274,6 +329,16 @@ impl DaemonState {
self.injected_render_latency_ms
}
/// T M10.11 Q6/Q8 — jitter magnitude (0 in production).
fn injected_render_latency_jitter_ms(&self) -> u64 {
self.injected_render_latency_jitter_ms
}
/// T M10.11 Q8 — jitter PRNG seed (default `0xC0FFEE`).
fn jitter_seed(&self) -> u64 {
self.jitter_seed
}
/// T M10.9 — look up or assign a color slot for the given uid.
///
/// Same uid across reconnect → same slot (the spec's
@ -388,6 +453,8 @@ pub fn run_daemon(socket_path: PathBuf, instance_name: Option<String>) -> Result
&mut editor,
&shutdown,
daemon_state.injected_render_latency_ms(),
daemon_state.injected_render_latency_jitter_ms(),
daemon_state.jitter_seed(),
)?;
// Dispatcher exited (shutdown or quit). Wake the accept thread
@ -682,17 +749,26 @@ fn per_attach_thread(
clippy::needless_pass_by_value,
clippy::too_many_lines
)]
#[allow(clippy::needless_pass_by_value)]
fn dispatcher_loop(
dispatcher_rx: mpsc::Receiver<DispatcherEvent>,
editor: &mut EditorState,
shutdown: &Arc<AtomicBool>,
injected_render_latency_ms: u64,
injected_render_latency_jitter_ms: u64,
jitter_seed: u64,
) -> Result<(), DaemonError> {
// Per-frontend dispatcher state.
let mut render_states: HashMap<FrontendId, RenderState> = HashMap::new();
let mut streams: HashMap<FrontendId, UnixStream> = HashMap::new();
let mut term_sizes: HashMap<FrontendId, CellSize> = HashMap::new();
let mut session_registry = SessionRegistry::new();
// T M10.11 Q8 — jitter PRNG, seeded once so the
// convergence-under-jitter scenario is deterministically
// reproducible. Mutated across the loop; one stream of delays
// for the whole dispatcher (jitter is dispatcher-wide, matching
// the fixed-latency seam's scope per the field docs).
let mut jitter_rng = SplitMix64::new(jitter_seed);
loop {
// Per-tick render + presence sweep for each attached
@ -810,7 +886,40 @@ fn dispatcher_loop(
// conditions. Dispatcher-wide scope: multi-
// frontend tests at injected latency conflate
// frontends.
if injected_render_latency_ms > 0
// T M10.11 Q6/Q8 Finding 5 — two scoped modes,
// one seam (Q6's "no new injection seams"
// preserved: single sleep-site; the match scope,
// not the seam count, varies):
//
// - **Fixed-latency mode** (`PMACS_INSTANCE_LATENCY_MS`,
// no jitter): CellDelta-only, unchanged from
// M10.10 Day 4. Criterion 1 ("local edit visible
// in <1 frame regardless of instance latency")
// is a *render-write* latency property; CellDelta
// is the right and only target. Preserving this
// scope exactly keeps criterion-1 tests' behavior
// identical.
// - **Jitter mode** (`PMACS_INSTANCE_LATENCY_JITTER_MS`):
// CellDelta *and* CrdtOp. Criterion 3 ("the CRDT
// layer converges under jitter") lives on the
// CrdtOp path — CRDT convergence is CrdtOp-driven,
// not CellDelta. Finding 5: Q6's CellDelta-only
// scope did not exercise criterion 3's assertion
// target; widening jitter-mode to CrdtOp closes
// that composition gap. Tension-B holds — both
// message types are *delayed*, neither *dropped*.
if injected_render_latency_jitter_ms > 0 {
if matches!(
msg,
InstanceMessage::CellDelta { .. } | InstanceMessage::CrdtOp { .. }
) {
let delay_ms = injected_render_latency_ms
+ (jitter_rng.next_u64() % injected_render_latency_jitter_ms);
if delay_ms > 0 {
thread::sleep(Duration::from_millis(delay_ms));
}
}
} else if injected_render_latency_ms > 0
&& matches!(msg, InstanceMessage::CellDelta { .. })
{
thread::sleep(Duration::from_millis(injected_render_latency_ms));

1424
tests/m10_11_acceptance.rs Normal file

File diff suppressed because it is too large Load Diff

183
tests/m10_11_perf.rs Normal file
View File

@ -0,0 +1,183 @@
// m10_11_perf.rs --- M10.11 perf gate: cross-frontend propagation.
//! T M10.11 perf gate (Q5).
//!
//! # Contract
//!
//! Per `M10.11-AUDIT.md` perf-gate ratchet: "50ms p99 budget for
//! `Key sent on stream_a → corresponding CellDelta read on stream_b`."
//! The 50ms budget is generous-but-honest:
//!
//! - M5.9's keystroke→local-render budget is 10ms p99 over loopback
//! `LocalSocket`.
//! - M10.11 adds daemon dispatch + cross-frontend broadcast + remote
//! read scheduling on top of M5.9's measured path. The 50ms
//! ratchet absorbs that overhead with headroom.
//!
//! # Methodology
//!
//! Pinned here so future "is this regression real?" debates have a
//! single source of truth; mirrors M5.9's methodology where the
//! shape carries over.
//!
//! - **What "cross-frontend propagation" means.** The interval
//! between A's `write_message(stream_a, FrontendEvent::Key)` and
//! B's first `read_message(stream_b)` that returns
//! `InstanceMessage::CellDelta`. Messages of other variants
//! (`PresenceUpdate`, `CrdtOp`, `BufferSnapshot`, `Cursor`) are
//! read-through (skipped without ending the wait) because they
//! represent the daemon's broadcast path but are not the spec's
//! "edits appear on both screens" observable. The `CellDelta` is.
//!
//! - **Sample count.** 100 warmup + 1000 measured (M5.9's precedent).
//!
//! - **Percentile computation.** `(len * p) / 100` integer
//! arithmetic; index `(1000 * 99) / 100 = 990` is the 991st
//! smallest sample for p99.
//!
//! - **Drain between iterations.** After reading B's `CellDelta`,
//! drain followup frames on both streams (`Cursor`, additional
//! `CellDelta`s from the same tick, presence broadcasts) with a
//! 1ms read timeout so the next iteration starts from a quiet
//! socket.
//!
//! - **Character cycling.** A types `'a'..='z'` cycling per
//! iteration; the test buffer never wraps a line (24×80 grid
//! absorbs all 1100 keystrokes on row 0 with no soft-wrap).
//!
//! - **Threshold.** 50ms p99. Actual perf on a quiet developer
//! machine is sub-millisecond (M5.9's machine measures sub-ms;
//! M10.11 adds one broadcast hop, so a small multiple). The
//! threshold catches catastrophic regressions, not subtle ones.
//!
//! # Why `#[ignore]`
//!
//! Perf measurement under debug-mode `cargo test` is meaningless —
//! the daemon's hot path doesn't optimize. CI runs this test under
//! a release-mode perf-gate job alongside M5.9's; local dev runs
//! (`cargo test`) skip it.
#![cfg(feature = "crdt")]
use std::time::{Duration, Instant};
use pmacs::protocol::{FrontendEvent, InstanceMessage, Key, KeyEvent, Modifiers};
use pmacs::transport::{TransportError, read_message, write_message};
mod common;
use common::daemon::{TestDaemon, attach_multi};
const WARMUP_SAMPLES: usize = 100;
const MEASURED_SAMPLES: usize = 1000;
const P99_THRESHOLD_MS: u128 = 50;
const PER_KEY_TIMEOUT: Duration = Duration::from_secs(5);
const DRAIN_TIMEOUT: Duration = Duration::from_millis(1);
#[test]
#[ignore = "perf gate; requires release build"]
fn m10_11_cross_frontend_propagation_p99_under_50ms() {
let daemon = TestDaemon::spawn();
let (hello_a, mut stream_a) = attach_multi(&daemon);
let (_hello_b, mut stream_b) = attach_multi(&daemon);
// Drain attach-time frames from both streams. Each replica
// receives a BufferSnapshot for *scratch* plus initial
// CellDelta + presence broadcasts; clear them so the first
// measured keystroke starts from a quiet socket on both sides.
drain_pending(&mut stream_a);
drain_pending(&mut stream_b);
let total = WARMUP_SAMPLES + MEASURED_SAMPLES;
let mut samples: Vec<Duration> = Vec::with_capacity(total);
let mut cursor: u8 = b'a';
for i in 0..total {
let key = FrontendEvent::Key(KeyEvent {
frontend_id: hello_a.assigned_frontend_id,
key: Key::Char(cursor as char),
mods: Modifiers::NONE,
timestamp_ns: 0,
});
cursor = if cursor >= b'z' { b'a' } else { cursor + 1 };
stream_b
.set_read_timeout(Some(PER_KEY_TIMEOUT))
.expect("set per-key timeout");
let t_send = Instant::now();
write_message(&mut stream_a, &key).expect("send key from A");
// Read B's stream until the first CellDelta arrives. Skip
// other variants (PresenceUpdate, CrdtOp, BufferSnapshot,
// Cursor) — they're part of the daemon's broadcast pipeline
// but not the spec's "edits appear on screen" observable.
loop {
match read_message::<InstanceMessage>(&mut stream_b) {
Ok(InstanceMessage::CellDelta { .. }) => break,
Ok(_other) => {}
Err(e) => panic!("read response on B for key #{i}: {e}"),
}
}
let elapsed = t_send.elapsed();
samples.push(elapsed);
// Drain followup frames on both streams so the next
// iteration starts quiet. A receives its own CellDelta /
// Cursor; B may receive additional follow-up messages.
drain_pending(&mut stream_a);
drain_pending(&mut stream_b);
}
let measured = &samples[WARMUP_SAMPLES..];
let mut sorted: Vec<Duration> = measured.to_vec();
sorted.sort();
let percentile = |p: usize| -> Duration {
let idx = (sorted.len() * p) / 100;
sorted[idx.min(sorted.len() - 1)]
};
let max = sorted[sorted.len() - 1];
let p50 = percentile(50);
let p90 = percentile(90);
let p99 = percentile(99);
println!(
"M10.11 cross-frontend propagation over {} measured samples (after {} warmup):",
measured.len(),
WARMUP_SAMPLES
);
println!(" p50: {p50:?}");
println!(" p90: {p90:?}");
println!(" p99: {p99:?}");
println!(" max: {max:?}");
println!(" threshold: {P99_THRESHOLD_MS}ms");
assert!(
p99.as_millis() < P99_THRESHOLD_MS,
"p99 cross-frontend latency {p99:?} exceeds {P99_THRESHOLD_MS}ms gate; \
p50={p50:?}, p90={p90:?}, max={max:?}"
);
}
/// Read-and-discard any pending frames on `stream` with a short
/// timeout. Returns the number of frames drained.
fn drain_pending(stream: &mut std::os::unix::net::UnixStream) -> usize {
let mut count = 0;
stream
.set_read_timeout(Some(DRAIN_TIMEOUT))
.expect("set drain timeout");
loop {
match read_message::<InstanceMessage>(stream) {
Ok(_) => count += 1,
Err(TransportError::Io(e))
if matches!(
e.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
break;
}
Err(_) => break,
}
}
count
}