1use super::{SaveError, SessionState};
5use crate::core::bounded_lock::{read_for, write_for};
6use crate::core::context_checkpoint::CanonicalSession;
7use lean_ctx_protocol::Sha256Digest;
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::time::{Duration, Instant};
11use tokio::sync::RwLock;
12
13const SYNC_SAVE_GUARD_WAIT: Duration = Duration::from_millis(250);
14const SYNC_SAVE_LANE_WAIT: Duration = Duration::from_secs(5);
15const SHUTDOWN_SAVE_LANE_WAIT: Duration = Duration::from_secs(15);
21const FOLLOWING_SAVE_LANE_WAIT: Duration = Duration::from_mins(1);
26
27#[derive(Debug, Default)]
38pub struct SaveGate {
39 lane: tokio::sync::Mutex<()>,
40 follower: AtomicBool,
41 closing: AtomicBool,
42}
43
44pub(super) struct SaveOutcome {
45 id: String,
46 version: u32,
47 expected: Option<Sha256Digest>,
48 pub(super) primary_sha256: Option<String>,
49 pub(super) committed: Option<Box<CanonicalSession>>,
50 pub(super) result: Result<(), SaveError>,
54}
55
56pub struct SharedSaveReceipt {
57 pub id: String,
58 pub version: u32,
59 pub current: bool,
61}
62
63impl SaveOutcome {
64 pub(super) fn new(id: &str, version: u32, expected: Option<Sha256Digest>) -> Self {
65 Self {
66 id: id.to_owned(),
67 version,
68 expected,
69 primary_sha256: None,
70 committed: None,
71 result: Ok(()),
72 }
73 }
74}
75
76impl SessionState {
77 pub(super) fn note_save_failure(&mut self) {
78 self.last_save_failed = true;
79 self.stats.unsaved_changes = self
80 .stats
81 .unsaved_changes
82 .max(super::state::BATCH_SAVE_INTERVAL);
83 }
84
85 pub(super) fn acknowledge_save(&mut self, outcome: SaveOutcome) -> Result<(), String> {
86 if self.id != outcome.id {
87 return outcome.result.map_err(String::from);
88 }
89 let same_base = self
90 .canonical_checkpoint
91 .as_ref()
92 .and_then(|binding| binding.storage_digest.as_ref())
93 == outcome.expected.as_ref();
94 if same_base && let Some(committed) = outcome.committed {
95 self.canonical_checkpoint = Some(committed);
98 }
99 if outcome.result.is_err() {
100 self.note_save_failure();
101 } else if same_base && self.version == outcome.version {
102 self.stats.unsaved_changes = 0;
103 self.last_save_failed = false;
104 self.last_flush = Some(std::time::Instant::now());
105 }
106 outcome.result.map_err(String::from)
107 }
108
109 pub async fn save_shared(owner: Arc<RwLock<Self>>) -> Result<(), String> {
112 Self::save_shared_within(owner, SYNC_SAVE_LANE_WAIT).await
113 }
114
115 async fn save_shared_within(
119 owner: Arc<RwLock<Self>>,
120 lane_wait: Duration,
121 ) -> Result<(), String> {
122 let gate = owner.read().await.save_gate.clone();
123 let lane = tokio::time::timeout(lane_wait, gate.lane.lock()).await;
124 let Ok(_lane) = lane else {
125 let mut live = owner.write().await;
126 if Arc::ptr_eq(&gate, &live.save_gate) {
127 live.note_save_failure();
128 }
129 return Err("session save lane unavailable".into());
130 };
131 Self::save_in_lane(owner, &gate).await
132 }
133
134 async fn save_shared_background(owner: Arc<RwLock<Self>>) -> Result<(), String> {
140 let gate = owner.read().await.save_gate.clone();
141 if gate.closing.load(Ordering::Acquire) {
142 return Ok(());
143 }
144 let _lane = if let Ok(lane) = gate.lane.try_lock() {
145 lane
146 } else {
147 if gate.follower.swap(true, Ordering::AcqRel) {
148 return Ok(());
149 }
150 let waited = tokio::time::timeout(FOLLOWING_SAVE_LANE_WAIT, gate.lane.lock()).await;
151 gate.follower.store(false, Ordering::Release);
154 let Ok(lane) = waited else {
155 let mut live = owner.write().await;
156 if Arc::ptr_eq(&gate, &live.save_gate) {
157 live.note_save_failure();
158 }
159 return Err("session save lane unavailable".into());
160 };
161 lane
162 };
163 if gate.closing.load(Ordering::Acquire) {
164 return Ok(());
165 }
166 {
167 let live = owner.read().await;
168 if Arc::ptr_eq(&gate, &live.save_gate)
169 && live.stats.unsaved_changes == 0
170 && !live.last_save_failed
171 {
172 return Ok(());
173 }
174 }
175 Self::save_in_lane(owner, &gate).await
176 }
177
178 async fn save_in_lane(owner: Arc<RwLock<Self>>, gate: &Arc<SaveGate>) -> Result<(), String> {
180 let (id, version, mut snapshot) = {
181 let session = owner.read().await;
182 if !Arc::ptr_eq(&gate, &session.save_gate) {
183 return Ok(()); }
185 (session.id.clone(), session.version, session.clone())
186 };
187 let prepared = match snapshot.prepare_save() {
193 Ok(prepared) => prepared,
194 Err(error) => {
195 let mut live = owner.write().await;
196 if Arc::ptr_eq(&gate, &live.save_gate) && live.id == id && live.version == version {
197 live.note_save_failure();
198 }
199 return Err(error);
200 }
201 };
202 if let Ok(outcome) =
203 tokio::task::spawn_blocking(move || prepared.write_to_disk_with_policy(false)).await
204 {
205 let mut live = owner.write().await;
206 if !Arc::ptr_eq(&gate, &live.save_gate) {
207 return Ok(());
208 }
209 live.acknowledge_save(outcome)
210 } else {
211 let mut live = owner.write().await;
212 if Arc::ptr_eq(&gate, &live.save_gate) && live.id == id && live.version == version {
213 live.note_save_failure();
214 }
215 Err("session save worker unavailable; verify committed state before retrying".into())
216 }
217 }
218
219 pub fn save_shared_sync(owner: &Arc<RwLock<Self>>) -> Result<SharedSaveReceipt, String> {
222 let gate = read_for(owner, "session save owner", SYNC_SAVE_GUARD_WAIT)
223 .ok_or_else(|| "session is busy; retry the save".to_string())?
224 .save_gate
225 .clone();
226 let lane_deadline = Instant::now() + SYNC_SAVE_LANE_WAIT;
227 let _lane = loop {
228 match gate.lane.try_lock() {
229 Ok(lane) => break lane,
230 Err(_) if Instant::now() < lane_deadline => {
231 std::thread::sleep(Duration::from_millis(2));
232 }
233 Err(_) => {
234 if let Some(mut live) =
235 write_for(owner, "session save owner", SYNC_SAVE_GUARD_WAIT)
236 && Arc::ptr_eq(&gate, &live.save_gate)
237 {
238 live.note_save_failure();
239 }
240 return Err("session save lane unavailable".into());
241 }
242 }
243 };
244 let (id, version, mut snapshot) = {
245 let session = read_for(owner, "session save owner", SYNC_SAVE_GUARD_WAIT)
246 .ok_or_else(|| "session is busy; retry the save".to_string())?;
247 if !Arc::ptr_eq(&gate, &session.save_gate) {
248 return Err("session owner changed before save; retry".into());
249 }
250 (session.id.clone(), session.version, session.clone())
251 };
252 let prepared = match snapshot.prepare_save() {
253 Ok(prepared) => prepared,
254 Err(error) => {
255 if let Some(mut live) = write_for(owner, "session save owner", SYNC_SAVE_GUARD_WAIT)
256 && Arc::ptr_eq(&gate, &live.save_gate)
257 && live.id == id
258 && live.version == version
259 {
260 live.note_save_failure();
261 }
262 return Err(error);
263 }
264 };
265 let outcome = prepared.write_to_disk_with_policy(false);
266 let Some(mut live) = write_for(owner, "session save owner", SYNC_SAVE_GUARD_WAIT) else {
267 return Err(
268 "session save outcome could not be acknowledged; verify durable state before retrying"
269 .into(),
270 );
271 };
272 if !Arc::ptr_eq(&gate, &live.save_gate) || live.id != id {
273 return Err(
274 "session owner changed after save; current state was not acknowledged".into(),
275 );
276 }
277 let same_base = live
278 .canonical_checkpoint
279 .as_ref()
280 .and_then(|binding| binding.storage_digest.as_ref())
281 == outcome.expected.as_ref();
282 live.acknowledge_save(outcome)?;
283 Ok(SharedSaveReceipt {
284 id,
285 version,
286 current: same_base
287 && live.version == version
288 && live.stats.unsaved_changes == 0
289 && !live.last_save_failed
290 && Arc::ptr_eq(&gate, &live.save_gate),
291 })
292 }
293
294 pub async fn save_shared_logged(owner: Arc<RwLock<Self>>) {
295 if let Err(error) = Self::save_shared_background(owner).await {
296 tracing::warn!(%error, "session persistence failed; unsaved state retained");
297 }
298 }
299
300 pub async fn save_shared_at_shutdown(owner: Arc<RwLock<Self>>) -> bool {
311 let gate = owner.read().await.save_gate.clone();
312 gate.closing.store(true, Ordering::Release);
313 let durable = Self::save_last_state(&owner, SHUTDOWN_SAVE_LANE_WAIT).await;
314 gate.closing.store(false, Ordering::Release);
317 durable
318 }
319
320 async fn save_last_state(owner: &Arc<RwLock<Self>>, lane_wait: Duration) -> bool {
321 for _attempt in 0..2 {
322 let saved = Self::save_shared_within(Arc::clone(owner), lane_wait).await;
323 if let Err(error) = &saved {
324 tracing::warn!(%error, "session persistence failed at shutdown; the last state is not durable");
325 return false;
326 }
327 let live = owner.read().await;
328 if live.stats.unsaved_changes == 0 && !live.last_save_failed {
329 return true;
330 }
331 }
332 tracing::warn!(
333 "session kept changing during the shutdown saves; the last state is not durable"
334 );
335 false
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::{SessionState, SharedSaveReceipt};
342 use std::sync::Arc;
343 use tokio::sync::RwLock;
344
345 #[test]
346 fn synchronous_shared_save_needs_no_tokio_runtime() {
347 let _data = crate::core::data_dir::isolated_data_dir();
348 let project = tempfile::tempdir().expect("project tempdir");
349 let mut session = SessionState::new();
350 session.id = "sync-shared-save".into();
351 session.project_root = Some(project.path().to_string_lossy().into_owned());
352 session.stats.unsaved_changes = 1;
353
354 let owner = Arc::new(RwLock::new(session));
355 let receipt: SharedSaveReceipt =
356 SessionState::save_shared_sync(&owner).expect("synchronous session save");
357
358 assert!(receipt.current, "saved version should be acknowledged");
359 let saved = crate::core::session::paths::sessions_dir()
360 .expect("session directory")
361 .join(format!("{}.json", receipt.id));
362 assert!(saved.is_file(), "the save adapter must write the session");
363 let live = owner.try_read().expect("uncontested session");
364 assert_eq!(live.stats.unsaved_changes, 0);
365 assert!(!live.last_save_failed);
366 }
367
368 #[test]
369 fn newer_live_version_stays_dirty_after_older_save_ack() {
370 let _data = crate::core::data_dir::isolated_data_dir();
371 let project = tempfile::tempdir().expect("project tempdir");
372 let mut snapshot = SessionState::new();
373 snapshot.id = "sync-save-newer-version".into();
374 snapshot.project_root = Some(project.path().to_string_lossy().into_owned());
375 snapshot.version = 1;
376 snapshot.stats.unsaved_changes = 1;
377
378 let prepared = snapshot.prepare_save().expect("prepare snapshot");
379 let outcome = prepared.write_to_disk_with_policy(false);
380 assert!(outcome.result.is_ok(), "fixture save should succeed");
381
382 let mut live = snapshot;
383 live.version = 2;
384 live.stats.unsaved_changes = 2;
385 live.acknowledge_save(outcome)
386 .expect("acknowledge older save");
387
388 assert_eq!(live.version, 2);
389 assert_eq!(live.stats.unsaved_changes, 2);
390 }
391
392 struct HeldStill {
397 _data: crate::core::data_dir::IsolatedDataDir,
398 _community: crate::core::policy::runtime::TestPolicyOverride,
399 _project: tempfile::TempDir,
400 }
401
402 fn unsaved_owner(id: &str) -> (HeldStill, Arc<RwLock<SessionState>>, std::path::PathBuf) {
403 let data = crate::core::data_dir::isolated_data_dir();
404 let community = crate::core::policy::runtime::TestPolicyOverride::set(None);
405 let project = tempfile::tempdir().expect("project tempdir");
406 let mut session = SessionState::new();
407 session.id = id.into();
408 session.project_root = Some(project.path().to_string_lossy().into_owned());
409 session.increment();
410 let saved = crate::core::session::paths::sessions_dir()
411 .expect("session directory")
412 .join(format!("{id}.json"));
413 let held = HeldStill {
414 _data: data,
415 _community: community,
416 _project: project,
417 };
418 (held, Arc::new(RwLock::new(session)), saved)
419 }
420
421 fn drive<T>(work: impl std::future::Future<Output = T>) -> T {
422 tokio::runtime::Builder::new_current_thread()
423 .enable_time()
424 .build()
425 .expect("runtime")
426 .block_on(work)
427 }
428
429 async fn until(condition: impl Fn() -> bool) {
430 for _ in 0..2000 {
431 if condition() {
432 return;
433 }
434 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
435 }
436 panic!("condition not reached");
437 }
438
439 #[test]
445 fn while_a_save_is_in_flight_one_request_follows_and_the_rest_return() {
446 let (_held_still, owner, saved) = unsaved_owner("save-lane-follower");
447 drive(async {
448 let gate = owner.read().await.save_gate.clone();
449 let in_flight = gate.lane.lock().await;
450 let follower = tokio::spawn(SessionState::save_shared_background(Arc::clone(&owner)));
451 until(|| gate.follower.load(super::Ordering::Acquire)).await;
452
453 for _ in 0..3 {
454 SessionState::save_shared_background(Arc::clone(&owner))
455 .await
456 .expect("a covered request is no failure");
457 }
458 assert!(!follower.is_finished(), "the follower waits for the lane");
459 assert!(!saved.exists(), "nobody saved behind the save in flight");
460 assert!(!owner.read().await.last_save_failed);
461
462 owner.write().await.increment();
464 drop(in_flight);
465 follower.await.expect("follower").expect("follower saves");
466
467 assert!(saved.is_file());
468 let live = owner.read().await;
469 assert_eq!(live.stats.unsaved_changes, 0, "both changes acknowledged");
470 assert!(!gate.follower.load(super::Ordering::Acquire));
471 });
472 }
473
474 #[test]
479 fn the_shutdown_save_reports_whether_the_last_state_is_durable() {
480 let (_held_still, owner, saved) = unsaved_owner("save-lane-shutdown");
481 drive(async {
482 let gate = owner.read().await.save_gate.clone();
483 let in_flight = gate.lane.lock().await;
484
485 let short = std::time::Duration::from_millis(50);
486 assert!(
487 !SessionState::save_last_state(&owner, short).await,
488 "a lane that stays taken is not a durable last state"
489 );
490 assert!(owner.read().await.last_save_failed);
491 assert!(!saved.exists());
492
493 let follower = tokio::spawn(SessionState::save_shared_background(Arc::clone(&owner)));
494 until(|| gate.follower.load(super::Ordering::Acquire)).await;
495 let shutdown = tokio::spawn(SessionState::save_shared_at_shutdown(Arc::clone(&owner)));
496 until(|| gate.closing.load(super::Ordering::Acquire)).await;
497 drop(in_flight);
498
499 follower
500 .await
501 .expect("follower")
502 .expect("yielding is no failure");
503 assert!(
504 shutdown.await.expect("shutdown save"),
505 "the last state is durable"
506 );
507 assert!(saved.is_file());
508 let live = owner.read().await;
509 assert_eq!(live.stats.unsaved_changes, 0);
510 assert!(!live.last_save_failed);
511 assert!(
512 !gate.closing.load(super::Ordering::Acquire),
513 "background saves resume"
514 );
515 });
516 }
517}