Skip to main content

launcher/headless/
network_files.rs

1//! Headless network file service.
2//!
3//! Spawned by the DLL on its FIRST request (`Display_Config.exe
4//! network-files`, stdin/stdout wired to two anonymous pipes), it
5//! serves the DLL's whole session: every managed-file download and every
6//! game-write upload. The DLL performs no HTTP at all; this process owns
7//! all of it: the conditional GETs with their ETag cache and
8//! copy-if-different writes, the authoritative-404 removals, the POSTs
9//! with their transport retries, and the replay .dat analysis (moved here
10//! from the DLL, riding the push as `dat_*` params).
11//!
12//! # Why a daemon
13//!
14//! One process for the whole session instead of one per file transfer:
15//! the DLL keeps a handle on it (the pipe pair) for quick requests: a
16//! request is one short pipe write, not a process spawn. Requests are
17//! served concurrently (one worker thread each, replies tagged with the
18//! request id), so a slow body on one replay slot never starves the
19//! others.
20//!
21//! # Not a zombie
22//!
23//! The daemon's main loop blocks on reading its STDIN. The stdin pipe's
24//! write end lives in the DLL (inside the game process). When the game
25//! quits, is killed or crashes, the OS closes that write end and the next
26//! read returns EOF: the Windows equivalent of unix's broken-pipe error.
27//! The daemon then stops accepting requests, drains the in-flight ones
28//! and exits.
29//!
30//! # Wire format
31//!
32//! Requests (one line, from stdin):
33//! ```text
34//!   <id> GET <timeout_ms> <url> <local-path>
35//!   <id> POST <url> <local-path>
36//! ```
37//! Replies (one line, to stdout):
38//! ```text
39//!   <id> OK|GONE|ERR <detail...>
40//! ```
41//! `local-path` is always the LAST field (it may contain spaces); the URL
42//! never contains spaces (the mod interpolates it percent-encoded). For a
43//! GET the daemon answers `OK` when the remote content is mirrored on
44//! disk (freshly written, already identical or answered 304), `GONE` when
45//! the remote authoritatively vanished (404, it removes the stale local
46//! copy), `ERR` otherwise. A POST answers `OK` once uploaded or
47//! definitively refused, `ERR` when the transport kept failing.
48
49use std::collections::HashMap;
50use std::io::{Read, Write};
51use std::path::{Path, PathBuf};
52use std::process::ExitCode;
53use std::sync::atomic::{AtomicUsize, Ordering};
54use std::sync::{Arc, Condvar, Mutex};
55use std::time::{Duration, Instant, SystemTime};
56
57use sha2::{Digest, Sha256};
58use supreme_net::{ConditionalGet, HttpError, http_get_conditional, http_post_bytes_timeout};
59
60/// Whole-request budget for one POST attempt (the engine's per-file
61/// ceiling).
62const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(60);
63
64/// Transport/server-failure retries after the first attempt (the old DLL-side
65/// queue used to retry forever on a 1 s tick; the daemon retries a few
66/// times with a pause and then reports, the next race's save re-uploads
67/// anything still missing, and the backend's ghost backfill joins late
68/// replays with their records).
69const TRANSPORT_RETRIES: usize = 3;
70
71/// Pause between transport/server-failure retries.
72const RETRY_PAUSE: Duration = Duration::from_secs(2);
73
74/// Worst-case wall time of one POST request: every attempt gets the full
75/// attempt timeout and every retry its pause (4 × 60 s + 3 × 2 s = 246 s
76/// with the constants above).
77const fn worst_case_post_duration() -> Duration {
78    let attempts = (TRANSPORT_RETRIES + 1) as u64;
79    let pauses = TRANSPORT_RETRIES as u64;
80    Duration::from_secs(ATTEMPT_TIMEOUT.as_secs() * attempts + RETRY_PAUSE.as_secs() * pauses)
81}
82
83/// The drain window after the hook side vanished, DERIVED from the POST
84/// worst case (+ slack) so it can never be shorter than the transfers it
85/// protects, data safety: the drain never cuts an in-flight upload that
86/// still had attempt time left.
87const DRAIN_CAP: Duration = Duration::from_secs(worst_case_post_duration().as_secs() + 10);
88
89/// Is this HTTP status a TRANSIENT server-side failure worth retrying? A
90/// flaky connection often answers 502/503/504 from an intermediary or 429
91/// under load, re-pushing can help, so these retry exactly like transport
92/// failures. A 4xx refusal (expired room, malformed body) is final:
93/// re-pushing the same bytes cannot help.
94fn status_is_transient(status: u16) -> bool {
95    status == 429 || (500..600).contains(&status)
96}
97
98/// The file's modification time, if it can be observed (the push tracker's
99/// raw material).
100fn mtime_of(path: &Path) -> Option<SystemTime> {
101    std::fs::metadata(path).and_then(|m| m.modified()).ok()
102}
103
104/// Per-path upload bookkeeping for the crash diagnostic: the mtime each
105/// pushed path was last ACCOUNTED at, a hand-off (the DLL gave us the
106/// write) or a mirror write (a refresh copied remote bytes over the file).
107/// At drain time a KNOWN path whose on-disk mtime is newer than its record
108/// means the game wrote the file again and died before the close hook
109/// could hand the write off, the bytes are on disk but nobody ever
110/// ordered their upload.
111#[derive(Default)]
112struct PushTracker {
113    accounted: Mutex<HashMap<PathBuf, SystemTime>>,
114}
115
116impl PushTracker {
117    fn note(&self, path: &Path, mtime: Option<SystemTime>) {
118        if let Some(mtime) = mtime {
119            self.accounted
120                .lock()
121                .unwrap_or_else(|poisoned| poisoned.into_inner())
122                .insert(path.to_owned(), mtime);
123        }
124    }
125
126    /// The DLL handed a game write of `path` to us (its close hook fired
127    /// after the game's buffers hit the disk).
128    fn note_handoff(&self, path: &Path) {
129        self.note(path, mtime_of(path));
130    }
131
132    /// A refresh mirrored remote bytes over `path`, an accounted change
133    /// that must never look like an un-handled game write.
134    fn note_mirrored(&self, path: &Path) {
135        self.note(path, mtime_of(path));
136    }
137
138    /// Every tracked path whose on-disk mtime grew past its last accounted
139    /// write: written again, never handed off (a crash-before-close),
140    /// unless a mirror write already re-accounted it.
141    fn unaccounted_changes(&self) -> Vec<PathBuf> {
142        let map = self
143            .accounted
144            .lock()
145            .unwrap_or_else(|poisoned| poisoned.into_inner());
146        map.iter()
147            .filter_map(|(path, accounted)| {
148                // `filter`'s closure sees `&(&PathBuf, &SystemTime)`; copy
149                // the mtime out so the comparison is by value, and clone
150                // the path only for the flagged entries.
151                let accounted = *accounted;
152                matches!(mtime_of(path), Some(current) if current > accounted).then(|| path.clone())
153            })
154            .collect()
155    }
156}
157
158/// One parsed request line.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum Request {
161    Get {
162        id: u64,
163        timeout: Duration,
164        url: String,
165        path: PathBuf,
166    },
167    Post {
168        id: u64,
169        url: String,
170        path: PathBuf,
171    },
172}
173
174/// Parse one request line. `None` for anything malformed (logged, then
175/// answered with ERR so the DLL's waiter never hangs). Fields are
176/// space-split with the path as the LAST field (it may contain spaces);
177/// the URL is by construction a single token (the mod interpolates it
178/// percent-encoded).
179pub fn parse_request(line: &str) -> Option<Request> {
180    let line = line.trim_end_matches('\r');
181    let mut head = line.splitn(3, ' ');
182    let id = head.next()?.parse::<u64>().ok()?;
183    let verb = head.next()?;
184    let rest = head.next()?;
185    match verb {
186        "GET" => {
187            // GET <timeout_ms> <url> <path...>
188            let mut parts = rest.splitn(3, ' ');
189            let timeout_ms = parts.next()?.parse::<u64>().ok()?;
190            let url = parts.next()?.to_owned();
191            let path = parts.next()?.to_owned();
192            Some(Request::Get {
193                id,
194                timeout: Duration::from_millis(timeout_ms),
195                url,
196                path: PathBuf::from(path),
197            })
198        }
199        "POST" => {
200            // POST <url> <path...>
201            let mut parts = rest.splitn(2, ' ');
202            let url = parts.next()?.to_owned();
203            let path = parts.next()?.to_owned();
204            Some(Request::Post {
205                id,
206                url,
207                path: PathBuf::from(path),
208            })
209        }
210        _ => None,
211    }
212}
213
214/// Incremental line assembler for the stdin stream: feed it whatever read()
215/// returned, it hands back every COMPLETE line (without the newline).
216#[derive(Default)]
217pub struct LineAssembler {
218    buf: Vec<u8>,
219}
220
221impl LineAssembler {
222    pub fn feed(&mut self, chunk: &[u8]) -> Vec<String> {
223        let mut lines = Vec::new();
224        self.buf.extend_from_slice(chunk);
225        while let Some(pos) = self.buf.iter().position(|&b| b == b'\n') {
226            let line: Vec<u8> = self.buf.drain(..=pos).collect();
227            let mut line = String::from_utf8_lossy(&line[..line.len() - 1]).into_owned();
228            if line.ends_with('\r') {
229                line.pop();
230            }
231            lines.push(line);
232        }
233        lines
234    }
235}
236
237/// The ETag cache for conditional GETs, keyed by URL (verbatim header
238/// values; the server compares leniently). Session-scoped by design: a
239/// fresh daemon re-fetches bodies once.
240#[derive(Default)]
241pub struct EtagCache {
242    by_url: Mutex<HashMap<String, String>>,
243}
244
245impl EtagCache {
246    fn get(&self, url: &str) -> Option<String> {
247        self.by_url
248            .lock()
249            .unwrap_or_else(|poisoned| poisoned.into_inner())
250            .get(url)
251            .cloned()
252    }
253
254    fn store(&self, url: &str, etag: Option<String>) {
255        if let Some(etag) = etag {
256            self.by_url
257                .lock()
258                .unwrap_or_else(|poisoned| poisoned.into_inner())
259                .insert(url.to_owned(), etag);
260        }
261    }
262}
263
264/// Does this managed-file URL address a replay slot? Both transfer
265/// directions key on it: pushes analyze the .dat and compress the body
266/// (the DAT-derived stamps + the compressed transfer form below),
267/// fetches announce the compressed transfer form with the capability
268/// marker (`replay_fetch_url`).
269pub fn is_replay_url(url: &str) -> bool {
270    url.contains("action=room_replay_file")
271}
272
273/// The URL a replay-slot FETCH really goes to: the `enc=xz` marker
274/// announces that this daemon decodes the CAS's compressed transfer
275/// form. The backend serves the `.xz` variant of a slot's blob ONLY
276/// when the marker is present, so an older daemon (one that cannot
277/// decode) keeps receiving the raw form and can never store a
278/// container as the game's `.dat`; backends predating the marker
279/// ignore the unknown param harmlessly. Other managed files
280/// (high-times tables, ...) fetch their plain URL.
281fn replay_fetch_url(url: &str) -> String {
282    if !is_replay_url(url) {
283        return url.to_owned();
284    }
285    let sep = if url.contains('?') { '&' } else { '?' };
286    format!("{url}{sep}enc=xz")
287}
288
289/// The xz container's leading magic (FD '7' 'z' 'X' 'Z' 00): the
290/// backend's CAS serves a slot's replay preferentially as the
291/// compressed variant (same bytes, xz'd, the priority download the
292/// profile/snapshot flow already uses), this is how the daemon tells
293/// the transfer form from the game file it must store.
294fn is_xz_stream(bytes: &[u8]) -> bool {
295    bytes.len() >= 6 && bytes[..6] == [0xfd, b'7', b'z', b'X', b'Z', 0x00]
296}
297
298/// The decode ceiling for a fetched transfer body: the backend's own
299/// replay cap (20 MB). A hostile or compromised server can name any
300/// output size in a small xz container (a 78 KB body can decode to
301/// gigabytes), so the decode reads at most one byte PAST the cap and
302/// refuses cleanly instead of growing the buffer to whatever the
303/// stream declares.
304const XZ_DECODE_CAP: usize = 20 * 1024 * 1024;
305
306/// Decompress a fetched transfer body (managed replay slots arrive as
307/// the CAS's .xz variant; anything else passes through unchanged, so a
308/// backend predating the compressed form or a raw blob keeps working).
309/// The file on disk is always the game-loadable raw .dat, bounded by
310/// [`XZ_DECODE_CAP`].
311fn xz_decode_transfer(bytes: Vec<u8>) -> Result<Vec<u8>, String> {
312    if !is_xz_stream(&bytes) {
313        return Ok(bytes);
314    }
315    let mut decoder = xz2::read::XzDecoder::new(bytes.as_slice());
316    let mut raw = Vec::new();
317    // `take` bounds the read at CAP + 1 bytes: the probe byte proves
318    // the stream decodes BEYOND the cap without ever buffering it.
319    (&mut decoder)
320        .take(XZ_DECODE_CAP as u64 + 1)
321        .read_to_end(&mut raw)
322        .map_err(|e| format!("xz decode: {e}"))?;
323    if raw.len() > XZ_DECODE_CAP {
324        return Err(format!(
325            "decoded replay exceeds the {} MB cap",
326            XZ_DECODE_CAP / (1024 * 1024)
327        ));
328    }
329    Ok(raw)
330}
331
332/// Compress one replay push and name the RAW bytes for the server:
333/// `encoding=xz` marks the transfer form, `hash` and `size` carry the
334/// sha-256 and byte count of the UNCOMPRESSED .dat (the server cannot
335/// decompress to verify, it trusts the client-declared identity exactly
336/// like the CAS trusts a hash-named .xz upload, and keys the pending
337/// store and the record attach on it). Returns the compressed body plus
338/// the extended URL. Fails (and the caller falls back to the raw push,
339/// a bigger transfer beats a failed one) on an encode failure or when
340/// the container is not strictly smaller than the raw bytes.
341fn compress_replay_push(url: &str, bytes: &[u8]) -> Result<(Vec<u8>, String), String> {
342    let mut encoder = xz2::write::XzEncoder::new(Vec::new(), 6);
343    encoder
344        .write_all(bytes)
345        .map_err(|e| format!("xz encode: {e}"))?;
346    let compressed = encoder.finish().map_err(|e| format!("xz finish: {e}"))?;
347    // Size guard: the container's ~60 byte overhead LOSES on tiny or
348    // incompressible bodies (measured: a 36-byte payload grows to 100).
349    // The raw push is always accepted, so it wins whenever the
350    // compressed form is not strictly smaller; the caller falls back
351    // to it on this error exactly like on an encode failure.
352    if compressed.len() >= bytes.len() {
353        return Err(format!(
354            "xz form is not smaller ({} raw bytes -> {} compressed)",
355            bytes.len(),
356            compressed.len()
357        ));
358    }
359    let hash = hex::encode(Sha256::digest(bytes));
360    let sep = if url.contains('?') { '&' } else { '?' };
361    Ok((
362        compressed,
363        format!("{url}{sep}encoding=xz&hash={hash}&size={}", bytes.len()),
364    ))
365}
366
367/// Analyze one replay .dat and extend the push URL with the DAT-derived
368/// stamps as query params (milliseconds, integers). Field semantics match
369/// the analyzer (and the backend's centisecond unit):
370/// - `dat_start`, the start-gate crossing on the recording clock (`AnalyzeResult::start_ms`);
371/// - `dat_cp1`, the CP1 crossing, start-relative (`checkpoint1_ms` is ALREADY cp1_total − start; subtracting again would double-count);
372/// - `dat_display`, the duration the game displays, start-relative (`displayed_ms`).
373///
374/// The backend converts to its centisecond unit. A failed analysis is an
375/// error (the caller pushes without metadata).
376fn analyze_and_extend_url(url: &str, bytes: &[u8]) -> Result<String, String> {
377    let result = supreme_replay_analyzer::analyze_replay_fast(bytes, None)
378        .map_err(|e| format!("analyze: {e}"))?;
379    let start_ms = result.start_ms;
380    let cp1_ms = result.checkpoint1_ms;
381    let display_ms = result.displayed_ms;
382    if !(start_ms.is_finite() && cp1_ms.is_finite() && display_ms.is_finite()) {
383        return Err("analyze: non-finite timings".to_owned());
384    }
385    Ok(extend_url_with_dat(url, start_ms, cp1_ms, display_ms))
386}
387
388/// The pure URL-join: append `dat_start` / `dat_cp1` / `dat_display`
389/// (rounded, negative-clamped milliseconds) with the right separator.
390fn extend_url_with_dat(url: &str, start_ms: f32, cp1_rel_ms: f32, display_ms: f32) -> String {
391    let sep = if url.contains('?') { '&' } else { '?' };
392    let stamp = |v: f32| -> i64 {
393        let v = v.round();
394        if v < 0.0 { 0 } else { v as i64 }
395    };
396    format!(
397        "{url}{sep}dat_start={}&dat_cp1={}&dat_display={}",
398        stamp(start_ms),
399        stamp(cp1_rel_ms),
400        stamp(display_ms),
401    )
402}
403
404/// One POST attempt loop, THE upload retries. Transport failures and
405/// transient server errors (5xx/429) retry a few times; a server REFUSAL
406/// is final (a refused upload, expired room, malformed body, is not
407/// retried; re-pushing refused bytes cannot help). The attempt count
408/// belongs to the file read that fixed the payload: each request's count
409/// starts fresh, and automatic polls never appear anywhere in it.
410fn post_with_retries(
411    url: &str,
412    bytes: &[u8],
413    log: &dyn LogSink,
414    request_id: u64,
415) -> Result<(), String> {
416    let started = Instant::now();
417    let total_attempts = TRANSPORT_RETRIES + 1;
418    let mut attempt = 0;
419    loop {
420        attempt += 1;
421        let attempt_started = Instant::now();
422        log.log(&format!(
423            "upload #{request_id} attempt {attempt}/{total_attempts}: POSTing {} bytes \
424             (timeout {ATTEMPT_TIMEOUT:?})",
425            bytes.len()
426        ));
427        match http_post_bytes_timeout(url, bytes, ATTEMPT_TIMEOUT) {
428            Ok(()) => {
429                log.log(&format!(
430                    "upload #{request_id} end: uploaded ({} bytes, attempt {attempt}, \
431                     {:.1}s total) -> {url}",
432                    bytes.len(),
433                    started.elapsed().as_secs_f32()
434                ));
435                return Ok(());
436            }
437            Err(HttpError::Status { status, body, .. }) => {
438                let detail = body.trim();
439                if status_is_transient(status) {
440                    if attempt <= TRANSPORT_RETRIES {
441                        log.log(&format!(
442                            "upload #{request_id} attempt {attempt} server error: HTTP {status} \
443                             after {:.1}s; retrying in {RETRY_PAUSE:?}: {detail}",
444                            attempt_started.elapsed().as_secs_f32()
445                        ));
446                        std::thread::sleep(RETRY_PAUSE);
447                        continue;
448                    }
449                    log.log(&format!(
450                        "upload #{request_id} end: FAILED ({:.1}s, {attempt} attempts): \
451                         HTTP {status} kept failing: {detail}",
452                        started.elapsed().as_secs_f32()
453                    ));
454                    return Err(format!("HTTP {status}: {detail}"));
455                }
456                log.log(&format!(
457                    "upload #{request_id} attempt {attempt} refused: HTTP {status}: {detail} \
458                     (final; a refusal is not retried)"
459                ));
460                return Err(format!("HTTP {status}: {detail}"));
461            }
462            Err(HttpError::Io(e)) => {
463                if attempt > TRANSPORT_RETRIES {
464                    log.log(&format!(
465                        "upload #{request_id} end: FAILED ({:.1}s, {attempt} attempts, transport): {e}",
466                        started.elapsed().as_secs_f32()
467                    ));
468                    return Err(format!("transport: {e}"));
469                }
470                log.log(&format!(
471                    "upload #{request_id} attempt {attempt} transport failure after {:.1}s: {e} \
472                     ; retrying in {{RETRY_PAUSE:?}}",
473                    attempt_started.elapsed().as_secs_f32()
474                ));
475                std::thread::sleep(RETRY_PAUSE);
476            }
477        }
478    }
479}
480
481/// Write `bytes` to `path` (parent dirs created); the physical tail of the
482/// GET mirror path (the DLL used to do this in-process; moved the
483/// write next to the fetch).
484fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
485    if let Some(parent) = path.parent()
486        && let Err(e) = std::fs::create_dir_all(parent)
487    {
488        return Err(format!(
489            "cannot create parent dir {}: {e}",
490            parent.display()
491        ));
492    }
493    std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))
494}
495
496/// Execute one request, returning the reply's status and detail. All of
497/// the daemon's semantics live here (single-threaded and testable; the
498/// runner below just wraps it in a worker thread). Every request lands
499/// exactly one terminal line in the session log; uploads open their chain
500/// with the file-read anchor (the receipt line is the runner's job, it
501/// must precede every worker line, in causal order).
502fn execute(
503    request: Request,
504    etags: &EtagCache,
505    log: &dyn LogSink,
506    tracker: &PushTracker,
507) -> (String, String) {
508    match request {
509        Request::Get {
510            id,
511            timeout,
512            url,
513            path,
514        } => {
515            // ONE line per refresh (the poll is a heartbeat, not an event
516            // chain): the reply status verbatim (OK/GONE/ERR, the log
517            // mirrors the wire), the reason and the duration. Never any
518            // attempt vocabulary here (contract tested).
519            let started = Instant::now();
520            // The URL the daemon actually requests: replay slots announce
521            // the compressed transfer form (see `replay_fetch_url`). The
522            // ETag cache keys on the same URL it fetched, so a 304 for the
523            // marked request can never leak into an unmarked one.
524            let fetch_url = replay_fetch_url(&url);
525            let cached_etag = etags.get(&fetch_url);
526            enum Refresh {
527                /// 304 / already identical / freshly written.
528                Ok(String),
529                /// Authoritative 404; the mirror acted (stale copy removed
530                /// or already absent). Its own reply status, the DLL's
531                /// engine keys on it.
532                Gone(String),
533                /// Transport failure, refusal, or a stale copy that
534                /// refused to be removed.
535                Err(String),
536            }
537            let outcome = match http_get_conditional(&fetch_url, cached_etag.as_deref(), timeout) {
538                Ok(ConditionalGet::NotModified) => Refresh::Ok("unchanged (304)".to_owned()),
539                Ok(ConditionalGet::Gone) => {
540                    if path.exists()
541                        && let Err(e) = std::fs::remove_file(&path)
542                    {
543                        Refresh::Err(format!(
544                            "remote gone (404) but {} could not be removed: {e}",
545                            path.display()
546                        ))
547                    } else {
548                        Refresh::Gone(format!(
549                            "remote gone (404); local copy {} removed",
550                            path.display()
551                        ))
552                    }
553                }
554                Ok(ConditionalGet::Body { bytes, etag }) => {
555                    etags.store(&fetch_url, etag);
556                    // The transfer form comes off the wire first: a
557                    // compressed slot body becomes the raw .dat here,
558                    // so the byte-compare and the write below only ever
559                    // see game-loadable bytes. A decode failure keeps
560                    // the local copy (never store a body we cannot
561                    // read).
562                    match xz_decode_transfer(bytes) {
563                        Ok(bytes) => {
564                            let local_bytes = std::fs::read(&path).ok();
565                            if local_bytes.as_deref() == Some(bytes.as_slice()) {
566                                Refresh::Ok(format!("body already on disk ({} bytes)", bytes.len()))
567                            } else {
568                                match write_file(&path, &bytes) {
569                                    Ok(()) => {
570                                        // The mirror write is an accounted
571                                        // change: it must never read as an
572                                        // un-handled game write at drain time.
573                                        tracker.note_mirrored(&path);
574                                        Refresh::Ok(format!(
575                                            "copied from remote ({} bytes) to {}",
576                                            bytes.len(),
577                                            path.display()
578                                        ))
579                                    }
580                                    Err(e) => Refresh::Err(e),
581                                }
582                            }
583                        }
584                        Err(e) => Refresh::Err(format!(
585                            "compressed body for {} could not be decoded: {e}",
586                            path.display()
587                        )),
588                    }
589                }
590                Err(HttpError::Status { status, body, .. }) => {
591                    Refresh::Err(format!("HTTP {status}: {}", body.trim()))
592                }
593                Err(HttpError::Io(e)) => Refresh::Err(e),
594            };
595            let elapsed = started.elapsed().as_secs_f32();
596            let (status, detail) = match &outcome {
597                Refresh::Ok(detail) => ("OK", detail.as_str()),
598                Refresh::Gone(detail) => ("GONE", detail.as_str()),
599                Refresh::Err(detail) => ("ERR", detail.as_str()),
600            };
601            log.log(&format!(
602                "refresh #{id} GET {fetch_url} -> {status} {detail} in {elapsed:.2}s"
603            ));
604            (status.to_owned(), detail.to_owned())
605        }
606        Request::Post { id, url, path, .. } => {
607            let started = Instant::now();
608            // THE important event: this read fixes the payload. The attempt
609            // count that follows belongs to THESE bytes; a read failure
610            // counts no attempt (there was nothing to upload).
611            let bytes = match std::fs::read(&path) {
612                Ok(bytes) => bytes,
613                Err(e) => {
614                    log.log(&format!(
615                        "upload #{id} end: FAILED (could not read {} after {:.2}s): {e}; \
616                         nothing uploaded, no attempt counted",
617                        path.display(),
618                        started.elapsed().as_secs_f32()
619                    ));
620                    return (
621                        "ERR".to_owned(),
622                        format!("could not read {}: {e}", path.display()),
623                    );
624                }
625            };
626            log.log(&format!(
627                "upload #{id} file read: {} bytes from {} in {:.2}s; the payload is fixed \
628                 now; later writes are their own uploads",
629                bytes.len(),
630                path.display(),
631                started.elapsed().as_secs_f32()
632            ));
633            // Replay pushes carry the DAT analysis as extra query params
634            // : the launcher has the replay analyzer the DLL must
635            // not. A failed analysis is fine, the push goes out without
636            // metadata.
637            let (url, bytes) = if is_replay_url(&url) {
638                let url = match analyze_and_extend_url(&url, &bytes) {
639                    Ok(extended) => {
640                        log.log(&format!(
641                            "upload #{id} replay analysis: dat stamps appended ({})",
642                            extended
643                                .split_once("dat_start=")
644                                .map(|(_, tail)| tail.to_owned())
645                                .unwrap_or_else(|| "".to_owned())
646                        ));
647                        extended
648                    }
649                    Err(e) => {
650                        log.log(&format!("upload #{id} replay analysis skipped: {e}"));
651                        url
652                    }
653                };
654                // The transfer form: compress the body and name the raw
655                // bytes' identity on the URL (the same CAS .xz form the
656                // profiles use; a raw push remains the fallback).
657                match compress_replay_push(&url, &bytes) {
658                    Ok((compressed, extended)) => {
659                        log.log(&format!(
660                            "upload #{id} replay transfer: xz ({} bytes -> {})",
661                            bytes.len(),
662                            compressed.len()
663                        ));
664                        (extended, compressed)
665                    }
666                    Err(e) => {
667                        log.log(&format!(
668                            "upload #{id} replay compression skipped, pushing raw: {e}"
669                        ));
670                        (url, bytes)
671                    }
672                }
673            } else {
674                (url, bytes)
675            };
676            match post_with_retries(&url, &bytes, log, id) {
677                Ok(()) => ("OK".to_owned(), "uploaded".to_owned()),
678                Err(detail) => ("ERR".to_owned(), detail),
679            }
680        }
681    }
682}
683
684/// The request-log contract : one diagnosable outcome for EVERY
685/// request, detail proportional to rarity and risk, never a flood.
686///
687/// - **Refresh (automatic poll GET)**, exactly ONE line:
688///   `refresh #<id> GET <url> -> ok (<detail>) in <dur>` or
689///   `refresh #<id> GET <url> -> FAILED (<detail>) in <dur>`. A poll is a
690///   heartbeat, not an event chain; its failure reason is IN the line.
691///   Refresh lines never borrow the upload attempt vocabulary (contract
692///   tested).
693/// - **Upload (game-write POST)**, the full chain, one line per link:
694///   `request #<id> received: upload <url> (file <path>)` (main thread,
695///   causal order, the hand-off the DLL sent),
696///   `upload #<id> file read: N bytes in <dur>` (THE anchor event: the
697///   payload is fixed here; the attempt count that follows belongs to
698///   THIS read),
699///   `upload #<id> attempt n/N: POSTing N bytes (timeout 60s)` plus a
700///   verdict line per non-ok attempt,
701///   `upload #<id> end: uploaded (N bytes, attempt n, <dur> total)` or
702///   `upload #<id> end: FAILED (...)`,
703///   and `reply #<id> could not be delivered ...` when the game died
704///   before the outcome could be piped back (the upload itself still
705///   completed).
706/// - **Lifecycle**, daemon start (with the launcher version), hook-side
707///   gone, drain begin (budget), unaccounted game writes at drain, drain
708///   end, and a one-line session summary (requests by class and outcome).
709///
710/// Automatic file POLLS and upload attempts never share vocabulary,
711/// counters or fate: a poll is one fetch, not a retry of anything, the
712/// file read is the important event. At drain time the daemon also
713/// reports a KNOWN push file whose on-disk mtime grew past its last
714/// accounted write: the game wrote it again and died before the close
715/// hook could hand it off, the one upload loss this pipeline cannot
716/// recover from, logged loudly so the diagnosis is on paper.
717trait LogSink: Send + Sync {
718    fn log(&self, line: &str);
719}
720
721/// Size at which the session log rotates on daemon start: the current
722/// file becomes `network_files.log.1` (one prior generation kept).
723/// Bounded disk footprint, recent history always readable, a log that
724/// grows unbounded for the life of the install hurts diagnosis more than
725/// it helps. Rotation happens only here (a starting daemon); a draining
726/// predecessor may still append to the renamed file for its last seconds
727/// (both handles stay valid on Windows), which at worst splits a line or
728/// two across the generations, harmless next to unbounded growth.
729const ROTATE_SIZE: u64 = 1024 * 1024;
730
731/// The production sink: `.supreme/logs/network_files.log` next to
732/// the launcher exe (which sits in the game dir, like the DLL). One file
733/// per game dir, appended across sessions; each line is one small
734/// append-mode open (atomic appends, safe against a rotating neighbour).
735struct FileLog {
736    path: Option<PathBuf>,
737}
738
739impl FileLog {
740    fn open() -> Self {
741        let dir = std::env::current_exe()
742            .ok()
743            .and_then(|exe| exe.parent().map(Path::to_path_buf));
744        let Some(dir) = dir else {
745            return Self { path: None };
746        };
747        Self::open_at(&dir)
748    }
749
750    /// The real open, on an explicit dir (testable): create the log dir,
751    /// rotate an oversized file, remember the path.
752    fn open_at(dir: &Path) -> Self {
753        let log_dir = dir.join(".supreme").join("logs");
754        if std::fs::create_dir_all(&log_dir).is_err() {
755            return Self { path: None };
756        }
757        let path = log_dir.join("network_files.log");
758        if let Ok(meta) = std::fs::metadata(&path)
759            && meta.len() > ROTATE_SIZE
760        {
761            let mut rotated = path.clone().into_os_string();
762            rotated.push(".1");
763            let _ = std::fs::rename(&path, rotated);
764        }
765        Self { path: Some(path) }
766    }
767}
768
769impl LogSink for FileLog {
770    fn log(&self, line: &str) {
771        let Some(path) = &self.path else {
772            eprintln!("network-files: {line}");
773            return;
774        };
775        let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
776        if let Ok(mut file) = std::fs::OpenOptions::new()
777            .create(true)
778            .append(true)
779            .open(path)
780        {
781            let _ = writeln!(file, "[{timestamp}] {line}");
782        }
783    }
784}
785
786/// The in-flight worker counter: the drain waits on this.
787#[derive(Default)]
788struct Workers {
789    count: AtomicUsize,
790    gate: Mutex<()>,
791    signal: Condvar,
792}
793
794/// Per-class session counters for the exit summary (one line: how many
795/// requests of each class, how many failed, the session's shape at a
796/// glance). Counted at the runner level (worker + inline paths), from the
797/// reply statuses `execute` produced.
798#[derive(Default)]
799struct SessionStats {
800    refreshes: AtomicUsize,
801    refreshes_err: AtomicUsize,
802    uploads: AtomicUsize,
803    uploads_err: AtomicUsize,
804}
805
806impl SessionStats {
807    /// Same, for a request already moved into `execute`.
808    fn record_kind(&self, is_upload: bool, status: &str) {
809        if is_upload {
810            self.uploads.fetch_add(1, Ordering::Relaxed);
811            if status == "ERR" {
812                self.uploads_err.fetch_add(1, Ordering::Relaxed);
813            }
814        } else {
815            self.refreshes.fetch_add(1, Ordering::Relaxed);
816            if status == "ERR" {
817                self.refreshes_err.fetch_add(1, Ordering::Relaxed);
818            }
819        }
820    }
821
822    /// The one-line exit summary.
823    fn summarize(&self) -> String {
824        let refreshes = self.refreshes.load(Ordering::Relaxed);
825        let refreshes_err = self.refreshes_err.load(Ordering::Relaxed);
826        let uploads = self.uploads.load(Ordering::Relaxed);
827        let uploads_err = self.uploads_err.load(Ordering::Relaxed);
828        format!(
829            "session summary: {refreshes} refreshes ({refreshes_err} failed), \
830             {uploads} uploads ({uploads_err} failed)"
831        )
832    }
833}
834
835impl Workers {
836    fn entered(self: &Arc<Self>) {
837        self.count.fetch_add(1, Ordering::SeqCst);
838    }
839
840    fn finished(self: &Arc<Self>) {
841        self.count.fetch_sub(1, Ordering::SeqCst);
842        let _guard = self.gate.lock().unwrap_or_else(|p| p.into_inner());
843        self.signal.notify_all();
844    }
845
846    /// Block until every worker finished or `cap` elapsed. Returns how
847    /// many were still running at the end (0 = clean drain).
848    fn drain(self: &Arc<Self>, cap: Duration) -> usize {
849        let deadline = Instant::now() + cap;
850        let mut guard = self.gate.lock().unwrap_or_else(|p| p.into_inner());
851        while self.count.load(Ordering::SeqCst) > 0 {
852            let now = Instant::now();
853            if now >= deadline {
854                break;
855            }
856            let (g, timeout) = self
857                .signal
858                .wait_timeout(guard, deadline - now)
859                .unwrap_or_else(|p| p.into_inner());
860            guard = g;
861            if timeout.timed_out() && Instant::now() >= deadline {
862                break;
863            }
864        }
865        self.count.load(Ordering::SeqCst)
866    }
867}
868
869/// Entry point. Serves requests until stdin closes (the hook side is
870/// gone, quit, kill or crash), then drains and exits. Never touches the
871/// GUI, the config or the update check.
872pub fn run_network_files() -> ExitCode {
873    let log: Arc<dyn LogSink> = Arc::new(FileLog::open());
874    log.log(&format!(
875        "networking daemon started (launcher v{}, pid {}; stdin=requests, stdout=replies)",
876        env!("CARGO_PKG_VERSION"),
877        std::process::id()
878    ));
879
880    let etags = Arc::new(EtagCache::default());
881    let workers = Arc::new(Workers::default());
882    let tracker = Arc::new(PushTracker::default());
883    let stats = Arc::new(SessionStats::default());
884    let stdout = Arc::new(Mutex::new(std::io::stdout()));
885
886    // One lock for write+flush. (The tempting one-liner
887    // `stdout.lock().write_all(..).and_then(|()| stdout.lock().flush())`
888    // self-deadlocks: the first MutexGuard is a temporary that lives until
889    // the end of the STATEMENT, so the second lock runs while it is still
890    // held, the first worker wedges the stdout mutex and every later
891    // reply is lost. Found by the Wine system test: reply #1 escaped only
892    // because LineWriter flushes on the newline inside write_all.)
893    let write_reply = |stdout: &Arc<Mutex<std::io::Stdout>>, line: &str| -> std::io::Result<()> {
894        let mut out = stdout
895            .lock()
896            .unwrap_or_else(|poisoned| poisoned.into_inner());
897        out.write_all(line.as_bytes())?;
898        out.flush()
899    };
900
901    // The main loop IS the broken-pipe watchdog: this read returns 0 (or
902    // errors) the moment the game process, the only holder of the stdin
903    // write end, is gone for any reason.
904    let mut assembler = LineAssembler::default();
905    let mut chunk = [0u8; 4096];
906    let mut stdin = std::io::stdin().lock();
907    'serve: loop {
908        let read = match stdin.read(&mut chunk) {
909            Ok(0) | Err(_) => break 'serve,
910            Ok(n) => n,
911        };
912        for line in assembler.feed(&chunk[..read]) {
913            let Some(request) = parse_request(&line) else {
914                log.log(&format!("malformed request line: {line:?}"));
915                continue;
916            };
917            let id = match &request {
918                Request::Get { id, .. } | Request::Post { id, .. } => *id,
919            };
920            // Receipt is logged, and, for uploads, the file's mtime is
921            // accounted, on the MAIN thread, strictly before the worker's
922            // own lines, so the log reads in true causal order. Uploads get
923            // the receipt line (the chain's first link); refreshes do not
924            // (one terminal line each, see the log contract).
925            if let Request::Post { url, path, .. } = &request {
926                log.log(&format!(
927                    "request #{id} received: upload {url} (file {})",
928                    path.display()
929                ));
930                tracker.note_handoff(path);
931            }
932            let state = Shared {
933                etags: etags.clone(),
934                workers: workers.clone(),
935                stdout: stdout.clone(),
936                log: log.clone(),
937                tracker: tracker.clone(),
938                stats: stats.clone(),
939            };
940            workers.entered();
941            let for_thread = request.clone();
942            let spawned = std::thread::Builder::new()
943                .name("net-request".to_owned())
944                .spawn(move || {
945                    let is_upload = matches!(for_thread, Request::Post { .. });
946                    let (status, detail) =
947                        execute(for_thread, &state.etags, state.log.as_ref(), &state.tracker);
948                    state.stats.record_kind(is_upload, &status);
949                    if let Err(e) = write_reply(&state.stdout, &format!("{id} {status} {detail}\n"))
950                    {
951                        // The hook side died before the outcome could be
952                        // piped back. The upload itself already completed,
953                        // record why the DLL never learned of it.
954                        state.log.log(&format!(
955                            "reply #{id} could not be delivered (hook side gone): {e}"
956                        ));
957                    }
958                    state.workers.finished();
959                });
960            if spawned.is_err() {
961                // Practically impossible; serve inline so the request is
962                // still answered (and counted properly).
963                let is_upload = matches!(request, Request::Post { .. });
964                let (status, detail) = execute(request, &etags, log.as_ref(), &tracker);
965                stats.record_kind(is_upload, &status);
966                if let Err(e) = write_reply(&stdout, &format!("{id} {status} {detail}\n")) {
967                    log.log(&format!(
968                        "reply #{id} could not be delivered (hook side gone): {e}"
969                    ));
970                }
971                workers.finished();
972            }
973        }
974    }
975
976    log.log(&format!(
977        "hook side gone (stdin closed); draining in-flight requests (budget {DRAIN_CAP:?})"
978    ));
979    let still_running = workers.drain(DRAIN_CAP);
980    // Crash diagnostic: pushes we KNOW about whose file changed since the
981    // last accounted write. The game wrote them again and died before its
982    // close hook could hand the write off, the one upload loss this
983    // pipeline cannot recover from, so it is written down explicitly.
984    for path in tracker.unaccounted_changes() {
985        log.log(&format!(
986            "drain: {} changed on disk after its last hand-off and was never handed off \
987             ; the game likely crashed before the save closed; those bytes were NOT uploaded",
988            path.display()
989        ));
990    }
991    log.log(&stats.summarize());
992    if still_running == 0 {
993        log.log("drain complete; exiting");
994    } else {
995        log.log(&format!(
996            "drain cap hit with {still_running} request(s) still in flight; exiting"
997        ));
998    }
999    ExitCode::from(0)
1000}
1001
1002/// Everything a worker thread needs.
1003struct Shared {
1004    etags: Arc<EtagCache>,
1005    workers: Arc<Workers>,
1006    stdout: Arc<Mutex<std::io::Stdout>>,
1007    log: Arc<dyn LogSink>,
1008    tracker: Arc<PushTracker>,
1009    stats: Arc<SessionStats>,
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015
1016    #[test]
1017    fn replay_pushes_compress_and_name_the_raw_identity() {
1018        let mut payload: Vec<u8> = Vec::new();
1019        for _ in 0..40 {
1020            payload.extend((0..=255u8).collect::<Vec<u8>>());
1021        }
1022        payload.extend_from_slice(b"GHOSTDATA".repeat(64).as_slice());
1023        let url = "https://srv/index.php?action=room_replay_file&id=r_ab12&slot=0&player=host";
1024        let (compressed, extended) =
1025            compress_replay_push(url, &payload).expect("compression succeeds");
1026        // The body is a real xz container and the URL names the RAW bytes.
1027        assert!(is_xz_stream(&compressed));
1028        assert!(extended.contains("encoding=xz"));
1029        assert!(extended.contains(&format!("hash={}", hex::encode(Sha256::digest(&payload)))));
1030        assert!(extended.contains(&format!("size={}", payload.len())));
1031        // The separator respects a URL that already carries a query.
1032        assert!(extended.contains('&') && !extended.contains("??"));
1033        // And the wire form round-trips to the exact .dat bytes.
1034        assert_eq!(
1035            xz_decode_transfer(compressed).as_deref(),
1036            Ok(payload.as_slice())
1037        );
1038    }
1039
1040    #[test]
1041    fn xz_transfer_decode_passes_raw_bodies_through() {
1042        // A raw body (a backend predating the compressed form, or a
1043        // plain high-times table) arrives unchanged.
1044        let raw = b"not an xz stream".to_vec();
1045        let raw_copy = raw.clone();
1046        assert_eq!(xz_decode_transfer(raw).as_deref(), Ok(raw_copy.as_slice()));
1047        // Short bodies cannot carry the magic, always pass through.
1048        assert_eq!(
1049            xz_decode_transfer(vec![0xfd, b'7', b'z']).as_deref(),
1050            Ok([0xfd, b'7', b'z'].as_slice())
1051        );
1052        // The magic itself decodes through the real container.
1053        let bytes = b"payload".to_vec();
1054        let mut encoder = xz2::write::XzEncoder::new(Vec::new(), 6);
1055        encoder.write_all(&bytes).expect("encode");
1056        let compressed = encoder.finish().expect("finish");
1057        assert_eq!(
1058            xz_decode_transfer(compressed).as_deref(),
1059            Ok(bytes.as_slice())
1060        );
1061    }
1062
1063    #[test]
1064    fn xz_decode_refuses_output_past_the_cap() {
1065        // A decompression bomb: 21 MB of zeros compress to a few hundred
1066        // bytes; a body larger than a legal replay must be refused
1067        // cleanly (never buffered), the way a hostile server would
1068        // deliver it.
1069        let bomb = vec![0u8; XZ_DECODE_CAP + 1];
1070        let mut encoder = xz2::write::XzEncoder::new(Vec::new(), 6);
1071        encoder.write_all(&bomb).expect("encode bomb");
1072        let compressed = encoder.finish().expect("finish bomb");
1073        assert!(compressed.len() < 64 * 1024, "the bomb is tiny on the wire");
1074        let err = xz_decode_transfer(compressed).expect_err("oversized decode refused");
1075        assert!(err.contains("cap"), "the refusal names the cap: {err}");
1076
1077        // The boundary itself is the contract: exactly at the cap the
1078        // replay is legal (the server accepts raw sizes up to the same
1079        // 20 MB), one byte past it is not.
1080        let at_cap = vec![0u8; XZ_DECODE_CAP];
1081        let mut encoder = xz2::write::XzEncoder::new(Vec::new(), 6);
1082        encoder.write_all(&at_cap).expect("encode at-cap");
1083        let compressed = encoder.finish().expect("finish at-cap");
1084        assert_eq!(
1085            xz_decode_transfer(compressed).as_deref(),
1086            Ok(at_cap.as_slice()),
1087            "a replay exactly at the cap decodes"
1088        );
1089    }
1090
1091    #[test]
1092    fn incompressible_and_tiny_pushes_prefer_the_raw_form() {
1093        // Incompressible bytes (chained sha-256 output): the container's
1094        // overhead cannot win, the size guard must refuse the xz form.
1095        let mut incompressible = Vec::new();
1096        let mut digest = Sha256::digest(b"seed");
1097        for _ in 0..256 {
1098            incompressible.extend_from_slice(&digest);
1099            digest = Sha256::digest(digest);
1100        }
1101        let err = compress_replay_push(
1102            "https://srv/index.php?action=room_replay_file&id=r_x&slot=0",
1103            &incompressible,
1104        )
1105        .expect_err("the xz form loses on incompressible bytes");
1106        assert!(err.contains("not smaller"), "the refusal says why: {err}");
1107
1108        // A tiny payload (the e2e suites' 36-byte .dat): the ~60-byte
1109        // container overhead more than doubles it, raw wins too.
1110        let err = compress_replay_push(
1111            "https://srv/index.php?action=room_replay_file&id=r_x&slot=0",
1112            b"tiny ghost payload for e2e tests..",
1113        )
1114        .expect_err("the xz form loses on tiny payloads");
1115        assert!(err.contains("not smaller"), "the refusal says why: {err}");
1116    }
1117
1118    #[test]
1119    fn parse_get_with_spaces_in_path() {
1120        let request = parse_request("12 GET 10000 https://srv/index.php?action=room_high_times_file&id=r_x C:/games/Supreme/.supreme/mods/online-tournament/high_times/Village Tracks Medium high_times.txt")
1121            .expect("parses");
1122        match request {
1123            Request::Get {
1124                id,
1125                timeout,
1126                url,
1127                path,
1128            } => {
1129                assert_eq!(id, 12);
1130                assert_eq!(timeout, Duration::from_secs(10));
1131                assert_eq!(
1132                    url,
1133                    "https://srv/index.php?action=room_high_times_file&id=r_x"
1134                );
1135                assert_eq!(
1136                    path,
1137                    PathBuf::from(
1138                        "C:/games/Supreme/.supreme/mods/online-tournament/high_times/\
1139                         Village Tracks Medium high_times.txt"
1140                    )
1141                );
1142            }
1143            other => panic!("expected Get, got {other:?}"),
1144        }
1145    }
1146
1147    #[test]
1148    fn parse_post_and_reject_garbage() {
1149        assert_eq!(
1150            parse_request("7 POST https://srv/x C:/a b.dat"),
1151            Some(Request::Post {
1152                id: 7,
1153                url: "https://srv/x".to_owned(),
1154                path: PathBuf::from("C:/a b.dat"),
1155            })
1156        );
1157        assert!(parse_request("").is_none());
1158        assert!(parse_request("nope").is_none());
1159        assert!(parse_request("3 FETCH https://srv/x C:/y").is_none());
1160        assert!(
1161            parse_request("3 GET abc https://srv/x C:/y").is_none(),
1162            "bad timeout"
1163        );
1164        assert!(
1165            parse_request("GET 100 https://srv/x C:/y").is_none(),
1166            "no id"
1167        );
1168        // The URL is a single token by construction (the mod interpolates
1169        // it percent-encoded); anything after the third split is the path.
1170        assert_eq!(
1171            parse_request("3 GET 100 https://srv/x y C:/z"),
1172            Some(Request::Get {
1173                id: 3,
1174                timeout: Duration::from_millis(100),
1175                url: "https://srv/x".to_owned(),
1176                path: PathBuf::from("y C:/z"),
1177            })
1178        );
1179    }
1180
1181    #[test]
1182    fn line_assembler_reassembles_fragments() {
1183        let mut asm = LineAssembler::default();
1184        assert!(asm.feed(b"1 GET 100 ht").is_empty());
1185        // The newline inside this chunk completes the FIRST line.
1186        assert_eq!(
1187            asm.feed(b"tps://srv/x C:/a b\n2 PO"),
1188            vec!["1 GET 100 https://srv/x C:/a b".to_owned()]
1189        );
1190        let lines = asm.feed(b"ST https://srv/y C:/c d\r\n");
1191        assert_eq!(lines, vec!["2 POST https://srv/y C:/c d".to_owned()]);
1192    }
1193
1194    #[test]
1195    fn extend_url_appends_with_ampersand_when_query_exists() {
1196        let out = extend_url_with_dat(
1197            "https://srv/index.php?action=room_replay_file&id=r_x&slot=0",
1198            150.4,
1199            12340.0,
1200            6761.0,
1201        );
1202        assert_eq!(
1203            out,
1204            "https://srv/index.php?action=room_replay_file&id=r_x&slot=0\
1205             &dat_start=150&dat_cp1=12340&dat_display=6761"
1206        );
1207    }
1208
1209    #[test]
1210    fn extend_url_uses_question_mark_when_needed() {
1211        let out = extend_url_with_dat("https://srv/index.php", 0.0, -3.0, 67000.6);
1212        assert_eq!(
1213            out,
1214            "https://srv/index.php?dat_start=0&dat_cp1=0&dat_display=67001"
1215        );
1216    }
1217
1218    #[test]
1219    fn replay_detection_matches_action_param() {
1220        let replay_url = "https://srv/index.php?action=room_replay_file&id=r_x&slot=0";
1221        let table_url = "https://srv/index.php?action=room_high_times_file&id=r_x";
1222        assert!(is_replay_url(replay_url));
1223        assert!(!is_replay_url(table_url));
1224    }
1225
1226    #[test]
1227    fn replay_fetches_announce_the_compressed_form_other_files_do_not() {
1228        // The capability marker: only replay-slot URLs gain `enc=xz`,
1229        // the backend serves the CAS .xz variant only when it is
1230        // present (mixed-fleet rollout safety, see `replay_fetch_url`).
1231        assert_eq!(
1232            replay_fetch_url("https://srv/index.php?action=room_replay_file&id=r_x&slot=0"),
1233            "https://srv/index.php?action=room_replay_file&id=r_x&slot=0&enc=xz"
1234        );
1235        // High-times tables and anything else stay verbatim.
1236        let table = "https://srv/index.php?action=room_high_times_file&id=r_x";
1237        assert_eq!(replay_fetch_url(table), table);
1238        let bare = "https://srv/some/other/file.txt";
1239        assert_eq!(replay_fetch_url(bare), bare);
1240    }
1241
1242    #[test]
1243    fn etag_cache_roundtrip() {
1244        let cache = EtagCache::default();
1245        assert_eq!(cache.get("https://srv/x"), None);
1246        cache.store("https://srv/x", Some("\"abc\"".to_owned()));
1247        assert_eq!(cache.get("https://srv/x"), Some("\"abc\"".to_owned()));
1248        cache.store("https://srv/x", None); // no ETag this round: keep the old one
1249        assert_eq!(cache.get("https://srv/x"), Some("\"abc\"".to_owned()));
1250    }
1251
1252    #[test]
1253    fn workers_drain_counts_down() {
1254        let workers = Arc::new(Workers::default());
1255        workers.entered();
1256        workers.entered();
1257        let w2 = workers.clone();
1258        std::thread::spawn(move || {
1259            std::thread::sleep(Duration::from_millis(50));
1260            w2.finished();
1261        });
1262        workers.finished();
1263        assert_eq!(
1264            workers.drain(Duration::from_secs(5)),
1265            0,
1266            "the spawned worker finished"
1267        );
1268    }
1269
1270    #[test]
1271    fn upload_attempt_timeout_is_the_user_directed_60s() {
1272        assert_eq!(ATTEMPT_TIMEOUT, Duration::from_secs(60));
1273    }
1274
1275    #[test]
1276    fn drain_cap_covers_the_post_worst_case() {
1277        // Data safety: the drain window can never be shorter than the
1278        // transfers it protects, a POST's worst case (all attempts timing
1279        // out) plus slack, derived so the two can never drift apart.
1280        let worst = worst_case_post_duration();
1281        assert_eq!(
1282            worst,
1283            Duration::from_secs(4 * 60 + 3 * 2),
1284            "4 attempts x 60 s + 3 x 2 s pauses"
1285        );
1286        assert!(DRAIN_CAP >= worst, "the drain must outlast the worst POST");
1287        assert_eq!(DRAIN_CAP, worst + Duration::from_secs(10));
1288    }
1289
1290    #[test]
1291    fn transient_status_classification() {
1292        // Retried like transport failures: intermediaries' 5xx and load
1293        // shedding (429), the flaky-connection face of a server.
1294        for status in [429, 500, 502, 503, 504] {
1295            assert!(status_is_transient(status), "{status} must be transient");
1296        }
1297        // Final refusals: re-pushing the same bytes cannot help.
1298        for status in [200, 204, 400, 403, 404, 409, 413, 422] {
1299            assert!(!status_is_transient(status), "{status} must be final");
1300        }
1301    }
1302
1303    #[test]
1304    fn push_tracker_flags_only_unaccounted_changes() {
1305        let dir = std::env::temp_dir().join("ssbz_push_tracker_test");
1306        let _ = std::fs::remove_dir_all(&dir);
1307        std::fs::create_dir_all(&dir).unwrap();
1308        let file = dir.join("replay_1.dat");
1309        std::fs::write(&file, b"v1").unwrap();
1310
1311        let tracker = PushTracker::default();
1312        tracker.note_handoff(&file);
1313        assert!(
1314            tracker.unaccounted_changes().is_empty(),
1315            "a fresh hand-off is accounted"
1316        );
1317
1318        // The game writes again (a real save rewrites the content) but the
1319        // close hook never fires, mtime grows past the record.
1320        std::thread::sleep(Duration::from_millis(20));
1321        std::fs::write(&file, b"v2 with different length").unwrap();
1322        let flagged = tracker.unaccounted_changes();
1323        assert_eq!(
1324            flagged,
1325            vec![file.clone()],
1326            "the crash-before-close write is flagged"
1327        );
1328
1329        // A mirror write re-accounts the file (a refresh copied remote
1330        // bytes over it), no longer an un-handled game write.
1331        tracker.note_mirrored(&file);
1332        assert!(
1333            tracker.unaccounted_changes().is_empty(),
1334            "mirror writes are accounted"
1335        );
1336
1337        // A removed file is never flagged.
1338        std::fs::remove_file(&file).unwrap();
1339        assert!(tracker.unaccounted_changes().is_empty());
1340        let _ = std::fs::remove_dir_all(&dir);
1341    }
1342
1343    // ---- the request-log contract, non-blocking, rotation ----
1344
1345    /// A capturing sink: every log line lands in a shared Vec, so the
1346    /// assertions run against the exact line grammar.
1347    #[derive(Default, Clone)]
1348    struct VecLog(Arc<Mutex<Vec<String>>>);
1349
1350    impl LogSink for VecLog {
1351        fn log(&self, line: &str) {
1352            self.0
1353                .lock()
1354                .unwrap_or_else(|p| p.into_inner())
1355                .push(line.to_owned());
1356        }
1357    }
1358
1359    impl VecLog {
1360        fn lines(&self) -> Vec<String> {
1361            self.0.lock().unwrap_or_else(|p| p.into_inner()).clone()
1362        }
1363    }
1364
1365    /// The request handler a test-server connection runs: (path, body)
1366    /// in, raw response bytes out.
1367    type RequestHandler = dyn Fn(&str, &[u8]) -> Vec<u8> + Send + Sync;
1368
1369    /// A minimal threaded HTTP server for the executor tests: one thread
1370    /// per connection (so a held request never blocks another), the
1371    /// handler gets (path, body) and returns the raw response bytes.
1372    fn spawn_test_server(
1373        handler: impl Fn(&str, &[u8]) -> Vec<u8> + Send + Sync + 'static,
1374    ) -> String {
1375        use std::net::TcpListener;
1376        let listener = TcpListener::bind("127.0.0.1:0").expect("bind an ephemeral port");
1377        let port = listener.local_addr().unwrap().port();
1378        let handler: Arc<RequestHandler> = Arc::new(handler);
1379        std::thread::spawn(move || {
1380            for stream in listener.incoming().flatten() {
1381                let handler = handler.clone();
1382                std::thread::spawn(move || {
1383                    let _ = serve_connection(stream, handler.as_ref());
1384                });
1385            }
1386        });
1387        format!("http://127.0.0.1:{port}")
1388    }
1389
1390    fn serve_connection(
1391        mut stream: std::net::TcpStream,
1392        handler: &RequestHandler,
1393    ) -> std::io::Result<()> {
1394        use std::io::Read;
1395        let mut buf: Vec<u8> = Vec::new();
1396        let mut chunk = [0u8; 1024];
1397        let head_end = loop {
1398            let n = stream.read(&mut chunk)?;
1399            if n == 0 {
1400                return Ok(()); // client went away; nothing to answer
1401            }
1402            buf.extend_from_slice(&chunk[..n]);
1403            if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
1404                break pos;
1405            }
1406            if buf.len() > 64 * 1024 {
1407                return Ok(()); // absurd head; drop the connection
1408            }
1409        };
1410        let head = String::from_utf8_lossy(&buf[..head_end]).into_owned();
1411        let content_length = head
1412            .lines()
1413            .find_map(|line| {
1414                let (name, value) = line.split_once(':')?;
1415                if name.trim().eq_ignore_ascii_case("content-length") {
1416                    value.trim().parse::<usize>().ok()
1417                } else {
1418                    None
1419                }
1420            })
1421            .unwrap_or(0);
1422        let body_start = head_end + 4;
1423        while buf.len() < body_start + content_length {
1424            let n = stream.read(&mut chunk)?;
1425            if n == 0 {
1426                break;
1427            }
1428            buf.extend_from_slice(&chunk[..n]);
1429        }
1430        let path = head.split_whitespace().nth(1).unwrap_or("/").to_owned();
1431        let body = buf
1432            .get(body_start..body_start + content_length)
1433            .unwrap_or(&[]);
1434        let response = handler(&path, body);
1435        stream.write_all(&response)?;
1436        Ok(())
1437    }
1438
1439    fn http_response(status_line: &str, body: &[u8]) -> Vec<u8> {
1440        let mut out = format!(
1441            "{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1442            body.len()
1443        )
1444        .into_bytes();
1445        out.extend_from_slice(body);
1446        out
1447    }
1448
1449    #[test]
1450    fn refresh_log_is_one_terminal_line_without_attempt_vocabulary() {
1451        let base = spawn_test_server(|_path, _body| http_response("HTTP/1.1 200 OK", b"table"));
1452        let dir = std::env::temp_dir().join("ssbz_refresh_contract_test");
1453        let _ = std::fs::remove_dir_all(&dir);
1454        std::fs::create_dir_all(&dir).unwrap();
1455        let out = dir.join("high_times.txt");
1456        let log = VecLog::default();
1457        let (status, _detail) = execute(
1458            Request::Get {
1459                id: 9,
1460                timeout: Duration::from_secs(5),
1461                url: format!("{base}/high_times.txt"),
1462                path: out,
1463            },
1464            &EtagCache::default(),
1465            &log,
1466            &PushTracker::default(),
1467        );
1468        assert_eq!(status, "OK");
1469        let lines = log.lines();
1470        assert_eq!(
1471            lines.len(),
1472            1,
1473            "exactly one terminal line per refresh: {lines:?}"
1474        );
1475        let line = &lines[0];
1476        assert!(line.starts_with("refresh #9 GET "), "got: {line}");
1477        assert!(
1478            line.contains(" -> OK "),
1479            "the wire status is in the line: {line}"
1480        );
1481        assert!(
1482            line.contains(" in "),
1483            "the duration is part of the contract: {line}"
1484        );
1485        for banned in ["attempt", "retry", "upload #"] {
1486            assert!(
1487                !line.contains(banned),
1488                "refresh lines never borrow '{banned}' (the poll is not a retry): {line}"
1489            );
1490        }
1491        let _ = std::fs::remove_dir_all(&dir);
1492    }
1493
1494    #[test]
1495    fn replay_slot_fetch_carries_the_marker_and_decodes_the_xz_body() {
1496        // The full fetch contract against a live socket: a replay-slot
1497        // GET must carry `enc=xz` (the served-form capability marker),
1498        // and the compressed body it gets back must land on disk as the
1499        // decoded raw .dat. A non-replay URL on the same server must
1500        // stay verbatim.
1501        let ghost = b"decoded ghost bytes".to_vec();
1502        let mut encoder = xz2::write::XzEncoder::new(Vec::new(), 6);
1503        encoder.write_all(&ghost).expect("encode ghost");
1504        let compressed_ghost = encoder.finish().expect("finish ghost");
1505
1506        let seen: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
1507            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1508        let seen_for_server = seen.clone();
1509        let compressed_for_server = compressed_ghost.clone();
1510        let base = spawn_test_server(move |path, _body| {
1511            seen_for_server
1512                .lock()
1513                .unwrap_or_else(|p| p.into_inner())
1514                .push(path.to_owned());
1515            if path.contains("action=room_replay_file") {
1516                http_response("HTTP/1.1 200 OK", &compressed_for_server)
1517            } else {
1518                http_response("HTTP/1.1 200 OK", b"plain table bytes")
1519            }
1520        });
1521
1522        let dir = std::env::temp_dir().join("ssbz_replay_fetch_marker_test");
1523        let _ = std::fs::remove_dir_all(&dir);
1524        std::fs::create_dir_all(&dir).unwrap();
1525        let replay_out = dir.join("replay_0.dat");
1526        let (status, _detail) = execute(
1527            Request::Get {
1528                id: 21,
1529                timeout: Duration::from_secs(5),
1530                url: format!("{base}/index.php?action=room_replay_file&id=r_x&slot=0"),
1531                path: replay_out.clone(),
1532            },
1533            &EtagCache::default(),
1534            &VecLog::default(),
1535            &PushTracker::default(),
1536        );
1537        assert_eq!(status, "OK");
1538        let requests = seen.lock().unwrap_or_else(|p| p.into_inner()).clone();
1539        assert_eq!(
1540            requests.last().map(String::as_str),
1541            Some("/index.php?action=room_replay_file&id=r_x&slot=0&enc=xz"),
1542            "the replay fetch announces the compressed form: {requests:?}"
1543        );
1544        assert_eq!(
1545            std::fs::read(&replay_out).unwrap(),
1546            ghost,
1547            "the compressed body lands as the decoded raw .dat"
1548        );
1549
1550        // A high-times fetch on the same server: verbatim URL, raw body
1551        // stored as-is.
1552        let table_out = dir.join("high_times.txt");
1553        let (status, _detail) = execute(
1554            Request::Get {
1555                id: 22,
1556                timeout: Duration::from_secs(5),
1557                url: format!("{base}/index.php?action=room_high_times_file&id=r_x"),
1558                path: table_out.clone(),
1559            },
1560            &EtagCache::default(),
1561            &VecLog::default(),
1562            &PushTracker::default(),
1563        );
1564        assert_eq!(status, "OK");
1565        let requests = seen.lock().unwrap_or_else(|p| p.into_inner()).clone();
1566        assert_eq!(
1567            requests.last().map(String::as_str),
1568            Some("/index.php?action=room_high_times_file&id=r_x"),
1569            "no marker on non-replay files: {requests:?}"
1570        );
1571        assert_eq!(
1572            std::fs::read(&table_out).unwrap(),
1573            b"plain table bytes".as_slice()
1574        );
1575        let _ = std::fs::remove_dir_all(&dir);
1576    }
1577
1578    #[test]
1579    fn incompressible_replay_pushes_go_out_raw() {
1580        // The size guard's wire behavior: an incompressible .dat must be
1581        // POSTed as the RAW bytes on the unextended URL (no
1582        // `encoding=xz`), the server accepts both forms.
1583        let mut incompressible = Vec::new();
1584        let mut digest = Sha256::digest(b"raw-push-seed");
1585        for _ in 0..128 {
1586            incompressible.extend_from_slice(&digest);
1587            digest = Sha256::digest(digest);
1588        }
1589        let received: std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>> =
1590            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1591        let received_for_server = received.clone();
1592        let payload_for_server = incompressible.clone();
1593        let base = spawn_test_server(move |path, body| {
1594            received_for_server
1595                .lock()
1596                .unwrap_or_else(|p| p.into_inner())
1597                .push((path.to_owned(), body.to_vec()));
1598            let _ = &payload_for_server;
1599            http_response("HTTP/1.1 200 OK", b"stored")
1600        });
1601
1602        let dir = std::env::temp_dir().join("ssbz_raw_push_test");
1603        let _ = std::fs::remove_dir_all(&dir);
1604        std::fs::create_dir_all(&dir).unwrap();
1605        let payload = dir.join("replay_0.dat");
1606        std::fs::write(&payload, &incompressible).expect("write payload");
1607        let (status, detail) = execute(
1608            Request::Post {
1609                id: 23,
1610                url: format!("{base}/index.php?action=room_replay_file&id=r_x&slot=0"),
1611                path: payload,
1612            },
1613            &EtagCache::default(),
1614            &VecLog::default(),
1615            &PushTracker::default(),
1616        );
1617        assert_eq!((status.as_str(), detail.as_str()), ("OK", "uploaded"));
1618        let received = received.lock().unwrap_or_else(|p| p.into_inner()).clone();
1619        assert_eq!(received.len(), 1, "one POST: {received:?}");
1620        let (path, body) = &received[0];
1621        assert!(
1622            path.starts_with("/index.php?action=room_replay_file&id=r_x&slot=0")
1623                && !path.contains("encoding=xz"),
1624            "no compressed transfer form on a raw-fallback push (the dat_* analysis \
1625             stamps may still be there): {path}"
1626        );
1627        assert!(!is_xz_stream(body), "the body is the raw .dat, not a container");
1628        assert_eq!(body, &incompressible, "the exact raw bytes went out");
1629        let _ = std::fs::remove_dir_all(&dir);
1630    }
1631
1632    #[test]
1633    fn upload_chain_anchors_at_the_file_read_and_ends_once() {
1634        let base = spawn_test_server(|_path, _body| http_response("HTTP/1.1 200 OK", b"stored"));
1635        let dir = std::env::temp_dir().join("ssbz_upload_chain_test");
1636        let _ = std::fs::remove_dir_all(&dir);
1637        std::fs::create_dir_all(&dir).unwrap();
1638        let payload = dir.join("replay_2.dat");
1639        std::fs::write(&payload, b"ghost-bytes").unwrap();
1640        let log = VecLog::default();
1641        let (status, detail) = execute(
1642            Request::Post {
1643                id: 4,
1644                url: format!("{base}/index.php?action=room_high_times_file&id=r_x"),
1645                path: payload,
1646            },
1647            &EtagCache::default(),
1648            &log,
1649            &PushTracker::default(),
1650        );
1651        assert_eq!(status, "OK");
1652        assert_eq!(detail, "uploaded");
1653        let lines = log.lines();
1654        assert!(
1655            lines.iter().any(|l| l.contains("file read: 11 bytes")),
1656            "THE anchor event (payload fixed): {lines:?}"
1657        );
1658        assert!(
1659            lines
1660                .iter()
1661                .any(|l| l.contains("attempt 1/4: POSTing 11 bytes")),
1662            "the attempt line: {lines:?}"
1663        );
1664        let ends: Vec<&String> = lines.iter().filter(|l| l.contains(" end: ")).collect();
1665        assert_eq!(ends.len(), 1, "exactly one terminal line: {ends:?}");
1666        assert!(
1667            ends[0].contains("uploaded (11 bytes, attempt 1"),
1668            "got: {}",
1669            ends[0]
1670        );
1671        assert!(
1672            lines.iter().all(|l| !l.contains("refresh #")),
1673            "upload lines never borrow the poll vocabulary: {lines:?}"
1674        );
1675        let _ = std::fs::remove_dir_all(&dir);
1676    }
1677
1678    #[test]
1679    fn upload_without_a_file_counts_no_attempt() {
1680        let base = spawn_test_server(|_path, _body| http_response("HTTP/1.1 200 OK", b"unused"));
1681        let log = VecLog::default();
1682        let missing = std::env::temp_dir().join("ssbz_missing_upload_payload.dat");
1683        let _ = std::fs::remove_file(&missing);
1684        let (status, detail) = execute(
1685            Request::Post {
1686                id: 5,
1687                url: format!("{base}/index.php?action=room_replay_file&id=r_x&slot=0"),
1688                path: missing,
1689            },
1690            &EtagCache::default(),
1691            &log,
1692            &PushTracker::default(),
1693        );
1694        assert_eq!(status, "ERR");
1695        assert!(detail.contains("could not read"), "got: {detail}");
1696        let lines = log.lines();
1697        assert_eq!(lines.len(), 1, "no chain without a payload: {lines:?}");
1698        assert!(
1699            lines[0].contains(" end: FAILED (could not read"),
1700            "got: {}",
1701            lines[0]
1702        );
1703        assert!(
1704            !lines[0].contains("attempt 1/") && !lines[0].contains("file read:"),
1705            "a read failure counts no numbered attempt and has no anchor: {}",
1706            lines[0]
1707        );
1708    }
1709
1710    #[test]
1711    fn a_slow_upload_does_not_block_other_requests() {
1712        // The concurrency guarantee: the 60 s upload timeout is
1713        // acceptable ONLY while it does not block other uploads or
1714        // downloads. A POST parked on a slow endpoint, a GET served
1715        // while it is still parked.
1716        const SLOW_MS: u64 = 2000;
1717        let base = spawn_test_server(move |path, _body| {
1718            if path == "/slow" {
1719                std::thread::sleep(Duration::from_millis(SLOW_MS));
1720            }
1721            http_response("HTTP/1.1 200 OK", b"ok")
1722        });
1723        let dir = std::env::temp_dir().join("ssbz_nonblocking_test");
1724        let _ = std::fs::remove_dir_all(&dir);
1725        std::fs::create_dir_all(&dir).unwrap();
1726        let payload = dir.join("replay_1.dat");
1727        std::fs::write(&payload, b"slow-ghost").unwrap();
1728        let out = dir.join("high_times.txt");
1729
1730        // NOTE: the production shape is ONE shared agent across workers
1731        // (the static in supreme_net); these executes share it too.
1732        let post_base = base.clone();
1733        let post = std::thread::spawn(move || {
1734            let started = Instant::now();
1735            let (status, _detail) = execute(
1736                Request::Post {
1737                    id: 1,
1738                    url: format!("{post_base}/slow"),
1739                    path: payload,
1740                },
1741                &EtagCache::default(),
1742                &VecLog::default(),
1743                &PushTracker::default(),
1744            );
1745            (status, started.elapsed())
1746        });
1747        std::thread::sleep(Duration::from_millis(300)); // the POST is now parked server-side
1748        let get_started = Instant::now();
1749        let (get_status, _detail) = execute(
1750            Request::Get {
1751                id: 2,
1752                timeout: Duration::from_secs(5),
1753                url: format!("{base}/fast"),
1754                path: out,
1755            },
1756            &EtagCache::default(),
1757            &VecLog::default(),
1758            &PushTracker::default(),
1759        );
1760        let get_elapsed = get_started.elapsed();
1761        assert_eq!(get_status, "OK");
1762        assert!(
1763            get_elapsed < Duration::from_millis(SLOW_MS - 200),
1764            "the fast GET must complete while the slow POST is still in flight (took {get_elapsed:?})"
1765        );
1766        let (post_status, post_elapsed) = post.join().unwrap();
1767        assert_eq!(post_status, "OK");
1768        assert!(
1769            post_elapsed >= Duration::from_millis(SLOW_MS),
1770            "the POST really waited out the slow endpoint (took {post_elapsed:?})"
1771        );
1772        let _ = std::fs::remove_dir_all(&dir);
1773    }
1774
1775    #[test]
1776    fn oversized_log_rotates_to_one_prior_generation() {
1777        let dir = std::env::temp_dir().join("ssbz_log_rotate_test");
1778        let _ = std::fs::remove_dir_all(&dir);
1779        let log_path = dir.join(".supreme").join("logs").join("network_files.log");
1780        std::fs::create_dir_all(log_path.parent().unwrap()).unwrap();
1781        std::fs::write(&log_path, vec![b'x'; ROTATE_SIZE as usize + 1]).unwrap();
1782        let mut rotated = log_path.clone().into_os_string();
1783        rotated.push(".1");
1784
1785        FileLog::open_at(&dir);
1786        assert_eq!(
1787            std::fs::metadata(&rotated).map(|m| m.len()).unwrap_or(0),
1788            ROTATE_SIZE + 1,
1789            "the oversized generation moved to .1"
1790        );
1791        // The fresh log is created on first write, not on open.
1792        let log = FileLog::open_at(&dir);
1793        log.log("hello");
1794        let fresh = std::fs::read_to_string(&log_path).unwrap();
1795        assert!(
1796            fresh.contains("hello"),
1797            "the fresh file receives lines: {fresh:?}"
1798        );
1799        // A second open rotates nothing (the fresh file is tiny, .1 stays).
1800        FileLog::open_at(&dir);
1801        assert_eq!(std::fs::metadata(&rotated).unwrap().len(), ROTATE_SIZE + 1);
1802        let _ = std::fs::remove_dir_all(&dir);
1803    }
1804
1805    #[test]
1806    fn session_summary_counts_by_class_and_outcome() {
1807        let stats = SessionStats::default();
1808        stats.record_kind(false, "OK");
1809        stats.record_kind(false, "ERR");
1810        stats.record_kind(true, "OK");
1811        stats.record_kind(true, "OK");
1812        stats.record_kind(true, "ERR");
1813        assert_eq!(
1814            stats.summarize(),
1815            "session summary: 2 refreshes (1 failed), 3 uploads (1 failed)"
1816        );
1817    }
1818}