use crate::value::Value;
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
pub(crate) fn session_dir() -> Option<PathBuf> {
if let Ok(d) = std::env::var("IRONSHELL_SESSION_DIR") {
return Some(PathBuf::from(d));
}
let key = std::env::var("IRONSHELL_SESSION").ok().unwrap_or_else(|| {
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
format!("{:x}", md5::compute(cwd.as_bytes()))
});
dirs::cache_dir().map(|c| c.join("ironshell").join("handles").join(key))
}
enum Persisted {
Written,
Taken,
Failed,
}
fn persist(dir: &Path, h: &Handle) -> Persisted {
if std::fs::create_dir_all(dir).is_err() {
return Persisted::Failed;
}
let Ok(json) = serde_json::to_string(&(&h.shape, h.items, h.bytes, &h.value)) else {
return Persisted::Failed;
};
let file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dir.join(format!("{}.json", h.id)));
match file {
Ok(mut f) => match f.write_all(json.as_bytes()) {
Ok(()) => Persisted::Written,
Err(_) => Persisted::Failed,
},
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Persisted::Taken,
Err(_) => Persisted::Failed,
}
}
fn load(id: &str) -> Option<Handle> {
let dir = session_dir()?;
if !is_handle_id(id) {
return None;
}
let raw = std::fs::read_to_string(dir.join(format!("{id}.json"))).ok()?;
let (shape, items, bytes, value): (String, usize, usize, Value) =
serde_json::from_str(&raw).ok()?;
Some(Handle {
id: id.to_string(),
shape,
items,
bytes,
value,
})
}
fn persisted_ids() -> Vec<String> {
match session_dir() {
Some(dir) => persisted_ids_in(&dir),
None => Vec::new(),
}
}
fn persisted_ids_in(dir: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut ids: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
name.strip_suffix(".json").map(|s| s.to_string())
})
.collect();
ids.sort_by_key(|id| id[1..].parse::<usize>().unwrap_or(0));
ids
}
const DEFAULT_THRESHOLD_BYTES: usize = 2048;
const PREVIEW_ROWS: usize = 3;
#[derive(Clone, Debug)]
pub struct Handle {
pub id: String,
pub shape: String,
pub items: usize,
pub bytes: usize,
pub value: Value,
}
impl Handle {
fn without_value(&self) -> Handle {
Handle {
id: self.id.clone(),
shape: self.shape.clone(),
items: self.items,
bytes: self.bytes,
value: Value::Null,
}
}
}
lazy_static::lazy_static! {
static ref STORE: Mutex<Vec<Handle>> = Mutex::new(Vec::new());
}
struct DirTally {
dir: PathBuf,
mtime: Option<SystemTime>,
count: usize,
max_id: usize,
}
static TALLY: Mutex<Option<DirTally>> = Mutex::new(None);
const PERSIST_TRIES: usize = 4;
const NEAR_CAP_LISTING: usize = 64;
fn dir_mtime(dir: &Path) -> Option<SystemTime> {
std::fs::metadata(dir).and_then(|m| m.modified()).ok()
}
fn session_count(dir: &Path, cap: usize) -> (usize, usize) {
let now = dir_mtime(dir);
let mut tally = TALLY.lock().unwrap_or_else(|e| e.into_inner());
if let (Some(t), Some(now)) = (tally.as_ref(), now) {
if t.dir == dir && t.mtime == Some(now) && t.count + NEAR_CAP_LISTING < cap {
return (t.count, t.max_id);
}
}
let ids = persisted_ids_in(dir);
let max_id = ids
.last()
.and_then(|id| id[1..].parse::<usize>().ok())
.unwrap_or(0);
*tally = Some(DirTally {
dir: dir.to_path_buf(),
mtime: now,
count: ids.len(),
max_id,
});
(ids.len(), max_id)
}
fn note_put(dir: &Path, number: usize) {
let mtime = dir_mtime(dir);
let mut tally = TALLY.lock().unwrap_or_else(|e| e.into_inner());
if let Some(t) = tally.as_mut().filter(|t| t.dir == dir) {
t.count += 1;
t.max_id = t.max_id.max(number);
t.mtime = mtime;
}
}
fn forget_tally() {
*TALLY.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
pub fn threshold_bytes() -> usize {
match std::env::var("IRONSHELL_HANDLE_BYTES") {
Ok(v) => v.trim().parse().unwrap_or(DEFAULT_THRESHOLD_BYTES),
Err(_) => DEFAULT_THRESHOLD_BYTES,
}
}
pub fn worth_handling(v: &Value, rendered_bytes: usize) -> bool {
let t = threshold_bytes();
if t == 0 || rendered_bytes <= t {
return false;
}
matches!(v, Value::Array(_) | Value::Record(_) | Value::Table(_))
}
fn item_count(v: &Value) -> usize {
match v {
Value::Array(a) => a.len(),
Value::Record(m) => m.len(),
_ => 1,
}
}
pub const MAX_HANDLES: usize = 4096;
pub fn put(v: Value, rendered_bytes: usize) -> anyhow::Result<Handle> {
put_capped(v, rendered_bytes, MAX_HANDLES)
}
fn put_capped(v: Value, rendered_bytes: usize, cap: usize) -> anyhow::Result<Handle> {
let mut store = STORE.lock().unwrap_or_else(|e| e.into_inner());
let dir = session_dir();
let (on_disk, last_number) = match &dir {
Some(d) => session_count(d, cap),
None => (0, 0),
};
let live = on_disk.max(store.len());
if live >= cap {
return Err(crate::safety::budget_exceeded(
"handle",
format!("the session already holds {live} result handles, the limit"),
"release one with handle_drop(id) before the next large result is handled",
));
}
let mut number = last_number.max(store.len()) + 1;
let mut h = Handle {
id: format!("h{number}"),
shape: crate::shapes::observe(&v),
items: item_count(&v),
bytes: rendered_bytes,
value: v,
};
if let Some(d) = &dir {
for _ in 0..PERSIST_TRIES {
match persist(d, &h) {
Persisted::Written => {
note_put(d, number);
break;
}
Persisted::Failed => break,
Persisted::Taken => {
forget_tally();
let (_, last) = session_count(d, cap);
number = last.max(store.len()) + 1;
h.id = format!("h{number}");
}
}
}
}
let described = h.without_value();
store.push(h);
Ok(described)
}
pub fn get(id: &str) -> Option<Value> {
{
let store = STORE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(h) = store.iter().find(|h| h.id == id) {
return Some(h.value.clone());
}
}
load(id).map(|h| h.value)
}
pub fn list() -> Vec<Handle> {
let store = STORE.lock().unwrap_or_else(|e| e.into_inner());
let mut out: Vec<Handle> = store.iter().map(Handle::without_value).collect();
for id in persisted_ids() {
if out.iter().any(|h| h.id == id) {
continue;
}
if let Some(h) = load(&id) {
out.push(Handle {
value: Value::Null,
..h
});
}
}
out.sort_by_key(|h| h.id[1..].parse::<usize>().unwrap_or(0));
out
}
fn is_handle_id(id: &str) -> bool {
id.strip_prefix('h')
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
pub fn drop_handle(id: &str) -> bool {
if !is_handle_id(id) {
return false;
}
let mut store = STORE.lock().unwrap_or_else(|e| e.into_inner());
let before = store.len();
store.retain(|h| h.id != id);
let in_memory = store.len() != before;
let on_disk = session_dir()
.map(|d| std::fs::remove_file(d.join(format!("{id}.json"))).is_ok())
.unwrap_or(false);
forget_tally();
in_memory || on_disk
}
pub fn clear() {
let mut store = STORE.lock().unwrap_or_else(|e| e.into_inner());
store.clear();
if let Some(dir) = session_dir() {
let _ = std::fs::remove_dir_all(dir);
}
forget_tally();
}
pub fn preview_of(v: &Value) -> Value {
match v {
Value::Array(items) => Value::Array(items.iter().take(PREVIEW_ROWS).cloned().collect()),
Value::Record(m) => {
let mut out = BTreeMap::new();
for (k, val) in m.iter().take(PREVIEW_ROWS) {
out.insert(k.clone(), Value::Str(crate::shapes::observe(val)));
}
Value::Record(out)
}
other => other.clone(),
}
}
pub fn omitted(v: &Value) -> usize {
item_count(v).saturating_sub(PREVIEW_ROWS)
}
#[cfg(test)]
mod tests {
use super::*;
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn store_lock() -> std::sync::MutexGuard<'static, ()> {
TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn big_array(n: usize) -> Value {
Value::Array((0..n).map(|i| Value::Int(i as i64)).collect())
}
#[test]
fn a_taken_id_is_skipped_and_the_other_writer_keeps_its_file() {
let _guard = store_lock();
let dir = std::env::temp_dir().join(format!("ish-handle-taken-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let previous = std::env::var_os("IRONSHELL_SESSION_DIR");
std::env::set_var("IRONSHELL_SESSION_DIR", &dir);
clear();
let first = put(big_array(3), 10).unwrap();
let foreign = dir.join("h2.json");
std::fs::write(&foreign, "theirs").unwrap();
note_put(&dir, 1);
let second = put(big_array(4), 10).unwrap();
let kept = std::fs::read_to_string(&foreign).unwrap();
clear();
match previous {
Some(p) => std::env::set_var("IRONSHELL_SESSION_DIR", p),
None => std::env::remove_var("IRONSHELL_SESSION_DIR"),
}
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(first.id, "h1");
assert_eq!(kept, "theirs", "the other writer's handle was overwritten");
assert_eq!(second.id, "h3", "the taken id was not skipped");
}
#[test]
fn persistence_encoding_is_lossless_where_plain_json_is_not() {
let mut m = std::collections::BTreeMap::new();
m.insert("s".to_string(), Value::Str("x".into()));
m.insert("i".to_string(), Value::Int(-3));
m.insert("f".to_string(), Value::Float(1.5));
m.insert("b".to_string(), Value::Bool(true));
m.insert("u".to_string(), Value::Uri("https://e.com".into()));
m.insert("n".to_string(), Value::Null);
m.insert(
"arr".to_string(),
Value::Array(vec![Value::Int(1), Value::Str("a".into())]),
);
let original = Value::Record(m);
let lossy = Value::from_json(&original.to_json());
assert_ne!(
lossy, original,
"if to_json/from_json became lossless this note should be revisited"
);
let encoded = serde_json::to_string(&original).expect("encode");
let back: Value = serde_json::from_str(&encoded).expect("decode");
assert_eq!(back, original, "the session encoding must be lossless");
}
#[test]
fn a_stored_value_comes_back_exactly() {
let _guard = store_lock();
clear();
let v = big_array(100);
let h = put(v.clone(), 9999).expect("under the cap");
assert_eq!(get(&h.id), Some(v), "a handle must be lossless");
}
#[test]
fn small_results_are_never_handled() {
let _guard = store_lock();
assert!(!worth_handling(&big_array(2), 10));
}
#[test]
fn an_unstructured_value_is_not_handled_however_large() {
let huge = Value::Str("x".repeat(100_000));
assert!(!worth_handling(&huge, 100_000));
}
#[test]
fn a_preview_reports_what_it_omits() {
let v = big_array(50);
assert_eq!(omitted(&v), 50 - PREVIEW_ROWS);
match preview_of(&v) {
Value::Array(a) => assert_eq!(a.len(), PREVIEW_ROWS),
other => panic!("expected an array preview, got {other:?}"),
}
}
#[test]
fn dropping_a_handle_frees_it_and_reports_whether_it_existed() {
let _guard = store_lock();
clear();
let h = put(big_array(10), 9999).expect("under the cap");
assert!(drop_handle(&h.id));
assert!(!drop_handle(&h.id), "dropping twice must report absence");
assert_eq!(get(&h.id), None);
}
#[test]
fn a_full_store_refuses_a_new_handle_instead_of_evicting() {
let _guard = store_lock();
let dir = std::env::temp_dir().join(format!("ae-handles-cap-{}", std::process::id()));
let previous = std::env::var_os("IRONSHELL_SESSION_DIR");
std::env::set_var("IRONSHELL_SESSION_DIR", &dir);
clear();
let kept: Vec<String> = (0..3)
.map(|_| {
put_capped(Value::Int(1), 9999, 3)
.expect("under the cap")
.id
})
.collect();
let refused = put_capped(Value::Int(1), 9999, 3).map_err(|e| format!("{e}"));
let first_still_there = get(&kept[0]).is_some();
clear();
match previous {
Some(v) => std::env::set_var("IRONSHELL_SESSION_DIR", v),
None => std::env::remove_var("IRONSHELL_SESSION_DIR"),
}
let _ = std::fs::remove_dir_all(&dir);
assert!(
first_still_there,
"the cap must not evict an existing handle"
);
let message = refused.expect_err("the fourth handle must be refused");
assert!(message.contains("E_BUDGET_EXCEEDED"), "{message}");
assert!(message.contains("handle_drop"), "{message}");
}
#[test]
fn a_zero_threshold_disables_handling() {
let _guard = store_lock();
std::env::set_var("IRONSHELL_HANDLE_BYTES", "0");
assert!(!worth_handling(&big_array(1000), 1_000_000));
std::env::remove_var("IRONSHELL_HANDLE_BYTES");
}
}