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
10 changes: 8 additions & 2 deletions cmd/ateom-microvm/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.Chec
chSocket = ra.apiSocket
}
client := ch.NewClient(chSocket)
if err := client.WaitReady(ctx, 10*time.Second); err != nil {
if _, err := client.WaitReady(ctx, 10*time.Second); err != nil {
return nil, fmt.Errorf("while waiting for CH api-socket: %w", err)
}

Expand Down Expand Up @@ -218,7 +218,13 @@ func (s *AteomService) snapshotVMState(ctx context.Context, client *ch.Client, r
// source). Overlay it onto that source to rebuild a COMPLETE memory-ranges, so the
// snapshot is self-contained and re-restorable. (A cold-run actor has no restore
// source and its snapshot is already complete — no merge.)
if ra != nil && ra.restoreSourceDir != "" {
if ra != nil && ra.snapshotIsSelfContained {
// Eager restore already pulled every populated extent into guest memory, so
// what cloud-hypervisor just wrote is the whole guest, not a delta. Merging
// would copy the entire resident set onto the restore source for nothing.
slog.InfoContext(ctx, "Snapshot is self-contained (eager restore); skipping merge",
slog.String("id", actorUID))
} else if ra != nil && ra.restoreSourceDir != "" {
base := filepath.Join(ra.restoreSourceDir, "memory-ranges")
delta := filepath.Join(checkpointDir, "memory-ranges")
tMerge := time.Now()
Expand Down
18 changes: 0 additions & 18 deletions cmd/ateom-microvm/internal/ch/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,24 +56,6 @@ func newAPIClient(socketPath string) *apiClient {
}
}

// get issues a GET and checks for a 2xx status.
func (c *apiClient) get(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+path, nil)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 300 {
return fmt.Errorf("GET %s: status %d", path, resp.StatusCode)
}
return nil
}

// getJSON issues a GET and decodes the 2xx JSON response into out.
func (c *apiClient) getJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+path, nil)
Expand Down
42 changes: 33 additions & 9 deletions cmd/ateom-microvm/internal/ch/ch.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
type Client struct {
apiSocket string
api *apiClient
info VMMInfo
}

// NewClient returns a Client bound to a cloud-hypervisor api-socket path. The
Expand All @@ -43,24 +44,47 @@ func NewClient(apiSocket string) *Client {
return &Client{apiSocket: apiSocket, api: newAPIClient(apiSocket)}
}

// Ping returns nil if the VMM api-socket answers vmm.ping.
func (c *Client) Ping(ctx context.Context) error {
return c.api.get(ctx, "/api/v1/vmm.ping")
// Info returns what the VMM last reported about itself, zero until a successful
// Ping or WaitReady. A Client belongs to one actor's VMM and is used from that
// actor's goroutine, so this needs no synchronization.
func (c *Client) Info() VMMInfo { return c.info }

// VMMInfo is what vmm.ping reports about the running VMM. Version is a semver
// ("53.0.0"); BuildVersion is the release tag it was built from ("v53.0").
type VMMInfo struct {
Version string `json:"version"`
BuildVersion string `json:"build_version"`
Features []string `json:"features"`
}

// Ping reports what the VMM says about itself, or an error if the api-socket does
// not answer vmm.ping.
func (c *Client) Ping(ctx context.Context) (VMMInfo, error) {
var info VMMInfo
if err := c.api.getJSON(ctx, "/api/v1/vmm.ping", &info); err != nil {
return VMMInfo{}, err
}
c.info = info
return info, nil
}

// WaitReady blocks until the api-socket answers vmm.ping or the deadline passes.
func (c *Client) WaitReady(ctx context.Context, deadline time.Duration) error {
// WaitReady blocks until the api-socket answers vmm.ping or the deadline passes,
// returning what that answer said. Callers get the VMM's version for free this way:
// the handshake already happens before every boot and restore, so nothing has to
// run the binary again to ask.
func (c *Client) WaitReady(ctx context.Context, deadline time.Duration) (VMMInfo, error) {
end := time.Now().Add(deadline)
for {
if err := c.Ping(ctx); err == nil {
return nil
info, err := c.Ping(ctx)
if err == nil {
return info, nil
}
if !time.Now().Before(end) {
return fmt.Errorf("cloud-hypervisor api socket %q not ready after %s", c.apiSocket, deadline)
return VMMInfo{}, fmt.Errorf("cloud-hypervisor api socket %q not ready after %s", c.apiSocket, deadline)
}
select {
case <-ctx.Done():
return ctx.Err()
return VMMInfo{}, ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
Expand Down
10 changes: 8 additions & 2 deletions cmd/ateom-microvm/internal/ch/ch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ func startFakeCH(t *testing.T) (*Client, *fakeCH) {
f.mu.Lock()
f.requests = append(f.requests, recordedReq{method: r.Method, path: r.URL.Path, body: string(body)})
f.mu.Unlock()
if r.URL.Path == "/api/v1/vmm.ping" {
// Mirror what a real VMM answers, which callers parse for its version.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"build_version":"v52.0","version":"52.0.0","pid":1,"features":["kvm"]}`))
return
}
w.WriteHeader(http.StatusNoContent)
})
f.srv = &http.Server{Handler: mux}
Expand All @@ -82,7 +88,7 @@ func TestClientLifecycleCalls(t *testing.T) {
client, fake := startFakeCH(t)
ctx := context.Background()

if err := client.WaitReady(ctx, time.Second); err != nil {
if _, err := client.WaitReady(ctx, time.Second); err != nil {
t.Fatalf("WaitReady: %v", err)
}
if err := client.Pause(ctx); err != nil {
Expand Down Expand Up @@ -135,7 +141,7 @@ func TestClientLifecycleCalls(t *testing.T) {
func TestWaitReadyTimesOut(t *testing.T) {
// Socket that never exists -> WaitReady should time out, not hang.
client := NewClient(filepath.Join(t.TempDir(), "nonexistent.sock"))
err := client.WaitReady(context.Background(), 50*time.Millisecond)
_, err := client.WaitReady(context.Background(), 50*time.Millisecond)
if err == nil {
t.Fatal("WaitReady returned nil for a dead socket, want timeout error")
}
Expand Down
108 changes: 108 additions & 0 deletions cmd/ateom-microvm/internal/ch/prefault.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ch

import (
"strconv"
"strings"
)

// Guest RAM restore modes accepted by vm.restore.
const (
// MemRestoreOnDemand faults pages in as the guest touches them, so an idle
// restored guest holds its working set rather than its whole snapshot.
MemRestoreOnDemand = "OnDemand"
// MemRestoreEager reads the snapshot's populated extents up front. It registers
// no userfaultfd, so nothing prefaults and nothing gates a later snapshot.
MemRestoreEager = "Copy"
)

// prefaultingSince is the first cloud-hypervisor release whose userfaultfd restore
// handler background-prefaults every registered page (PR #8150) and refuses
// vm.snapshot until that finishes (PR #8556, the fix for issue #8525).
var prefaultingSince = [3]int{53, 0, 0}

// prefaultingUntil bounds the affected range, exclusive. It is deliberately open
// ({0,0,0} means "no known fix yet") rather than absent: OnDemand is the mode we
// actually want, so when a release stops prefaulting unconditionally — or lets a
// caller decline it — set this to that version and the smaller idle footprint comes
// back on its own, instead of every future release inheriting the workaround.
var prefaultingUntil = [3]int{0, 0, 0}

// PrefaultsUnconditionally reports whether this VMM prefaults an OnDemand restore.
//
// It reports true when the version cannot be read or parsed. The two ways to be
// wrong are not equal: choosing OnDemand on an affected version leaves the guest
// unable to pass its readiness probe, because the prefault storm starves it, while
// choosing eager on an unaffected one merely costs memory. Callers should log when
// they fall back on an unknown version, since that cost is otherwise invisible.
func (i VMMInfo) PrefaultsUnconditionally() bool {
v, ok := i.semver()
if !ok {
return true
}
if compareVersions(v, prefaultingSince) < 0 {
return false
}
if prefaultingUntil != [3]int{0, 0, 0} && compareVersions(v, prefaultingUntil) >= 0 {
return false
}
return true
}

// semver parses the reported version, preferring the semver field ("53.0.0") and
// falling back to the release tag ("v53.0").
func (i VMMInfo) semver() ([3]int, bool) {
if v, ok := parseVersion(i.Version); ok {
return v, true
}
return parseVersion(i.BuildVersion)
}

// parseVersion reads a dotted version, tolerating a leading "v", a missing patch
// component, and trailing build metadata ("53.0.0-dirty").
func parseVersion(s string) ([3]int, bool) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "v")
if s == "" {
return [3]int{}, false
}
// Drop any pre-release or build suffix.
if i := strings.IndexAny(s, "-+"); i >= 0 {
s = s[:i]
}

var out [3]int
for idx, part := range strings.SplitN(s, ".", 3) {
n, err := strconv.Atoi(part)
if err != nil || n < 0 {
return [3]int{}, false
}
out[idx] = n
}
return out, true
}

func compareVersions(a, b [3]int) int {
for i := range a {
switch {
case a[i] < b[i]:
return -1
case a[i] > b[i]:
return 1
}
}
return 0
}
86 changes: 86 additions & 0 deletions cmd/ateom-microvm/internal/ch/prefault_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ch

import "testing"

func TestPrefaultsUnconditionally(t *testing.T) {
for _, tc := range []struct {
name string
info VMMInfo
want bool
}{
// Real payloads, as reported by the binaries we ship.
{"v52 unaffected", VMMInfo{Version: "52.0.0", BuildVersion: "v52.0"}, false},
{"v53 affected", VMMInfo{Version: "53.0.0", BuildVersion: "v53.0"}, true},
{"a later release stays affected until a fix is known", VMMInfo{Version: "60.1.2"}, true},
{"much older", VMMInfo{Version: "41.0.0"}, false},

// The semver field is preferred, but the release tag is enough on its own.
{"tag only", VMMInfo{BuildVersion: "v52.0"}, false},
{"tag only, affected", VMMInfo{BuildVersion: "v53.0"}, true},
{"suffixed", VMMInfo{Version: "53.0.0-dirty"}, true},

// Unknown means eager: a wrong guess toward eager costs memory, a wrong guess
// toward OnDemand leaves the guest unable to pass its readiness probe.
{"empty", VMMInfo{}, true},
{"garbage", VMMInfo{Version: "not-a-version"}, true},
{"partial garbage", VMMInfo{Version: "53.x.0"}, true},
} {
t.Run(tc.name, func(t *testing.T) {
if got := tc.info.PrefaultsUnconditionally(); got != tc.want {
t.Errorf("PrefaultsUnconditionally() = %v, want %v (info %+v)", got, tc.want, tc.info)
}
})
}
}

// TestPrefaultingUpperBound documents how to retire the workaround: setting the
// exclusive upper bound restores OnDemand on releases that carry the fix.
func TestPrefaultingUpperBound(t *testing.T) {
orig := prefaultingUntil
prefaultingUntil = [3]int{55, 0, 0}
t.Cleanup(func() { prefaultingUntil = orig })

if !(VMMInfo{Version: "54.0.0"}).PrefaultsUnconditionally() {
t.Error("54.0.0 is inside the affected range, want prefaulting")
}
if (VMMInfo{Version: "55.0.0"}).PrefaultsUnconditionally() {
t.Error("55.0.0 is at the fix, want OnDemand back")
}
}

func TestParseVersion(t *testing.T) {
for _, tc := range []struct {
in string
want [3]int
ok bool
}{
{"53.0.0", [3]int{53, 0, 0}, true},
{"v53.0", [3]int{53, 0, 0}, true},
{"53", [3]int{53, 0, 0}, true},
{" 52.1.3 ", [3]int{52, 1, 3}, true},
{"53.0.0+build7", [3]int{53, 0, 0}, true},
{"", [3]int{}, false},
{"v", [3]int{}, false},
{"abc", [3]int{}, false},
{"53.abc", [3]int{}, false},
} {
got, ok := parseVersion(tc.in)
if ok != tc.ok || (ok && got != tc.want) {
t.Errorf("parseVersion(%q) = %v,%v want %v,%v", tc.in, got, ok, tc.want, tc.ok)
}
}
}
2 changes: 1 addition & 1 deletion cmd/ateom-microvm/internal/ch/restorefds.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func LaunchVMM(ctx context.Context, o LaunchVMMOptions) (*exec.Cmd, *Client, err
return nil, nil, fmt.Errorf("while starting cloud-hypervisor: %w", err)
}
client := NewClient(o.APISocket)
if err := client.WaitReady(ctx, 15*time.Second); err != nil {
if _, err := client.WaitReady(ctx, 15*time.Second); err != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
return nil, nil, fmt.Errorf("while waiting for VMM api-socket: %w", err)
Expand Down
Loading
Loading