Skip to content

fix(bes): stream an invocation once when bazel and the CLI share a backend - #1349

Merged
gregmagolan merged 4 commits into
mainfrom
fix/bes-duplicate-stream
Jul 28, 2026
Merged

fix(bes): stream an invocation once when bazel and the CLI share a backend#1349
gregmagolan merged 4 commits into
mainfrom
fix/bes-duplicate-stream

Conversation

@gregmagolan

@gregmagolan gregmagolan commented Jul 27, 2026

Copy link
Copy Markdown
Member

Configuring one BES endpoint on both upload paths — Bazel's --bes_backend and the CLI's own sink — makes both stream the same invocation. The backend records a duplicate, and the two streams race to close; the CLI-side one typically loses and shows up truncated. A customer hit this on a Workflows runner by setting --bes_backend to the URL the runner already injects as ASPECT_WORKFLOWS_BES_BACKEND.

The CLI now drops its sink to an endpoint Bazel is already uploading to, so the invocation is streamed once. Bazel wins the tie because it's the more explicit request: the CLI's backends are auto-wired (the runner's env var, a --deployment's advertised host) while --bes_backend is set by hand. Each drop prints an INFO: line. Both sink groups are covered — those built from --bes-backend/--deployment, and those features push onto BazelTrait.build_event_sinks (the runner's env-injected one, the customer's actual path). A file sink is a local BEP dump, not an upload, so it always survives.

Bazel's effective backend is read through the run command, catching it from --bazel-flag, a .bazelrc, or an expanded --config. Endpoints match on host:port with the scheme's default port made explicit, so grpcs://h, grpcs://h:443, h, and h:443 are one endpoint (Bazel documents these as [SCHEME://]HOST[:PORT] and assumes grpcs when the scheme is omitted). Scheme is ignored at equal ports; grpc://h and grpcs://h still differ, their defaults being 80 and 443.

Suppression can leave a build with no gRPC sink at all, and the id the Aspect Web UI keys on is only minted when one exists — so a naive drop would delete the "✨ Aspect Workflows" link for exactly these users. The backend still holds the invocation, indexed under Bazel's UUID because Bazel uploaded it, so resolve_aspect_url falls back to data["bazel"]["invocation_id"] when the suppressed backend was the runner's own ASPECT_WORKFLOWS_BES_BACKEND. Dropping a third-party BES says nothing about what the Aspect UI holds, so the link stays suppressed there.

Endpoints are also now reported as configured. bazel.build_events.grpc rewrote grpcs:// to https:// before storing the URI on the sink, but that string is also what sink.uri returns and what every user-facing line names, so a runner set to grpcs://remote.foobar.aspect.build logged Streaming build events to https://remote.foobar.aspect.build. The rewrite is a transport concern — tonic's Channel::from_shared only understands http/https — so it moved to build_event_stream::client, at the one point the string becomes a channel. grpc:// maps to http:// for the same reason, which it previously did not.


Changes are visible to end-users: yes

  • Searched for relevant documentation and updated as needed: yes
  • Breaking change (forces users to change their own code or config): no
  • Suggested release notes appear below: yes

Suggested release notes

  • The CLI no longer opens its own Build Event Service stream to a backend Bazel is already uploading to via --bes_backend. Configuring the same endpoint on both paths (for example, setting --bes_backend to the URL a Workflows runner injects as ASPECT_WORKFLOWS_BES_BACKEND) previously produced two streams for one invocation, recorded a duplicate on the backend, and could leave the CLI-side stream truncated. Matching accounts for default ports and an omitted scheme, so grpcs://bes.example.com, grpcs://bes.example.com:443, and bes.example.com are recognized as one endpoint. The Aspect Web UI link is preserved, and an INFO: line reports each suppressed stream.
  • BES endpoints are now reported as configured. A grpcs:// backend was previously logged as https:// — naming an endpoint that was never set — because the scheme mapping tonic needs was applied to the stored URI rather than only at dial time.

Test plan

  • New test cases added — bes_sinks_test.axl covers sink collection, duplicate dropping across both sink groups (including when every gRPC sink is suppressed), and the Aspect-backend gate; aspect_endpoint_auth_test.axl covers authority normalization and endpoint equivalence (default ports in both spellings, omitted scheme, differing ports, empty inputs); bazel_results_test.axl covers the Web UI link fallback, its third-party negative case, sink-id precedence, and survival of the build_started reset; client.rs covers the scheme mapping, http pass-through, leading-scheme-only rewriting, and that every spelling survives Channel::from_shared.
  • Covered by existing test cases — all 42 aspect dev test-* suites and 414 Rust tests across aspect-cli, axl-runtime, and build-event-stream pass.
  • Manual testing; end-to-end against real builds:
# The customer's case: runner-injected backend + the same URL on Bazel's flag.
# Suppressed, and the ✨ Aspect Web UI link still renders (on Bazel's id) even
# though ASPECT_DEBUG=1 reports "0 gRPC sinks configured".
ASPECT_WORKFLOWS_BES_BACKEND=grpcs://bes.example.com \
ASPECT_WORKFLOWS_BES_RESULTS_URL=https://app.example.com ASPECT_DEBUG=1 \
  aspect build --bazel-flag=--bes_backend=grpcs://bes.example.com:443 //some:target

# Same endpoint via --bes-backend — both default-port spellings, and schemeless.
aspect build --bes-backend=grpcs://bes.example.com:443 \
  --bazel-flag=--bes_backend=grpcs://bes.example.com //some:target
aspect build --bes-backend=grpcs://bes.example.com \
  --bazel-flag=--bes_backend=bes.example.com //some:target

# Negative controls: distinct endpoints both stream; an env sink with no Bazel
# flag is untouched.
aspect build --bes-backend=grpcs://bes.cli-only.example.com \
  --bazel-flag=--bes_backend=grpcs://bes.example.com //some:target
ASPECT_WORKFLOWS_BES_BACKEND=grpcs://bes.example.com aspect build //some:target

# Endpoints reported as configured. Verified against a live TLS backend that
# grpcs:// still negotiates TLS (server acks "build_enqueued ack'd in 115ms")
# and against a local listener that grpc:// still opens a plaintext connection.
ASPECT_WORKFLOWS_BES_BACKEND=grpcs://remote.foobar.aspect.build aspect build //some:target
#   INFO: Streaming build events to grpcs://remote.foobar.aspect.build.
ASPECT_WORKFLOWS_BES_BACKEND=grpc://buildbarn-frontend.awd.internal:8980 aspect build //some:target
#   INFO: Streaming build events to grpc://buildbarn-frontend.awd.internal:8980.

🤖 Generated with Claude Code

…s to

When a CLI-streamed BES backend names the same endpoint as Bazel's own
`--bes_backend`, both upload the same invocation: the backend records a
duplicate, and the two streams race to close — losing that race is how the
CLI-side stream ends up truncated.

Drop the CLI sink in that case. Bazel wins the tie because it is the more
explicit request: the CLI's backends are auto-wired (a Workflows runner's
`ASPECT_WORKFLOWS_BES_BACKEND`, a `--deployment`'s advertised BES host) while
`--bes_backend` is set by hand. Each drop prints an INFO line so the
suppression is visible.

Bazel's effective backend is read through the run command, so it is caught
wherever it came from — `--bazel-flag`, a `.bazelrc`, or an expanded
`--config`. Endpoints are matched on `host:port` with the scheme's default
port made explicit, so `grpcs://h`, `grpcs://h:443`, and `h:443` all compare
equal. Scheme is ignored at equal ports, which is required rather than merely
lenient: the runtime rewrites `grpcs://` to `https://` on the sink, so the
runner's sink URI and the user's flag never share one. `grpc://h` and
`grpcs://h` still differ, their default ports being 80 and 443.

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

@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: e238ec1005

ℹ️ 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/private/lib/bazel_runner.axl Outdated
Comment thread crates/aspect-cli/src/builtins/aspect/private/lib/aspect_endpoint_auth.axl Outdated
@aspect-workflows

aspect-workflows Bot commented Jul 27, 2026

Copy link
Copy Markdown

✨ Aspect Workflows Tasks

📅 Tue Jul 28 01:12:43 UTC 2026

❌ 1 failed task

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

⚠️ 3 flagged tasks

  • ⚠️ delivery-gha-debug [delivery] · ⏱ 42.2s · 🐙 GitHub Actions · ☑️ Check
    💬 Delivery complete (1 delivered · 2 warn · 3 skipped)
  • ⚠️ delivery-gha [delivery] · ⏱ 30.8s · 🐙 GitHub Actions · ☑️ Check
    💬 Delivery complete (1 delivered · 2 warn · 3 skipped)
  • ⚠️ delivery-uncacheable-warn [delivery] · ⏱ 12.7s · 🐙 GitHub Actions
    💬 Delivery complete (1 warn)

✅ 27 successful tasks

  • ✅ axl-smoke-gha-bootstrap [build] · ⏱ 18s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (1 built)
  • ✅ run-axl-smoke [run] · ⏱ 23.2s · 🐙 GitHub Actions · ☑️ Check
    💬 Ran //examples/deliverable:py_deliverable
  • ✅ run-axl-smoke-2 [run] · ⏱ 14.6s · 🐙 GitHub Actions · ☑️ Check
    💬 Ran //examples/deliverable:sh_deliverable
  • ✅ axl-tests-gha-bootstrap [build] · ⏱ 19.6s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (1 built)
  • ✅ build-gha-debug [build] · ⏱ 5m 50s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (166 built)
  • ✅ build-gha [build] · ⏱ 17.4s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (166 built)
  • ✅ build-gha-ephemeral [build] · ⏱ 55.3s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (9 built)
  • ✅ buildifier-gha-debug [buildifier] · ⏱ 1m 19s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ buildifier-gha [buildifier] · ⏱ 51.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-gha-debug [format] · ⏱ 1m 31s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task [format] · ⏱ 1m 21s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task-2 [format] · ⏱ 14.3s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task-3 [format] · ⏱ 12.9s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task-4 [format] · ⏱ 11.6s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-gha [format] · ⏱ 1m 39s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ gazelle-gha-debug [gazelle] · ⏱ 27.6s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-from-source-gha-debug [gazelle] · ⏱ 1m 58s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-from-source-gha [gazelle] · ⏱ 1m 34s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-gha [gazelle] · ⏱ 16.3s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ init-shell [build] · ⏱ 44.2s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (10 built)
  • ✅ lint-gha-debug [lint] · ⏱ 58.6s · 🐙 GitHub Actions · ☑️ Check
    💬 Lint complete (clean)
  • ✅ lint-gha [lint] · ⏱ 43.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Lint complete (clean)
  • ✅ test-gha-debug [test] · ⏱ 4m 7s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (26/26 passed · 22 cached)
  • ✅ test-gha-coverage [test] · ⏱ 31s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed · 1 cached)
  • ✅ test-gha-target-pattern-file [test] · ⏱ 17.5s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed · 1 cached)
  • ✅ test-gha [test] · ⏱ 1m 5s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (26/26 passed · 26 cached)
  • ✅ test-gha-ephemeral [test] · ⏱ 48.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)

🔁 Reproduce

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

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

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


⏱ Last updated Tue Jul 28 01:22:44 UTC 2026 · 📊 GitHub API quota 3,078/15,000 (21% used, resets in 10m)
🚀 Powered by Aspect CLI (v0.0.0-dev)  |  Aspect Build · X · LinkedIn · YouTube

gregmagolan and others added 3 commits July 27, 2026 17:08
…pressing

Addresses two review findings on the duplicate-stream suppression.

Suppressing the CLI's sink to the Aspect backend left the build with no gRPC
sink at all, so `Build::spawn` minted no `sink_invocation_id` and the Aspect
Web UI link vanished from every status surface — for exactly the users who
wired BES up twice. The backend does still hold the invocation in that case,
indexed under Bazel's own UUID because Bazel is the uploader, so
`resolve_aspect_url` now falls back to `data["bazel"]["invocation_id"]` when
`bes_streamed_by_bazel` is set. The flag is gated on the suppressed backend
being the runner's own `ASPECT_WORKFLOWS_BES_BACKEND`: dropping a third-party
BES says nothing about what the Aspect UI has. Like `sink_invocation_id`, it
survives the `build_started` reset and a retry's `init_data()`.

Endpoint normalization also treated a schemeless URI as having no default
port, so `--bes_backend=bes.example.com` missed a `grpcs://bes.example.com`
sink and both streamed. Bazel documents these as `[SCHEME://]HOST[:PORT]` and
assumes grpcs when the scheme is omitted, so the default now applies there
too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bazel.build_events.grpc` rewrote `grpcs://` to `https://` before storing the
URI on the sink, but that string is also what `sink.uri` returns and what
every user-facing line names. On a runner with

    ASPECT_WORKFLOWS_BES_BACKEND=grpcs://remote.silo-aws.aspect.build

the CLI reported

    INFO: Streaming build events to https://remote.silo-aws.aspect.build.

naming an endpoint the user never configured. The rewrite exists because
tonic's `Channel::from_shared` only understands http/https, so it is a
transport concern; it now lives in `build_event_stream::client`, at the one
point the string becomes a channel. Everything upstream — `sink.uri`, the
announce and summary lines, the `BES sink <endpoint>` debug/warning prefixes —
keeps the spelling the user wrote.

`grpc://` is mapped to `http://` for the same reason, which it previously was
not: it reached tonic unmapped.

Verified against a live TLS backend that `grpcs://` still negotiates TLS and
the server acks (`build_enqueued ack'd in 115ms`) while the logs read
`grpcs://`, and against a local listener that `grpc://` still opens a plaintext
connection.

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

Follow-up cleanup to the duplicate-stream fix; no behavior change.

Each of the six bazel-spawning tasks had grown the same four-line ritual —
resolve Bazel's backend, filter two sink groups through two different helpers,
set a data key, repeat the same comment. That is now `collect_bes_sinks(ctx,
bazel_trait, rc)` plus `bes_streamed_by_bazel(ctx, rc)`, and the two filter
helpers collapse into one `_drop_bazel_streamed` taking a `uri_of` accessor,
since they differed only in how they read a URI.

`endpoint_host` and `endpoint_authority` were parsing URIs separately; both now
go through `_split_endpoint`, which incidentally fixes `endpoint_host` not
trimming surrounding whitespace. `_DEFAULT_SCHEME` and `SCHEME_ALIASES` replace
the schemes that were spelled inline.

`process_event`'s build_started reset now restores `_TASK_OWNED_KEYS` rather
than naming each preserved field twice, so a future task-owned key does not
have to remember to add itself in two places.

Tests exercise the public entry points instead of the now-private helpers, and
cover `collect_bes_sinks` directly — including the all-sinks-suppressed case
that costs the Web UI its sink id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gregmagolan gregmagolan changed the title fix(bes): don't stream from the CLI to a backend bazel already uploads to fix(bes): stream an invocation once when bazel and the CLI share a backend Jul 28, 2026
@gregmagolan
gregmagolan merged commit 723976d into main Jul 28, 2026
71 checks passed
@gregmagolan
gregmagolan deleted the fix/bes-duplicate-stream branch July 28, 2026 07:41
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