diff --git a/CHANGELOG.md b/CHANGELOG.md index a18212d..5bf38da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,10 @@ All notable changes to this project will be documented in this file. VS Code commands. Preserve source/history, actor identities, committed state, mailbox envelopes and retained logs; reject unfinished handlers, pause runnable actors on save and restore their scheduling eligibility with fresh incarnations. - Snapshot files are server-local and never overwrite existing destinations. + Snapshot files are server-local; replacement requires explicit authorization + and is atomic. +- Atomic snapshot replacement with `save --overwrite`; VS Code prompts before + replacing an existing server-local snapshot. - Native VS Code debugger attachment to failed actors for retained-state, reserved-message and failure inspection, plus DAP exception details for both diff --git a/README.md b/README.md index 2c75c06..91d72f4 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ command history are session-local and are not restored on reconnect. ```sh cargo run -p runa-cli -- save /path/to/image.runa.json +cargo run -p runa-cli -- save --overwrite /path/to/image.runa.json cargo run -p runa-cli -- restore /path/to/image.runa.json ``` @@ -104,9 +105,12 @@ 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 ` and `restore `. VS Code provides +The REPL also accepts `save `, `save --overwrite `, and +`restore `. 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. +server's filesystem, not the client's. Saving refuses an existing destination +unless overwrite is explicitly enabled; VS Code asks before replacing one. +Replacement atomically swaps in a fully written and synced snapshot. The server captures all actors at a handler boundary and leaves runnable actors paused after a successful save. Saving is rejected without changing the image if any handler is unfinished, including paused or awaiting execution. diff --git a/crates/runa-cli/src/main.rs b/crates/runa-cli/src/main.rs index 0d11455..7a323cf 100644 --- a/crates/runa-cli/src/main.rs +++ b/crates/runa-cli/src/main.rs @@ -226,8 +226,13 @@ struct Args { #[derive(Debug, clap::Subcommand)] enum CliCommand { - /// Save the image to a new server-local file and pause runnable actors. - Save { file: String }, + /// Save the image to a server-local file and pause runnable actors. + Save { + /// Atomically replace an existing destination file. + #[arg(long)] + overwrite: bool, + file: String, + }, /// Replace the image from a server-local file; resume previously runnable actors. Restore { file: String }, /// Replace committed state on a paused or failed actor without active execution. @@ -319,7 +324,7 @@ impl CliCommand { }) }; Ok(match self { - Self::Save { file } => Command::SaveImage { file }, + Self::Save { file, overwrite } => Command::SaveImage { file, overwrite }, Self::Restore { file } => Command::RestoreImage { file }, Self::SetState { actor, state: json } => { edit_request(actor_ref(&actor)?, Some(parse_value(&json, &state)?), None)? @@ -610,13 +615,28 @@ fn execute_line( } "help" => print_help(output), "save" | "restore" => { - let [file] = args else { - writeln!(output, "error: usage: {command} ")?; + let overwrite = command == "save" && args.contains(&"--overwrite"); + let positional: Vec<_> = args + .iter() + .copied() + .filter(|arg| *arg != "--overwrite") + .collect(); + let [file] = positional.as_slice() else { + writeln!( + output, + "error: usage: {command} {}", + if command == "save" { + "[--overwrite] " + } else { + "" + } + )?; return Ok(true); }; let request = if command == "save" { Command::SaveImage { file: (*file).to_owned(), + overwrite, } } else { Command::RestoreImage { @@ -1203,7 +1223,7 @@ fn print_help(output: &mut W) -> io::Result<()> { log-level \n\ logs [-f|--follow] [actor...]\n\ sources\n\ - save (pauses runnable actors; unfinished handlers reject save)\n\ + save [--overwrite] (pauses runnable actors; unfinished handlers reject save)\n\ restore (replaces image; resumes previously runnable actors)\n\ write \n\ commit \n\ @@ -1284,6 +1304,17 @@ mod tests { Command::Spawn { state: Value::Record(values), .. } if values["text"] == Value::String("two spaces".to_owned()) )); + let args = ::try_parse_from([ + "runa-cli", + "save", + "--overwrite", + "/tmp/image.json", + ]) + .unwrap(); + assert!(matches!( + args.command.request(&mut server()).unwrap(), + Command::SaveImage { file, overwrite: true } if file == "/tmp/image.json" + )); for args in [ vec!["runa-cli"], vec!["runa-cli", "actors"], diff --git a/crates/runa-protocol/src/lib.rs b/crates/runa-protocol/src/lib.rs index d539fd1..8a0b0f7 100644 --- a/crates/runa-protocol/src/lib.rs +++ b/crates/runa-protocol/src/lib.rs @@ -51,7 +51,7 @@ impl Request { } match &self.command { - Command::SaveImage { file } | Command::RestoreImage { file } => { + Command::SaveImage { file, .. } | Command::RestoreImage { file } => { if file.trim().is_empty() || file.len() > 4096 || file.contains('\0') { return Err(invalid_request( "snapshot file must be a nonempty server-local path of at most 4096 bytes", @@ -218,6 +218,8 @@ impl Request { pub enum Command { SaveImage { file: String, + #[serde(default)] + overwrite: bool, }, RestoreImage { file: String, diff --git a/crates/runa/src/lib.rs b/crates/runa/src/lib.rs index bff0ae8..c9242bd 100644 --- a/crates/runa/src/lib.rs +++ b/crates/runa/src/lib.rs @@ -66,9 +66,11 @@ impl Server { fn dispatch_command(&mut self, command: Command) -> Result { match command { - Command::SaveImage { file } => storage::save(&mut self.image, &file) - .map(|()| Reply::Acknowledged) - .map_err(api_error), + Command::SaveImage { file, overwrite } => { + storage::save(&mut self.image, &file, overwrite) + .map(|()| Reply::Acknowledged) + .map_err(api_error) + } Command::RestoreImage { file } => storage::restore(&mut self.image, &file) .map(|()| Reply::Acknowledged) .map_err(api_error), @@ -577,6 +579,7 @@ fn api_error(error: impl Into>) -> ProtocolError { let message = error.to_string(); let code = if message.starts_with("behavior revision conflict:") || message.starts_with("source identity conflict:") + || message.starts_with("snapshot destination already exists") { ErrorCode::Conflict } else if matches!( diff --git a/crates/runa/src/storage.rs b/crates/runa/src/storage.rs index 6d1cbc2..994a317 100644 --- a/crates/runa/src/storage.rs +++ b/crates/runa/src/storage.rs @@ -6,7 +6,11 @@ use runa_image_api::{ImageApi, ImageSnapshot}; const MAX_SNAPSHOT_BYTES: u64 = 64 * 1024 * 1024; -pub(crate) fn save(image: &mut ImageApi, file: &str) -> Result<(), Box> { +pub(crate) fn save( + image: &mut ImageApi, + file: &str, + overwrite: bool, +) -> Result<(), Box> { let snapshot = image.snapshot()?; let bytes = serde_json::to_vec_pretty(&snapshot)?; if bytes.len() as u64 > MAX_SNAPSHOT_BYTES { @@ -20,8 +24,20 @@ pub(crate) fn save(image: &mut ImageApi, file: &str) -> Result<(), Box Result<(), Box> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("replace.json"); + let file = path.to_str().unwrap(); + let mut image = ImageApi::new(); + image.write_source("actors/probe", None, SOURCE)?; + let actor = image.spawn( + "actors/probe", + Value::Integer(3), + NonZeroUsize::new(2).unwrap(), + )?; + save(&mut image, file, false)?; + let first = std::fs::read(&path)?; + assert!( + save(&mut image, file, false) + .unwrap_err() + .to_string() + .contains("already exists") + ); + assert_eq!(std::fs::read(&path)?, first); + image.resume(actor)?; + image.send(actor, Value::Integer(4))?; + image.tick(1, NonZeroUsize::new(1000).unwrap()); + assert_eq!(image.inspect(actor)?.state, Value::Integer(7)); + save(&mut image, file, true)?; + let second = std::fs::read(&path)?; + assert_ne!(first, second); + let mut restored = ImageApi::new(); + restore(&mut restored, file)?; + assert_eq!(restored.inspect(actor)?.state, Value::Integer(7)); + assert_eq!(restored.inspect(actor)?.status, Status::Idle); + + restored.send(actor, Value::Integer(1))?; + restored.tick(1, NonZeroUsize::new(1).unwrap()); + let unfinished = std::fs::read(&path)?; + assert!(save(&mut restored, file, true).is_err()); + assert_eq!(std::fs::read(&path)?, unfinished); + assert_eq!(restored.inspect(actor)?.status, Status::Running); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let target = dir.path().join("real.json"); + let link = dir.path().join("alias.json"); + std::fs::write(&target, b"preserve target")?; + symlink(&target, &link)?; + save(&mut image, link.to_str().unwrap(), true)?; + assert!(!std::fs::symlink_metadata(&link)?.file_type().is_symlink()); + assert_eq!(std::fs::read(target)?, b"preserve target"); + } + Ok(()) + } + #[test] fn temporary_and_pending_behaviors_restore_without_overwriting_shared_source() -> Result<(), Box> { @@ -181,7 +252,7 @@ mod tests { image.r#become(pending, "actors/next")?; image.send(actor, Value::Null)?; image.send(pending, Value::Null)?; - save(&mut image, file)?; + save(&mut image, file, false)?; restore(&mut image, file)?; assert!(image.has_temporary_behavior(actor)); assert_eq!(image.source("actors/probe"), Some(&source)); diff --git a/crates/runa/tests/local_client.rs b/crates/runa/tests/local_client.rs index 7710298..abd9336 100644 --- a/crates/runa/tests/local_client.rs +++ b/crates/runa/tests/local_client.rs @@ -148,7 +148,10 @@ fn startup_restore_uses_saved_image_without_bundled_sources_and_honors_env_overr .dispatch(runa_protocol::Request { version: runa_protocol::PROTOCOL_VERSION, request_id: "save-startup".into(), - command: Command::SaveImage { file: file.into() }, + command: Command::SaveImage { + file: file.into(), + overwrite: false, + }, }) .result .unwrap(); diff --git a/docs/protocol.md b/docs/protocol.md index 3f49772..bf92edb 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -51,7 +51,7 @@ behavior aliases. They exclude the `source/` root and `.rn` extension. | Operation | Parameters | Reply kind/data | | --- | --- | --- | -| `save_image` | `file`: new server-local destination | `acknowledged` | +| `save_image` | `file`: server-local destination, `overwrite`: explicit atomic replacement authorization (defaults false) | `acknowledged` | | `restore_image` | `file`: server-local snapshot to replace image | `acknowledged` | | `list_sources` | none | `sources`: file array | | `read_source` | `path` | `source`: file | @@ -75,8 +75,9 @@ behavior aliases. They exclude the `source/` root and `.rn` extension. `save_image` and `restore_image` are additive v5 operations. File paths must be nonempty, contain no NUL and fit 4096 UTF-8 bytes. Snapshot contents are stored on the server, not transferred through the 1 MiB WebSocket envelope. -Save refuses existing destinations and unfinished handlers, and leaves runnable -actors paused on success. Restore validates and recompiles the entire snapshot +Save refuses existing destinations unless `overwrite` is true, and refuses +unfinished handlers. An authorized replacement atomically publishes a complete +synced snapshot. Successful save leaves runnable actors paused. Restore validates and recompiles the entire snapshot before replacing an image that has no unfinished handlers. Saved runnable actors resume eligibility; previously paused/failed actors retain their status. Incarnations advance to invalidate old debugger sessions. Both successful @@ -94,7 +95,7 @@ uncommitted; compilation failures do not spawn an actor. Rename preserves history and atomically updates installed/pending actor path bindings when `update_actors` is true. In-use paths require that authorization; -existing destination paths are never overwritten. Delete discards working text +existing destination paths are never overwritten without explicit authorization. Delete discards working text and history and rejects installed/pending users unless all are failed, with an explicit stop-first error. Compiled artifacts are retained. diff --git a/docs/runtime.md b/docs/runtime.md index e126c53..73cb4b0 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -458,15 +458,18 @@ 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 ` and `restore ` are available in the CLI/REPL and through +`save ` (optionally `--overwrite`) and `restore ` 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, -syncs its data, publishes it without overwriting an existing destination, and -syncs the directory. The server serializes snapshot operations with all image +syncs its data, then publishes it without overwriting by default. An explicit +overwrite request atomically replaces the destination only after the new +snapshot is complete and synced. The destination directory is synced afterward. +The server serializes snapshot operations with all image commands; no scheduler tick runs during capture or disk I/O. Successful saves leave previously runnable actors paused. Failed capture/write operations do not -pause them. A final filesystem sync failure is reported explicitly; the -destination may already exist in that case. +pause them. A final filesystem sync failure is reported explicitly; after an +overwrite, the new destination may already be visible even when that final sync +fails. Snapshots contain exact source text and implementation identities, the compiler fingerprint, working sources (even invalid text), source commit history, diff --git a/vscode/README.md b/vscode/README.md index 6359ca3..c2b6484 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -71,7 +71,8 @@ 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 -without changing the image. Existing destination files are never overwritten. +without changing the image. Existing destination files require confirmation +before atomic replacement. Unsaved editor buffers are not part of the image snapshot; save desired source or actor edits first. diff --git a/vscode/src/client.ts b/vscode/src/client.ts index 632d535..1a14c45 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -427,8 +427,8 @@ export class ImageClient { await this.request({ operation: "tick", parameters: { ticks, instruction_budget: budget } }); } - async saveImage(file: string): Promise { - await this.snapshotFile("save_image", file); + async saveImage(file: string, overwrite = false): Promise { + await this.snapshotFile("save_image", file, overwrite); } async restoreImage(file: string): Promise { @@ -438,10 +438,13 @@ export class ImageClient { private async snapshotFile( operation: "save_image" | "restore_image", file: string, + overwrite = false, ): Promise { if (!file.trim() || Buffer.byteLength(file, "utf8") > 4096 || file.includes("\0")) throw new Error("Snapshot file must be a nonempty server-local path of at most 4096 bytes"); - await this.request({ operation, parameters: { file } }); + if (operation === "save_image") + await this.request({ operation, parameters: { file, overwrite } }); + else await this.request({ operation, parameters: { file } }); } private request(command: Command): Promise { diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index dc88c63..5611e43 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -7,6 +7,7 @@ import { SystemMessage, LOG_LEVELS, LogLevel, + ProtocolError, StepMode, } from "./protocol"; import { RefreshQueue } from "./refresh"; @@ -836,7 +837,19 @@ export function activate(context: vscode.ExtensionContext): { connection.check(); await connection.client.restoreImage(file); } else { - await connection.client.saveImage(file); + try { + await connection.client.saveImage(file); + } catch (error) { + if (!(error instanceof ProtocolError) || error.code !== "conflict") throw error; + const answer = await vscode.window.showWarningMessage( + "A snapshot already exists at this server-local path. Replace it atomically?", + { modal: true }, + "Replace Snapshot", + ); + if (answer !== "Replace Snapshot") return; + connection.check(); + await connection.client.saveImage(file, true); + } } connection.check(); await refresh(); diff --git a/vscode/src/protocol.ts b/vscode/src/protocol.ts index 2188ca4..fdc88a5 100644 --- a/vscode/src/protocol.ts +++ b/vscode/src/protocol.ts @@ -128,7 +128,8 @@ export interface DebuggerSnapshot { } export type Command = - | { operation: "save_image" | "restore_image"; parameters: { file: string } } + | { operation: "save_image"; parameters: { file: string; overwrite: boolean } } + | { operation: "restore_image"; parameters: { file: string } } | { operation: "set_breakpoints"; parameters: { actor_ref: string; implementation_id: string; lines: number[] }; diff --git a/vscode/test/client.test.ts b/vscode/test/client.test.ts index 72d2373..b560c77 100644 --- a/vscode/test/client.test.ts +++ b/vscode/test/client.test.ts @@ -27,10 +27,26 @@ test("only numeric loopback endpoints and valid ports are accepted", () => { test("image snapshot commands validate server-local paths and propagate failures", async (t) => { const operations: string[] = []; + let firstSave = true; const address = await fakeImage(t, (request, socket) => { operations.push(request.command.operation); assert.equal(request.command.parameters?.file, "/server/image.json"); - if (request.command.operation === "restore_image") + if (request.command.operation === "save_image") + assert.equal(typeof request.command.parameters?.overwrite, "boolean"); + if ( + request.command.operation === "save_image" && + !request.command.parameters?.overwrite && + firstSave + ) { + firstSave = false; + socket.send( + json({ + version: 5, + request_id: request.request_id, + result: { Err: { code: "conflict", message: "snapshot destination already exists" } }, + }), + ); + } else if (request.command.operation === "restore_image") socket.send( json({ version: 5, @@ -47,9 +63,13 @@ test("image snapshot commands validate server-local paths and propagate failures await assert.rejects(client.restoreImage(file), /server-local path/); } assert.deepEqual(operations, []); - await client.saveImage("/server/image.json"); + await assert.rejects( + client.saveImage("/server/image.json"), + /snapshot destination already exists/, + ); + await client.saveImage("/server/image.json", true); await assert.rejects(client.restoreImage("/server/image.json"), /unfinished handler/); - assert.deepEqual(operations, ["save_image", "restore_image"]); + assert.deepEqual(operations, ["save_image", "save_image", "restore_image"]); }); test("source step modes are sent explicitly and retain the default into behavior", async (t) => {