feat: restore saved images at server startup

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-04 02:03:20 +01:00
parent 65c08b04c6
commit 99dc794b30
8 changed files with 244 additions and 10 deletions

View File

@ -56,6 +56,10 @@ All notable changes to this project will be documented in this file.
### Added
- Server startup restoration with `--restore <file>` / `RUNA_RESTORE`, reusing
snapshot validation and actor scheduling rules without merging bundled
sources or falling back to a fresh image on errors.
- Explicit versioned disk image save/restore through the CLI, REPL, protocol and
VS Code commands. Preserve source/history, actor identities, committed state,
mailbox envelopes and retained logs; reject unfinished handlers, pause runnable

View File

@ -89,6 +89,21 @@ cargo run -p runa-cli -- save /path/to/image.runa.json
cargo run -p runa-cli -- restore /path/to/image.runa.json
```
To open the snapshot when starting the server, use:
```sh
cargo run -p runa -- --restore /path/to/image.runa.json
# Alternatively:
RUNA_RESTORE=/path/to/image.runa.json mise run start
```
`--restore` overrides `RUNA_RESTORE`. Startup restoration validates the snapshot
before accepting clients and does not add or overwrite bundled sources.
Missing, malformed or incompatible snapshots fail startup rather than silently
starting a fresh image. Without either option, startup loads the bundled counter
source as before. Restored actors follow the same status rules as live restore;
the server does not execute messages until scheduler ticks are requested.
The REPL also accepts `save <file>` and `restore <file>`. VS Code provides
**Runa: Save Image...** and **Runa: Restore Image...**. Paths refer to the
server's filesystem, not the client's. Saving requires a new destination file.

View File

@ -12,19 +12,28 @@ struct Args {
/// Loopback WebSocket listen address (port 0 selects an ephemeral port).
#[arg(long, env = "RUNA_LISTEN", default_value = "127.0.0.1:3737")]
listen: SocketAddr,
/// Restore a server-local image snapshot instead of loading bundled sources.
#[arg(long, env = "RUNA_RESTORE")]
restore: Option<String>,
}
fn initial_image() -> io::Result<Server> {
fn initial_image(restore: Option<&str>) -> io::Result<Server> {
let mut server = Server::new();
let command = match restore {
Some(file) => Command::RestoreImage {
file: file.to_owned(),
},
None => Command::WriteSource {
path: "actors/counter".to_owned(),
expected_source_id: None,
source: include_str!("../../../runa-std/actors/counter.rn").to_owned(),
},
};
server
.dispatch(Request {
version: PROTOCOL_VERSION,
request_id: "bootstrap-counter".to_owned(),
command: Command::WriteSource {
path: "actors/counter".to_owned(),
expected_source_id: None,
source: include_str!("../../../runa-std/actors/counter.rn").to_owned(),
},
request_id: "bootstrap-image".to_owned(),
command,
})
.result
.map_err(|error| io::Error::other(error.message))?;
@ -34,7 +43,7 @@ fn initial_image() -> io::Result<Server> {
fn main() -> ExitCode {
let args = Args::parse();
match bind_loopback_listener(args.listen)
.and_then(|listener| serve_websocket(listener, initial_image()?))
.and_then(|listener| serve_websocket(listener, initial_image(args.restore.as_deref())?))
{
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
@ -67,4 +76,20 @@ mod tests {
assert!(Args::try_parse_from(["runa", "--stdio"]).is_err());
assert!(Args::try_parse_from(["runa", "extra"]).is_err());
}
#[test]
fn clap_accepts_restore_and_rejects_missing_file() {
let args = Args::try_parse_from(["runa", "--restore", "/images/saved image.json"]).unwrap();
assert_eq!(args.restore.as_deref(), Some("/images/saved image.json"));
assert!(Args::try_parse_from(["runa", "--restore"]).is_err());
let command = <Args as clap::CommandFactory>::command();
let restore = command
.get_arguments()
.find(|argument| argument.get_id() == "restore")
.unwrap();
assert_eq!(
restore.get_env(),
Some(std::ffi::OsStr::new("RUNA_RESTORE"))
);
}
}

View File

@ -39,9 +39,23 @@ fn install_counter_fixture(client: &mut WebSocketClient) {
}
fn start_server_with(args: &[&str], listen_env: &str) -> (ServerProcess, SocketAddr) {
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
start_server_with_restore(args, listen_env, None)
}
fn start_server_with_restore(
args: &[&str],
listen_env: &str,
restore: Option<&str>,
) -> (ServerProcess, SocketAddr) {
let mut command = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"));
command
.args(args)
.env("RUNA_LISTEN", listen_env)
.env_remove("RUNA_RESTORE");
if let Some(file) = restore {
command.env("RUNA_RESTORE", file);
}
let child = command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
@ -78,6 +92,7 @@ fn server_clap_supports_env_overrides_help_and_explicit_errors() {
let output = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
.args(args)
.env_remove("RUNA_LISTEN")
.env_remove("RUNA_RESTORE")
.output()
.unwrap();
assert!(!output.status.success());
@ -86,12 +101,167 @@ fn server_clap_supports_env_overrides_help_and_explicit_errors() {
let help = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
.arg("--help")
.env_remove("RUNA_LISTEN")
.env_remove("RUNA_RESTORE")
.output()
.unwrap();
assert!(help.status.success());
let help = String::from_utf8(help.stdout).unwrap();
assert!(help.contains("127.0.0.1:3737"));
assert!(help.contains("RUNA_LISTEN"));
assert!(help.contains("RUNA_RESTORE"));
}
#[test]
fn startup_restore_uses_saved_image_without_bundled_sources_and_honors_env_overrides() {
use runa_image_api::ImageApi;
use std::num::NonZeroUsize;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("saved image.json");
let file = path.to_str().unwrap();
let mut image = ImageApi::new();
image
.write_source(
"tests/startup",
None,
"pub fn handle(ctx, state, sender, message) { state + message }",
)
.unwrap();
let actor = image
.spawn(
"tests/startup",
Value::Integer(8),
NonZeroUsize::new(2).unwrap(),
)
.unwrap();
let paused = image
.spawn(
"tests/startup",
Value::Integer(9),
NonZeroUsize::new(2).unwrap(),
)
.unwrap();
image.pause(paused).unwrap();
image.send(actor, Value::Integer(3)).unwrap();
let mut server = runa::Server::with_image_api(image);
server
.dispatch(runa_protocol::Request {
version: runa_protocol::PROTOCOL_VERSION,
request_id: "save-startup".into(),
command: Command::SaveImage { file: file.into() },
})
.result
.unwrap();
let saved_bytes = std::fs::read(&path).unwrap();
for (args, env) in [
(
vec!["--listen", "127.0.0.1:0", "--restore", file],
Some("missing.json"),
),
(vec!["--listen", "127.0.0.1:0"], Some(file)),
] {
let (_server, address) = start_server_with_restore(&args, "invalid", env);
let mut client = WebSocketClient::connect(address).unwrap();
let Reply::Sources(sources) = client
.request(Command::ListSources)
.unwrap()
.result
.unwrap()
else {
panic!("expected sources")
};
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].path, "tests/startup");
let Reply::Actor(before) = client
.request(Command::InspectActor {
actor_ref: actor.to_string(),
})
.unwrap()
.result
.unwrap()
else {
panic!("expected actor")
};
assert_eq!(before.state, Value::Integer(8));
assert_eq!(before.incarnation, 2);
assert_eq!(before.status, runa_protocol::ActorStatusDto::Idle);
client
.request(Command::Tick {
ticks: 1,
instruction_budget: 1000,
})
.unwrap()
.result
.unwrap();
let Reply::Actor(after) = client
.request(Command::InspectActor {
actor_ref: actor.to_string(),
})
.unwrap()
.result
.unwrap()
else {
panic!("expected actor")
};
assert_eq!(after.state, Value::Integer(11));
let Reply::Actor(after) = client
.request(Command::InspectActor {
actor_ref: paused.to_string(),
})
.unwrap()
.result
.unwrap()
else {
panic!("expected actor")
};
assert_eq!(after.state, Value::Integer(9));
assert_eq!(after.status, runa_protocol::ActorStatusDto::Paused);
client.close().unwrap();
}
// The file and its original lifecycle statuses are not changed by startup.
assert_eq!(std::fs::read(path).unwrap(), saved_bytes);
}
#[test]
fn startup_restore_failures_exit_without_advertising_a_live_image() {
let directory = tempfile::tempdir().unwrap();
let invalid = directory.path().join("invalid.json");
std::fs::write(&invalid, "{").unwrap();
let missing = directory.path().join("missing.json");
let incompatible = directory.path().join("incompatible.json");
let mut snapshot =
serde_json::to_value(runa_image_api::ImageApi::new().snapshot().unwrap()).unwrap();
snapshot["version"] = 999.into();
std::fs::write(&incompatible, serde_json::to_vec(&snapshot).unwrap()).unwrap();
for file in [
invalid.to_str().unwrap(),
missing.to_str().unwrap(),
incompatible.to_str().unwrap(),
"",
] {
let output = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
.args(["--listen", "127.0.0.1:0", "--restore", file])
.env_remove("RUNA_LISTEN")
.env_remove("RUNA_RESTORE")
.output()
.unwrap();
assert!(!output.status.success());
let error = String::from_utf8(output.stderr).unwrap();
assert!(error.contains("runa:"), "{error}");
assert!(!error.contains("listening on"), "{error}");
}
let output = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
.args(["--listen", "127.0.0.1:0"])
.env("RUNA_RESTORE", missing)
.output()
.unwrap();
assert!(!output.status.success());
assert!(
!String::from_utf8(output.stderr)
.unwrap()
.contains("listening on")
);
}
fn watcher(address: SocketAddr) -> tungstenite::WebSocket<std::net::TcpStream> {
@ -286,6 +456,7 @@ impl Drop for ServerProcess {
fn websocket_server_accepts_local_protocol_requests() {
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
.args(["--listen", "127.0.0.1:0"])
.env_remove("RUNA_RESTORE")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()

View File

@ -324,6 +324,10 @@ mailbox envelopes/capacities and retained logs are included. See
[runtime guarantees](runtime.md#image-save-and-restore) for exact scope.
SQLite-backed per-transition durability, supervised recovery and execution-stack
restoration remain planned.
The server can explicitly open a snapshot on startup using `--restore` or
`RUNA_RESTORE`, with the same validation and scheduling rules as live restore.
Restored images are not merged with bundled sources, and failed restoration
does not fall back to a new image or serve clients.
## 11. Workspace Boundaries and Dependency Direction

View File

@ -449,6 +449,15 @@ capacity, and successful message effects.
## Image save and restore
The server accepts `--restore <file>` or `RUNA_RESTORE` to open an image at
startup; the explicit flag takes precedence. Startup uses the same restore
validation and incarnation/status rules as the protocol operation, without
bootstrapping bundled sources into the restored image. The listener is not
advertised and clients are not served until restoration succeeds. File,
validation or compilation failures exit with an error; there is no fallback
to a fresh image. Without a restore option, the bundled counter source is loaded
as before. No automatic scheduler ticks or failed-message retries are introduced.
`save <new-file>` and `restore <file>` are available in the CLI/REPL and through
the trusted image protocol. Files are server-local, versioned JSON with a
64 MiB limit. Saving writes a temporary file in the destination directory,
@ -497,7 +506,7 @@ changes after the last successful save can be lost on process exit.
## Limits
This is not yet a full image: no per-transition durable mailboxes,
transactional outbox, supervisors, GUI, or automatic image bootstrap.
transactional outbox, supervisors, GUI, or automatic checkpoint recovery.
Behavior registry and mailbox data survive process exit only in explicit saves;
installation requires an idle handler boundary and does not upgrade active
execution. The example counter script is bundled through `include_str!`.

View File

@ -63,6 +63,11 @@ machine running the extension host, not necessarily the desktop.
### Disk image save/restore
To reconnect to a saved image after stopping the server, start it with
`cargo run -p runa -- --restore /path/to/image.runa.json` or set `RUNA_RESTORE`
when using `mise run start`, then connect normally. Startup restoration adds
no bundled sources and executes no automatic ticks.
**Runa: Save Image...** prompts for a new file on the server's filesystem.
It saves the whole image at a handler boundary and leaves runnable actors
paused. Unfinished handlers, including paused/awaiting execution, reject saving

View File

@ -28,6 +28,7 @@ export async function startImage(): Promise<{
process.platform === "win32" ? "runa.exe" : "runa",
);
const child = spawn(executable, ["--listen", "127.0.0.1:0"], {
env: { ...process.env, RUNA_RESTORE: undefined },
stdio: ["ignore", "ignore", "pipe"],
});
let diagnostics = "";