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,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 } => {
|
||||||
.map(|()| Reply::Acknowledged)
|
storage::save(&mut self.image, &file, overwrite)
|
||||||
.map_err(api_error),
|
.map(|()| Reply::Acknowledged)
|
||||||
|
.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!(
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
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();
|
connection.check();
|
||||||
await refresh();
|
await refresh();
|
||||||
|
|
|
||||||
|
|
@ -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[] };
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue