feat: add explicit atomic snapshot replacement
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
99dc794b30
commit
d63580cc02
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<W: Write>(
|
|||
}
|
||||
"help" => print_help(output),
|
||||
"save" | "restore" => {
|
||||
let [file] = args else {
|
||||
writeln!(output, "error: usage: {command} <server-local-file>")?;
|
||||
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} {}<server-local-file>",
|
||||
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<W: Write>(output: &mut W) -> io::Result<()> {
|
|||
log-level <actor> <trace|debug|info|warn|error|fatal>\n\
|
||||
logs [-f|--follow] [actor...]\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\
|
||||
write <path> <local-source-file>\n\
|
||||
commit <path>\n\
|
||||
|
|
@ -1284,6 +1304,17 @@ mod tests {
|
|||
Command::Spawn { state: Value::Record(values), .. }
|
||||
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 [
|
||||
vec!["runa-cli"],
|
||||
vec!["runa-cli", "actors"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -66,9 +66,11 @@ impl Server {
|
|||
|
||||
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
|
||||
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_err(api_error),
|
||||
.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<Box<dyn std::error::Error>>) -> 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!(
|
||||
|
|
|
|||
|
|
@ -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<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 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<dyn std::
|
|||
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
|
||||
temporary.write_all(&bytes)?;
|
||||
temporary.as_file().sync_all()?;
|
||||
// Never overwrite an existing snapshot, including a symlink.
|
||||
temporary.persist_noclobber(path)?;
|
||||
if overwrite {
|
||||
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()?;
|
||||
image.pause_after_save()?;
|
||||
Ok(())
|
||||
|
|
@ -73,7 +89,7 @@ mod tests {
|
|||
let current = image.source("actors/probe").unwrap().source_id;
|
||||
image.write_source("actors/probe", Some(current), "invalid working source")?;
|
||||
let before = image.inspect(running)?;
|
||||
save(&mut image, file)?;
|
||||
save(&mut image, file, false)?;
|
||||
assert_eq!(image.inspect(running)?.status, Status::Paused);
|
||||
let mut restored = ImageApi::new();
|
||||
restore(&mut restored, file)?;
|
||||
|
|
@ -129,7 +145,7 @@ mod tests {
|
|||
image.send(actor, Value::Integer(1))?;
|
||||
image.tick(1, NonZeroUsize::new(1).unwrap());
|
||||
assert!(
|
||||
save(&mut image, file)
|
||||
save(&mut image, file, false)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unfinished")
|
||||
|
|
@ -137,19 +153,74 @@ mod tests {
|
|||
assert!(!path.exists());
|
||||
assert!(image.inspect(actor)?.execution.is_some());
|
||||
image.pause(actor)?;
|
||||
assert!(save(&mut image, file).is_err());
|
||||
assert!(save(&mut image, file, false).is_err());
|
||||
image.resume(actor)?;
|
||||
image.tick(1, NonZeroUsize::new(1000).unwrap());
|
||||
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!(image.inspect(actor)?.status, Status::Idle);
|
||||
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);
|
||||
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]
|
||||
fn temporary_and_pending_behaviors_restore_without_overwriting_shared_source()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -427,8 +427,8 @@ export class ImageClient {
|
|||
await this.request({ operation: "tick", parameters: { ticks, instruction_budget: budget } });
|
||||
}
|
||||
|
||||
async saveImage(file: string): Promise<void> {
|
||||
await this.snapshotFile("save_image", file);
|
||||
async saveImage(file: string, overwrite = false): Promise<void> {
|
||||
await this.snapshotFile("save_image", file, overwrite);
|
||||
}
|
||||
|
||||
async restoreImage(file: string): Promise<void> {
|
||||
|
|
@ -438,10 +438,13 @@ export class ImageClient {
|
|||
private async snapshotFile(
|
||||
operation: "save_image" | "restore_image",
|
||||
file: string,
|
||||
overwrite = false,
|
||||
): Promise<void> {
|
||||
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<Reply> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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[] };
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue