Each component builds on its own, and stops reporting when it is done
A project's components were decoupled everywhere except the one place it showed: there was a single build slot per project, and a single card state in the app keyed by project alone. So pressing Update on one client of a two-client project disabled the other client's button for the length of a build it shares nothing with, drew this one's progress bar and download percentage under the other's row, and -- had the button been pressable -- would have been a silent no-op on the server, since a second request while one was running returned without starting anything. The slot is now per component. `Inner` has no `building` flag; a component's own `ComponentRun` with an open `step` is the answer, and `claim` writes that entry synchronously under the lock the route answers from, so nothing can read a just-claimed component as idle -- which the phone would take for a build that had already finished. A failure is recorded against the component whose command it was rather than in the project's one error slot, which two components building at once cannot share. A pull stays exclusive with everything, because there is one checkout and it rewrites the files every component builds from. Releasing it and claiming what it decided to build happen under one lock: a phone polling in the gap would find a project neither pulling nor building and call the run over. The app mirrors the split -- `ProjectState` for the pull and the project-wide Rebuild, `ComponentState` keyed by component for everything one component is asked to do. Two hierarchies rather than one keyed by a pair, so a download has nowhere project-wide to be stored. A component's failure is drawn in its own row beside the Retry that acts on it, which is also where a failed service action now reports. And a finished component shows nothing at all: the elapsed times are gone from both halves of the wire, and its button simply goes back to being pressable. A bar, a count and a last line all describe something happening now, and left up they sit there looking live next to a sibling that genuinely is. Verified on the emulator against test-projects/two-clients, which exists for this: while `tablet` built, its row alone carried the bar and its button alone was disabled, `phone` stayed pressable and silent, and both returned to normal with no timing left behind.
This commit is contained in:
1 parent
4641b9ec9b
commit
90082bd286
6 files changed
+862
-318
No files matched your search
+441
-149
@@ -11,7 +11,6 @@ use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -85,22 +84,38 @@ struct Inner {
|
||||
/// [`BuildState::matches`] -- so its own copy of the components goes
|
||||
/// stale the moment a build records against it.
|
||||
built_from: HashMap<String, String>,
|
||||
building: bool,
|
||||
/// When the current run began, for the elapsed time shown while it is
|
||||
/// still going.
|
||||
started: Option<Instant>,
|
||||
/// 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 the current run has reached, in the order
|
||||
/// it reached them.
|
||||
/// 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 completed run, cleared when a new one
|
||||
/// starts. Kept rather than logged-and-dropped because the phone is
|
||||
/// where this is being driven from and usually has no access to the
|
||||
/// 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
|
||||
@@ -108,12 +123,30 @@ struct Inner {
|
||||
/// button that appears only when git happened to phrase itself a
|
||||
/// certain way is a button nobody can rely on.
|
||||
unrelated_histories: bool,
|
||||
/// The component whose step produced that failure.
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
/// Whether this component is being worked on right now.
|
||||
///
|
||||
/// Kept beside the message so the card can open the right log without
|
||||
/// guessing: a component that failed to build wants its build log,
|
||||
/// while every other component still wants the runtime one.
|
||||
failed: Option<String>,
|
||||
/// 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.
|
||||
@@ -129,10 +162,6 @@ struct ComponentRun {
|
||||
/// What it is doing now -- building, installing, restarting -- or
|
||||
/// `None` once it has finished.
|
||||
step: Option<String>,
|
||||
started: Instant,
|
||||
/// Filled in when the component finishes, so the card can keep showing
|
||||
/// how long it took.
|
||||
took_ms: Option<u64>,
|
||||
/// 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
|
||||
@@ -184,7 +213,38 @@ pub struct BuildState {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildStatus {
|
||||
pub stale: bool,
|
||||
/// Flattened, so `/status` answers the one flat object it always has
|
||||
/// while a card can carry [`RunningBuild`] on its own. They are split
|
||||
/// because `stale` is the expensive half: it walks every component's
|
||||
/// directory, and `describe` is on the manifest path.
|
||||
#[serde(flatten)]
|
||||
pub run: RunningBuild,
|
||||
}
|
||||
|
||||
/// What a build is doing, with nothing in it that has to be measured off
|
||||
/// disk to answer.
|
||||
///
|
||||
/// This is the whole of what the phone needs to pick a build back up. It
|
||||
/// is reported on the card as well as from `/status`, because the app's
|
||||
/// record of a run lives only in the composition: leaving the app tears
|
||||
/// down the polling loop and the card state with it, and without this the
|
||||
/// list it comes back to cannot say that a build is still going. The
|
||||
/// server never lost anything -- the run owns its own `Arc<BuildState>`
|
||||
/// and outlives every request -- so the fix is for the manifest to say so
|
||||
/// rather than for anything here to be re-attached to.
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RunningBuild {
|
||||
/// 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
|
||||
@@ -193,23 +253,21 @@ pub struct BuildStatus {
|
||||
/// What the whole project is doing -- fetching, pulling -- absent
|
||||
/// between runs and while the work belongs to a component instead.
|
||||
pub phase: Option<String>,
|
||||
/// Milliseconds since this run started.
|
||||
pub elapsed_ms: u64,
|
||||
/// Each component the run has reached, in the order it reached them.
|
||||
/// The card draws each of these inside that component's own row.
|
||||
/// 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)]
|
||||
#[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>,
|
||||
/// How long it has been going, or took.
|
||||
pub elapsed_ms: u64,
|
||||
/// 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
|
||||
@@ -260,6 +318,12 @@ fn parse_cargo_progress(line: &str) -> Option<(u64, u64)> {
|
||||
/// 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.
|
||||
@@ -393,35 +457,78 @@ impl BuildState {
|
||||
/// that runs here, so a project already current with its checkout
|
||||
/// could never record one and would report unknown for ever.
|
||||
///
|
||||
/// Still idempotent -- while one run is going, another does nothing,
|
||||
/// whichever component either names: there is one build slot per
|
||||
/// project, not one per component, so a second request while the
|
||||
/// first is still running is a no-op rather than a second concurrent
|
||||
/// build. The phone notices by polling `/status` and re-reads once it
|
||||
/// clears.
|
||||
/// 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();
|
||||
if inner.building {
|
||||
// The one thing a component still waits behind. See
|
||||
// `Inner::pulling`.
|
||||
if inner.pulling {
|
||||
return;
|
||||
}
|
||||
inner.building = true;
|
||||
inner.error = None;
|
||||
inner.failed = None;
|
||||
inner.unrelated_histories = false;
|
||||
inner.started = Some(Instant::now());
|
||||
inner.runs.clear();
|
||||
}
|
||||
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);
|
||||
let component = component.map(str::to_string);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
this.run_build(component.as_deref(), &record);
|
||||
});
|
||||
tokio::task::spawn_blocking(move || this.run_claimed(claimed, &record));
|
||||
}
|
||||
|
||||
/// Whether any component `name` selects is behind. Whole project for
|
||||
@@ -531,8 +638,7 @@ impl BuildState {
|
||||
|
||||
/// Fetches, fast-forwards if there is anything to take, and builds --
|
||||
/// the Pull button, which acts on the build machine rather than the
|
||||
/// phone. Idempotent in the same way as [`Self::trigger_if_needed`]:
|
||||
/// while one is running, another does nothing.
|
||||
/// 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
|
||||
@@ -558,6 +664,12 @@ impl BuildState {
|
||||
/// 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,
|
||||
@@ -566,15 +678,12 @@ impl BuildState {
|
||||
) {
|
||||
{
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
if inner.building {
|
||||
if inner.anything_running() {
|
||||
return;
|
||||
}
|
||||
inner.building = true;
|
||||
inner.pulling = true;
|
||||
inner.error = None;
|
||||
inner.failed = None;
|
||||
inner.unrelated_histories = false;
|
||||
inner.started = Some(Instant::now());
|
||||
inner.runs.clear();
|
||||
}
|
||||
|
||||
let this = Arc::clone(self);
|
||||
@@ -587,16 +696,27 @@ impl BuildState {
|
||||
}
|
||||
Ok(pulled) => {
|
||||
// Nothing configured to build, or nothing allowed to:
|
||||
// the pull was the whole job, and reporting success is
|
||||
// all that is left.
|
||||
// the pull was the whole job.
|
||||
// `may_build()` is called here, with the pulled
|
||||
// declaration on disk, for the reason in the doc
|
||||
// comment above.
|
||||
if !may_build() || !this.has_command() || !(pulled || this.is_stale(None)) {
|
||||
this.finish(None, None);
|
||||
} else {
|
||||
this.run_build(None, &record);
|
||||
}
|
||||
// comment above. Both it and `is_stale` take the lock
|
||||
// themselves, so they are asked before it is held.
|
||||
let 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
|
||||
};
|
||||
this.run_claimed(claimed, &record);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -624,69 +744,64 @@ impl BuildState {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Builds every component `name` selects, at once.
|
||||
/// 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. The
|
||||
/// saving only appears when more than one has work to do, which is
|
||||
/// the case a pull produces; `name` is how a caller that wants only
|
||||
/// one opts out of paying for the others (see
|
||||
/// [`Self::trigger_if_needed`]) -- `None` still means all of them.
|
||||
/// 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, and
|
||||
/// the first failure in declaration order is the one reported, which
|
||||
/// is what a walk in that order would have said.
|
||||
///
|
||||
/// Each component's name is the phase name, so the card says which one
|
||||
/// is being worked on without needing anything new to carry it.
|
||||
fn run_build(self: &Arc<Self>, name: Option<&str>, record: &RecordBuilt) {
|
||||
/// 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, component) in self.components.iter().enumerate() {
|
||||
if name.is_some_and(|name| component.name() != name) {
|
||||
continue;
|
||||
}
|
||||
if component.build().is_empty() {
|
||||
continue;
|
||||
}
|
||||
for index in claimed {
|
||||
let this = Arc::clone(self);
|
||||
let record = Arc::clone(record);
|
||||
running.push((
|
||||
component.name().to_string(),
|
||||
self.components[index].name().to_string(),
|
||||
std::thread::spawn(move || this.build_indexed(index, &record)),
|
||||
));
|
||||
}
|
||||
|
||||
let mut error = None;
|
||||
// Which component's step ended the walk, so the card can open that
|
||||
// component's *build* log rather than its runtime one.
|
||||
let mut failed = None;
|
||||
// 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 has finished and the whole run is reported. See
|
||||
// `is_self`.
|
||||
// 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() {
|
||||
Ok(outcome) => outcome,
|
||||
// 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.
|
||||
Err(_) => Err(format!("building {name} panicked -- see this server's log")),
|
||||
// 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(message) if error.is_none() => {
|
||||
error = Some(message);
|
||||
failed = Some(name);
|
||||
}
|
||||
Err(_) => {}
|
||||
Err(_) => failed = true,
|
||||
}
|
||||
}
|
||||
|
||||
let mut restart = error.is_none() && restart_self;
|
||||
// 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.
|
||||
@@ -696,10 +811,10 @@ impl BuildState {
|
||||
);
|
||||
restart = false;
|
||||
}
|
||||
self.finish(error, failed);
|
||||
if restart {
|
||||
// Answered and finished first, so whatever happens next cannot
|
||||
// take the report away from whoever asked for it.
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -733,7 +848,7 @@ impl BuildState {
|
||||
component: &Component,
|
||||
record: &RecordBuilt,
|
||||
) -> Result<bool, String> {
|
||||
self.begin_component(component.name(), "building");
|
||||
self.begin_component(component.name(), BUILDING);
|
||||
if let Err(message) = self.run_streaming(component) {
|
||||
tracing::error!("{} failed: {message}", component.name());
|
||||
return Err(message);
|
||||
@@ -985,16 +1100,18 @@ impl BuildState {
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
/// Marks the start of a step, closing the previous one with its
|
||||
/// duration so the phone can show where the time went.
|
||||
/// 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`, starting its entry if this is
|
||||
/// the first thing it has done this run.
|
||||
/// 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;
|
||||
@@ -1006,51 +1123,36 @@ impl BuildState {
|
||||
// A count belongs to the step that printed it.
|
||||
run.progress = None;
|
||||
}
|
||||
None => inner.runs.push(ComponentRun {
|
||||
name: component.to_string(),
|
||||
step: Some(step.to_string()),
|
||||
started: Instant::now(),
|
||||
took_ms: None,
|
||||
progress: None,
|
||||
log: VecDeque::new(),
|
||||
error: 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.took_ms = Some(run.started.elapsed().as_millis() as u64);
|
||||
run.step = None;
|
||||
run.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the current step and ends the run.
|
||||
fn finish(&self, error: Option<String>, failed: Option<String>) {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
inner.phase = None;
|
||||
inner.building = false;
|
||||
// Cleared so the elapsed time belongs to a run in progress rather
|
||||
// than counting up forever on an idle server.
|
||||
inner.started = None;
|
||||
inner.error = error;
|
||||
inner.failed = failed;
|
||||
}
|
||||
|
||||
/// Finishes a run that never got past its pull.
|
||||
/// Ends a pull that could not be made.
|
||||
///
|
||||
/// Separate from [`Self::finish`] only because a pull failure carries
|
||||
/// the one thing a build failure cannot: whether abandoning this
|
||||
/// checkout's own history would clear it. Cleared where its siblings
|
||||
/// are, at the start of every run, so this is the only thing that can
|
||||
/// 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) {
|
||||
self.inner.lock().unwrap().unrelated_histories = error.unrelated_histories;
|
||||
// No component: a pull fails before the walk starts.
|
||||
self.finish(Some(error.message), None);
|
||||
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
|
||||
@@ -1086,14 +1188,32 @@ impl BuildState {
|
||||
crate::config::has_command(&self.components)
|
||||
}
|
||||
|
||||
/// Whether the last completed build stopped at this component.
|
||||
/// Whether this component's last build stopped at it.
|
||||
///
|
||||
/// The card asks so it can open that component's build log first. A
|
||||
/// build that has not failed leaves every component answering false,
|
||||
/// which is what makes the runtime log the ordinary default.
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// What this project is doing, or `None` when nothing is.
|
||||
///
|
||||
/// Deliberately not [`Self::status`], which also answers `stale` and
|
||||
/// so walks every component's directory. This is reached from
|
||||
/// `describe`, which builds every card on `/manifest` -- fetched on
|
||||
/// every open, resume and Refresh -- so it is a lock and some clones
|
||||
/// and nothing else.
|
||||
pub fn running(&self) -> Option<RunningBuild> {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
inner.error.is_some() && inner.failed.as_deref() == Some(component)
|
||||
inner.anything_running().then(|| Self::snapshot(&inner))
|
||||
}
|
||||
|
||||
pub fn status(&self) -> BuildStatus {
|
||||
@@ -1102,24 +1222,24 @@ impl BuildState {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
BuildStatus {
|
||||
stale,
|
||||
building: inner.building,
|
||||
run: Self::snapshot(&inner),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one place the run is read out of the lock, so `/status` and a
|
||||
/// card cannot come to describe the same build differently.
|
||||
fn snapshot(inner: &Inner) -> RunningBuild {
|
||||
RunningBuild {
|
||||
building: inner.anything_running(),
|
||||
error: inner.error.clone(),
|
||||
unrelated_histories: inner.unrelated_histories,
|
||||
phase: inner.phase.clone(),
|
||||
elapsed_ms: inner
|
||||
.started
|
||||
.map(|started| started.elapsed().as_millis() as u64)
|
||||
.unwrap_or(0),
|
||||
components: inner
|
||||
.runs
|
||||
.iter()
|
||||
.map(|run| ComponentStatus {
|
||||
name: run.name.clone(),
|
||||
step: run.step.clone(),
|
||||
// Still going, or however long it took.
|
||||
elapsed_ms: run
|
||||
.took_ms
|
||||
.unwrap_or_else(|| run.started.elapsed().as_millis() as u64),
|
||||
progress: run
|
||||
.progress
|
||||
.filter(|(_, total)| *total > 0)
|
||||
@@ -1135,6 +1255,29 @@ impl BuildState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -1298,7 +1441,7 @@ mod tests {
|
||||
move || crate::config::project_config(&project).matches_accepted(&accepted, None),
|
||||
Arc::new(|_: &str, _: String| {}),
|
||||
);
|
||||
while state.status().building {
|
||||
while state.status().run.building {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
@@ -1347,7 +1490,7 @@ mod tests {
|
||||
|
||||
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 {
|
||||
while state.status().run.building {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"build did not finish in time"
|
||||
@@ -1367,6 +1510,7 @@ mod tests {
|
||||
let status = state.status();
|
||||
assert_eq!(
|
||||
status
|
||||
.run
|
||||
.components
|
||||
.iter()
|
||||
.map(|c| c.name.as_str())
|
||||
@@ -1376,6 +1520,154 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
build: crate::config::Command::from_line(match name {
|
||||
"slow" => "./slow.sh",
|
||||
_ => "touch quick-built",
|
||||
}),
|
||||
cwd: Some(PathBuf::from(name)),
|
||||
stale_when: None,
|
||||
strip: false,
|
||||
package: None,
|
||||
built_from: 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().run;
|
||||
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().run.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(),
|
||||
build: crate::config::Command::from_line(match name {
|
||||
"broken" => "false",
|
||||
_ => "true",
|
||||
}),
|
||||
cwd: Some(PathBuf::from(name)),
|
||||
stale_when: None,
|
||||
strip: false,
|
||||
package: None,
|
||||
built_from: 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().run.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().run;
|
||||
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.
|
||||
///
|
||||
@@ -1431,7 +1723,7 @@ mod tests {
|
||||
|
||||
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 {
|
||||
while state.status().run.building {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"build did not finish in time"
|
||||
|
||||
@@ -493,6 +493,23 @@ struct ManifestApp {
|
||||
/// one flag for the list, so a card says whether *it* is the one still
|
||||
/// being worked out.
|
||||
check_pending: bool,
|
||||
/// The build running for this project right now, if one is, in the
|
||||
/// same shape `/status` answers.
|
||||
///
|
||||
/// Here so that a build survives leaving the app. The run itself never
|
||||
/// stops -- it owns its own `Arc<BuildState>` and outlives the request
|
||||
/// that started it -- but the phone's record of it lives only in the
|
||||
/// composition, so backgrounding tears down the polling loop and the
|
||||
/// card state together. Without this the list it comes back to cannot
|
||||
/// say a build is still going, and the card reads as one that was
|
||||
/// killed: it offers Update again, and pressing it does nothing,
|
||||
/// because there is already a run in this project's one build slot.
|
||||
///
|
||||
/// Read with [`crate::build_state::BuildState::running`] rather than
|
||||
/// `status`, which also answers `stale` and walks every component's
|
||||
/// directory to do it -- this is on the manifest path.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
build: Option<crate::build_state::RunningBuild>,
|
||||
/// What this project produces, in the order it is built. One entry is
|
||||
/// the ordinary case and the card shows it inline; more than one is
|
||||
/// what the phone draws as a nested list.
|
||||
@@ -656,6 +673,7 @@ async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp
|
||||
.build
|
||||
.as_ref()
|
||||
.is_some_and(|build| build.has_command()),
|
||||
build: entry.build.as_ref().and_then(|build| build.running()),
|
||||
git_ipv4: entry.git_ipv4,
|
||||
built_in: entry.built_in,
|
||||
pending_declaration: pending
|
||||
|
||||
Reference in new issue
Block a user