use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::Result;
use crossbeam_channel::Sender;
use super::ui::{ConfirmAction, Mode};
use super::{ClientId, Event, Server};
use crate::layout::PaneId;
use crate::paths;
use crate::persist::{self, SavedScrollback, SessionSnapshot};
const SCROLLBACK_EVERY: Duration = Duration::from_secs(10);
const SCROLLBACK_BUDGET: Duration = Duration::from_millis(4);
const BUFFERS_RETRY: Duration = Duration::from_secs(10);
impl Server {
pub(super) fn persist_unless_writing(&mut self) {
if self.persisting.lock().is_ok_and(|p| p.saves > 0) {
return;
}
self.persist();
}
pub(super) fn persist(&mut self) {
self.persist_dirty = false;
self.last_persist = Instant::now();
if self.workspaces.is_empty() && self.unrestored.is_empty() {
return;
}
if !self.config.save_scrollback {
self.scrollback.clear();
}
let panes = &self.panes;
self.scrollback.retain(|id, _| panes.contains_key(id));
let snap = persist::snapshot(self);
let text = serde_json::to_vec(&snap).ok();
let failed = self.persisting.lock().map_or(true, |p| p.state_failed);
if text.is_some() && text == self.persisted && !failed {
return;
}
self.persisted = text;
if let Ok(mut p) = self.persisting.lock() {
p.saves += 1;
}
let _ = self.persister.send(Persist::Save(self.session.clone(), snap));
}
pub(super) fn save_now(&mut self) -> Result<(), String> {
self.save_scrollback(true);
self.persist();
self.save_buffers(true);
let (done, written) = crossbeam_channel::bounded(1);
self.persister
.send(Persist::Flush(done))
.map_err(|_| "the layout could not be saved: its writer is gone".to_string())?;
written
.recv_timeout(Duration::from_secs(10))
.unwrap_or_else(|_| Err("the layout could not be saved: its writer did not finish in time".into()))
}
pub(super) fn save_scrollback(&mut self, all: bool) {
if !self.config.save_scrollback || self.workspaces.is_empty() {
return;
}
let failed = self.persisting.lock().map(|mut p| std::mem::take(&mut p.failed)).unwrap_or_default();
for s in self.scrollback.values_mut() {
s.retry |= s.file.as_ref().is_some_and(|f| failed.contains(f));
}
let start = Instant::now();
let mut due: Vec<(Option<Instant>, PaneId)> = self
.panes
.values()
.filter(|p| p.is_terminal() && !p.suspended && (all || p.history_stored()))
.filter_map(|p| {
let saved = self.scrollback.get(&p.id);
let taken = saved.map(|s| s.taken);
let changed = saved.is_none_or(|s| s.retry || p.last_output > s.taken);
let ripe = all || taken.is_none_or(|t| start >= t + SCROLLBACK_EVERY);
(changed && ripe).then_some((taken, p.id))
})
.collect();
due.sort();
for (_, id) in due {
if !all && start.elapsed() > SCROLLBACK_BUDGET {
break;
}
let taken = Instant::now();
let Some(text) = self.panes.get(&id).and_then(|p| p.scrollback_ansi()) else { continue };
let had = self.scrollback.get(&id).and_then(|s| s.file.clone());
if text.is_empty() {
self.persist_dirty |= had.is_some();
self.scrollback.insert(id, SavedScrollback { file: None, taken, retry: false });
continue;
}
self.persist_dirty |= had.is_none();
let file = had.unwrap_or_else(|| format!("{}-{id}.ans", self.scrollback_tag));
if self.persisting.lock().is_ok_and(|mut p| p.offer(&file, text)) {
let _ = self.persister.send(Persist::Scrollback { session: self.session.clone(), file: file.clone() });
}
self.scrollback.insert(id, SavedScrollback { file: Some(file), taken, retry: false });
}
}
pub fn mark_persist(&mut self) {
self.persist_dirty = true;
}
pub(super) fn restore(&mut self) {
let removing = std::thread::Builder::new().name("temps".into()).spawn(|| {
for dir in paths::written_dirs() {
let _ = paths::remove_files_in(&dir, paths::abandoned_temp);
}
});
if let Err(e) = removing {
self.warnings.push(format!("cannot remove the temporary files a killed kraal left: {e}"));
}
let interrupted = crate::runbooks::recover_interrupted(&self.session);
if !interrupted.is_empty() {
self.warnings.push(format!(
"kraal stopped while these runbooks ran: {} (their logs say how far they got)",
interrupted.join(", ")
));
}
let snap = match persist::load(&self.session) {
Ok(Some(s)) => s,
Ok(None) => return,
Err(e) => {
let kept = keep_aside(&paths::session_state_path(&self.session));
self.warnings.push(format!("could not restore the previous session ({e:#}); {kept}"));
return;
}
};
if let Err(e) = self.apply_snapshot(snap) {
self.warnings.push(format!("restore: {e:#}"));
}
}
pub(super) fn apply_snapshot(&mut self, snap: SessionSnapshot) -> Result<()> {
self.with_batch(|server| persist::apply(server, snap))
}
pub(super) fn ask_restart(&self) -> Mode {
let mut message = format!(
"Restart session {} on the kraal installed now? The layout, the scrollback, SSH shells and agents come back.",
self.session
);
if let Some(stops) = self.stops() {
message.push('\n');
message.push_str(&stops);
}
Mode::confirm(message, ConfirmAction::Restart)
}
pub(super) fn ask_quit(&self) -> Mode {
let message = match self.stops() {
Some(stops) => format!("Quit kraal? {stops}\nThe layout is saved for next time."),
None => "Quit kraal? The layout is saved for next time.".into(),
};
Mode::confirm(message, ConfirmAction::Quit)
}
fn stops(&self) -> Option<String> {
let mut lines = Vec::new();
for id in self.workspaces.iter().flat_map(|w| w.panes()) {
let Some(pane) = self.panes.get(&id) else { continue };
let program = if pane.shell_waiting() { None } else { pane.foreground_argv() };
let info = self.pane_info(id);
for what in persist::stops_with_server(&info, program.as_deref(), &pane.background_jobs()) {
lines.push(format!("\n {}", persist::stopping_line(&info, &what)));
}
}
(!lines.is_empty()).then(|| format!("These programs stop:{}", lines.concat()))
}
pub(super) fn quit(&mut self) {
match self.save_now() {
Ok(()) => self.quitting = true,
Err(e) => self.warnings.insert(0, format!("not quitting: {e}")),
}
}
pub(super) fn restart(&mut self, client: ClientId) {
let started = crate::client::installed_exe()
.and_then(|exe| crate::client::restart_detached(&exe, &self.session))
.map(crate::exec::reap_later);
match started {
Ok(()) => self.toast(client, "restarting the session…"),
Err(e) => self.toast(client, format!("cannot restart: {e:#}")),
}
}
}
pub(super) enum Persist {
Save(String, SessionSnapshot),
Scrollback {
session: String,
file: String,
},
Buffers,
Delete(String),
Flush(Sender<Result<(), String>>),
}
#[derive(Default)]
pub(super) struct Persisting {
texts: HashMap<String, Vec<u8>>,
buffers: Option<BuffersFile>,
saves: usize,
failed: Vec<String>,
state_failed: bool,
buffers_failed: Option<Instant>,
}
type BuffersFile = (std::path::PathBuf, Option<Vec<u8>>);
impl Persisting {
fn offer(&mut self, file: &str, text: Vec<u8>) -> bool {
self.texts.insert(file.to_string(), text).is_none()
}
fn take(&mut self, file: &str) -> Option<Vec<u8>> {
self.texts.remove(file)
}
pub(super) fn offer_buffers(&mut self, file: BuffersFile) -> bool {
self.buffers.replace(file).is_none()
}
pub(super) fn retry_buffers(&mut self, stopping: bool) -> bool {
self.buffers_failed.take_if(|at| stopping || at.elapsed() >= BUFFERS_RETRY).is_some()
}
}
pub(super) fn spawn_persister(
events: Sender<Event>,
shared: Arc<Mutex<Persisting>>,
) -> std::io::Result<Sender<Persist>> {
let (tx, rx) = crossbeam_channel::unbounded::<Persist>();
std::thread::Builder::new().name("persist".into()).spawn(move || {
let mut last: Result<(), String> = Ok(());
let mut failing: HashMap<String, String> = HashMap::new();
let mut buffers_failing: Option<String> = None;
while let Ok(first) = rx.recv() {
let mut queue = vec![first];
queue.extend(rx.try_iter());
for (i, job) in queue.iter().enumerate() {
match job {
Persist::Save(session, snap) => {
if let Ok(mut s) = shared.lock() {
s.saves = s.saves.saturating_sub(1);
}
if matches!(queue.get(i + 1), Some(Persist::Save(..))) {
continue;
}
last = persist::save(session, snap).map_err(|e| format!("{e:#}"));
if let Ok(mut s) = shared.lock() {
s.state_failed = last.is_err();
}
match &last {
Err(e) => {
let _ = events.send(Event::Warning(format!("saving session state: {e}")));
}
Ok(()) => {
persist::clean_scrollback(session, snap);
let named = persist::scrollback_files(snap);
failing.retain(|f, _| named.contains(f.as_str()));
}
}
}
Persist::Scrollback { session, file } => {
let Some(text) = shared.lock().ok().and_then(|mut s| s.take(file)) else { continue };
match persist::save_scrollback(session, file, &text) {
Ok(()) => {
failing.remove(file);
}
Err(e) => {
if failing.is_empty() {
let _ = events.send(Event::Warning(format!("saving a pane's scrollback: {e:#}")));
}
failing.insert(file.clone(), format!("{e:#}"));
if let Ok(mut s) = shared.lock() {
s.failed.push(file.clone());
}
}
}
}
Persist::Buffers => {
let Some((path, data)) = shared.lock().ok().and_then(|mut s| s.buffers.take()) else {
continue;
};
let written = super::buffers::write_file(&path, data.as_deref());
if let Ok(mut s) = shared.lock() {
s.buffers_failed = written.is_err().then(Instant::now);
}
match written {
Ok(()) => buffers_failing = None,
Err(e) => {
if buffers_failing.is_none() {
let _ = events.send(Event::Warning(format!("keeping the paste buffers: {e}")));
}
buffers_failing = Some(e.to_string());
}
}
}
Persist::Delete(session) => {
let _ = std::fs::remove_file(paths::session_state_path(session));
let _ = persist::forget_scrollback(session);
last = Ok(());
failing.clear();
}
Persist::Flush(done) => {
let _ = done.send(flush_answer(&last, &failing, buffers_failing.as_deref()));
}
}
}
}
})?;
Ok(tx)
}
fn flush_answer(
last: &Result<(), String>,
failing: &HashMap<String, String>,
buffers_failing: Option<&str>,
) -> Result<(), String> {
if let Err(e) = last {
return Err(format!("the layout could not be saved: {e}"));
}
match (failing.values().next(), buffers_failing) {
(Some(e), _) if failing.len() == 1 => Err(format!("the scrollback of a pane could not be saved: {e}")),
(Some(e), _) => Err(format!("the scrollback of {} panes could not be saved: {e}", failing.len())),
(None, Some(e)) => Err(format!("the paste buffers could not be saved: {e}")),
(None, None) => Ok(()),
}
}
fn keep_aside(path: &std::path::Path) -> String {
let aside = path.with_extension(format!("json.unreadable-{}", crate::accounts::now()));
match std::fs::rename(path, &aside) {
Ok(()) => format!("kept it as {}", paths::shorten_home(&aside)),
Err(e) => format!("it could not be kept aside ({e}): saving this session replaces it"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unreadable_state_is_kept_aside_or_said_not_to_be() {
let dir = std::env::temp_dir().join(format!("kraal-unreadable-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let state = dir.join("main.json");
std::fs::write(&state, "{").unwrap();
let kept = keep_aside(&state);
assert!(kept.starts_with("kept it as ") && !state.exists(), "{kept}");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
let lost = keep_aside(&state);
assert!(lost.starts_with("it could not be kept aside"), "{lost}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_slow_disk_keeps_one_text_per_file_waiting() {
let mut p = Persisting::default();
let mut jobs = 0;
for i in 0..100u8 {
jobs += usize::from(p.offer("1-2-3.ans", vec![i; 1 << 20]));
jobs += usize::from(p.offer("1-2-4.ans", vec![i; 10]));
}
let waiting: usize = p.texts.values().map(Vec::len).sum();
assert_eq!((waiting, jobs), ((1 << 20) + 10, 2));
assert_eq!(p.take("1-2-3.ans").map(|t| t[0]), Some(99), "the latest is written");
assert_eq!(p.take("1-2-3.ans"), None);
assert!(p.offer("1-2-3.ans", Vec::new()), "once written, the next text needs a job");
}
#[test]
fn a_slow_disk_keeps_the_latest_paste_buffers_waiting() {
let mut p = Persisting::default();
let path = std::path::PathBuf::from("main.json");
let mut jobs = 0;
for i in 0..100u8 {
jobs += usize::from(p.offer_buffers((path.clone(), Some(vec![i; 1 << 20]))));
}
let waiting = p.buffers.as_ref().and_then(|(_, data)| data.as_ref()).map(|d| (d.len(), d[0]));
assert_eq!((waiting, jobs), (Some((1 << 20, 99)), 1));
assert!(!p.offer_buffers((path.clone(), None)));
assert_eq!(p.buffers.take(), Some((path.clone(), None)));
assert!(p.offer_buffers((path, None)), "once taken, the next needs a job");
}
#[test]
fn a_failed_write_of_the_paste_buffers_is_tried_again() {
let mut p = Persisting::default();
assert!(!p.retry_buffers(true), "nothing failed");
p.buffers_failed = Some(Instant::now());
assert!(!p.retry_buffers(false), "not at once");
assert!(p.retry_buffers(true), "at once when the server stops");
assert!(!p.retry_buffers(true), "once");
p.buffers_failed = Instant::now().checked_sub(BUFFERS_RETRY);
assert!(p.retry_buffers(false));
}
#[test]
fn the_writer_says_what_could_not_be_saved() {
let none = HashMap::new();
assert_eq!(flush_answer(&Ok(()), &none, None), Ok(()));
let one = HashMap::from([("a.ans".to_string(), "No space left on device".to_string())]);
let said = flush_answer(&Ok(()), &one, None).unwrap_err();
assert_eq!(said, "the scrollback of a pane could not be saved: No space left on device");
let state = flush_answer(&Err("read-only file system".into()), &one, None).unwrap_err();
assert_eq!(state, "the layout could not be saved: read-only file system");
let buffers = flush_answer(&Ok(()), &none, Some("Not a directory")).unwrap_err();
assert_eq!(buffers, "the paste buffers could not be saved: Not a directory");
}
}