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:
parent
05fbbd9919
commit
b6c07cb840
111
src/daemon.rs
111
src/daemon.rs
|
|
@ -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));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue