Skip to content

fix(workflows): don't signal runner unhealthy mid-job on sandbox poisoning - #1402

Merged
gregmagolan merged 2 commits into
mainfrom
fix/sandbox-poisoning-midjob-unhealthy-signal
Aug 27, 2026
Merged

fix(workflows): don't signal runner unhealthy mid-job on sandbox poisoning#1402
gregmagolan merged 2 commits into
mainfrom
fix/sandbox-poisoning-midjob-unhealthy-signal

Conversation

@gregmagolan

@gregmagolan gregmagolan commented Aug 27, 2026

Copy link
Copy Markdown
Member

A customer's Buildkite job died with status 143 a couple of minutes after a Bazel server crash (Socket closed), instead of reporting the real Bazel exit code.

The still_poisoned branch of the bazel#23880 sandbox recovery hook called signal_instance_unhealthy inline, from bazel_attempt_end. That signal is a hard reclaim, not a drain request: on AWS it flips the instance Unhealthy with --no-should-respect-grace-period, on GCP it calls instance-groups managed delete-instances. Meanwhile the retry loop, which knows nothing about the hook's side effect, started the next attempt. The ASG/MIG reclaimed the box out from under the running job, the agent took a cancellation signal, and the job exited 143.

That retry was doomed regardless: we mark the runner unhealthy precisely because the sandbox could not be cleaned, and every subsequent Bazel invocation against the same output base crashes the same way.

What changed

The hooks now share one sandbox_recovery.new(environment, retry_predicate) state holder:

  • attempt_end records the poisoning verdict instead of signalling.
  • build_retry declines to retry once that verdict is set, delegating to the task's existing predicate otherwise.
  • build_end handles the separate "final exit is server-internal-error class" policy.

Both signal paths register a ctx.defer rather than signalling inline. build_end is a per-build seam, not per-task — delivery.axl fires it after phases 1/2 and then keeps working for minutes (phase 3 plus the deliverable subprocesses), and phase 3 fires only bazel_attempt_end. ctx.defer runs after the task impl returns, even on a fatal Starlark error, so it is the only seam that reliably means "the job is over". A signal_pending latch keeps it to one reclaim per task however many attempts or builds report trouble.

build_retry is a single-valued attr, so the previous predicate is passed into .new() and delegated to rather than overwritten — otherwise enabling this feature would silently reinstate default_retry over any predicate a custom task or another feature installed. The server-sick classification in build_end deliberately stays on default_retry: a task that declines to retry 37 has not thereby made its runner healthy.

Net effect: the job ends on its real exit code, and the runner still leaves the pool before it can pick up the next job.


Changes are visible to end-users: yes

  • Searched for relevant documentation and updated as needed: n/a (internal runner behavior; no docsite surface)
  • Breaking change (forces users to change their own code or config): no
  • Suggested release notes appear below: yes

Suggested release notes

  • Fixed a bug where an unrecoverable Bazel sandbox-poisoning state (checkSandboxBaseTopOnlyContainsPersistentDirs crash bazelbuild/bazel#23880) marked the Workflows runner unhealthy during a job, causing the CI agent to be cancelled mid-job and the job to fail with exit code 143 instead of the actual Bazel exit code. The runner is now marked unhealthy after the task completes, and the doomed retry is skipped.

Test plan

  • Covered by existing test cases
  • New test cases added

aspect dev test-sandbox-recovery — 15 sections, covering:

  • poisoning defers the signal instead of firing it mid-task, and the deferred signal still fires
  • build_retry stops retrying once poisoning is recorded, including over a permissive task predicate
  • build_retry delegates to the task's predicate when no poisoning is recorded
  • repeated poisoning across attempts and builds registers exactly one defer
  • build_end classification ignores the task predicate and uses default_retry
  • the pre-existing contracts: clean/repaired stay quiet, final 36/37 signal, final 0/1/2/3/4/6/7/8 do not, and a missing signal binary is a no-op

Each new regression test was confirmed to fail against the pre-fix behavior. The full AXL suite (~45 dev test-* tasks) and the Rust sandbox_recovery unit tests pass.

Not reproduced end-to-end on a live runner — the diagnosis is from the customer's log plus the signal_instance_unhealthy implementation in silo (lib.sh:1548-1565). The ~2 min gap between the unhealthy signal and the cancellation matches ASG reaction latency plus the deliberate sleep 10 for CloudWatch flushing.

🤖 Generated with Claude Code

…oning

The `still_poisoned` branch of the bazel#23880 sandbox recovery hook ran
as `bazel_attempt_end` and called `signal_instance_unhealthy` immediately,
including after non-final attempts. That signal is a hard reclaim, not a
drain request — on AWS it flips the instance Unhealthy with
`--no-should-respect-grace-period`, on GCP it calls `delete-instances`.
The retry loop, which knows nothing about the hook's side effect, then
started the next attempt on an instance the ASG/MIG was already tearing
down. A couple of minutes later the agent took a cancellation signal and
the job died with SIGTERM/143 instead of reporting its real Bazel exit
code.

The retry was doomed regardless: we mark unhealthy precisely because the
sandbox could not be cleaned, and every subsequent invocation on that
output base crashes the same way.

Coordinate the three seams through one `sandbox_recovery.new(environment)`
state holder. `attempt_end` now records the poisoning verdict instead of
signalling; `build_retry` declines to retry once it is set; `build_end`
performs the deferred signal. The job ends on its real exit code and the
runner still leaves the pool before it can take the next job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fd3cb0e7-c47d-4785-bfa9-bac4300d7ee9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd781302a1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread crates/aspect-cli/src/builtins/aspect/feature/workflows.axl
Comment thread crates/aspect-cli/src/builtins/aspect/feature/workflows.axl
Comment thread crates/aspect-cli/src/builtins/aspect/feature/workflows.axl
@gregmagolan
gregmagolan requested a review from thesayyn August 27, 2026 18:40
@aspect-workflows

aspect-workflows Bot commented Aug 27, 2026

Copy link
Copy Markdown

✨ Aspect Workflows Tasks

📅 Thu Aug 27 18:49:48 UTC 2026

🔄 4 in progress tasks

  • 🔄 build-axl-smoke [build] · ⏱ 8.6s · 🐙 GitHub Actions · ☑️ Check
    💬 Spawning bazel build...
  • 🔄 build-axl-smoke-2 [build] · ⏱ 9.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Spawning bazel build...
  • 🔄 run-axl-smoke [run] · ⏱ 9.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Building //examples/deliverable:py_deliverable...
  • 🔄 run-axl-smoke-2 [run] · ⏱ 8.4s · 🐙 GitHub Actions · ☑️ Check
    💬 Building //examples/deliverable:sh_deliverable...

❌ 1 failed task

  • ❌ delivery-uncacheable [delivery] · ⏱ 27.5s · ✨ Aspect · 🐙 GitHub Actions
    💬 failed in deliver · Delivery failed (1 delivery fail)

⚠️ 2 flagged tasks

  • ⚠️ delivery-gha-debug [delivery] · ⏱ 33.3s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Delivery complete (1 delivered · 2 warn · 4 skipped)
  • ⚠️ delivery-gha [delivery] · ⏱ 1m 12s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Delivery complete (1 delivered · 2 warn · 4 skipped)

✅ 29 successful tasks

  • ✅ axl-smoke-gha-bootstrap [build] · ⏱ 1m 11s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (1 built)
  • ✅ axl-tests-gha-bootstrap [build] · ⏱ 24.5s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (1 built)
  • ✅ build-gha-debug [build] · ⏱ 22m 40s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (174 built)
  • ✅ build-gha [build] · ⏱ 17m 8s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (174 built)
  • ✅ build-gha-ephemeral [build] · ⏱ 38s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (10 built)
  • ✅ buildifier-gha-debug [buildifier] · ⏱ 1m 25s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ buildifier-gha [buildifier] · ⏱ 53.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ delivery-no-remote-exec [delivery] · ⏱ 17.7s · ✨ Aspect · 🐙 GitHub Actions
    💬 Delivery complete (no deliveries)
  • ✅ delivery-uncacheable-warn [delivery] · ⏱ 17s · ✨ Aspect · 🐙 GitHub Actions
    💬 Delivery complete (no deliveries)
  • ✅ format-gha-debug [format] · ⏱ 1m 25s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task [format] · ⏱ 1m 46s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task-2 [format] · ⏱ 15.2s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task-3 [format] · ⏱ 14.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task-4 [format] · ⏱ 15s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-gha [format] · ⏱ 1m 36s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ gazelle-gha-debug [gazelle] · ⏱ 1m 22s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-from-source-gha-debug [gazelle] · ⏱ 2m 2s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-from-source-gha [gazelle] · ⏱ 2m 19s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-gha [gazelle] · ⏱ 1m 3s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ init-shell [build] · ⏱ 49.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (10 built)
  • ✅ lint-gha-debug [lint] · ⏱ 1m 19s · 🐙 GitHub Actions · ☑️ Check
    💬 Lint complete (clean)
  • ✅ lint-gha [lint] · ⏱ 1m 25s · 🐙 GitHub Actions · ☑️ Check
    💬 Lint complete (clean)
  • ✅ test-gha-debug [test] · ⏱ 18m 56s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (28/28 passed · 27 cached)
  • ✅ test-gha-bazel-flag-spellings [build] · ⏱ 18.7s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (1 built)
  • ✅ test-gha-ide-target-pattern-file [build] · ⏱ 30.2s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (1 built)
  • ✅ test-gha-coverage [test] · ⏱ 24.7s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed · 1 cached)
  • ✅ test-gha-target-pattern-file [test] · ⏱ 35.5s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed · 1 cached)
  • ✅ test-gha [test] · ⏱ 16m 15s · ✨ Aspect · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (28/28 passed · 28 cached)
  • ✅ test-gha-ephemeral [test] · ⏱ 55.9s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)

🔁 Reproduce

❌ delivery (delivery-uncacheable · delivery-gha-debug · delivery-gha)

# --mode=always --track-state=false for off-runner with no state backend.
aspect delivery \
  --commit-sha=80236850ab01fe4fcfad302348f9f1c0dfc34453 \
  --mode=always \
  --track-state=false \
  --dry-run=true

Install aspect: aspect.build/docs/cli/install


⏱ Last updated Thu Aug 27 19:13:06 UTC 2026 · 📊 GitHub API quota 1,948/15,000 (13% used, resets in 10m)
🚀 Powered by Aspect CLI (v0.0.0-dev)  |  Aspect Build · X · LinkedIn · YouTube

…d_retry

Addresses review feedback on the first pass, which moved the signal from
`bazel_attempt_end` to `build_end`. That seam is per-*build*, not per-task:
`delivery.axl` fires `build_end` after phases 1/2 and then keeps working
for minutes (phase 3 plus the deliverable subprocesses), and phase 3
deliberately fires only `bazel_attempt_end`. So the mid-job reclaim race
survived for delivery, and a phase-3-only poisoning verdict never signalled
at all.

Both signal paths now register a `ctx.defer`, which runs after the task
impl returns (even on a fatal Starlark error) and is the only true
end-of-task seam. A `signal_pending` latch keeps it to one reclaim per
task however many attempts or builds report trouble.

`build_retry` is a single-valued attr, so overwriting it dropped any
predicate a custom task or another feature had installed and silently
reinstated `default_retry`. The current predicate is now passed into
`sandbox_recovery.new` and delegated to unless poisoning forces False.
The server-sick classification in `build_end` deliberately stays on
`default_retry` — a task that declines to retry 37 has not thereby made
its runner healthy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gregmagolan
gregmagolan merged commit 4f8d977 into main Aug 27, 2026
77 checks passed
@gregmagolan
gregmagolan deleted the fix/sandbox-poisoning-midjob-unhealthy-signal branch August 27, 2026 19:56
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.

1 participant