| 1 | |
| 2 | |
| 3 | #[test] |
| 4 | fn stub_retention_removes_legacy_named_stub_instead_of_warning_forever() { |
| 5 | // Founder run 2026-09-28: `session_<timestamp>.json` stubs from early |
| 6 | // builds carry a uuid id, so removal by id hit NotFound and the same |
| 7 | // two stubs were re-listed and warned about on every launch. |
| 8 | let tmp = tempdir().expect("tempdir"); |
| 9 | let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager"); |
| 10 | let legacy_id = Uuid::new_v4().to_string(); |
| 11 | write_empty_session_record( |
| 12 | &manager, |
| 13 | &legacy_id, |
| 14 | Path::new("/tmp"), |
| 15 | Utc::now() - chrono::Duration::days(120), |
| 16 | ); |
| 17 | let legacy_path = manager.sessions_dir.join("session_20260526_133732.json"); |
| 18 | fs::rename( |
| 19 | manager.validated_session_path(&legacy_id).expect("path"), |
| 20 | &legacy_path, |
| 21 | ) |
| 22 | .expect("rename to legacy name"); |
| 23 | for index in 0..MAX_EMPTY_SESSION_STUBS { |
| 24 | write_empty_session_record( |
| 25 | &manager, |
| 26 | &Uuid::new_v4().to_string(), |
| 27 | Path::new("/tmp"), |
| 28 | Utc::now() - chrono::Duration::minutes(index as i64), |
| 29 | ); |
| 30 | } |
| 31 | // Every save runs retention, so the newer stubs above already pushed |
| 32 | // the legacy one past the stub cap; run it once more explicitly. |
| 33 | manager.cleanup_old_sessions().expect("retention"); |
| 34 | |
| 35 | assert!( |
| 36 | !legacy_path.exists(), |
| 37 | "retention retires the file it listed, not `<id>.json`" |
| 38 | ); |
| 39 | let listed = manager.list_sessions().expect("sessions"); |
| 40 | assert_eq!(listed.len(), MAX_EMPTY_SESSION_STUBS); |
| 41 | assert!(listed.iter().all(|session| session.id != legacy_id)); |
| 42 | } |
| 43 | |
| 44 | #[test] |
| 45 | fn stub_retention_treats_an_already_removed_stub_as_removed() { |
| 46 | let tmp = tempdir().expect("tempdir"); |
| 47 | let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager"); |
| 48 | let id = Uuid::new_v4().to_string(); |
| 49 | let canonical = manager.validated_session_path(&id).expect("path"); |
| 50 | manager |
| 51 | .retire_empty_stub(&canonical, &id) |
| 52 | .expect("a stub gone before removal ran is already removed"); |
| 53 | manager |
| 54 | .retire_empty_stub(&manager.sessions_dir.join("session_gone.json"), &id) |
| 55 | .expect("a legacy stub gone before removal ran is already removed"); |
| 56 | } |
| 57 | |
| 58 | #[test] |
| 59 | fn prune_sessions_older_than_returns_zero_for_empty_dir() { |
| 60 | let tmp = tempdir().expect("tempdir"); |
| 61 | let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); |
| 62 | let pruned = manager |
| 63 | .prune_sessions_older_than(std::time::Duration::from_secs(3600)) |
| 64 | .expect("prune"); |
| 65 | assert_eq!(pruned, 0); |
| 66 | } |
| 67 | |
| 68 | #[test] |
| 69 | fn prune_sessions_older_than_keeps_fresh_records() { |
| 70 | let tmp = tempdir().expect("tempdir"); |
| 71 | let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); |
| 72 | // All updated within the last hour. |
| 73 | write_session_with_updated_at( |
| 74 | &manager, |
| 75 | "fresh-1", |
| 76 | Utc::now() - chrono::Duration::minutes(30), |
| 77 | ); |
| 78 | write_session_with_updated_at( |
| 79 | &manager, |
| 80 | "fresh-2", |
| 81 | Utc::now() - chrono::Duration::minutes(5), |
| 82 | ); |
| 83 | let pruned = manager |
| 84 | .prune_sessions_older_than(std::time::Duration::from_secs(3600)) |
| 85 | .expect("prune"); |
| 86 | assert_eq!(pruned, 0); |
| 87 | // Both files still on disk. |
| 88 | assert_eq!(manager.list_sessions().expect("list").len(), 2); |
| 89 | } |
| 90 | |
| 91 | #[test] |
| 92 | fn prune_sessions_older_than_huge_max_age_keeps_everything() { |
| 93 | let tmp = tempdir().expect("tempdir"); |
| 94 | let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); |
| 95 | write_session_with_updated_at(&manager, "old", Utc::now() - chrono::Duration::days(3650)); |
| 96 | write_session_with_updated_at(&manager, "new", Utc::now()); |
| 97 | // Both overflow paths: from_std rejects u64::MAX seconds, and a |
| 98 | // representable-but-enormous age underflows the DateTime subtraction. |
| 99 | for max_age in [ |
| 100 | std::time::Duration::MAX, |
| 101 | std::time::Duration::from_secs(i64::MAX as u64 / 1_000), |
| 102 | ] { |
| 103 | let pruned = manager |
| 104 | .prune_sessions_older_than(max_age) |
| 105 | .expect("huge max_age must not error or panic"); |
| 106 | assert_eq!(pruned, 0, "{max_age:?}"); |
| 107 | assert_eq!(manager.list_sessions().expect("list").len(), 2); |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | #[test] |
| 112 | fn prune_sessions_older_than_removes_stale_records() { |
| 113 | let tmp = tempdir().expect("tempdir"); |
| 114 | let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); |
| 115 | // Two stale records ≥7 days old. |
| 116 | write_session_with_updated_at(&manager, "stale-1", Utc::now() - chrono::Duration::days(8)); |
| 117 | write_session_with_updated_at(&manager, "stale-2", Utc::now() - chrono::Duration::days(30)); |
| 118 | let pruned = manager |
| 119 | .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600)) |
| 120 | .expect("prune"); |
| 121 | assert_eq!(pruned, 2); |
| 122 | assert_eq!(manager.list_sessions().expect("list").len(), 0); |
| 123 | } |
| 124 | |
| 125 | #[test] |
| 126 | fn prune_sessions_older_than_only_removes_stale_records_in_mixed_dir() { |
| 127 | let tmp = tempdir().expect("tempdir"); |
| 128 | let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); |
| 129 | write_session_with_updated_at(&manager, "fresh", Utc::now() - chrono::Duration::hours(1)); |
| 130 | write_session_with_updated_at(&manager, "stale", Utc::now() - chrono::Duration::days(60)); |
| 131 | let pruned = manager |
| 132 | .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600)) |
| 133 | .expect("prune"); |
| 134 | assert_eq!(pruned, 1); |
| 135 | let remaining = manager.list_sessions().expect("list"); |
| 136 | assert_eq!(remaining.len(), 1); |
| 137 | assert_eq!(remaining[0].id, "fresh"); |
| 138 | } |
| 139 | |
| 140 | #[test] |
| 141 | fn prune_sessions_older_than_skips_checkpoint_directory() { |
| 142 | // The checkpoint subsystem owns `<sessions>/checkpoints/` — |
| 143 | // prune must not walk into it. The list_sessions iterator |
| 144 | // already filters to top-level `*.json` files (skipping |
| 145 | // sub-directories), so this test pins that behaviour. |
| 146 | let tmp = tempdir().expect("tempdir"); |
| 147 | let sessions_dir = tmp.path().join("sessions"); |
| 148 | let manager = SessionManager::new(sessions_dir.clone()).expect("new"); |
| 149 | let checkpoint_dir = sessions_dir.join("checkpoints"); |
| 150 | fs::create_dir_all(&checkpoint_dir).expect("mkdir checkpoints"); |
| 151 | // Drop a legacy checkpoint inside the checkpoint dir; prune should |
| 152 | // leave it alone. It belongs to a readable, unrelated origin: an |
| 153 | // unreadable origin makes retention fail closed instead (see |
| 154 | // `retention_fails_closed_on_an_unreadable_legacy_checkpoint_origin`). |
| 155 | let checkpoint_file = checkpoint_dir.join(LEGACY_CHECKPOINT_FILE); |
| 156 | let unrelated = save_late_usage_test_session(&manager, "unrelated-origin"); |
| 157 | write_atomic( |
| 158 | &checkpoint_file, |
| 159 | serialize_saved_session(unrelated) |
| 160 | .expect("legacy bytes") |
| 161 | .as_bytes(), |
| 162 | ) |
| 163 | .expect("write checkpoint"); |
| 164 | |
| 165 | write_session_with_updated_at(&manager, "stale", Utc::now() - chrono::Duration::days(60)); |
| 166 | let pruned = manager |
| 167 | .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600)) |
| 168 | .expect("prune"); |
| 169 | assert_eq!(pruned, 1, "the top-level stale session should be removed"); |
| 170 | assert!( |
| 171 | checkpoint_file.exists(), |
| 172 | "checkpoint file should be untouched" |
| 173 | ); |
| 174 | } |
| 175 | |
| 176 | #[test] |
| 177 | fn test_load_offline_queue_rejects_newer_schema() { |
| 178 | let tmp = tempdir().expect("tempdir"); |
| 179 | let sessions_dir = tmp.path().join("sessions"); |
| 180 | let manager = SessionManager::new(sessions_dir.clone()).expect("new"); |
| 181 | let checkpoints = sessions_dir.join("checkpoints"); |
| 182 | fs::create_dir_all(&checkpoints).expect("create checkpoints dir"); |
| 183 | let path = checkpoints.join("session-A.offline_queue.json"); |
| 184 | fs::write( |
| 185 | &path, |
| 186 | r#"{ |
| 187 | "schema_version": 999, |
| 188 | "messages": [], |
| 189 | "draft": null |
| 190 | }"#, |
| 191 | ) |
| 192 | .expect("write queue"); |
| 193 | |
| 194 | let err = manager |
| 195 | .load_offline_queue_state("session-A") |
| 196 | .expect_err("should reject schema"); |
| 197 | assert!( |
| 198 | err.to_string().contains("newer than supported"), |
| 199 | "unexpected error: {err}" |
| 200 | ); |
| 201 | |
| 202 | // An unreadable *legacy* global queue is somebody else's problem to |
| 203 | // recover: it must not fail this session's boot, and must survive. |
| 204 | let legacy = checkpoints.join("offline_queue.json"); |
| 205 | fs::write(&legacy, r#"{"schema_version": 999}"#).expect("write legacy queue"); |
| 206 | assert!( |
| 207 | manager |
| 208 | .load_offline_queue_state("session-B") |
| 209 | .expect("legacy corruption must not fail the boot") |
| 210 | .is_none() |
| 211 | ); |
| 212 | assert!(legacy.exists(), "unreadable legacy queue is left in place"); |
| 213 | } |
| 214 | #[cfg(all(unix, not(target_os = "solaris")))] |
| 215 | #[test] |
| 216 | fn offline_queue_lease_releases_while_an_inherited_descriptor_remains_open() { |
| 217 | let directory = tempfile::tempdir().expect("queue fixture"); |
| 218 | let manager = SessionManager::new(directory.path().join("sessions")).expect("manager"); |
| 219 | let editor = manager |
| 220 | .acquire_offline_queue_lease("shared-session") |
| 221 | .expect("first editor"); |
| 222 | // dup and fork share the same open-file description. Keep it alive |
| 223 | // without a timing race or forking the multithreaded test process. |
| 224 | let inherited = editor._file.try_clone().expect("inherited descriptor"); |
| 225 | let pending_write = std::sync::Arc::clone(&editor); |
| 226 | drop(editor); |
| 227 | assert_eq!( |
| 228 | manager |
| 229 | .acquire_offline_queue_lease("shared-session") |
| 230 | .unwrap_err() |
| 231 | .kind(), |
| 232 | io::ErrorKind::WouldBlock, |
| 233 | "pending writes retain the exclusive editor lease" |
| 234 | ); |
| 235 | drop(pending_write); |
| 236 | let next_editor = manager |
| 237 | .acquire_offline_queue_lease("shared-session") |
| 238 | .expect("completed editor releases even while a child retains its descriptor"); |
| 239 | drop(inherited); |
| 240 | assert_eq!( |
| 241 | manager |
| 242 | .acquire_offline_queue_lease("shared-session") |
| 243 | .unwrap_err() |
| 244 | .kind(), |
| 245 | io::ErrorKind::WouldBlock, |
| 246 | "closing the old descriptor must not release the next editor's lock" |
| 247 | ); |
| 248 | drop(next_editor); |
| 249 | assert!( |
| 250 | manager |
| 251 | .acquire_offline_queue_lease("shared-session") |
| 252 | .is_ok() |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | #[test] |
| 257 | fn offline_queue_lease_excludes_another_process_and_releases() { |
| 258 | const PROBE: &str = "CODEWHALE_QUEUE_LEASE_PROBE_DIR"; |
| 259 | const HELD: &str = "CODEWHALE_QUEUE_LEASE_PROBE_HELD"; |
| 260 | if let Some(directory) = std::env::var_os(PROBE) { |
| 261 | let manager = SessionManager::new(PathBuf::from(directory)).expect("child store"); |
| 262 | let result = manager.acquire_offline_queue_lease("shared-session"); |
| 263 | if std::env::var(HELD).as_deref() == Ok("1") { |
| 264 | assert_eq!(result.unwrap_err().kind(), io::ErrorKind::WouldBlock); |
| 265 | } else { |
| 266 | assert!(result.is_ok(), "closed owner must release its kernel lock"); |
| 267 | } |
| 268 | return; |
| 269 | } |
| 270 | let directory = tempfile::tempdir().expect("queue fixture"); |
| 271 | let sessions = directory.path().join("sessions"); |
| 272 | let manager = SessionManager::new(sessions.clone()).expect("parent store"); |
| 273 | let lease = manager |
| 274 | .acquire_offline_queue_lease("shared-session") |
| 275 | .expect("first editor"); |
| 276 | let probe = |held: bool| { |
| 277 | let output = std::process::Command::new( |
| 278 | std::env::current_exe().expect("test executable"), |
| 279 | ) |
| 280 | .args([ |
| 281 | "--exact", |
| 282 | "session_manager::tests::offline_queue_lease_excludes_another_process_and_releases", |
| 283 | "--nocapture", |
| 284 | "--test-threads=1", |
| 285 | ]) |
| 286 | .env(PROBE, &sessions) |
| 287 | .env(HELD, if held { "1" } else { "0" }) |
| 288 | .output() |
| 289 | .expect("second editor process"); |
| 290 | assert!( |
| 291 | output.status.success(), |
| 292 | "{}\n{}", |
| 293 | String::from_utf8_lossy(&output.stdout), |
| 294 | String::from_utf8_lossy(&output.stderr) |
| 295 | ); |
| 296 | assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); |
| 297 | }; |
| 298 | probe(true); |
| 299 | let _different_session = manager |
| 300 | .acquire_offline_queue_lease("different-session") |
| 301 | .expect("unrelated queue is available"); |
| 302 | drop(lease); |
| 303 | probe(false); |
| 304 | for invalid in ["", "../session", "nested/session"] { |
| 305 | assert_eq!( |
| 306 | manager |
| 307 | .acquire_offline_queue_lease(invalid) |
| 308 | .unwrap_err() |
| 309 | .kind(), |
| 310 | io::ErrorKind::InvalidInput |
| 311 | ); |
| 312 | } |
| 313 | } |
| 314 |