Skip to main content

agent_engine/daemon/
reload.rs

1//! `synaps daemon reload` (PLAN-phase3 §2.8, C3): version gate → drain →
2//! `Checkpoint{Reload}` every session → reload-state → announce
3//! `Reloading`/`Bye{Reloading}` → `execv` self (same pid, adopted flock,
4//! CLOEXEC listener) → new image rehydrates sessions from reload-state
5//! BEFORE accepting.
6//!
7//! What reload cannot preserve (§2.8, stated once): in-flight turns
8//! (checkpointed = cancelled; partial history + restart marker kept), pending prompts (answered
9//! `None`), PTY/background shells (closed, announced), `turn_replay`,
10//! input ownership (the owner reclaims it on
11//! reconnect via `was_owner`). What it preserves — each session's
12//! `Checkpoint{Reload}` reply carries a `SessionReloadRecord`: its journal
13//! and id (the rehydrated session continues the same journal), its
14//! `SessionConfig` as created (cwd, system, prompt manifest, compaction
15//! policy, auto-compact, …), its keep-warm pin, its lifecycle (a Parked
16//! session comes back Parked), the non-persisted runtime knobs
17//! (`settings_replay`: context window, compaction model, retries,
18//! timeouts, tool-output cap, worker grants, `/system`), the CURRENT
19//! model/thinking (`/model` mid-session survives), and the session's exact
20//! tool activations (pins only, re-validated by the new runtime at its first
21//! turn start — `SessionToolSet::restore_carried`).
22
23use std::path::{Path, PathBuf};
24use std::sync::atomic::Ordering;
25use std::sync::Arc;
26use std::time::Duration;
27
28use serde::{Deserialize, Serialize};
29
30use super::registry::{self, DaemonLock, DaemonPaths};
31use super::DaemonState;
32use crate::session::wire::{ClientFrame, CHECKPOINT_QUERY_ID, PROTOCOL_VERSION};
33use crate::session::{
34    CheckpointReason, SessionCommand, SessionConfig, SessionEventWire, SessionHandle,
35    SessionLifecycle, SessionReloadRecord, SessionSetting,
36};
37
38/// Env: path of the reload-state file handed to the new image.
39pub const RELOAD_STATE_ENV: &str = "SYNAPS_DAEMON_RELOAD_STATE";
40/// Env: inherited `daemon.lock` fd (mandatory when `RELOAD_STATE_ENV` is set).
41pub const LOCK_FD_ENV: &str = "SYNAPS_DAEMON_LOCK_FD";
42/// Env: the subagent handle counter read at the `execv` itself, later than
43/// `ReloadState::next_subagent_id` (written after the checkpoints): a worker
44/// that ignored its stop could still dispatch in between.
45pub const SUBAGENT_ID_FLOOR_ENV: &str = "SYNAPS_DAEMON_SUBAGENT_ID_FLOOR";
46/// Env: default drain budget in seconds.
47pub const DRAIN_SECS_ENV: &str = "SYNAPS_DAEMON_RELOAD_DRAIN_SECS";
48const DEFAULT_DRAIN: Duration = Duration::from_secs(30);
49const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
50/// Per-session `Checkpoint{Reload}` wait: `cancel_turn` (the drain + its
51/// bounded save) + stopping the session's workers + the checkpoint's own
52/// bounded save + 1 s margin.
53const CHECKPOINT_BUDGET: Duration =
54    Duration::from_secs(crate::session::budgets::RELOAD_CHECKPOINT_BUDGET_SECS);
55/// What clients are told to wait before reconnecting.
56pub const RETRY_AFTER_MS: u64 = 500;
57
58#[derive(Debug, Clone, Default)]
59pub struct ReloadRequest {
60    pub now: bool,
61    pub drain_secs: Option<u64>,
62    pub exe: Option<PathBuf>,
63}
64
65impl ReloadRequest {
66    pub fn from_frame(frame: &ClientFrame) -> Option<Self> {
67        match frame {
68            ClientFrame::Reload { now, drain_secs, exe } => {
69                Some(Self { now: *now, drain_secs: *drain_secs, exe: exe.clone() })
70            }
71            _ => None,
72        }
73    }
74}
75
76#[derive(Debug)]
77pub enum ReloadError {
78    /// Refused before anything was disturbed (`RefuseReason::ReloadRefused`).
79    Refused(String),
80    /// `execv` returned: the old image continues; sessions are checkpointed.
81    ExecFailed(String),
82}
83
84impl std::fmt::Display for ReloadError {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        match self {
87            Self::Refused(w) => write!(f, "reload refused: {w}"),
88            Self::ExecFailed(e) => write!(f, "reload exec failed: {e}"),
89        }
90    }
91}
92
93/// `<exe> daemon --print-version` output.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct PrintVersion {
96    pub binary_version: String,
97    pub protocol_version: u32,
98}
99
100impl PrintVersion {
101    pub fn current() -> Self {
102        Self {
103            binary_version: crate::session::wire::binary_version(),
104            protocol_version: PROTOCOL_VERSION,
105        }
106    }
107}
108
109/// One recorded session.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ReloadSession {
112    pub id: String,
113    pub journal_id: String,
114    pub config: SessionConfig,
115    pub keep_warm: bool,
116    pub lifecycle: SessionLifecycle,
117    /// Non-persisted knobs re-`Set` after create (incl. `/system`).
118    #[serde(default)]
119    pub settings_replay: Vec<SessionSetting>,
120    /// Per-session `/keepwarm` override (task #444), re-applied after create.
121    #[serde(default)]
122    pub cache_keepwarm: Option<String>,
123    pub input_owner_kind: Option<crate::session::ClientKind>,
124    /// Workers the checkpoint stopped (or that still owed the parent a
125    /// completion wake), handed to the new image (`AdoptWorkers`).
126    #[serde(default)]
127    pub workers: Vec<crate::runtime::subagent::CarriedWorker>,
128    /// The session's exact tool activations, restored into the new image's
129    /// runtime (re-validated there; see `SessionToolSet::restore_carried`).
130    /// Empty from an older image or a checkpoint that did not confirm.
131    #[serde(default)]
132    pub tool_activations: Vec<crate::tools::activation::CarriedActivation>,
133}
134
135/// `registry_dir()/daemon[-P].reload.json` body.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ReloadState {
138    pub generation: u64,
139    pub written_at: chrono::DateTime<chrono::Utc>,
140    pub sessions: Vec<ReloadSession>,
141    pub expected_clients: usize,
142    /// The subagent handle counter (`sa_N`) at the exec. The new image never
143    /// hands out a lower id, so a model's remembered handles keep meaning
144    /// the worker they named. `0` (older reload-state): derived from the
145    /// carried workers only.
146    #[serde(default)]
147    pub next_subagent_id: u64,
148}
149
150impl ReloadState {
151    /// The first handle number the new image may use: past the old image's
152    /// counter and every carried worker id.
153    pub fn subagent_id_floor(&self) -> u64 {
154        let carried = self
155            .sessions
156            .iter()
157            .flat_map(|s| &s.workers)
158            .filter_map(|w| crate::runtime::subagent::handle_number(&w.id))
159            .max()
160            .map_or(0, |n| n + 1);
161        self.next_subagent_id.max(carried)
162    }
163}
164
165pub fn reload_state_path(paths: &DaemonPaths) -> PathBuf {
166    let stem = paths
167        .json
168        .file_stem()
169        .map(|s| s.to_string_lossy().into_owned())
170        .unwrap_or_else(|| "daemon".into());
171    paths.dir.join(format!("{stem}.reload.json"))
172}
173
174/// The executable to re-exec: argv[0] canonicalised (NOT `/proc/self/exe`,
175/// which reads "(deleted)" after an in-place rebuild); `current_exe` only
176/// as a fallback when argv[0] is not resolvable (e.g. bare name on PATH).
177pub fn resolve_exe() -> PathBuf {
178    if let Some(a0) = std::env::args_os().next() {
179        let p = PathBuf::from(&a0);
180        if p.components().count() > 1 {
181            if let Ok(c) = p.canonicalize() {
182                return c;
183            }
184        }
185    }
186    std::env::current_exe().unwrap_or_else(|_| PathBuf::from("synaps"))
187}
188
189fn semver_tuple(s: &str) -> Option<(u64, u64, u64)> {
190    let core = s.split(['-', '+']).next()?;
191    let mut it = core.split('.').map(|p| p.parse::<u64>().ok());
192    Some((it.next()??, it.next()??, it.next()??))
193}
194
195/// Directional gate (§2.1): new protocol ≥ ours (clients must still be able
196/// to reconnect) and new binary ≥ ours (newer OR EQUAL; same-version
197/// rebuilds are the common case).
198pub fn version_gate(current: &PrintVersion, new: &PrintVersion) -> Result<(), String> {
199    if new.protocol_version < current.protocol_version {
200        return Err(format!(
201            "new binary speaks protocol {} < {}: reconnecting clients would be refused",
202            new.protocol_version, current.protocol_version
203        ));
204    }
205    match (semver_tuple(&current.binary_version), semver_tuple(&new.binary_version)) {
206        (Some(c), Some(n)) if n < c => Err(format!(
207            "new binary {} is older than the running {} (reload is newer-or-equal only)",
208            new.binary_version, current.binary_version
209        )),
210        (Some(_), Some(_)) => Ok(()),
211        _ => Err(format!(
212            "cannot compare versions {:?} vs {:?}",
213            current.binary_version, new.binary_version
214        )),
215    }
216}
217
218/// M8: `--exe` must be something we would trust to `execv` in our own pid:
219/// an existing regular file (canonicalised), executable, owned by our uid
220/// (or root — package-installed binaries), not group/world-writable.
221/// Returns the canonical path. Same-uid 0600 socket → not a privilege
222/// boundary; this is a footgun guard, not a sandbox.
223#[cfg(unix)]
224pub fn validate_exe(exe: &Path) -> Result<PathBuf, String> {
225    use std::os::unix::fs::{MetadataExt, PermissionsExt};
226    let canon = exe
227        .canonicalize()
228        .map_err(|e| format!("--exe {}: {e}", exe.display()))?;
229    let md = std::fs::metadata(&canon).map_err(|e| format!("--exe {}: {e}", canon.display()))?;
230    if !md.is_file() {
231        return Err(format!("--exe {}: not a regular file", canon.display()));
232    }
233    let mode = md.permissions().mode();
234    if mode & 0o111 == 0 {
235        return Err(format!("--exe {}: not executable", canon.display()));
236    }
237    // SAFETY: getuid has no preconditions.
238    let uid = unsafe { libc::getuid() };
239    if md.uid() != uid && md.uid() != 0 {
240        return Err(format!(
241            "--exe {}: owned by uid {} (expected {} or root)",
242            canon.display(),
243            md.uid(),
244            uid
245        ));
246    }
247    if mode & 0o022 != 0 {
248        return Err(format!(
249            "--exe {}: group/world-writable (mode {:o})",
250            canon.display(),
251            mode & 0o777
252        ));
253    }
254    Ok(canon)
255}
256
257#[cfg(not(unix))]
258pub fn validate_exe(exe: &Path) -> Result<PathBuf, String> {
259    exe.canonicalize().map_err(|e| format!("--exe {}: {e}", exe.display()))
260}
261
262/// Run `<exe> daemon --print-version` (5 s) and parse it.
263pub async fn probe_version(exe: &Path) -> Result<PrintVersion, String> {
264    let out = tokio::time::timeout(
265        VERSION_PROBE_TIMEOUT,
266        tokio::process::Command::new(exe)
267            .args(["daemon", "--print-version"])
268            .env("SYNAPS_DAEMON", "1")
269            .stdin(std::process::Stdio::null())
270            .stderr(std::process::Stdio::piped())
271            .stdout(std::process::Stdio::piped())
272            .output(),
273    )
274    .await
275    .map_err(|_| format!("{} --print-version timed out", exe.display()))?
276    .map_err(|e| format!("cannot run {}: {e}", exe.display()))?;
277    if !out.status.success() {
278        return Err(format!(
279            "{} --print-version exited {:?}: {}",
280            exe.display(),
281            out.status.code(),
282            String::from_utf8_lossy(&out.stderr).trim()
283        ));
284    }
285    serde_json::from_slice(&out.stdout)
286        .map_err(|e| format!("{} --print-version: unparsable output ({e})", exe.display()))
287}
288
289/// `Checkpoint{Reload}` → the actor's `SessionReloadRecord` (`None` when
290/// the checkpoint did not confirm in budget).
291async fn checkpoint_one(handle: SessionHandle) -> Option<SessionReloadRecord> {
292    let mut rx = handle.subscribe();
293    if handle
294        .send(SessionCommand::Checkpoint { reason: CheckpointReason::Reload })
295        .await
296        .is_err()
297    {
298        return None;
299    }
300    let wait = async {
301        loop {
302            match rx.recv().await {
303                Ok(env) => {
304                    if let SessionEventWire::QueryResult { id, value } = env.event {
305                        if id == CHECKPOINT_QUERY_ID {
306                            return serde_json::from_value(value["record"].clone()).ok();
307                        }
308                    }
309                }
310                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
311                Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
312            }
313        }
314    };
315    tokio::time::timeout(CHECKPOINT_BUDGET, wait).await.unwrap_or(None)
316}
317
318/// The session as it IS: the actor's record when the checkpoint confirmed,
319/// else what the handle alone knows (journal, cwd, keep-warm, lifecycle,
320/// current model from the published view, the `/keepwarm` override).
321fn record(handle: &SessionHandle, rec: Option<SessionReloadRecord>) -> ReloadSession {
322    let meta = handle.meta();
323    let journal_id = handle.journal_id();
324    if rec.is_none() {
325        tracing::warn!(
326            session = %handle.id,
327            "reload: no checkpoint reply in time — this session's workers are not carried: \
328             no archive is guaranteed and their parent will not be woken"
329        );
330    }
331    let (config, keep_warm, lifecycle, settings_replay, cache_keepwarm, model, workers, tool_activations) = match rec {
332        Some(r) => (
333            r.config,
334            r.keep_warm,
335            r.lifecycle,
336            r.settings_replay,
337            r.cache_keepwarm,
338            r.model,
339            r.workers,
340            r.tool_activations,
341        ),
342        None => (
343            SessionConfig {
344                cwd: meta.cwd.clone(),
345                // Like `/keepwarm`: the user's `/memory` choice survives a
346                // reload whose checkpoint did not confirm.
347                memory_mode: handle.memory_mode(),
348                env: None,
349            env_stripped: Vec::new(),
350                ..SessionConfig::default()
351            },
352            // The pin is on the handle too, so it survives a reload whose
353            // checkpoint did not confirm.
354            handle.pinned(),
355            handle.lifecycle(),
356            Vec::new(),
357            // Persisted independently of the checkpoint: an explicit
358            // `/keepwarm` choice must survive a reload that timed out.
359            handle.cache_keepwarm_override(),
360            handle.view().model.clone(),
361            // No checkpoint reply: whatever workers ran are lost to the exec
362            // without a record (the actor never answered).
363            Vec::new(),
364            // Nor are its tool activations known.
365            Vec::new(),
366        ),
367    };
368    ReloadSession {
369        id: meta.id.as_str().to_string(),
370        journal_id: journal_id.clone(),
371        config: SessionConfig {
372            continue_session: Some(Some(journal_id)),
373            model_override: Some(model),
374            keep_warm,
375            // The journal carries the CURRENT name (saveas after create);
376            // re-applying the create-time `--name` would undo a rename.
377            name: None,
378            ..config
379        },
380        keep_warm,
381        lifecycle,
382        settings_replay,
383        cache_keepwarm,
384        input_owner_kind: None,
385        workers,
386        tool_activations,
387    }
388}
389
390/// Steps 1–4 (§2.8): gate, drain, checkpoint, reload-state, announce.
391/// Nothing irreversible has happened when this returns `Ok`; the caller
392/// (the requesting connection) sends its own `Bye{Reloading}`, flushes,
393/// then calls [`exec`].
394pub struct Prepared {
395    pub exe: PathBuf,
396    pub rs_path: PathBuf,
397    pub generation: u64,
398}
399
400pub async fn prepare(
401    state: &Arc<DaemonState>,
402    paths: &DaemonPaths,
403    req: ReloadRequest,
404) -> Result<Prepared, ReloadError> {
405    // 1. version gate — before anything is disturbed.
406    // The recorded exe from this image's own record (not daemon.json, which
407    // can be lost while the daemon runs).
408    let exe = req.exe.clone().unwrap_or_else(|| state.daemon_info().exe.unwrap_or_else(resolve_exe));
409    let exe = validate_exe(&exe).map_err(ReloadError::Refused)?;
410    let new = probe_version(&exe).await.map_err(ReloadError::Refused)?;
411    version_gate(&PrintVersion::current(), &new).map_err(ReloadError::Refused)?;
412    if state.reloading.swap(true, Ordering::SeqCst) {
413        return Err(ReloadError::Refused("a reload is already in progress".into()));
414    }
415    let generation = state.generation + 1;
416    tracing::info!(exe = %exe.display(), new = %new.binary_version, generation, "daemon: reload accepted");
417
418    // 2. drain (refuse new work), then checkpoint every session concurrently.
419    let drain = if req.now {
420        Duration::ZERO
421    } else {
422        req.drain_secs
423            .map(Duration::from_secs)
424            .or_else(|| std::env::var(DRAIN_SECS_ENV).ok()?.parse().ok().map(Duration::from_secs))
425            .unwrap_or(DEFAULT_DRAIN)
426    };
427    let t0 = std::time::Instant::now();
428    while t0.elapsed() < drain {
429        let mut all_idle = true;
430        for h in state.live_sessions() {
431            if !super::session_is_idle(&h).await {
432                all_idle = false;
433                break;
434            }
435        }
436        if all_idle {
437            break;
438        }
439        tokio::time::sleep(Duration::from_millis(200)).await;
440    }
441    let handles = state.live_sessions();
442    let results = futures::future::join_all(handles.iter().cloned().map(checkpoint_one)).await;
443    let mut sessions = Vec::with_capacity(handles.len());
444    for (h, rec) in handles.iter().zip(results) {
445        if rec.is_none() {
446            tracing::warn!(session = %h.id, "reload: checkpoint did not confirm; recording from the handle");
447        }
448        sessions.push(record(h, rec));
449    }
450
451    // 3. reload-state (0600, atomic).
452    let rs = ReloadState {
453        generation,
454        written_at: chrono::Utc::now(),
455        sessions,
456        expected_clients: state.connections.load(Ordering::SeqCst),
457        // After every checkpoint: no session can launch a worker any more.
458        next_subagent_id: crate::tools::peek_next_subagent_id(),
459    };
460    let rs_path = reload_state_path(paths);
461    // If either write fails we early-return WITHOUT reaching exec() (which is the
462    // only other place `reloading` is cleared), so reset the drain flag here —
463    // otherwise a failed reload-state write bricks the daemon (it refuses every
464    // new Create/Submit/Compact until restart).
465    let written = serde_json::to_vec_pretty(&rs)
466        .map_err(|e| e.to_string())
467        .and_then(|body| registry::write_private_atomic(&rs_path, &body).map_err(|e| e.to_string()));
468    if let Err(e) = written {
469        // No exec will follow: every checkpointed session lifts its fence
470        // and hears about the workers its checkpoint stopped.
471        abort_checkpoints(state).await;
472        state.reloading.store(false, Ordering::SeqCst);
473        return Err(ReloadError::ExecFailed(e));
474    }
475
476    // 4. announce — every OTHER conn selects on `reload_announce` and sends
477    //    Event(Reloading) + Bye{Reloading}; give their writers ≤ 1 s.
478    state.reload_generation.store(generation, Ordering::SeqCst);
479    state.fire_announce();
480    let t1 = std::time::Instant::now();
481    while state.connections.load(Ordering::SeqCst) > 1 && t1.elapsed() < Duration::from_secs(crate::session::budgets::RELOAD_ANNOUNCE_WAIT_SECS) {
482        tokio::time::sleep(Duration::from_millis(20)).await;
483    }
484    Ok(Prepared { exe, rs_path, generation })
485}
486
487/// Step 5: rewrite `daemon.json` (generation+1, same pid), stop sidecars,
488/// hand the flock to the next image and `execv`. Returns only on failure —
489/// then `daemon.json`/reload-state are restored, extension discovery is
490/// re-spawned (the sidecars were stopped for the exec — M4: no zombie
491/// daemon without hooks), and the old image keeps serving (sessions
492/// checkpointed but alive; clients that got `Bye` reconnect to THIS image
493/// — a `reconnect_of.generation` mismatch is tolerated, the daemon answers
494/// with its own generation).
495pub async fn exec(state: &Arc<DaemonState>, p: Prepared) -> ReloadError {
496    // From this image's own record, not daemon.json (which can be lost while
497    // the daemon runs). The record is held and written under the metadata
498    // lock, so a heal in the hand-off window writes the same.
499    // `previous` stays untouched: a failed exec puts it back whole.
500    let previous = state.daemon_info();
501    let prev_generation = previous.generation;
502    let info = registry::DaemonInfo {
503        generation: p.generation,
504        started_at: chrono::Utc::now(),
505        exe: Some(p.exe.clone()),
506        ..previous.clone()
507    };
508    if let Err(e) = state.meta.publish(info) {
509        tracing::warn!(path = %state.paths.json.display(), error = %e, "daemon: daemon.json write failed at reload");
510    }
511    let _ = tokio::time::timeout(
512        Duration::from_secs(crate::session::budgets::RELOAD_EXT_SHUTDOWN_SECS),
513        async { state.host.ext_manager().write().await.shutdown_all().await },
514    )
515    .await;
516
517    let err = exec_self(&p.exe, &p.rs_path, state);
518    tracing::error!(error = %err, "daemon: reload exec failed; continuing on the old image");
519    // The record before this attempt, whole: exe and started_at too, so the
520    // next default reload uses the old exe, not the one that failed.
521    if let Err(e) = state.meta.publish(previous) {
522        tracing::warn!(
523            path = %state.paths.json.display(),
524            error = %e,
525            "daemon: daemon.json write failed after the failed reload exec"
526        );
527    }
528    let _ = std::fs::remove_file(&p.rs_path);
529    state.reload_generation.store(prev_generation, Ordering::SeqCst);
530    state.reset_announce();
531    // Sidecars were shut down for the exec: bring them back (bounded like
532    // `run_foreground`'s first discovery) so the surviving image has hooks.
533    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
534    let _loader = crate::extensions::loader::spawn_discover_and_load(
535        Arc::clone(state.host.ext_manager()),
536        tx,
537        None,
538    );
539    let wait = async {
540        while let Some(ev) = rx.recv().await {
541            if let crate::extensions::loader::ExtensionLoaderEvent::Finished { loaded, failed } = ev {
542                tracing::info!(loaded = loaded.len(), failed = failed.len(), "daemon: extensions re-discovered after failed reload");
543                break;
544            }
545        }
546    };
547    if tokio::time::timeout(
548        Duration::from_secs(crate::session::budgets::RELOAD_EXT_REDISCOVERY_SECS),
549        wait,
550    )
551    .await
552    .is_err()
553    {
554        tracing::warn!("daemon: extension re-discovery still running; resuming the sessions");
555    }
556    // The checkpoint fenced every session and stopped its workers for an
557    // exec that did not happen: their parents are still here and still
558    // waiting — lift the fences and tell them.
559    abort_checkpoints(state).await;
560    state.reloading.store(false, Ordering::SeqCst);
561    ReloadError::ExecFailed(err)
562}
563
564#[cfg(unix)]
565fn exec_self(exe: &Path, rs_path: &Path, state: &DaemonState) -> String {
566    use std::os::unix::process::CommandExt;
567    let Some(lock_fd) = state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|l| l.raw_fd()) else {
568        return "no lock fd to inherit".into();
569    };
570    // Clear FD_CLOEXEC on the lock so the new image adopts the SAME flock
571    // (liveness oracle never flickers). The listener keeps CLOEXEC: it is
572    // closed at exec and the new image rebinds the path.
573    // SAFETY: fcntl on an fd we own.
574    unsafe {
575        let flags = libc::fcntl(lock_fd, libc::F_GETFD);
576        if flags >= 0 {
577            libc::fcntl(lock_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
578        }
579    }
580    let mut args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
581    if args.is_empty() || args.iter().all(|a| a != "daemon") {
582        args = vec!["daemon".into(), "--foreground".into()];
583    }
584    let mut cmd = std::process::Command::new(exe);
585    cmd.args(&args)
586        .env("SYNAPS_DAEMON", "1")
587        .env(LOCK_FD_ENV, lock_fd.to_string())
588        .env(RELOAD_STATE_ENV, rs_path)
589        .env(SUBAGENT_ID_FLOOR_ENV, crate::tools::peek_next_subagent_id().to_string());
590    let e = cmd.exec();
591    // Only reached on failure: restore CLOEXEC.
592    // SAFETY: as above.
593    unsafe {
594        let flags = libc::fcntl(lock_fd, libc::F_GETFD);
595        if flags >= 0 {
596            libc::fcntl(lock_fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
597        }
598    }
599    e.to_string()
600}
601
602#[cfg(not(unix))]
603fn exec_self(_exe: &Path, _rs_path: &Path, _state: &DaemonState) -> String {
604    "reload is unix-only".into()
605}
606
607/// New-image side (`Daemon::start`): when `RELOAD_STATE_ENV` is set, the
608/// lock is ADOPTED from `LOCK_FD_ENV` (mandatory — refuse to start without
609/// it, risk §6.8) and the recorded generation is returned.
610pub fn adopt_from_env() -> anyhow::Result<Option<(DaemonLock, ReloadState, PathBuf)>> {
611    let Some(rs_path) = std::env::var_os(RELOAD_STATE_ENV) else {
612        return Ok(None);
613    };
614    let rs_path = PathBuf::from(rs_path);
615    std::env::remove_var(RELOAD_STATE_ENV);
616    let fd: i32 = std::env::var(LOCK_FD_ENV)
617        .ok()
618        .and_then(|s| s.parse().ok())
619        .ok_or_else(|| anyhow::anyhow!("{RELOAD_STATE_ENV} set without {LOCK_FD_ENV}: refusing to start (the flock is the liveness oracle)"))?;
620    std::env::remove_var(LOCK_FD_ENV);
621    let id_floor = std::env::var(SUBAGENT_ID_FLOOR_ENV).ok().and_then(|s| s.parse::<u64>().ok());
622    std::env::remove_var(SUBAGENT_ID_FLOOR_ENV);
623    let lock = DaemonLock::adopt(fd)?;
624    let body = std::fs::read_to_string(&rs_path)?;
625    let mut rs: ReloadState = serde_json::from_str(&body)?;
626    if let Some(floor) = id_floor {
627        rs.next_subagent_id = rs.next_subagent_id.max(floor);
628    }
629    Ok(Some((lock, rs, rs_path)))
630}
631
632/// Rehydrate every recorded session BEFORE accepting. A session that
633/// fails to come back is logged and skipped (its journal is on disk;
634/// `--continue` brings it back).
635///
636/// A session whose journal was never written (no turn yet — `save` skips
637/// an empty conversation) cannot be continued; it is recreated fresh under
638/// a new id and aliased from the old one.
639/// F23: detect "is live in another process" in the error string — this is
640/// the `SessionLockError::Held` case, where an in-process runtime holds
641/// the journal lock. Instead of aliasing to an empty impostor, register a
642/// Parked placeholder under the SAME id.
643pub(crate) fn is_lock_held_error(e: &str) -> bool {
644    e.contains("is live in another process")
645}
646
647/// Extract `"pid N (kind)"` from the lock-held error message.
648pub(crate) fn parse_holder_from_error(e: &str) -> String {
649    // "session X is live in another process (pid N, kind) — …"
650    if let Some(start) = e.find("(pid ") {
651        let after = &e[start + 1..]; // "pid N, kind) — …"
652        if let Some(end) = after.find(')') {
653            return after[..end].to_string();
654        }
655    }
656    "unknown holder".to_string()
657}
658
659/// No exec after the checkpoints (it failed, or the reload stopped before
660/// it): every live session lifts its reload fence and is handed back the
661/// workers its checkpoint stopped (`ReloadAborted`, a no-op for a session
662/// that never checkpointed). The actor re-announces from its own record,
663/// so a checkpoint that answered too late for the reload record is covered.
664async fn abort_checkpoints(state: &Arc<DaemonState>) {
665    let budget = Duration::from_secs(crate::session::budgets::RELOAD_ABORT_SEND_SECS);
666    let sends = state
667        .live_sessions()
668        .into_iter()
669        .map(|h| async move { send_reload_aborted(&h, budget).await });
670    futures::future::join_all(sends).await;
671}
672
673/// `ReloadAborted` must arrive: dropped, the session would wait out the
674/// fence's backstop. `SessionHandle::send` never waits for queue room, so
675/// retry a full queue (the actor drains it) for up to `budget`.
676pub(crate) async fn send_reload_aborted(h: &SessionHandle, budget: Duration) -> bool {
677    use crate::session::TransportError;
678    let deadline = tokio::time::Instant::now() + budget;
679    loop {
680        match h.send(SessionCommand::ReloadAborted).await {
681            Ok(()) => return true,
682            Err(TransportError::Backpressure) if tokio::time::Instant::now() < deadline => {
683                tokio::time::sleep(Duration::from_millis(20)).await;
684            }
685            Err(e) => {
686                tracing::warn!(
687                    session = %h.id,
688                    error = %e,
689                    "reload: could not tell the session the exec failed; its reload fence lifts at the backstop"
690                );
691                return false;
692            }
693        }
694    }
695}
696
697/// The config a session is restored with: as saved, except that the
698/// restore never waits on `on_session_start`. Each restored session runs
699/// the hook in the background (the deferred path quick-start sessions
700/// use), so the restore of N sessions does not take N hook round trips
701/// while the socket waits. A session restored Parked runs it only once it
702/// is unparked.
703fn restore_config(saved: &SessionConfig, lifecycle: SessionLifecycle) -> SessionConfig {
704    SessionConfig {
705        await_extensions: false,
706        restore_parked: matches!(lifecycle, SessionLifecycle::Parked | SessionLifecycle::Parking),
707        ..saved.clone()
708    }
709}
710
711pub async fn rehydrate(state: &Arc<DaemonState>, rs: &ReloadState) {
712    // Before any session can dispatch: ids continue where the old image
713    // stopped, so `sa_1` never names two different workers.
714    crate::tools::seed_next_subagent_id(rs.subagent_id_floor());
715    for s in &rs.sessions {
716        let config = restore_config(&s.config, s.lifecycle);
717        let created = match state.create(config.clone()).await {
718            Ok(h) => Ok(h),
719            // F23: journal locked by another process — register a Parked
720            // placeholder under the SAME id. No alias, no fresh session.
721            Err(e) if is_lock_held_error(&e) => {
722                let holder = parse_holder_from_error(&e);
723                tracing::warn!(
724                    session = %s.id, holder = %holder,
725                    "daemon: journal locked by another process; registering Parked placeholder"
726                );
727                let sid = crate::session::SessionId::from(s.id.as_str());
728                let (handle, _task) = crate::session::handle::locked_placeholder::spawn(
729                    sid,
730                    s.config.clone(),
731                    holder,
732                );
733                state.insert(handle);
734                continue;
735            }
736            Err(e) if s.config.continue_session.is_some() => {
737                tracing::warn!(session = %s.id, error = %e, "daemon: journal not continuable; recreating fresh");
738                state
739                    .create(SessionConfig {
740                        continue_session: None,
741                        ..config.clone()
742                    })
743                    .await
744            }
745            Err(e) => Err(e),
746        };
747        match created {
748            Ok(h) => {
749                let new_id = h.id.as_str().to_string();
750                // First, before anything can start a turn: the same
751                // conversation continues in this image, so the grants it
752                // held come with it (re-validated by the new runtime at its
753                // first turn start). A session recreated fresh (journal not
754                // continuable) is a new conversation and inherits none.
755                let continued = h.meta().continued;
756                if continued && !s.tool_activations.is_empty() {
757                    let _ = h
758                        .send(SessionCommand::RestoreToolActivations {
759                            activations: s.tool_activations.clone(),
760                        })
761                        .await;
762                }
763                if new_id != s.id {
764                    state
765                        .reload_aliases
766                        .lock()
767                        .unwrap_or_else(|e| e.into_inner())
768                        .insert(s.id.clone(), new_id.clone());
769                }
770                // Non-persisted knobs (host-originated `Set`: no owner check).
771                for (i, setting) in s.settings_replay.iter().enumerate() {
772                    let _ = h
773                        .send(SessionCommand::Set {
774                            id: u64::MAX - i as u64,
775                            setting: setting.clone(),
776                        })
777                        .await;
778                }
779                if s.keep_warm {
780                    let _ = h.send(SessionCommand::KeepWarm { on: true }).await;
781                }
782                if let Some(mode) = &s.cache_keepwarm {
783                    let _ = h
784                        .send(SessionCommand::EngineCommand {
785                            id: u64::MAX,
786                            name: "keepwarm".into(),
787                            arg: mode.clone(),
788                        })
789                        .await;
790                }
791                // Before any Park: re-register the stopped workers and wake
792                // their parent (it was waiting on completions the exec ate).
793                if !s.workers.is_empty() {
794                    let _ = h
795                        .send(SessionCommand::AdoptWorkers { workers: s.workers.clone() })
796                        .await;
797                }
798                if matches!(s.lifecycle, SessionLifecycle::Parked | SessionLifecycle::Parking) {
799                    let _ = h.send(SessionCommand::Park).await;
800                } else {
801                    // Last, after the pin, keep-warm and workers it weighs:
802                    // the old image's park timer died with the exec, and a
803                    // session nobody reconnects to gets no detach to arm one.
804                    let _ = h.send(SessionCommand::ArmPark).await;
805                }
806                tracing::info!(old = %s.id, new = %new_id, lifecycle = ?s.lifecycle, "daemon: session rehydrated after reload");
807            }
808            Err(e) => tracing::warn!(session = %s.id, error = %e, "daemon: session could not be rehydrated"),
809        }
810    }
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816
817    /// The session's tool activations ride the reload-state file; a file
818    /// from an older image (no field) reads as none.
819    #[test]
820    fn reload_session_carries_tool_activations() {
821        use crate::tools::activation::{CarriedActivation, CarriedProvenance};
822        let carried = vec![CarriedActivation {
823            tool_id: "builtin:subagent_start".into(),
824            schema_digest: "cd".repeat(32),
825            provenance: CarriedProvenance::BuiltinRuntime,
826        }];
827        let s = ReloadSession {
828            id: "s".into(),
829            journal_id: "j".into(),
830            config: SessionConfig::default(),
831            keep_warm: false,
832            lifecycle: SessionLifecycle::Live,
833            settings_replay: Vec::new(),
834            cache_keepwarm: None,
835            input_owner_kind: None,
836            workers: Vec::new(),
837            tool_activations: carried.clone(),
838        };
839        let v = serde_json::to_value(&s).unwrap();
840        let back: ReloadSession = serde_json::from_value(v.clone()).unwrap();
841        assert_eq!(back.tool_activations, carried);
842        // Never via the create config's wire form.
843        assert!(v["config"].get("restore_tool_activations").is_none());
844
845        let mut older = v;
846        older.as_object_mut().unwrap().remove("tool_activations");
847        let back: ReloadSession = serde_json::from_value(older).unwrap();
848        assert!(back.tool_activations.is_empty());
849    }
850
851    /// Reload-state written by an older image (no workers, no counter)
852    /// still parses: the new fields default.
853    #[test]
854    fn reload_state_from_an_older_image_parses() {
855        let rs: ReloadState = serde_json::from_value(serde_json::json!({
856            "generation": 9,
857            "written_at": "2026-10-14T18:17:03Z",
858            "sessions": [],
859            "expected_clients": 1
860        }))
861        .unwrap();
862        assert_eq!(rs.next_subagent_id, 0);
863        assert_eq!(rs.subagent_id_floor(), 0);
864    }
865
866    /// The new image's first handle is past the old counter AND past every
867    /// carried id (an older writer that only carried workers still works).
868    #[test]
869    fn subagent_id_floor_covers_the_counter_and_every_carried_id() {
870        let worker = |id: &str| crate::runtime::subagent::CarriedWorker {
871            id: id.into(),
872            subagent_id: 0,
873            agent: String::new(),
874            status: "cancelled".into(),
875            archive_path: None,
876            stopped_by_reload: true,
877            wake_lost: false,
878            wake: true,
879            elapsed_secs: 0,
880            preview: String::new(),
881        };
882        let session = |workers| ReloadSession {
883            id: "s".into(),
884            journal_id: "j".into(),
885            config: SessionConfig::default(),
886            keep_warm: false,
887            lifecycle: SessionLifecycle::Live,
888            settings_replay: Vec::new(),
889            cache_keepwarm: None,
890            input_owner_kind: None,
891            workers,
892            tool_activations: Vec::new(),
893        };
894        let mut rs = ReloadState {
895            generation: 10,
896            written_at: chrono::Utc::now(),
897            sessions: vec![session(vec![worker("sa_288"), worker("sa_286")]), session(vec![])],
898            expected_clients: 0,
899            next_subagent_id: 0,
900        };
901        assert_eq!(rs.subagent_id_floor(), 289);
902        rs.next_subagent_id = 300;
903        assert_eq!(rs.subagent_id_floor(), 300);
904        // Round trip keeps both.
905        let back: ReloadState = serde_json::from_slice(&serde_json::to_vec(&rs).unwrap()).unwrap();
906        assert_eq!(back.subagent_id_floor(), 300);
907        assert_eq!(back.sessions[0].workers.len(), 2);
908    }
909
910    fn pv(b: &str, p: u32) -> PrintVersion {
911        PrintVersion { binary_version: b.into(), protocol_version: p }
912    }
913
914    #[test]
915    fn gate_is_directional_newer_or_equal() {
916        let cur = pv("0.9.0", 2);
917        assert!(version_gate(&cur, &pv("0.9.0", 2)).is_ok(), "equal allowed");
918        assert!(version_gate(&cur, &pv("0.9.1", 2)).is_ok());
919        assert!(version_gate(&cur, &pv("1.0.0-rc1", 3)).is_ok());
920        assert!(version_gate(&cur, &pv("0.8.9", 2)).is_err(), "older refused");
921        assert!(version_gate(&cur, &pv("0.9.0", 1)).is_err(), "older protocol refused");
922        assert!(version_gate(&cur, &pv("garbage", 2)).is_err());
923        // Two-digit minor (daemon dev builds report 0.9.1 → 0.10.0): compared
924        // numerically, not as strings — "10" < "9" lexically would refuse it.
925        assert!(
926            version_gate(&pv("0.9.1", 3), &pv("0.10.0", 3)).is_ok(),
927            "0.10.0 is newer than 0.9.1"
928        );
929        assert!(
930            version_gate(&pv("0.10.0", 3), &pv("0.9.1", 3)).is_err(),
931            "0.9.1 is older than 0.10.0"
932        );
933    }
934
935    #[cfg(unix)]
936    #[test]
937    fn validate_exe_refuses_missing_dirs_non_exec_and_writable() {
938        use std::os::unix::fs::PermissionsExt;
939        let d = tempfile::tempdir().unwrap();
940        assert!(validate_exe(&d.path().join("nope")).unwrap_err().contains("nope"));
941        assert!(validate_exe(d.path()).unwrap_err().contains("not a regular file"));
942        let f = d.path().join("bin");
943        std::fs::write(&f, "#!/bin/sh\nexit 0\n").unwrap();
944        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).unwrap();
945        assert!(validate_exe(&f).unwrap_err().contains("not executable"));
946        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o777)).unwrap();
947        assert!(validate_exe(&f).unwrap_err().contains("writable"));
948        std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
949        assert_eq!(validate_exe(&f).unwrap(), f.canonicalize().unwrap());
950        // A symlink resolves to its target.
951        let l = d.path().join("link");
952        std::os::unix::fs::symlink(&f, &l).unwrap();
953        assert_eq!(validate_exe(&l).unwrap(), f.canonicalize().unwrap());
954    }
955
956    /// A restored session never waits on `on_session_start` at create; the
957    /// rest of its saved config is kept. One restored Parked defers the hook
958    /// to its unpark, and that flag never goes on the wire.
959    #[test]
960    fn a_restore_does_not_wait_on_the_session_start_hook() {
961        let saved = SessionConfig {
962            model_override: Some("claude-sonnet-4-5".into()),
963            keep_warm: true,
964            await_extensions: true,
965            ..Default::default()
966        };
967        let restored = restore_config(&saved, SessionLifecycle::Live);
968        assert!(!restored.await_extensions);
969        assert!(!restored.restore_parked);
970        let as_saved = SessionConfig {
971            await_extensions: true,
972            ..restored
973        };
974        assert_eq!(
975            serde_json::to_value(&as_saved).unwrap(),
976            serde_json::to_value(&saved).unwrap()
977        );
978        for parked in [SessionLifecycle::Parked, SessionLifecycle::Parking] {
979            let restored = restore_config(&saved, parked);
980            assert!(restored.restore_parked, "{parked:?}");
981            assert!(!restored.await_extensions);
982            let wire = serde_json::to_value(&restored).unwrap();
983            assert!(wire.get("restore_parked").is_none(), "never on the wire");
984        }
985    }
986
987    #[test]
988    fn reload_state_path_follows_profile() {
989        let d = tempfile::tempdir().unwrap();
990        let p = registry::daemon_paths_in(d.path(), Some("work"));
991        assert!(reload_state_path(&p).ends_with("daemon-work.reload.json"), "{:?}", reload_state_path(&p));
992        let p = registry::daemon_paths_in(d.path(), None);
993        assert!(reload_state_path(&p).ends_with("daemon.reload.json"));
994    }
995
996    /// Task #444: a reload whose checkpoint did not confirm (`rec == None`)
997    /// still carries the explicit `/keepwarm` choice, read from the handle,
998    /// instead of resurrecting the config default.
999    #[test]
1000    fn keepwarm_override_survives_an_unconfirmed_checkpoint() {
1001        use crate::core::config::CacheKeepWarm;
1002        use crate::session::handle::echo::{meta_for, view_for};
1003        let id = crate::session::SessionId("kw-reload".into());
1004        let (handle, ep) = SessionHandle::new(meta_for(&id), view_for());
1005        assert_eq!(record(&handle, None).cache_keepwarm, None, "no override yet");
1006        // What the actor writes on `/keepwarm off`.
1007        *ep.cache_keepwarm.lock().unwrap() = Some(CacheKeepWarm::Off);
1008        assert_eq!(record(&handle, None).cache_keepwarm.as_deref(), Some("off"));
1009        *ep.cache_keepwarm.lock().unwrap() = Some(CacheKeepWarm::Hours(6));
1010        assert_eq!(record(&handle, None).cache_keepwarm.as_deref(), Some("6h"));
1011    }
1012
1013    /// Likewise the pin: an unconfirmed checkpoint must not unpin a
1014    /// session (it would park, and stop holding the daemon's idle exit).
1015    #[test]
1016    fn the_pin_survives_an_unconfirmed_checkpoint() {
1017        use crate::session::handle::echo::{meta_for, view_for};
1018        let id = crate::session::SessionId("pin-reload".into());
1019        let (handle, ep) = SessionHandle::new(meta_for(&id), view_for());
1020        assert!(!record(&handle, None).keep_warm);
1021        ep.keep_warm.store(true, std::sync::atomic::Ordering::Release);
1022        let r = record(&handle, None);
1023        assert!(r.keep_warm && r.config.keep_warm);
1024    }
1025
1026    /// A `memory_mode` this image does not know (written by a newer one)
1027    /// reads as `Off` — the consent-safe reading — instead of failing the
1028    /// session's config and with it the whole reload state, so no session
1029    /// would be rehydrated. A missing or null value stays no choice.
1030    #[test]
1031    fn an_unknown_memory_mode_reads_as_off() {
1032        use crate::session::SessionMemoryMode;
1033        let cfg = |v: serde_json::Value| {
1034            serde_json::from_value::<SessionConfig>(serde_json::json!({ "memory_mode": v }))
1035        };
1036        assert_eq!(cfg(serde_json::json!("off")).unwrap().memory_mode, Some(SessionMemoryMode::Off));
1037        assert_eq!(
1038            cfg(serde_json::json!("capture_and_recall")).unwrap().memory_mode,
1039            Some(SessionMemoryMode::CaptureAndRecall)
1040        );
1041        assert_eq!(
1042            cfg(serde_json::json!("recall_forever")).unwrap().memory_mode,
1043            Some(SessionMemoryMode::Off)
1044        );
1045        assert_eq!(cfg(serde_json::json!(7)).unwrap().memory_mode, Some(SessionMemoryMode::Off));
1046        assert_eq!(cfg(serde_json::Value::Null).unwrap().memory_mode, None);
1047        let missing = serde_json::from_value::<SessionConfig>(serde_json::json!({})).unwrap();
1048        assert_eq!(missing.memory_mode, None);
1049        let rs: ReloadState = serde_json::from_value(serde_json::json!({
1050            "generation": 2,
1051            "written_at": "2026-10-14T18:17:03Z",
1052            "sessions": [{
1053                "id": "s1", "journal_id": "s1", "keep_warm": false, "lifecycle": "live",
1054                "config": { "memory_mode": "recall_forever" }
1055            }],
1056            "expected_clients": 0
1057        }))
1058        .expect("the reload state still parses");
1059        assert_eq!(rs.sessions[0].config.memory_mode, Some(SessionMemoryMode::Off));
1060    }
1061
1062    /// The restore without the hook wait keeps #255's `/memory` carry: the
1063    /// session's recorded choice rides in the saved config, Parked or not.
1064    #[test]
1065    fn a_restore_keeps_the_sessions_memory_choice() {
1066        use crate::session::SessionMemoryMode;
1067        for mode in [Some(SessionMemoryMode::Off), Some(SessionMemoryMode::CaptureOnly), None] {
1068            let saved = SessionConfig {
1069                memory_mode: mode,
1070                await_extensions: true,
1071                ..Default::default()
1072            };
1073            for lifecycle in [SessionLifecycle::Live, SessionLifecycle::Parked, SessionLifecycle::Parking] {
1074                let restored = restore_config(&saved, lifecycle);
1075                assert_eq!(restored.memory_mode, mode, "{lifecycle:?}");
1076                assert!(!restored.await_extensions, "{lifecycle:?}");
1077            }
1078        }
1079    }
1080
1081    /// Likewise the session's `/memory` choice: a reload without a
1082    /// checkpoint reply must not re-grant memory the user turned off.
1083    #[test]
1084    fn memory_choice_survives_an_unconfirmed_checkpoint() {
1085        use crate::session::handle::echo::{meta_for, view_for};
1086        use crate::session::SessionMemoryMode;
1087        let id = crate::session::SessionId("mem-reload".into());
1088        let (handle, ep) = SessionHandle::new(meta_for(&id), view_for());
1089        assert_eq!(record(&handle, None).config.memory_mode, None);
1090        *ep.memory_mode.lock().unwrap() = Some(SessionMemoryMode::Off);
1091        assert_eq!(record(&handle, None).config.memory_mode, Some(SessionMemoryMode::Off));
1092    }
1093
1094    /// A lock-held placeholder (F23) never answers a checkpoint, so the
1095    /// next reload records it from its handle: its memory choice must ride
1096    /// along, or a session reloaded twice comes back in the default.
1097    #[tokio::test]
1098    async fn a_locked_placeholder_keeps_its_memory_choice() {
1099        use crate::session::SessionMemoryMode;
1100        let sid = crate::session::SessionId("locked-mem".into());
1101        let config = SessionConfig {
1102            memory_mode: Some(SessionMemoryMode::Off),
1103            ..SessionConfig::default()
1104        };
1105        let (handle, task) =
1106            crate::session::handle::locked_placeholder::spawn(sid, config, "pid 1 (tui)".into());
1107        assert_eq!(record(&handle, None).config.memory_mode, Some(SessionMemoryMode::Off));
1108        task.abort();
1109    }
1110
1111    /// `ReloadAborted` reaches a session whose command queue is full (the
1112    /// actor drains it) instead of being dropped, which would leave the
1113    /// session fenced until the backstop.
1114    #[tokio::test]
1115    async fn reload_aborted_waits_for_room_in_a_full_queue() {
1116        use crate::session::handle::echo::{meta_for, view_for};
1117        let id = crate::session::SessionId("abort-full".into());
1118        let (handle, mut ep) = SessionHandle::new(meta_for(&id), view_for());
1119        while handle.send(SessionCommand::Save).await.is_ok() {}
1120        let drain = tokio::spawn(async move {
1121            tokio::time::sleep(Duration::from_millis(100)).await;
1122            let mut got_abort = false;
1123            while let Some(a) = ep.cmd_rx.recv().await {
1124                if matches!(a.cmd, SessionCommand::ReloadAborted) {
1125                    got_abort = true;
1126                    break;
1127                }
1128            }
1129            got_abort
1130        });
1131        assert!(send_reload_aborted(&handle, Duration::from_secs(5)).await);
1132        assert!(drain.await.unwrap(), "delivered");
1133        // No room ever: gives up within its budget, reporting it.
1134        let id = crate::session::SessionId("abort-stuck".into());
1135        let (stuck, _ep) = SessionHandle::new(meta_for(&id), view_for());
1136        while stuck.send(SessionCommand::Save).await.is_ok() {}
1137        assert!(!send_reload_aborted(&stuck, Duration::from_millis(100)).await);
1138    }
1139}