Skip to content

Commit bd4d2a5

Browse files
authored
feat: snapshot/resume — serialize interpreter state mid-execution (#954)
## Summary - Add `Bash::snapshot() -> Vec<u8>` and `Bash::from_snapshot(&[u8])` for serializing/restoring interpreter state between `exec()` calls - Captures shell state (variables, env, arrays, cwd, aliases, traps), VFS contents, and session counters - JSON serialization for Phase 1 (debuggable, human-readable) - JS/TS bindings: `snapshot()`, `restoreSnapshot()`, `Bash.fromSnapshot()` - `vfs_snapshot()`/`vfs_restore()` on `FileSystemExt` trait for `InMemoryFs`, `MountableFs`, `OverlayFs` ## Test plan - [x] 10 new snapshot tests pass (`cargo test --test snapshot_tests`) - [x] Full build clean (`cargo build`) - [x] `cargo fmt --check` clean - [x] `cargo clippy --all-targets --all-features -- -D warnings` clean Closes #930
1 parent 0ef3582 commit bd4d2a5

File tree

10 files changed

+490
-1
lines changed

10 files changed

+490
-1
lines changed

crates/bashkit-js/src/lib.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,60 @@ impl Bash {
365365
})
366366
}
367367

368+
// ========================================================================
369+
// Snapshot / Resume
370+
// ========================================================================
371+
372+
/// Serialize interpreter state (shell variables, VFS contents, counters) to bytes.
373+
///
374+
/// Returns a `Buffer` (Uint8Array) that can be persisted and used with
375+
/// `Bash.fromSnapshot()` to restore the session later.
376+
#[napi]
377+
pub fn snapshot(&self) -> napi::Result<napi::bindgen_prelude::Buffer> {
378+
block_on_with(&self.state, |s| async move {
379+
let bash = s.inner.lock().await;
380+
let bytes = bash
381+
.snapshot()
382+
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
383+
Ok(napi::bindgen_prelude::Buffer::from(bytes))
384+
})
385+
}
386+
387+
/// Restore interpreter state from a snapshot previously created with `snapshot()`.
388+
#[napi]
389+
pub fn restore_snapshot(&self, data: napi::bindgen_prelude::Buffer) -> napi::Result<()> {
390+
block_on_with(&self.state, |s| async move {
391+
let mut bash = s.inner.lock().await;
392+
bash.restore_snapshot(&data)
393+
.map_err(|e| napi::Error::from_reason(e.to_string()))
394+
})
395+
}
396+
397+
/// Create a new Bash instance from a snapshot.
398+
#[napi(factory)]
399+
pub fn from_snapshot(data: napi::bindgen_prelude::Buffer) -> napi::Result<Self> {
400+
let bash =
401+
RustBash::from_snapshot(&data).map_err(|e| napi::Error::from_reason(e.to_string()))?;
402+
let rt = tokio::runtime::Builder::new_current_thread()
403+
.enable_all()
404+
.build()
405+
.map_err(|e| napi::Error::from_reason(format!("Failed to create runtime: {e}")))?;
406+
Ok(Self {
407+
state: Arc::new(SharedState {
408+
inner: Mutex::new(bash),
409+
rt: tokio::sync::Mutex::new(rt),
410+
cancelled: Arc::new(AtomicBool::new(false)),
411+
username: None,
412+
hostname: None,
413+
max_commands: None,
414+
max_loop_iterations: None,
415+
python: false,
416+
external_functions: Vec::new(),
417+
external_handler: None,
418+
}),
419+
})
420+
}
421+
368422
// ========================================================================
369423
// VFS — direct filesystem access
370424
// ========================================================================

crates/bashkit-js/wrapper.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,52 @@ export class Bash {
282282
this.native.reset();
283283
}
284284

285+
// Snapshot / Resume
286+
287+
/**
288+
* Serialize interpreter state (variables, VFS, counters) to a Uint8Array.
289+
*
290+
* The snapshot can be persisted to disk, sent over the network, and later
291+
* used with `Bash.fromSnapshot()` to restore the session.
292+
*
293+
* @example
294+
* ```typescript
295+
* const bash = new Bash();
296+
* await bash.execute("x=42");
297+
* const snapshot = bash.snapshot();
298+
* // persist snapshot...
299+
* const bash2 = Bash.fromSnapshot(snapshot);
300+
* const r = await bash2.execute("echo $x"); // "42\n"
301+
* ```
302+
*/
303+
snapshot(): Uint8Array {
304+
return this.native.snapshot();
305+
}
306+
307+
/**
308+
* Restore interpreter state from a previously captured snapshot.
309+
* Preserves current configuration (limits, builtins) but replaces
310+
* shell state and VFS contents.
311+
*/
312+
restoreSnapshot(data: Uint8Array): void {
313+
this.native.restoreSnapshot(Buffer.from(data));
314+
}
315+
316+
/**
317+
* Create a new Bash instance from a snapshot.
318+
*
319+
* @example
320+
* ```typescript
321+
* const snapshot = existingBash.snapshot();
322+
* const restored = Bash.fromSnapshot(snapshot);
323+
* ```
324+
*/
325+
static fromSnapshot(data: Uint8Array): Bash {
326+
const instance = new Bash();
327+
instance.native = NativeBash.fromSnapshot(Buffer.from(data));
328+
return instance;
329+
}
330+
285331
// VFS — direct filesystem access
286332

