feat: add explicit atomic snapshot replacement

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-04 04:30:25 +01:00
parent 99dc794b30
commit d63580cc02
14 changed files with 200 additions and 41 deletions

View File

@ -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, 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
actors on save and restore their scheduling eligibility with fresh incarnations. 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, - Native VS Code debugger attachment to failed actors for retained-state,
reserved-message and failure inspection, plus DAP exception details for both reserved-message and failure inspection, plus DAP exception details for both

View File

@ -86,6 +86,7 @@ command history are session-local and are not restored on reconnect.
```sh ```sh
cargo run -p runa-cli -- save /path/to/image.runa.json 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 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; 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 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>`, `save --overwrite <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 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 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 paused after a successful save. Saving is rejected without changing the image
if any handler is unfinished, including paused or awaiting execution. if any handler is unfinished, including paused or awaiting execution.

View File

@ -226,8 +226,13 @@ struct Args {
#[derive(Debug, clap::Subcommand)] #[derive(Debug, clap::Subcommand)]
enum CliCommand { enum CliCommand {
/// Save the image to a new server-local file and pause runnable actors. /// Save the image to a server-local file and pause runnable actors.
Save { file: String }, 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. /// Replace the image from a server-local file; resume previously runnable actors.
Restore { file: String }, Restore { file: String },
/// Replace committed state on a paused or failed actor without active execution. /// Replace committed state on a paused or failed actor without active execution.
@ -319,7 +324,7 @@ impl CliCommand {
}) })
}; };
Ok(match self { Ok(match self {
Self::Save { file } => Command::SaveImage { file }, Self::Save { file, overwrite } => Command::SaveImage { file, overwrite },
Self::Restore { file } => Command::RestoreImage { file }, Self::Restore { file } => Command::RestoreImage { file },
Self::SetState { actor, state: json } => { Self::SetState { actor, state: json } => {
edit_request(actor_ref(&actor)?, Some(parse_value(&json, &state)?), None)? edit_request(actor_ref(&actor)?, Some(parse_value(&json, &state)?), None)?
@ -610,13 +615,28 @@ fn execute_line<W: Write>(
} }
"help" => print_help(output), "help" => print_help(output),
"save" | "restore" => { "save" | "restore" => {
let [file] = args else { let overwrite = command == "save" && args.contains(&"--overwrite");
writeln!(output, "error: usage: {command} <server-local-file>")?; let positional: Vec<_> = args
.iter()
.copied()
.filter(|arg| *arg != "--overwrite")
.collect();
let [file] = positional.as_slice() else {
writeln!(
output,
"error: usage: {command} {}<server-local-file>",
if command == "save" {
"[--overwrite] "
} else {
""
}
)?;
return Ok(true); return Ok(true);
}; };
let request = if command == "save" { let request = if command == "save" {
Command::SaveImage { Command::SaveImage {
file: (*file).to_owned(), file: (*file).to_owned(),
overwrite,
} }
} else { } else {
Command::RestoreImage { Command::RestoreImage {
@ -1203,7 +1223,7 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
log-level <actor> <trace|debug|info|warn|error|fatal>\n\ log-level <actor> <trace|debug|info|warn|error|fatal>\n\
logs [-f|--follow] [actor...]\n\ logs [-f|--follow] [actor...]\n\
sources\n\ sources\n\
save <new-server-local-file> (pauses runnable actors; unfinished handlers reject save)\n\ save [--overwrite] <server-local-file> (pauses runnable actors; unfinished handlers reject save)\n\
restore <server-local-file> (replaces image; resumes previously runnable actors)\n\ restore <server-local-file> (replaces image; resumes previously runnable actors)\n\
write <path> <local-source-file>\n\ write <path> <local-source-file>\n\
commit <path>\n\ commit <path>\n\
@ -1284,6 +1304,17 @@ mod tests {
Command::Spawn { state: Value::Record(values), .. } Command::Spawn { state: Value::Record(values), .. }
if values["text"] == Value::String("two spaces".to_owned()) if values["text"] == Value::String("two spaces".to_owned())
)); ));
let args = <Args as clap::Parser>::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 [ for args in [
vec!["runa-cli"], vec!["runa-cli"],
vec!["runa-cli", "actors"], vec!["runa-cli", "actors"],

View File

@ -51,7 +51,7 @@ impl Request {
} }
match &self.command { 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') { if file.trim().is_empty() || file.len() > 4096 || file.contains('\0') {
return Err(invalid_request( return Err(invalid_request(
"snapshot file must be a nonempty server-local path of at most 4096 bytes", "snapshot file must be a nonempty server-local path of at most 4096 bytes",
@ -218,6 +218,8 @@ impl Request {
pub enum Command { pub enum Command {
SaveImage { SaveImage {
file: String, file: String,
#[serde(default)]
overwrite: bool,
}, },
RestoreImage { RestoreImage {
file: String, file: String,

View File

@ -66,9 +66,11 @@ impl Server {
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> { fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
match command { match command {
Command::SaveImage { file } => storage::save(&mut self.image, &file) Command::SaveImage { file, overwrite } => {
storage::save(&mut self.image, &file, overwrite)
.map(|()| Reply::Acknowledged) .map(|()| Reply::Acknowledged)
.map_err(api_error), .map_err(api_error)
}
Command::RestoreImage { file } => storage::restore(&mut self.image, &file) Command::RestoreImage { file } => storage::restore(&mut self.image, &file)
.map(|()| Reply::Acknowledged) .map(|()| Reply::Acknowledged)
.map_err(api_error), .map_err(api_error),
@ -577,6 +579,7 @@ fn api_error(error: impl Into<Box<dyn std::error::Error>>) -> ProtocolError {
let message = error.to_string(); let message = error.to_string();
let code = if message.starts_with("behavior revision conflict:") let code = if message.starts_with("behavior revision conflict:")
|| message.starts_with("source identity conflict:") || message.starts_with("source identity conflict:")
|| message.starts_with("snapshot destination already exists")
{ {
ErrorCode::Conflict ErrorCode::Conflict
} else if matches!( } else if matches!(

View File

@ -6,7 +6,11 @@ use runa_image_api::{ImageApi, ImageSnapshot};
const MAX_SNAPSHOT_BYTES: u64 = 64 * 1024 * 1024; const MAX_SNAPSHOT_BYTES: u64 = 64 * 1024 * 1024;
pub(crate) fn save(image: &mut ImageApi, file: &str) -> Result<(), Box<dyn std::error::Error>> { pub(crate) fn save(
image: &mut ImageApi,
file: &str,
overwrite: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let snapshot = image.snapshot()?; let snapshot = image.snapshot()?;
let bytes = serde_json::to_vec_pretty(&snapshot)?; let bytes = serde_json::to_vec_pretty(&snapshot)?;
if bytes.len() as u64 > MAX_SNAPSHOT_BYTES { if bytes.len() as u64 > MAX_SNAPSHOT_BYTES {
@ -20,8 +24,20 @@ pub(crate) fn save(image: &mut ImageApi, file: &str) -> Result<(), Box<dyn std::
let mut temporary = tempfile::NamedTempFile::new_in(parent)?; let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
temporary.write_all(&bytes)?; temporary.write_all(&bytes)?;
temporary.as_file().sync_all()?; temporary.as_file().sync_all()?;
// Never overwrite an existing snapshot, including a symlink. if overwrite {
temporary.persist_noclobber(path)?; temporary.persist(path).map_err(|error| error.error)?;
} else {
temporary.persist_noclobber(path).map_err(|error| {
if error.error.kind() == std::io::ErrorKind::AlreadyExists {
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"snapshot destination already exists; explicitly enable overwrite",
)
} else {
error.error
}
})?;
}
File::open(parent)?.sync_all()?; File::open(parent)?.sync_all()?;
image.pause_after_save()?; image.pause_after_save()?;
Ok(()) Ok(())
@ -73,7 +89,7 @@ mod tests {
let current = image.source("actors/probe").unwrap().source_id; let current = image.source("actors/probe").unwrap().source_id;
image.write_source("actors/probe", Some(current), "invalid working source")?; image.write_source("actors/probe", Some(current), "invalid working source")?;
let before = image.inspect(running)?; let before = image.inspect(running)?;
save(&mut image, file)?; save(&mut image, file, false)?;
assert_eq!(image.inspect(running)?.status, Status::Paused); assert_eq!(image.inspect(running)?.status, Status::Paused);
let mut restored = ImageApi::new(); let mut restored = ImageApi::new();
restore(&mut restored, file)?; restore(&mut restored, file)?;
@ -129,7 +145,7 @@ mod tests {
image.send(actor, Value::Integer(1))?; image.send(actor, Value::Integer(1))?;
image.tick(1, NonZeroUsize::new(1).unwrap()); image.tick(1, NonZeroUsize::new(1).unwrap());
assert!( assert!(
save(&mut image, file) save(&mut image, file, false)
.unwrap_err() .unwrap_err()
.to_string() .to_string()
.contains("unfinished") .contains("unfinished")
@ -137,19 +153,74 @@ mod tests {
assert!(!path.exists()); assert!(!path.exists());
assert!(image.inspect(actor)?.execution.is_some()); assert!(image.inspect(actor)?.execution.is_some());
image.pause(actor)?; image.pause(actor)?;
assert!(save(&mut image, file).is_err()); assert!(save(&mut image, file, false).is_err());
image.resume(actor)?; image.resume(actor)?;
image.tick(1, NonZeroUsize::new(1000).unwrap()); image.tick(1, NonZeroUsize::new(1000).unwrap());
std::fs::write(&path, "existing snapshot")?; std::fs::write(&path, "existing snapshot")?;
assert!(save(&mut image, file).is_err()); assert!(save(&mut image, file, false).is_err());
assert_eq!(std::fs::read_to_string(&path)?, "existing snapshot"); assert_eq!(std::fs::read_to_string(&path)?, "existing snapshot");
assert_eq!(image.inspect(actor)?.status, Status::Idle); assert_eq!(image.inspect(actor)?.status, Status::Idle);
let missing = dir.path().join("missing/image.json"); let missing = dir.path().join("missing/image.json");
assert!(save(&mut image, missing.to_str().unwrap()).is_err()); assert!(save(&mut image, missing.to_str().unwrap(), false).is_err());
assert_eq!(image.inspect(actor)?.status, Status::Idle); assert_eq!(image.inspect(actor)?.status, Status::Idle);
Ok(()) Ok(())
} }
#[test]
fn overwrite_atomically_replaces_snapshots_only_after_boundary_validation()
-> Result<(), Box<dyn std::error::Error>> {
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] #[test]
fn temporary_and_pending_behaviors_restore_without_overwriting_shared_source() fn temporary_and_pending_behaviors_restore_without_overwriting_shared_source()
-> Result<(), Box<dyn std::error::Error>> { -> Result<(), Box<dyn std::error::Error>> {
@ -181,7 +252,7 @@ mod tests {
image.r#become(pending, "actors/next")?; image.r#become(pending, "actors/next")?;
image.send(actor, Value::Null)?; image.send(actor, Value::Null)?;
image.send(pending, Value::Null)?; image.send(pending, Value::Null)?;
save(&mut image, file)?; save(&mut image, file, false)?;
restore(&mut image, file)?; restore(&mut image, file)?;
assert!(image.has_temporary_behavior(actor)); assert!(image.has_temporary_behavior(actor));
assert_eq!(image.source("actors/probe"), Some(&source)); assert_eq!(image.source("actors/probe"), Some(&source));

View File

@ -148,7 +148,10 @@ fn startup_restore_uses_saved_image_without_bundled_sources_and_honors_env_overr
.dispatch(runa_protocol::Request { .dispatch(runa_protocol::Request {
version: runa_protocol::PROTOCOL_VERSION, version: runa_protocol::PROTOCOL_VERSION,
request_id: "save-startup".into(), request_id: "save-startup".into(),
command: Command::SaveImage { file: file.into() }, command: Command::SaveImage {
file: file.into(),
overwrite: false,
},
}) })
.result .result
.unwrap(); .unwrap();

View File

@ -51,7 +51,7 @@ behavior aliases. They exclude the `source/` root and `.rn` extension.
| Operation | Parameters | Reply kind/data | | 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` | | `restore_image` | `file`: server-local snapshot to replace image | `acknowledged` |
| `list_sources` | none | `sources`: file array | | `list_sources` | none | `sources`: file array |
| `read_source` | `path` | `source`: file | | `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 `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 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. on the server, not transferred through the 1 MiB WebSocket envelope.
Save refuses existing destinations and unfinished handlers, and leaves runnable Save refuses existing destinations unless `overwrite` is true, and refuses
actors paused on success. Restore validates and recompiles the entire snapshot 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 before replacing an image that has no unfinished handlers. Saved runnable actors
resume eligibility; previously paused/failed actors retain their status. resume eligibility; previously paused/failed actors retain their status.
Incarnations advance to invalidate old debugger sessions. Both successful 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 Rename preserves history and atomically updates installed/pending actor path
bindings when `update_actors` is true. In-use paths require that authorization; 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 and history and rejects installed/pending users unless all are failed, with an
explicit stop-first error. Compiled artifacts are retained. explicit stop-first error. Compiled artifacts are retained.

View File

@ -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 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. 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 <file>` (optionally `--overwrite`) 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,
syncs its data, publishes it without overwriting an existing destination, and syncs its data, then publishes it without overwriting by default. An explicit
syncs the directory. The server serializes snapshot operations with all image 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 commands; no scheduler tick runs during capture or disk I/O. Successful saves
leave previously runnable actors paused. Failed capture/write operations do not leave previously runnable actors paused. Failed capture/write operations do not
pause them. A final filesystem sync failure is reported explicitly; the pause them. A final filesystem sync failure is reported explicitly; after an
destination may already exist in that case. overwrite, the new destination may already be visible even when that final sync
fails.
Snapshots contain exact source text and implementation identities, the compiler Snapshots contain exact source text and implementation identities, the compiler
fingerprint, working sources (even invalid text), source commit history, fingerprint, working sources (even invalid text), source commit history,

View File

@ -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. **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
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 Unsaved editor buffers are not part of the image snapshot; save desired source
or actor edits first. or actor edits first.

View File

@ -427,8 +427,8 @@ export class ImageClient {
await this.request({ operation: "tick", parameters: { ticks, instruction_budget: budget } }); await this.request({ operation: "tick", parameters: { ticks, instruction_budget: budget } });
} }
async saveImage(file: string): Promise<void> { async saveImage(file: string, overwrite = false): Promise<void> {
await this.snapshotFile("save_image", file); await this.snapshotFile("save_image", file, overwrite);
} }
async restoreImage(file: string): Promise<void> { async restoreImage(file: string): Promise<void> {
@ -438,10 +438,13 @@ export class ImageClient {
private async snapshotFile( private async snapshotFile(
operation: "save_image" | "restore_image", operation: "save_image" | "restore_image",
file: string, file: string,
overwrite = false,
): Promise<void> { ): Promise<void> {
if (!file.trim() || Buffer.byteLength(file, "utf8") > 4096 || file.includes("\0")) 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"); 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<Reply> { private request(command: Command): Promise<Reply> {

View File

@ -7,6 +7,7 @@ import {
SystemMessage, SystemMessage,
LOG_LEVELS, LOG_LEVELS,
LogLevel, LogLevel,
ProtocolError,
StepMode, StepMode,
} from "./protocol"; } from "./protocol";
import { RefreshQueue } from "./refresh"; import { RefreshQueue } from "./refresh";
@ -836,7 +837,19 @@ export function activate(context: vscode.ExtensionContext): {
connection.check(); connection.check();
await connection.client.restoreImage(file); await connection.client.restoreImage(file);
} else { } else {
try {
await connection.client.saveImage(file); 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(); connection.check();
await refresh(); await refresh();

View File

@ -128,7 +128,8 @@ export interface DebuggerSnapshot {
} }
export type Command = 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"; operation: "set_breakpoints";
parameters: { actor_ref: string; implementation_id: string; lines: number[] }; parameters: { actor_ref: string; implementation_id: string; lines: number[] };

View File

@ -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) => { test("image snapshot commands validate server-local paths and propagate failures", async (t) => {
const operations: string[] = []; const operations: string[] = [];
let firstSave = true;
const address = await fakeImage(t, (request, socket) => { const address = await fakeImage(t, (request, socket) => {
operations.push(request.command.operation); operations.push(request.command.operation);
assert.equal(request.command.parameters?.file, "/server/image.json"); 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( socket.send(
json({ json({
version: 5, 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/); await assert.rejects(client.restoreImage(file), /server-local path/);
} }
assert.deepEqual(operations, []); 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/); 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) => { test("source step modes are sent explicitly and retain the default into behavior", async (t) => {