Skip to main content

recursive/session/
lifecycle.rs

1//! Session lifecycle operations: locking, truncation, and UUID chain recovery.
2//!
3//! Merges the former `session_lock.rs` (Goal 151 sentinel lock) with
4//! `truncate_transcript_to_turn` and `read_last_message_uuid` from
5//! `session.rs` during the Goal 221 module refactor.
6
7use std::io::{BufRead, BufWriter, Write};
8use std::path::{Path, PathBuf};
9
10// Crate-internal so the test suite can construct custom sentinel files
11// for stale-lock recovery tests.
12pub(crate) const SESSION_LOCK_FILE: &str = ".lock";
13
14// ---------------------------------------------------------------------------
15// Session lock (Goal 151)
16// ---------------------------------------------------------------------------
17
18/// Error type carried inside [`std::io::Error::other`] when
19/// [`SessionLock::acquire`] refuses because another live process
20/// holds the lock.
21#[derive(Debug, Clone)]
22pub struct SessionLockBusy {
23    pub pid: u32,
24    pub hostname: String,
25    pub started_at_unix: u64,
26    pub session_dir: PathBuf,
27}
28
29impl std::fmt::Display for SessionLockBusy {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        write!(
32            f,
33            "session at {} is being written by another process \
34             (pid {}, host {}, started {}). \
35             If you believe this is stale, remove {}/{} and retry.",
36            self.session_dir.display(),
37            self.pid,
38            self.hostname,
39            self.started_at_unix,
40            self.session_dir.display(),
41            SESSION_LOCK_FILE,
42        )
43    }
44}
45
46impl std::error::Error for SessionLockBusy {}
47
48/// Parsed contents of a `.lock` sentinel file.
49///
50/// `pub(crate)` so the test suite can construct custom sentinels for
51/// stale-lock recovery / cross-host abort tests.
52pub(crate) struct SentinelInfo {
53    pub(crate) pid: u32,
54    pub(crate) hostname: String,
55    pub(crate) started_at_unix: u64,
56}
57
58impl SentinelInfo {
59    /// Build a sentinel describing this process. Newlines and
60    /// carriage returns in the hostname are stripped so they can't
61    /// break the line-delimited format.
62    fn for_self() -> Self {
63        Self {
64            pid: std::process::id(),
65            hostname: current_hostname(),
66            started_at_unix: std::time::SystemTime::now()
67                .duration_since(std::time::UNIX_EPOCH)
68                .map(|d| d.as_secs())
69                .unwrap_or(0),
70        }
71    }
72
73    pub(crate) fn parse(text: &str) -> Option<Self> {
74        let mut lines = text.lines();
75        let pid: u32 = lines.next()?.trim().parse().ok()?;
76        let hostname = lines.next()?.trim().to_string();
77        let started_at_unix: u64 = lines.next().unwrap_or("0").trim().parse().unwrap_or(0);
78        Some(Self {
79            pid,
80            hostname,
81            started_at_unix,
82        })
83    }
84
85    pub(crate) fn serialise(&self) -> String {
86        format!(
87            "{}\n{}\n{}\n",
88            self.pid, self.hostname, self.started_at_unix
89        )
90    }
91}
92
93/// Return our hostname as a single-line string with newlines stripped
94/// (so it can't break the `\n`-delimited sentinel format).
95///
96/// Tries `$HOSTNAME` / `$COMPUTERNAME` first, then falls back to
97/// invoking `hostname(1)`. Cheap (~1ms) and only called at lock
98/// acquire/release time.
99pub(crate) fn current_hostname() -> String {
100    let raw = std::env::var("HOSTNAME")
101        .or_else(|_| std::env::var("COMPUTERNAME"))
102        .unwrap_or_else(|_| {
103            std::process::Command::new("hostname")
104                .output()
105                .ok()
106                .and_then(|o| String::from_utf8(o.stdout).ok())
107                .unwrap_or_default()
108        });
109    raw.replace(['\n', '\r'], "_").trim().to_string()
110}
111
112/// Check whether `pid` is alive on the local host using `kill(2)`
113/// with signal 0 (the "exists?" probe).
114///
115/// Implementation: spawns `/bin/kill -0 <pid>`. This avoids needing
116/// a `libc` direct dependency and is fast enough at lock-acquire
117/// time. On non-Unix platforms, conservatively assumes alive — we
118/// would rather refuse a resume than corrupt a session. Power
119/// users on those platforms can remove `.lock` manually.
120fn is_pid_alive(pid: u32) -> bool {
121    #[cfg(target_os = "linux")]
122    {
123        // On Linux, /proc/<pid> exists iff the process is alive.
124        // This avoids the ambiguity of kill(1) exit codes for out-of-range PIDs.
125        std::path::Path::new(&format!("/proc/{pid}")).exists()
126    }
127    #[cfg(all(unix, not(target_os = "linux")))]
128    {
129        std::process::Command::new("/bin/kill")
130            .arg("-0")
131            .arg(pid.to_string())
132            .stdout(std::process::Stdio::null())
133            .stderr(std::process::Stdio::null())
134            .status()
135            .map(|s| s.success())
136            .unwrap_or(false)
137    }
138    #[cfg(target_os = "windows")]
139    {
140        // tasklist /FI "PID eq <pid>" /NH /FO CSV prints a CSV row like
141        //   "name.exe","<pid>","..."
142        // when the process exists.  When no process matches it prints
143        // "INFO: No tasks are running …".  For out-of-range or otherwise
144        // unrecognised PIDs (e.g. u32::MAX) the output may be empty or
145        // contain only an error line — not the "INFO: No tasks" sentinel.
146        // Use a positive check (look for the PID as a quoted CSV field)
147        // instead of the fragile negative check so that all non-matching
148        // output is treated as dead.  If `tasklist` is unavailable fall
149        // back to the conservative "assume alive" policy.
150        let output = std::process::Command::new("tasklist")
151            .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
152            .output();
153        match output {
154            Ok(o) => {
155                let stdout = String::from_utf8_lossy(&o.stdout);
156                stdout.contains(&format!("\"{pid}\""))
157            }
158            Err(_) => true,
159        }
160    }
161    #[cfg(not(any(unix, target_os = "windows")))]
162    {
163        let _ = pid;
164        true
165    }
166}
167
168/// RAII guard preventing concurrent writes to the same JSONL session.
169///
170/// Owns the sentinel file `<session_dir>/.lock` for the lifetime of
171/// the guard. On `Drop`, the sentinel is best-effort removed.
172///
173/// Stale-lock recovery: if the sentinel exists, its hostname matches
174/// ours, and the recorded pid is **not** alive, `acquire` overwrites
175/// it (logging a warning to stderr). If the hostname differs,
176/// `acquire` refuses regardless of pid liveness — pid namespaces
177/// across hosts aren't comparable.
178///
179/// **Why not `flock(2)`**: see g151 design — the sentinel approach
180/// gives a better error message ("pid 12345 on host X is still
181/// running") and avoids NFS / iCloud / Dropbox flakiness.
182#[derive(Debug)]
183pub struct SessionLock {
184    lock_path: PathBuf,
185}
186
187impl SessionLock {
188    /// Acquire the lock for `session_dir`. Returns
189    /// [`std::io::Error`] wrapping a [`SessionLockBusy`] when
190    /// another live process holds it (or when an unrecoverable
191    /// cross-host lock is detected).
192    pub fn acquire(session_dir: &Path) -> std::io::Result<Self> {
193        use std::fs::OpenOptions;
194        use std::io::Write;
195
196        let lock_path = session_dir.join(SESSION_LOCK_FILE);
197
198        if let Some(parent) = lock_path.parent() {
199            std::fs::create_dir_all(parent)?;
200        }
201
202        let info = SentinelInfo::for_self();
203        let sentinel = info.serialise();
204
205        // Attempt an atomic exclusive create: succeeds only if the file does
206        // not exist, eliminating the TOCTOU window between `is_file()` and
207        // `write()` that allowed two concurrent processes to both believe they
208        // held the lock.
209        match OpenOptions::new()
210            .write(true)
211            .create_new(true)
212            .open(&lock_path)
213        {
214            Ok(mut f) => {
215                f.write_all(sentinel.as_bytes())?;
216                return Ok(Self { lock_path });
217            }
218            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
219                // Fall through: inspect the existing sentinel.
220            }
221            Err(e) => return Err(e),
222        }
223
224        // The file exists. Read it and decide whether to steal (stale) or
225        // refuse (live owner on same or different host).
226        match std::fs::read_to_string(&lock_path) {
227            Ok(text) => {
228                if let Some(existing) = SentinelInfo::parse(&text) {
229                    let our_host = current_hostname();
230                    if existing.hostname != our_host || is_pid_alive(existing.pid) {
231                        return Err(std::io::Error::other(SessionLockBusy {
232                            pid: existing.pid,
233                            hostname: existing.hostname,
234                            started_at_unix: existing.started_at_unix,
235                            session_dir: session_dir.to_path_buf(),
236                        }));
237                    }
238                    // Stale: pid is dead on our host. Recover by overwriting.
239                    eprintln!(
240                        "warning: recovered stale session lock at {} \
241                         (pid {} not running)",
242                        lock_path.display(),
243                        existing.pid,
244                    );
245                }
246                // Parse failed — corrupt sentinel. Treat as recoverable.
247            }
248            Err(_) => {
249                // Read failed — treat as recoverable (e.g. race-removed).
250            }
251        }
252
253        // Overwrite the stale/corrupt sentinel.  Another concurrent process
254        // may have beaten us to the steal; that is an accepted edge case for
255        // the stale-recovery path (both processes were racing on a dead pid).
256        std::fs::write(&lock_path, &sentinel)?;
257        Ok(Self { lock_path })
258    }
259
260    /// Path to the sentinel file. Mostly useful for tests / error
261    /// messages.
262    pub fn path(&self) -> &Path {
263        &self.lock_path
264    }
265}
266
267impl Drop for SessionLock {
268    fn drop(&mut self) {
269        let _ = std::fs::remove_file(&self.lock_path);
270    }
271}
272
273// ---------------------------------------------------------------------------
274// UUID chain recovery
275// ---------------------------------------------------------------------------
276
277/// Read the UUID of the last message-type (TranscriptEntry) line in a JSONL
278/// file. Skips compact_boundary system entries. Returns `None` if the file
279/// is empty, unreadable, or all entries lack a UUID (pre-g155 files).
280pub(crate) fn read_last_message_uuid(jsonl_path: &Path) -> Option<String> {
281    let file = std::fs::File::open(jsonl_path).ok()?;
282    let reader = std::io::BufReader::new(file);
283    let mut last = None;
284    for line in reader.lines().map_while(Result::ok) {
285        if line.trim().is_empty() {
286            continue;
287        }
288        if let Ok(entry) = serde_json::from_str::<super::serialize::TranscriptEntry>(&line) {
289            if !entry.uuid.is_empty() {
290                last = Some(entry.uuid);
291            }
292        }
293    }
294    last
295}
296
297// ---------------------------------------------------------------------------
298// Transcript truncation
299// ---------------------------------------------------------------------------
300
301/// Truncate `transcript.jsonl` (and the session's `.meta.json`
302/// `message_count`) so that only the messages from turns
303/// `0..cutoff_turn` survive.
304///
305/// "Turn N" is defined as the N-th non-system, non-tool user message
306/// in the transcript (0-indexed). The system prompt (if any) and any
307/// seed messages preceding the first user turn are always preserved.
308///
309/// Used by `recursive sessions rewind --to-turn N` to keep transcript
310/// state in sync with the workspace state restored from a checkpoint.
311pub fn truncate_transcript_to_turn(
312    session_dir: &Path,
313    cutoff_turn: usize,
314) -> std::io::Result<TruncateStats> {
315    let jsonl_path = session_dir.join("transcript.jsonl");
316    if !jsonl_path.exists() {
317        return Ok(TruncateStats {
318            kept: 0,
319            dropped: 0,
320        });
321    }
322
323    // Stream-read so we don't load the whole transcript into memory.
324    let file = std::fs::File::open(&jsonl_path)?;
325    let reader = std::io::BufReader::new(file);
326
327    let tmp_path = jsonl_path.with_extension("jsonl.rewind-tmp");
328    let tmp = std::fs::File::create(&tmp_path)?;
329    let mut writer = BufWriter::new(tmp);
330
331    let mut user_seen = 0usize;
332    let mut kept = 0u64;
333    let mut dropped = 0u64;
334    let mut stop = false;
335
336    for line in reader.lines() {
337        let line = line?;
338        if line.trim().is_empty() {
339            continue;
340        }
341
342        if stop {
343            dropped += 1;
344            continue;
345        }
346
347        // Peek role without full deserialisation.
348        let role = serde_json::from_str::<serde_json::Value>(&line)
349            .ok()
350            .and_then(|v| v.get("role").and_then(|r| r.as_str()).map(str::to_string));
351
352        let is_turn_boundary = matches!(role.as_deref(), Some("user"));
353        if is_turn_boundary {
354            if user_seen >= cutoff_turn {
355                // This user message starts the turn we're rewinding;
356                // drop it and everything after.
357                stop = true;
358                dropped += 1;
359                continue;
360            }
361            user_seen += 1;
362        }
363
364        writer.write_all(line.as_bytes())?;
365        writer.write_all(b"\n")?;
366        kept += 1;
367    }
368    writer.flush()?;
369    drop(writer);
370
371    std::fs::rename(&tmp_path, &jsonl_path)?;
372
373    // Update .meta.json message_count if present.
374    let meta_path = session_dir.join(".meta.json");
375    if meta_path.exists() {
376        if let Ok(bytes) = std::fs::read(&meta_path) {
377            if let Ok(mut meta) = serde_json::from_slice::<super::SessionMeta>(&bytes) {
378                meta.message_count = kept;
379                meta.updated_at = super::chrono_lite_now();
380                if let Ok(json) = serde_json::to_string_pretty(&meta) {
381                    let _ = crate::atomic::atomic_write(&meta_path, json.as_bytes());
382                }
383            }
384        }
385    }
386
387    Ok(TruncateStats { kept, dropped })
388}
389
390/// Stats returned by [`truncate_transcript_to_turn`].
391#[derive(Debug, Clone, Copy)]
392pub struct TruncateStats {
393    pub kept: u64,
394    pub dropped: u64,
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::message::Message;
401    use crate::session::{SessionReader, SessionStatus, SessionWriter};
402
403    // -- SentinelInfo unit tests ---------------------------------------------
404
405    #[test]
406    fn sentinel_parse_reads_three_lines() {
407        // kills field-order mutations in SentinelInfo::parse
408        let text = "12345\nmyhostname\n1700000000\n";
409        let rec = SentinelInfo::parse(text).expect("must parse");
410        assert_eq!(rec.pid, 12345);
411        assert_eq!(rec.hostname, "myhostname");
412        assert_eq!(rec.started_at_unix, 1_700_000_000);
413    }
414
415    #[test]
416    fn sentinel_serialise_round_trips() {
417        // kills format-string mutations in SentinelInfo::serialise
418        let rec = SentinelInfo {
419            pid: 99,
420            hostname: "box01".to_string(),
421            started_at_unix: 42,
422        };
423        let text = rec.serialise();
424        let restored = SentinelInfo::parse(&text).expect("round-trip must succeed");
425        assert_eq!(restored.pid, 99);
426        assert_eq!(restored.hostname, "box01");
427        assert_eq!(restored.started_at_unix, 42);
428    }
429
430    #[test]
431    fn current_hostname_is_non_empty() {
432        // kills mutations that replace current_hostname() body with ""
433        let h = current_hostname();
434        assert!(!h.is_empty(), "hostname must be non-empty");
435    }
436
437    // -- SessionLock tests --------------------------------------------------
438
439    #[test]
440    fn lock_dead_pid_recovered() {
441        let tmp = crate::test_util::IsolatedWorkspace::new();
442        let session_dir = tmp.path().join("session-B");
443        std::fs::create_dir_all(&session_dir).unwrap();
444
445        // Forge a stale lock with a pid that's almost certainly
446        // dead. We use u32::MAX which is well past any valid pid
447        // on Linux/macOS (PID_MAX_LIMIT is 2^22 by default).
448        let stale = SentinelInfo {
449            pid: u32::MAX,
450            hostname: current_hostname(),
451            started_at_unix: 0,
452        };
453        std::fs::write(session_dir.join(SESSION_LOCK_FILE), stale.serialise()).unwrap();
454
455        // Recovery should succeed and overwrite the sentinel.
456        let lock = SessionLock::acquire(&session_dir).unwrap();
457        let raw = std::fs::read_to_string(lock.path()).unwrap();
458        let parsed = SentinelInfo::parse(&raw).unwrap();
459        assert_eq!(parsed.pid, std::process::id());
460    }
461
462    #[test]
463    fn lock_cross_host_aborts() {
464        let tmp = crate::test_util::IsolatedWorkspace::new();
465        let session_dir = tmp.path().join("session-C");
466        std::fs::create_dir_all(&session_dir).unwrap();
467
468        // Forge a lock from a different host. Even though the pid
469        // is dead, cross-host pid checks aren't safe → refuse.
470        let cross = SentinelInfo {
471            pid: u32::MAX,
472            hostname: "definitely-not-our-host-123".to_string(),
473            started_at_unix: 0,
474        };
475        std::fs::write(session_dir.join(SESSION_LOCK_FILE), cross.serialise()).unwrap();
476
477        let err = SessionLock::acquire(&session_dir).expect_err("cross-host should fail");
478        assert!(
479            err.to_string().contains("definitely-not-our-host-123"),
480            "expected cross-host error to mention recorded host, got: {err}"
481        );
482    }
483
484    #[test]
485    fn lock_released_on_drop() {
486        let tmp = crate::test_util::IsolatedWorkspace::new();
487        let session_dir = tmp.path().join("session-D");
488        std::fs::create_dir_all(&session_dir).unwrap();
489
490        let lock = SessionLock::acquire(&session_dir).unwrap();
491        assert!(lock.path().exists());
492        drop(lock);
493        // Drop must remove the sentinel file.
494        assert!(
495            !session_dir.join(SESSION_LOCK_FILE).exists(),
496            "sentinel must be removed on Drop"
497        );
498
499        // A fresh acquire should succeed.
500        let _lock2 = SessionLock::acquire(&session_dir).unwrap();
501    }
502
503    #[test]
504    fn lock_alive_pid_blocks_acquire() {
505        let tmp = crate::test_util::IsolatedWorkspace::new();
506        let session_dir = tmp.path().join("session-A");
507        std::fs::create_dir_all(&session_dir).unwrap();
508
509        // First acquire succeeds; lock file now holds OUR pid.
510        let lock = SessionLock::acquire(&session_dir).unwrap();
511
512        // Second acquire by the same process: pid is alive (it's
513        // us!), so it must refuse.
514        let err = SessionLock::acquire(&session_dir).expect_err("second acquire should fail");
515        // Match the inner SessionLockBusy via Display.
516        assert!(
517            err.to_string()
518                .contains(&format!("pid {}", std::process::id())),
519            "expected error to mention our pid {}, got: {}",
520            std::process::id(),
521            err
522        );
523
524        drop(lock);
525    }
526
527    // -- truncate_transcript_to_turn tests -----------------------------------
528
529    #[test]
530    fn truncate_transcript_to_turn_drops_at_user_boundary() {
531        let dir = crate::test_util::IsolatedWorkspace::new();
532        let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
533        // Sequence: system, user(turn 0), assistant, user(turn 1),
534        // assistant, user(turn 2), assistant.
535        w.append(&Message::system("sys".to_string()), None, None)
536            .unwrap();
537        w.append(&Message::user("u0".to_string()), None, None)
538            .unwrap();
539        w.append(&Message::assistant("a0".to_string()), None, None)
540            .unwrap();
541        w.append(&Message::user("u1".to_string()), None, None)
542            .unwrap();
543        w.append(&Message::assistant("a1".to_string()), None, None)
544            .unwrap();
545        w.append(&Message::user("u2".to_string()), None, None)
546            .unwrap();
547        w.append(&Message::assistant("a2".to_string()), None, None)
548            .unwrap();
549        w.finish(SessionStatus::Completed).unwrap();
550
551        let session_dir = w.session_dir().to_path_buf();
552
553        // Rewind to turn 1 → keep system + u0 + a0; drop u1 onwards.
554        let stats = truncate_transcript_to_turn(&session_dir, 1).unwrap();
555        assert_eq!(stats.kept, 3);
556        assert_eq!(stats.dropped, 4);
557
558        let entries = SessionReader::load_transcript(&session_dir).unwrap();
559        assert_eq!(entries.len(), 3);
560        assert_eq!(entries[0].role, "system");
561        assert_eq!(entries[1].role, "user");
562        assert_eq!(entries[1].content, "u0");
563        assert_eq!(entries[2].role, "assistant");
564        assert_eq!(entries[2].content, "a0");
565
566        // Meta should reflect the new count.
567        let meta = SessionReader::load_meta(&session_dir).unwrap();
568        assert_eq!(meta.message_count, 3);
569    }
570
571    #[test]
572    fn truncate_transcript_to_zero_drops_all_turns_keeps_system() {
573        let dir = crate::test_util::IsolatedWorkspace::new();
574        let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
575        w.append(&Message::system("sys".to_string()), None, None)
576            .unwrap();
577        w.append(&Message::user("u0".to_string()), None, None)
578            .unwrap();
579        w.append(&Message::assistant("a0".to_string()), None, None)
580            .unwrap();
581        w.finish(SessionStatus::Completed).unwrap();
582        let session_dir = w.session_dir().to_path_buf();
583
584        let stats = truncate_transcript_to_turn(&session_dir, 0).unwrap();
585        assert_eq!(stats.kept, 1, "system message should remain");
586        assert_eq!(stats.dropped, 2);
587
588        let entries = SessionReader::load_transcript(&session_dir).unwrap();
589        assert_eq!(entries.len(), 1);
590        assert_eq!(entries[0].role, "system");
591    }
592
593    #[test]
594    fn truncate_transcript_missing_file_is_noop() {
595        let dir = crate::test_util::IsolatedWorkspace::new();
596        // No session created → no transcript.jsonl. Should not panic.
597        let stats = truncate_transcript_to_turn(dir.path(), 5).unwrap();
598        assert_eq!(stats.kept, 0);
599        assert_eq!(stats.dropped, 0);
600    }
601
602    #[test]
603    fn truncate_transcript_cutoff_beyond_end_keeps_all() {
604        // kills `if user_seen >= cutoff_turn` guard mutation (>= → >) or removal
605        let dir = crate::test_util::IsolatedWorkspace::new();
606        let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
607        w.append(&Message::system("sys".to_string()), None, None)
608            .unwrap();
609        w.append(&Message::user("u0".to_string()), None, None)
610            .unwrap();
611        w.append(&Message::assistant("a0".to_string()), None, None)
612            .unwrap();
613        w.finish(SessionStatus::Completed).unwrap();
614        let session_dir = w.session_dir().to_path_buf();
615
616        // cutoff_turn = 99 is beyond all messages → nothing dropped
617        let stats = truncate_transcript_to_turn(&session_dir, 99).unwrap();
618        assert_eq!(stats.dropped, 0, "cutoff beyond end must drop nothing");
619        assert_eq!(stats.kept, 3, "all 3 messages must be kept");
620    }
621
622    #[test]
623    fn truncate_transcript_exact_boundary_drops_last_turn() {
624        // kills off-by-one mutations: `>=` vs `>` in user_seen comparison
625        let dir = crate::test_util::IsolatedWorkspace::new();
626        let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
627        // 2 user turns: turn 0 (u0+a0), turn 1 (u1+a1)
628        w.append(&Message::user("u0".to_string()), None, None)
629            .unwrap();
630        w.append(&Message::assistant("a0".to_string()), None, None)
631            .unwrap();
632        w.append(&Message::user("u1".to_string()), None, None)
633            .unwrap();
634        w.append(&Message::assistant("a1".to_string()), None, None)
635            .unwrap();
636        w.finish(SessionStatus::Completed).unwrap();
637        let session_dir = w.session_dir().to_path_buf();
638
639        // cutoff_turn = 1 → keep turn 0 (u0+a0), drop turn 1 (u1+a1)
640        let stats = truncate_transcript_to_turn(&session_dir, 1).unwrap();
641        assert_eq!(stats.kept, 2, "must keep u0+a0");
642        assert_eq!(stats.dropped, 2, "must drop u1+a1");
643    }
644}