287333
/** Read a file from the virtual filesystem as a UTF-8 string. */

crates/bashkit/src/fs/memory.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1612,6 +1612,15 @@ impl FileSystemExt for InMemoryFs {
16121612
fn limits(&self) -> FsLimits {
16131613
self.limits.clone()
16141614
}
1615+
1616+
fn vfs_snapshot(&self) -> Option<VfsSnapshot> {
1617+
Some(self.snapshot())
1618+
}
1619+
1620+
fn vfs_restore(&self, snapshot: &VfsSnapshot) -> bool {
1621+
self.restore(snapshot);
1622+
true
1623+
}
16151624
}
16161625

16171626
#[cfg(test)]

crates/bashkit/src/fs/mountable.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,16 @@ impl FileSystemExt for MountableFs {
496496
let (fs, resolved) = self.resolve(path);
497497
fs.mkfifo(&resolved, mode).await
498498
}
499+
500+
fn vfs_snapshot(&self) -> Option<super::VfsSnapshot> {
501+
// Delegate to root filesystem
502+
self.root.vfs_snapshot()
503+
}
504+
505+
fn vfs_restore(&self, snapshot: &super::VfsSnapshot) -> bool {
506+
// Delegate to root filesystem
507+
self.root.vfs_restore(snapshot)
508+
}
499509
}
500510

501511
#[cfg(test)]

crates/bashkit/src/fs/overlay.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,15 @@ impl FileSystemExt for OverlayFs {
806806
fn limits(&self) -> FsLimits {
807807
self.limits.clone()
808808
}
809+
810+
fn vfs_snapshot(&self) -> Option<super::VfsSnapshot> {
811+
Some(self.upper.snapshot())
812+
}
813+
814+
fn vfs_restore(&self, snapshot: &super::VfsSnapshot) -> bool {
815+
self.upper.restore(snapshot);
816+
true
817+
}
809818
}
810819

811820
#[cfg(test)]

crates/bashkit/src/fs/traits.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,23 @@ pub trait FileSystemExt: Send + Sync {
153153
fn limits(&self) -> FsLimits {
154154
FsLimits::unlimited()
155155
}
156+
157+
/// Take a snapshot of the filesystem contents for serialization.
158+
///
159+
/// Returns `None` if this filesystem implementation doesn't support snapshots.
160+
/// The default implementation returns `None`. `InMemoryFs` and filesystems
161+
/// wrapping it (e.g. `MountableFs`, `OverlayFs`) return `Some(snapshot)`.
162+
fn vfs_snapshot(&self) -> Option<super::VfsSnapshot> {
163+
None
164+
}
165+
166+
/// Restore filesystem contents from a snapshot.
167+
///
168+
/// Returns `false` if this filesystem doesn't support restore.
169+
/// The default implementation returns `false`.
170+
fn vfs_restore(&self, _snapshot: &super::VfsSnapshot) -> bool {
171+
false
172+
}
156173
}
157174

158175
/// Async virtual filesystem trait.

crates/bashkit/src/interpreter/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,17 @@ impl Interpreter {
10041004
self.traps = state.traps.clone();
10051005
}
10061006

1007+
/// Get a reference to the current execution counters.
1008+
pub fn counters(&self) -> &crate::limits::ExecutionCounters {
1009+
&self.counters
1010+
}
1011+
1012+
/// Restore session-level counters from a snapshot.
1013+
pub fn restore_session_counters(&mut self, session_commands: u64, session_exec_calls: u64) {
1014+
self.counters.session_commands = session_commands;
1015+
self.counters.session_exec_calls = session_exec_calls;
1016+
}
1017+
10071018
/// Set an output callback for streaming output during execution.
10081019
///
10091020
/// When set, the interpreter calls this callback with `(stdout_chunk, stderr_chunk)`

crates/bashkit/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ pub mod parser;
412412
/// Requires the `scripted_tool` feature.
413413
#[cfg(feature = "scripted_tool")]
414414
pub mod scripted_tool;
415+
mod snapshot;
415416
/// Tool contract for LLM integration
416417
pub mod tool;
417418
/// Structured execution trace events.
@@ -434,6 +435,7 @@ pub use limits::{
434435
ExecutionCounters, ExecutionLimits, LimitExceeded, MemoryBudget, MemoryLimits, SessionLimits,
435436
};
436437
pub use network::NetworkAllowlist;
438+
pub use snapshot::Snapshot;
437439
pub use tool::BashToolBuilder as ToolBuilder;
438440
pub use tool::{
439441
BashTool, BashToolBuilder, Tool, ToolError, ToolExecution, ToolImage, ToolOutput,

0 commit comments

Comments
 (0)