Timing under load: the GPU span test, co-watch re-entry, and the SRT link tests #614
23 changed files with 2371 additions and 298 deletions
2
.github/workflows/standards-build.yml
vendored
2
.github/workflows/standards-build.yml
vendored
|
|
@ -632,6 +632,8 @@ jobs:
|
|||
log=$(mktemp)
|
||||
cargo test -p jouleclaw-energy --features ioreport -- --nocapture 2>&1 | tee "$log"
|
||||
grep -q 'ioreport liveness:' "$log"
|
||||
# tests/process_gpu.rs prints this once its 1.5 s GPU burn was checked against the oracle.
|
||||
grep -q 'process-gpu: oracle-checked:' "$log"
|
||||
|
||||
- name: sandbox-meter
|
||||
shell: bash
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
//!
|
||||
//! - **The buffer** (§5.3.1): each sequence number is delivered `buffer` after it was
|
||||
//! expected (its arrival, or, for one found missing, the arrival that showed the gap),
|
||||
//! in order; one still missing then is dropped.
|
||||
//! in order; one still missing then is dropped. A packet is taken only once what fell due
|
||||
//! before it is delivered or dropped, so a retransmission taken at or after its time is
|
||||
//! late whether or not the timer ran first.
|
||||
//! - **Retransmission requests** (§5.3.2): a missing packet is first requested `reorder`
|
||||
//! after it was found missing, then again every interval: 1.5 × the RTT where the RTT echo
|
||||
//! (§5.2.6) has measured it, and otherwise (buffer − reorder) / the requests allowed, as
|
||||
|
|
@ -153,7 +155,16 @@ impl Receiver {
|
|||
|
||||
pub fn on_rtp(&mut self, p: Rtp) {
|
||||
let now = self.now_us();
|
||||
self.on_rtp_at(p, now);
|
||||
}
|
||||
|
||||
/// An RTP packet taken at `now`, µs on the receiver's clock.
|
||||
fn on_rtp_at(&mut self, p: Rtp, now: u64) {
|
||||
self.last_recv_us = now;
|
||||
// What fell due before this packet first, as the timer would have done it: a
|
||||
// retransmission taken at or after its sequence number's time finds it dropped, and
|
||||
// is late, whatever the order of the timer and the arrivals.
|
||||
self.deliver(now);
|
||||
let base = p.ssrc & !1;
|
||||
match self.media {
|
||||
None => self.media = Some(base),
|
||||
|
|
@ -294,6 +305,11 @@ impl Receiver {
|
|||
|
||||
pub fn on_timer(&mut self) {
|
||||
let now = self.now_us();
|
||||
self.on_timer_at(now);
|
||||
}
|
||||
|
||||
/// The timer, at `now`, µs on the receiver's clock.
|
||||
fn on_timer_at(&mut self, now: u64) {
|
||||
self.deliver(now);
|
||||
if self.closed.is_some() {
|
||||
return;
|
||||
|
|
@ -387,9 +403,11 @@ impl Receiver {
|
|||
match self.buf.remove(&next) {
|
||||
Some(s) => {
|
||||
let bytes = s.payload.len() as u32;
|
||||
// A recovered packet's margin is still the time it arrived before its
|
||||
// delivery time, though it is delivered sooner.
|
||||
a.resolve(next, match s.missed_at {
|
||||
None => Outcome::FirstTry { bytes },
|
||||
Some(at) => Outcome::Recovered { bytes, recovery_us: s.arrived_us.saturating_sub(at) as u32, margin_us: 0 },
|
||||
Some(at) => Outcome::Recovered { bytes, recovery_us: s.arrived_us.saturating_sub(at) as u32, margin_us: (at + self.cfg.buffer_us).saturating_sub(s.arrived_us).min(u32::MAX as u64) as u32 },
|
||||
});
|
||||
self.delivered.push(s.payload);
|
||||
}
|
||||
|
|
@ -401,3 +419,93 @@ impl Receiver {
|
|||
self.missing.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::stats::{Part, Run};
|
||||
|
||||
const BUFFER_US: u64 = 1_000_000;
|
||||
|
||||
fn rtp(seq: u16, ssrc: u32) -> Rtp {
|
||||
Rtp { pt: 33, marker: false, seq, ts: 0, ssrc, payload: seq.to_be_bytes().to_vec() }
|
||||
}
|
||||
|
||||
/// When 12 is taken in [`rexmit_at`], showing 11 missing: not the receiver's time
|
||||
/// origin, so a recovery or a time counted from anywhere but that moment shows.
|
||||
const FOUND_US: u64 = 20_000;
|
||||
/// 11's time: the buffer after it was found missing.
|
||||
const DUE_US: u64 = FOUND_US + BUFFER_US;
|
||||
|
||||
/// 10 taken at 0 and 12 at [`FOUND_US`], so 11 is found missing then and its time,
|
||||
/// where it is delivered or dropped, is [`DUE_US`]. Its retransmission is taken at
|
||||
/// `rexmit_us`, after a timer tick at `tick_us` where one is given, and a tick at 2 s
|
||||
/// passes the rest. The part, and the sequence numbers delivered, in order.
|
||||
fn rexmit_at(rexmit_us: u64, tick_us: Option<u64>) -> (Part, Vec<u16>) {
|
||||
let account = Arc::new(Mutex::new(Account::new()));
|
||||
let mut r = Receiver::new(RecvConfig { buffer_us: BUFFER_US, ..RecvConfig::default() }, account.clone(), Arc::new(AtomicU64::new(0)));
|
||||
r.on_rtp_at(rtp(10, 0x5000), 0);
|
||||
r.on_rtp_at(rtp(12, 0x5000), FOUND_US);
|
||||
if let Some(t) = tick_us {
|
||||
r.on_timer_at(t);
|
||||
}
|
||||
r.on_rtp_at(rtp(11, 0x5001), rexmit_us);
|
||||
r.on_timer_at(2_000_000);
|
||||
let part = account.lock().unwrap().cut(2_000_000, false);
|
||||
part.check().unwrap();
|
||||
(part, r.delivered.iter().map(|p| u16::from_be_bytes([p[0], p[1]])).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retransmission_before_its_time_is_recovered_with_its_margin() {
|
||||
for (t, tick) in [(600_000, None), (600_000, Some(500_000)), (DUE_US - 1, None)] {
|
||||
let (p, got) = rexmit_at(t, tick);
|
||||
assert_eq!(got, [10, 11, 12], "taken at {t} µs");
|
||||
assert_eq!((p.delivered, p.first_try, p.dropped, p.late), (3, 2, 0, 0), "taken at {t} µs: {p:?}");
|
||||
assert_eq!((p.recovery.samples, p.recovery.max, p.margin_min), (1, (t - FOUND_US) as u32, (DUE_US - t) as u32), "taken at {t} µs");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retransmission_at_or_after_its_time_is_late_with_or_without_a_tick_between() {
|
||||
for t in [DUE_US, 1_200_000] {
|
||||
for tick in [None, Some(DUE_US - 1), Some(DUE_US), Some(t - 1)] {
|
||||
let (p, got) = rexmit_at(t, tick);
|
||||
assert_eq!(got, [10, 12], "taken at {t} µs, tick {tick:?}");
|
||||
assert_eq!((p.delivered, p.first_try, p.dropped, p.late), (2, 2, 1, 1), "taken at {t} µs, tick {tick:?}: {p:?}");
|
||||
assert_eq!((p.recovery.samples, p.margin_min), (0, 0));
|
||||
assert_eq!(p.runs, [Run { first: p.seq_first + 1, count: 1, recovered: 0, dropped: 1, outstanding: 0 }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_becomes_of_a_retransmission_does_not_depend_on_the_timer() {
|
||||
let outcome = |p: &Part| (p.delivered, p.first_try, p.dropped, p.late, p.recovery, p.margin_min, p.runs.clone());
|
||||
for t in (FOUND_US..=1_500_000).step_by(5_000) {
|
||||
let (alone, got) = rexmit_at(t, None);
|
||||
for tick in [(FOUND_US + t) / 2, (t - 1).max(FOUND_US), t] {
|
||||
let (p, g) = rexmit_at(t, Some(tick));
|
||||
assert_eq!((outcome(&p), &g), (outcome(&alone), &got), "taken at {t} µs, a tick at {tick} µs before it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_recovered_and_still_held_when_the_flow_ends_keeps_its_margin() {
|
||||
// 11, found missing at 20 ms, is retransmitted at 100 ms; the flow ends at 150 ms,
|
||||
// before anything is due, and delivers what it holds.
|
||||
let account = Arc::new(Mutex::new(Account::new()));
|
||||
let mut r = Receiver::new(RecvConfig { buffer_us: BUFFER_US, ..RecvConfig::default() }, account.clone(), Arc::new(AtomicU64::new(0)));
|
||||
r.on_rtp_at(rtp(10, 0x5000), 0);
|
||||
r.on_rtp_at(rtp(12, 0x5000), FOUND_US);
|
||||
r.on_rtp_at(rtp(11, 0x5001), 100_000);
|
||||
r.on_timer_at(150_000);
|
||||
r.close("deleted");
|
||||
let p = account.lock().unwrap().cut(1_000_000, true);
|
||||
p.check().unwrap();
|
||||
assert_eq!(r.delivered.len(), 3);
|
||||
assert_eq!((p.delivered, p.first_try, p.dropped, p.outstanding), (3, 2, 0, 0), "{p:?}");
|
||||
assert_eq!((p.recovery.samples, p.recovery.max, p.margin_min), (1, 80_000, (DUE_US - 100_000) as u32), "{p:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
//! (§4.7): the mean, over 1 000 ACKACK arrivals, of how far each arrived (less half the
|
||||
//! RTT) from where `base` put it, applied once it passes 5 ms.
|
||||
//! - **Too-late packet drop** (§4.6): when the next packet to deliver is missing and the
|
||||
//! first packet after it is due, the missing ones are dropped.
|
||||
//! first packet after it that has arrived is due, the missing ones are dropped. A
|
||||
//! datagram is taken only once what fell due before it is delivered or dropped, so a
|
||||
//! retransmission taken at or after its drop point is late whether or not the timer ran
|
||||
//! first; one taken after its own delivery time but before that point is delivered at
|
||||
//! once, recovered with no margin.
|
||||
//! - **ACK** (§3.2.4, §4.8.1): a full ACK every 10 ms while the acknowledged position moves
|
||||
//! (and at least every 100 ms, so the RTT is measured), a light ACK every 64 packets; the
|
||||
//! position is the first sequence number neither received nor resolved.
|
||||
|
|
@ -92,6 +96,8 @@ pub struct Receiver {
|
|||
last_ack_pos: u64,
|
||||
since_light: u32,
|
||||
last_nak_us: u64,
|
||||
/// When the timer last ran.
|
||||
last_tick_us: u64,
|
||||
last_sent_us: u64,
|
||||
last_recv_us: u64,
|
||||
rate_since_us: u64,
|
||||
|
|
@ -140,6 +146,7 @@ impl Receiver {
|
|||
last_ack_pos: next,
|
||||
since_light: 0,
|
||||
last_nak_us: 0,
|
||||
last_tick_us: 0,
|
||||
last_sent_us: 0,
|
||||
last_recv_us: 0,
|
||||
rate_since_us: 0,
|
||||
|
|
@ -189,7 +196,17 @@ impl Receiver {
|
|||
|
||||
pub fn on_datagram(&mut self, b: &[u8]) {
|
||||
let now = self.now_us();
|
||||
self.on_datagram_at(b, now);
|
||||
}
|
||||
|
||||
/// A datagram taken at `now`, µs on the receiver's clock.
|
||||
fn on_datagram_at(&mut self, b: &[u8], now: u64) {
|
||||
self.last_recv_us = now;
|
||||
// What fell due before this datagram first, as the timer would have done it: a
|
||||
// retransmission taken once the first packet after its gap is due finds its
|
||||
// sequence number dropped (§4.6), and is late, whatever the order of the timer and
|
||||
// the arrivals.
|
||||
self.deliver(now);
|
||||
match Packet::read(b) {
|
||||
Some(Packet::Data(d)) => self.on_data(d, now),
|
||||
Some(Packet::Control(c)) => self.on_control(c, now),
|
||||
|
|
@ -340,24 +357,14 @@ impl Receiver {
|
|||
return;
|
||||
}
|
||||
let slot = self.buf.remove(&self.next).unwrap();
|
||||
let bytes = slot.payload.len() as u32;
|
||||
let o = match slot.missed_at {
|
||||
None => Outcome::FirstTry { bytes },
|
||||
// The margin is at most the window: a peer whose handshake reached
|
||||
// the receiver later than its data does puts its packets' delivery times
|
||||
// later than the window after their arrival.
|
||||
Some(at) => Outcome::Recovered {
|
||||
bytes,
|
||||
recovery_us: slot.arrived_us.saturating_sub(at).min(u32::MAX as u64) as u32,
|
||||
margin_us: self.due(slot.ts).saturating_sub(slot.arrived_us).min(self.cfg.latency_us).min(u32::MAX as u64) as u32,
|
||||
},
|
||||
};
|
||||
let o = self.outcome(&slot);
|
||||
self.account.lock().unwrap().resolve(self.next, o);
|
||||
self.delivered.push(slot.payload);
|
||||
self.next += 1;
|
||||
continue;
|
||||
}
|
||||
// The next one is missing: drop it once the first packet after it is due.
|
||||
// The next one is missing: drop it once the first packet after it that has
|
||||
// arrived is due.
|
||||
let Some((&after, slot)) = self.buf.range(self.next..).next() else { return };
|
||||
if now < self.due(slot.ts) {
|
||||
return;
|
||||
|
|
@ -379,8 +386,33 @@ impl Receiver {
|
|||
}
|
||||
}
|
||||
|
||||
/// How a packet held in `slot` ends when it is delivered, in time or as the link ends.
|
||||
fn outcome(&self, slot: &Slot) -> Outcome {
|
||||
let bytes = slot.payload.len() as u32;
|
||||
match slot.missed_at {
|
||||
None => Outcome::FirstTry { bytes },
|
||||
// The margin is at most the window: a peer whose handshake reached the receiver
|
||||
// later than its data does puts its packets' delivery times later than the
|
||||
// window after their arrival. It is 0 for a packet taken at or after its own
|
||||
// delivery time, before the first packet after it that has arrived was due: not
|
||||
// dropped yet, it is delivered at once (live-contribution §6.1). The recovery is
|
||||
// counted from when it was found missing, so it is not bounded by the window.
|
||||
Some(at) => Outcome::Recovered {
|
||||
bytes,
|
||||
recovery_us: slot.arrived_us.saturating_sub(at).min(u32::MAX as u64) as u32,
|
||||
margin_us: self.due(slot.ts).saturating_sub(slot.arrived_us).min(self.cfg.latency_us).min(u32::MAX as u64) as u32,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_timer(&mut self) {
|
||||
let now = self.now_us();
|
||||
self.on_timer_at(now);
|
||||
}
|
||||
|
||||
/// The timer, at `now`, µs on the receiver's clock.
|
||||
fn on_timer_at(&mut self, now: u64) {
|
||||
self.last_tick_us = now;
|
||||
self.deliver(now);
|
||||
if self.closed.is_some() {
|
||||
return;
|
||||
|
|
@ -438,8 +470,15 @@ impl Receiver {
|
|||
|
||||
/// When the receiver next has something to do, µs on its clock.
|
||||
pub fn next_wake_us(&self) -> u64 {
|
||||
let now = self.now_us();
|
||||
let mut w = now + ACK_PERIOD_US;
|
||||
self.next_wake_at(self.now_us())
|
||||
}
|
||||
|
||||
/// As [`Receiver::next_wake_us`], at `now`. The timer runs an ACK period after it last
|
||||
/// ran, not after the last datagram: counted from each datagram, it would wait as long as
|
||||
/// datagrams came closer together than that, and the full ACKs and periodic NAKs with it,
|
||||
/// until the first packet held fell due.
|
||||
fn next_wake_at(&self, now: u64) -> u64 {
|
||||
let mut w = self.last_tick_us + ACK_PERIOD_US;
|
||||
if let Some(s) = self.buf.get(&self.next) {
|
||||
w = w.min(self.due(s.ts));
|
||||
} else if let Some((_, s)) = self.buf.range(self.next..).next() {
|
||||
|
|
@ -467,18 +506,15 @@ impl Receiver {
|
|||
self.close_now(why);
|
||||
}
|
||||
|
||||
/// Everything held delivered now, in order; the missing among it outstanding.
|
||||
/// Everything held delivered now, in order; the missing among it outstanding. A
|
||||
/// recovered packet's margin is still the time it arrived before its delivery time,
|
||||
/// though it is delivered sooner.
|
||||
fn flush(&mut self) {
|
||||
let mut a = self.account.lock().unwrap();
|
||||
while self.next <= self.highest {
|
||||
match self.buf.remove(&self.next) {
|
||||
Some(slot) => {
|
||||
let bytes = slot.payload.len() as u32;
|
||||
let o = match slot.missed_at {
|
||||
None => Outcome::FirstTry { bytes },
|
||||
Some(at) => Outcome::Recovered { bytes, recovery_us: slot.arrived_us.saturating_sub(at).min(u32::MAX as u64) as u32, margin_us: 0 },
|
||||
};
|
||||
a.resolve(self.next, o);
|
||||
a.resolve(self.next, self.outcome(&slot));
|
||||
self.delivered.push(slot.payload);
|
||||
}
|
||||
None => a.resolve(self.next, Outcome::Outstanding),
|
||||
|
|
@ -493,3 +529,219 @@ impl Receiver {
|
|||
self.cfg.latency_us
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::srt::packet::loss_list_read;
|
||||
use crate::stats::{Part, Run};
|
||||
|
||||
const LATENCY_US: u64 = 300_000;
|
||||
|
||||
fn receiver() -> (Receiver, Arc<Mutex<Account>>) {
|
||||
let account = Arc::new(Mutex::new(Account::new()));
|
||||
let cfg = RecvConfig { latency_us: LATENCY_US, peer_idle_us: 5_000_000, flow_window: 8192, require_crypto: false, keys: None, passphrase: None };
|
||||
(Receiver::new(cfg, 7, 1000, 0, Instant::now(), account.clone(), Arc::new(AtomicU64::new(0))), account)
|
||||
}
|
||||
|
||||
fn data(seq: u32, ts: u32, rexmit: bool) -> Vec<u8> {
|
||||
Data { seq, pp: 3, order: false, kk: 0, rexmit, msgno: seq, ts, dst: 7, payload: seq.to_be_bytes().to_vec() }.write()
|
||||
}
|
||||
|
||||
/// When 1002 is taken in [`rexmit_at`], showing 1001 missing: not the receiver's time
|
||||
/// origin, so a recovery counted from anywhere but that moment shows.
|
||||
const FOUND_US: u64 = 20_000;
|
||||
|
||||
/// 1000 (timestamp 0) taken at 0 and 1002 (100 ms) at [`FOUND_US`], so 1001 (50 ms) is
|
||||
/// found missing then: its own delivery time is 350 ms, and the first packet after it
|
||||
/// that has arrived, 1002, is due at 400 ms, where §4.6 drops it. Its retransmission is
|
||||
/// taken at `rexmit_us`, after a timer tick at `tick_us` where one is given, and a tick
|
||||
/// at 1 s passes the rest. The part, and the sequence numbers delivered, in order.
|
||||
fn rexmit_at(rexmit_us: u64, tick_us: Option<u64>) -> (Part, Vec<u32>) {
|
||||
let (mut r, account) = receiver();
|
||||
r.on_datagram_at(&data(1000, 0, false), 0);
|
||||
r.on_datagram_at(&data(1002, 100_000, false), FOUND_US);
|
||||
if let Some(t) = tick_us {
|
||||
r.on_timer_at(t);
|
||||
}
|
||||
r.on_datagram_at(&data(1001, 50_000, true), rexmit_us);
|
||||
r.on_timer_at(1_000_000);
|
||||
let part = account.lock().unwrap().cut(1_000_000, false);
|
||||
part.check().unwrap();
|
||||
(part, r.delivered.iter().map(|p| u32::from_be_bytes(p[..4].try_into().unwrap())).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retransmission_before_its_delivery_time_is_recovered_with_its_margin() {
|
||||
for (t, tick) in [(250_000, None), (250_000, Some(200_000)), (349_999, None)] {
|
||||
let (p, got) = rexmit_at(t, tick);
|
||||
assert_eq!(got, [1000, 1001, 1002], "taken at {t} µs");
|
||||
assert_eq!((p.delivered, p.first_try, p.dropped, p.late), (3, 2, 0, 0), "taken at {t} µs: {p:?}");
|
||||
// Found missing at 20 ms, due at 350 ms: the recovery runs from the first, the
|
||||
// margin is the time to spare before the second.
|
||||
assert_eq!((p.recovery.samples, p.recovery.max, p.margin_min), (1, (t - FOUND_US) as u32, 350_000 - t as u32), "taken at {t} µs");
|
||||
assert!(p.margin_min > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retransmission_after_its_delivery_time_but_before_the_drop_point_is_recovered_with_no_margin() {
|
||||
// 1002 is not due yet, so 1001 has not been dropped: it is delivered at once, in order.
|
||||
for (t, tick) in [(350_000, None), (375_000, None), (375_000, Some(360_000)), (399_999, None)] {
|
||||
let (p, got) = rexmit_at(t, tick);
|
||||
assert_eq!(got, [1000, 1001, 1002], "taken at {t} µs");
|
||||
assert_eq!((p.delivered, p.first_try, p.dropped, p.late), (3, 2, 0, 0), "taken at {t} µs: {p:?}");
|
||||
assert_eq!((p.recovery.samples, p.recovery.max, p.margin_min), (1, (t - FOUND_US) as u32, 0), "taken at {t} µs");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retransmission_after_the_drop_point_is_late_with_or_without_a_tick_between() {
|
||||
for t in [400_000, 425_000, 600_000] {
|
||||
for tick in [None, Some(390_000), Some(400_000), Some(t - 1)] {
|
||||
let (p, got) = rexmit_at(t, tick);
|
||||
assert_eq!(got, [1000, 1002], "taken at {t} µs, tick {tick:?}");
|
||||
assert_eq!((p.delivered, p.first_try, p.dropped, p.late), (2, 2, 1, 1), "taken at {t} µs, tick {tick:?}: {p:?}");
|
||||
assert_eq!((p.recovery.samples, p.margin_min), (0, 0));
|
||||
assert_eq!(p.runs, [Run { first: p.seq_first + 1, count: 1, recovered: 0, dropped: 1, outstanding: 0 }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_becomes_of_a_retransmission_does_not_depend_on_the_timer() {
|
||||
let outcome = |p: &Part| (p.delivered, p.first_try, p.dropped, p.late, p.recovery, p.margin_min, p.runs.clone());
|
||||
for t in (FOUND_US..=600_000).step_by(2_500) {
|
||||
let (alone, got) = rexmit_at(t, None);
|
||||
for tick in [(FOUND_US + t) / 2, (t - 1).max(FOUND_US), t] {
|
||||
let (p, g) = rexmit_at(t, Some(tick));
|
||||
assert_eq!((outcome(&p), &g), (outcome(&alone), &got), "taken at {t} µs, a tick at {tick} µs before it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_timer_runs_while_datagrams_keep_coming() {
|
||||
// 1000 at 0, then 1002 onwards every 2 ms, each stamped when it was sent: 1002 shows
|
||||
// 1001 missing at 4 ms, and its NAK and every retransmission are lost. The test runs
|
||||
// the connection's loop on a clock of its own: the timer when it is due no later than
|
||||
// the next datagram, the datagram otherwise.
|
||||
let (mut r, _) = receiver();
|
||||
let (mut now, mut ticks, mut naks, mut acks) = (0, Vec::new(), Vec::new(), Vec::new());
|
||||
let mut sent = |r: &mut Receiver, at: u64| {
|
||||
for b in r.out.drain(..) {
|
||||
let Some(Packet::Control(c)) = Packet::read(&b) else { continue };
|
||||
if c.ctype == ctrl::NAK && loss_list_read(&c.cif).unwrap().iter().any(|&(a, z)| (a..=z).contains(&1001)) {
|
||||
naks.push(at);
|
||||
} else if c.ctype == ctrl::ACK && c.info != 0 {
|
||||
acks.push(at);
|
||||
}
|
||||
}
|
||||
};
|
||||
for k in (0..200u32).filter(|&k| k != 1) {
|
||||
let at = k as u64 * 2_000;
|
||||
while r.next_wake_at(now) <= at {
|
||||
now = r.next_wake_at(now);
|
||||
r.on_timer_at(now);
|
||||
ticks.push(now);
|
||||
sent(&mut r, now);
|
||||
}
|
||||
now = at;
|
||||
r.on_datagram_at(&data(1000 + k, at as u32, false), now);
|
||||
sent(&mut r, now);
|
||||
}
|
||||
// It ran every ACK period from the start, not first when 1000 fell due at 300 ms.
|
||||
let gaps: Vec<u64> = [0].iter().chain(&ticks).zip(&ticks).map(|(a, b)| b - a).collect();
|
||||
assert!(gaps.iter().all(|&g| g <= ACK_PERIOD_US), "the timer ran at {ticks:?}");
|
||||
// The full ACKs began with it. The periodic NAK first went a NAK interval after the
|
||||
// link began, (RTT + 4 × RTTVar) / 2 = 150 ms at the initial RTT; the NAK sent at once
|
||||
// when 1002 showed the gap does not restart that timer.
|
||||
assert_eq!(acks.first(), Some(&ACK_PERIOD_US), "full ACKs at {acks:?}");
|
||||
assert_eq!(naks.get(..2), Some(&[4_000, 150_000][..]), "NAKs naming 1001 at {naks:?}");
|
||||
}
|
||||
|
||||
/// The connection's loop on a clock of its own, as in the test above: data every 2 ms
|
||||
/// from 1000 on, each stamped when it was sent, but for `lost`, whose NAKs and
|
||||
/// retransmissions are lost too; every full ACK answered by its ACKACK `rtt_us` later,
|
||||
/// stamped half that after the ACK. Until `until_us`: the receiver, and when each NAK
|
||||
/// naming `lost` went.
|
||||
fn naks_after_round_trips(rtt_us: u64, lost: u32, until_us: u64) -> (Receiver, Vec<u64>) {
|
||||
fn sent(r: &mut Receiver, at: u64, rtt_us: u64, lost: u32, naks: &mut Vec<u64>, ackacks: &mut VecDeque<(u64, u32)>) {
|
||||
for b in r.out.drain(..) {
|
||||
let Some(Packet::Control(c)) = Packet::read(&b) else { continue };
|
||||
if c.ctype == ctrl::NAK && loss_list_read(&c.cif).unwrap().iter().any(|&(a, z)| (a..=z).contains(&lost)) {
|
||||
naks.push(at);
|
||||
} else if c.ctype == ctrl::ACK && c.info != 0 {
|
||||
ackacks.push_back((at + rtt_us, c.info));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut r, _) = receiver();
|
||||
let (mut now, mut k, mut naks) = (0, 0u32, Vec::new());
|
||||
let mut ackacks: VecDeque<(u64, u32)> = VecDeque::new();
|
||||
loop {
|
||||
let data_at = k as u64 * 2_000;
|
||||
let ackack = ackacks.front().copied();
|
||||
let next = ackack.map_or(data_at, |(at, _)| at.min(data_at));
|
||||
if next > until_us {
|
||||
return (r, naks);
|
||||
}
|
||||
while r.next_wake_at(now) <= next {
|
||||
now = r.next_wake_at(now);
|
||||
r.on_timer_at(now);
|
||||
sent(&mut r, now, rtt_us, lost, &mut naks, &mut ackacks);
|
||||
}
|
||||
now = next;
|
||||
match ackack {
|
||||
Some((at, n)) if at == next => {
|
||||
ackacks.pop_front();
|
||||
r.on_datagram_at(&Control::new(ctrl::ACKACK, n, (at - rtt_us / 2) as u32, 7, Vec::new()).write(), now);
|
||||
}
|
||||
_ => {
|
||||
if 1000 + k != lost {
|
||||
r.on_datagram_at(&data(1000 + k, data_at as u32, false), now);
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
sent(&mut r, now, rtt_us, lost, &mut naks, &mut ackacks);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_periodic_nak_follows_the_measured_round_trip_down_to_its_floor() {
|
||||
// 1750 is sent at 1.5 s, once some 150 round trips have been measured; 1751 shows it
|
||||
// missing at 1.502 s, and is due at 1.802 s, where 1750 is dropped.
|
||||
let (found, drop) = (1_502_000, 1_802_000);
|
||||
// A round trip of 60 ms: (RTT + 4 × RTTVar) / 2 = 30 ms. One of 1 ms: half a
|
||||
// millisecond, raised to the 20 ms floor.
|
||||
for (rtt, interval) in [(60_000, 30_000), (1_000, NAK_FLOOR_US)] {
|
||||
let (r, naks) = naks_after_round_trips(rtt, 1750, 2_000_000);
|
||||
assert_eq!((r.rtt, r.rtt_var, r.nak_interval()), (rtt, 0, interval), "a round trip of {rtt} µs");
|
||||
// At once for the gap; then the periodic NAK, whose timer had not run since the
|
||||
// link began, at the next tick; then every interval until the drop point.
|
||||
assert_eq!(naks.first(), Some(&found), "a round trip of {rtt} µs: NAKs at {naks:?}");
|
||||
assert!(naks[1] - naks[0] <= ACK_PERIOD_US, "a round trip of {rtt} µs: NAKs at {naks:?}");
|
||||
assert!(naks[1..].windows(2).all(|w| w[1] - w[0] == interval), "a round trip of {rtt} µs: NAKs at {naks:?}");
|
||||
let last = *naks.last().unwrap();
|
||||
assert!(last < drop && last + interval >= drop, "a round trip of {rtt} µs: NAKs at {naks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packet_recovered_and_still_held_when_the_link_ends_keeps_its_margin() {
|
||||
// 1001, found missing at 20 ms and due at 350 ms, is retransmitted at 100 ms; the link
|
||||
// ends at 150 ms, before anything is due, and delivers what it holds.
|
||||
let (mut r, account) = receiver();
|
||||
r.on_datagram_at(&data(1000, 0, false), 0);
|
||||
r.on_datagram_at(&data(1002, 100_000, false), FOUND_US);
|
||||
r.on_datagram_at(&data(1001, 50_000, true), 100_000);
|
||||
r.on_timer_at(150_000);
|
||||
r.close_now("peer_shutdown");
|
||||
let p = account.lock().unwrap().cut(1_000_000, true);
|
||||
p.check().unwrap();
|
||||
assert_eq!(r.delivered.len(), 3);
|
||||
assert_eq!((p.delivered, p.first_try, p.dropped, p.outstanding), (3, 2, 0, 0), "{p:?}");
|
||||
assert_eq!((p.recovery.samples, p.recovery.max, p.margin_min), (1, 80_000, 250_000), "{p:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ const MAX_SAMPLES: usize = 65_536;
|
|||
pub enum Outcome {
|
||||
/// It arrived before anything found it missing.
|
||||
FirstTry { bytes: u32 },
|
||||
/// It was found missing, then arrived before its delivery time: `recovery_us` after it
|
||||
/// was found missing, `margin_us` before its delivery time.
|
||||
/// It was found missing, then arrived before the receiver dropped it: `recovery_us`
|
||||
/// after it was found missing, `margin_us` before its delivery time, 0 if it arrived at
|
||||
/// or after that (SRT drops a packet only when the first one after it is due).
|
||||
Recovered { bytes: u32, recovery_us: u32, margin_us: u32 },
|
||||
/// Its delivery time passed without it.
|
||||
Dropped,
|
||||
|
|
@ -120,9 +121,11 @@ pub struct Part {
|
|||
/// Packets that arrived marked as retransmissions.
|
||||
pub retransmitted: u64,
|
||||
pub rtt: Summary,
|
||||
/// From a recovered packet's being found missing to its arrival.
|
||||
/// From a recovered packet's being found missing to its arrival, when the receiver took
|
||||
/// it. Not bounded by the window, which runs from the packet's timestamp.
|
||||
pub recovery: Summary,
|
||||
/// The least time a recovered packet arrived before its delivery time (0 with none).
|
||||
/// The least time a recovered packet arrived before its delivery time, each at most the
|
||||
/// window and 0 for one that arrived at or after it (0 with none).
|
||||
pub margin_min: u32,
|
||||
pub runs: Vec<Run>,
|
||||
pub runs_truncated: bool,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! order and whole, with no loss and through a proxy that drops datagrams each way; the
|
||||
//! account of every sequence number; encryption, a wrong passphrase, a missing one, the
|
||||
//! stream ID reaching the admission and its refusal codes; and the latency window: a packet
|
||||
//! is not delivered before it.
|
||||
//! is not delivered before it, at a listener or a caller that receives.
|
||||
|
||||
use joule_contrib::lossy::{Counts, Impair, Proxy};
|
||||
use joule_contrib::srt::packet::{rej, Packet};
|
||||
|
|
@ -117,23 +117,29 @@ async fn every_payload_arrives_in_order_and_is_accounted_for() {
|
|||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn losses_each_way_are_recovered_within_the_window() {
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut l = listener(Some("correct horse battery"), 300, seen).await;
|
||||
// A window of a second: the runner's host is shared, and a busy one has kept the
|
||||
// receiving task hundreds of milliseconds behind its socket, which a window of 300 ms
|
||||
// could not absorb. What is asserted below holds for any window the host can meet.
|
||||
let mut l = listener(Some("correct horse battery"), 1000, seen).await;
|
||||
// 3% of datagrams dropped toward the listener, 3% back (NAKs and ACKs among them), and
|
||||
// datagrams 50 to 54 in a row.
|
||||
let up = Impair { loss_ppm: 30_000, drop_index: &[50, 51, 52, 53, 54], ..Impair::default() };
|
||||
let down = Impair { loss_ppm: 30_000, ..Impair::default() };
|
||||
let px = Proxy::start(l.addr, up, down, 0x5eed).await.unwrap();
|
||||
let cfg = CallConfig { passphrase: Some("correct horse battery".into()), keylen: 32, latency_ms: 300, ..CallConfig::default() };
|
||||
let cfg = CallConfig { passphrase: Some("correct horse battery".into()), keylen: 32, latency_ms: 1000, ..CallConfig::default() };
|
||||
let tx = timeout(STEP, srt::call(px.addr, cfg)).await.unwrap().unwrap();
|
||||
let mut rx = timeout(STEP, l.accept()).await.unwrap().unwrap();
|
||||
assert_eq!(rx.info.cipher_bits, 256);
|
||||
assert_eq!(rx.info.latency_us, 1_000_000);
|
||||
let got = carry(&tx, &mut rx, 1000, 500).await;
|
||||
assert!(px.up.dropped.load(std::sync::atomic::Ordering::Relaxed) > 20, "the proxy dropped datagrams");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let part = rx.account.lock().unwrap().cut(1, false);
|
||||
part.check().unwrap();
|
||||
assert!(part.delivered >= got.len() as u64);
|
||||
// Everything was recovered: the window is many round trips on loopback.
|
||||
assert!(part.delivered >= got.len() as u64, "{part:?}");
|
||||
// Nothing was dropped: the receiver drops what has not arrived when the first packet
|
||||
// after it that has arrived is due (live-contribution §4.1), so every loss was recovered
|
||||
// before it would have been dropped.
|
||||
assert_eq!(part.dropped, 0, "{part:?}");
|
||||
assert_eq!(got.len(), 1000);
|
||||
assert!(got.iter().enumerate().all(|(i, p)| *p == payload(i as u64)));
|
||||
|
|
@ -142,10 +148,20 @@ async fn losses_each_way_are_recovered_within_the_window() {
|
|||
let lost = first_sends_lost(&px.up);
|
||||
assert!(lost.len() >= 2, "the path lost first sends of data: {lost:?}");
|
||||
assert_eq!(part.delivered - part.first_try, lost.len() as u64, "each first send lost was found and recovered: {part:?}");
|
||||
assert!(part.requests > 0 && part.retransmitted > 0);
|
||||
// The proxy neither delays nor reorders, so every packet recovered came back as a
|
||||
// retransmission (answering a NAK, or a probe of the stalled tail); and every NAK names
|
||||
// at least one sequence number.
|
||||
assert!(part.requests > 0 && part.retransmitted >= part.delivered - part.first_try, "{part:?}");
|
||||
assert!(part.requested >= part.requests, "{part:?}");
|
||||
let burst = longest_run(&lost);
|
||||
assert!(part.runs.iter().any(|r| r.count >= burst), "the longest burst of losses ({burst}) is one run: {:?}", part.runs);
|
||||
assert!(part.recovery.max < 300_000 && part.margin_min > 0);
|
||||
// Every recovered packet was taken before its own delivery time: with a margin of 0 it
|
||||
// would have been taken at or after it, before the first packet after it that has
|
||||
// arrived was due, and delivered late (live-contribution §6.1, §6.2). How long a recovery
|
||||
// took is not bounded here: it counts from when the packet was found missing, not from
|
||||
// its timestamp, which a late time base puts later (§6.3); the receiver's unit tests pin
|
||||
// where it is counted from, on a clock of their own.
|
||||
assert!(part.margin_min > 0, "{part:?}");
|
||||
px.stop();
|
||||
}
|
||||
|
||||
|
|
@ -179,17 +195,45 @@ async fn refusals_carry_their_codes() {
|
|||
assert_eq!(l.refused.load(std::sync::atomic::Ordering::Relaxed), 4);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn a_caller_that_receives_holds_a_packet_for_the_latency_window() {
|
||||
// The caller's time base comes from the listener's CONCLUSION reply, not from a handshake
|
||||
// it received first: a packet is still not delivered before its window.
|
||||
let adm = |_: &ConnRequest| Ok(Admitted { passphrase: None, role: Role::Send, latency_ms: None, tag: 0 });
|
||||
let mut l = srt::Listener::bind(ListenerConfig::default(), Arc::new(adm)).await.unwrap();
|
||||
let mut rx = srt::call(l.addr, CallConfig { role: Role::Receive, latency_ms: 400, ..CallConfig::default() }).await.unwrap();
|
||||
let tx = l.accept().await.unwrap();
|
||||
assert_eq!(rx.info.latency_us, 400_000, "the larger of the two ends' latencies");
|
||||
let t = Instant::now();
|
||||
tx.send.clone().unwrap().send(payload(0)).await.unwrap();
|
||||
let p = timeout(STEP, rx.data.recv()).await.unwrap().unwrap();
|
||||
let held = t.elapsed();
|
||||
assert_eq!(p, payload(0));
|
||||
assert!(held >= Duration::from_millis(380) && held < Duration::from_millis(900), "delivered at its TSBPD time: {held:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn a_listener_that_sends_and_a_caller_that_receives() {
|
||||
let adm = |_: &ConnRequest| Ok(Admitted { passphrase: Some("both ways round".into()), role: Role::Send, latency_ms: None, tag: 0 });
|
||||
let mut l = srt::Listener::bind(ListenerConfig::default(), Arc::new(adm)).await.unwrap();
|
||||
let px = Proxy::start(l.addr, Impair { loss_ppm: 20_000, ..Impair::default() }, Impair { loss_ppm: 20_000, ..Impair::default() }, 99).await.unwrap();
|
||||
let cfg = CallConfig { passphrase: Some("both ways round".into()), role: Role::Receive, latency_ms: 250, ..CallConfig::default() };
|
||||
// A window of a second, as above: a busy host has kept this receiver too far behind its
|
||||
// socket to meet a quarter of one.
|
||||
let cfg = CallConfig { passphrase: Some("both ways round".into()), role: Role::Receive, latency_ms: 1000, ..CallConfig::default() };
|
||||
let mut rx = timeout(STEP, srt::call(px.addr, cfg)).await.unwrap().unwrap();
|
||||
let tx = timeout(STEP, l.accept()).await.unwrap().unwrap();
|
||||
assert_eq!(rx.info.latency_us, 1_000_000, "the larger of the two ends' latencies");
|
||||
let got = carry(&tx, &mut rx, 600, 400).await;
|
||||
assert_eq!(got.len(), 600);
|
||||
assert!(got.iter().enumerate().all(|(i, p)| *p == payload(i as u64)));
|
||||
let s = tx.sent.lock().unwrap().clone();
|
||||
assert!(s.retransmitted > 0 && s.requests > 0, "{s:?}");
|
||||
// The caller's account, as the listener's above: nothing dropped, and every packet
|
||||
// recovered taken before its own delivery time.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let part = rx.account.lock().unwrap().cut(1, false);
|
||||
part.check().unwrap();
|
||||
assert!(part.delivered >= got.len() as u64 && part.delivered > part.first_try, "{part:?}");
|
||||
assert_eq!(part.dropped, 0, "{part:?}");
|
||||
assert!(part.margin_min > 0, "{part:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,9 +130,11 @@ authority and clock keys and:
|
|||
posts, each carried only when a verifier would accept it (its chain, its signature, its
|
||||
grant's policy and rate, and co-watch §5 and §7.2). Each window, the clock signs the skew
|
||||
statements of co-watch §8.4 from what the log holds; and a viewer silent for four seconds, or
|
||||
whose grant has ended, leaves. A watch room is never end-to-end encrypted (its source is
|
||||
served in the clear), admitted by age (its source is served without a grant) or a room with
|
||||
co-performance: the service refuses such a configuration.
|
||||
whose grant has ended, leaves. One that enters again before the clock has signed that `leave`
|
||||
has it signed first, then its `join`, so that no `join` comes while it is present. A watch
|
||||
room is never end-to-end encrypted (its source is served in the clear), admitted by age (its
|
||||
source is served without a grant) or a room with co-performance: the service refuses such a
|
||||
configuration.
|
||||
|
||||
- **keeps a shared world** (`[world]`, WAI `extensions/live-worlds.md`). Each room then has a
|
||||
world statement (`WLW1`) the authority signs: the world its operations replay in (for
|
||||
|
|
@ -263,7 +265,7 @@ the service, run in each mode, answers every operation with a status it lists.
|
|||
| `GET /watch/time` | a watch room: `{"now_us", "t0_us"}`, the service's clock, for a page's estimate of the room clock |
|
||||
| `GET /watch/source` | a watch room's source, its bytes whole, of its media type |
|
||||
| `GET /watch/challenge` | `{"challenge": <hex>}`: single-use, good for a minute |
|
||||
| `POST /watch/join` `{"room", "grant": <hex>, "proof": <hex>}` | the proof (live-rooms §10.5.1) over a challenge, for `POST //<host><public_prefix>/watch/join`; the clock signs the principal's `join`: `{"ok", "t"}`; `401` `{"error": <reason>}` for a proof that fails |
|
||||
| `POST /watch/join` `{"room", "grant": <hex>, "proof": <hex>}` | the proof (live-rooms §10.5.1) over a challenge, for `POST //<host><public_prefix>/watch/join`; the clock signs the principal's `join`, after its `leave` if the room took it for gone and that is not signed yet: `{"ok", "t"}`; `401` `{"error": <reason>}` for a proof that fails |
|
||||
| `POST /watch/post` `{"room", "events": [<hex>]}` (at most 8) | the viewer's events, each checked and carried in order: `{"ok", "carried", "log"}`; `409` `{"error": <reason>, "carried"}` at the first refused |
|
||||
| `GET /watch?room=<id>&from=<n>[&wait_ms=<ms>]` | the watch room's log from entry `n`: `{"room", "entries": [{"seq", "event": <hex>}], "next", "end"}`, held up to 8 s while nothing is past `n` |
|
||||
| `GET /watch/room` | the room as a co-watch verifier is given it (`descriptor`, `grants`, `chains`, `policies`, `sources`), the latest window's skew statements read, and the refusals by reason |
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
//! | `GET /watch/time` | `{"now_us", "t0_us"}`: the service's clock, for a page's estimate of the room clock |
|
||||
//! | `GET /watch/source` | the source's bytes, of its media type |
|
||||
//! | `GET /watch/challenge` | `{"challenge": <hex>}`: single-use, good for a minute (live-rooms §10.5.3) |
|
||||
//! | `POST /watch/join` `{"room", "grant": <hex>, "proof": <hex>}` | the grant's principal enters, the proof over a challenge for `POST //<host><prefix>/watch/join`: the clock signs its `join`; `{"ok", "t": <its t>}`; `401` `{"error": <reason>}` for a proof that fails |
|
||||
//! | `POST /watch/join` `{"room", "grant": <hex>, "proof": <hex>}` | the grant's principal enters, the proof over a challenge for `POST //<host><prefix>/watch/join`: the clock signs its `join`, after its `leave` if the room took it for gone and that is not signed yet; `{"ok", "t": <its t>}` (and `"again"` while it is present); `401` `{"error": <reason>}` for a proof that fails |
|
||||
//! | `POST /watch/post` `{"room", "events": [<hex>]}` (at most 8) | each event checked as co-watch §10 has it and carried, in order: `{"ok", "carried"}`; `409` `{"error": <reason>, "carried"}` at the first refused |
|
||||
//! | `GET /watch?room=<id>&from=<n>[&wait_ms=<ms>]` | the room's log from entry `n` (`{"room", "entries": [{"seq", "event": <hex>}], "next", "end"}`), held up to 8 s while it has nothing past `n` |
|
||||
//! | `GET /watch/room` | the room as a co-watch verifier is given it (co-watch §11.1), with the latest skew statements read |
|
||||
|
|
@ -834,7 +834,7 @@ async fn watch_join(svc: &Arc<Service>, host: &str, body: &[u8]) -> Response<Ful
|
|||
return reply(StatusCode::BAD_REQUEST, json!({"error": "room, grant and proof: hexadecimal"}));
|
||||
};
|
||||
let context = wai::live_room::proof_context("POST", host, &format!("{}/watch/join", svc.public_prefix));
|
||||
let (generation, principal, digest, joined) = {
|
||||
let (generation, principal, digest) = {
|
||||
let mut st = svc.state.lock().unwrap();
|
||||
if st.epoch.id != room {
|
||||
return reply(StatusCode::GONE, json!({"error": "room", "room": hex::encode(st.epoch.id)}));
|
||||
|
|
@ -850,13 +850,13 @@ async fn watch_join(svc: &Arc<Service>, host: &str, body: &[u8]) -> Response<Ful
|
|||
};
|
||||
let vw = w.viewers.get_mut(&principal).expect("admitted");
|
||||
vw.heard_us = crate::now_us();
|
||||
(generation, principal, vw.digest, vw.joined.filter(|_| !vw.leaving))
|
||||
(generation, principal, vw.digest)
|
||||
};
|
||||
if let Some(t) = joined {
|
||||
return reply(StatusCode::OK, json!({"ok": true, "t": t, "again": true}));
|
||||
}
|
||||
match svc.emit(generation, principal, &[crate::epoch::Lifecycle::Join { grant: digest }]).await {
|
||||
Ok(t) => {
|
||||
// Present, or taken for gone with its `leave` not yet signed: that `leave`, then the `join`,
|
||||
// under the clock's lock (a `join` of a present viewer is refused, live-rooms §7.2).
|
||||
match svc.watch_enter(generation, principal, digest).await {
|
||||
Ok((t, true)) => reply(StatusCode::OK, json!({"ok": true, "t": t, "again": true})),
|
||||
Ok((t, false)) => {
|
||||
tracing::info!(principal = %hex::encode(&principal[..6]), t, "a viewer joins the watch room");
|
||||
reply(StatusCode::OK, json!({"ok": true, "t": t}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -602,7 +602,17 @@ impl Service {
|
|||
/// [`Service::emit_items`], each record at room-clock `min_t` or later.
|
||||
pub async fn emit_items_after(&self, generation: u64, items: &[Item], min_t: u64) -> Result<Vec<(Vec<u8>, u64)>, String> {
|
||||
let mut clock = self.clock.lock().await;
|
||||
let client = self.events_track(&mut clock, generation).await?;
|
||||
let out = self.sign_items(&mut clock, generation, items, min_t).await?;
|
||||
drop(clock);
|
||||
self.changed.notify_waiters();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`Service::emit_items_after`], the clock's lock held by the caller, who may read the
|
||||
/// room's state under it first: no other record of the clock's comes between that reading
|
||||
/// and these records.
|
||||
async fn sign_items(&self, clock: &mut Clock, generation: u64, items: &[Item], min_t: u64) -> Result<Vec<(Vec<u8>, u64)>, String> {
|
||||
let client = self.events_track(clock, generation).await?;
|
||||
let room = clock.track.as_ref().expect("made above").room;
|
||||
let mut out = Vec::new();
|
||||
for what in items {
|
||||
|
|
@ -649,9 +659,68 @@ impl Service {
|
|||
self.watch_record(generation, &rec, what);
|
||||
out.push((rec, t));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A watch room's viewer, read under the state's lock: `f` of it, or `None` when the room
|
||||
/// of `generation` was replaced or has no such viewer.
|
||||
fn watch_viewer<X>(&self, generation: u64, principal: &[u8; 32], f: impl FnOnce(&watch::Viewer) -> Option<X>) -> Option<X> {
|
||||
let st = self.state.lock().unwrap();
|
||||
if st.epoch.generation != generation {
|
||||
return None;
|
||||
}
|
||||
st.epoch.watch.as_ref()?.viewers.get(principal).and_then(f)
|
||||
}
|
||||
|
||||
/// A watch room's viewer's `leave` the room asked of the clock ([`watch::WatchRoom::ended`]):
|
||||
/// its reason and the least `t` it may have, after the viewer's last event; `None` once it
|
||||
/// is signed, or when there is none.
|
||||
fn watch_pending_leave(v: &watch::Viewer) -> Option<(u8, u64)> {
|
||||
v.joined.and(v.leaving).map(|why| (why, v.last_t + 1))
|
||||
}
|
||||
|
||||
/// The `leave` the room asked for the watch room's viewer `principal`, signed, unless it was
|
||||
/// signed since: the viewer may have entered again first ([`Service::watch_enter`]), and a
|
||||
/// `leave` signed after that `join` would end the presence it began. The clock's lock is
|
||||
/// held from reading the viewer to signing, so the two are signed in the order the room's
|
||||
/// state stands for. The `leave`'s `t`, if this signed it.
|
||||
pub async fn watch_leave(&self, generation: u64, principal: [u8; 32]) -> Result<Option<u64>, String> {
|
||||
let mut clock = self.clock.lock().await;
|
||||
let Some((reason, after)) = self.watch_viewer(generation, &principal, Service::watch_pending_leave) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let v = self.sign_items(&mut clock, generation, &[Item::Life(principal, Lifecycle::Leave { reason })], after).await?;
|
||||
drop(clock);
|
||||
self.changed.notify_waiters();
|
||||
Ok(out)
|
||||
Ok(v.last().map(|(_, t)| *t))
|
||||
}
|
||||
|
||||
/// The watch room's viewer `principal`, admitted by its proof, enters under the grant whose
|
||||
/// digest is `grant`: the clock signs its `join`. A viewer the room took for gone, whose
|
||||
/// `leave` is not signed yet (it waits behind the clock's other records), has that `leave`
|
||||
/// signed first: it ends the presence before, and the `join` comes when the viewer is not
|
||||
/// present, as live-rooms §7.2 has it (`already_present`). Under the clock's lock, as
|
||||
/// [`Service::watch_leave`]. The `join`'s `t`, and whether the viewer was present already,
|
||||
/// when nothing is signed.
|
||||
pub async fn watch_enter(&self, generation: u64, principal: [u8; 32], grant: [u8; 32]) -> Result<(u64, bool), String> {
|
||||
let mut clock = self.clock.lock().await;
|
||||
let Some((present, pending)) = self.watch_viewer(generation, &principal, |v| Some((v.joined.filter(|_| v.leaving.is_none()), Service::watch_pending_leave(v)))) else {
|
||||
return Err("a room since replaced".into());
|
||||
};
|
||||
if let Some(t) = present {
|
||||
return Ok((t, true));
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
let mut after = 0;
|
||||
if let Some((reason, t)) = pending {
|
||||
items.push(Item::Life(principal, Lifecycle::Leave { reason }));
|
||||
after = t;
|
||||
}
|
||||
items.push(Item::Life(principal, Lifecycle::Join { grant }));
|
||||
let v = self.sign_items(&mut clock, generation, &items, after).await?;
|
||||
drop(clock);
|
||||
self.changed.notify_waiters();
|
||||
Ok((v.last().map_or(0, |(_, t)| *t), false))
|
||||
}
|
||||
|
||||
/// In a watch room, every record of the clock's chain is carried on the room's log too,
|
||||
|
|
@ -670,7 +739,8 @@ impl Service {
|
|||
|
||||
/// The clock's part in a watch room: each window's skew statements once the window has
|
||||
/// ended (co-watch §8.4), and a `leave` for each viewer that has fallen silent or whose
|
||||
/// grant has ended. Runs for as long as the service does.
|
||||
/// grant has ended, unless it entered again before the clock could sign it
|
||||
/// ([`Service::watch_leave`]). Runs for as long as the service does.
|
||||
pub async fn run_watch(self: Arc<Self>) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
|
|
@ -690,8 +760,10 @@ impl Service {
|
|||
tracing::warn!(error = %e, "a skew statement could not be sent");
|
||||
}
|
||||
}
|
||||
for (p, why, min_t) in ended {
|
||||
if let Err(e) = self.emit_after(generation, p, &[Lifecycle::Leave { reason: why }], min_t).await {
|
||||
// A viewer's post between `ended` and its `leave` is refused `not_present`, and it
|
||||
// may enter again before the `leave` is signed: `watch_leave` then signs nothing.
|
||||
for (p, _) in ended {
|
||||
if let Err(e) = self.watch_leave(generation, p).await {
|
||||
tracing::warn!(error = %e, "a viewer's leave could not be sent");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,8 +132,10 @@ pub struct Viewer {
|
|||
pub digest: [u8; 32],
|
||||
/// Its presence: the `t` of its `join`, while present.
|
||||
pub joined: Option<u64>,
|
||||
/// A `leave` asked of the clock and not yet signed.
|
||||
pub leaving: bool,
|
||||
/// The reason of a `leave` the room asked of the clock and the clock has not yet signed:
|
||||
/// until it is, the viewer posts nothing, and the clock signs that `leave` before any
|
||||
/// `join` of it ([`crate::Service::watch_enter`]).
|
||||
pub leaving: Option<u8>,
|
||||
/// Present by its proof at `/watch/join`, and so gone once silent; a publisher is present
|
||||
/// while its tracks are.
|
||||
pub by_proof: bool,
|
||||
|
|
@ -201,7 +203,7 @@ impl WatchRoom {
|
|||
/// A viewer's grant the room issued.
|
||||
pub fn granted(&mut self, record: Vec<u8>, grant: Grant, digest: [u8; 32]) {
|
||||
let p = grant.principal;
|
||||
self.viewers.insert(p, Viewer { grant, record, digest, joined: None, leaving: false, by_proof: false, digests: Vec::new(), last_t: 0, heard_us: 0, recent: VecDeque::new() });
|
||||
self.viewers.insert(p, Viewer { grant, record, digest, joined: None, leaving: None, by_proof: false, digests: Vec::new(), last_t: 0, heard_us: 0, recent: VecDeque::new() });
|
||||
}
|
||||
|
||||
pub fn present(&self) -> usize {
|
||||
|
|
@ -222,7 +224,7 @@ impl WatchRoom {
|
|||
self.grant_changes.push(GrantChange { principal: subject, t, subscribe });
|
||||
if let Some(v) = self.viewers.get_mut(&subject) {
|
||||
v.joined = Some(t);
|
||||
v.leaving = false;
|
||||
v.leaving = None;
|
||||
v.last_t = v.last_t.max(t);
|
||||
}
|
||||
}
|
||||
|
|
@ -232,7 +234,7 @@ impl WatchRoom {
|
|||
}
|
||||
if let Some(v) = self.viewers.get_mut(&subject) {
|
||||
v.joined = None;
|
||||
v.leaving = false;
|
||||
v.leaving = None;
|
||||
}
|
||||
}
|
||||
Lifecycle::Publish { .. } | Lifecycle::Exchange { .. } => {}
|
||||
|
|
@ -286,7 +288,7 @@ impl WatchRoom {
|
|||
if (ev.seq as usize) < v.digests.len() {
|
||||
return if v.digests[ev.seq as usize] == digest { Ok(()) } else { Err("seq_replay") };
|
||||
}
|
||||
let joined = v.joined.filter(|_| !v.leaving).ok_or("not_present")?;
|
||||
let joined = v.joined.filter(|_| v.leaving.is_none()).ok_or("not_present")?;
|
||||
ev.verify_signature().map_err(|r| r.code())?;
|
||||
if ev.seq as usize != v.digests.len() {
|
||||
return Err("delivery_gap");
|
||||
|
|
@ -403,13 +405,13 @@ impl WatchRoom {
|
|||
}
|
||||
|
||||
/// The viewers whose presence has ended at `now_us` (µs since 1970): silent too long, or
|
||||
/// their grant over. Each with its `leave` reason and the least `t` the `leave` may have,
|
||||
/// after the viewer's last event.
|
||||
pub fn ended(&mut self, now_us: u64) -> Vec<([u8; 32], u8, u64)> {
|
||||
/// their grant over. Each with its `leave` reason, which [`Viewer::leaving`] holds until the
|
||||
/// clock signs that `leave`.
|
||||
pub fn ended(&mut self, now_us: u64) -> Vec<([u8; 32], u8)> {
|
||||
let now_s = now_us / 1_000_000;
|
||||
let mut out = Vec::new();
|
||||
for (p, v) in self.viewers.iter_mut() {
|
||||
if v.joined.is_none() || v.leaving || !v.by_proof {
|
||||
if v.joined.is_none() || v.leaving.is_some() || !v.by_proof {
|
||||
continue;
|
||||
}
|
||||
let why = if now_s >= v.grant.exp {
|
||||
|
|
@ -419,8 +421,8 @@ impl WatchRoom {
|
|||
} else {
|
||||
continue;
|
||||
};
|
||||
v.leaving = true;
|
||||
out.push((*p, why, v.last_t + 1));
|
||||
v.leaving = Some(why);
|
||||
out.push((*p, why));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -625,7 +627,7 @@ mod tests {
|
|||
let mut w = e.watch.take().unwrap();
|
||||
let ended = w.ended(t0 + 20 * s);
|
||||
assert_eq!(ended.len(), 2);
|
||||
assert!(ended.iter().all(|(_, why, _)| *why == leave::CONNECTION_LOST));
|
||||
assert!(ended.iter().all(|(_, why)| *why == leave::CONNECTION_LOST));
|
||||
assert!(w.ended(t0 + 21 * s).is_empty(), "a leave is asked once");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -656,3 +656,145 @@ async fn run_watch() {
|
|||
assert_eq!(room_now["refused"]["control_not_ahead"].as_u64(), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A watch room's viewer enters by a proof of `k` over a fresh challenge, for `target`: the
|
||||
/// answer to its `POST /watch/join`.
|
||||
async fn watch_join(addr: std::net::SocketAddr, room: &str, k: &SigningKey, grant: &[u8], target: &str) -> (u16, Value) {
|
||||
let (_, c) = call(addr, "GET", "/watch/challenge", None, "10.0.1.9").await;
|
||||
let c: [u8; 32] = hex::decode(c["challenge"].as_str().unwrap()).unwrap().try_into().unwrap();
|
||||
let digest: [u8; 32] = Sha256::digest(&grant[..grant.len() - 64]).into();
|
||||
let proof = wai::live_room::Proof::sign(digest, k, c, &format!("POST {target}")).to_bytes();
|
||||
let body = serde_json::json!({"room": room, "grant": hex::encode(grant), "proof": hex::encode(&proof)});
|
||||
call(addr, "POST", "/watch/join", Some(&body), "10.0.1.9").await
|
||||
}
|
||||
|
||||
/// A watch room's viewer taken for gone (silent past the room's limit) while its `leave` waits
|
||||
/// behind the clock's other records, as it does while the clock signs a window's skew
|
||||
/// statements: its post in between is refused `not_present`, and it enters again before the
|
||||
/// `leave` is signed. The clock then signs that `leave` first and its `join` after, so the
|
||||
/// room's own `leave` for it finds nothing left to sign, and the event refused is carried
|
||||
/// signed again. The other order, the `leave` signed before it enters again, signs the `join`
|
||||
/// alone. Every `join` comes while the viewer is not present (live-rooms §7.2), and the room
|
||||
/// the log carries verifies (co-watch §11).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn a_watch_viewer_taken_for_gone_enters_again_after_its_leave() {
|
||||
timeout(Duration::from_secs(60), run_watch_again()).await.expect("the test finishes in time");
|
||||
}
|
||||
|
||||
async fn run_watch_again() {
|
||||
use wai::co_watch::{self as cw, state, Presentation};
|
||||
use wai::live_events::{kind, Event};
|
||||
let keys = Keys::generate();
|
||||
let server = relay(&keys).await;
|
||||
let dir = std::env::temp_dir().join(format!("joule-moq-room-watch-again-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("source.wav");
|
||||
std::fs::write(&file, vec![7u8; 4096]).unwrap();
|
||||
let cfg = Config::from_toml(&format!(
|
||||
"listen = \"127.0.0.1:0\"\nkeys = \"/nonexistent\"\nrelay_url = \"ws://{}{}\"\n[room]\nwatch_source = \"{}\"\nwatch_duration_ms = 60000\n",
|
||||
server.websocket_addr.unwrap(),
|
||||
server.relay().config().path,
|
||||
file.display()
|
||||
))
|
||||
.unwrap();
|
||||
let svc = Service::new(Keys::from_toml(&keys.to_toml()).unwrap(), &cfg);
|
||||
let listener = tokio::net::TcpListener::bind(cfg.listen).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
// The clock's session and the service, without the watch loop: the test asks for the
|
||||
// viewer's `leave` and has the clock sign it when the case calls for it.
|
||||
tokio::spawn(svc.clone().run_clock());
|
||||
tokio::spawn(http::serve(svc.clone(), listener));
|
||||
timeout(STEP, async {
|
||||
while !svc.connected.load(Ordering::Acquire) {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the clock's session opens");
|
||||
|
||||
let (_, r) = call(addr, "GET", "/room", None, "10.0.1.9").await;
|
||||
let policy = cw::WatchPolicy::parse(&hex::decode(r["watch"]["policy"].as_str().expect("a watch room")).unwrap()).unwrap();
|
||||
let room = r["room"].as_str().unwrap().to_string();
|
||||
let room_id: [u8; 32] = hex::decode(&room).unwrap().try_into().unwrap();
|
||||
let t0 = r["t0_us"].as_u64().unwrap();
|
||||
let a = key(0x61);
|
||||
let pa = a.verifying_key().to_bytes();
|
||||
let (st, g) = ask(addr, &a, "viewer", "10.0.1.1").await;
|
||||
assert_eq!(st, 200, "{g}");
|
||||
let grant = hex::decode(g["grant"].as_str().unwrap()).unwrap();
|
||||
let target = "//room.test/watch/join";
|
||||
let unix_us = || std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros() as u64;
|
||||
let now_t = || unix_us().saturating_sub(t0);
|
||||
let enter = || {
|
||||
let (room, a, grant) = (&room, &a, &grant);
|
||||
async move {
|
||||
let (st, j) = watch_join(addr, room, a, grant, target).await;
|
||||
assert_eq!((st, j["again"].as_bool()), (200, None), "a join the clock signs: {j}");
|
||||
j["t"].as_u64().unwrap()
|
||||
}
|
||||
};
|
||||
// Its presentation statement at `t`, the event at `seq` of its chain after `prev`; posted.
|
||||
let statement = |seq: u64, prev: [u8; 32], t: u64| {
|
||||
Event::sign(&a, room_id, seq, prev, t, None, kind::META, Presentation::sign(&a, room_id, policy.source, t, 0, state::PAUSED, 1_000_000).encode())
|
||||
};
|
||||
let post = |e: &Event| {
|
||||
let body = serde_json::json!({"room": room, "events": [hex::encode(e.to_bytes())]});
|
||||
async move { call(addr, "POST", "/watch/post", Some(&body), "10.0.1.1").await }
|
||||
};
|
||||
// The room takes the viewer for gone, as it does once it is silent past its limit: its
|
||||
// `leave` is asked of the clock, and not yet signed.
|
||||
let silent = || {
|
||||
let mut st = svc.state.lock().unwrap();
|
||||
let generation = st.epoch.generation;
|
||||
let ended = st.epoch.watch.as_mut().expect("a watch room").ended(unix_us() + joule_moq_room::watch::SILENT_US + 1_000_000);
|
||||
assert_eq!(ended, vec![(pa, wai::live_room::leave::CONNECTION_LOST)], "the viewer is taken for gone");
|
||||
generation
|
||||
};
|
||||
|
||||
let joined = enter().await;
|
||||
let e = statement(0, [0; 32], now_t().max(joined));
|
||||
let (st, r) = post(&e).await;
|
||||
assert_eq!(st, 200, "{r}");
|
||||
let (mut seq, mut prev) = (1, e.digest());
|
||||
|
||||
// Taken for gone, and back before its `leave` is signed.
|
||||
let generation = silent();
|
||||
let (st, r) = post(&statement(seq, prev, now_t())).await;
|
||||
assert_eq!((st, r["error"].as_str()), (409, Some("not_present")), "a viewer taken for gone posts nothing");
|
||||
let joined = enter().await;
|
||||
assert_eq!(svc.watch_leave(generation, pa).await, Ok(None), "the leave was signed before the join, and is not signed after it");
|
||||
let e = statement(seq, prev, now_t().max(joined));
|
||||
let (st, r) = post(&e).await;
|
||||
assert_eq!(st, 200, "the position refused, signed again after the join: {r}");
|
||||
(seq, prev) = (seq + 1, e.digest());
|
||||
|
||||
// Taken for gone, its `leave` signed, then back.
|
||||
let generation = silent();
|
||||
assert!(matches!(svc.watch_leave(generation, pa).await, Ok(Some(_))), "the room's leave for it is signed");
|
||||
assert_eq!(svc.watch_leave(generation, pa).await, Ok(None), "and signed once");
|
||||
let (st, r) = post(&statement(seq, prev, now_t())).await;
|
||||
assert_eq!((st, r["error"].as_str()), (409, Some("not_present")));
|
||||
let joined = enter().await;
|
||||
let e = statement(seq, prev, now_t().max(joined));
|
||||
let (st, r) = post(&e).await;
|
||||
assert_eq!(st, 200, "{r}");
|
||||
|
||||
// The clock's records of the viewer, in order, and the room as a verifier is given it.
|
||||
let (_, v) = call(addr, "GET", "/watch/room", None, "10.0.1.9").await;
|
||||
let hexes = |v: &Value| -> Vec<Vec<u8>> { v.as_array().unwrap().iter().map(|h| hex::decode(h.as_str().unwrap()).unwrap()).collect() };
|
||||
let chains: Vec<Vec<Vec<u8>>> = v["chains"].as_array().unwrap().iter().map(hexes).collect();
|
||||
let life: Vec<u8> = chains[0].iter().map(|r| Event::parse(r).unwrap().kind).filter(|k| *k == kind::JOIN || *k == kind::LEAVE).collect();
|
||||
assert_eq!(life, [kind::JOIN, kind::LEAVE, kind::JOIN, kind::LEAVE, kind::JOIN], "the clock's lifecycle of the viewer");
|
||||
assert_eq!(chains[1].len(), 3, "the viewer's chain, whole");
|
||||
let run = cw::verify(&cw::Input {
|
||||
descriptor: &hex::decode(v["descriptor"].as_str().unwrap()).unwrap(),
|
||||
grants: &hexes(&v["grants"]),
|
||||
head: None,
|
||||
chains: &chains,
|
||||
policies: &hexes(&v["policies"]),
|
||||
sources: &[Some(vec![cw::SourceObject { content_hash: Sha256::digest(vec![7u8; 4096]).into(), duration_ms: 60_000 }])],
|
||||
});
|
||||
assert_eq!(run.outcome, cw::Outcome::Accept);
|
||||
assert_eq!(v["refused"]["not_present"].as_u64(), Some(2));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,10 @@
|
|||
// page fetching the source and checking it by the content hash the room's
|
||||
// policy names, entering the room's co-watch log by a proof of its key; one
|
||||
// plays, one pauses, one seeks, each control applied by every page at its
|
||||
// instant; a viewer joins late and is brought to the room's position; every
|
||||
// instant (the one the room carried, which moves only when a page the room
|
||||
// took for gone signs it again); the service refuses no event but the posts
|
||||
// of a page it took for gone, each followed by that page entering again; a
|
||||
// viewer joins late and is brought to the room's position; every
|
||||
// page states what it presents twice a slot, and the room clock's skew
|
||||
// statements measure them: in the last windows every viewer and every pair is
|
||||
// in sync within the policy's bound, as each page shows; and the room, read
|
||||
|
|
@ -2060,6 +2063,26 @@ async function scenarioCowatch(run) {
|
|||
return ok && all;
|
||||
}, 250);
|
||||
const BOUND_US = 250000;
|
||||
// A control signed on `who`'s page, the n-th of the room's, and the instant the room carried
|
||||
// it at, read from the room's log. A page the room took for gone (a page on a loaded machine
|
||||
// can be silent past its limit) enters again and signs again what the room had not carried,
|
||||
// no earlier than its new join plus the lead, so that instant can be later than the one the
|
||||
// page first signed it for. Only that moves it: the room carries it at the instant signed
|
||||
// unless the page entered again between signing it and its being carried (`rejoins`, which
|
||||
// the page counts as it signs again; `gone` counts the refusal before it enters, so a page
|
||||
// already entering again when the control is signed would have counted it before).
|
||||
const control = async (who, n, name, arg = 0) => {
|
||||
const before = (await snap(who.p)).cowatch.rejoins;
|
||||
const signed = await who.p.evaluate(([x, y]) => window.roomTest.watchControl(x, y), [name, arg]);
|
||||
const at = await until(`${label}: ${who.p.name}'s ${name} carried`, STEP, async () => {
|
||||
const c = (await who.p.evaluate(() => window.roomTest.watchLog())).entries.filter((e) => e.kind === "control")[n - 1];
|
||||
return c && c.principal === who.s.cowatch.principal && c.at;
|
||||
}, 250);
|
||||
const after = (await snap(who.p)).cowatch.rejoins;
|
||||
check(`${label}: ${who.p.name}'s ${name} is carried at the instant signed, unless its page entered again meanwhile`, at === signed || after > before,
|
||||
`signed for ${signed}, carried at ${at}; the page entered again ${after - before} time(s) meanwhile`);
|
||||
return { signed, at, moved: at !== signed ? ` (signed for ${signed}, signed again after its page entered again)` : "" };
|
||||
};
|
||||
try {
|
||||
const A = await enter("ada");
|
||||
const m = await markup(A.p);
|
||||
|
|
@ -2068,21 +2091,21 @@ async function scenarioCowatch(run) {
|
|||
check(`${label}: each page fetched the source and checked it by the content hash the room's policy names`, A.s.cowatch.verified_source && B.s.cowatch.verified_source, `${wav.bytes} bytes, ${wav.duration_ms} ms`);
|
||||
check(`${label}: each page entered the room's co-watch log by a proof of its key, and its clock is estimated against the room's`, A.s.cowatch.joined > 0 && B.s.cowatch.joined > 0, `joined at ${A.s.cowatch.joined} and ${B.s.cowatch.joined} µs of room clock; offsets ${A.s.cowatch.offset_us} and ${B.s.cowatch.offset_us} µs`);
|
||||
// Ada plays: every page applies it at its instant.
|
||||
const playAt = await A.p.evaluate(() => window.roomTest.watchControl("play"));
|
||||
const t1 = await together(`${label}: both play from Ada's control`, 1, true, playAt);
|
||||
check(`${label}: a play, sent ahead of its instant, starts both players together`, true, t1.map(([n, c]) => `${n} ${(c.error_us / 1000).toFixed(1)} ms`).join(", ") + `; effective at ${playAt}`);
|
||||
const play = await control(A, 1, "play");
|
||||
const t1 = await together(`${label}: both play from Ada's control`, 1, true, play.at);
|
||||
check(`${label}: a play, sent ahead of its instant, starts both players together`, true, t1.map(([n, c]) => `${n} ${(c.error_us / 1000).toFixed(1)} ms`).join(", ") + `; effective at ${play.at}${play.moved}`);
|
||||
await sleep(4000);
|
||||
// Ben pauses.
|
||||
const pauseAt = await B.p.evaluate(() => window.roomTest.watchControl("pause"));
|
||||
const t2 = await together(`${label}: both pause on Ben's control`, 2, false, pauseAt);
|
||||
const pause = await control(B, 2, "pause");
|
||||
const t2 = await together(`${label}: both pause on Ben's control`, 2, false, pause.at);
|
||||
const pos2 = t2.map(([, c]) => c.current_time);
|
||||
check(`${label}: a pause stops both players at one position`, Math.abs(pos2[0] - pos2[1]) * 1e6 <= BOUND_US, pos2.map((x) => x.toFixed(3)).join(" s, ") + " s");
|
||||
check(`${label}: a pause stops both players at one position`, Math.abs(pos2[0] - pos2[1]) * 1e6 <= BOUND_US, pos2.map((x) => x.toFixed(3)).join(" s, ") + " s" + pause.moved);
|
||||
await sleep(1500);
|
||||
const againAt = await A.p.evaluate(() => window.roomTest.watchControl("play"));
|
||||
await together(`${label}: both play again`, 3, true, againAt);
|
||||
const again = await control(A, 3, "play");
|
||||
await together(`${label}: both play again`, 3, true, again.at);
|
||||
// Ben seeks to 60 s.
|
||||
const seekAt = await B.p.evaluate(() => window.roomTest.watchControl("seek", 60e6));
|
||||
const t4 = await together(`${label}: both go to 60 s on Ben's seek`, 4, true, seekAt);
|
||||
const seek = await control(B, 4, "seek", 60e6);
|
||||
const t4 = await together(`${label}: both go to 60 s on Ben's seek`, 4, true, seek.at);
|
||||
check(`${label}: a seek takes every player to the position it names`, t4.every(([, c]) => c.current_time >= 59.5 && c.current_time < 75), t4.map(([n, c]) => `${n} at ${c.current_time.toFixed(2)} s`).join(", "));
|
||||
await sleep(3000);
|
||||
// Cleo joins late, and is brought to where the room is.
|
||||
|
|
@ -2102,7 +2125,31 @@ async function scenarioCowatch(run) {
|
|||
const vs = lastWindow.flatMap((s) => s.viewers);
|
||||
check(`${label}: the room clock's last window states all three viewers in sync within the bound`, vs.length === 3 && vs.every((v) => v.verdict === "in_sync" && v.pq <= BOUND_US && v.counted > 0 && v.counted === v.due),
|
||||
vs.map((v) => `${v.principal.slice(0, 8)} ${v.verdict} p95 ${(v.pq / 1000).toFixed(1)} ms (${v.counted}/${v.due})`).join(", "));
|
||||
check(`${label}: the service carried every event the pages posted`, Object.keys(final.refused || {}).length === 0, JSON.stringify(final.refused));
|
||||
// The room takes a page silent past its limit for gone (a loaded host can hold a page's
|
||||
// thread that long, as in its entering): the page's next post is refused `not_present`, and
|
||||
// it enters again and signs again what the room had not carried. That is the design at
|
||||
// work, not a fault. So the service's only refusals are those, one for each time a page was
|
||||
// taken for gone, and each page entered again as often (at most three times), with no other
|
||||
// event of it refused and no page stopped. The service counts a refusal before the page
|
||||
// it answers can, so the pages are read either side of the service, again every 500 ms
|
||||
// for at most 15 s, until the two readings of the pages agree and the service's count is
|
||||
// theirs; the check takes the last reading either way.
|
||||
const counts = (all) => JSON.stringify(all.map(([, c]) => [c.gone, c.rejoins]));
|
||||
const goneOf = (all) => all.reduce((n, [, c]) => n + c.gone, 0);
|
||||
let gone;
|
||||
for (const settle = Date.now() + 15000; ;) {
|
||||
const before = await cws();
|
||||
const r = await fetch(`${srv.url}/room-api/watch/room`).then((x) => x.json());
|
||||
const after = await cws();
|
||||
gone = { pages: after, refused: r.refused || {} };
|
||||
if ((counts(before) === counts(after) && (gone.refused.not_present || 0) === goneOf(after)) || Date.now() > settle) break;
|
||||
await sleep(500);
|
||||
}
|
||||
const goneSum = goneOf(gone.pages);
|
||||
check(`${label}: the service carried every event the pages posted, but where it took a page for gone, which entered again and went on`,
|
||||
Object.keys(gone.refused).every((k) => k === "not_present") && (gone.refused.not_present || 0) === goneSum
|
||||
&& gone.pages.every(([, c]) => c.rejoins === c.gone && c.gone <= 3 && Object.keys(c.post_refused).length === 0 && !c.stopped),
|
||||
`the service refused ${JSON.stringify(gone.refused)}; ` + gone.pages.map(([n, c]) => `${n}: taken for gone ${c.gone}, entered again ${c.rejoins}, signed again ${c.resigned}, dropped ${c.dropped}${Object.keys(c.post_refused).length ? `, refused ${JSON.stringify(c.post_refused)}` : ""}${c.stopped ? `, stopped: ${c.stopped}` : ""}`).join("; "));
|
||||
const shown = await until(`${label}: each page shows the room in sync`, STEP, async () => {
|
||||
const all = await cws();
|
||||
return all.every(([, c]) => c.last_window && c.last_window.to >= target && c.dom && /in sync/.test(c.dom)) && all;
|
||||
|
|
@ -2110,7 +2157,7 @@ async function scenarioCowatch(run) {
|
|||
check(`${label}: each page shows its own offset and the room's verdicts`, shown.every(([, c]) => /^You: [+−]\d+ ms from the room · Room: all 3 in sync within 250 ms$/.test(c.dom)), shown.map(([n, c]) => `${n}: "${c.dom}"`).join("; "));
|
||||
const all = await cws();
|
||||
check(`${label}: no page refused a record of the log or had an event refused`, all.every(([, c]) => Object.keys(c.refused).length === 0 && Object.keys(c.post_refused).length === 0 && !c.stopped),
|
||||
all.map(([n, c]) => `${n}: ${c.statements} statements, ${c.posted} carried, ${c.nudges} nudged, ${c.seeks} seeks`).join("; "));
|
||||
all.map(([n, c]) => `${n}: ${c.statements} statements, ${c.posted} carried, ${c.nudges} nudged, ${c.seeks} seeks, taken for gone ${c.gone}, entered again ${c.rejoins}, signed again ${c.resigned}, dropped ${c.dropped}`).join("; "));
|
||||
// The room as the service carried it, verified from the extension's text alone.
|
||||
const roomFile = path.join(dir, "watch-room.json");
|
||||
fs.writeFileSync(roomFile, JSON.stringify(final));
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@
|
|||
|
||||
import initWai, * as wai from "../pkg/wai_live_web.js";
|
||||
import { E2ee } from "./e2ee.js";
|
||||
import { CoWatch, ACTION } from "./cowatch.js";
|
||||
import { CoWatch, ACTION, readStatement } from "./cowatch.js";
|
||||
import { RemoteViewer, keyFromSeed, readEvent } from "./remote.js";
|
||||
import { connect } from "../moq/transport.js";
|
||||
import { MoqSession, RequestRefused, withTimeout } from "../moq/session.js";
|
||||
|
|
@ -524,7 +524,14 @@ async function enter(role, given = null) {
|
|||
// has no other record on yet.
|
||||
if (v.watch) await startCoWatch(v, r.body, seed, principal);
|
||||
// A remote desktop room's page publishes nothing but its events: it enters as a viewer does.
|
||||
await begin(remoteRoom ? "viewer" : ROLE_OF[role], stream);
|
||||
try {
|
||||
await begin(remoteRoom ? "viewer" : ROLE_OF[role], stream);
|
||||
} catch (e) {
|
||||
// A watch room's page that has no session follows no log and applies no control: it stops
|
||||
// stating, rather than state a presentation it does not keep, and the room takes it for gone.
|
||||
if (cowatch) cowatch.stop(`the page did not enter the room: ${(e && e.message) || e}`);
|
||||
throw e;
|
||||
}
|
||||
pollRoom();
|
||||
if (cowatch) runCoWatch();
|
||||
if (e2ee) {
|
||||
|
|
@ -647,13 +654,17 @@ async function startCoWatch(v, granted, seed, principal) {
|
|||
await cowatch.estimateClock();
|
||||
await cowatch.loadSource();
|
||||
await cowatch.join();
|
||||
// A present viewer states what it presents at least once a slot (co-watch §7.3), and the room
|
||||
// takes one silent past its limit for gone: the statements start with the join, not once the
|
||||
// page has its relay session, which on a loaded machine can take longer than that limit.
|
||||
cowatch.run(() => state.phase !== "left").catch(fail);
|
||||
}
|
||||
|
||||
// The log is followed once the room's grants are read (`begin`): whether a control's author may
|
||||
// send it is read from them (§5.2).
|
||||
function runCoWatch() {
|
||||
const principal = me.principal;
|
||||
const alive = () => state.phase !== "left";
|
||||
cowatch.follow(alive).catch(fail);
|
||||
cowatch.run(alive).catch(fail);
|
||||
cowatch.follow(() => state.phase !== "left").catch(fail);
|
||||
for (const [id, a] of [["cw-play", ACTION.play], ["cw-pause", ACTION.pause], ["cw-back", ACTION.seek], ["cw-fwd", ACTION.seek], ["cw-rate", ACTION.rate]]) {
|
||||
if ($(id)) $(id).disabled = !mayControl(principal, a);
|
||||
}
|
||||
|
|
@ -2121,6 +2132,58 @@ window.roomTest.snapshot = () => ({
|
|||
// A shared control of a watch room (co-watch §5): `play`, `pause`, `seek` (to, µs) or `rate`
|
||||
// (millionths), at the policy's lead from now. Resolves with the instant it takes effect.
|
||||
window.roomTest.watchControl = (name, arg = 0) => watchControl(name, arg);
|
||||
// This page's thread held, as a page's can be on a loaded machine: nothing of the page runs, so
|
||||
// it states nothing, until the room's co-watch log carries the clock's `leave` for it, which the
|
||||
// room signs once a viewer is silent past its limit (or `ms` passed). The log is read with
|
||||
// blocking requests, which hold the thread without spinning. Then, in the same task and so
|
||||
// before the page can enter again, the control `then` (`[name, arg]`), if given, is signed.
|
||||
// Resolves with `{ began, held_ms, left, at }`: `began` the room clock when the hold began, on
|
||||
// this page's estimate, and `at` the control's instant as signed then (signed again after the
|
||||
// page enters again, it may move).
|
||||
window.roomTest.watchHoldUntilGone = (ms, then = null) => {
|
||||
if (!cowatch || cowatch.joinT == null) throw new Error("not in a watch room's co-watch log");
|
||||
const began = cowatch.now();
|
||||
const start = Date.now();
|
||||
let from = 0;
|
||||
let left = false;
|
||||
while (!left && Date.now() - start < ms) {
|
||||
const x = new XMLHttpRequest();
|
||||
x.open("GET", `${service}/watch?room=${cowatch.room}&from=${from}&wait_ms=500`, false);
|
||||
x.send();
|
||||
if (x.status !== 200) break;
|
||||
const page = JSON.parse(x.responseText);
|
||||
for (const e of page.entries) {
|
||||
const v = JSON.parse(wai.eventRead(unhex(e.event)));
|
||||
if (v.kind_name === "leave" && v.payload && v.payload.subject === me.principal && v.t > cowatch.joinT) left = true;
|
||||
}
|
||||
from = page.next;
|
||||
}
|
||||
const at = then ? watchControl(...then) : null;
|
||||
return { began, held_ms: Date.now() - start, left, at };
|
||||
};
|
||||
// The watch room's co-watch log as the room carries it, each record read and its signature
|
||||
// checked: its principal, `seq`, `t` and kind, a lifecycle event's subject, and a co-watch
|
||||
// statement's kind and fields (co-watch §4.2: a control's `action` and `at`, a presentation
|
||||
// statement's `at`). With the policy's lead and slot, and the room clock now on this page's
|
||||
// estimate.
|
||||
window.roomTest.watchLog = async () => {
|
||||
if (!cowatch) throw new Error("not a watch room");
|
||||
const entries = [];
|
||||
for (let from = 0; ;) {
|
||||
const r = await api(`/watch?room=${cowatch.room}&from=${from}&wait_ms=0`);
|
||||
if (r.status !== 200 || !r.body) throw new Error(`the room's log: ${r.status}`);
|
||||
for (const e of r.body.entries) {
|
||||
const bytes = unhex(e.event);
|
||||
const v = JSON.parse(wai.eventRead(bytes));
|
||||
if (!v.ok) throw new Error(`entry ${e.seq} of the room's log does not verify: ${v.reason}`);
|
||||
const s = v.kind === 1 ? readStatement(readEvent(bytes).payload) : null;
|
||||
entries.push({ entry: e.seq, principal: v.principal, seq: v.seq, t: v.t, kind: s ? s.kind : v.kind_name, subject: v.payload && v.payload.subject, action: s && s.action, at: s && s.at });
|
||||
}
|
||||
if (r.body.next >= r.body.end) break;
|
||||
from = r.body.next;
|
||||
}
|
||||
return { entries, lead_ms: cowatch.policy.lead_ms, period_ms: cowatch.policy.period_ms, now: cowatch.now() };
|
||||
};
|
||||
// A participant's grant presented as a bare token, with no proof: the relay must refuse
|
||||
// the session (live-rooms §10.5.2, UNAUTHORIZED). Resolves with how the session ended.
|
||||
window.roomTest.tryBareGrant = async () => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
// its estimate of the room clock, keeps a media element at the controlled position by the
|
||||
// catch-up rule, signs a presentation statement twice a slot under the page's key, and shows
|
||||
// the room's sync: its own error, and each viewer's verdict from the clock's last skew
|
||||
// statement.
|
||||
// statement. Taken for gone after a silence, it enters again and signs again, after its new
|
||||
// `join`, the controls the room had not carried.
|
||||
//
|
||||
// Everything here is the platform's: WebCrypto's SHA-256 and Ed25519, an HTMLMediaElement.
|
||||
|
||||
|
|
@ -273,6 +274,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|||
const STATES_PER_SLOT = 2;
|
||||
// Statements one post carries at most: a front may hold a request's body to a few KiB.
|
||||
const POST_EVENTS = 4;
|
||||
// Times a page taken for gone enters again with nothing of it carried since its last entry
|
||||
// (taken for gone again at once, as by a second stall) before it stops.
|
||||
const REENTRIES = 3;
|
||||
|
||||
export class CoWatch {
|
||||
// o: { api (path, body) -> {status, body}, wai, room, t0, seed (Uint8Array), principal (hex),
|
||||
|
|
@ -289,13 +293,19 @@ export class CoWatch {
|
|||
this.timeline = new Timeline(this.policyList, []);
|
||||
this.seq = 0;
|
||||
this.prev = "00".repeat(32);
|
||||
// Where the room's copy of this page's chain ends: the next seq, and the last digest it carried.
|
||||
this.carried = { seq: 0, prev: "00".repeat(32) };
|
||||
this.lastT = 0;
|
||||
this.joinT = null;
|
||||
this.outbox = [];
|
||||
this.outbox = []; // each { rec, seq, digest, control }: signed, not yet carried
|
||||
this.reentered = 0; // entries again since the room last carried an event of this page
|
||||
this.stopped = null;
|
||||
this.skews = [];
|
||||
this.others = {}; // principal -> its last statement: at, position, error
|
||||
this.stats = { log: 0, controls: 0, applied: 0, refused: {}, statements: 0, posted: 0, post_refused: {}, seeks: 0, nudges: 0, verified_source: false, errors: [] };
|
||||
// `gone`: posts refused `not_present`, the room having taken the page for gone; `rejoins`:
|
||||
// its entries again; `resigned`: controls signed again after one; `dropped`: statements
|
||||
// and controls signed before one and not signed again.
|
||||
this.stats = { log: 0, controls: 0, applied: 0, refused: {}, statements: 0, posted: 0, post_refused: {}, seeks: 0, nudges: 0, verified_source: false, gone: 0, rejoins: 0, resigned: 0, dropped: 0, followed_at: null, errors: [] };
|
||||
this.onChange = o.onChange || (() => {});
|
||||
}
|
||||
|
||||
|
|
@ -352,24 +362,97 @@ export class CoWatch {
|
|||
this.log(`co-watch: joined at room clock ${r.body.t}`);
|
||||
}
|
||||
|
||||
/** A `meta` event of this page's chain, signed (live-events §3), at `t`. */
|
||||
event(t, payload) {
|
||||
/** A `meta` event of this page's chain, signed (live-events §3), at `t`: the outbox's entry
|
||||
* for it, with the control it carries, if it is one. */
|
||||
event(t, payload, control = null) {
|
||||
t = Math.max(t, this.lastT, this.joinT || 0);
|
||||
const rec = this.wai.eventSign(this.seed, unhex(this.room), BigInt(this.seq), unhex(this.prev), BigInt(t), META, payload);
|
||||
const seq = this.seq;
|
||||
const rec = this.wai.eventSign(this.seed, unhex(this.room), BigInt(seq), unhex(this.prev), BigInt(t), META, payload);
|
||||
const v = JSON.parse(this.wai.eventRead(rec));
|
||||
this.seq++;
|
||||
this.prev = v.digest;
|
||||
this.lastT = t;
|
||||
return rec;
|
||||
return { rec, seq, digest: v.digest, control };
|
||||
}
|
||||
|
||||
/** When a control asked for `at` is signed now: its `t`, and its instant, `at` or, if the
|
||||
* policy's lead (§5.3) no longer allows that, the earliest instant it does. */
|
||||
controlTime(at) {
|
||||
const t = Math.max(this.now(), this.lastT, this.joinT || 0);
|
||||
return { t, at: Math.max(at, t + this.policy.lead_ms * 1000 + 50000) };
|
||||
}
|
||||
|
||||
/** A control signed now, at the instant `when` (`controlTime`) gives. */
|
||||
signControl(c, when = this.controlTime(c.at)) {
|
||||
const { t, at } = when;
|
||||
return this.event(t, controlPayload({ ...c, at }), { ...c, at });
|
||||
}
|
||||
|
||||
/** Ask the room to `action` (ACTION) with `arg`, at the policy's lead from now (§5.3). */
|
||||
control(action, arg = 0) {
|
||||
const t = this.now();
|
||||
const at = t + this.policy.lead_ms * 1000 + 50000;
|
||||
this.outbox.push(this.event(t, controlPayload({ action, at, arg })));
|
||||
const e = this.signControl({ action, at: 0, arg });
|
||||
this.outbox.push(e);
|
||||
this.flush();
|
||||
return at;
|
||||
return e.control.at;
|
||||
}
|
||||
|
||||
/** The outbox's first `n` events carried: the room's copy of the chain ends with them. */
|
||||
carry(n) {
|
||||
if (!n) return;
|
||||
const last = this.outbox[n - 1];
|
||||
this.carried = { seq: last.seq + 1, prev: last.digest };
|
||||
this.outbox.splice(0, n);
|
||||
this.stats.posted += n;
|
||||
}
|
||||
|
||||
/** After entering again. Every event the room has not carried was signed before its new
|
||||
* `join`, so the room would refuse it as reversed in time: the chain goes on from where the
|
||||
* room's copy ends, at or after the join. The room holds none of those events (it carries
|
||||
* no event it refuses), so signing their positions again leaves it one event at each
|
||||
* (co-watch §10). A control is signed again, at its instant if the policy's lead still
|
||||
* allows it, else at the earliest it does, unless that moves it to or past the instant of
|
||||
* another viewer's control the page holds at or after its own: controls apply in the order
|
||||
* of their instants (§5.4), and the earlier press would then override the later one, so it
|
||||
* is dropped, as a press the room never had. A presentation statement of an instant before
|
||||
* the page was present again is dropped, and the page states anew within half a slot. */
|
||||
resign() {
|
||||
const pending = this.outbox;
|
||||
this.outbox = [];
|
||||
this.seq = this.carried.seq;
|
||||
this.prev = this.carried.prev;
|
||||
let resigned = 0;
|
||||
for (const e of pending) {
|
||||
if (!e.control) continue;
|
||||
const c = e.control;
|
||||
const when = this.controlTime(c.at);
|
||||
const passed = this.controls.find((x) => x.author !== this.principal && x.at >= c.at && x.at <= when.at);
|
||||
if (passed) {
|
||||
this.log(`co-watch: a ${ACTION_NAME[c.action]} at ${c.at}, signed while the room had taken this page for gone, is dropped: moved to ${when.at}, it would apply after another viewer's ${ACTION_NAME[passed.action]} at ${passed.at}`);
|
||||
continue;
|
||||
}
|
||||
const again = this.signControl(c, when);
|
||||
this.outbox.push(again);
|
||||
if (again.control.at !== c.at) this.log(`co-watch: a ${ACTION_NAME[c.action]} moved from ${c.at} to ${again.control.at}, the earliest the lead allows`);
|
||||
resigned++;
|
||||
}
|
||||
this.stats.rejoins++;
|
||||
this.stats.resigned += resigned;
|
||||
this.stats.dropped += pending.length - resigned;
|
||||
this.log(`co-watch: entered again; ${resigned} control(s) signed again, ${pending.length - resigned} earlier event(s) dropped`);
|
||||
}
|
||||
|
||||
/** Enter again, the room having taken this page for gone, and sign again what it had not
|
||||
* carried (`resign`). Whether it entered: a join the service could not make now (the clock
|
||||
* could not sign it) leaves the outbox as it is, for the next post to try again. */
|
||||
async enterAgain() {
|
||||
try {
|
||||
await this.join();
|
||||
} catch (e) {
|
||||
this.log(`co-watch: entering again failed (${e.message}); the next post tries again`);
|
||||
return false;
|
||||
}
|
||||
this.resign();
|
||||
return true;
|
||||
}
|
||||
|
||||
async flush() {
|
||||
|
|
@ -378,23 +461,26 @@ export class CoWatch {
|
|||
try {
|
||||
while (this.outbox.length) {
|
||||
const batch = this.outbox.slice(0, POST_EVENTS);
|
||||
const r = await this.api("/watch/post", { room: this.room, events: batch.map(hex) });
|
||||
const r = await this.api("/watch/post", { room: this.room, events: batch.map((e) => hex(e.rec)) });
|
||||
if (r.status === 200) {
|
||||
this.outbox.splice(0, batch.length);
|
||||
this.stats.posted += batch.length;
|
||||
this.rejoined = false;
|
||||
} else if (r.status === 409 && r.body && r.body.error === "not_present" && !this.rejoined) {
|
||||
// The room took this page for gone (it was silent too long): it enters again, and
|
||||
// its chain goes on where it was.
|
||||
this.outbox.splice(0, r.body.carried || 0);
|
||||
this.rejoined = true;
|
||||
await this.join();
|
||||
this.carry(batch.length);
|
||||
this.reentered = 0;
|
||||
} else if (r.status === 409 && r.body && r.body.error === "not_present" && this.reentered < REENTRIES) {
|
||||
// The room took this page for gone (it was silent too long, as a page on a loaded
|
||||
// machine can be): it enters again, and its chain goes on from what the room carried.
|
||||
// Taken for gone again before anything of it was carried (a second stall, right
|
||||
// after its join), it enters again too, a few times at most.
|
||||
this.carry(r.body.carried || 0);
|
||||
this.stats.gone++;
|
||||
if (await this.enterAgain()) this.reentered++;
|
||||
else await sleep(500);
|
||||
} else if (r.status === 409) {
|
||||
// Those before it were carried; this one never will be, and the chain cannot go
|
||||
// on past it: the page stops stating rather than leave a gap it cannot fill.
|
||||
const why = r.body.error;
|
||||
this.stats.post_refused[why] = (this.stats.post_refused[why] || 0) + 1;
|
||||
this.outbox.splice(0, (r.body.carried || 0) + 1);
|
||||
this.carry(r.body.carried || 0);
|
||||
this.outbox.shift();
|
||||
this.stop(`the room refused an event: ${why}`);
|
||||
break;
|
||||
} else {
|
||||
|
|
@ -414,6 +500,7 @@ export class CoWatch {
|
|||
|
||||
/** Follow the room's log, each record checked before anything of it is applied. */
|
||||
async follow(alive) {
|
||||
this.stats.followed_at = this.now();
|
||||
while (alive() && !this.stopped) {
|
||||
let r;
|
||||
try { r = await this.api(`/watch?room=${this.room}&from=${this.at}`); } catch { await sleep(1000); continue; }
|
||||
|
|
@ -488,9 +575,18 @@ export class CoWatch {
|
|||
const state = this.media.readyState < 3 && !this.media.paused ? STATE.stalled : ended ? STATE.ended : this.media.paused ? STATE.paused : STATE.playing;
|
||||
const pos = Math.round(this.media.currentTime * 1e6);
|
||||
const body = { room: this.room, player: this.principal, source: this.policy.source, at: x, position: pos, state, rate: Math.max(1, Math.round(this.media.playbackRate * 1e6)) };
|
||||
this.outbox.push(this.event(x, await signPresentation(body, this.key.privateKey)));
|
||||
this.stats.statements++;
|
||||
this.flush();
|
||||
const entries = this.stats.rejoins;
|
||||
const signed = await signPresentation(body, this.key.privateKey);
|
||||
if (this.stats.rejoins !== entries) {
|
||||
// The page entered again while it signed (`flush`): `x` is an instant from before its
|
||||
// present join, which it states nothing of. It states anew at once.
|
||||
this.stats.dropped++;
|
||||
next = 0;
|
||||
} else {
|
||||
this.outbox.push(this.event(x, signed));
|
||||
this.stats.statements++;
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
this.onChange();
|
||||
await sleep(50);
|
||||
|
|
@ -502,7 +598,7 @@ export class CoWatch {
|
|||
const last = this.skews.length ? this.skews[this.skews.length - 1] : null;
|
||||
const lastWindow = last ? this.skews.filter((s) => s.from === last.from) : [];
|
||||
return {
|
||||
joined: this.joinT, at: this.at, offset_us: this.offset, room_now: this.now(), controls: this.controls.length,
|
||||
principal: this.principal, joined: this.joinT, at: this.at, offset_us: this.offset, room_now: this.now(), controls: this.controls.length, outbox: this.outbox.length,
|
||||
overridden: this.controls.filter((c) => this.timeline.overridden.has(c.digest)).length,
|
||||
controlled: this.controlled ?? null, error_us: this.error ?? null,
|
||||
playing: !this.media.paused, rate: this.media.playbackRate, current_time: this.media.currentTime,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
// wai/live-conformance/cowatch/ (`node --test web/room/cowatch.test.mjs`): every catch-up case
|
||||
// its correction (co-watch §9); each accepted room's controlled position recomputed from its
|
||||
// policies and controls (§5.4, §6), as the corpus states it at every change and at every
|
||||
// statement; its controls written byte for byte (§5.1); and its presentation statements
|
||||
// written and signed byte for byte (§7.1), each at the rate the catch-up rule gives (§9).
|
||||
// statement; its controls written byte for byte (§5.1); its presentation statements
|
||||
// written and signed byte for byte (§7.1), each at the rate the catch-up rule gives (§9); and
|
||||
// the player taken for gone by the room re-entering with its chain whole (§7.3, §10).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import * as cw from "./cowatch.js";
|
||||
|
||||
|
|
@ -117,3 +119,212 @@ test("a control the room would refuse is not applied", () => {
|
|||
test("the clock's offset is taken from the least round trip", () => {
|
||||
assert.equal(cw.clockOffset([{ sent: 1000, got: 1100, server: 5_000_000 }, { sent: 2000, got: 2010, server: 7_000_000 }]), 7_000_000 - 2_005_000);
|
||||
});
|
||||
|
||||
// A record of the tests below: its fields as JSON, in place of a signed WEV1 record, and its
|
||||
// digest the SHA-256 of those bytes.
|
||||
const fakeWai = {
|
||||
eventSign: (seed, room, seq, prev, t, kind, payload) => new TextEncoder().encode(JSON.stringify({ seq: Number(seq), prev: cw.hex(prev), t: Number(t), payload: cw.hex(payload) })),
|
||||
eventRead: (rec) => JSON.stringify({ ok: true, digest: createHash("sha256").update(rec).digest("hex") }),
|
||||
};
|
||||
|
||||
// A watch room's service as joule-moq-room's watch.rs carries one viewer's events (co-watch
|
||||
// §10): while the viewer is present, each event the next of its chain, at or after its `join`
|
||||
// and its last event, and a control at least the lead ahead of its `t`; the first that is not
|
||||
// is refused with the service's reason, those before it carried. Its clock signs a `join`
|
||||
// `late` µs after the page's estimate of now. `failJoins`: the next joins its clock cannot
|
||||
// sign (503). `leaveAfterJoin`: the next joins whose presence a `leave` the room had asked
|
||||
// before them, signed after them, ends at once (a service that ordered them so).
|
||||
function watchService(lead_ms, late) {
|
||||
const s = { now: 0, present: false, joined: 0, lastT: 0, joins: 0, chain: [], controls: [], failJoins: 0, leaveAfterJoin: 0 };
|
||||
s.api = async (path, body) => {
|
||||
if (path === "/watch/challenge") return { status: 200, body: { challenge: "00".repeat(32) } };
|
||||
if (path === "/watch/join") {
|
||||
if (s.failJoins > 0) {
|
||||
s.failJoins--;
|
||||
return { status: 503, body: { error: "the clock has no session" } };
|
||||
}
|
||||
s.joined = s.now + late;
|
||||
s.lastT = Math.max(s.lastT, s.joined);
|
||||
s.present = true;
|
||||
s.joins++;
|
||||
if (s.leaveAfterJoin > 0) {
|
||||
s.leaveAfterJoin--;
|
||||
s.present = false;
|
||||
}
|
||||
return { status: 200, body: { ok: true, t: s.joined } };
|
||||
}
|
||||
assert.equal(path, "/watch/post");
|
||||
for (const [k, h] of body.events.entries()) {
|
||||
const ev = JSON.parse(new TextDecoder().decode(cw.unhex(h)));
|
||||
const st = cw.readStatement(cw.unhex(ev.payload));
|
||||
const why = !s.present ? "not_present"
|
||||
: ev.seq !== s.chain.length ? "delivery_gap"
|
||||
: ev.prev !== (s.chain.length ? s.chain[s.chain.length - 1] : "00".repeat(32)) ? "chain_broken"
|
||||
: ev.t < s.lastT || ev.t < s.joined ? "time_reversed"
|
||||
: st && st.kind === "control" && st.at < ev.t + lead_ms * 1000 ? "control_not_ahead"
|
||||
: null;
|
||||
if (why) return { status: 409, body: { error: why, carried: k } };
|
||||
s.chain.push(createHash("sha256").update(cw.unhex(h)).digest("hex"));
|
||||
s.lastT = ev.t;
|
||||
if (st && st.kind === "control") s.controls.push({ action: st.action, at: st.at, t: ev.t });
|
||||
}
|
||||
return { status: 200, body: { ok: true, carried: body.events.length } };
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Until the page has posted all it holds, or stopped. */
|
||||
async function settled(c) {
|
||||
const end = Date.now() + 5000;
|
||||
while ((c.posting || c.outbox.length) && !c.stopped && Date.now() < end) await new Promise((r) => setTimeout(r, 1));
|
||||
}
|
||||
|
||||
test("a page the room took for gone enters again, and its control signed before then is carried", async () => {
|
||||
const policy = vector("watch_party").policies[0];
|
||||
const lead = cw.readPolicy(cw.unhex(policy)).lead_ms;
|
||||
const svc = watchService(lead, 200_000);
|
||||
const seed = new Uint8Array(32).fill(7);
|
||||
const key = await cw.keyFromSeed(seed);
|
||||
const logs = [];
|
||||
const c = new cw.CoWatch({
|
||||
api: svc.api, wai: fakeWai, room: "11".repeat(32), t0: 0, seed, principal: cw.hex(key.publicKey), grant: "33".repeat(100), media: {},
|
||||
policies: [policy], objects: [], sourceUrl: "", mayControl: () => true, target: "//127.0.0.1/watch/join", log: (l) => logs.push(l),
|
||||
});
|
||||
c.now = () => svc.now;
|
||||
svc.now = 1_000_000;
|
||||
await c.join();
|
||||
svc.now = 1_500_000;
|
||||
const playAt = c.control(cw.ACTION.play);
|
||||
await settled(c);
|
||||
assert.deepEqual(svc.controls, [{ action: cw.ACTION.play, at: playAt, t: 1_500_000 }]);
|
||||
|
||||
// The page falls silent past the room's limit (its thread held, on a loaded machine): the
|
||||
// room takes it for gone. Then it states what it presents, and a pause is pressed, both
|
||||
// signed before the room's next `join` for it, which its clock signs 200 ms later.
|
||||
svc.present = false;
|
||||
svc.now = 7_000_000;
|
||||
const source = cw.readPolicy(cw.unhex(policy)).source;
|
||||
const body = { room: c.room, player: c.principal, source, at: svc.now, position: 5_500_000, state: cw.STATE.playing, rate: cw.NORMAL };
|
||||
c.outbox.push(c.event(svc.now, await cw.signPresentation(body, key.privateKey)));
|
||||
const pauseAt = c.control(cw.ACTION.pause);
|
||||
await settled(c);
|
||||
|
||||
assert.equal(c.stopped, null, logs.join("\n"));
|
||||
assert.deepEqual(c.stats.post_refused, {});
|
||||
assert.equal(svc.joins, 2);
|
||||
assert.deepEqual([c.stats.rejoins, c.stats.resigned, c.stats.dropped], [1, 1, 1]);
|
||||
// The room's copy of the chain is whole: the play, then the pause signed again after the
|
||||
// join, at the earliest instant its lead allows from there; the statement of an instant
|
||||
// before the page was present again is not carried.
|
||||
assert.equal(svc.chain.length, 2);
|
||||
const [, pause] = svc.controls;
|
||||
assert.equal(pause.action, cw.ACTION.pause);
|
||||
assert.ok(pause.t >= svc.joined, `the pause's t ${pause.t}, the join's ${svc.joined}`);
|
||||
assert.equal(pause.at, svc.joined + lead * 1000 + 50_000);
|
||||
assert.ok(pause.at > pauseAt);
|
||||
assert.deepEqual([c.seq, c.prev], [2, svc.chain[1]]);
|
||||
|
||||
// And it goes on: the next event follows the re-signed one.
|
||||
svc.now = 8_000_000;
|
||||
c.control(cw.ACTION.play);
|
||||
await settled(c);
|
||||
assert.equal(c.stopped, null);
|
||||
assert.deepEqual([svc.chain.length, svc.controls.length], [3, 3]);
|
||||
});
|
||||
|
||||
/** A page in a watch room kept by `watchService`, entered at room clock 1 s, its play carried
|
||||
* at 1.5 s. */
|
||||
async function entered() {
|
||||
const policy = vector("watch_party").policies[0];
|
||||
const p = cw.readPolicy(cw.unhex(policy));
|
||||
const svc = watchService(p.lead_ms, 200_000);
|
||||
const seed = new Uint8Array(32).fill(7);
|
||||
const key = await cw.keyFromSeed(seed);
|
||||
const logs = [];
|
||||
const c = new cw.CoWatch({
|
||||
api: svc.api, wai: fakeWai, room: "11".repeat(32), t0: 0, seed, principal: cw.hex(key.publicKey), grant: "33".repeat(100), media: {},
|
||||
policies: [policy], objects: [], sourceUrl: "", mayControl: () => true, target: "//127.0.0.1/watch/join", log: (l) => logs.push(l),
|
||||
});
|
||||
c.now = () => svc.now;
|
||||
svc.now = 1_000_000;
|
||||
await c.join();
|
||||
svc.now = 1_500_000;
|
||||
c.control(cw.ACTION.play);
|
||||
await settled(c);
|
||||
assert.equal(svc.controls.length, 1);
|
||||
return { svc, c, logs, lead: p.lead_ms };
|
||||
}
|
||||
|
||||
test("a page taken for gone again right after it entered again enters once more, its control carried", async () => {
|
||||
const { svc, c, logs, lead } = await entered();
|
||||
// Gone; and the `leave` the room asked for it is signed only after its next join, which it
|
||||
// ends (a service that signed them in that order).
|
||||
svc.present = false;
|
||||
svc.leaveAfterJoin = 1;
|
||||
svc.now = 7_000_000;
|
||||
c.control(cw.ACTION.pause);
|
||||
await settled(c);
|
||||
assert.equal(c.stopped, null, logs.join("\n"));
|
||||
assert.deepEqual(c.stats.post_refused, {});
|
||||
assert.equal(svc.joins, 3);
|
||||
assert.deepEqual([c.stats.gone, c.stats.rejoins, c.stats.resigned], [2, 2, 2]);
|
||||
assert.deepEqual(svc.controls.map((x) => x.action), [cw.ACTION.play, cw.ACTION.pause]);
|
||||
assert.ok(svc.controls[1].t >= svc.joined);
|
||||
assert.equal(svc.controls[1].at, svc.joined + lead * 1000 + 50_000);
|
||||
});
|
||||
|
||||
test("a join the service cannot make is tried again by the next post, and the page goes on", async () => {
|
||||
const { svc, c, logs } = await entered();
|
||||
svc.present = false;
|
||||
svc.failJoins = 1;
|
||||
svc.now = 7_000_000;
|
||||
c.control(cw.ACTION.pause);
|
||||
await settled(c);
|
||||
assert.equal(c.stopped, null, logs.join("\n"));
|
||||
assert.deepEqual(c.stats.post_refused, {});
|
||||
assert.equal(svc.joins, 2);
|
||||
assert.deepEqual([c.stats.gone, c.stats.rejoins, c.stats.resigned], [2, 1, 1]);
|
||||
assert.deepEqual(svc.controls.map((x) => x.action), [cw.ACTION.play, cw.ACTION.pause]);
|
||||
assert.ok(logs.some((l) => /entering again failed/.test(l)), logs.join("\n"));
|
||||
});
|
||||
|
||||
test("a page the room takes for gone at every entry stops after a few", async () => {
|
||||
const { svc, c } = await entered();
|
||||
svc.present = false;
|
||||
svc.leaveAfterJoin = Infinity;
|
||||
svc.now = 7_000_000;
|
||||
c.control(cw.ACTION.pause);
|
||||
await settled(c);
|
||||
assert.match(c.stopped || "", /not_present/);
|
||||
assert.deepEqual(c.stats.post_refused, { not_present: 1 });
|
||||
assert.deepEqual([c.stats.gone, c.stats.rejoins, svc.joins], [3, 3, 4]);
|
||||
assert.equal(svc.controls.length, 1);
|
||||
});
|
||||
|
||||
test("a control pressed while the room had taken the page for gone is dropped where signing it again would put it past another viewer's", async () => {
|
||||
const { svc, c, logs, lead } = await entered();
|
||||
const other = (at, action) => c.controls.push({ at, t: svc.now, digest: cw.hex(createHash("sha256").update(String(at)).digest()), action, arg: 0, author: "22".repeat(32) });
|
||||
// Gone; another viewer's pause is carried for 100 ms after this page's seek as pressed.
|
||||
// Signed again after the join, 200 ms later, the seek would apply after that pause: dropped.
|
||||
svc.present = false;
|
||||
svc.now = 7_000_000;
|
||||
other(svc.now + lead * 1000 + 50_000 + 100_000, cw.ACTION.pause);
|
||||
c.control(cw.ACTION.seek, 1_000_000);
|
||||
await settled(c);
|
||||
assert.equal(c.stopped, null, logs.join("\n"));
|
||||
assert.deepEqual([c.stats.rejoins, c.stats.resigned, c.stats.dropped], [1, 0, 1]);
|
||||
assert.equal(svc.controls.length, 1, "the seek is not carried");
|
||||
assert.ok(logs.some((l) => /is dropped: moved to/.test(l)), logs.join("\n"));
|
||||
// Gone again; other viewers' controls before its instant as pressed, and past where it
|
||||
// moves to: signed again, it keeps its place among them.
|
||||
svc.present = false;
|
||||
svc.now = 9_000_000;
|
||||
other(svc.now + 100_000, cw.ACTION.pause);
|
||||
other(svc.now + 5_000_000, cw.ACTION.play);
|
||||
const seekAt = c.control(cw.ACTION.seek, 2_000_000);
|
||||
await settled(c);
|
||||
assert.equal(c.stopped, null, logs.join("\n"));
|
||||
assert.deepEqual([c.stats.rejoins, c.stats.resigned], [2, 1]);
|
||||
assert.deepEqual(svc.controls.map((x) => x.action), [cw.ACTION.play, cw.ACTION.seek]);
|
||||
assert.ok(svc.controls[1].at > seekAt && svc.controls[1].at === svc.joined + lead * 1000 + 50_000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -129,6 +129,22 @@ Measured on an M5 Max (2026-10-04):
|
|||
on its next change, or as it stands once no client accrues GPU time. On this
|
||||
host 18% of 5 s windows (24% of 10 s) saw a GPU client vanish.
|
||||
|
||||
`tests/process_gpu.rs` lists the registry with `ioreg` before, around and after
|
||||
each span of GPU work it checks against the oracle or through the span meter,
|
||||
independently of the counter, and confirms each refusal there: the client the
|
||||
counter saw vanish is one the registry listed under the same pid (gone), or
|
||||
whose fall it shows inside the span (gone back), and the refusal names the
|
||||
client the meter's rule picks. A reading of this process's own GPU time that the
|
||||
registry contradicts fails the test at once. A span whose vanished client the
|
||||
registry never listed, or whose readings of another process's clients it cannot
|
||||
explain, is taken again later; none it can vouch for within 90 s fails the test
|
||||
as inconclusive. Agreement with the oracle is checked over the clients present at
|
||||
both ends, with any vanished client taken out of both snapshots. The rail is
|
||||
checked against the test's own read on each span checked against the oracle (the
|
||||
1.5 s burn and the contended burn) whose rail was current at both ends, a shared
|
||||
span included. On a shared host, the 1.5 s burn gives a figure or `GpuShared`,
|
||||
depending on what else runs on the GPU.
|
||||
|
||||
## Not yet a hardware reading
|
||||
|
||||
- **Apple Silicon host-wide CPU energy on macOS 27.0.1**, for an unprivileged
|
||||
|
|
|
|||
|
|
@ -391,7 +391,10 @@ pub enum EnergyError {
|
|||
/// If it was this process's, its own GPU time over the span is unknown; if
|
||||
/// another's, every client's is, and so whether this process had the GPU
|
||||
/// to itself: no figure. (A process that spent no GPU time has an exact
|
||||
/// zero, whatever other clients did.)
|
||||
/// zero, whatever other clients did.) When several vanished, it names
|
||||
/// this process's own first, else the oldest: the least I/O Registry
|
||||
/// entry ID, which the kernel gives entries in the order it registers
|
||||
/// them, wherever the client sits in the snapshot.
|
||||
#[error("GPU client {client:#x} (pid {pid}) was gone or reset at the span's end: every client's GPU time over it is unknown")]
|
||||
GpuClientVanished {
|
||||
/// The process that held the client.
|
||||
|
|
|
|||
|
|
@ -81,7 +81,8 @@ pub const SOLE_USER_PERCENT: u64 = 99;
|
|||
/// One GPU client in a [`GpuSnapshot`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct GpuClient {
|
||||
/// The client's I/O Registry entry ID: never reused while the host is up.
|
||||
/// The client's I/O Registry entry ID: never reused while the host is up,
|
||||
/// and given in the order the kernel makes entries, so a lower one is older.
|
||||
pub id: u64,
|
||||
/// The process that opened it.
|
||||
pub pid: i32,
|
||||
|
|
@ -168,11 +169,15 @@ impl GpuShare {
|
|||
/// Each client's GPU time over the span, by registry ID: a client present at
|
||||
/// both ends its advance, one new at the end its whole time. A client present
|
||||
/// at the start and gone at the end, or whose time went back, is
|
||||
/// [`EnergyError::GpuClientVanished`]. Only the clients `pick` selects are
|
||||
/// summed or checked.
|
||||
/// [`EnergyError::GpuClientVanished`], the oldest of them if several: the
|
||||
/// start's clients are checked in the order of their registry IDs, which the
|
||||
/// kernel gives entries in the order it makes them. Only the clients
|
||||
/// `pick` selects are summed or checked.
|
||||
fn spent(start: &GpuSnapshot, end: &GpuSnapshot, pick: impl Fn(&GpuClient) -> bool) -> Result<u64, EnergyError> {
|
||||
let mut ns: u64 = 0;
|
||||
for c in start.clients.iter().filter(|c| pick(c)) {
|
||||
let mut oldest_first: Vec<&GpuClient> = start.clients.iter().filter(|c| pick(c)).collect();
|
||||
oldest_first.sort_unstable_by_key(|c| c.id);
|
||||
for c in oldest_first {
|
||||
let delta = end
|
||||
.clients
|
||||
.iter()
|
||||
|
|
@ -584,6 +589,29 @@ mod tests {
|
|||
assert!(matches!(apportion(&mine_back, &mine_back_end, ME), Err(EnergyError::GpuClientVanished { pid: ME, client: 1 })));
|
||||
}
|
||||
|
||||
/// Several clients vanished: any is a true witness, and the refusal names
|
||||
/// this process's own first, wherever it sits; else the oldest of the
|
||||
/// others, the least registry ID, gone or gone back alike, in whatever
|
||||
/// order the snapshot lists them. Deterministic for given snapshots,
|
||||
/// whatever else the host did.
|
||||
#[test]
|
||||
fn a_refusal_names_this_processs_own_vanished_client_first_else_the_oldest() {
|
||||
let start = snap(0, &[client(10, 77, 5), client(11, 78, 5), client(20, 500, 5), client(30, ME, 0)]);
|
||||
let end = snap(5_000_000, &[client(11, 78, 9), client(30, ME, 1_000_000)]);
|
||||
assert!(matches!(apportion(&start, &end, ME), Err(EnergyError::GpuClientVanished { pid: 77, client: 10 })));
|
||||
let reversed = GpuSnapshot { clients: start.clients.iter().rev().copied().collect(), ..start.clone() };
|
||||
assert!(matches!(apportion(&reversed, &end, ME), Err(EnergyError::GpuClientVanished { pid: 77, client: 10 })));
|
||||
let shuffled = GpuSnapshot { clients: vec![client(20, 500, 5), client(30, ME, 0), client(10, 77, 5), client(11, 78, 5)], ..start.clone() };
|
||||
assert!(matches!(apportion(&shuffled, &end, ME), Err(EnergyError::GpuClientVanished { pid: 77, client: 10 })));
|
||||
let first_back = snap(5_000_000, &[client(10, 77, 4), client(11, 78, 9), client(30, ME, 1_000_000)]);
|
||||
assert!(matches!(apportion(&start, &first_back, ME), Err(EnergyError::GpuClientVanished { pid: 77, client: 10 })));
|
||||
let mine_last = snap(0, &[client(10, 77, 5), client(20, 500, 5), client(30, ME, 0), client(31, ME, 0)]);
|
||||
let mine_gone = snap(5_000_000, &[client(30, ME, 1_000_000)]);
|
||||
assert!(matches!(apportion(&mine_last, &mine_gone, ME), Err(EnergyError::GpuClientVanished { pid: ME, client: 31 })));
|
||||
let mine_two = snap(0, &[client(32, ME, 0), client(10, 77, 5), client(31, ME, 0), client(30, ME, 0)]);
|
||||
assert!(matches!(apportion(&mine_two, &mine_gone, ME), Err(EnergyError::GpuClientVanished { pid: ME, client: 31 })));
|
||||
}
|
||||
|
||||
/// No GPU time of this process's is an exact zero, a hardware reading:
|
||||
/// under contention, beside a vanished client, a rail that went back,
|
||||
/// held, or is not current. None of those can change a zero.
|
||||
|
|
|
|||
|
|
@ -232,6 +232,19 @@ pub(crate) fn combine(
|
|||
}
|
||||
}
|
||||
|
||||
/// A span's GPU figure from its two snapshots, by `share`, and the snapshots
|
||||
/// when both were taken; else the start's failure, or the end's.
|
||||
fn gpu_figure(
|
||||
start: Result<GpuSnapshot, EnergyError>,
|
||||
end: Result<GpuSnapshot, EnergyError>,
|
||||
share: impl FnOnce(&GpuSnapshot, &GpuSnapshot) -> Result<GpuShare, EnergyError>,
|
||||
) -> (Result<GpuShare, EnergyError>, Option<(GpuSnapshot, GpuSnapshot)>) {
|
||||
match (start, end) {
|
||||
(Ok(s), Ok(e)) => (share(&s, &e), Some((s, e))),
|
||||
(Err(x), _) | (Ok(_), Err(x)) => (Err(x), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Meters spans of in-process work on this process's CPU and GPU together,
|
||||
/// falling back to the host meter where they give no figure. See the module
|
||||
/// docs.
|
||||
|
|
@ -296,10 +309,22 @@ impl ProcessSpanMeter {
|
|||
|
||||
/// End the span begun at `start`: its energy, or why there is none.
|
||||
pub fn finish(&self, start: SpanStart, uses: SpanUses) -> Result<SpanEnergy, EnergyError> {
|
||||
self.finish_showing_gpu(start, uses).0
|
||||
}
|
||||
|
||||
/// [`Self::finish`], and the span's two GPU snapshots when both were
|
||||
/// taken: for the hardware tests, which check what the GPU counter made of
|
||||
/// them against a registry read of their own; not part of the metering API.
|
||||
#[doc(hidden)]
|
||||
pub fn finish_showing_gpu(
|
||||
&self,
|
||||
start: SpanStart,
|
||||
uses: SpanUses,
|
||||
) -> (Result<SpanEnergy, EnergyError>, Option<(GpuSnapshot, GpuSnapshot)>) {
|
||||
let end = read_end(self);
|
||||
let cpu = self.cpu_window(start.cpu_nj, end.cpu_nj);
|
||||
let gpu = start.gpu.and_then(|s| end.gpu.and_then(|e| self.gpu.share(&s, &e)));
|
||||
combine(cpu, gpu, uses, self.fallback.active_name(), (start.fallback, end.fallback))
|
||||
let (gpu, snapshots) = gpu_figure(start.gpu, end.gpu, |s, e| self.gpu.share(s, e));
|
||||
(combine(cpu, gpu, uses, self.fallback.active_name(), (start.fallback, end.fallback)), snapshots)
|
||||
}
|
||||
|
||||
/// Run `work`, which ran on what `uses` says, and meter it.
|
||||
|
|
@ -491,6 +516,36 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn gpu_snap(rail_nj: u64, gpu_ns: u64) -> GpuSnapshot {
|
||||
GpuSnapshot {
|
||||
rails_nj: vec![("GPU Energy".to_string(), rail_nj)],
|
||||
clients: vec![crate::process_gpu::GpuClient { id: 1, pid: 7, gpu_ns }],
|
||||
rail_current: true,
|
||||
at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A span's GPU figure is the share over its own two snapshots, and those
|
||||
/// two come back with it; a snapshot that failed is the figure's error,
|
||||
/// the start's before the end's, and then nothing is shared or handed back.
|
||||
#[test]
|
||||
fn a_spans_gpu_figure_comes_with_the_snapshots_it_was_shared_over() {
|
||||
let (s, e) = (gpu_snap(10, 100), gpu_snap(90, 400));
|
||||
let advance = |a: &GpuSnapshot, b: &GpuSnapshot| {
|
||||
Ok(GpuShare { uj: b.rails_nj[0].1 - a.rails_nj[0].1, self_ns: b.clients[0].gpu_ns - a.clients[0].gpu_ns, rail_uj: None, all_ns: None })
|
||||
};
|
||||
let (g, shown) = gpu_figure(Ok(s.clone()), Ok(e.clone()), advance);
|
||||
assert_eq!(g.ok(), Some(GpuShare { uj: 80, self_ns: 300, rail_uj: None, all_ns: None }));
|
||||
assert_eq!(shown, Some((s.clone(), e.clone())));
|
||||
let never = |_: &GpuSnapshot, _: &GpuSnapshot| -> Result<GpuShare, EnergyError> { panic!("shared over a failed snapshot") };
|
||||
let (g, shown) = gpu_figure(Err(EnergyError::NotCurrent), Err(EnergyError::NotAdvanced), never);
|
||||
assert!(matches!(g, Err(EnergyError::NotCurrent)) && shown.is_none(), "{g:?}");
|
||||
let (g, shown) = gpu_figure(Ok(s), Err(EnergyError::NotAdvanced), never);
|
||||
assert!(matches!(g, Err(EnergyError::NotAdvanced)) && shown.is_none(), "{g:?}");
|
||||
let (g, shown) = gpu_figure(Err(EnergyError::CounterWrap), Ok(e), never);
|
||||
assert!(matches!(g, Err(EnergyError::CounterWrap)) && shown.is_none(), "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_weaker_grade() {
|
||||
use Provenance::*;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,36 @@ wire format may change and such changes are recorded here.
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Contribution links: a packet taken after its sequence number's drop point is late, whatever the timer did
|
||||
|
||||
- **[live-contribution](extensions/live-contribution.md) §6.1:** a packet *arrives* when the receiver takes it. An
|
||||
SRT packet that arrives at or after its own delivery time, before the first packet after it that has arrived is
|
||||
due, has not been dropped (§4.1): it is delivered at once and is **recovered**, with a margin of 0. One that
|
||||
arrives at or after that point is **late**, whether or not the receiver had dropped its sequence number before
|
||||
taking it. **§6.2:** `margin_min` counts such a packet's margin as 0. **§6.3:** an SRT recovery is not bounded by
|
||||
`window`. **§5.3 and §6.8:** a recovered packet is on time only with a margin above 0; the record does not count
|
||||
the SRT packets recovered after their delivery time, nor say when a packet delivered at the first try was taken.
|
||||
The verifier's rules and the corpus are unchanged.
|
||||
- The reference SRT and RIST receivers resolve what has fallen due before they take each packet. Before, a
|
||||
retransmission taken after its drop point with no timer tick between was delivered as recovered with a margin of
|
||||
0, and with a tick between it was dropped and counted late. Unit tests now drive each receiver on a clock of their
|
||||
own through every case.
|
||||
- The reference SRT receiver's timer runs an ACK period (10 ms) after it last ran. It was counted from the last
|
||||
datagram, so while datagrams came closer together than that it did not run until the first packet held fell due:
|
||||
in a link's first window it sent no full ACK and no periodic NAK, and a packet whose first NAK or retransmission was
|
||||
lost went unrequested until that window was nearly over.
|
||||
- A recovered packet still held when a link ends keeps its margin, the time it arrived before its delivery time; it
|
||||
was recorded as 0, which read as a recovery after the delivery time.
|
||||
- `joule-contrib`'s SRT link tests over a lossy path use a window of a second, and no longer bound the recovery time,
|
||||
which counts from when a packet was found missing and is not a property of the protocol (§6.3); the receivers' unit
|
||||
tests now find each gap away from the clock's origin, so a recovery counted from anywhere else fails them. The tests
|
||||
still require a margin above 0, the end-to-end sign that every packet recovered was taken before its own delivery
|
||||
time. Their failure in CI (nothing dropped, every payload whole and in order, the recovery or the margin out of
|
||||
bounds) fits the ordering defect above, a busy runner's lag (the receiving task hundreds of milliseconds behind its
|
||||
socket), or both, with the starved timer lengthening the first window's recoveries; the part was not printed, so
|
||||
which cannot be told. The assertions now print it. The caller that receives is held to the same account, and to its
|
||||
latency window.
|
||||
|
||||
### The contribution link record has its own magic, `WLK1`
|
||||
|
||||
- **Wire change.** The contribution link record ([extensions/live-contribution.md](extensions/live-contribution.md)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,16 @@
|
|||
// the other pauses: both must apply each control at its instant, within a quarter second of
|
||||
// the room, and stop at one position.
|
||||
//
|
||||
// Then Ben's page is held, as a page on a loaded machine can be: it states nothing until the
|
||||
// room takes it for gone (a viewer silent past the room's limit has left). Ada seeks while it
|
||||
// is held, and Ben's play is signed as the hold ends, before his page can enter again. His page
|
||||
// must enter again, his play be signed again after his new `join` and carried, and both pages
|
||||
// play from Ada's seek at the instant the room carried, within the bound, until Ada pauses
|
||||
// them at one position again. At the end the room's log shows what happened, and each page's
|
||||
// own counts agree with it: one `leave`, Ben's, in the hold; each page stating from its join,
|
||||
// before it had its relay session, and in every slot while it was present (co-watch §7.3);
|
||||
// Ada never taken for gone, Ben once; and no page stopped.
|
||||
//
|
||||
// node wai/examples/watch-together/test/watch-together.test.mjs
|
||||
import { check, create, dev, dump, finish, frame, launch, open, sleep, STEP, tmpdir, until } from "../../../quickstart/test/lib.mjs";
|
||||
|
||||
|
|
@ -12,6 +22,7 @@ const d = await dev(dir);
|
|||
const browser = await launch();
|
||||
const pages = [];
|
||||
const BOUND_US = 250000;
|
||||
const PLAY = 1;
|
||||
try {
|
||||
const enter = async (name) => {
|
||||
const p = await open(browser, `${d.url}/`, name);
|
||||
|
|
@ -24,24 +35,99 @@ try {
|
|||
if (x.errors.length) throw new Error(x.errors[0]);
|
||||
return x.cowatch && x.cowatch.joined != null && x;
|
||||
}, 200);
|
||||
return { p, f, s };
|
||||
return { name, p, f, s, principal: s.cowatch.principal };
|
||||
};
|
||||
const A = await enter("ada");
|
||||
const B = await enter("ben");
|
||||
check("each page fetched the room's source and checked it by the content hash its policy names", A.s.cowatch.verified_source && B.s.cowatch.verified_source);
|
||||
const cws = async () => Promise.all([A, B].map(async (x) => (await x.f.evaluate(() => window.roomTest.snapshot())).cowatch));
|
||||
const together = (what, n, playing, after) => until(what, STEP, async () => {
|
||||
const all = await cws();
|
||||
return all.every((c) => c.controls >= n && c.room_now >= after && c.playing === playing && c.error_us != null && Math.abs(c.error_us) <= BOUND_US && !c.stopped) && all;
|
||||
}, 250);
|
||||
const brief = (all) => all.map((c, i) => `${[A, B][i].name} ${JSON.stringify({ controls: c.controls, room_now: c.room_now, playing: c.playing, error_us: c.error_us, stopped: c.stopped, gone: c.gone, rejoins: c.rejoins, post_refused: c.post_refused })}`).join("; ");
|
||||
// Both pages hold `n` controls, are past the last one's instant, playing or not as it says,
|
||||
// and within the bound of the room. A page that stopped applies no control again: the wait
|
||||
// ends there.
|
||||
const together = async (what, n, playing, after) => {
|
||||
const r = await until(what, STEP, async () => {
|
||||
const all = await cws();
|
||||
if (all.some((c) => c.stopped)) return { stopped: all };
|
||||
if (all.every((c) => c.controls >= n && c.room_now >= after && c.playing === playing && c.error_us != null && Math.abs(c.error_us) <= BOUND_US)) return { all };
|
||||
throw new Error(brief(all));
|
||||
}, 250);
|
||||
if (r.stopped) throw new Error(`${what}: a page stopped: ${brief(r.stopped)}`);
|
||||
return r.all;
|
||||
};
|
||||
const ms = (us) => `${(us / 1000).toFixed(1)} ms`;
|
||||
|
||||
// As a person runs it: Ada plays, Ben pauses, each while present.
|
||||
const playAt = await A.f.evaluate(() => window.roomTest.watchControl("play"));
|
||||
const t1 = await together("both play on Ada's control", 1, true, playAt);
|
||||
check("a play starts both players together, each within a quarter second of the room", true, t1.map((c) => `${(c.error_us / 1000).toFixed(1)} ms`).join(", "));
|
||||
check("a play starts both players together, each within a quarter second of the room", true, t1.map((c) => ms(c.error_us)).join(", "));
|
||||
await sleep(3000);
|
||||
const pauseAt = await B.f.evaluate(() => window.roomTest.watchControl("pause"));
|
||||
const t2 = await together("both pause on Ben's control", 2, false, pauseAt);
|
||||
const pos = t2.map((c) => c.current_time);
|
||||
check("a pause stops both players at one position", Math.abs(pos[0] - pos[1]) * 1e6 <= BOUND_US && pos[0] > 1, pos.map((x) => x.toFixed(3)).join(" s, ") + " s");
|
||||
|
||||
// Ben's page held until the room takes it for gone (at most 30 s: the room ends a viewer
|
||||
// silent for 4 s); Ada seeks back meanwhile; Ben's play is signed as the hold ends, in the
|
||||
// same task, so before his page can enter again.
|
||||
const held = B.f.evaluate(() => window.roomTest.watchHoldUntilGone(30000, ["play"]));
|
||||
held.catch(() => {});
|
||||
await sleep(1000);
|
||||
await A.f.evaluate(() => window.roomTest.watchControl("seek", 2e6));
|
||||
const hold = await held;
|
||||
check("Ben's page, held, was taken for gone by the room", hold.left, `held ${hold.held_ms} ms`);
|
||||
const log = () => A.f.evaluate(() => window.roomTest.watchLog());
|
||||
const joinsOf = (L, p) => L.entries.filter((e) => e.kind === "join" && e.subject === p);
|
||||
// The instant the room carried Ben's play at: signed again after his page entered again, and
|
||||
// so at or after his new join's `t` plus the policy's lead.
|
||||
const { L: L1, again, play } = await until("Ben's play carried after his page entered again", STEP, async () => {
|
||||
const L = await log();
|
||||
const again = joinsOf(L, B.principal)[1];
|
||||
const play = again && L.entries.find((e) => e.principal === B.principal && e.kind === "control" && e.action === PLAY && e.entry > again.entry);
|
||||
return play && { L, again, play };
|
||||
}, 250);
|
||||
check("Ben's page entered again, and his play, signed before it did, was signed again after his new join and carried",
|
||||
play.at >= again.t + L1.lead_ms * 1000 && play.at >= hold.at, `at ${play.at} µs, ${ms(play.at - hold.at)} after the instant it was first signed for, ${ms(play.at - again.t)} after his join`);
|
||||
const t3 = await together("both play from Ada's seek on Ben's play", 4, true, play.at);
|
||||
check("both pages play from Ada's seek at the instant the room carried Ben's play, each within a quarter second of the room", true, t3.map((c) => ms(c.error_us)).join(", "));
|
||||
await sleep(3000);
|
||||
const pause2At = await A.f.evaluate(() => window.roomTest.watchControl("pause"));
|
||||
const t4 = await together("both pause on Ada's control", 5, false, pause2At);
|
||||
const pos2 = t4.map((c) => c.current_time);
|
||||
check("a pause stops both players at one position again, past Ada's seek", Math.abs(pos2[0] - pos2[1]) * 1e6 <= BOUND_US && pos2[0] > 2, pos2.map((x) => x.toFixed(3)).join(" s, ") + " s");
|
||||
|
||||
// What the room's log shows, and what each page counted.
|
||||
const end = await cws();
|
||||
const L = await log();
|
||||
const P = L.period_ms * 1000;
|
||||
const [a, b] = end;
|
||||
const leaves = L.entries.filter((e) => e.kind === "leave");
|
||||
check("the room took one page for gone, Ben's held one: one leave in its log, his, after his hold began",
|
||||
leaves.length === 1 && leaves[0].subject === B.principal && leaves[0].t >= hold.began, leaves.map((e) => `${e.subject === B.principal ? "ben" : e.subject === A.principal ? "ada" : e.subject.slice(0, 8)} at ${e.t}`).join(", ") || "none");
|
||||
const [ja, jb] = [joinsOf(L, A.principal), joinsOf(L, B.principal)];
|
||||
check("Ada entered once, Ben twice: again after his leave", ja.length === 1 && jb.length === 2 && leaves.length === 1 && jb[1].entry > leaves[0].entry, `${ja.length} and ${jb.length} joins`);
|
||||
// Every slot wholly within a stretch of a page's presence holds a statement of it (co-watch
|
||||
// §7.3), from its join to a slot before the last statement the log holds, and for Ben's
|
||||
// first stretch to a slot before his hold began.
|
||||
const stated = (p) => L.entries.filter((e) => e.principal === p && e.kind === "presentation").map((e) => e.at);
|
||||
const last = Math.max(...stated(A.principal), ...stated(B.principal)) - P;
|
||||
let slots = 0;
|
||||
let widest = 0;
|
||||
const empty = (name, p, from, to) => {
|
||||
const ats = stated(p).filter((x) => x >= from && x < to);
|
||||
ats.forEach((x, i) => { if (i) widest = Math.max(widest, x - ats[i - 1]); });
|
||||
const out = [];
|
||||
for (let s = Math.ceil(from / P) * P; s + P <= to; s += P, slots++) if (!ats.some((x) => x >= s && x < s + P)) out.push(`${name} ${s}`);
|
||||
return out;
|
||||
};
|
||||
const gaps = ja.length === 1 && jb.length === 2 ? [...empty("ada", A.principal, ja[0].t, last), ...empty("ben", B.principal, jb[0].t, hold.began - P), ...empty("ben", B.principal, jb[1].t, last)] : ["no stretches to check"];
|
||||
check("each page stated what it presented in every slot while it was present (co-watch §7.3), but in Ben's hold", gaps.length === 0,
|
||||
gaps.length ? `no statement in the slot from ${gaps.slice(0, 6).join(", ")}` : `${slots} slots; at most ${ms(widest)} between two statements of a page`);
|
||||
check("each page stated from its join, before it had its relay session and followed the log",
|
||||
[a, b].every((c) => c.followed_at != null && stated(c.principal).length && stated(c.principal)[0] < c.followed_at), [a, b].map((c, i) => `${[A, B][i].name}: first at ${stated(c.principal)[0]}, following from ${c.followed_at}`).join("; "));
|
||||
check("Ada's page was never taken for gone, Ben's once, entering again once with his play signed again; the room refused no other event of either, and no page stopped",
|
||||
a.gone === 0 && a.rejoins === 0 && b.gone === 1 && b.rejoins === 1 && b.resigned === 1 && end.every((c) => Object.keys(c.post_refused).length === 0 && !c.stopped),
|
||||
end.map((c, i) => `${[A, B][i].name}: ${c.posted} carried, ${c.gone} taken for gone, ${c.rejoins} entered again, ${c.resigned} signed again, ${c.dropped} dropped${Object.keys(c.post_refused).length ? `, refused ${JSON.stringify(c.post_refused)}` : ""}`).join("; "));
|
||||
} catch (e) {
|
||||
check("the example ran to its end", false, (e && e.stack || String(e)).split("\n").slice(0, 3).join(" | "));
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -503,7 +503,10 @@ the next evaluations' rates.
|
|||
and §5.3 accept it, and a presentation statement only when §7.2 does (§5.3 asks it of
|
||||
controls). It carries each viewer's events in its chain order. A skew statement it signs
|
||||
names, as `held`, what it held when it made it, so a statement that arrives later changes
|
||||
nothing that statement says.
|
||||
nothing that statement says. An event it refuses it neither carries nor holds: a viewer
|
||||
whose event was refused because the room had taken it for gone (its presence ended) enters
|
||||
again and signs the positions of its chain from where the room's copy ends, and the room
|
||||
holds one event at each, not two different events of one `seq` (live-events §11).
|
||||
- **The room clock** signs the lifecycle (live-rooms §7.2), and so when a viewer is present,
|
||||
and signs skew statements (§8.4): what the room states about how closely it watched
|
||||
together. The service that serves the source states the policy (§3) and serves the objects
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ deployment SHOULD set it to several round trips of the paths it serves (live-ing
|
|||
the mean, over 1 000 ACKACKs, of how far each arrived (less half the RTT) from where
|
||||
`base` put it, once the mean passes 5 ms.
|
||||
- **Too-late drop** (§4.6): when the next packet to deliver is missing and the first
|
||||
packet after it is due, the missing ones are dropped.
|
||||
packet after it that has arrived is due, the missing ones are dropped.
|
||||
- **ACK** (§4.8.1): a full ACK every 10 ms while the acknowledged position moves, and at
|
||||
least every 100 ms; a light ACK every 64 packets.
|
||||
- **NAK** (§4.8.2): at once for each gap, and periodically every (RTT + 4 × RTTVar) / 2
|
||||
|
|
@ -296,9 +296,11 @@ manifest's `live` member (live-ingest §6) carries the extended PTS as `rtp` and
|
|||
|
||||
Because the link delivers each packet at its sender's time plus a constant (SRT's TSBPD,
|
||||
RIST's buffer), the arrivals follow the publisher's clock, and §7's bound applies to the
|
||||
variation of the path and the encoder; a packet recovered within the window arrives on
|
||||
time. A frame whose time lies past `max_skew_ms` from its arrival re-anchors the mapping,
|
||||
as live-ingest §7.1 says.
|
||||
variation of the path and the encoder; a packet recovered with a margin above 0 (§6.2)
|
||||
arrives on time. An SRT packet recovered after its own delivery time but before it would
|
||||
be dropped (a margin of 0, §6.1) is delivered late by as much, as is a packet delivered at
|
||||
the first try that the receiver took after its delivery time (§6.8). A frame whose time
|
||||
lies past `max_skew_ms` from its arrival re-anchors the mapping, as live-ingest §7.1 says.
|
||||
|
||||
### 5.4 Tracks, receipts and the catalog
|
||||
|
||||
|
|
@ -320,13 +322,23 @@ each comes to exactly one end:
|
|||
|
||||
- **delivered at the first try**: its first send arrived before anything found it missing;
|
||||
- **recovered**: it was found missing (a gap behind a later arrival), then arrived, by a
|
||||
retransmission or late from reordering, before its delivery time; or its first arrival
|
||||
retransmission or late from reordering, before it was dropped; or its first arrival
|
||||
was a retransmission (SRT's R flag, RIST's retransmission SSRC) with nothing behind it
|
||||
yet to show the gap, as when a sender probes a stalled tail (§4.1), and it is found
|
||||
missing as it arrives (a recovery of 0 µs);
|
||||
- **dropped**: its delivery time passed without it;
|
||||
- **dropped**: its time passed without it: for SRT, the delivery time of the first packet
|
||||
after it that has arrived (§4.1's too-late drop); for RIST, its own delivery time (§4.2);
|
||||
- **outstanding**: the link ended before it arrived and before its delivery time.
|
||||
|
||||
A packet **arrives** when the receiver takes it, which on a busy host can be well after it
|
||||
reached the receiver's socket. An SRT packet that arrives at or after its own delivery time,
|
||||
but before the first packet after it that has arrived is due, has not been dropped: it is
|
||||
delivered at once, in order, and is recovered with a margin of 0 (§6.2). One that arrives
|
||||
at or after that point is late (§6.3), whether or not the receiver had dropped its sequence
|
||||
number before taking it: a receiver resolves what has fallen due before it takes each
|
||||
packet. For RIST a packet's own delivery time is where it is dropped, so there is no such
|
||||
case.
|
||||
|
||||
The record is cut into **parts**. A part covers the sequence numbers passed while it was
|
||||
open, and the events (requests sent, retransmissions and duplicates received, late
|
||||
arrivals, round trips) that happened while it was open, on the room clock from `start` to
|
||||
|
|
@ -362,7 +374,7 @@ measured before the first sequence number fall into the first part.
|
|||
| `requests`, `requested`, `retransmitted` | 8 each | u64 (§6.3) |
|
||||
| `rtt` | 16 | four u32: the round trips' `samples`, `min`, `median`, `max`, µs |
|
||||
| `recovery` | 16 | four u32: the recoveries' `samples`, `min`, `median`, `max`, µs |
|
||||
| `margin_min` | 4 | u32: the least time, µs, a recovered packet arrived before its delivery time, each such time taken at most `window`; 0 with none recovered |
|
||||
| `margin_min` | 4 | u32: the least time, µs, a recovered packet arrived before its delivery time, each such time taken at most `window`, and as 0 for one that arrived at or after it (§6.1); 0 with none recovered |
|
||||
| `tracks` | 1 + 80 × *n* | a u8 count *n*, then each track's head (§6.4): the track digest (32; live-events §3.1), its receipt chain's `track_alias` (u64; live-ingest §8.2), the groups signed on it so far (u64), the link hash of the latest of their group receipts (32; jwp-receipts §2.4.1), zero with none |
|
||||
| `runs` | 3 + 20 × *k* | a u16 count *k*, a u8 `truncated`, then each loss run: `first` (u64), `count`, `recovered`, `dropped` (u32 each) |
|
||||
| `sig` | 64 | Ed25519 by `hop` (§6.5) |
|
||||
|
|
@ -386,7 +398,9 @@ order; with more, it lists the first 512 and sets `truncated`.
|
|||
- `rtt`: the round trips the gateway measured (SRT: from each ACK to its ACKACK; RIST: from
|
||||
each echo request to its response, less the stated processing delay).
|
||||
- `recovery`: for each recovered packet, from its being found missing to its arrival;
|
||||
`recovery.samples = delivered − first_try`.
|
||||
`recovery.samples = delivered − first_try`. An SRT recovery is not bounded by `window`: it
|
||||
runs from when the packet was found missing, and the window from where its timestamp puts
|
||||
it on the receiver's clock (§4.1), which can be later.
|
||||
- `ended`: 1 the peer ended the link (SRT SHUTDOWN, a RIST keep-alive with D); 2 nothing
|
||||
arrived for the idle time; 3 the session was DELETEd; 4 the gateway stopped; 5 the relay
|
||||
was lost; 6 the relay fell behind; 7 another reason.
|
||||
|
|
@ -477,9 +491,17 @@ a long session has more parts, not larger ones.
|
|||
- A RIST receiver cannot know of packets lost before the first it received, nor after the
|
||||
last: RIST states neither. An SRT receiver knows the first from the handshake's initial
|
||||
sequence number.
|
||||
- A recovered packet arrived in time; the record does not say whether the unit it was part
|
||||
of was later carried (a PES can still be cut by a packet dropped elsewhere, §5.1). The
|
||||
session record (live-ingest §16) counts the units dropped.
|
||||
- A recovered packet arrived before it was dropped. For SRT that can be after its own
|
||||
delivery time (§6.1): such a packet counts a margin of 0 (§6.2), and the record does not
|
||||
say how many there were, nor how late each was delivered. A `margin_min` above 0 says that
|
||||
every packet recovered in the part arrived before its delivery time.
|
||||
- It does not say when a packet delivered at the first try arrived against its delivery
|
||||
time. One the receiver took after it (a receiver behind its socket by more than the
|
||||
window, or a path slower than the latency), with nothing missing before it, is delivered
|
||||
at once and counted at the first try.
|
||||
- It does not say whether the unit a recovered packet was part of was later carried (a PES
|
||||
can still be cut by a packet dropped elsewhere, §5.1). The session record (live-ingest
|
||||
§16) counts the units dropped.
|
||||
- The figures are the gateway's clock's. The record states no energy: live-ingest §16's
|
||||
session record and live-metering's per-hop record do.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue