Nothing here ever wrote a remote-tracking ref except the fetch inside a
pull, and a pull is reachable only when the branch you are already on is
behind -- so a branch pushed from another machine reached the picker as a
side effect of pulling something else, and a project already up to date
could never be moved onto a new branch at all.
POST /apps/{key}/fetch answers with the whole ref list, so the pickers
repopulate from after the fetch in one round trip. It is the only call
here that asks for --prune: making the picker say what the remote says is
its job, where a pull should change as little as it can. Still not on a
timer and not on opening the sheet, because a fetch mutates the checkout.
Picking a remote-only branch then had to work: `git checkout origin/topic`
detaches HEAD, since git's DWIM fires on the bare name, so the control
that says it is picking a branch produced the state picking a commit is
meant to produce -- and printed its detached-HEAD advice and succeeded.
Moving the checkout no longer builds. A pull is somebody taking new work;
a move is somebody looking, and charging a full build for a look starts a
minute of work an accidental tap cannot call back. What it leaves behind
is a component whose build no longer matches the checkout, so the build
button's word now follows the state: Rebuild only where every component
it covers is known current, Build otherwise.
The sheet loses its Checkout heading and its paragraph, both pickers sit
in a weighted row so a long branch name cannot wrap the label one letter
per line, and the failure text moved below them -- above, it shoved the
pickers down the screen as somebody reached for one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2361 lines
101 KiB
Rust
2361 lines
101 KiB
Rust
//! Runs an app's optional on-demand build step and tracks it, so the phone
|
|
//! can be shown "building..." rather than silently downloading a stale APK.
|
|
//!
|
|
//! Nothing here knows what any particular build does: it is a command from
|
|
//! the config plus a two-file staleness rule (see
|
|
//! `crate::config::Component`). The build runs in the background and
|
|
//! the routes answer immediately either way, so a multi-minute build never
|
|
//! holds a request open past a phone's read timeout.
|
|
|
|
use std::collections::{HashMap, VecDeque};
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use serde::Serialize;
|
|
|
|
use crate::config::Component;
|
|
|
|
/// What a build has got through, in whatever it counts in.
|
|
#[derive(Clone, Copy, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Progress {
|
|
pub done: u64,
|
|
pub total: u64,
|
|
}
|
|
|
|
/// The marker a build prints to report progress, at the start of a line.
|
|
///
|
|
/// Recognised on every build's output rather than switched on by a config
|
|
/// key: a build opts in by printing it, and one more `starts_with` per line
|
|
/// is not worth a field, an acceptance, and a manifest flag to avoid.
|
|
/// Nothing here knows what the numbers count -- Gradle tasks, files,
|
|
/// anything -- only that the second is the whole of it.
|
|
const PROGRESS_MARKER: &str = "@@progress ";
|
|
|
|
/// What is known about whether a component's build is current.
|
|
///
|
|
/// Three states rather than a boolean, because "we cannot tell" must not
|
|
/// share a value with either answer. A project this server has never
|
|
/// built, one outside git, and one with uncommitted work in its directory
|
|
/// are all *unknown* -- and an unknown drawn as `Current` would be the
|
|
/// exact failure this was added to stop.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum Freshness {
|
|
/// Built from the commit its own directory is on.
|
|
Current,
|
|
/// Its directory has moved past the commit it was built from.
|
|
Behind,
|
|
/// Never built here, no checkout to compare against, uncommitted work
|
|
/// in its directory, or a checkout parked on a commit somebody chose.
|
|
Unknown,
|
|
}
|
|
|
|
/// How a finished component reports the commit it was built from.
|
|
///
|
|
/// A callback rather than a handle to the app list: `BuildState` lives
|
|
/// *inside* that list, so holding it would be a cycle, and the single fact
|
|
/// a build needs to report upward does not justify one. The route that
|
|
/// starts a build has the list and supplies this.
|
|
pub type RecordBuilt = Arc<dyn Fn(&str, String) + Send + Sync>;
|
|
|
|
/// What a move does once the working tree has moved: the acceptance gate
|
|
/// to ask on the tree it left, and where to record what got built.
|
|
///
|
|
/// `None` is a move that builds nothing at all, which is every checkout --
|
|
/// the two halves travel together because neither is any use without the
|
|
/// other, and an `Option` of the pair is what makes "build, but with
|
|
/// nowhere to record it" unsayable.
|
|
type BuildAfterMoving = Option<(Box<dyn Fn() -> bool + Send>, RecordBuilt)>;
|
|
|
|
/// The handles a build shares with the rest of the server, as opposed to
|
|
/// the project configuration it is built from.
|
|
///
|
|
/// Grouped because they travel together and because they are a different
|
|
/// kind of thing from the rest of the arguments: everything else describes
|
|
/// *this project*, while these two are the server's own state that a build
|
|
/// happens to touch -- a pull marking a checkout current, and an exec
|
|
/// waiting for a download.
|
|
#[derive(Clone)]
|
|
pub struct Shared {
|
|
pub remote_checks: crate::git::RemoteChecks,
|
|
pub downloads: Arc<crate::restart::Downloads>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Inner {
|
|
/// What each component was last built from, by component name.
|
|
///
|
|
/// Seeded from the config at construction and updated as a run
|
|
/// records. Kept here rather than read back off `components` because
|
|
/// this state is deliberately *carried across* a config write -- see
|
|
/// [`BuildState::matches`] -- so its own copy of the components goes
|
|
/// stale the moment a build records against it.
|
|
built_from: HashMap<String, String>,
|
|
/// A pull is running. The one thing here that is the *project's* and
|
|
/// not a component's: there is one checkout, and a pull rewrites the
|
|
/// files every component builds from, so it is exclusive with all of
|
|
/// them.
|
|
///
|
|
/// Components have no such flag between them -- whether one is being
|
|
/// built is a property of its own entry in `runs`, so building one
|
|
/// neither blocks nor says anything about the others.
|
|
pulling: bool,
|
|
/// What the *project* is doing: fetching, pulling. Work belonging to
|
|
/// one component is in `runs` instead, because that is where the card
|
|
/// shows it -- a progress bar under the whole project could only ever
|
|
/// say that something, somewhere, was happening.
|
|
phase: Option<String>,
|
|
/// One entry per component that has been built since this server
|
|
/// started, in the order they were first reached.
|
|
///
|
|
/// Kept after a run rather than cleared at the start of the next one,
|
|
/// because a run is now one component's: clearing the list would
|
|
/// throw away a sibling's outcome to report on something that has
|
|
/// nothing to do with it. An entry is replaced only when *that*
|
|
/// component is claimed again.
|
|
runs: Vec<ComponentRun>,
|
|
/// The failure from the last pull, cleared when any new work starts.
|
|
/// Kept rather than logged-and-dropped because the phone is where
|
|
/// this is being driven from and usually has no access to the
|
|
/// server's log.
|
|
///
|
|
/// Pulls only. A build failure belongs to the component that produced
|
|
/// it (`ComponentRun::error`) -- held here it was the project's one
|
|
/// error slot, so two components building at once had one place to
|
|
/// report two outcomes.
|
|
error: Option<String>,
|
|
/// Whether that failure was a pull with no fast-forward to make,
|
|
/// which is the one the phone can offer a way past. Beside the
|
|
/// message rather than inside it because the phone acts on it: a
|
|
/// button that appears only when git happened to phrase itself a
|
|
/// certain way is a button nobody can rely on.
|
|
unrelated_histories: bool,
|
|
}
|
|
|
|
impl Inner {
|
|
/// Whether this component is being worked on right now.
|
|
///
|
|
/// Its own entry having an open step, which is the same thing the
|
|
/// phone reads off `ComponentStatus::step` -- so what disables a
|
|
/// component's button here and what draws its bar there cannot come
|
|
/// to disagree.
|
|
fn component_running(&self, name: &str) -> bool {
|
|
self.runs
|
|
.iter()
|
|
.any(|run| run.name == name && run.step.is_some())
|
|
}
|
|
|
|
/// Whether anything at all is happening for this project.
|
|
///
|
|
/// What `building` means on the wire, and deliberately a derived
|
|
/// answer rather than a flag of its own: a flag would be a second
|
|
/// place for the truth, and the one it disagreed with would be the
|
|
/// per-component one everything else is now drawn from.
|
|
fn anything_running(&self) -> bool {
|
|
self.pulling || self.runs.iter().any(|run| run.step.is_some())
|
|
}
|
|
}
|
|
|
|
/// What one component's part of the build is doing, or did.
|
|
///
|
|
/// Per component rather than one set of numbers for the whole project,
|
|
/// because that is how it is read: a progress bar and the lines scrolling
|
|
/// under it belong to the thing being built, and shown under the project
|
|
/// they could only say that *something* was happening. It is also what
|
|
/// lets two components build at once without their output interleaving
|
|
/// into one stream.
|
|
struct ComponentRun {
|
|
name: String,
|
|
/// What it is doing now -- building, installing, restarting -- or
|
|
/// `None` once it has finished.
|
|
step: Option<String>,
|
|
/// Steps done and steps total, when the running command reports them.
|
|
progress: Option<(u64, u64)>,
|
|
/// The tail of this component's output. Bounded because this is a
|
|
/// progress display, not a log file -- `crate::logs` has the whole of
|
|
/// it, per component, on disk.
|
|
log: VecDeque<String>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
pub struct BuildState {
|
|
/// What this project produces, in the order it produces it. A run walks
|
|
/// them, and the phase names on the status are the component names, so
|
|
/// the card shows which one is being worked on without any new
|
|
/// machinery.
|
|
components: Vec<Component>,
|
|
/// Which app this is, for naming the log files a build writes. The
|
|
/// only thing here that needs the key; everything else is about the
|
|
/// project on disk.
|
|
key: String,
|
|
/// Whether the Pull button is offered. Per project rather than per
|
|
/// component: there is one checkout.
|
|
git_pull: bool,
|
|
project_path: PathBuf,
|
|
/// True for the entry that *is* this server.
|
|
///
|
|
/// Only its `Server` component is delivered differently: that component
|
|
/// is this process, so restarting it means exec-ing the binary just
|
|
/// built (`crate::restart`) rather than asking the service script to
|
|
/// stop and start the code doing the asking -- which would kill the
|
|
/// walk partway through, with nobody left to report that it finished.
|
|
///
|
|
/// Passed in rather than read from the declaration because no project
|
|
/// may claim it: a file that could would be a file that can make this
|
|
/// server exec an arbitrary binary.
|
|
is_self: bool,
|
|
/// Force git's remote commands onto IPv4, for the fetch a pull starts.
|
|
/// Beside `git_pull` and treated the same way, including in
|
|
/// [`Self::matches`] -- toggling it has to reach the next pull, and
|
|
/// the state is reused across config writes unless something says not
|
|
/// to.
|
|
git_ipv4: bool,
|
|
/// Shared with the list, so a pull can record that this checkout is
|
|
/// current instead of leaving a stale "new commits" on the card.
|
|
shared: Shared,
|
|
inner: Mutex<Inner>,
|
|
}
|
|
|
|
/// What `/status` answers: whether anything needs building, and what is
|
|
/// being done about it.
|
|
///
|
|
/// One struct rather than a nested one. It was split so a card on
|
|
/// `/manifest` could carry the run half without paying for `stale`, which
|
|
/// walks every component's directory -- but nothing on the phone ever read
|
|
/// that field, so the split was an indirection with one user and the
|
|
/// second half of it has gone.
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct BuildStatus {
|
|
/// The expensive one: answering it walks every component's directory,
|
|
/// which is why this is on `/status` and not on the manifest path.
|
|
pub stale: bool,
|
|
/// Anything at all is happening for this project -- a pull, or any
|
|
/// component being built. Deliberately the *project's* question, for
|
|
/// the controls that act on the whole checkout; a caller asking about
|
|
/// one component reads that component's `step` instead, because this
|
|
/// answers yes while a sibling builds and would disable a button that
|
|
/// has nothing to wait for.
|
|
pub building: bool,
|
|
/// Why the last pull could not be made. Pulls only: a build failure is
|
|
/// reported against the component that produced it, since two
|
|
/// building at once have two outcomes and this is one field.
|
|
pub error: Option<String>,
|
|
/// That failure was a pull the checkout has no fast-forward for,
|
|
/// because it shares no history with its upstream. The phone offers
|
|
/// the forced pull for this and nothing else.
|
|
pub unrelated_histories: bool,
|
|
/// What the whole project is doing -- fetching, pulling -- absent
|
|
/// between runs and while the work belongs to a component instead.
|
|
pub phase: Option<String>,
|
|
/// Every component built since this server started, in the order they
|
|
/// were first reached. The card draws each of these inside that
|
|
/// component's own row, and reads whether *it* is busy from its own
|
|
/// entry.
|
|
pub components: Vec<ComponentStatus>,
|
|
}
|
|
|
|
/// One component's part of a build, as the phone sees it.
|
|
#[derive(Serialize, Clone)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ComponentStatus {
|
|
pub name: String,
|
|
/// What it is doing now, absent once it has finished.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub step: Option<String>,
|
|
/// Steps done and steps total, when the running command reports them.
|
|
/// Absent for a command that says nothing, which is most of them --
|
|
/// the phone shows a bar that only spins rather than inventing a
|
|
/// number.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub progress: Option<Progress>,
|
|
/// The last few lines this component printed, oldest first.
|
|
pub log: Vec<String>,
|
|
/// Why this component's step failed, if it did.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
/// Reads a progress report off a line of build output, in any of the
|
|
/// shapes this understands.
|
|
///
|
|
/// Anything that isn't exactly that shape is output, not a report: a build
|
|
/// that prints the marker with something unparseable after it has a bug
|
|
/// worth seeing in its log rather than being silently swallowed.
|
|
fn parse_progress(line: &str) -> Option<(u64, u64)> {
|
|
let line = line.trim();
|
|
if let Some(rest) = line.strip_prefix(PROGRESS_MARKER) {
|
|
let (done, total) = rest.trim().split_once('/')?;
|
|
return Some((done.trim().parse().ok()?, total.trim().parse().ok()?));
|
|
}
|
|
parse_cargo_progress(line)
|
|
}
|
|
|
|
/// Reads cargo's own counter: `Building [===> ] 236/237: crate(bin)`.
|
|
///
|
|
/// Cargo's, not this server's arithmetic -- it is the number of units it
|
|
/// has left to compile, which is exactly the thing a bar can honestly be
|
|
/// drawn from. `spawn` is what makes cargo emit it into a pipe at all.
|
|
///
|
|
/// Anchored on the bracketed bar so that a line merely containing `12/34`
|
|
/// is not read as progress: build output is full of numbers.
|
|
fn parse_cargo_progress(line: &str) -> Option<(u64, u64)> {
|
|
let rest = line.strip_prefix("Building ")?;
|
|
let counts = rest.strip_prefix('[')?.split_once(']')?.1.trim();
|
|
// `236/237: dev-updater(bin)` -- the crate after the colon is what it
|
|
// is working on now, which the log line above it already said.
|
|
let counts = counts.split(':').next()?;
|
|
let (done, total) = counts.split_once('/')?;
|
|
Some((done.trim().parse().ok()?, total.trim().parse().ok()?))
|
|
}
|
|
|
|
/// How much command output is kept for the phone. Enough to see what a
|
|
/// build is doing, far short of keeping a build log in memory.
|
|
const LOG_LINES: usize = 40;
|
|
|
|
/// The step a component is in while its own build command runs, named
|
|
/// once because two places have to agree on it: `claim` writes it when it
|
|
/// takes the component, and `build_component` writes it again when it
|
|
/// actually starts.
|
|
const BUILDING: &str = "building";
|
|
|
|
/// Calls `emit` once per segment of a command's output, where a segment
|
|
/// ends at a newline **or** a carriage return, and once more for anything
|
|
/// left unterminated when the stream ends.
|
|
///
|
|
/// Both terminators, because a tool that redraws a counter in place ends
|
|
/// its line with `\r` and no newline. Cargo does exactly this:
|
|
///
|
|
/// ```text
|
|
/// Compiling dev-updater v0.1.0 (...)\n
|
|
/// Building [=====> ] 236/237: dev-updater(bin) \r Finished `release` ...\n
|
|
/// ```
|
|
///
|
|
/// `BufReader::lines()` splits on `\n` alone, so the count sat unread
|
|
/// until whatever printed the next newline. On a build with many crates
|
|
/// to compile that is the next `Compiling` line and the bar moved fine;
|
|
/// on an incremental one -- a single dirty crate, which is what a pull
|
|
/// produces -- it is the `Finished` line, arriving after the build is
|
|
/// over. So the card showed nothing to measure for exactly the builds
|
|
/// somebody watches, and it looked like a build system that could not
|
|
/// report progress rather than a report being held.
|
|
///
|
|
/// A `\r\n` pair ends a segment and then opens an empty one, which the
|
|
/// caller drops along with every other blank segment.
|
|
fn read_segments(pipe: impl std::io::Read, mut emit: impl FnMut(&str)) {
|
|
use std::io::BufRead;
|
|
|
|
let mut reader = std::io::BufReader::new(pipe);
|
|
// Held across fills, because a terminator is what ends a segment and
|
|
// a read can stop anywhere -- including inside a multi-byte
|
|
// character, which is safe here only because both terminators are
|
|
// ASCII and so cannot appear inside one.
|
|
let mut pending: Vec<u8> = Vec::new();
|
|
loop {
|
|
// Copied out so the borrow of the reader ends before `consume`.
|
|
let chunk = match reader.fill_buf() {
|
|
Ok([]) => break,
|
|
Ok(bytes) => bytes.to_vec(),
|
|
// The process's exit status is what says whether the build
|
|
// worked; a pipe that cannot be read further is just the end
|
|
// of what there is to show.
|
|
Err(_) => break,
|
|
};
|
|
reader.consume(chunk.len());
|
|
for byte in chunk {
|
|
if byte == b'\n' || byte == b'\r' {
|
|
emit(&String::from_utf8_lossy(&pending));
|
|
pending.clear();
|
|
} else {
|
|
pending.push(byte);
|
|
}
|
|
}
|
|
}
|
|
if !pending.is_empty() {
|
|
emit(&String::from_utf8_lossy(&pending));
|
|
}
|
|
}
|
|
|
|
impl BuildState {
|
|
pub fn new(
|
|
key: String,
|
|
components: Vec<Component>,
|
|
git_pull: bool,
|
|
git_ipv4: bool,
|
|
project_path: PathBuf,
|
|
shared: Shared,
|
|
is_self: bool,
|
|
) -> Arc<Self> {
|
|
let built_from = components
|
|
.iter()
|
|
.filter_map(|component| {
|
|
Some((
|
|
component.name().to_string(),
|
|
component.built_from()?.to_string(),
|
|
))
|
|
})
|
|
.collect();
|
|
Arc::new(Self {
|
|
key,
|
|
inner: Mutex::new(Inner {
|
|
built_from,
|
|
..Inner::default()
|
|
}),
|
|
components,
|
|
git_pull,
|
|
git_ipv4,
|
|
project_path,
|
|
shared,
|
|
is_self,
|
|
})
|
|
}
|
|
|
|
/// Whether this state was built from the same configuration, i.e.
|
|
/// whether it can be carried across a config reload -- see
|
|
/// `crate::registry::build_entries`.
|
|
/// Compared by *declaration*, not by full equality. Recording what a
|
|
/// build was made from writes the config, which rebuilds the entry
|
|
/// list -- and if that counted as a different configuration the state
|
|
/// would be replaced mid-walk, leaving the phone polling a fresh
|
|
/// `BuildState` that reports nothing is building. The same is true of
|
|
/// the package a download reads back.
|
|
pub fn matches(&self, components: &[Component], git_pull: bool, git_ipv4: bool) -> bool {
|
|
self.git_pull == git_pull
|
|
&& self.git_ipv4 == git_ipv4
|
|
&& self.components.len() == components.len()
|
|
&& std::iter::zip(&self.components, components).all(|(mine, theirs)| {
|
|
// The declaration, plus the one choice that changes what
|
|
// this state would actually run. `same_declaration`
|
|
// deliberately ignores the chosen mode -- picking one is
|
|
// not the project asking for something new -- but this
|
|
// state holds a *snapshot* of the components it builds
|
|
// from, so a mode changed underneath it would go on
|
|
// running the old mode's command from a card reporting
|
|
// the new one. Strip is not here: it is decided at
|
|
// download, and nothing this state does depends on it.
|
|
mine.same_declaration(theirs) && mine.effective_mode() == theirs.effective_mode()
|
|
})
|
|
}
|
|
|
|
/// Idempotent: kicks off the build in the background if this app is
|
|
/// stale and no build is already running, a no-op otherwise. Safe for
|
|
/// the phone to call on every download, which is exactly what it does.
|
|
///
|
|
/// `component` narrows both the staleness check and the build itself
|
|
/// to one component -- what the phone asks before downloading it, so
|
|
/// that pressing Update on one client of a multi-client project
|
|
/// doesn't also pay for the other's build. `None` is every other
|
|
/// caller, which still means "the whole project".
|
|
pub fn trigger_if_needed(self: &Arc<Self>, component: Option<&str>, record: RecordBuilt) {
|
|
// Before the lock, not inside it -- see `is_stale`.
|
|
if self.is_stale(component) {
|
|
self.build_now(component, record);
|
|
}
|
|
}
|
|
|
|
/// Builds whether or not anything looks stale, for somebody who asked.
|
|
///
|
|
/// The staleness rules exist to keep a *download* from rebuilding the
|
|
/// world; they have no business overruling a person who pressed a
|
|
/// button. This is also what makes the freshness signal reachable at
|
|
/// all: the commit a build was made from is only recorded by a build
|
|
/// that runs here, so a project already current with its checkout
|
|
/// could never record one and would report unknown for ever.
|
|
///
|
|
/// Idempotent per *component*, not per project: a component already
|
|
/// being built is left alone, and every other one this selects starts
|
|
/// regardless. So two components of one project build at the same
|
|
/// time, and asking for one says nothing about the others -- which is
|
|
/// the whole point of a project being able to produce more than one
|
|
/// thing. The phone notices a component is busy by polling `/status`
|
|
/// and reading that component's own step.
|
|
///
|
|
/// `component` restricts the run to one named component, or every one
|
|
/// with a command for `None` -- see [`Self::trigger_if_needed`] for
|
|
/// why a caller would want the former.
|
|
pub fn build_now(self: &Arc<Self>, component: Option<&str>, record: RecordBuilt) {
|
|
let claimed = {
|
|
let mut inner = self.inner.lock().unwrap();
|
|
// The one thing a component still waits behind. See
|
|
// `Inner::pulling`.
|
|
if inner.pulling {
|
|
return;
|
|
}
|
|
self.claim(&mut inner, component)
|
|
};
|
|
self.start(claimed, record);
|
|
}
|
|
|
|
/// Marks each component `name` selects that has something to build
|
|
/// and is not already building, and answers where they are.
|
|
///
|
|
/// Takes the guard rather than the lock so that a caller can do this
|
|
/// and something else in one step -- which the pull needs: releasing
|
|
/// `pulling` and claiming what it decided to build have to happen
|
|
/// together, or a phone polling in between sees a project that is
|
|
/// neither pulling nor building and reads its run as finished.
|
|
///
|
|
/// The entry is written **here**, synchronously, rather than by the
|
|
/// build thread once it gets going. The route answers as soon as this
|
|
/// returns, so a status read in that window would otherwise find the
|
|
/// component idle -- and idle is exactly what the phone is waiting
|
|
/// for.
|
|
fn claim(&self, inner: &mut Inner, name: Option<&str>) -> Vec<usize> {
|
|
let mut claimed = Vec::new();
|
|
for (index, component) in self.components.iter().enumerate() {
|
|
if name.is_some_and(|name| component.name() != name) {
|
|
continue;
|
|
}
|
|
if component.build().is_empty() {
|
|
continue;
|
|
}
|
|
if inner.component_running(component.name()) {
|
|
continue;
|
|
}
|
|
// Whatever the last run left against this component goes now,
|
|
// so nothing from it can be read as this run's outcome -- an
|
|
// old error in particular, which the phone treats as a reason
|
|
// not to download.
|
|
reset_run(inner, component.name());
|
|
claimed.push(index);
|
|
}
|
|
if !claimed.is_empty() {
|
|
// The pull this describes is the one being built on top of.
|
|
inner.error = None;
|
|
inner.unrelated_histories = false;
|
|
}
|
|
claimed
|
|
}
|
|
|
|
/// Runs what [`Self::claim`] claimed, off the request's thread.
|
|
fn start(self: &Arc<Self>, claimed: Vec<usize>, record: RecordBuilt) {
|
|
if claimed.is_empty() {
|
|
return;
|
|
}
|
|
let this = Arc::clone(self);
|
|
tokio::task::spawn_blocking(move || this.run_claimed(claimed, &record));
|
|
}
|
|
|
|
/// Whether any component `name` selects is behind. Whole project for
|
|
/// `None`, which is what a run with nothing named means: one
|
|
/// component being stale is reason enough to walk the whole list, and
|
|
/// each component's own command is skipped if it has nothing to do.
|
|
fn is_stale(&self, name: Option<&str>) -> bool {
|
|
// Read once, here, and passed down. `component_is_stale` must not
|
|
// take this lock itself: it is reached from callers that already
|
|
// hold it, and a `std::sync::Mutex` is not reentrant, so doing so
|
|
// is a deadlock rather than a slow path.
|
|
let built_from = self.inner.lock().unwrap().built_from.clone();
|
|
self.components
|
|
.iter()
|
|
.filter(|component| name.is_none_or(|name| component.name() == name))
|
|
.any(|component| self.component_is_stale(component, &built_from))
|
|
}
|
|
|
|
/// What can be said about this component's build, for the card.
|
|
///
|
|
/// Deliberately not the same question as [`Self::component_is_stale`],
|
|
/// which decides whether to *run* a build. Uncommitted work makes the
|
|
/// comparison unreliable, so it reports unknown -- but it must not
|
|
/// trigger a rebuild: somebody editing on the build machine is
|
|
/// building it themselves, and this server's job in that state is to
|
|
/// notice the new output rather than to produce one. Rebuilding
|
|
/// uncommitted work and sending it to a phone would also be a
|
|
/// surprising thing to do with work its author has not committed.
|
|
///
|
|
/// `parked` says the checkout is sitting on a commit somebody chose
|
|
/// rather than following a branch, which is the one way a phone can
|
|
/// leave it: picking a commit in the settings sheet detaches HEAD.
|
|
/// Saying "out of date" about a checkout somebody deliberately moved
|
|
/// backwards is nagging about a decision already made -- and it is
|
|
/// nothing to act on either, because every way this reads as behind
|
|
/// while parked is a build that failed, a declaration waiting to be
|
|
/// accepted, or a component with no build step, each of which is
|
|
/// already said on the same card beside the button for it. So while
|
|
/// parked a differing commit is reported as unknown: withheld, rather
|
|
/// than claimed current, since nothing here measured the output to be
|
|
/// what somebody wanted.
|
|
///
|
|
/// Passed in rather than read here. The caller has already asked git
|
|
/// for this project's status, and asking again would be one more
|
|
/// process per component on a path fetched on every open, resume and
|
|
/// Refresh.
|
|
pub fn freshness(&self, component: &Component, parked: bool) -> Freshness {
|
|
// Behind for a reason no commit can express: what is on disk was
|
|
// built some other way than this component is set to build now.
|
|
// Reported before the checkout is consulted at all, because it is
|
|
// a fact rather than a comparison -- a clean tree at the very
|
|
// commit the debug build was made from still does not make that
|
|
// build a release one.
|
|
if component.built_in_another_mode() {
|
|
return Freshness::Behind;
|
|
}
|
|
let built = self
|
|
.inner
|
|
.lock()
|
|
.unwrap()
|
|
.built_from
|
|
.get(component.name())
|
|
.cloned();
|
|
let Some(built) = built else {
|
|
return Freshness::Unknown;
|
|
};
|
|
if crate::git::subtree_dirty(&self.project_path, &component.watched_paths()) != Some(false)
|
|
{
|
|
return Freshness::Unknown;
|
|
}
|
|
match crate::git::subtree_head(&self.project_path, &component.watched_paths()) {
|
|
Some(current) if current == built => Freshness::Current,
|
|
Some(_) if parked => Freshness::Unknown,
|
|
Some(_) => Freshness::Behind,
|
|
None => Freshness::Unknown,
|
|
}
|
|
}
|
|
|
|
fn component_is_stale(
|
|
&self,
|
|
component: &Component,
|
|
built_from: &HashMap<String, String>,
|
|
) -> bool {
|
|
if component.build().is_empty() {
|
|
return false;
|
|
}
|
|
// The same fact `freshness` reports as Behind, and it has to be
|
|
// asked here too: for an `Apk`, the "never built at all" check
|
|
// below finds *any* variant under the component's directory, so a
|
|
// debug build sitting there is enough to make a component
|
|
// switched to release look built. Nothing else would notice --
|
|
// the commit has not moved -- so Update would do nothing and the
|
|
// phone would install the debug APK from a card saying release.
|
|
if component.built_in_another_mode() {
|
|
return true;
|
|
}
|
|
// Nothing built at all is as far behind as an output gets, and it
|
|
// is checked before any rule rather than after: a `staleWhen`
|
|
// compares two files *inside* a build, so a component that has
|
|
// never been built reports not-stale under it and would leave the
|
|
// first build impossible to trigger -- which a project can be
|
|
// added before having done.
|
|
//
|
|
// Scoped to *this component's own* directory, not the project
|
|
// root: a project producing two APKs has one component's output
|
|
// sitting under the root-anchored patterns too (an inner
|
|
// `*/build/outputs/apk/*/*.apk` matches a one-level subdirectory
|
|
// regardless of which component it belongs to), which made the
|
|
// whole project read as "something is built here" the moment
|
|
// either component had ever been built -- masking that the
|
|
// *other* component, never built, had nothing to trigger it.
|
|
//
|
|
// Only asked of an `Apk`: a `Server` never has one to find under
|
|
// its own directory by definition, so the same question asked of
|
|
// it would report every server "never built" for ever, which is
|
|
// exactly the false staleness this check exists to rule out.
|
|
if matches!(component, Component::Apk { .. })
|
|
&& crate::discover::find_apks(&component.dir(&self.project_path)).is_empty()
|
|
{
|
|
return true;
|
|
}
|
|
// Behind the checkout: this component was built from a commit
|
|
// that its own directory has since moved past. The case nothing
|
|
// used to catch -- a pull that fetched but did not build, because
|
|
// the declaration was waiting to be accepted or because the walk
|
|
// failed earlier -- left an output older than the commits it
|
|
// should have come from, and the card had no reason to doubt it.
|
|
//
|
|
// Both halves must be known. No recorded commit means this server
|
|
// has never built it, and an unreadable checkout means it cannot
|
|
// tell; neither is evidence of staleness, and announcing one would
|
|
// make every project built before this existed demand a rebuild.
|
|
if let Some(built) = built_from.get(component.name())
|
|
&& crate::git::subtree_head(&self.project_path, &component.watched_paths())
|
|
.is_some_and(|current| ¤t != built)
|
|
{
|
|
return true;
|
|
}
|
|
// Deliberately not the other way round: a matching commit does not
|
|
// prove currency, because a `staleWhen` rule compares two build
|
|
// outputs and can be behind for reasons no commit explains.
|
|
component
|
|
.stale_when()
|
|
.is_some_and(|rule| rule.is_stale(&self.project_path))
|
|
}
|
|
|
|
/// Fetches, fast-forwards if there is anything to take, and builds --
|
|
/// the Pull button, which acts on the build machine rather than the
|
|
/// phone. While one is running, another does nothing.
|
|
///
|
|
/// Building happens when the pull actually moved the branch, or when
|
|
/// the configured staleness rule says the output is behind anyway; a
|
|
/// pull that finds nothing new therefore costs nothing.
|
|
///
|
|
/// `may_build` answers "may the project's command run?" -- false when
|
|
/// it is asking for a build step nobody has accepted (see
|
|
/// `AppEntry::pending_declaration`), in which case this pulls and
|
|
/// stops. The decision belongs to the caller because it is about the
|
|
/// project's config file, which is not this module's business -- here
|
|
/// it is simply "don't run the command".
|
|
///
|
|
/// It is a closure rather than a bool because of *when* it has to be
|
|
/// asked: **after the fast-forward, not before it.** The declaration
|
|
/// lives in the checkout, so the pull is the one thing that changes
|
|
/// the answer, and a bool computed at the call site is necessarily the
|
|
/// answer for the commit being replaced. Passing one meant a pull
|
|
/// whose new commits changed the declaration built anyway -- the card
|
|
/// went off and updated the app instead of stopping to ask, which is
|
|
/// the single case this gate exists for.
|
|
/// `force` abandons this checkout's own history in favour of the
|
|
/// upstream's, and is only ever pressed after a pull has already
|
|
/// reported that the two are unrelated (`git::PullError`). It is a
|
|
/// parameter rather than something decided here because it is a
|
|
/// person's answer to that report, not a state of the repository.
|
|
///
|
|
/// Unlike a build, this is exclusive with everything the project is
|
|
/// doing: it rewrites the one checkout every component is built from,
|
|
/// so it waits for any component still building and blocks any that
|
|
/// would start. That is the project's own coupling rather than the
|
|
/// components', and it is the only one left here.
|
|
pub fn pull_and_build(
|
|
self: &Arc<Self>,
|
|
force: bool,
|
|
may_build: impl Fn() -> bool + Send + 'static,
|
|
record: RecordBuilt,
|
|
) {
|
|
self.after_moving(
|
|
move |this| this.pull(force),
|
|
Some((Box::new(may_build), record)),
|
|
);
|
|
}
|
|
|
|
/// Moves this checkout onto `target` -- a branch or a commit -- and
|
|
/// builds nothing.
|
|
///
|
|
/// Exclusive with everything, and deferred to the same machinery as a
|
|
/// pull, because it is the same kind of act: there is one checkout,
|
|
/// and this rewrites the files every component builds from. It
|
|
/// reports through the project's own state for the same reason, so a
|
|
/// card shows it exactly as it shows a pull.
|
|
///
|
|
/// Where it deliberately differs from a pull is that nothing is built
|
|
/// afterwards. A pull is somebody taking new work, so building it is
|
|
/// the point; moving the checkout is somebody *looking* -- at another
|
|
/// branch, at last week's commit -- and making the cost of looking a
|
|
/// full build of every component means an accidental tap starts a
|
|
/// minute of work with nothing to cancel it, and replaces outputs
|
|
/// that were wanted. What that leaves behind is a component whose
|
|
/// build no longer matches the checkout, which the card already says
|
|
/// and already carries the button for. Iris asked for this on
|
|
/// 2026-09-02.
|
|
pub fn move_checkout(self: &Arc<Self>, target: String) {
|
|
self.after_moving(
|
|
move |this| {
|
|
this.begin_project("checking out");
|
|
crate::git::checkout(&this.project_path, &target)?;
|
|
tracing::info!("checked out {target} in {}", this.project_path.display());
|
|
Ok(true)
|
|
},
|
|
None,
|
|
);
|
|
}
|
|
|
|
/// The half a pull and a checkout share: run the thing that moves the
|
|
/// working tree, then build what it left behind.
|
|
///
|
|
/// One copy, because the part that is easy to get wrong is not the
|
|
/// git command -- it is handing the run over under a single lock so
|
|
/// that nothing can observe the moment between the move ending and
|
|
/// the builds it decided on starting. A phone polling in that gap
|
|
/// sees a project that is neither pulling nor building and calls the
|
|
/// run finished.
|
|
fn after_moving(
|
|
self: &Arc<Self>,
|
|
move_it: impl FnOnce(&Arc<Self>) -> Result<bool, crate::git::PullError> + Send + 'static,
|
|
then_build: BuildAfterMoving,
|
|
) {
|
|
// Claimed before anything is spawned, and while nothing else is
|
|
// running: one checkout, so a move is exclusive with every build
|
|
// as well as with another move.
|
|
{
|
|
let mut inner = self.inner.lock().unwrap();
|
|
if inner.anything_running() {
|
|
return;
|
|
}
|
|
inner.pulling = true;
|
|
inner.error = None;
|
|
inner.unrelated_histories = false;
|
|
}
|
|
let this = Arc::clone(self);
|
|
tokio::task::spawn_blocking(move || {
|
|
let outcome = move_it(&this);
|
|
match outcome {
|
|
Err(error) => {
|
|
tracing::error!("moving the checkout failed: {}", error.message);
|
|
this.fail_pull(error);
|
|
}
|
|
Ok(pulled) => {
|
|
// Nothing configured to build, nothing allowed to, or
|
|
// a move that builds nothing by definition: the move
|
|
// was the whole job.
|
|
// `may_build()` is called here, with the pulled
|
|
// declaration on disk, for the reason in the doc
|
|
// comment above. Both it and `is_stale` take the lock
|
|
// themselves, so they are asked before it is held.
|
|
let build = then_build
|
|
.as_ref()
|
|
.is_some_and(|(may_build, _)| may_build())
|
|
&& this.has_command()
|
|
&& (pulled || this.is_stale(None));
|
|
// Handing the run over in one step, so nothing can
|
|
// observe the moment between the pull ending and the
|
|
// builds it decided on starting -- see `claim`.
|
|
let claimed = {
|
|
let mut inner = this.inner.lock().unwrap();
|
|
let claimed = match build {
|
|
true => this.claim(&mut inner, None),
|
|
false => Vec::new(),
|
|
};
|
|
inner.pulling = false;
|
|
inner.phase = None;
|
|
claimed
|
|
};
|
|
if let Some((_, record)) = then_build {
|
|
this.run_claimed(claimed, &record);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Fetches and fast-forwards, reporting whether anything arrived.
|
|
fn pull(&self, force: bool) -> Result<bool, crate::git::PullError> {
|
|
self.begin_project("fetching");
|
|
// No prune: taking commits is not the moment to decide which
|
|
// remote branches still exist, and the Fetch button is what asks
|
|
// that question.
|
|
crate::git::fetch(&self.project_path, self.git_ipv4, false)?;
|
|
// Counted after the fetch, from refs now on disk: this is the one
|
|
// place that has the objects to count against.
|
|
let behind = crate::git::behind(&self.project_path);
|
|
if behind == 0 {
|
|
return Ok(false);
|
|
}
|
|
self.begin_project("pulling");
|
|
crate::git::pull(&self.project_path, self.git_ipv4, force)?;
|
|
tracing::info!(
|
|
"pulled {behind} commit(s) into {}",
|
|
self.project_path.display()
|
|
);
|
|
// The card should stop offering what was just taken, rather than
|
|
// waiting out the check interval to notice.
|
|
self.shared.remote_checks.mark_current(&self.project_path);
|
|
Ok(true)
|
|
}
|
|
|
|
/// Builds the claimed components, at once, and reports each against
|
|
/// itself.
|
|
///
|
|
/// Every one of them together rather than in turn -- they are
|
|
/// independent, a Rust build and a Gradle build share nothing but the
|
|
/// machine, and measured on this one, running them together takes
|
|
/// about three quarters of the time running them in turn does.
|
|
///
|
|
/// What running them together costs is that a failure no longer stops
|
|
/// the others: they are already running by the time it happens, so
|
|
/// stopping them would mean killing work that is probably fine. There
|
|
/// is no "the run failed" left to report either way -- an outcome
|
|
/// belongs to the component that produced it, and the phone reads it
|
|
/// off that component's own entry.
|
|
fn run_claimed(self: &Arc<Self>, claimed: Vec<usize>, record: &RecordBuilt) {
|
|
let mut running = Vec::new();
|
|
for index in claimed {
|
|
let this = Arc::clone(self);
|
|
let record = Arc::clone(record);
|
|
running.push((
|
|
self.components[index].name().to_string(),
|
|
std::thread::spawn(move || this.build_indexed(index, &record)),
|
|
));
|
|
}
|
|
|
|
// Whether anything in *this* run fell over, which is only asked
|
|
// about the restart below. It is not reported anywhere: the
|
|
// component that failed has already recorded its own message.
|
|
let mut failed = false;
|
|
// Set by the component that is this process; acted on once every
|
|
// other component in the run has finished. See `is_self`.
|
|
let mut restart_self = false;
|
|
for (name, handle) in running {
|
|
let outcome = match handle.join() {
|
|
// A panic in a build thread is this server's bug, not the
|
|
// project's, but the card still has to say something --
|
|
// silence would read as a build that simply did nothing.
|
|
// Recorded here because the panic is what stopped
|
|
// `build_indexed` from doing it, and a component left with
|
|
// an open step never stops looking busy.
|
|
Err(_) => {
|
|
let message = format!("building {name} panicked -- see this server's log");
|
|
self.finish_component(&name, Some(message.clone()));
|
|
Err(message)
|
|
}
|
|
Ok(outcome) => outcome,
|
|
};
|
|
match outcome {
|
|
Ok(is_self) => restart_self |= is_self,
|
|
Err(_) => failed = true,
|
|
}
|
|
}
|
|
|
|
// Not restarting on a failure is about *this process*, not about
|
|
// the components: an exec drops the connection the phone is
|
|
// reading the failure over, and it would lose the report it is
|
|
// waiting for.
|
|
let mut restart = !failed && restart_self;
|
|
// A build with nothing to do leaves the binary alone, and exec-ing
|
|
// into the same file would drop the phone's connection to deliver
|
|
// the build it already had.
|
|
if restart && crate::restart::binary_unchanged() {
|
|
tracing::info!(
|
|
"the build left this server's binary untouched, so it is not restarting"
|
|
);
|
|
restart = false;
|
|
}
|
|
if restart {
|
|
// Every component has closed its own entry by now, so whatever
|
|
// happens next cannot take the report away from whoever asked
|
|
// for it.
|
|
crate::restart::deferred(self.handover(), Arc::clone(&self.shared.downloads));
|
|
}
|
|
}
|
|
|
|
/// Builds the component at `index`, and closes its entry whatever
|
|
/// happens.
|
|
///
|
|
/// By index because the components belong to this state, which the
|
|
/// thread holds an `Arc` to -- borrowing one across a thread boundary
|
|
/// would tie the thread's lifetime to this call's.
|
|
///
|
|
/// Answers whether that component is this server itself, which is the
|
|
/// one thing the caller has to act on after every thread is done.
|
|
fn build_indexed(self: &Arc<Self>, index: usize, record: &RecordBuilt) -> Result<bool, String> {
|
|
let component = &self.components[index];
|
|
let outcome = self.build_component(component, record);
|
|
// One place, so no exit from `build_component` can leave a
|
|
// component looking like it is still working.
|
|
self.finish_component(component.name(), outcome.as_ref().err().cloned());
|
|
outcome
|
|
}
|
|
|
|
/// Builds one component and delivers it, or says why it could not.
|
|
///
|
|
/// One function rather than the walk's body inline, so that "the build
|
|
/// stopped here" is recorded in exactly one place -- every failure
|
|
/// leaves by the same return, and the caller names the component
|
|
/// without each site having to remember to.
|
|
fn build_component(
|
|
self: &Arc<Self>,
|
|
component: &Component,
|
|
record: &RecordBuilt,
|
|
) -> Result<bool, String> {
|
|
self.begin_component(component.name(), BUILDING);
|
|
if let Err(message) = self.run_streaming(component) {
|
|
tracing::error!("{} failed: {message}", component.name());
|
|
return Err(message);
|
|
}
|
|
// Recorded per component and only on success, so a walk that
|
|
// fails partway credits what actually built and nothing else:
|
|
// if the APK fails after the server succeeded, the server is
|
|
// current and the APK is not. Scoped to the component's own
|
|
// directory, so a commit touching only the app does not make
|
|
// the server look stale.
|
|
//
|
|
// A checkout that cannot be read records nothing, which reads
|
|
// as unknown rather than as current -- see `component_is_stale`.
|
|
if let Some(sha) = crate::git::subtree_head(&self.project_path, &component.watched_paths())
|
|
{
|
|
self.inner
|
|
.lock()
|
|
.unwrap()
|
|
.built_from
|
|
.insert(component.name().to_string(), sha.clone());
|
|
record(component.name(), sha);
|
|
}
|
|
|
|
// Delivering a server is restarting it, so it happens here rather
|
|
// than at the end: a later component's failure should not leave a
|
|
// built-but-not-restarted server behind.
|
|
let Component::Server { name, .. } = component else {
|
|
return Ok(false);
|
|
};
|
|
let Some(service) = crate::service::driver(&self.key, component) else {
|
|
return Ok(false);
|
|
};
|
|
let service = &service;
|
|
|
|
// What the manager says decides both halves: whether the unit is
|
|
// worth refreshing, and whether anything should be restarted
|
|
// afterwards.
|
|
let installed = match crate::service::status(service, &self.project_path, component.cwd()) {
|
|
Ok(state) => Some(state),
|
|
// Not a build failure: the build worked, and whether the
|
|
// service came back is a separate thing the card reports on
|
|
// its own.
|
|
Err(err) => {
|
|
tracing::warn!("could not ask {name} how it is running: {err}");
|
|
None
|
|
}
|
|
};
|
|
|
|
// Refresh the unit whenever there is one, for either kind of
|
|
// server. It is generated from a script in the repository, so a
|
|
// pull can change how the service is *defined* and not only what
|
|
// it runs -- and that change has to land even when the build
|
|
// produced an identical binary, which is the ordinary case for a
|
|
// pull that only touched the service script. `start.sh` has
|
|
// always reinstalled every run for exactly this reason.
|
|
//
|
|
// Safe on a running service: writing the unit and reloading stops
|
|
// nothing.
|
|
if installed.is_some_and(|state| state != crate::service::ServiceState::NotInstalled) {
|
|
self.begin_component(name, "installing");
|
|
if let Err(message) =
|
|
self.run_streaming_command(name, service, component.cwd(), &["install"], None)
|
|
{
|
|
tracing::error!("installing {name} failed: {message}");
|
|
return Err(message);
|
|
}
|
|
}
|
|
|
|
if self.is_self {
|
|
// This one is the process doing the walking, so its restart
|
|
// waits until every other component is done and the result is
|
|
// reported. See the end of `run_build`.
|
|
return Ok(true);
|
|
}
|
|
if installed == Some(crate::service::ServiceState::Running) {
|
|
self.begin_component(name, "restarting");
|
|
if let Err(message) =
|
|
self.run_streaming_command(name, service, component.cwd(), &["restart"], None)
|
|
{
|
|
tracing::error!("restarting {name} failed: {message}");
|
|
return Err(message);
|
|
}
|
|
} else if let Some(state) = installed {
|
|
tracing::info!("{name} is {state:?}, so it was not started");
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
/// How this server should be restarted, if it declares a way.
|
|
///
|
|
/// Only ever asked for this server's own project, where exactly one
|
|
/// `Server` component is this process.
|
|
fn handover(&self) -> Option<crate::restart::Handover> {
|
|
let (component, script) = self.components.iter().find_map(|component| {
|
|
matches!(component, Component::Server { .. })
|
|
.then(|| crate::service::driver(&self.key, component))
|
|
.flatten()
|
|
.map(|script| (component, script))
|
|
})?;
|
|
Some(crate::restart::Handover {
|
|
script,
|
|
project: self.project_path.clone(),
|
|
cwd: component.cwd().map(std::path::Path::to_path_buf),
|
|
})
|
|
}
|
|
|
|
/// Runs the build command, recording its output as it arrives rather
|
|
/// than collecting it at the end.
|
|
///
|
|
/// A build is the long part of a pull, and a phone showing nothing but
|
|
/// a spinner for a minute is indistinguishable from one that is stuck.
|
|
/// Reading the pipes as they fill also means a chatty build can't fill
|
|
/// a pipe buffer and block itself, which collecting output at the end
|
|
/// risks for anything verbose.
|
|
fn run_streaming(self: &Arc<Self>, component: &Component) -> Result<(), String> {
|
|
// Opened per component and rotated here, so a failed build is
|
|
// still readable after this server restarts -- the tail kept in
|
|
// memory for the progress display dies with the process, and a
|
|
// build that brought the server down is exactly the one somebody
|
|
// wants to read afterwards.
|
|
let log = crate::logs::open_build_log(&self.key, component.name())
|
|
.map(|file| Arc::new(Mutex::new(file)));
|
|
// The mode, for a build command written once -- which is how a
|
|
// project with a script that takes `release` or `debug` says it,
|
|
// and why nothing has to be written twice in that case. Nothing
|
|
// for a command written per mode: that command already *is* the
|
|
// answer, and handing the word over as well would pass a stray
|
|
// argument to a build that never asked for one.
|
|
let mode = component.build_mode_argument();
|
|
self.run_streaming_command(
|
|
component.name(),
|
|
component.build(),
|
|
component.cwd(),
|
|
mode.as_slice(),
|
|
log,
|
|
)
|
|
}
|
|
|
|
fn run_streaming_command(
|
|
self: &Arc<Self>,
|
|
component: &str,
|
|
command: &crate::config::Command,
|
|
cwd: Option<&std::path::Path>,
|
|
extra: &[&str],
|
|
log: Option<Arc<Mutex<std::fs::File>>>,
|
|
) -> Result<(), String> {
|
|
let mut child = self
|
|
.spawn(command, cwd, extra)?
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()
|
|
.map_err(|err| format!("failed to start the build: {err}"))?;
|
|
|
|
// Both pipes, on separate threads: whichever the build writes to,
|
|
// waiting on only one of them would deadlock when the other fills.
|
|
let mut readers = Vec::new();
|
|
for pipe in [
|
|
child
|
|
.stdout
|
|
.take()
|
|
.map(|out| Box::new(out) as Box<dyn std::io::Read + Send>),
|
|
child
|
|
.stderr
|
|
.take()
|
|
.map(|err| Box::new(err) as Box<dyn std::io::Read + Send>),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
let state = Arc::clone(self);
|
|
let log = log.clone();
|
|
// Owned per reader thread, because the line it routes has to
|
|
// reach the component that printed it and the thread outlives
|
|
// this borrow.
|
|
let owned = component.to_string();
|
|
readers.push(std::thread::spawn(move || {
|
|
read_segments(pipe, |segment| {
|
|
let segment = segment.trim_end();
|
|
if segment.is_empty() {
|
|
return;
|
|
}
|
|
// The file gets everything, including the progress
|
|
// markers: it is the record of what the command
|
|
// actually printed, not the display. One segment per
|
|
// line, which is already what a wrapper emitting
|
|
// `@@progress` produces, and what keeps a line from
|
|
// one pipe out of the middle of a line from the other.
|
|
if let Some(log) = &log {
|
|
use std::io::Write;
|
|
let _ = writeln!(log.lock().unwrap(), "{segment}");
|
|
}
|
|
match parse_progress(segment) {
|
|
// Machinery, not output: it would only push the
|
|
// build's own last line out of the display.
|
|
Some(progress) => state.set_progress(&owned, progress),
|
|
None => state.log_line(&owned, segment.to_string()),
|
|
}
|
|
});
|
|
}));
|
|
}
|
|
|
|
let status = child
|
|
.wait()
|
|
.map_err(|err| format!("waiting on the build: {err}"))?;
|
|
for reader in readers {
|
|
let _ = reader.join();
|
|
}
|
|
|
|
if status.success() {
|
|
return Ok(());
|
|
}
|
|
// The tail is what a failing build explains itself in, and it is
|
|
// already here rather than needing to be collected separately.
|
|
let tail: Vec<String> = self
|
|
.inner
|
|
.lock()
|
|
.unwrap()
|
|
.runs
|
|
.iter()
|
|
.find(|run| run.name == component)
|
|
.map(|run| run.log.iter().cloned().collect())
|
|
.unwrap_or_default();
|
|
Err(format!(
|
|
"the build step failed ({status}):\n{}",
|
|
truncate_tail(&tail.join("\n"), 2000)
|
|
))
|
|
}
|
|
|
|
/// The command to run, configured but not started.
|
|
fn spawn(
|
|
&self,
|
|
command: &crate::config::Command,
|
|
cwd: Option<&std::path::Path>,
|
|
extra: &[&str],
|
|
) -> Result<Command, String> {
|
|
let mut process = command.to_process(&self.project_path, cwd, extra)?;
|
|
// Cargo hides its progress counter unless it is talking to a
|
|
// terminal, and it is talking to a pipe. Asked for it explicitly,
|
|
// it prints `Building [===> ] 236/237: crate` -- counts it worked
|
|
// out itself, which is the only kind this shows. Set here rather
|
|
// than asked of each project, because a build that happens to be
|
|
// cargo should not have to know that it is being watched, and
|
|
// because a project could otherwise only offer this by
|
|
// reimplementing it.
|
|
//
|
|
// Harmless to anything that is not cargo: an unread variable.
|
|
// Width fixed so the bar is not sized against a terminal there
|
|
// isn't; nothing reads the bar itself, only the numbers after it.
|
|
process.env("CARGO_TERM_PROGRESS_WHEN", "always");
|
|
process.env("CARGO_TERM_PROGRESS_WIDTH", "100");
|
|
// For the build systems that cannot be asked, a wrapper this
|
|
// server ships. Handed over as a path rather than expected on
|
|
// PATH, and absent when a person runs the same script by hand --
|
|
// which is why a script tests for it rather than depending on it.
|
|
// See `crate::shipped`.
|
|
process.env("DEV_UPDATER_PROGRESS", crate::shipped::build_progress());
|
|
Ok(process)
|
|
}
|
|
|
|
/// Marks what the *project* is doing. Only fetching and pulling: the
|
|
/// rest belongs to a component.
|
|
fn begin_project(&self, name: &str) {
|
|
self.inner.lock().unwrap().phase = Some(name.to_string());
|
|
}
|
|
|
|
/// Marks one component as doing `step`.
|
|
///
|
|
/// Its entry already exists -- `claim` writes it before the build
|
|
/// thread starts, so that nothing can read the component as idle in
|
|
/// between -- but it is created here if it somehow does not, since a
|
|
/// step nobody can see is worse than a duplicated one.
|
|
fn begin_component(&self, component: &str, step: &str) {
|
|
let mut inner = self.inner.lock().unwrap();
|
|
// The project-wide phase is over once a component is working;
|
|
// otherwise the card shows "pulling" under a build.
|
|
inner.phase = None;
|
|
match inner.runs.iter_mut().find(|run| run.name == component) {
|
|
Some(run) => {
|
|
run.step = Some(step.to_string());
|
|
// A count belongs to the step that printed it.
|
|
run.progress = None;
|
|
}
|
|
None => reset_run(&mut inner, component),
|
|
}
|
|
}
|
|
|
|
/// Marks one component as done, with why it stopped if it failed.
|
|
///
|
|
/// The end of a run, now that a run is one component's: there is no
|
|
/// project-wide "finished" left to say, and the phone reads this
|
|
/// component's closed step as the answer for this component alone.
|
|
fn finish_component(&self, component: &str, error: Option<String>) {
|
|
let mut inner = self.inner.lock().unwrap();
|
|
if let Some(run) = inner.runs.iter_mut().find(|run| run.name == component) {
|
|
run.step = None;
|
|
run.error = error;
|
|
}
|
|
}
|
|
|
|
/// Ends a pull that could not be made.
|
|
///
|
|
/// The project's own failure rather than a component's, and it
|
|
/// carries the one thing a build failure cannot: whether abandoning
|
|
/// this checkout's own history would clear it. Cleared at the start of
|
|
/// anything that runs afterwards, so a pull is the only thing that can
|
|
/// ever make it true.
|
|
fn fail_pull(&self, error: crate::git::PullError) {
|
|
let mut inner = self.inner.lock().unwrap();
|
|
inner.unrelated_histories = error.unrelated_histories;
|
|
inner.error = Some(error.message);
|
|
inner.phase = None;
|
|
inner.pulling = false;
|
|
}
|
|
|
|
/// Records how far the running command says it has got. Replaces the
|
|
/// previous count rather than accumulating: the command reports its
|
|
/// own total, and `begin` clears this so one component's count is
|
|
/// never shown against the next one's work.
|
|
fn set_progress(&self, component: &str, progress: (u64, u64)) {
|
|
let mut inner = self.inner.lock().unwrap();
|
|
if let Some(run) = inner.runs.iter_mut().find(|run| run.name == component) {
|
|
run.progress = Some(progress);
|
|
}
|
|
}
|
|
|
|
/// Records one line of a component's output for its progress display,
|
|
/// dropping the oldest once the window is full.
|
|
///
|
|
/// Kept against the component that printed it rather than in one
|
|
/// stream, so two building at once do not interleave into something
|
|
/// neither of them said.
|
|
fn log_line(&self, component: &str, line: String) {
|
|
let mut inner = self.inner.lock().unwrap();
|
|
if let Some(run) = inner.runs.iter_mut().find(|run| run.name == component) {
|
|
if run.log.len() == LOG_LINES {
|
|
run.log.pop_front();
|
|
}
|
|
run.log.push_back(line);
|
|
}
|
|
}
|
|
|
|
/// Whether there is anything to run, as opposed to a checkout that
|
|
/// can only be pulled.
|
|
pub fn has_command(&self) -> bool {
|
|
crate::config::has_command(&self.components)
|
|
}
|
|
|
|
/// Whether this component's last build stopped at it.
|
|
///
|
|
/// The card asks so it can open that component's build log first. A
|
|
/// component that has not failed answers false, which is what makes
|
|
/// the runtime log the ordinary default -- and it is asked of the
|
|
/// component's own entry, so one component's failure cannot send
|
|
/// another's card to the wrong tab.
|
|
pub fn build_failed(&self, component: &str) -> bool {
|
|
self.inner
|
|
.lock()
|
|
.unwrap()
|
|
.runs
|
|
.iter()
|
|
.any(|run| run.name == component && run.error.is_some())
|
|
}
|
|
|
|
pub fn status(&self) -> BuildStatus {
|
|
// Before the lock, not inside it -- see `is_stale`.
|
|
let stale = self.is_stale(None);
|
|
let inner = self.inner.lock().unwrap();
|
|
BuildStatus {
|
|
stale,
|
|
building: inner.anything_running(),
|
|
error: inner.error.clone(),
|
|
unrelated_histories: inner.unrelated_histories,
|
|
phase: inner.phase.clone(),
|
|
components: inner
|
|
.runs
|
|
.iter()
|
|
.map(|run| ComponentStatus {
|
|
name: run.name.clone(),
|
|
step: run.step.clone(),
|
|
progress: run
|
|
.progress
|
|
.filter(|(_, total)| *total > 0)
|
|
.map(|(done, total)| Progress {
|
|
done: done.min(total),
|
|
total,
|
|
}),
|
|
log: run.log.iter().cloned().collect(),
|
|
error: run.error.clone(),
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Starts this component's entry over, replacing whatever the last build
|
|
/// of it left behind.
|
|
///
|
|
/// The step is set here rather than by the thread that will do the work,
|
|
/// so there is no window in which a claimed component reads as idle. The
|
|
/// word is the first thing every build does anyway (`build_component`),
|
|
/// so nothing has to say it twice.
|
|
fn reset_run(inner: &mut Inner, component: &str) {
|
|
let fresh = ComponentRun {
|
|
name: component.to_string(),
|
|
step: Some(BUILDING.to_string()),
|
|
progress: None,
|
|
log: VecDeque::new(),
|
|
error: None,
|
|
};
|
|
match inner.runs.iter_mut().find(|run| run.name == component) {
|
|
// In place, so the order the phone receives components in stays
|
|
// the order it first saw them.
|
|
Some(run) => *run = fresh,
|
|
None => inner.runs.push(fresh),
|
|
}
|
|
}
|
|
|
|
fn truncate_tail(s: &str, max_chars: usize) -> String {
|
|
let char_count = s.chars().count();
|
|
if char_count <= max_chars {
|
|
s.to_string()
|
|
} else {
|
|
s.chars().skip(char_count - max_chars).collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
/// Cargo's own counter, which `spawn` asks it to print into a pipe.
|
|
/// The numbers are cargo's; nothing here works them out.
|
|
#[test]
|
|
fn cargos_progress_counter_is_read_and_other_numbers_are_not() {
|
|
assert_eq!(
|
|
parse_progress(" Building [=====> ] 236/237: dev-updater(bin)"),
|
|
Some((236, 237))
|
|
);
|
|
assert_eq!(parse_progress(" Building [] 0/12"), Some((0, 12)));
|
|
|
|
// Build output is full of numbers; only the bracketed bar means a
|
|
// count. A crate that happens to be called this must not move it.
|
|
assert_eq!(parse_progress(" Compiling foo v1.2.3 (12/34)"), None);
|
|
assert_eq!(parse_progress("warning: 3/4 of these are unused"), None);
|
|
assert_eq!(parse_progress("Building the thing 1/2"), None);
|
|
}
|
|
|
|
/// A project can still report its own, which is the only way a tool
|
|
/// with no counter of its own gets a bar.
|
|
#[test]
|
|
fn the_explicit_marker_still_wins() {
|
|
assert_eq!(parse_progress("@@progress 3/7"), Some((3, 7)));
|
|
assert_eq!(parse_progress("@@progress nonsense"), None);
|
|
}
|
|
|
|
use std::path::Path;
|
|
|
|
use super::*;
|
|
|
|
fn run(dir: &Path, args: &[&str]) {
|
|
let out = std::process::Command::new(args[0])
|
|
.args(&args[1..])
|
|
.current_dir(dir)
|
|
.output()
|
|
.expect("run");
|
|
assert!(
|
|
out.status.success(),
|
|
"{args:?}: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
|
|
/// One checkout producing two things, which is the shape both real
|
|
/// projects have: a server under `server/` and an app under `app/`.
|
|
fn two_component_checkout(root: &Path) -> Vec<Component> {
|
|
std::fs::create_dir_all(root.join("server")).expect("mkdir");
|
|
std::fs::create_dir_all(root.join("app/build/outputs/apk/debug")).expect("mkdir");
|
|
// Something for `find_apks` to see, so staleness is decided by the
|
|
// rules under test rather than by nothing being built.
|
|
std::fs::write(root.join("app/build/outputs/apk/debug/a.apk"), b"").expect("write");
|
|
std::fs::write(root.join("server/main.rs"), "one").expect("write");
|
|
std::fs::write(root.join("app/main.kt"), "one").expect("write");
|
|
for args in [
|
|
&["git", "init", "-q", "-b", "main"][..],
|
|
&["git", "config", "user.email", "t@example.com"],
|
|
&["git", "config", "user.name", "Test"],
|
|
&["git", "add", "-A"],
|
|
&["git", "commit", "-qm", "one"],
|
|
] {
|
|
run(root, args);
|
|
}
|
|
vec![
|
|
Component::Server {
|
|
name: "backend".to_string(),
|
|
modes: Vec::new(),
|
|
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
|
|
cwd: Some(PathBuf::from("server")),
|
|
stale_when: None,
|
|
also_watch: Vec::new(),
|
|
service: None,
|
|
mode: None,
|
|
built_from: None,
|
|
built_mode: None,
|
|
},
|
|
Component::Apk {
|
|
name: "app".to_string(),
|
|
modes: Vec::new(),
|
|
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
|
|
cwd: Some(PathBuf::from("app")),
|
|
stale_when: None,
|
|
also_watch: Vec::new(),
|
|
strip: false,
|
|
enroll: crate::config::Command::default(),
|
|
strip_here: None,
|
|
mode: None,
|
|
package: None,
|
|
built_from: None,
|
|
built_mode: None,
|
|
},
|
|
]
|
|
}
|
|
|
|
/// A pull that brings a changed declaration must stop, not build.
|
|
///
|
|
/// The gate answers "may this project's command run?", and the
|
|
/// declaration it reads lives in the checkout -- so the pull is the
|
|
/// one thing that changes the answer. Asked before the fast-forward
|
|
/// it necessarily reports on the commit being replaced, which is how
|
|
/// a card went off and updated the app instead of stopping to ask.
|
|
/// Hence a closure: this holds the *moment* it is called.
|
|
#[tokio::test]
|
|
async fn a_pull_that_changes_the_declaration_does_not_build() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let origin = dir.path().join("origin");
|
|
std::fs::create_dir_all(&origin).expect("mkdir");
|
|
let declare =
|
|
|command: &str| format!("components: [Apk(name: \"app\", build: \"{command}\")],\n");
|
|
std::fs::write(
|
|
origin.join(crate::config::PROJECT_CONFIG_FILE),
|
|
declare("touch built-marker"),
|
|
)
|
|
.expect("write");
|
|
for args in [
|
|
&["git", "init", "-q", "-b", "main"][..],
|
|
&["git", "config", "user.email", "t@example.com"],
|
|
&["git", "config", "user.name", "Test"],
|
|
&["git", "add", "-A"],
|
|
&["git", "commit", "-qm", "one"],
|
|
] {
|
|
run(&origin, args);
|
|
}
|
|
let clone = dir.path().join("clone");
|
|
run(
|
|
dir.path(),
|
|
&[
|
|
"git",
|
|
"clone",
|
|
"-q",
|
|
origin.to_str().unwrap(),
|
|
clone.to_str().unwrap(),
|
|
],
|
|
);
|
|
|
|
// What this machine accepted, which is what the checkout asks for
|
|
// right now -- so the gate is open at the moment the button is
|
|
// pressed, and only the pull closes it.
|
|
let accepted = vec![Component::Apk {
|
|
name: "app".to_string(),
|
|
modes: Vec::new(),
|
|
build: crate::config::ByMode::One(crate::config::Command::from_line(
|
|
"touch built-marker",
|
|
)),
|
|
cwd: None,
|
|
stale_when: None,
|
|
also_watch: Vec::new(),
|
|
strip: false,
|
|
enroll: crate::config::Command::default(),
|
|
strip_here: None,
|
|
package: None,
|
|
mode: None,
|
|
built_from: None,
|
|
built_mode: None,
|
|
}];
|
|
let state = state_for(&clone, accepted.clone());
|
|
|
|
// The commit the pull is about to take, asking for a different
|
|
// command from the one that was accepted.
|
|
std::fs::write(
|
|
origin.join(crate::config::PROJECT_CONFIG_FILE),
|
|
declare("touch never-run"),
|
|
)
|
|
.expect("write");
|
|
run(&origin, &["git", "commit", "-qam", "two"]);
|
|
|
|
let project = clone.clone();
|
|
state.pull_and_build(
|
|
false,
|
|
move || {
|
|
crate::config::project_config(&project)
|
|
.declaration
|
|
.matches_accepted(&accepted, None)
|
|
},
|
|
Arc::new(|_: &str, _: String| {}),
|
|
);
|
|
while state.status().building {
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
|
|
assert!(
|
|
clone.join(".dev-updater.ron").exists(),
|
|
"the pull itself still happens -- it is how the new request arrives to be read",
|
|
);
|
|
assert!(
|
|
!clone.join("built-marker").exists(),
|
|
"the declaration changed under the acceptance, so nothing may run",
|
|
);
|
|
}
|
|
|
|
/// Naming one component builds only that one.
|
|
///
|
|
/// The case a project with more than one component exists to avoid
|
|
/// paying for: pressing Update on one client of a two-client project
|
|
/// must not also run the other's build, which for a slow one (an ARM
|
|
/// cross-compile, say) is the entire complaint a phone would have.
|
|
#[tokio::test]
|
|
async fn naming_a_component_builds_only_that_one() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
std::fs::create_dir_all(root.join("server")).expect("mkdir");
|
|
std::fs::create_dir_all(root.join("app")).expect("mkdir");
|
|
let components = vec![
|
|
Component::Server {
|
|
name: "backend".to_string(),
|
|
modes: Vec::new(),
|
|
build: crate::config::ByMode::One(crate::config::Command::from_line(
|
|
"touch backend-built",
|
|
)),
|
|
cwd: Some(PathBuf::from("server")),
|
|
stale_when: None,
|
|
also_watch: Vec::new(),
|
|
service: None,
|
|
mode: None,
|
|
built_from: None,
|
|
built_mode: None,
|
|
},
|
|
Component::Apk {
|
|
name: "app".to_string(),
|
|
modes: Vec::new(),
|
|
build: crate::config::ByMode::One(crate::config::Command::from_line(
|
|
"touch app-built",
|
|
)),
|
|
cwd: Some(PathBuf::from("app")),
|
|
stale_when: None,
|
|
also_watch: Vec::new(),
|
|
strip: false,
|
|
enroll: crate::config::Command::default(),
|
|
strip_here: None,
|
|
mode: None,
|
|
package: None,
|
|
built_from: None,
|
|
built_mode: None,
|
|
},
|
|
];
|
|
let state = state_for(root, components);
|
|
|
|
state.build_now(Some("app"), Arc::new(|_: &str, _: String| {}));
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
|
while state.status().building {
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"build did not finish in time"
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
|
|
assert!(
|
|
root.join("app/app-built").exists(),
|
|
"the named component ran"
|
|
);
|
|
assert!(
|
|
!root.join("server/backend-built").exists(),
|
|
"naming one component must not build the other",
|
|
);
|
|
|
|
let status = state.status();
|
|
assert_eq!(
|
|
status
|
|
.components
|
|
.iter()
|
|
.map(|c| c.name.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["app"],
|
|
"the status only reports the component that actually ran",
|
|
);
|
|
}
|
|
|
|
/// Building one component must neither block another's build nor make
|
|
/// it look busy.
|
|
///
|
|
/// The complaint this decoupling is for: with one build slot per
|
|
/// project, pressing Update on one client of a two-client project
|
|
/// left the other's request a silent no-op and its buttons disabled
|
|
/// for the length of a build it had nothing to do with. Asserted from
|
|
/// *inside* the slow component's build, because "both finished
|
|
/// eventually" is equally true of running them one after the other --
|
|
/// what is being tested is that the second one does not wait.
|
|
#[tokio::test]
|
|
async fn one_component_building_does_not_hold_up_another() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
for name in ["slow", "quick"] {
|
|
std::fs::create_dir_all(root.join(name)).expect("mkdir");
|
|
}
|
|
// A script rather than `sh -c '...'`: a command is split on
|
|
// whitespace and never goes through a shell, so the quotes would
|
|
// be four literal arguments and the "slow" build would fail
|
|
// instantly -- which reads exactly like the two running in turn.
|
|
let script = root.join("slow.sh");
|
|
std::fs::write(&script, "#!/bin/sh\nsleep 0.6\ntouch slow-built\n").expect("write");
|
|
std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755))
|
|
.expect("chmod");
|
|
|
|
let components = ["slow", "quick"]
|
|
.map(|name| Component::Apk {
|
|
name: name.to_string(),
|
|
modes: Vec::new(),
|
|
build: crate::config::ByMode::One(crate::config::Command::from_line(match name {
|
|
"slow" => "./slow.sh",
|
|
_ => "touch quick-built",
|
|
})),
|
|
cwd: Some(PathBuf::from(name)),
|
|
stale_when: None,
|
|
also_watch: Vec::new(),
|
|
strip: false,
|
|
enroll: crate::config::Command::default(),
|
|
strip_here: None,
|
|
mode: None,
|
|
package: None,
|
|
built_from: None,
|
|
built_mode: None,
|
|
})
|
|
.to_vec();
|
|
let state = state_for(root, components);
|
|
|
|
state.build_now(Some("slow"), Arc::new(|_: &str, _: String| {}));
|
|
state.build_now(Some("quick"), Arc::new(|_: &str, _: String| {}));
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
|
loop {
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"quick never finished, so it was waiting on slow",
|
|
);
|
|
let status = state.status();
|
|
let quick = status.components.iter().find(|c| c.name == "quick");
|
|
if quick.is_some_and(|quick| quick.step.is_none()) {
|
|
assert!(
|
|
status
|
|
.components
|
|
.iter()
|
|
.any(|c| c.name == "slow" && c.step.is_some()),
|
|
"slow finished first, so this proves nothing about waiting",
|
|
);
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
assert!(root.join("quick/quick-built").exists(), "quick really ran");
|
|
assert!(
|
|
!root.join("slow/slow-built").exists(),
|
|
"slow is still going, which is the point",
|
|
);
|
|
|
|
while state.status().building {
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"slow did not finish in time"
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
assert!(
|
|
root.join("slow/slow-built").exists(),
|
|
"and the one that was still going finished on its own",
|
|
);
|
|
}
|
|
|
|
/// A component's failure is its own, and does not become the
|
|
/// project's.
|
|
///
|
|
/// With one error slot per project, a failed build reported itself on
|
|
/// every component's card and turned every Update button into Retry.
|
|
#[tokio::test]
|
|
async fn a_failure_is_reported_against_the_component_that_produced_it() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
for name in ["broken", "fine"] {
|
|
std::fs::create_dir_all(root.join(name)).expect("mkdir");
|
|
}
|
|
let components = ["broken", "fine"]
|
|
.map(|name| Component::Apk {
|
|
name: name.to_string(),
|
|
modes: Vec::new(),
|
|
build: crate::config::ByMode::One(crate::config::Command::from_line(match name {
|
|
"broken" => "false",
|
|
_ => "true",
|
|
})),
|
|
cwd: Some(PathBuf::from(name)),
|
|
stale_when: None,
|
|
also_watch: Vec::new(),
|
|
strip: false,
|
|
enroll: crate::config::Command::default(),
|
|
strip_here: None,
|
|
mode: None,
|
|
package: None,
|
|
built_from: None,
|
|
built_mode: None,
|
|
})
|
|
.to_vec();
|
|
let state = state_for(root, components);
|
|
|
|
state.build_now(None, Arc::new(|_: &str, _: String| {}));
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
|
while state.status().building {
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"build did not finish in time"
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
|
|
let status = state.status();
|
|
assert!(
|
|
status.error.is_none(),
|
|
"the project's error slot is for pulls, not for a component's build",
|
|
);
|
|
let component = |name: &str| {
|
|
status
|
|
.components
|
|
.iter()
|
|
.find(|c| c.name == name)
|
|
.expect("an entry per component")
|
|
.error
|
|
.clone()
|
|
};
|
|
assert!(component("broken").is_some(), "the one that failed says so");
|
|
assert!(
|
|
component("fine").is_none(),
|
|
"and the one that did not is left alone",
|
|
);
|
|
assert!(state.build_failed("broken"));
|
|
assert!(
|
|
!state.build_failed("fine"),
|
|
"so its card opens the runtime log, not a build log it has no failure in",
|
|
);
|
|
}
|
|
|
|
/// A sibling that has already been built must not mask that this one
|
|
/// never has.
|
|
///
|
|
/// The bug the previous test's fix uncovered: `APK_PATTERNS` includes
|
|
/// a one-level pattern (`*/build/outputs/apk/*/*.apk`), so a second
|
|
/// component's output sitting one directory below the project root
|
|
/// satisfies a scan of the *root* even when it belongs to a component
|
|
/// nobody has ever built here. `component_is_stale`'s "never built"
|
|
/// check used to run against the project root rather than the
|
|
/// component's own directory, so the moment either of two components
|
|
/// had been built once, the other quietly stopped being offered its
|
|
/// own first build -- `prepare` (what Update runs before a download)
|
|
/// saw a component with a command and no output and declared it
|
|
/// current anyway.
|
|
#[tokio::test]
|
|
async fn a_sibling_already_built_does_not_hide_that_this_one_never_has() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
std::fs::create_dir_all(root.join("a")).expect("mkdir");
|
|
std::fs::create_dir_all(root.join("b")).expect("mkdir");
|
|
let components = vec![
|
|
Component::Apk {
|
|
name: "a".to_string(),
|
|
modes: Vec::new(),
|
|
build: crate::config::ByMode::One(crate::config::Command::from_line(
|
|
"touch a-built",
|
|
)),
|
|
cwd: Some(PathBuf::from("a")),
|
|
stale_when: None,
|
|
also_watch: Vec::new(),
|
|
strip: false,
|
|
enroll: crate::config::Command::default(),
|
|
strip_here: None,
|
|
mode: None,
|
|
package: None,
|
|
built_from: None,
|
|
built_mode: None,
|
|
},
|
|
Component::Apk {
|
|
name: "b".to_string(),
|
|
modes: Vec::new(),
|
|
build: crate::config::ByMode::One(crate::config::Command::from_line(
|
|
"touch b-built",
|
|
)),
|
|
cwd: Some(PathBuf::from("b")),
|
|
stale_when: None,
|
|
also_watch: Vec::new(),
|
|
strip: false,
|
|
enroll: crate::config::Command::default(),
|
|
strip_here: None,
|
|
mode: None,
|
|
package: None,
|
|
built_from: None,
|
|
built_mode: None,
|
|
},
|
|
];
|
|
let state = state_for(root, components);
|
|
|
|
// `a` has an APK on disk already -- built by something other than
|
|
// this state, which is the ordinary case for "added after the
|
|
// fact" -- while `b` has never been built at all.
|
|
std::fs::create_dir_all(root.join("a/build/outputs/apk/debug")).expect("mkdir");
|
|
std::fs::write(root.join("a/build/outputs/apk/debug/a.apk"), b"").expect("write");
|
|
|
|
assert!(
|
|
stale(&state, "b"),
|
|
"b has never been built, regardless of what a has on disk",
|
|
);
|
|
|
|
state.trigger_if_needed(Some("b"), Arc::new(|_: &str, _: String| {}));
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
|
while state.status().building {
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"build did not finish in time"
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
assert!(
|
|
root.join("b/b-built").exists(),
|
|
"prepare must have run b's build"
|
|
);
|
|
}
|
|
|
|
fn state_for(root: &Path, components: Vec<Component>) -> Arc<BuildState> {
|
|
BuildState::new(
|
|
"test".to_string(),
|
|
components,
|
|
true,
|
|
false,
|
|
root.to_path_buf(),
|
|
Shared {
|
|
remote_checks: crate::git::RemoteChecks::default(),
|
|
downloads: Arc::new(crate::restart::Downloads::default()),
|
|
},
|
|
false,
|
|
)
|
|
}
|
|
|
|
fn stale(state: &BuildState, name: &str) -> bool {
|
|
let component = state
|
|
.components
|
|
.iter()
|
|
.find(|component| component.name() == name)
|
|
.expect("component");
|
|
state.component_is_stale(component, &state.inner.lock().unwrap().built_from.clone())
|
|
}
|
|
|
|
/// The trap a per-component staleness check walks straight into once
|
|
/// modes exist. An APK's "never built at all" test finds *any* build
|
|
/// under the component's directory, so a debug APK sitting there is
|
|
/// enough to make a component switched to release look built -- and
|
|
/// switching modes moves no commit, so nothing else has an opinion.
|
|
/// Update would then do nothing and the phone would install the debug
|
|
/// build from a card saying release.
|
|
///
|
|
/// Asserted through `component_is_stale` rather than through
|
|
/// `built_in_another_mode` alone, because the rule being right is not
|
|
/// the same as the check that decides whether to build asking it.
|
|
#[test]
|
|
fn a_component_switched_to_another_mode_is_stale_even_with_a_build_on_disk() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
// A build output where an APK actually lands, so the "nothing
|
|
// built here" branch is satisfied and cannot be what answers.
|
|
let built = root.join("build/outputs/apk/debug");
|
|
std::fs::create_dir_all(&built).expect("mkdir");
|
|
std::fs::write(built.join("app-debug.apk"), b"not really an apk").expect("write");
|
|
|
|
let moded = |mode: Option<&str>| {
|
|
vec![Component::Apk {
|
|
name: "app".to_string(),
|
|
modes: vec!["release".to_string(), "debug".to_string()],
|
|
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
|
|
cwd: None,
|
|
stale_when: None,
|
|
also_watch: Vec::new(),
|
|
strip: false,
|
|
enroll: crate::config::Command::default(),
|
|
strip_here: None,
|
|
package: None,
|
|
mode: mode.map(str::to_string),
|
|
// Built here once, in debug -- without which there is no
|
|
// build of ours for the comparison to be about.
|
|
built_from: Some("abc123".to_string()),
|
|
built_mode: Some("debug".to_string()),
|
|
}]
|
|
};
|
|
|
|
let matching = state_for(root, moded(Some("debug")));
|
|
assert!(
|
|
!stale(&matching, "app"),
|
|
"the build on disk is this mode's, so there is nothing to do",
|
|
);
|
|
|
|
let switched = state_for(root, moded(Some("release")));
|
|
assert!(
|
|
stale(&switched, "app"),
|
|
"a debug build must not satisfy a component set to build release",
|
|
);
|
|
assert_eq!(
|
|
switched.freshness(&switched.components[0], false),
|
|
Freshness::Behind,
|
|
"and the card has to say so rather than reading as current",
|
|
);
|
|
assert_eq!(
|
|
switched.freshness(&switched.components[0], true),
|
|
Freshness::Behind,
|
|
"a parked checkout says nothing about which mode was built, so this one still \
|
|
reports -- what the parking silences is the commit comparison alone",
|
|
);
|
|
}
|
|
|
|
/// Picking a commit is a decision, and a card that answers it with
|
|
/// "out of date" is nagging about one already made. The comparison
|
|
/// itself is unchanged -- what changes is that a *parked* checkout
|
|
/// withholds it rather than reporting the difference as being behind.
|
|
///
|
|
/// Checked against the ordinary case in the same test, because that is
|
|
/// the half a change like this can break without looking broken: a
|
|
/// checkout following its branch must still say when its build is
|
|
/// older than what is checked out.
|
|
#[test]
|
|
fn a_checkout_parked_on_a_chosen_commit_is_not_reported_as_out_of_date() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
let components = two_component_checkout(root);
|
|
let first = crate::git::subtree_head(root, &[PathBuf::from("app")]).expect("a commit");
|
|
|
|
std::fs::write(root.join("app/main.kt"), "two").expect("write");
|
|
run(root, &["git", "commit", "-qam", "two"]);
|
|
|
|
let state = state_for(root, components);
|
|
// Built at the first commit, and the checkout has since moved on:
|
|
// the ordinary way to be behind, and the one that must survive.
|
|
state
|
|
.inner
|
|
.lock()
|
|
.unwrap()
|
|
.built_from
|
|
.insert("app".to_string(), first);
|
|
let app = state
|
|
.components
|
|
.iter()
|
|
.find(|component| component.name() == "app")
|
|
.expect("the app component");
|
|
|
|
assert_eq!(
|
|
state.freshness(app, false),
|
|
Freshness::Behind,
|
|
"a checkout following its branch still reports a build older than it",
|
|
);
|
|
assert_eq!(
|
|
state.freshness(app, true),
|
|
Freshness::Unknown,
|
|
"and one parked on a chosen commit withholds it rather than nagging",
|
|
);
|
|
}
|
|
|
|
/// Moving the checkout runs git and nothing else.
|
|
///
|
|
/// It used to build whatever the move left behind, which made looking
|
|
/// at another branch cost a full build of every component -- and there
|
|
/// is no way to call one back from a phone. What replaces it is the
|
|
/// card saying the component no longer matches the checkout, with its
|
|
/// own Build beside it. Asserted by giving the components a command
|
|
/// that leaves a file, so "it did not build" is a fact about the disk
|
|
/// rather than about a status word that could be read too early.
|
|
#[tokio::test]
|
|
async fn moving_the_checkout_builds_nothing() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
let mut components = two_component_checkout(root);
|
|
for component in &mut components {
|
|
let marker = format!("{}-built", component.name());
|
|
match component {
|
|
Component::Apk { build, .. } | Component::Server { build, .. } => {
|
|
*build = crate::config::ByMode::One(crate::config::Command::from_line(
|
|
&format!("touch ../{marker}"),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
run(root, &["git", "checkout", "-qb", "elsewhere"]);
|
|
std::fs::write(root.join("app/main.kt"), "two").expect("write");
|
|
run(root, &["git", "commit", "-qam", "two"]);
|
|
run(root, &["git", "checkout", "-q", "main"]);
|
|
|
|
let state = state_for(root, components);
|
|
state.move_checkout("elsewhere".to_string());
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
|
while state.status().building {
|
|
assert!(std::time::Instant::now() < deadline, "the move never ended");
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
|
|
assert_eq!(
|
|
crate::git::status(root).expect("a checkout").branch,
|
|
"elsewhere",
|
|
"the checkout really moved, so the rest of this is about a move that happened",
|
|
);
|
|
for marker in ["backend-built", "app-built"] {
|
|
assert!(
|
|
!root.join(marker).exists(),
|
|
"{marker} exists, so moving the checkout built something",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The shape of bug a unit test that only calls `component_is_stale`
|
|
/// cannot see: `status()` and `trigger_if_needed` hold the lock that
|
|
/// the staleness check also wants. Taking it twice on one thread is a
|
|
/// deadlock, and it hung every `/status` and `/prepare` for any
|
|
/// project that had a build on disk -- which is every real one.
|
|
///
|
|
/// Run on a thread with a deadline so a regression fails in
|
|
/// milliseconds instead of hanging the suite.
|
|
#[test]
|
|
fn asking_for_status_does_not_deadlock() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
let components = two_component_checkout(root);
|
|
let state = state_for(root, components);
|
|
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let _ = state.status();
|
|
let _ = tx.send(());
|
|
});
|
|
assert!(
|
|
rx.recv_timeout(std::time::Duration::from_secs(5)).is_ok(),
|
|
"status() deadlocked on its own lock",
|
|
);
|
|
}
|
|
|
|
/// A commit that touches one component's directory must not make the
|
|
/// other look behind. Without the scoping, every app-only change would
|
|
/// offer a pointless rebuild of the server -- in the one repository
|
|
/// shape this feature was asked for.
|
|
#[test]
|
|
fn a_commit_in_one_subtree_leaves_the_other_current() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
let components = two_component_checkout(root);
|
|
let state = state_for(root, components);
|
|
|
|
// Built now, from what is checked out now.
|
|
for name in ["backend", "app"] {
|
|
let cwd = PathBuf::from(if name == "backend" { "server" } else { "app" });
|
|
let sha = crate::git::subtree_head(root, std::slice::from_ref(&cwd)).expect("a commit");
|
|
state
|
|
.inner
|
|
.lock()
|
|
.unwrap()
|
|
.built_from
|
|
.insert(name.to_string(), sha);
|
|
}
|
|
assert!(!stale(&state, "backend"), "just built");
|
|
assert!(!stale(&state, "app"), "just built");
|
|
|
|
// A change to the app only.
|
|
std::fs::write(root.join("app/main.kt"), "two").expect("write");
|
|
run(root, &["git", "commit", "-qam", "app only"]);
|
|
|
|
assert!(stale(&state, "app"), "the app moved and was not rebuilt");
|
|
assert!(
|
|
!stale(&state, "backend"),
|
|
"the server's directory did not change"
|
|
);
|
|
}
|
|
|
|
/// The other half of that scoping: a component's build reads more
|
|
/// than its own directory, and what it names with `alsoWatch` counts
|
|
/// as its own.
|
|
///
|
|
/// The case this is for had every piece defensible and the whole
|
|
/// silent: two clients sourcing one shared script that locates their
|
|
/// SDK and mints their signing key, a fix committed to that script,
|
|
/// and no component's subtree head moved -- so nothing rebuilt, and
|
|
/// every card correctly reported "current" while answering a
|
|
/// narrower question than the reader was asking. The component that
|
|
/// says nothing must still be left alone, which is the second
|
|
/// assertion here.
|
|
#[test]
|
|
fn a_commit_to_a_watched_path_makes_only_the_components_that_watch_it_stale() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
let mut components = two_component_checkout(root);
|
|
let Component::Apk { also_watch, .. } = &mut components[1] else {
|
|
panic!("the app is the second component");
|
|
};
|
|
*also_watch = vec![PathBuf::from("scripts")];
|
|
let state = state_for(root, components);
|
|
|
|
std::fs::create_dir_all(root.join("scripts")).expect("mkdir");
|
|
std::fs::write(root.join("scripts/env.sh"), "one").expect("write");
|
|
run(root, &["git", "add", "-A"]);
|
|
run(root, &["git", "commit", "-qm", "scripts"]);
|
|
|
|
for name in ["backend", "app"] {
|
|
let component = state
|
|
.components
|
|
.iter()
|
|
.find(|component| component.name() == name)
|
|
.expect("component");
|
|
let sha = crate::git::subtree_head(root, &component.watched_paths()).expect("a commit");
|
|
state
|
|
.inner
|
|
.lock()
|
|
.unwrap()
|
|
.built_from
|
|
.insert(name.to_string(), sha);
|
|
}
|
|
assert!(!stale(&state, "backend"), "just built");
|
|
assert!(!stale(&state, "app"), "just built");
|
|
|
|
// A change to neither component's own directory.
|
|
std::fs::write(root.join("scripts/env.sh"), "two").expect("write");
|
|
run(root, &["git", "commit", "-qam", "shared script"]);
|
|
|
|
assert!(
|
|
stale(&state, "app"),
|
|
"the app watches scripts/, so a commit there is a commit to it",
|
|
);
|
|
assert!(
|
|
!stale(&state, "backend"),
|
|
"the server said nothing about scripts/, so nothing about it changed",
|
|
);
|
|
}
|
|
|
|
/// Nothing recorded is "we have never built this", not "this is out of
|
|
/// date" -- otherwise every project that existed before this feature
|
|
/// would demand a rebuild the moment it arrived.
|
|
#[test]
|
|
fn a_component_this_server_has_never_built_is_not_called_stale() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
let components = two_component_checkout(root);
|
|
let state = state_for(root, components);
|
|
|
|
assert!(
|
|
state.inner.lock().unwrap().built_from.is_empty(),
|
|
"nothing recorded"
|
|
);
|
|
assert!(!stale(&state, "backend"));
|
|
assert!(!stale(&state, "app"));
|
|
}
|
|
|
|
/// The same must hold where there is no checkout to compare against:
|
|
/// unreadable is not evidence of anything.
|
|
#[test]
|
|
fn a_project_outside_git_is_not_called_stale() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let root = dir.path();
|
|
std::fs::create_dir_all(root.join("app/build/outputs/apk/debug")).expect("mkdir");
|
|
std::fs::write(root.join("app/build/outputs/apk/debug/a.apk"), b"").expect("write");
|
|
let state = state_for(
|
|
root,
|
|
vec![Component::Apk {
|
|
name: "app".to_string(),
|
|
modes: Vec::new(),
|
|
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
|
|
cwd: None,
|
|
stale_when: None,
|
|
also_watch: Vec::new(),
|
|
strip: false,
|
|
enroll: crate::config::Command::default(),
|
|
strip_here: None,
|
|
package: None,
|
|
mode: None,
|
|
built_from: Some("0000000000000000000000000000000000000000".to_string()),
|
|
built_mode: None,
|
|
}],
|
|
);
|
|
assert!(!stale(&state, "app"), "no checkout means no opinion");
|
|
}
|
|
|
|
/// The marker is recognised only in exactly its shape; anything else on
|
|
/// a line is the build talking and belongs in the log, where a build
|
|
/// that prints a malformed report can be seen to have done so.
|
|
#[test]
|
|
fn progress_is_read_only_from_the_marker() {
|
|
assert_eq!(parse_progress("@@progress 12/43"), Some((12, 43)));
|
|
assert_eq!(parse_progress(" @@progress 0/1 "), Some((0, 1)));
|
|
assert_eq!(parse_progress("@@progress 7 / 9 "), Some((7, 9)));
|
|
|
|
assert_eq!(parse_progress("Building @@progress 1/2"), None);
|
|
assert_eq!(parse_progress("@@progress soon"), None);
|
|
assert_eq!(parse_progress("@@progress 1/"), None);
|
|
assert_eq!(parse_progress("> Task :app:compileDebugKotlin"), None);
|
|
}
|
|
|
|
/// Feeds `read_segments` one chunk per `read`, recording how many
|
|
/// segments had been emitted before each one was handed over.
|
|
struct Chunks {
|
|
chunks: std::vec::IntoIter<&'static [u8]>,
|
|
emitted: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
|
|
before_each_read: std::rc::Rc<std::cell::RefCell<Vec<usize>>>,
|
|
}
|
|
|
|
impl std::io::Read for Chunks {
|
|
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
|
self.before_each_read
|
|
.borrow_mut()
|
|
.push(self.emitted.borrow().len());
|
|
let Some(chunk) = self.chunks.next() else {
|
|
return Ok(0);
|
|
};
|
|
assert!(chunk.len() <= buffer.len(), "the test's chunks are small");
|
|
buffer[..chunk.len()].copy_from_slice(chunk);
|
|
Ok(chunk.len())
|
|
}
|
|
}
|
|
|
|
/// The defect this splitting exists for, and the half of it a batch
|
|
/// test cannot see: it is not whether the counter is *parsed* but
|
|
/// **when** it is delivered.
|
|
///
|
|
/// Cargo ends its counter with a carriage return and no newline, so a
|
|
/// reader waiting for `\n` holds it until whatever prints the next
|
|
/// one. Here that is the `Finished` line, which on a real incremental
|
|
/// build arrives only when the build is over -- so the card had
|
|
/// nothing to measure for the whole compile, which is exactly what a
|
|
/// pull produces. The assertion is therefore about the count of
|
|
/// segments already emitted when the *second* chunk is asked for.
|
|
#[test]
|
|
fn a_carriage_return_delivers_its_segment_without_waiting_for_a_newline() {
|
|
// Byte for byte what `cargo build --release` printed with one
|
|
// dirty crate, shortened only in the bar and the path.
|
|
let chunks: Vec<&'static [u8]> = vec![
|
|
b" Compiling dev-updater v0.1.0 (/p)\n Building [==> ] 236/237: dev-updater(bin) \r",
|
|
b" Finished `release` profile [optimized] target(s) in 7.60s\n",
|
|
];
|
|
let emitted = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
|
let before_each_read = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
|
|
|
let collected = std::rc::Rc::clone(&emitted);
|
|
read_segments(
|
|
Chunks {
|
|
chunks: chunks.into_iter(),
|
|
emitted: std::rc::Rc::clone(&emitted),
|
|
before_each_read: std::rc::Rc::clone(&before_each_read),
|
|
},
|
|
|segment| collected.borrow_mut().push(segment.to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
before_each_read.borrow().as_slice(),
|
|
[0, 2, 3],
|
|
"the counter must be delivered with its own chunk, not held \
|
|
until the newline in the next one"
|
|
);
|
|
let emitted = emitted.borrow();
|
|
assert_eq!(emitted.len(), 3);
|
|
assert_eq!(
|
|
parse_progress(&emitted[1]),
|
|
Some((236, 237)),
|
|
"and it is still the cargo counter once split"
|
|
);
|
|
}
|
|
|
|
/// The case the fix was not written for: output that was already
|
|
/// arriving line by line must not change shape. A cold build is
|
|
/// mostly this, and it is where a wrapper's `@@progress` lines live
|
|
/// too.
|
|
#[test]
|
|
fn newline_terminated_output_is_unchanged_and_blank_segments_are_dropped() {
|
|
let mut segments = Vec::new();
|
|
read_segments(
|
|
&b"> Task :app:compileDebugKotlin\n@@progress 7/9\r\n\nlast, unterminated"[..],
|
|
|segment| segments.push(segment.to_string()),
|
|
);
|
|
assert_eq!(
|
|
segments,
|
|
[
|
|
"> Task :app:compileDebugKotlin",
|
|
"@@progress 7/9",
|
|
"",
|
|
"",
|
|
"last, unterminated"
|
|
],
|
|
"a CRLF ends one segment and opens an empty one; the caller \
|
|
drops the blanks"
|
|
);
|
|
}
|
|
}
|