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
|
/// signal. v0.2+ work on per-frontend latency injection would
|
||||||
/// move the sleep into a per-frontend writer thread.
|
/// move the sleep into a per-frontend writer thread.
|
||||||
injected_render_latency_ms: u64,
|
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.
|
/// T M10.8 Day 4 — RAII guard for the non-multi-session slot.
|
||||||
|
|
@ -256,6 +297,18 @@ impl DaemonState {
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(0);
|
.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 {
|
Self {
|
||||||
instance_name,
|
instance_name,
|
||||||
started: Instant::now(),
|
started: Instant::now(),
|
||||||
|
|
@ -265,6 +318,8 @@ impl DaemonState {
|
||||||
non_multi_session_count: AtomicU64::new(0),
|
non_multi_session_count: AtomicU64::new(0),
|
||||||
color_registry: std::sync::Mutex::new(HashMap::new()),
|
color_registry: std::sync::Mutex::new(HashMap::new()),
|
||||||
injected_render_latency_ms,
|
injected_render_latency_ms,
|
||||||
|
injected_render_latency_jitter_ms,
|
||||||
|
jitter_seed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,6 +329,16 @@ impl DaemonState {
|
||||||
self.injected_render_latency_ms
|
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.
|
/// T M10.9 — look up or assign a color slot for the given uid.
|
||||||
///
|
///
|
||||||
/// Same uid across reconnect → same slot (the spec's
|
/// 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,
|
&mut editor,
|
||||||
&shutdown,
|
&shutdown,
|
||||||
daemon_state.injected_render_latency_ms(),
|
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
|
// Dispatcher exited (shutdown or quit). Wake the accept thread
|
||||||
|
|
@ -682,17 +749,26 @@ fn per_attach_thread(
|
||||||
clippy::needless_pass_by_value,
|
clippy::needless_pass_by_value,
|
||||||
clippy::too_many_lines
|
clippy::too_many_lines
|
||||||
)]
|
)]
|
||||||
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn dispatcher_loop(
|
fn dispatcher_loop(
|
||||||
dispatcher_rx: mpsc::Receiver<DispatcherEvent>,
|
dispatcher_rx: mpsc::Receiver<DispatcherEvent>,
|
||||||
editor: &mut EditorState,
|
editor: &mut EditorState,
|
||||||
shutdown: &Arc<AtomicBool>,
|
shutdown: &Arc<AtomicBool>,
|
||||||
injected_render_latency_ms: u64,
|
injected_render_latency_ms: u64,
|
||||||
|
injected_render_latency_jitter_ms: u64,
|
||||||
|
jitter_seed: u64,
|
||||||
) -> Result<(), DaemonError> {
|
) -> Result<(), DaemonError> {
|
||||||
// Per-frontend dispatcher state.
|
// Per-frontend dispatcher state.
|
||||||
let mut render_states: HashMap<FrontendId, RenderState> = HashMap::new();
|
let mut render_states: HashMap<FrontendId, RenderState> = HashMap::new();
|
||||||
let mut streams: HashMap<FrontendId, UnixStream> = HashMap::new();
|
let mut streams: HashMap<FrontendId, UnixStream> = HashMap::new();
|
||||||
let mut term_sizes: HashMap<FrontendId, CellSize> = HashMap::new();
|
let mut term_sizes: HashMap<FrontendId, CellSize> = HashMap::new();
|
||||||
let mut session_registry = SessionRegistry::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 {
|
loop {
|
||||||
// Per-tick render + presence sweep for each attached
|
// Per-tick render + presence sweep for each attached
|
||||||
|
|
@ -810,7 +886,40 @@ fn dispatcher_loop(
|
||||||
// conditions. Dispatcher-wide scope: multi-
|
// conditions. Dispatcher-wide scope: multi-
|
||||||
// frontend tests at injected latency conflate
|
// frontend tests at injected latency conflate
|
||||||
// frontends.
|
// 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 { .. })
|
&& matches!(msg, InstanceMessage::CellDelta { .. })
|
||||||
{
|
{
|
||||||
thread::sleep(Duration::from_millis(injected_render_latency_ms));
|
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