Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .config/mise/tasks/ci.toml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ description = "CI's `mutants` job's command: the wrapper over cargo-mutants. The
# `--in-place` mutates this checkout, which the runner throws away, so rust-cache can restore
# dependencies into its target. Run from a worktree you keep, pass it without `--in-place`, or
# run `mise run mutants`.
# The CLI's parity test spawns microvms-core's `parity-tunnel-peer` example from the test
# binary's target directory, and a mutant's `cargo test -p microvms-cli` never builds another
# package's example, so the fixture is built first into the target `--in-place` tests use.
depends = ["parity:fixtures"]
tools = { "cargo:cargo-mutants" = "{{vars.cargo_mutants}}" }
run = "./tools/check-mutants.py"

Expand Down
29 changes: 21 additions & 8 deletions bindings/microvms-js/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,8 @@ export declare class BuildHookTimeout {
* MicroVM lifecycle by ID (get, list, suspend, resume, terminate, and wait) and image
* administration (list, delete, versions and their status, builds).
*
* Holds no lifecycle state, so it checks nothing a `Sandbox` would (STATE-5, STATE-7,
* STATE-12). Use it when a process has only an identifier.
* Holds no lifecycle state, so it checks nothing a `Sandbox` would (STATE-5, STATE-7). Use
* it when a process has only an identifier.
*/
export declare class ControlPlane {
/**
Expand All @@ -207,7 +207,10 @@ export declare class ControlPlane {
list(options?: ListMicrovmsOptions | undefined | null): Promise<Array<MicrovmSummary>>
/** `SuspendMicrovm`. Resolves once accepted; `waitForState` for SUSPENDED. */
suspend(microvmId: string): Promise<void>
/** `ResumeMicrovm`. Resolves once accepted; `waitForState` for RUNNING. */
/**
* `ResumeMicrovm`. Resolves once accepted; `waitForState` for RUNNING. A VM that ended
* before the resume, whatever ended it, rejects with `ERR_WINDOW_CLOSED` (STATE-12).
*/
resume(microvmId: string): Promise<void>
/** `TerminateMicrovm`. Resolves once accepted; `waitForState` for TERMINATED. */
terminate(microvmId: string): Promise<void>
Expand Down Expand Up @@ -996,11 +999,21 @@ export declare class Sandbox {
* # What the core refuses, before any wire call
*
* A resume after `terminate` (STATE-11) — a terminated VM never returns to RUNNING, and
* even a call the service accepted would hand back a different machine. A resume from
* anything but SUSPENDED (STATE-7). And a resume past the launch-time suspended window
* (STATE-12), which is the one worth knowing about: the `idlePolicy` terminates a
* suspended VM once that window passes, so there is nothing left to resume, and calling
* would cost the full poll timeout to learn something worse.
* even a call the service accepted would hand back a different machine. And a resume
* from anything but SUSPENDED (STATE-7).
*
* # What the service decides (STATE-12)
*
* How long the VM has been suspended. The `idlePolicy` terminates a VM the idle policy
* suspended once `suspendedSec` passes, and a VM this sandbox suspended stayed resumable
* 1283 s into a 60 s window (measured 2026-10-12, us-east-1). `ERR_WINDOW_CLOSED` is the
* service's answer once a resume reaches it: the service refused the resume, and the VM
* is TERMINATING or TERMINATED, whatever ended it. The sandbox is TERMINATED after it.
*
* A sandbox doesn't see a suspend the idle policy makes. It still holds the VM as
* RUNNING, so `resume` rejects with `ERR_INVALID_ARG` before any call (STATE-7). That
* suspend reaches a caller only through `Sandbox.adopt`, which reads the state from the
* service, or `ControlPlane.resume`, which holds no lifecycle to refuse from.
*
* Nothing is re-delivered: no run-hook payload, no token, no bootstrap. The in-memory
* token survived the freeze, and re-delivering it would hit the daemon's one-shot
Expand Down
7 changes: 4 additions & 3 deletions bindings/microvms-js/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,8 @@ pub struct DeleteImageOptions {
/// MicroVM lifecycle by ID (get, list, suspend, resume, terminate, and wait) and image
/// administration (list, delete, versions and their status, builds).
///
/// Holds no lifecycle state, so it checks nothing a `Sandbox` would (STATE-5, STATE-7,
/// STATE-12). Use it when a process has only an identifier.
/// Holds no lifecycle state, so it checks nothing a `Sandbox` would (STATE-5, STATE-7). Use
/// it when a process has only an identifier.
#[napi]
pub struct ControlPlane {
inner: Arc<CoreControlPlane>,
Expand Down Expand Up @@ -344,7 +344,8 @@ impl ControlPlane {
self.inner.suspend(&microvm_id).await.map_err(js_async)
}

/// `ResumeMicrovm`. Resolves once accepted; `waitForState` for RUNNING.
/// `ResumeMicrovm`. Resolves once accepted; `waitForState` for RUNNING. A VM that ended
/// before the resume, whatever ended it, rejects with `ERR_WINDOW_CLOSED` (STATE-12).
#[napi]
pub async fn resume(&self, microvm_id: String) -> Result<(), AsyncError> {
self.inner.resume(&microvm_id).await.map_err(js_async)
Expand Down
20 changes: 15 additions & 5 deletions bindings/microvms-js/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1144,11 +1144,21 @@ impl Sandbox {
/// # What the core refuses, before any wire call
///
/// A resume after `terminate` (STATE-11) — a terminated VM never returns to RUNNING, and
/// even a call the service accepted would hand back a different machine. A resume from
/// anything but SUSPENDED (STATE-7). And a resume past the launch-time suspended window
/// (STATE-12), which is the one worth knowing about: the `idlePolicy` terminates a
/// suspended VM once that window passes, so there is nothing left to resume, and calling
/// would cost the full poll timeout to learn something worse.
/// even a call the service accepted would hand back a different machine. And a resume
/// from anything but SUSPENDED (STATE-7).
///
/// # What the service decides (STATE-12)
///
/// How long the VM has been suspended. The `idlePolicy` terminates a VM the idle policy
/// suspended once `suspendedSec` passes, and a VM this sandbox suspended stayed resumable
/// 1283 s into a 60 s window (measured 2026-10-12, us-east-1). `ERR_WINDOW_CLOSED` is the
/// service's answer once a resume reaches it: the service refused the resume, and the VM
/// is TERMINATING or TERMINATED, whatever ended it. The sandbox is TERMINATED after it.
///
/// A sandbox doesn't see a suspend the idle policy makes. It still holds the VM as
/// RUNNING, so `resume` rejects with `ERR_INVALID_ARG` before any call (STATE-7). That
/// suspend reaches a caller only through `Sandbox.adopt`, which reads the state from the
/// service, or `ControlPlane.resume`, which holds no lifecycle to refuse from.
///
/// Nothing is re-delivered: no run-hook payload, no token, no bootstrap. The in-memory
/// token survived the freeze, and re-delivering it would hit the daemon's one-shot
Expand Down
36 changes: 25 additions & 11 deletions bindings/microvms-py/microvms.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -497,9 +497,9 @@ class ControlPlane:
MicroVM lifecycle by ID (get, list, suspend, resume, terminate, and wait) and image
administration (list, delete, versions and their status, builds).

Holds no lifecycle state, so it checks nothing a `Sandbox` would (STATE-5, STATE-7,
STATE-12): a suspend of a SUSPENDED VM is the service's to refuse. Use it when a
process has only an identifier, such as a durable workflow step in a fresh process.
Holds no lifecycle state, so it checks nothing a `Sandbox` would (STATE-5, STATE-7): a
suspend of a SUSPENDED VM is the service's to refuse. Use it when a process has only an
identifier, such as a durable workflow step in a fresh process.
"""
def __new__(cls, /, region: Region) -> ControlPlane:
"""
Expand Down Expand Up @@ -582,7 +582,8 @@ class ControlPlane:
"""
def resume(self, /, microvm_id: str) -> None:
"""
`ResumeMicrovm`. Returns once accepted; `wait_for_state` for RUNNING.
`ResumeMicrovm`. Returns once accepted; `wait_for_state` for RUNNING. A VM that ended
before the resume, whatever ended it, raises `WindowClosedError` (STATE-12).
"""
async def resume_async(self, /, microvm_id: str) -> None:
"""
Expand Down Expand Up @@ -2650,11 +2651,22 @@ class Sandbox:
# What the core refuses, before any wire call

A resume after `terminate` (STATE-11) — a terminated VM never returns to RUNNING,
and even a call the service accepted would hand back a different machine. A resume
from anything but SUSPENDED (STATE-7). And a resume past the launch-time suspended
window (STATE-12), which is the one worth knowing about: the `idlePolicy` terminates
a suspended VM once that window passes, so there is nothing left to resume, and
calling would cost the full poll timeout to learn something worse.
and even a call the service accepted would hand back a different machine. And a
resume from anything but SUSPENDED (STATE-7).

# What the service decides (STATE-12)

How long the VM has been suspended. The `idlePolicy` terminates a VM the idle policy
suspended once `suspended_sec` passes, and a VM this sandbox suspended stayed
resumable 1283 s into a 60 s window (measured 2026-10-12, us-east-1).
`WindowClosedError` is the service's answer once a resume reaches it: the service
refused the resume, and the VM is TERMINATING or TERMINATED, whatever ended it. The
sandbox is TERMINATED after it.

A sandbox doesn't see a suspend the idle policy makes. It still holds the VM as
RUNNING, so `resume` raises `InvalidArgError` before any call (STATE-7). That suspend
reaches a caller only through `Sandbox.adopt`, which reads the state from the service,
or `ControlPlane.resume`, which holds no lifecycle to refuse from.

Nothing is re-delivered: no run-hook payload, no token, no bootstrap. The in-memory
token survived the freeze, and re-delivering it would hit the daemon's one-shot
Expand Down Expand Up @@ -3849,6 +3861,8 @@ class UnexpectedError(MicrovmError):

class WindowClosedError(MicrovmError):
"""
ERR_WINDOW_CLOSED — the launch-time suspended window passed, so there is nothing to
resume. A longer window has to be set at launch on the next VM.
ERR_WINDOW_CLOSED — the MicroVM ended before the resume (the suspended window, the
maximum duration, or a terminate), and it can't be resumed; launch a new one. The
suspended window ends a VM the idle policy suspended; an explicit suspend stayed
resumable 1283 s into a 60 s window (measured 2026-10-12, us-east-1).
"""
9 changes: 5 additions & 4 deletions bindings/microvms-py/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -444,9 +444,9 @@ impl PyImageBuild {
/// MicroVM lifecycle by ID (get, list, suspend, resume, terminate, and wait) and image
/// administration (list, delete, versions and their status, builds).
///
/// Holds no lifecycle state, so it checks nothing a `Sandbox` would (STATE-5, STATE-7,
/// STATE-12): a suspend of a SUSPENDED VM is the service's to refuse. Use it when a
/// process has only an identifier, such as a durable workflow step in a fresh process.
/// Holds no lifecycle state, so it checks nothing a `Sandbox` would (STATE-5, STATE-7): a
/// suspend of a SUSPENDED VM is the service's to refuse. Use it when a process has only an
/// identifier, such as a durable workflow step in a fresh process.
#[pyclass(frozen, name = "ControlPlane", module = "microvms")]
pub struct PyControlPlane {
inner: Arc<ControlPlane>,
Expand Down Expand Up @@ -703,7 +703,8 @@ impl PyControlPlane {
Ok(runtime::spawn_shielded(self.suspend_op(microvm_id)).await?)
}

/// `ResumeMicrovm`. Returns once accepted; `wait_for_state` for RUNNING.
/// `ResumeMicrovm`. Returns once accepted; `wait_for_state` for RUNNING. A VM that ended
/// before the resume, whatever ended it, raises `WindowClosedError` (STATE-12).
fn resume(&self, py: Python<'_>, microvm_id: String) -> PyCoreResult<()> {
Ok(runtime::block_on(py, self.resume_op(microvm_id))?)
}
Expand Down
6 changes: 4 additions & 2 deletions bindings/microvms-py/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,10 @@ create_exception!(
microvms,
WindowClosedError,
MicrovmError,
"ERR_WINDOW_CLOSED — the launch-time suspended window passed, so there is nothing to \
resume. A longer window has to be set at launch on the next VM."
"ERR_WINDOW_CLOSED — the MicroVM ended before the resume (the suspended window, the \
maximum duration, or a terminate), and it can't be resumed; launch a new one. The \
suspended window ends a VM the idle policy suspended; an explicit suspend stayed \
resumable 1283 s into a 60 s window (measured 2026-10-12, us-east-1)."
);
create_exception!(
microvms,
Expand Down
21 changes: 16 additions & 5 deletions bindings/microvms-py/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1904,11 +1904,22 @@ impl PySandbox {
/// # What the core refuses, before any wire call
///
/// A resume after `terminate` (STATE-11) — a terminated VM never returns to RUNNING,
/// and even a call the service accepted would hand back a different machine. A resume
/// from anything but SUSPENDED (STATE-7). And a resume past the launch-time suspended
/// window (STATE-12), which is the one worth knowing about: the `idlePolicy` terminates
/// a suspended VM once that window passes, so there is nothing left to resume, and
/// calling would cost the full poll timeout to learn something worse.
/// and even a call the service accepted would hand back a different machine. And a
/// resume from anything but SUSPENDED (STATE-7).
///
/// # What the service decides (STATE-12)
///
/// How long the VM has been suspended. The `idlePolicy` terminates a VM the idle policy
/// suspended once `suspended_sec` passes, and a VM this sandbox suspended stayed
/// resumable 1283 s into a 60 s window (measured 2026-10-12, us-east-1).
/// `WindowClosedError` is the service's answer once a resume reaches it: the service
/// refused the resume, and the VM is TERMINATING or TERMINATED, whatever ended it. The
/// sandbox is TERMINATED after it.
///
/// A sandbox doesn't see a suspend the idle policy makes. It still holds the VM as
/// RUNNING, so `resume` raises `InvalidArgError` before any call (STATE-7). That suspend
/// reaches a caller only through `Sandbox.adopt`, which reads the state from the service,
/// or `ControlPlane.resume`, which holds no lifecycle to refuse from.
///
/// Nothing is re-delivered: no run-hook payload, no token, no bootstrap. The in-memory
/// token survived the freeze, and re-delivering it would hit the daemon's one-shot
Expand Down
14 changes: 14 additions & 0 deletions changelog.d/597.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
- **A resume past the suspended window reaches the service (#597).** `Sandbox::resume` refused
with `ERR_WINDOW_CLOSED` once `suspendedDurationSeconds` had passed since the sandbox's own
`suspend()`, before any call. The service didn't hold an explicit suspend to that window:
measured 2026-10-12 in us-east-1, two VMs suspended with `SuspendMicrovm`, under the idle
case's own policy, resumed about 910 s into a 60 s window, and a third at 1283 s, so the
client refused resumes the service honors. AWS doesn't promise that behavior, and the client
no longer depends on either answer. The window ends a VM the idle
policy suspended, and the service then refuses the resume with a `ValidationException`.
`ERR_WINDOW_CLOSED` now reports that refusal, for a VM `GetMicrovm` reports TERMINATING or
TERMINATED, with its state and `stateReason`, and the sandbox is TERMINATED after it. The
mapping is core's `ControlPlane::resume`, so `microvm resume`, which exited `ERR_PLATFORM`
(9) there, now exits `ERR_WINDOW_CLOSED` (8), and both bindings' `ControlPlane.resume` raise
the same code. A VM a terminate or its maximum duration ended gets the same refusal, so the
error names no cause, and it carries the service's refusal as its source.
20 changes: 10 additions & 10 deletions crates/microvms-app/src/clock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
//! sources: the control plane's read `std::time::Instant` and the session's read tokio's.
//! Under a deterministic simulator those disagree. tokio's clock is virtual there and `std`'s
//! isn't, so a control-plane wait whose sleeps advanced virtual time measured its deadline
//! on the host's real time and never reached it. `Sandbox` already times the suspended window
//! on the plane's clock rather than its own, so a window isn't measured on one clock while
//! the polls run on another. One trait, with tokio's clock in production, keeps that property
//! for everything that reads time. The trait is re-exported at `control::Clock` and at
//! `session::Clock`, the paths each half had.
//! on the host's real time and never reached it. `Sandbox` already waits on the plane's clock
//! rather than one of its own, so a deadline isn't measured on one clock while the polls sleep
//! on another. One trait, with tokio's clock in production, keeps that property for everything
//! that reads time. The trait is re-exported at `control::Clock` and at `session::Clock`, the
//! paths each half had.
//!
//! # Why a trait rather than `tokio::time::pause`
//!
Expand All @@ -24,11 +24,11 @@
//! # Monotonic and wall time are separate readings
//!
//! [`Clock::elapsed`] is monotonic, the same reasoning the Python client's injectable
//! `time.monotonic` carried: the suspended window is a *duration*, and a wall clock that steps
//! backward would reopen a closed one. [`Clock::unix_now`] is the wall reading, for the values
//! that are dates rather than durations: a name record's timestamp, an exec id, the cost
//! engine's today. Deriving one from the other would make a stepped wall clock move a
//! deadline.
//! `time.monotonic` carried: a wait's deadline and a proxy token's age are *durations*, and a
//! wall clock that steps backward would hold a passed deadline open. [`Clock::unix_now`] is the
//! wall reading, for the values that are dates rather than durations: a name record's
//! timestamp, an exec id, the cost engine's today. Deriving one from the other would make a
//! stepped wall clock move a deadline.

use std::fmt;
use std::future::Future;
Expand Down
Loading
Loading