返回 CodeWhale
test_cases_04.rs
根目录 / crates / tui / src / session_manager / test_cases_04.rs
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
314 lines RUST