Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions crates/aspect-cli/src/builtins/aspect/bazel.axl
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ BazelTrait = trait(
# each task's impl for lint/delivery.
task_flags = attr(list[typing.Callable[[TaskContext], list[str]]], default = [], description = "Hooks returning additional flags; called with TaskContext at invocation time"),

# Captured-output opt-in. When True, the Bazel child's stderr is captured
# by the runtime, run through a processing pipeline, and forwarded to the
# real stderr instead of being inherited directly. Phase 1 forwards
# verbatim (a plain pipe in non-TTY contexts, a PTY interactively so the
# live progress UI is preserved). This is the foundation for later
# output cleanup (dedup repeated lines + count) and pattern-matched
# hooks / hung-server detection — those will add a reserved `output_match`
# hook list and dedup config here, and act on detection state from the
# existing `bazel_attempt_end` hook. Default off → behavior unchanged.
capture_output = attr(bool, default = False, description = "Capture the Bazel child's stderr, run it through the output processing pipeline, and forward it to the real stderr (instead of inheriting it directly)"),

# Lifecycle hooks — lists of callables; callers close over their own state
build_start = attr(list[typing.Callable[[TaskContext], None]], default = [], description = "Hooks called once before the Bazel invocation starts"),
build_event = attr(list[typing.Callable[[TaskContext, dict], None]], default = [], description = "Hooks called for each Build Event Protocol event received during the build"),
Expand Down
24 changes: 23 additions & 1 deletion crates/aspect-cli/src/builtins/aspect/lib/bazel_runner.axl
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,27 @@ def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclu
# forwarded as a flag rather than expanded to argv (the flag exists
# to bypass OS command-line length limits). These are defaults the
# user can still override via --bazel-flag=...
base_flags = ["--isatty=" + str(int(ctx.std.io.stdout.is_tty))]
#
# When output capture is on, the child's stderr no longer goes to the real
# terminal — it goes to a runtime-owned pipe/PTY (see `output_processor`
# below). `--isatty` must match the captured fd, not the parent's stdout:
# - destination stderr is a TTY → a PTY is allocated → --isatty=1 so
# Bazel renders its curses UI into the PTY (forwarded near-verbatim).
# - otherwise → a plain pipe → --isatty=0 --curses=no so Bazel emits
# clean newline-terminated lines (the tractable substrate for the
# deferred dedup/match work).
# When capture is off, keep the historical behavior: derive --isatty from
# the parent's stdout (the child inherits the terminal directly).
output_processor = None
if bazel_trait.capture_output:
capture_is_tty = ctx.std.io.stderr.is_tty
output_processor = bazel.output.processor(tty = capture_is_tty)
if capture_is_tty:
base_flags = ["--isatty=1"]
else:
base_flags = ["--isatty=0", "--curses=no"]
else:
base_flags = ["--isatty=" + str(int(ctx.std.io.stdout.is_tty))]

if targets == None:
# --target-pattern-file is only honored when the task declares it.
Expand Down Expand Up @@ -385,6 +405,7 @@ def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclu
invocation = ctx.bazel.test(
build_events = build_events,
execution_log = bazel_trait.execution_log_sinks or False,
output = output_processor,
announce_version = announce_version,
announce_command = announce_command,
*targets
Expand All @@ -393,6 +414,7 @@ def run_bazel_task(ctx: TaskContext, command: str, targets = None) -> TaskConclu
invocation = ctx.bazel.build(
build_events = build_events,
execution_log = bazel_trait.execution_log_sinks or False,
output = output_processor,
announce_version = announce_version,
announce_command = announce_command,
*targets
Expand Down
3 changes: 2 additions & 1 deletion crates/axl-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ http-body-util = "0.1.3"
url = "2.5.4"
zstd = "0.13.3"

nix = { version = "0.30.1", features = ["fs", "signal"] }
nix = { version = "0.30.1", features = ["fs", "signal", "term"] }
libc = "0.2"
wasmi = "0.51.0"
wasmi_wasi = "0.51.0"

Expand Down
111 changes: 110 additions & 1 deletion crates/axl-runtime/src/engine/bazel/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,64 @@ pub(crate) fn build_event_iter_methods(registry: &mut MethodsBuilder) {
}
}

/// How the captured stderr fd is allocated for a build.
///
/// Pipe mode is the plain-`Stdio::piped()` substrate used in non-TTY contexts
/// (CI, redirected output): Bazel emits clean newline-terminated lines.
/// Pty mode allocates a pseudo-terminal so Bazel keeps its live curses UI; the
/// runtime forwards the master bytes near-verbatim. The mode is decided when
/// the processor is constructed, from whether the parent's stderr is a TTY
/// (see `bazel.output.processor`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureMode {
Pipe,
Pty,
}

/// Starlark handle passed as `ctx.bazel.build(output = ...)` to enable stderr
/// capture + forwarding. Created via `bazel.output.processor(...)`, single-use
/// per build (mirrors `BuildEventIter`).
///
/// Phase 1 carries only the capture mode; it is the seam where the deferred
/// dedup config and pattern matchers will be added. `Build::spawn` reads the
/// mode to decide how to allocate the child's stderr and starts an
/// `OutputStream` over the read end.
#[derive(Clone, Debug, ProvidesStaticType, Display, Trace, NoSerialize, Allocative)]
#[display("<bazel.output.OutputProcessor>")]
pub struct OutputProcessor {
#[allocative(skip)]
mode: CaptureMode,
}

impl OutputProcessor {
pub fn new(mode: CaptureMode) -> Self {
Self { mode }
}

pub fn mode(&self) -> CaptureMode {
self.mode
}
}

impl<'v> AllocValue<'v> for OutputProcessor {
fn alloc_value(self, heap: Heap<'v>) -> Value<'v> {
heap.alloc_complex_no_freeze(self)
}
}

impl<'v> UnpackValue<'v> for OutputProcessor {
type Error = anyhow::Error;
fn unpack_value_impl(value: Value<'v>) -> Result<Option<Self>, Self::Error> {
let v = value
.downcast_ref_err::<OutputProcessor>()
.into_anyhow_result()?;
Ok(Some(v.clone()))
}
}

#[starlark_value(type = "bazel.output.OutputProcessor")]
impl<'v> values::StarlarkValue<'v> for OutputProcessor {}

fn matches_kinds(event: &BuildEvent, kinds: Option<&Arc<HashSet<i32>>>) -> bool {
let Some(kinds) = kinds else {
return true;
Expand Down Expand Up @@ -716,6 +774,13 @@ pub struct Build {
#[allocative(skip)]
execlog_stream: RefCell<Option<ExecLogStream>>,

/// Captured-stderr forwarder, present only when the build was spawned with
/// `output = bazel.output.processor(...)`. Joined in `wait()` after the
/// child is reaped so all forwarded stderr is flushed before the task
/// prints its terminal summary.
#[allocative(skip)]
output_stream: RefCell<Option<super::stream::OutputStream>>,

/// Shared UUID every gRPC sink indexes this invocation under. Minted
/// before bazel emits `build_started` so forwarders can start
/// immediately; distinct from Bazel's `build_started.uuid`.
Expand Down Expand Up @@ -755,6 +820,7 @@ impl Build {
startup_flags: Vec<String>,
stdout: Stdio,
stderr: Stdio,
output: Option<OutputProcessor>,
current_dir: Option<String>,
announce: AnnounceSpawn,
rt: AsyncRuntime,
Expand Down Expand Up @@ -856,13 +922,43 @@ impl Build {
announce_spawn(announce, version.as_ref(), &cmd);

cmd.stdout(stdout);
cmd.stderr(stderr);
// When capturing, the child's stderr goes to a runtime-owned pipe/PTY
// instead of the resolved `stderr` Stdio; the `OutputStream` started
// after spawn reads, processes, and forwards it to the real stderr.
let mut capture = match &output {
Some(p) => Some(super::capture::Capture::open(p.mode())?),
None => None,
};
match &mut capture {
Some(c) => {
cmd.stderr(std::mem::replace(&mut c.child_stderr, Stdio::null()));
}
None => {
cmd.stderr(stderr);
}
}
cmd.stdin(Stdio::null());

let child = cmd
.spawn()
.map_err(|e| io::Error::other(format!("failed to spawn bazel: {e}")))?;

// Start forwarding captured stderr now that the child holds the write
// end. Drop the parent's PTY-slave copy first (release_after_spawn) so
// the master read can observe EOF when the child exits — otherwise the
// forwarder thread would hang forever in `wait()`.
let output_stream = match capture {
Some(mut c) => {
c.release_after_spawn();
Some(super::stream::OutputStream::spawn(
c.reader,
Box::new(std::io::stderr()),
vec![],
))
}
None => None,
};

// Register the bazel client with the live-subprocess registry so
// aspect-cli's OS-signal handler can forward SIGINT to it on
// CI cancellation. The guard is stored on `Self` and unregisters
Expand Down Expand Up @@ -952,6 +1048,7 @@ impl Build {
build_event_stream: RefCell::new(build_event_stream),
workspace_event_stream: RefCell::new(workspace_event_stream),
execlog_stream: RefCell::new(execlog_stream),
output_stream: RefCell::new(output_stream),
sink_invocation_id: RefCell::new(sink_invocation_id),
live_guard: RefCell::new(Some(live_guard)),
span: RefCell::new(span),
Expand Down Expand Up @@ -1087,6 +1184,18 @@ pub(crate) fn build_methods(registry: &mut MethodsBuilder) {
}
};

// Drain the captured-stderr forwarder. The child has exited (reaped
// above), so its stderr write end is closed and the reader reaches
// EOF; joining here guarantees all forwarded stderr is flushed before
// the caller (`_emit_terminal`) prints the task's terminal summary.
let output_stream = build.output_stream.take();
if let Some(mut output_stream) = output_stream {
match output_stream.join() {
Ok(_) => {}
Err(err) => anyhow::bail!("output stream thread error: {}", err),
}
};

// Drop the span to end the trace
drop(build.span.replace(tracing::Span::none()));

Expand Down
Loading
Loading