1use 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
60const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(60);
63
64const TRANSPORT_RETRIES: usize = 3;
70
71const RETRY_PAUSE: Duration = Duration::from_secs(2);
73
74const 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
83const DRAIN_CAP: Duration = Duration::from_secs(worst_case_post_duration().as_secs() + 10);
88
89fn status_is_transient(status: u16) -> bool {
95 status == 429 || (500..600).contains(&status)
96}
97
98fn mtime_of(path: &Path) -> Option<SystemTime> {
101 std::fs::metadata(path).and_then(|m| m.modified()).ok()
102}
103
104#[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 fn note_handoff(&self, path: &Path) {
129 self.note(path, mtime_of(path));
130 }
131
132 fn note_mirrored(&self, path: &Path) {
135 self.note(path, mtime_of(path));
136 }
137
138 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 let accounted = *accounted;
152 matches!(mtime_of(path), Some(current) if current > accounted).then(|| path.clone())
153 })
154 .collect()
155 }
156}
157
158#[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
174pub 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 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 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#[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#[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
264pub fn is_replay_url(url: &str) -> bool {
270 url.contains("action=room_replay_file")
271}
272
273fn 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
289fn is_xz_stream(bytes: &[u8]) -> bool {
295 bytes.len() >= 6 && bytes[..6] == [0xfd, b'7', b'z', b'X', b'Z', 0x00]
296}
297
298const XZ_DECODE_CAP: usize = 20 * 1024 * 1024;
305
306fn 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 (&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
332fn 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 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
367fn 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
388fn 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
404fn 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
481fn 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
496fn 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 let started = Instant::now();
520 let fetch_url = replay_fetch_url(&url);
525 let cached_etag = etags.get(&fetch_url);
526 enum Refresh {
527 Ok(String),
529 Gone(String),
533 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 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 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 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 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 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
684trait LogSink: Send + Sync {
718 fn log(&self, line: &str);
719}
720
721const ROTATE_SIZE: u64 = 1024 * 1024;
730
731struct 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 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#[derive(Default)]
788struct Workers {
789 count: AtomicUsize,
790 gate: Mutex<()>,
791 signal: Condvar,
792}
793
794#[derive(Default)]
799struct SessionStats {
800 refreshes: AtomicUsize,
801 refreshes_err: AtomicUsize,
802 uploads: AtomicUsize,
803 uploads_err: AtomicUsize,
804}
805
806impl SessionStats {
807 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 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 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
869pub 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 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 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 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 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 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 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
1002struct 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 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 assert!(extended.contains('&') && !extended.contains("??"));
1033 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 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 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 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 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 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 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 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 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 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 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 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); 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 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 for status in [429, 500, 502, 503, 504] {
1295 assert!(status_is_transient(status), "{status} must be transient");
1296 }
1297 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 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 tracker.note_mirrored(&file);
1332 assert!(
1333 tracker.unaccounted_changes().is_empty(),
1334 "mirror writes are accounted"
1335 );
1336
1337 std::fs::remove_file(&file).unwrap();
1339 assert!(tracker.unaccounted_changes().is_empty());
1340 let _ = std::fs::remove_dir_all(&dir);
1341 }
1342
1343 #[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 type RequestHandler = dyn Fn(&str, &[u8]) -> Vec<u8> + Send + Sync;
1368
1369 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(()); }
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(()); }
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 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 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 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 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 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)); 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 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 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}