Skip to main content

lean_ctx/core/session/
save_outcome.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Commit acknowledgement without replacing edits made during background I/O.
3
4use 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);
15/// How long the shutdown save WAITS FOR THE LANE — not a limit on the save.
16/// A background save in flight when the connection closes holds the lane for
17/// one projection of the session. Once the shutdown save has the lane it
18/// runs to its end: it is the thing to finish, and a timeout could not stop
19/// its blocking work anyway.
20const SHUTDOWN_SAVE_LANE_WAIT: Duration = Duration::from_secs(15);
21/// How long the ONE background save that follows a save in flight waits for
22/// the lane. Bounded like every wait here; there is at most one such waiter
23/// per session, so the bound can cover a slow save instead of giving up
24/// behind it call after call.
25const FOLLOWING_SAVE_LANE_WAIT: Duration = Duration::from_mins(1);
26
27/// The save lane of one live session, shared by its clones.
28///
29/// `lane` is held from the snapshot through the acknowledgement of a save.
30/// Background saves are requested after tool calls and at checkpoints; while
31/// one is in flight, exactly ONE further request waits behind it (`follower`)
32/// and every other request returns at once — the follower takes its snapshot
33/// when it gets the lane, so it saves whatever is current then, and a third
34/// request has nothing to add. `closing` is set by the shutdown save: a
35/// follower that gets the lane afterwards yields it unused, because the
36/// shutdown save is about to store the last state itself.
37#[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    /// `Err` leaves the pending changes pending: admission refused the
51    /// session, the admitting authority changed before the commit, or the
52    /// commit itself failed.
53    pub(super) result: Result<(), SaveError>,
54}
55
56pub struct SharedSaveReceipt {
57    pub id: String,
58    pub version: u32,
59    /// True only when the owner still has the exact version saved by this call.
60    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            // Includes a primary commit followed by a pointer-write failure.
96            // Update only the binding; pending task/findings/etc remain untouched.
97            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    /// Flush the shared live owner without holding its state lock during disk I/O.
110    /// The per-owner lane covers preparation through acknowledgement, not state.
111    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    /// [`Self::save_shared`] with the wait for the save lane given by the
116    /// caller. The wait stays bounded; only the shutdown save takes a longer
117    /// one, because it is the last chance to make the current state durable.
118    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    /// A save requested in the background (after a tool call, at a
135    /// checkpoint, at idle). See [`SaveGate`]: one save in flight, one
136    /// behind it, the rest return — their state is covered by the follower's
137    /// later snapshot. A follower whose state was already acknowledged by
138    /// the save it waited behind does nothing.
139    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            // Cleared BEFORE the snapshot below: a request that arrives from
152            // here on is not covered by it and becomes the next follower.
153            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    /// The save itself. The caller holds `gate`'s lane for the whole call.
179    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(()); // The scheduled owner was replaced, not the new session.
184            }
185            (session.id.clone(), session.version, session.clone())
186        };
187        // `prepare_save` only detaches the copy (and keeps this task's request
188        // project with it). Storage admission, serialization, the authority
189        // re-check and the disk I/O all run on the blocking pool, outside the
190        // live session lock and off the async executor. The save lane still
191        // spans prepare, admission, disk I/O and ack.
192        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    /// Synchronous MCP adapter with the same save lane, detached projection,
220    /// version check, and owner acknowledgement as save_shared.
221    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    /// The save of a server that is ending: the same save, behind the same
301    /// lane. Background followers are told to yield (`SaveGate`); a save
302    /// already in flight is waited for up to `SHUTDOWN_SAVE_LANE_WAIT`.
303    ///
304    /// `true`: the owner's current version is acknowledged as durable.
305    /// `false`: it is not, and the log says why. A save that failed (lane not
306    /// free in time, admission refused, authority changed, commit failed) is
307    /// not repeated blindly — its changes stay pending as after any failed
308    /// save, and there is no later save in this process to pick them up. A
309    /// session that CHANGED while it was being saved is saved once more.
310    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        // A server object can outlive this call (a connection of a shared
315        // server dropped): background saves work again afterwards.
316        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    /// A live session with one unsaved change in its own data directory,
393    /// under no project policy (environment first, policy second), and the
394    /// file its save writes. The guards stay on the test thread, outside the
395    /// async block the test drives.
396    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    /// While a save holds the lane, ONE background request waits behind it
440    /// and every further one returns at once without saving and without
441    /// marking a failure; the follower then saves the state that is current
442    /// when it gets the lane. Before, each request waited five seconds,
443    /// failed and marked the session — one per tool call.
444    #[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            // A change made while the follower waits is part of what it saves.
463            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    /// The shutdown save: a background follower yields the lane unused, the
475    /// last state is stored and reported durable, and background saves work
476    /// again afterwards. A lane that does not come free within the wait is
477    /// reported as NOT durable — never as saved.
478    #[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}