Skip to content

VM lifecycle: suspend, resume, idle timer, suspend cap, eight-hour cap - #35

Merged
lex00 merged 2 commits into
mainfrom
feature/11-suspend-resume
Jul 31, 2026
Merged

lex00 merged 2 commits into
mainfrom
feature/11-suspend-resume

Conversation

@lex00

@lex00 lex00 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Stacked on #34 — base is feature/6-recording, so review that one first.

SuspendMicrovm walks RUNNING to SUSPENDED through SUSPENDING on the clock. ResumeMicrovm goes SUSPENDED straight back to RUNNING with no state between, per the recorded asymmetry: the same five-second poll that caught PENDING on the initial launch saw nothing at all on the way back, and the enum has no RESUMING to occupy anyway.

The generation counter

clock.Clock has no cancel, by design. Each VM carries a counter bumped on every state change; a timer captures it when armed and does nothing if the VM has moved on. That is what makes a resume during SUSPENDING safe — the in-flight transition finds a changed generation and declines to land — and what stops the first suspension's cap from reclaiming a VM that has since suspended a second time. Traffic resets reuse the shape: the idle timer fires, finds activity newer than it expected, and re-arms for the remainder.

The three timers: maxIdleDurationSeconds without endpoint traffic moves RUNNING to SUSPENDING, suspendedDurationSeconds in SUSPENDED reclaims the VM, and eight hours ends the session whatever state it is in.

The state marker

A monotonic counter Touch bumps and nothing resets, so #12's endpoint stub can serve it back across a suspend and resume to prove the VM was not rebuilt. Deliberately absent from the GetMicrovm body — it is m80's own instrumentation, and putting it on a modeled response would be an invented member on the wire. TestMarkerIsNotOnTheWire pins that.

Scope note: VM state is now serialized

Transitions run on clock callbacks, which under clock.Real are separate goroutines, while handlers read the same fields to build a response. Every test used clock.Test, whose callbacks run on the test goroutine, so -race could never see the collision. Probing the unmodified tree under -race shows it plainly:

WARNING: DATA RACE
Write at 0x00c0001781f0 by goroutine 16:
  vms.(*Service).Run.func1()  internal/vms/vms.go:115
Previous read at 0x00c0001781f0 by goroutine 10:
  vms.detail()                internal/vms/handlers.go:164

Handlers now render a snapshot taken under the service mutex, and TestTransitionsAndHandlersDoNotRace runs the real clock so the detector has something to look at. #11 adds three more timers per VM, so this could not wait.

Unrecorded behavior, and what I guessed

Suspending an already suspended VM, or one on its way to TERMINATED, is a no-op answered 200; PENDING is allowed through. None of that was recorded. Idempotence is the safer guess than an invented error for a reconciler that may re-issue the call. The one recorded case is pinned: any mutation of a TERMINATED VM is a plain 400 ValidationException, neither modeled conflict type, which is what case 82 asserts.

Cap-driven termination reports stateReason: "Success.", same as an explicit one. The service's wording for those paths was never recorded and a different string would be invention.

Optional steps

ResumeMicrovm was never sent by the suite at all: auth-token-while-suspended is #12's operation and sits between suspend and resume in case 40, so its 501 halted the case first. #11's acceptance asks that the resume transition match the recorded fixture, and there was no way to show it.

"optional": true on a step exempts it from halting its scenario when the target answers 501, and nothing else — a step that genuinely fails still halts however it is marked, and a step carrying capture should never be marked. Both halves are pinned by tests.

vm-suspend-resume now runs end to end, and resume and get-until-running-again pass against their recorded fixtures.

Result

52 pass, 0 fail against a fresh m80, up from 43 on the parent. 23/29 operations exercised, up from 22. go build, go vet, gofmt and go test -race clean.

Closes #11

