feat: restore saved images at server startup
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
65c08b04c6
commit
99dc794b30
|
|
@ -56,6 +56,10 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
### Added
|
### 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
|
- Explicit versioned disk image save/restore through the CLI, REPL, protocol and
|
||||||
VS Code commands. Preserve source/history, actor identities, committed state,
|
VS Code commands. Preserve source/history, actor identities, committed state,
|
||||||
mailbox envelopes and retained logs; reject unfinished handlers, pause runnable
|
mailbox envelopes and retained logs; reject unfinished handlers, pause runnable
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -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
|
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
|
The REPL also accepts `save <file>` and `restore <file>`. VS Code provides
|
||||||
**Runa: Save Image...** and **Runa: Restore Image...**. Paths refer to the
|
**Runa: Save Image...** and **Runa: Restore Image...**. Paths refer to the
|
||||||
server's filesystem, not the client's. Saving requires a new destination file.
|
server's filesystem, not the client's. Saving requires a new destination file.
|
||||||
|
|
|
||||||
|
|
@ -12,19 +12,28 @@ struct Args {
|
||||||
/// Loopback WebSocket listen address (port 0 selects an ephemeral port).
|
/// Loopback WebSocket listen address (port 0 selects an ephemeral port).
|
||||||
#[arg(long, env = "RUNA_LISTEN", default_value = "127.0.0.1:3737")]
|
#[arg(long, env = "RUNA_LISTEN", default_value = "127.0.0.1:3737")]
|
||||||
listen: SocketAddr,
|
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 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
|
server
|
||||||
.dispatch(Request {
|
.dispatch(Request {
|
||||||
version: PROTOCOL_VERSION,
|
version: PROTOCOL_VERSION,
|
||||||
request_id: "bootstrap-counter".to_owned(),
|
request_id: "bootstrap-image".to_owned(),
|
||||||
command: Command::WriteSource {
|
command,
|
||||||
path: "actors/counter".to_owned(),
|
|
||||||
expected_source_id: None,
|
|
||||||
source: include_str!("../../../runa-std/actors/counter.rn").to_owned(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.result
|
.result
|
||||||
.map_err(|error| io::Error::other(error.message))?;
|
.map_err(|error| io::Error::other(error.message))?;
|
||||||
|
|
@ -34,7 +43,7 @@ fn initial_image() -> io::Result<Server> {
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
match bind_loopback_listener(args.listen)
|
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,
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|
@ -67,4 +76,20 @@ mod tests {
|
||||||
assert!(Args::try_parse_from(["runa", "--stdio"]).is_err());
|
assert!(Args::try_parse_from(["runa", "--stdio"]).is_err());
|
||||||
assert!(Args::try_parse_from(["runa", "extra"]).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"))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,23 @@ fn install_counter_fixture(client: &mut WebSocketClient) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_server_with(args: &[&str], listen_env: &str) -> (ServerProcess, SocketAddr) {
|
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)
|
.args(args)
|
||||||
.env("RUNA_LISTEN", listen_env)
|
.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())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::piped())
|
.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"))
|
let output = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
|
||||||
.args(args)
|
.args(args)
|
||||||
.env_remove("RUNA_LISTEN")
|
.env_remove("RUNA_LISTEN")
|
||||||
|
.env_remove("RUNA_RESTORE")
|
||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!output.status.success());
|
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"))
|
let help = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
|
||||||
.arg("--help")
|
.arg("--help")
|
||||||
.env_remove("RUNA_LISTEN")
|
.env_remove("RUNA_LISTEN")
|
||||||
|
.env_remove("RUNA_RESTORE")
|
||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(help.status.success());
|
assert!(help.status.success());
|
||||||
let help = String::from_utf8(help.stdout).unwrap();
|
let help = String::from_utf8(help.stdout).unwrap();
|
||||||
assert!(help.contains("127.0.0.1:3737"));
|
assert!(help.contains("127.0.0.1:3737"));
|
||||||
assert!(help.contains("RUNA_LISTEN"));
|
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> {
|
fn watcher(address: SocketAddr) -> tungstenite::WebSocket<std::net::TcpStream> {
|
||||||
|
|
@ -286,6 +456,7 @@ impl Drop for ServerProcess {
|
||||||
fn websocket_server_accepts_local_protocol_requests() {
|
fn websocket_server_accepts_local_protocol_requests() {
|
||||||
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
|
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
|
||||||
.args(["--listen", "127.0.0.1:0"])
|
.args(["--listen", "127.0.0.1:0"])
|
||||||
|
.env_remove("RUNA_RESTORE")
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,10 @@ mailbox envelopes/capacities and retained logs are included. See
|
||||||
[runtime guarantees](runtime.md#image-save-and-restore) for exact scope.
|
[runtime guarantees](runtime.md#image-save-and-restore) for exact scope.
|
||||||
SQLite-backed per-transition durability, supervised recovery and execution-stack
|
SQLite-backed per-transition durability, supervised recovery and execution-stack
|
||||||
restoration remain planned.
|
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
|
## 11. Workspace Boundaries and Dependency Direction
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -449,6 +449,15 @@ capacity, and successful message effects.
|
||||||
|
|
||||||
## Image save and restore
|
## 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
|
`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
|
the trusted image protocol. Files are server-local, versioned JSON with a
|
||||||
64 MiB limit. Saving writes a temporary file in the destination directory,
|
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
|
## Limits
|
||||||
|
|
||||||
This is not yet a full image: no per-transition durable mailboxes,
|
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;
|
Behavior registry and mailbox data survive process exit only in explicit saves;
|
||||||
installation requires an idle handler boundary and does not upgrade active
|
installation requires an idle handler boundary and does not upgrade active
|
||||||
execution. The example counter script is bundled through `include_str!`.
|
execution. The example counter script is bundled through `include_str!`.
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ machine running the extension host, not necessarily the desktop.
|
||||||
|
|
||||||
### Disk image save/restore
|
### 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.
|
**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
|
It saves the whole image at a handler boundary and leaves runnable actors
|
||||||
paused. Unfinished handlers, including paused/awaiting execution, reject saving
|
paused. Unfinished handlers, including paused/awaiting execution, reject saving
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export async function startImage(): Promise<{
|
||||||
process.platform === "win32" ? "runa.exe" : "runa",
|
process.platform === "win32" ? "runa.exe" : "runa",
|
||||||
);
|
);
|
||||||
const child = spawn(executable, ["--listen", "127.0.0.1:0"], {
|
const child = spawn(executable, ["--listen", "127.0.0.1:0"], {
|
||||||
|
env: { ...process.env, RUNA_RESTORE: undefined },
|
||||||
stdio: ["ignore", "ignore", "pipe"],
|
stdio: ["ignore", "ignore", "pipe"],
|
||||||
});
|
});
|
||||||
let diagnostics = "";
|
let diagnostics = "";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue