1use std::path::{Path, PathBuf};
34use std::sync::atomic::Ordering;
35use std::sync::Arc;
36use std::time::Duration;
37
38use serde::{Deserialize, Serialize};
39
40use super::registry::{self, DaemonLock, DaemonPaths};
41use super::DaemonState;
42use crate::session::wire::{ClientFrame, CHECKPOINT_QUERY_ID, PROTOCOL_VERSION};
43use crate::session::{
44 CheckpointReason, SessionCommand, SessionConfig, SessionEventWire, SessionHandle,
45 SessionLifecycle, SessionReloadRecord, SessionSetting,
46};
47
48pub const RELOAD_STATE_ENV: &str = "SYNAPS_DAEMON_RELOAD_STATE";
50pub const LOCK_FD_ENV: &str = "SYNAPS_DAEMON_LOCK_FD";
52pub const SUBAGENT_ID_FLOOR_ENV: &str = "SYNAPS_DAEMON_SUBAGENT_ID_FLOOR";
56pub const HANDOFF_EVENT_ENV: &str = "SYNAPS_DAEMON_HANDOFF_EVENT";
59pub const HANDOFF_PID_ENV: &str = "SYNAPS_DAEMON_HANDOFF_PID";
62#[cfg(windows)]
65const HANDOFF_ARM_TIMEOUT: Duration = Duration::from_secs(15);
66#[cfg(windows)]
68const HANDOFF_EXIT_TIMEOUT: Duration = Duration::from_secs(30);
69pub const DRAIN_SECS_ENV: &str = "SYNAPS_DAEMON_RELOAD_DRAIN_SECS";
71const DEFAULT_DRAIN: Duration = Duration::from_secs(30);
72const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
73const CHECKPOINT_BUDGET: Duration =
77 Duration::from_secs(crate::session::budgets::RELOAD_CHECKPOINT_BUDGET_SECS);
78pub const RETRY_AFTER_MS: u64 = 500;
80
81#[derive(Debug, Clone, Default)]
82pub struct ReloadRequest {
83 pub now: bool,
84 pub drain_secs: Option<u64>,
85 pub exe: Option<PathBuf>,
86}
87
88impl ReloadRequest {
89 pub fn from_frame(frame: &ClientFrame) -> Option<Self> {
90 match frame {
91 ClientFrame::Reload { now, drain_secs, exe } => {
92 Some(Self { now: *now, drain_secs: *drain_secs, exe: exe.clone() })
93 }
94 _ => None,
95 }
96 }
97}
98
99#[derive(Debug)]
100pub enum ReloadError {
101 Refused(String),
103 ExecFailed(String),
105}
106
107impl std::fmt::Display for ReloadError {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
110 Self::Refused(w) => write!(f, "reload refused: {w}"),
111 Self::ExecFailed(e) => write!(f, "reload exec failed: {e}"),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct PrintVersion {
119 pub binary_version: String,
120 pub protocol_version: u32,
121}
122
123impl PrintVersion {
124 pub fn current() -> Self {
125 Self {
126 binary_version: crate::session::wire::binary_version(),
127 protocol_version: PROTOCOL_VERSION,
128 }
129 }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ReloadSession {
135 pub id: String,
136 pub journal_id: String,
137 pub config: SessionConfig,
138 pub keep_warm: bool,
139 pub lifecycle: SessionLifecycle,
140 #[serde(default)]
142 pub settings_replay: Vec<SessionSetting>,
143 #[serde(default)]
145 pub cache_keepwarm: Option<String>,
146 pub input_owner_kind: Option<crate::session::ClientKind>,
147 #[serde(default)]
150 pub workers: Vec<crate::runtime::subagent::CarriedWorker>,
151 #[serde(default)]
155 pub tool_activations: Vec<crate::tools::activation::CarriedActivation>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub owner_token: Option<crate::session::OwnerToken>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ReloadState {
167 pub generation: u64,
168 pub written_at: chrono::DateTime<chrono::Utc>,
169 pub sessions: Vec<ReloadSession>,
170 pub expected_clients: usize,
171 #[serde(default)]
176 pub next_subagent_id: u64,
177}
178
179impl ReloadState {
180 pub fn subagent_id_floor(&self) -> u64 {
183 let carried = self
184 .sessions
185 .iter()
186 .flat_map(|s| &s.workers)
187 .filter_map(|w| crate::runtime::subagent::handle_number(&w.id))
188 .max()
189 .map_or(0, |n| n + 1);
190 self.next_subagent_id.max(carried)
191 }
192}
193
194pub fn reload_state_path(paths: &DaemonPaths) -> PathBuf {
195 let stem = paths
196 .json
197 .file_stem()
198 .map(|s| s.to_string_lossy().into_owned())
199 .unwrap_or_else(|| "daemon".into());
200 paths.dir.join(format!("{stem}.reload.json"))
201}
202
203pub fn resolve_exe() -> PathBuf {
207 if let Some(a0) = std::env::args_os().next() {
208 let p = PathBuf::from(&a0);
209 if p.components().count() > 1 {
210 if let Ok(c) = p.canonicalize() {
211 return c;
212 }
213 }
214 }
215 std::env::current_exe().unwrap_or_else(|_| PathBuf::from("synaps"))
216}
217
218fn semver_tuple(s: &str) -> Option<(u64, u64, u64)> {
219 let core = s.split(['-', '+']).next()?;
220 let mut it = core.split('.').map(|p| p.parse::<u64>().ok());
221 Some((it.next()??, it.next()??, it.next()??))
222}
223
224pub fn version_gate(current: &PrintVersion, new: &PrintVersion) -> Result<(), String> {
228 if new.protocol_version < current.protocol_version {
229 return Err(format!(
230 "new binary speaks protocol {} < {}: reconnecting clients would be refused",
231 new.protocol_version, current.protocol_version
232 ));
233 }
234 match (semver_tuple(¤t.binary_version), semver_tuple(&new.binary_version)) {
235 (Some(c), Some(n)) if n < c => Err(format!(
236 "new binary {} is older than the running {} (reload is newer-or-equal only)",
237 new.binary_version, current.binary_version
238 )),
239 (Some(_), Some(_)) => Ok(()),
240 _ => Err(format!(
241 "cannot compare versions {:?} vs {:?}",
242 current.binary_version, new.binary_version
243 )),
244 }
245}
246
247#[cfg(unix)]
253pub fn validate_exe(exe: &Path) -> Result<PathBuf, String> {
254 use std::os::unix::fs::{MetadataExt, PermissionsExt};
255 let canon = exe
256 .canonicalize()
257 .map_err(|e| format!("--exe {}: {e}", exe.display()))?;
258 let md = std::fs::metadata(&canon).map_err(|e| format!("--exe {}: {e}", canon.display()))?;
259 if !md.is_file() {
260 return Err(format!("--exe {}: not a regular file", canon.display()));
261 }
262 let mode = md.permissions().mode();
263 if mode & 0o111 == 0 {
264 return Err(format!("--exe {}: not executable", canon.display()));
265 }
266 let uid = unsafe { libc::getuid() };
268 if md.uid() != uid && md.uid() != 0 {
269 return Err(format!(
270 "--exe {}: owned by uid {} (expected {} or root)",
271 canon.display(),
272 md.uid(),
273 uid
274 ));
275 }
276 if mode & 0o022 != 0 {
277 return Err(format!(
278 "--exe {}: group/world-writable (mode {:o})",
279 canon.display(),
280 mode & 0o777
281 ));
282 }
283 Ok(canon)
284}
285
286#[cfg(not(unix))]
287pub fn validate_exe(exe: &Path) -> Result<PathBuf, String> {
288 exe.canonicalize().map_err(|e| format!("--exe {}: {e}", exe.display()))
289}
290
291pub async fn probe_version(exe: &Path) -> Result<PrintVersion, String> {
293 let out = tokio::time::timeout(
294 VERSION_PROBE_TIMEOUT,
295 tokio::process::Command::new(exe)
296 .args(["daemon", "--print-version"])
297 .env("SYNAPS_DAEMON", "1")
298 .stdin(std::process::Stdio::null())
299 .stderr(std::process::Stdio::piped())
300 .stdout(std::process::Stdio::piped())
301 .output(),
302 )
303 .await
304 .map_err(|_| format!("{} --print-version timed out", exe.display()))?
305 .map_err(|e| format!("cannot run {}: {e}", exe.display()))?;
306 if !out.status.success() {
307 return Err(format!(
308 "{} --print-version exited {:?}: {}",
309 exe.display(),
310 out.status.code(),
311 String::from_utf8_lossy(&out.stderr).trim()
312 ));
313 }
314 serde_json::from_slice(&out.stdout)
315 .map_err(|e| format!("{} --print-version: unparsable output ({e})", exe.display()))
316}
317
318async fn checkpoint_one(handle: SessionHandle) -> Option<SessionReloadRecord> {
321 let mut rx = handle.subscribe();
322 if handle
323 .send(SessionCommand::Checkpoint { reason: CheckpointReason::Reload })
324 .await
325 .is_err()
326 {
327 return None;
328 }
329 let wait = async {
330 loop {
331 match rx.recv().await {
332 Ok(env) => {
333 if let SessionEventWire::QueryResult { id, value } = env.event {
334 if id == CHECKPOINT_QUERY_ID {
335 return serde_json::from_value(value["record"].clone()).ok();
336 }
337 }
338 }
339 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
340 Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
341 }
342 }
343 };
344 tokio::time::timeout(CHECKPOINT_BUDGET, wait).await.unwrap_or(None)
345}
346
347fn record(handle: &SessionHandle, rec: Option<SessionReloadRecord>) -> ReloadSession {
351 let meta = handle.meta();
352 let journal_id = handle.journal_id();
353 if rec.is_none() {
354 tracing::warn!(
355 session = %handle.id,
356 "reload: no checkpoint reply in time — this session's workers are not carried: \
357 no archive is guaranteed and their parent will not be woken"
358 );
359 }
360 let (config, keep_warm, lifecycle, settings_replay, cache_keepwarm, model, workers, tool_activations) = match rec {
361 Some(r) => (
362 r.config,
363 r.keep_warm,
364 r.lifecycle,
365 r.settings_replay,
366 r.cache_keepwarm,
367 r.model,
368 r.workers,
369 r.tool_activations,
370 ),
371 None => (
372 SessionConfig {
373 cwd: meta.cwd.clone(),
374 env: None,
375 env_stripped: Vec::new(),
376 ..SessionConfig::default()
377 },
378 handle.pinned(),
381 handle.lifecycle(),
382 Vec::new(),
383 handle.cache_keepwarm_override(),
386 handle.view().model.clone(),
387 Vec::new(),
390 Vec::new(),
392 ),
393 };
394 ReloadSession {
395 id: meta.id.as_str().to_string(),
396 journal_id: journal_id.clone(),
397 config: SessionConfig {
398 continue_session: Some(Some(journal_id)),
399 model_override: Some(model),
400 keep_warm,
401 name: None,
404 ..config
405 },
406 keep_warm,
407 lifecycle,
408 settings_replay,
409 cache_keepwarm,
410 input_owner_kind: None,
411 workers,
412 tool_activations,
413 owner_token: handle.owner_token().cloned(),
414 }
415}
416
417pub struct Prepared {
422 pub exe: PathBuf,
423 pub rs_path: PathBuf,
424 pub generation: u64,
425}
426
427pub async fn prepare(
428 state: &Arc<DaemonState>,
429 paths: &DaemonPaths,
430 req: ReloadRequest,
431) -> Result<Prepared, ReloadError> {
432 let exe = req.exe.clone().unwrap_or_else(|| state.daemon_info().exe.unwrap_or_else(resolve_exe));
436 let exe = validate_exe(&exe).map_err(ReloadError::Refused)?;
437 let new = probe_version(&exe).await.map_err(ReloadError::Refused)?;
438 version_gate(&PrintVersion::current(), &new).map_err(ReloadError::Refused)?;
439 if state.reloading.swap(true, Ordering::SeqCst) {
440 return Err(ReloadError::Refused("a reload is already in progress".into()));
441 }
442 let generation = state.generation + 1;
443 tracing::info!(exe = %exe.display(), new = %new.binary_version, generation, "daemon: reload accepted");
444
445 let drain = if req.now {
447 Duration::ZERO
448 } else {
449 req.drain_secs
450 .map(Duration::from_secs)
451 .or_else(|| std::env::var(DRAIN_SECS_ENV).ok()?.parse().ok().map(Duration::from_secs))
452 .unwrap_or(DEFAULT_DRAIN)
453 };
454 let t0 = std::time::Instant::now();
455 while t0.elapsed() < drain {
456 let mut all_idle = true;
457 for h in state.live_sessions() {
458 if !super::session_is_idle(&h).await {
459 all_idle = false;
460 break;
461 }
462 }
463 if all_idle {
464 break;
465 }
466 tokio::time::sleep(Duration::from_millis(200)).await;
467 }
468 let handles = state.live_sessions();
469 let results = futures::future::join_all(handles.iter().cloned().map(checkpoint_one)).await;
470 let mut sessions = Vec::with_capacity(handles.len());
471 for (h, rec) in handles.iter().zip(results) {
472 if rec.is_none() {
473 tracing::warn!(session = %h.id, "reload: checkpoint did not confirm; recording from the handle");
474 }
475 sessions.push(record(h, rec));
476 }
477
478 let rs = ReloadState {
480 generation,
481 written_at: chrono::Utc::now(),
482 sessions,
483 expected_clients: state.connections.load(Ordering::SeqCst),
484 next_subagent_id: crate::tools::peek_next_subagent_id(),
486 };
487 let rs_path = reload_state_path(paths);
488 let written = serde_json::to_vec_pretty(&rs)
493 .map_err(|e| e.to_string())
494 .and_then(|body| registry::write_private_atomic(&rs_path, &body).map_err(|e| e.to_string()));
495 if let Err(e) = written {
496 abort_checkpoints(state).await;
499 state.reloading.store(false, Ordering::SeqCst);
500 return Err(ReloadError::ExecFailed(e));
501 }
502
503 state.reload_generation.store(generation, Ordering::SeqCst);
506 state.fire_announce();
507 let t1 = std::time::Instant::now();
508 while state.connections.load(Ordering::SeqCst) > 1 && t1.elapsed() < Duration::from_secs(crate::session::budgets::RELOAD_ANNOUNCE_WAIT_SECS) {
509 tokio::time::sleep(Duration::from_millis(20)).await;
510 }
511 Ok(Prepared { exe, rs_path, generation })
512}
513
514pub async fn exec(state: &Arc<DaemonState>, p: Prepared) -> ReloadError {
523 let previous = state.daemon_info();
528 let prev_generation = previous.generation;
529 let info = registry::DaemonInfo {
530 generation: p.generation,
531 started_at: chrono::Utc::now(),
532 exe: Some(p.exe.clone()),
533 ..previous.clone()
534 };
535 if let Err(e) = state.meta.publish(info) {
536 tracing::warn!(path = %state.paths.json.display(), error = %e, "daemon: daemon.json write failed at reload");
537 }
538 let _ = tokio::time::timeout(
539 Duration::from_secs(crate::session::budgets::RELOAD_EXT_SHUTDOWN_SECS),
540 async { state.host.ext_manager().write().await.shutdown_all().await },
541 )
542 .await;
543
544 #[cfg(unix)]
545 let err = exec_self(&p.exe, &p.rs_path, state);
546 #[cfg(windows)]
547 let err = exec_successor(&p.exe, &p.rs_path, state).await;
548 tracing::error!(error = %err, "daemon: reload exec failed; continuing on the old image");
549 if let Err(e) = state.meta.publish(previous) {
552 tracing::warn!(
553 path = %state.paths.json.display(),
554 error = %e,
555 "daemon: daemon.json write failed after the failed reload exec"
556 );
557 }
558 let _ = std::fs::remove_file(&p.rs_path);
559 state.reload_generation.store(prev_generation, Ordering::SeqCst);
560 state.reset_announce();
561 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
564 let _loader = crate::extensions::loader::spawn_discover_and_load(
565 Arc::clone(state.host.ext_manager()),
566 tx,
567 None,
568 );
569 let wait = async {
570 while let Some(ev) = rx.recv().await {
571 if let crate::extensions::loader::ExtensionLoaderEvent::Finished { loaded, failed } = ev {
572 tracing::info!(loaded = loaded.len(), failed = failed.len(), "daemon: extensions re-discovered after failed reload");
573 break;
574 }
575 }
576 };
577 if tokio::time::timeout(
578 Duration::from_secs(crate::session::budgets::RELOAD_EXT_REDISCOVERY_SECS),
579 wait,
580 )
581 .await
582 .is_err()
583 {
584 tracing::warn!("daemon: extension re-discovery still running; resuming the sessions");
585 }
586 abort_checkpoints(state).await;
590 state.reloading.store(false, Ordering::SeqCst);
591 ReloadError::ExecFailed(err)
592}
593
594#[cfg(unix)]
595fn exec_self(exe: &Path, rs_path: &Path, state: &DaemonState) -> String {
596 use std::os::unix::process::CommandExt;
597 let Some(lock_fd) = state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|l| l.raw_fd()) else {
598 return "no lock fd to inherit".into();
599 };
600 unsafe {
605 let flags = libc::fcntl(lock_fd, libc::F_GETFD);
606 if flags >= 0 {
607 libc::fcntl(lock_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
608 }
609 }
610 let mut args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
611 if args.is_empty() || args.iter().all(|a| a != "daemon") {
612 args = vec!["daemon".into(), "--foreground".into()];
613 }
614 let mut cmd = std::process::Command::new(exe);
615 cmd.args(&args)
616 .env("SYNAPS_DAEMON", "1")
617 .env(LOCK_FD_ENV, lock_fd.to_string())
618 .env(RELOAD_STATE_ENV, rs_path)
619 .env(SUBAGENT_ID_FLOOR_ENV, crate::tools::peek_next_subagent_id().to_string());
620 let e = cmd.exec();
621 unsafe {
624 let flags = libc::fcntl(lock_fd, libc::F_GETFD);
625 if flags >= 0 {
626 libc::fcntl(lock_fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
627 }
628 }
629 e.to_string()
630}
631
632#[cfg(windows)]
639async fn exec_successor(exe: &Path, rs_path: &Path, state: &DaemonState) -> String {
640 use super::win;
641 let armed = match win::Event::create_unique("handoff") {
642 Ok(e) => e,
643 Err(e) => return format!("hand-off event: {e}"),
644 };
645 let mut args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
646 if args.is_empty() || args.iter().all(|a| a != "daemon") {
647 args = vec!["daemon".into(), "--foreground".into()];
648 }
649 let mut env = crate::process_scope::inherited_env();
650 env.retain(|(k, _)| k != super::READY_EVENT_ENV);
651 crate::process_scope::set_env(&mut env, "SYNAPS_DAEMON", "1");
652 crate::process_scope::set_env(&mut env, RELOAD_STATE_ENV, rs_path.as_os_str());
653 crate::process_scope::set_env(&mut env, HANDOFF_EVENT_ENV, armed.name());
654 crate::process_scope::set_env(&mut env, HANDOFF_PID_ENV, std::process::id().to_string());
655 crate::process_scope::set_env(&mut env, SUBAGENT_ID_FLOOR_ENV, crate::tools::peek_next_subagent_id().to_string());
656 let child = match win::spawn_detached(exe, &args, &env) {
657 Ok(c) => c,
658 Err(e) => return format!("cannot start {}: {e}", exe.display()),
659 };
660 let waited = tokio::task::spawn_blocking(move || {
661 let outcome = win::wait_event_or_exit(&armed, &child, HANDOFF_ARM_TIMEOUT);
662 (child, outcome)
663 })
664 .await;
665 let (child, outcome) = match waited {
666 Ok(r) => r,
667 Err(e) => return format!("hand-off wait: {e}"),
668 };
669 match outcome {
670 win::Wait::Signalled => {
671 tracing::info!(pid = child.id(), "daemon: reload hand-off armed; this image exits");
672 state.host.flush_logs();
673 std::process::exit(0);
674 }
675 win::Wait::Exited => {
676 let mut child = child;
677 let code = child.wait(Duration::ZERO);
678 let tail = win::stderr_tail(child.take_stderr(), 64 * 1024, Duration::from_millis(500));
679 let why = tail
680 .lines()
681 .map(str::trim)
682 .rfind(|l| !l.is_empty())
683 .map(|l| l.trim_start_matches("synaps daemon: ").to_string())
684 .unwrap_or_else(|| "no output".into());
685 format!(
686 "new image exited (code {}) before taking over: {why}",
687 code.map_or_else(|| "?".to_string(), |c| c.to_string())
688 )
689 }
690 win::Wait::TimedOut => {
691 child.kill();
692 let _ = child.wait(Duration::from_secs(5));
693 format!("new image did not take over within {HANDOFF_ARM_TIMEOUT:?}")
694 }
695 }
696}
697
698type Adopted = (DaemonLock, ReloadState, PathBuf);
700
701#[cfg(windows)]
704static TAKEN_OVER: std::sync::Mutex<Option<Adopted>> = std::sync::Mutex::new(None);
705
706#[cfg(windows)]
710pub fn take_over_from_env(paths: &DaemonPaths) -> anyhow::Result<()> {
711 if let Some(adopted) = handoff_from_env(paths)? {
712 *TAKEN_OVER.lock().unwrap_or_else(|e| e.into_inner()) = Some(adopted);
713 }
714 Ok(())
715}
716
717#[cfg(windows)]
720pub fn adopt_from_env(paths: &DaemonPaths) -> anyhow::Result<Option<Adopted>> {
721 if let Some(adopted) = TAKEN_OVER.lock().unwrap_or_else(|e| e.into_inner()).take() {
722 return Ok(Some(adopted));
723 }
724 handoff_from_env(paths)
725}
726
727#[cfg(windows)]
734fn handoff_from_env(paths: &DaemonPaths) -> anyhow::Result<Option<Adopted>> {
735 let Some(rs_path) = std::env::var_os(RELOAD_STATE_ENV) else {
736 return Ok(None);
737 };
738 let rs_path = PathBuf::from(rs_path);
739 std::env::remove_var(RELOAD_STATE_ENV);
740 let event = std::env::var(HANDOFF_EVENT_ENV).ok();
741 std::env::remove_var(HANDOFF_EVENT_ENV);
742 let old_pid = std::env::var(HANDOFF_PID_ENV).ok().and_then(|s| s.parse::<u32>().ok());
743 std::env::remove_var(HANDOFF_PID_ENV);
744 let id_floor = std::env::var(SUBAGENT_ID_FLOOR_ENV).ok().and_then(|s| s.parse::<u64>().ok());
745 std::env::remove_var(SUBAGENT_ID_FLOOR_ENV);
746 let event = event.ok_or_else(|| {
747 anyhow::anyhow!("{RELOAD_STATE_ENV} set without {HANDOFF_EVENT_ENV}: refusing to start (the lock is the liveness oracle)")
748 })?;
749 let body = std::fs::read_to_string(&rs_path)?;
750 let mut rs: ReloadState = serde_json::from_str(&body)?;
751 if let Some(floor) = id_floor {
752 rs.next_subagent_id = rs.next_subagent_id.max(floor);
753 }
754 let old = old_pid.and_then(super::win::Process::open);
756 let mut signal_err = None;
757 let lock = DaemonLock::acquire_handoff(paths, HANDOFF_EXIT_TIMEOUT, || {
758 signal_err = super::win::signal_event(&event).err();
759 })
760 .map_err(|e| match &signal_err {
761 Some(s) => anyhow::anyhow!("taking daemon.lock over: {e} (the previous image was never told: {s})"),
762 None => anyhow::anyhow!("taking daemon.lock over from the previous image: {e}"),
763 })?;
764 if let Some(old) = old {
765 if !old.wait(Duration::from_secs(10)) {
766 tracing::warn!(pid = old_pid, "daemon: the previous image still runs after releasing daemon.lock");
767 }
768 }
769 tracing::info!(generation = rs.generation, "daemon: took daemon.lock over from the previous image");
770 super::win::detach_stdio();
772 Ok(Some((lock, rs, rs_path)))
773}
774
775#[cfg(unix)]
779pub fn adopt_from_env(_paths: &DaemonPaths) -> anyhow::Result<Option<Adopted>> {
780 let Some(rs_path) = std::env::var_os(RELOAD_STATE_ENV) else {
781 return Ok(None);
782 };
783 let rs_path = PathBuf::from(rs_path);
784 std::env::remove_var(RELOAD_STATE_ENV);
785 let fd: i32 = std::env::var(LOCK_FD_ENV)
786 .ok()
787 .and_then(|s| s.parse().ok())
788 .ok_or_else(|| anyhow::anyhow!("{RELOAD_STATE_ENV} set without {LOCK_FD_ENV}: refusing to start (the flock is the liveness oracle)"))?;
789 std::env::remove_var(LOCK_FD_ENV);
790 let id_floor = std::env::var(SUBAGENT_ID_FLOOR_ENV).ok().and_then(|s| s.parse::<u64>().ok());
791 std::env::remove_var(SUBAGENT_ID_FLOOR_ENV);
792 let lock = DaemonLock::adopt(fd)?;
793 let body = std::fs::read_to_string(&rs_path)?;
794 let mut rs: ReloadState = serde_json::from_str(&body)?;
795 if let Some(floor) = id_floor {
796 rs.next_subagent_id = rs.next_subagent_id.max(floor);
797 }
798 Ok(Some((lock, rs, rs_path)))
799}
800
801pub(crate) fn is_lock_held_error(e: &str) -> bool {
813 e.contains("is live in another process")
814}
815
816pub(crate) fn parse_holder_from_error(e: &str) -> String {
818 if let Some(start) = e.find("(pid ") {
820 let after = &e[start + 1..]; if let Some(end) = after.find(')') {
822 return after[..end].to_string();
823 }
824 }
825 "unknown holder".to_string()
826}
827
828async fn abort_checkpoints(state: &Arc<DaemonState>) {
834 let budget = Duration::from_secs(crate::session::budgets::RELOAD_ABORT_SEND_SECS);
835 let sends = state
836 .live_sessions()
837 .into_iter()
838 .map(|h| async move { send_reload_aborted(&h, budget).await });
839 futures::future::join_all(sends).await;
840}
841
842pub(crate) async fn send_reload_aborted(h: &SessionHandle, budget: Duration) -> bool {
846 use crate::session::TransportError;
847 let deadline = tokio::time::Instant::now() + budget;
848 loop {
849 match h.send(SessionCommand::ReloadAborted).await {
850 Ok(()) => return true,
851 Err(TransportError::Backpressure) if tokio::time::Instant::now() < deadline => {
852 tokio::time::sleep(Duration::from_millis(20)).await;
853 }
854 Err(e) => {
855 tracing::warn!(
856 session = %h.id,
857 error = %e,
858 "reload: could not tell the session the exec failed; its reload fence lifts at the backstop"
859 );
860 return false;
861 }
862 }
863 }
864}
865
866fn restore_config(saved: &SessionConfig, lifecycle: SessionLifecycle, chat: &str) -> SessionConfig {
880 SessionConfig {
881 await_extensions: false,
882 restore_parked: matches!(lifecycle, SessionLifecycle::Parked | SessionLifecycle::Parking),
883 cwd: restorable_folder(saved.cwd.clone(), chat),
884 ..saved.clone()
885 }
886}
887
888pub(crate) fn restorable_folder(folder: Option<PathBuf>, chat: &str) -> Option<PathBuf> {
903 let folder = folder?;
904 let gone = match std::fs::metadata(&folder) {
905 Ok(found) if found.is_dir() => return Some(folder),
906 Ok(_) => "it is not a directory",
907 Err(error) if error.kind() == std::io::ErrorKind::NotFound => "it does not exist",
908 Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
909 "its path runs through something that is not a directory"
910 }
911 Err(error) => {
912 tracing::warn!(
913 session = %chat,
914 folder = %folder.display(),
915 error = %error,
916 "{}",
917 UNSEEN_NOTICE
918 );
919 return Some(folder);
920 }
921 };
922 tracing::warn!(
923 session = %chat,
924 folder = %folder.display(),
925 "{}",
926 gone_notice(gone)
927 );
928 None
929}
930
931const UNSEEN_NOTICE: &str = "the chat's folder cannot be looked at; restored with it all the same";
934
935fn gone_notice(gone: &str) -> String {
939 format!(
940 "the chat's folder is gone ({gone}); it is restored in the host process's folder \
941 and that folder's project"
942 )
943}
944
945pub async fn rehydrate(state: &Arc<DaemonState>, rs: &ReloadState) {
946 crate::tools::seed_next_subagent_id(rs.subagent_id_floor());
949 for s in &rs.sessions {
950 let owned = s.owner_token.clone().map(|token| crate::session::OwnedConfig { token, restored: true });
954 let config = SessionConfig { owned, ..restore_config(&s.config, s.lifecycle, &s.id) };
955 let created = match state.create(config.clone()).await {
956 Ok(h) => Ok(h),
957 Err(e) if is_lock_held_error(&e) => {
960 let holder = parse_holder_from_error(&e);
961 tracing::warn!(
962 session = %s.id, holder = %holder,
963 "daemon: journal locked by another process; registering Parked placeholder"
964 );
965 let sid = crate::session::SessionId::from(s.id.as_str());
966 let (handle, _task) = crate::session::handle::locked_placeholder::spawn(
968 sid,
969 config.clone(),
970 holder,
971 );
972 state.insert(handle);
973 continue;
974 }
975 Err(e) if s.config.continue_session.is_some() => {
976 tracing::warn!(session = %s.id, error = %e, "daemon: journal not continuable; recreating fresh");
977 state
978 .create(SessionConfig {
979 continue_session: None,
980 ..config.clone()
981 })
982 .await
983 }
984 Err(e) => Err(e),
985 };
986 match created {
987 Ok(h) => {
988 let new_id = h.id.as_str().to_string();
989 let continued = h.meta().continued;
995 if continued && !s.tool_activations.is_empty() {
996 let _ = h
997 .send(SessionCommand::RestoreToolActivations {
998 activations: s.tool_activations.clone(),
999 })
1000 .await;
1001 }
1002 if new_id != s.id {
1003 state
1004 .reload_aliases
1005 .lock()
1006 .unwrap_or_else(|e| e.into_inner())
1007 .insert(s.id.clone(), new_id.clone());
1008 }
1009 for (i, setting) in s.settings_replay.iter().enumerate() {
1011 let _ = h
1012 .send(SessionCommand::Set {
1013 id: u64::MAX - i as u64,
1014 setting: setting.clone(),
1015 })
1016 .await;
1017 }
1018 if s.keep_warm {
1019 let _ = h.send(SessionCommand::KeepWarm { on: true }).await;
1020 }
1021 if let Some(mode) = &s.cache_keepwarm {
1022 let _ = h
1023 .send(SessionCommand::EngineCommand {
1024 id: u64::MAX,
1025 name: "keepwarm".into(),
1026 arg: mode.clone(),
1027 })
1028 .await;
1029 }
1030 if !s.workers.is_empty() {
1033 let _ = h
1034 .send(SessionCommand::AdoptWorkers { workers: s.workers.clone() })
1035 .await;
1036 }
1037 if matches!(s.lifecycle, SessionLifecycle::Parked | SessionLifecycle::Parking) {
1038 let _ = h.send(SessionCommand::Park).await;
1039 } else {
1040 let _ = h.send(SessionCommand::ArmPark).await;
1044 }
1045 tracing::info!(old = %s.id, new = %new_id, lifecycle = ?s.lifecycle, "daemon: session rehydrated after reload");
1046 }
1047 Err(e) => tracing::warn!(session = %s.id, error = %e, "daemon: session could not be rehydrated"),
1048 }
1049 }
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055
1056 #[test]
1059 fn reload_session_carries_tool_activations() {
1060 use crate::tools::activation::{CarriedActivation, CarriedProvenance};
1061 let carried = vec![CarriedActivation {
1062 tool_id: "builtin:subagent_start".into(),
1063 schema_digest: "cd".repeat(32),
1064 provenance: CarriedProvenance::BuiltinRuntime,
1065 }];
1066 let s = ReloadSession {
1067 id: "s".into(),
1068 journal_id: "j".into(),
1069 config: SessionConfig::default(),
1070 keep_warm: false,
1071 lifecycle: SessionLifecycle::Live,
1072 settings_replay: Vec::new(),
1073 cache_keepwarm: None,
1074 input_owner_kind: None,
1075 workers: Vec::new(),
1076 tool_activations: carried.clone(),
1077 owner_token: None,
1078 };
1079 let v = serde_json::to_value(&s).unwrap();
1080 let back: ReloadSession = serde_json::from_value(v.clone()).unwrap();
1081 assert_eq!(back.tool_activations, carried);
1082 assert!(v["config"].get("restore_tool_activations").is_none());
1084
1085 let mut older = v;
1086 older.as_object_mut().unwrap().remove("tool_activations");
1087 let back: ReloadSession = serde_json::from_value(older).unwrap();
1088 assert!(back.tool_activations.is_empty());
1089 }
1090
1091 #[test]
1094 fn reload_state_from_an_older_image_parses() {
1095 let rs: ReloadState = serde_json::from_value(serde_json::json!({
1096 "generation": 9,
1097 "written_at": "2026-10-14T18:17:03Z",
1098 "sessions": [],
1099 "expected_clients": 1
1100 }))
1101 .unwrap();
1102 assert_eq!(rs.next_subagent_id, 0);
1103 assert_eq!(rs.subagent_id_floor(), 0);
1104 }
1105
1106 #[test]
1109 fn subagent_id_floor_covers_the_counter_and_every_carried_id() {
1110 let worker = |id: &str| crate::runtime::subagent::CarriedWorker {
1111 id: id.into(),
1112 subagent_id: 0,
1113 agent: String::new(),
1114 status: "cancelled".into(),
1115 archive_path: None,
1116 stopped_by_reload: true,
1117 wake_lost: false,
1118 wake: true,
1119 elapsed_secs: 0,
1120 preview: String::new(),
1121 };
1122 let session = |workers| ReloadSession {
1123 id: "s".into(),
1124 journal_id: "j".into(),
1125 config: SessionConfig::default(),
1126 keep_warm: false,
1127 lifecycle: SessionLifecycle::Live,
1128 settings_replay: Vec::new(),
1129 cache_keepwarm: None,
1130 input_owner_kind: None,
1131 workers,
1132 tool_activations: Vec::new(),
1133 owner_token: None,
1134 };
1135 let mut rs = ReloadState {
1136 generation: 10,
1137 written_at: chrono::Utc::now(),
1138 sessions: vec![session(vec![worker("sa_288"), worker("sa_286")]), session(vec![])],
1139 expected_clients: 0,
1140 next_subagent_id: 0,
1141 };
1142 assert_eq!(rs.subagent_id_floor(), 289);
1143 rs.next_subagent_id = 300;
1144 assert_eq!(rs.subagent_id_floor(), 300);
1145 let back: ReloadState = serde_json::from_slice(&serde_json::to_vec(&rs).unwrap()).unwrap();
1147 assert_eq!(back.subagent_id_floor(), 300);
1148 assert_eq!(back.sessions[0].workers.len(), 2);
1149 }
1150
1151 fn pv(b: &str, p: u32) -> PrintVersion {
1152 PrintVersion { binary_version: b.into(), protocol_version: p }
1153 }
1154
1155 #[test]
1156 fn gate_is_directional_newer_or_equal() {
1157 let cur = pv("0.9.0", 2);
1158 assert!(version_gate(&cur, &pv("0.9.0", 2)).is_ok(), "equal allowed");
1159 assert!(version_gate(&cur, &pv("0.9.1", 2)).is_ok());
1160 assert!(version_gate(&cur, &pv("1.0.0-rc1", 3)).is_ok());
1161 assert!(version_gate(&cur, &pv("0.8.9", 2)).is_err(), "older refused");
1162 assert!(version_gate(&cur, &pv("0.9.0", 1)).is_err(), "older protocol refused");
1163 assert!(version_gate(&cur, &pv("garbage", 2)).is_err());
1164 assert!(
1167 version_gate(&pv("0.9.1", 3), &pv("0.10.0", 3)).is_ok(),
1168 "0.10.0 is newer than 0.9.1"
1169 );
1170 assert!(
1171 version_gate(&pv("0.10.0", 3), &pv("0.9.1", 3)).is_err(),
1172 "0.9.1 is older than 0.10.0"
1173 );
1174 }
1175
1176 #[cfg(unix)]
1177 #[test]
1178 fn validate_exe_refuses_missing_dirs_non_exec_and_writable() {
1179 use std::os::unix::fs::PermissionsExt;
1180 let d = tempfile::tempdir().unwrap();
1181 assert!(validate_exe(&d.path().join("nope")).unwrap_err().contains("nope"));
1182 assert!(validate_exe(d.path()).unwrap_err().contains("not a regular file"));
1183 let f = d.path().join("bin");
1184 std::fs::write(&f, "#!/bin/sh\nexit 0\n").unwrap();
1185 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).unwrap();
1186 assert!(validate_exe(&f).unwrap_err().contains("not executable"));
1187 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o777)).unwrap();
1188 assert!(validate_exe(&f).unwrap_err().contains("writable"));
1189 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
1190 assert_eq!(validate_exe(&f).unwrap(), f.canonicalize().unwrap());
1191 let l = d.path().join("link");
1193 std::os::unix::fs::symlink(&f, &l).unwrap();
1194 assert_eq!(validate_exe(&l).unwrap(), f.canonicalize().unwrap());
1195 }
1196
1197 #[test]
1201 fn a_restore_does_not_wait_on_the_session_start_hook() {
1202 let saved = SessionConfig {
1203 model_override: Some("claude-sonnet-4-5".into()),
1204 keep_warm: true,
1205 await_extensions: true,
1206 ..Default::default()
1207 };
1208 let restored = restore_config(&saved, SessionLifecycle::Live, "chat");
1209 assert!(!restored.await_extensions);
1210 assert!(!restored.restore_parked);
1211 let as_saved = SessionConfig {
1212 await_extensions: true,
1213 ..restored
1214 };
1215 assert_eq!(
1216 serde_json::to_value(&as_saved).unwrap(),
1217 serde_json::to_value(&saved).unwrap()
1218 );
1219 for parked in [SessionLifecycle::Parked, SessionLifecycle::Parking] {
1220 let restored = restore_config(&saved, parked, "chat");
1221 assert!(restored.restore_parked, "{parked:?}");
1222 assert!(!restored.await_extensions);
1223 let wire = serde_json::to_value(&restored).unwrap();
1224 assert!(wire.get("restore_parked").is_none(), "never on the wire");
1225 }
1226 }
1227
1228 #[test]
1237 fn a_restore_drops_a_folder_that_is_gone_as_the_pinned_restore_does() {
1238 let parent = tempfile::tempdir().unwrap();
1239 let (kept, gone) = (parent.path().join("kept"), parent.path().join("gone"));
1240 std::fs::create_dir(&kept).unwrap();
1241 let saved = |folder: &Path| SessionConfig {
1242 cwd: Some(folder.to_path_buf()),
1243 ..Default::default()
1244 };
1245
1246 let binding = crate::memory_backend::MemoryBinding::for_cwd(Some(gone.clone()));
1248 let error = binding.scope().unwrap_err().to_string();
1249 assert!(error.contains("host project scope unavailable"), "{error}");
1250 let binding = crate::memory_backend::MemoryBinding::for_cwd(Some(kept.clone()));
1251 assert!(binding.scope().is_ok());
1252
1253 for lifecycle in [SessionLifecycle::Live, SessionLifecycle::Parked] {
1254 assert_eq!(restore_config(&saved(&kept), lifecycle, "chat").cwd, Some(kept.clone()));
1255 assert_eq!(restore_config(&saved(&gone), lifecycle, "chat").cwd, None, "{lifecycle:?}");
1256 }
1257 std::fs::write(&gone, b"").unwrap();
1259 assert_eq!(restore_config(&saved(&gone), SessionLifecycle::Live, "chat").cwd, None);
1260 assert_eq!(restore_config(&SessionConfig::default(), SessionLifecycle::Live, "chat").cwd, None);
1262 }
1263
1264 #[test]
1270 fn the_lines_about_a_chats_folder_name_no_host() {
1271 let gone = gone_notice("it does not exist");
1272 assert!(gone.starts_with("the chat's folder is gone (it does not exist); "), "{gone}");
1273 for line in [gone.as_str(), UNSEEN_NOTICE] {
1274 assert!(line.starts_with("the chat's folder "), "{line}");
1275 assert!(!line.contains("daemon"), "{line}");
1276 }
1277 }
1278
1279 #[cfg(unix)]
1285 #[test]
1286 fn a_folder_is_dropped_only_when_it_is_absent_or_not_a_directory() {
1287 use std::os::unix::fs::PermissionsExt;
1288 let parent = tempfile::tempdir().unwrap();
1289 let at = |name: &str| parent.path().join(name);
1290 let restored = |folder: &Path| restorable_folder(Some(folder.to_path_buf()), "chat");
1291 assert_eq!(restorable_folder(None, "chat"), None);
1292
1293 assert_eq!(restored(&at("absent")), None);
1295 std::fs::write(at("file"), b"").unwrap();
1296 assert_eq!(restored(&at("file")), None);
1297 let under = at("file").join("project");
1300 let not_a_directory = std::fs::metadata(&under).unwrap_err().kind();
1301 assert_eq!(not_a_directory, std::io::ErrorKind::NotADirectory);
1302 assert_eq!(restored(&under), None);
1303 assert_eq!(restored(&under.join("deeper")), None);
1304 std::os::unix::fs::symlink(at("absent"), at("dangling")).unwrap();
1306 assert_eq!(restored(&at("dangling")), None);
1307
1308 std::fs::create_dir(at("dir")).unwrap();
1310 assert_eq!(restored(&at("dir")), Some(at("dir")));
1311 std::os::unix::fs::symlink(at("dir"), at("link")).unwrap();
1312 assert_eq!(restored(&at("link")), Some(at("link")));
1313
1314 let closed = at("closed");
1318 std::fs::create_dir_all(closed.join("project")).unwrap();
1319 let mode = |mode: u32| {
1320 std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(mode)).unwrap()
1321 };
1322 mode(0o000);
1323 let denied = |path: &Path| {
1324 std::fs::metadata(path).is_err_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
1325 };
1326 if denied(&closed.join("project")) {
1328 assert!(!closed.join("project").is_dir(), "what the old rule went by");
1329 for hidden in [closed.join("project"), closed.join("nothing")] {
1330 assert_eq!(restored(&hidden), Some(hidden.clone()));
1331 }
1332 }
1333 mode(0o700);
1334 assert_eq!(restored(&closed.join("project")), Some(closed.join("project")));
1335 assert_eq!(restored(&closed.join("nothing")), None);
1336 }
1337
1338 #[test]
1339 fn reload_state_path_follows_profile() {
1340 let d = tempfile::tempdir().unwrap();
1341 let p = registry::daemon_paths_in(d.path(), Some("work"));
1342 assert!(reload_state_path(&p).ends_with("daemon-work.reload.json"), "{:?}", reload_state_path(&p));
1343 let p = registry::daemon_paths_in(d.path(), None);
1344 assert!(reload_state_path(&p).ends_with("daemon.reload.json"));
1345 }
1346
1347 #[test]
1351 fn keepwarm_override_survives_an_unconfirmed_checkpoint() {
1352 use crate::core::config::CacheKeepWarm;
1353 use crate::session::handle::echo::{meta_for, view_for};
1354 let id = crate::session::SessionId("kw-reload".into());
1355 let (handle, ep) = SessionHandle::new(meta_for(&id), view_for());
1356 assert_eq!(record(&handle, None).cache_keepwarm, None, "no override yet");
1357 *ep.cache_keepwarm.lock().unwrap() = Some(CacheKeepWarm::Off);
1359 assert_eq!(record(&handle, None).cache_keepwarm.as_deref(), Some("off"));
1360 *ep.cache_keepwarm.lock().unwrap() = Some(CacheKeepWarm::Hours(6));
1361 assert_eq!(record(&handle, None).cache_keepwarm.as_deref(), Some("6h"));
1362 }
1363
1364 #[test]
1367 fn the_pin_survives_an_unconfirmed_checkpoint() {
1368 use crate::session::handle::echo::{meta_for, view_for};
1369 let id = crate::session::SessionId("pin-reload".into());
1370 let (handle, ep) = SessionHandle::new(meta_for(&id), view_for());
1371 assert!(!record(&handle, None).keep_warm);
1372 ep.keep_warm.store(true, std::sync::atomic::Ordering::Release);
1373 let r = record(&handle, None);
1374 assert!(r.keep_warm && r.config.keep_warm);
1375 }
1376
1377 #[test]
1382 fn an_old_memory_mode_is_read_and_ignored() {
1383 for v in [
1384 serde_json::json!("capture_and_recall"),
1385 serde_json::json!("off"),
1386 serde_json::json!("recall_forever"),
1387 serde_json::json!(7),
1388 serde_json::Value::Null,
1389 ] {
1390 let cfg: SessionConfig = serde_json::from_value(serde_json::json!({
1391 "memory_mode": v,
1392 "model_override": "claude-sonnet-4-5",
1393 "keep_warm": true
1394 }))
1395 .unwrap_or_else(|e| panic!("memory_mode = {v}: {e}"));
1396 assert_eq!(cfg.model_override.as_deref(), Some("claude-sonnet-4-5"));
1397 assert!(cfg.keep_warm);
1398 let back = serde_json::to_value(&cfg).unwrap();
1399 assert!(back.get("memory_mode").is_none(), "not written back: {back}");
1400 }
1401 let rs: ReloadState = serde_json::from_value(serde_json::json!({
1402 "generation": 2,
1403 "written_at": "2026-10-14T18:17:03Z",
1404 "sessions": [{
1405 "id": "s1", "journal_id": "s1", "keep_warm": false, "lifecycle": "live",
1406 "config": { "memory_mode": "capture_and_recall", "cwd": "/srv/chat" }
1407 }],
1408 "expected_clients": 0
1409 }))
1410 .expect("the reload state still parses");
1411 assert_eq!(rs.sessions.len(), 1);
1412 let restored = restore_config(&rs.sessions[0].config, SessionLifecycle::Live, "s1");
1413 assert!(!restored.await_extensions);
1414 }
1415
1416 #[test]
1418 fn a_restore_keeps_the_sessions_memory_choice() {
1419 let saved = SessionConfig {
1420 await_extensions: true,
1421 ..Default::default()
1422 };
1423 for lifecycle in [SessionLifecycle::Live, SessionLifecycle::Parked, SessionLifecycle::Parking] {
1424 let restored = restore_config(&saved, lifecycle, "chat");
1425 assert!(!restored.await_extensions, "{lifecycle:?}");
1426 }
1427 }
1428
1429 #[tokio::test]
1433 async fn reload_aborted_waits_for_room_in_a_full_queue() {
1434 use crate::session::handle::echo::{meta_for, view_for};
1435 let id = crate::session::SessionId("abort-full".into());
1436 let (handle, mut ep) = SessionHandle::new(meta_for(&id), view_for());
1437 while handle.send(SessionCommand::Save).await.is_ok() {}
1438 let drain = tokio::spawn(async move {
1439 tokio::time::sleep(Duration::from_millis(100)).await;
1440 let mut got_abort = false;
1441 while let Some(a) = ep.cmd_rx.recv().await {
1442 if matches!(a.cmd, SessionCommand::ReloadAborted) {
1443 got_abort = true;
1444 break;
1445 }
1446 }
1447 got_abort
1448 });
1449 assert!(send_reload_aborted(&handle, Duration::from_secs(5)).await);
1450 assert!(drain.await.unwrap(), "delivered");
1451 let id = crate::session::SessionId("abort-stuck".into());
1453 let (stuck, _ep) = SessionHandle::new(meta_for(&id), view_for());
1454 while stuck.send(SessionCommand::Save).await.is_ok() {}
1455 assert!(!send_reload_aborted(&stuck, Duration::from_millis(100)).await);
1456 }
1457}