lex00 added a commit that referenced this pull request Jul 31, 2026
main gained the mkdocs site and the strict docs gate (#27) and the floci
architecture findings (#28) after this branch was cut, and the two sides
had both reformatted 60-connectors.json.

The conflict is a genuine union rather than a pick. main added the
subset:floci tag, correcting which cases the floci subset covers. This
branch added the live-recorded request members the connector create and
update actually need — AssociatedComputeResourceTypes, NetworkProtocol,
ClientToken and OperatorRole, with the operatorRoleArn param behind them.
Both are kept; dropping either would lose a real finding.

This also unblocks CI on the PR. A conflicting pull request has no
mergeable ref for pull_request workflows to run against, which is why #34
showed no checks at all while #35 and #36 were green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6T4MeDN1RBiWaNtud6x77
lex00 and others added 2 commits July 30, 2026 22:52
SuspendMicrovm walks RUNNING to SUSPENDED through SUSPENDING on the
clock. ResumeMicrovm goes SUSPENDED straight back to RUNNING with no
state between, per the recorded asymmetry: the same five-second poll that
caught PENDING on the initial launch saw nothing at all on the way back,
and the enum has no RESUMING to occupy anyway.

Three timers bound a VM's life. maxIdleDurationSeconds without endpoint
traffic moves RUNNING to SUSPENDING; suspendedDurationSeconds in
SUSPENDED reclaims the VM; eight hours ends the session whatever state it
is in. clock.Clock has no cancel, by design, so each VM carries a
generation counter bumped on every state change: a timer captures it when
armed and does nothing if the VM has moved on. That is what makes a
resume during SUSPENDING safe, and what stops the first suspension's cap
from reclaiming a VM that has since suspended a second time. Traffic
resets use the same shape — the idle timer fires, finds activity newer
than it expected, and re-arms for the remainder.

The state marker is a monotonic counter that Touch bumps and nothing
resets, so #12's endpoint stub can serve it back across a suspend and
resume to prove the VM was not rebuilt. It is deliberately absent from
the GetMicrovm body: it is m80's own instrumentation, and putting it on a
modeled response would be an invented member on the wire. A test pins
that.

Also serializes VM state. Transitions run on clock callbacks, which under
clock.Real are separate goroutines, while handlers read the same fields
to build a response. Every test used clock.Test, whose callbacks run on
the test goroutine, so -race could never see the collision; probing the
unmodified tree under -race shows it plainly, Run's transition writing
vm.State against detail reading it. Handlers now render a snapshot taken
under the service mutex, and TestTransitionsAndHandlersDoNotRace runs the
real clock so the detector has something to look at. #11 adds three more
timers per VM, so this could not wait.

Suspending an already suspended VM, or one on its way to TERMINATED, is a
no-op answered 200; PENDING is allowed through. None of that was
recorded, and idempotence is the safer guess than an invented error for a
reconciler that may re-issue the call. The one recorded case is pinned:
any mutation of a TERMINATED VM is a plain 400 ValidationException,
neither modeled conflict type, which is what case 82 asserts.

Conformance against a fresh m80: 47 pass, 0 fail, up from 43 on the
parent branch. Coverage stays at 22 of 29 operations exercised, because
ResumeMicrovm is still never sent — auth-token-while-suspended is #12's
operation and sits between suspend and resume in case 40, so its 501
halts the case first. The unit tests cover the transition meanwhile.

Closes #11

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6T4MeDN1RBiWaNtud6x77
A failing step halts its scenario because the state the later steps assume
is no longer trustworthy. An unimplemented one halted it too, which is
wrong for a step nothing downstream depends on.

CreateMicrovmAuthToken sits between suspend and resume in
vm-suspend-resume for a good reason — that is the only place a live
recording can observe a token issued against a suspended VM — but while
it was unimplemented it took ResumeMicrovm off the coverage report with
it, and there was nothing wrong with ResumeMicrovm. #11's acceptance asks
that the resume transition match the recorded fixture, and the suite had
no way to show it.

"optional": true exempts a step from halting the scenario when the target
answers 501, and nothing else. A step that genuinely fails still halts
however it is marked. A step carrying capture should not be optional: the
vars it would have set go missing and the failure resurfaces several
steps later, a long way from its cause. Both halves are pinned by tests.

vm-suspend-resume now runs end to end. resume and get-until-running-again
pass against their recorded fixtures, which is the first time the suite
has checked the transition rather than the unit tests alone. 47 pass
becomes 52, and 22 of 29 operations exercised becomes 23.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6T4MeDN1RBiWaNtud6x77
@lex00
lex00 force-pushed the feature/11-suspend-resume branch from 6802911 to f2076c2 Compare July 31, 2026 04:52
@lex00
lex00 changed the base branch from feature/6-recording to main July 31, 2026 04:53
@lex00 lex00 closed this Jul 31, 2026
@lex00 lex00 reopened this Jul 31, 2026
@lex00
lex00 merged commit c7d9c2d into main Jul 31, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VM lifecycle: suspend, resume, idle timer, suspend cap, eight-hour cap

1 participant