-
Notifications
You must be signed in to change notification settings - Fork 437
fix(orchestrator): refuse a memory pause when envd is unresponsive #3648
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+324
−0
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| //go:build linux | ||
|
|
||
| package sandbox | ||
|
|
||
| import ( | ||
| "context" | ||
| "time" | ||
|
|
||
| "go.opentelemetry.io/otel/attribute" | ||
| "go.opentelemetry.io/otel/trace" | ||
| "go.uber.org/zap" | ||
| ) | ||
|
|
||
| // awaitEnvdHealthy is the envd half of the snapshot-admission pre-flight. | ||
| // | ||
| // A memory snapshot does not restart envd, it restores the running process | ||
| // mid-execution. So an envd that is already unresponsive when the snapshot is | ||
| // taken is captured in that state and faithfully replayed on every later | ||
| // resume: resume-fc succeeds, the VM comes back, and then sandbox-wait-for-start | ||
| // retries envd's /init until the request budget is gone. Nothing records the | ||
| // snapshot as bad, so every retry repeats it on any node, indefinitely. | ||
| // | ||
| // The pause path already had two signals that envd was gone — the pre-pause | ||
| // freeze and heap collapse both burning their full timeouts — but both are | ||
| // best-effort by design and cannot fail a pause. This probe turns the same | ||
| // question into an admission decision, asked BEFORE any destructive step. | ||
| // | ||
| // Deliberately retryable rather than terminal. An unanswered health probe is a | ||
| // strong predictor that the snapshot would be unresumable, not a certainty: | ||
| // sandboxes whose freeze and collapse both timed out have still gone on to | ||
| // resume cleanly. Deferring the pause costs a retry; condemning the sandbox | ||
| // cannot be undone. | ||
| // | ||
| // Returns a nil error when the pause may proceed, including when the probe is | ||
| // disabled or cannot be run. | ||
| // AwaitEnvdAdmission runs as its own pre-flight, before (and independently of) | ||
| // the durable-header admission wait, so the two can be rolled out separately. | ||
| // A negative timeout disables the probe. A nil error means the pause may | ||
| // proceed. | ||
| // | ||
| // The timeout is passed in rather than read here, matching how the caller gates | ||
| // the durable-header wait on PauseAdmissionGraceMs: the flag lookup belongs to | ||
| // the server's feature-flag client, and keeping this method free of one leaves | ||
| // it pure and callable from a Sandbox built without flags. | ||
| func (s *Sandbox) AwaitEnvdAdmission(ctx context.Context, timeout time.Duration) (SnapshotAdmissionOutcome, time.Duration, error) { | ||
| if timeout < 0 { | ||
| return SnapshotAdmissionReady, 0, nil | ||
| } | ||
|
|
||
| ctx, span := tracer.Start(ctx, "envd-admission") | ||
| defer span.End() | ||
|
|
||
| return s.awaitEnvdHealthy(ctx, timeout) | ||
| } | ||
|
|
||
| func (s *Sandbox) awaitEnvdHealthy(ctx context.Context, timeout time.Duration) (SnapshotAdmissionOutcome, time.Duration, error) { | ||
| // Checks owns the probe and its HTTP client. It is stopped inside Pause, | ||
| // well after admission, but a sandbox torn down concurrently can leave it | ||
| // nil — in which case say nothing rather than refuse on missing evidence. | ||
| if s.Checks == nil { | ||
| return SnapshotAdmissionReady, 0, nil | ||
| } | ||
|
|
||
| start := time.Now() | ||
| healthy, err := s.Checks.getHealth(ctx, timeout) | ||
| waited := time.Since(start) | ||
|
|
||
| span := trace.SpanFromContext(ctx) | ||
| span.SetAttributes( | ||
| attribute.Bool("admission.envd_healthy", healthy), | ||
| attribute.Int64("admission.envd_probe_ms", waited.Milliseconds()), | ||
| attribute.Int64("admission.envd_probe_timeout_ms", timeout.Milliseconds()), | ||
| ) | ||
|
|
||
| if healthy { | ||
| return SnapshotAdmissionReady, waited, nil | ||
| } | ||
|
|
||
| // The caller's context ending is not evidence about envd. Let the existing | ||
| // mid-wait handling own it: nothing was decided, the sandbox is untouched. | ||
| if ctx.Err() != nil { | ||
| return "", waited, ctx.Err() | ||
| } | ||
|
|
||
| s.log().Warn(ctx, "refusing pause: envd is not answering health checks", | ||
| zap.Error(err), zap.Duration("probe", waited)) | ||
|
|
||
| return SnapshotAdmissionEnvdUnhealthy, waited, ErrSnapshotAdmissionEnvdUnhealthy | ||
| } |
171 changes: 171 additions & 0 deletions
171
packages/orchestrator/pkg/sandbox/pause_envd_health_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| //go:build linux | ||
|
|
||
| package sandbox | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "net" | ||
| "net/http" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/network" | ||
| "github.com/e2b-dev/infra/packages/shared/pkg/sandboxtypes" | ||
| ) | ||
|
|
||
| // probeTimeout is what a rollout would set: generous next to the 100ms | ||
| // monitoring probe, because here a false refusal costs a customer a pause | ||
| // rather than a log line. | ||
| const probeTimeout = 500 * time.Millisecond | ||
|
|
||
| // stubRoundTripper answers every request with a canned result, so the health | ||
| // probe can be exercised without a real guest. getHealth builds its URL from | ||
| // the slot IP and the fixed envd port, so intercepting at the transport is the | ||
| // only way in. | ||
| type stubRoundTripper struct { | ||
| status int | ||
| err error | ||
| calls int | ||
| } | ||
|
|
||
| func (rt *stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { | ||
| rt.calls++ | ||
| if rt.err != nil { | ||
| return nil, rt.err | ||
| } | ||
|
|
||
| return &http.Response{StatusCode: rt.status, Body: http.NoBody, Request: r}, nil | ||
| } | ||
|
|
||
| func newHealthProbeSandbox(t *testing.T) *Sandbox { | ||
| t.Helper() | ||
|
|
||
| sbx := &Sandbox{ | ||
| Metadata: &Metadata{ | ||
| Config: NewConfig(Config{}), | ||
| Runtime: sandboxtypes.RuntimeMetadata{SandboxID: "test-sandbox"}, | ||
| }, | ||
| Resources: &Resources{Slot: &network.Slot{HostIP: net.IPv4(127, 0, 0, 1)}}, | ||
| } | ||
| sbx.Checks = NewChecks(sbx) | ||
|
|
||
| return sbx | ||
| } | ||
|
|
||
| func withStubTransport(t *testing.T, rt http.RoundTripper) { | ||
| t.Helper() | ||
|
|
||
| orig := sandboxHttpClient | ||
| sandboxHttpClient = http.Client{Transport: rt} | ||
| t.Cleanup(func() { sandboxHttpClient = orig }) | ||
| } | ||
|
|
||
| // The flag defaults to -1, which must leave the pause path exactly as it was: | ||
| // no probe, no refusal, and crucially no dial of the guest. | ||
| // | ||
| //nolint:paralleltest // overrides the package-level sandboxHttpClient | ||
| func TestAwaitEnvdHealthy_DisabledByDefault(t *testing.T) { | ||
| rt := &stubRoundTripper{err: errors.New("envd is gone")} | ||
| withStubTransport(t, rt) | ||
|
|
||
| outcome, _, err := newHealthProbeSandbox(t).AwaitEnvdAdmission(t.Context(), -1) | ||
| require.NoError(t, err, "a disabled probe must never refuse a pause") | ||
| assert.Equal(t, SnapshotAdmissionReady, outcome) | ||
| assert.Zero(t, rt.calls, "a disabled probe must not dial the guest") | ||
| } | ||
|
|
||
| // The case this change exists for: envd does not answer, so the pause is | ||
| // refused BEFORE any destructive step rather than producing a snapshot that | ||
| // records a wedged agent and can never be resumed. | ||
| // | ||
| //nolint:paralleltest // overrides the package-level sandboxHttpClient | ||
| func TestAwaitEnvdHealthy_UnresponsiveEnvdRefusesRetryably(t *testing.T) { | ||
| rt := &stubRoundTripper{err: errors.New("connection refused")} | ||
| withStubTransport(t, rt) | ||
|
|
||
| outcome, _, err := newHealthProbeSandbox(t).AwaitEnvdAdmission(t.Context(), probeTimeout) | ||
| require.Error(t, err) | ||
| require.ErrorIs(t, err, ErrSnapshotAdmissionEnvdUnhealthy) | ||
| assert.Equal(t, SnapshotAdmissionEnvdUnhealthy, outcome) | ||
| assert.Equal(t, 1, rt.calls) | ||
| } | ||
|
|
||
| // A healthy envd must be admitted. envd answers /health with 204; anything else | ||
| // is a failure, so this also pins that a 200 is NOT mistaken for healthy. | ||
| // | ||
| //nolint:paralleltest // overrides the package-level sandboxHttpClient | ||
| func TestAwaitEnvdHealthy_HealthyEnvdAdmits(t *testing.T) { | ||
| withStubTransport(t, &stubRoundTripper{status: http.StatusNoContent}) | ||
|
|
||
| outcome, _, err := newHealthProbeSandbox(t).AwaitEnvdAdmission(t.Context(), probeTimeout) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, SnapshotAdmissionReady, outcome) | ||
| } | ||
|
|
||
| //nolint:paralleltest // overrides the package-level sandboxHttpClient | ||
| func TestAwaitEnvdHealthy_UnexpectedStatusRefuses(t *testing.T) { | ||
| withStubTransport(t, &stubRoundTripper{status: http.StatusInternalServerError}) | ||
|
|
||
| outcome, _, err := newHealthProbeSandbox(t).AwaitEnvdAdmission(t.Context(), probeTimeout) | ||
| require.Error(t, err) | ||
| require.ErrorIs(t, err, ErrSnapshotAdmissionEnvdUnhealthy) | ||
| assert.Equal(t, SnapshotAdmissionEnvdUnhealthy, outcome) | ||
| } | ||
|
|
||
| // A sandbox torn down concurrently can leave Checks nil. Missing evidence is | ||
| // not evidence of a wedged envd, so the pause proceeds. | ||
| // | ||
| //nolint:paralleltest // overrides the package-level sandboxHttpClient | ||
| func TestAwaitEnvdHealthy_NilChecksAdmits(t *testing.T) { | ||
| withStubTransport(t, &stubRoundTripper{err: errors.New("envd is gone")}) | ||
|
|
||
| sbx := newHealthProbeSandbox(t) | ||
| sbx.Checks = nil | ||
|
|
||
| outcome, _, err := sbx.AwaitEnvdAdmission(t.Context(), probeTimeout) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, SnapshotAdmissionReady, outcome) | ||
| } | ||
|
|
||
| // A context that ends mid-probe says nothing about envd. It must surface as the | ||
| // caller's context error with an EMPTY outcome, which the handlers map to | ||
| // "nothing was decided, the sandbox is untouched" — never as a refusal. | ||
| // | ||
| //nolint:paralleltest // overrides the package-level sandboxHttpClient | ||
| func TestAwaitEnvdHealthy_ContextCancelledIsNotARefusal(t *testing.T) { | ||
| withStubTransport(t, &stubRoundTripper{err: errors.New("cancelled")}) | ||
|
|
||
| ctx, cancel := context.WithCancel(t.Context()) | ||
| cancel() | ||
|
|
||
| outcome, _, err := newHealthProbeSandbox(t).AwaitEnvdAdmission(ctx, probeTimeout) | ||
| require.Error(t, err) | ||
| require.NotErrorIs(t, err, ErrSnapshotAdmissionEnvdUnhealthy, | ||
| "a cancelled context must not be reported as an unhealthy envd") | ||
| assert.Equal(t, SnapshotAdmissionOutcome(""), outcome, | ||
| "an empty outcome is what tells the handler nothing was decided") | ||
| } | ||
|
|
||
| // The refusal must be its own sentinel and must NOT satisfy the pending one. | ||
| // The two take different branches in the Pause handler: pending and | ||
| // envd-unhealthy both return a retryable ResourceExhausted, while anything | ||
| // falling through to the default case is treated as a latched error and KILLS | ||
| // the sandbox. Conflating them would turn an unresponsive envd into a kill, | ||
| // which is precisely what the retryable refusal exists to avoid — envd often | ||
| // recovers, and sandboxes whose pre-pause freeze and collapse both timed out | ||
| // have still gone on to resume cleanly. | ||
| func TestEnvdUnhealthySentinelIsDistinctAndNotLatched(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| require.NotErrorIs(t, ErrSnapshotAdmissionEnvdUnhealthy, ErrSnapshotAdmissionPending) | ||
| require.NotErrorIs(t, ErrSnapshotAdmissionPending, ErrSnapshotAdmissionEnvdUnhealthy) | ||
|
|
||
| // Outcome labels are metric dimensions; keep them distinct and stable. | ||
| assert.NotEqual(t, SnapshotAdmissionRefused, SnapshotAdmissionEnvdUnhealthy) | ||
| assert.NotEqual(t, SnapshotAdmissionLatchedError, SnapshotAdmissionEnvdUnhealthy) | ||
| assert.Equal(t, SnapshotAdmissionEnvdUnhealthy, SnapshotAdmissionOutcome("envd_unhealthy")) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Team-targeted health flag never enables
High Severity
Moving the
PauseEnvdHealthTimeoutMslookup out ofAwaitEnvdAdmissiondropped theTeamContextandTemplateContextthat used to be added before the flag was read. Pause and Checkpoint only attach a sandbox-kind context, so a team-targeted value never matches and the probe stays disabled.Additional Locations (1)
packages/orchestrator/pkg/server/sandboxes.go#L1072-L1074Reviewed by Cursor Bugbot for commit 32fa5d4. Configure here.