//! 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 std::time::Instant; 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, or uncommitted /// work in its directory. 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; /// 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, } #[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, building: bool, /// When the current run began, for the elapsed time shown while it is /// still going. started: Option, /// 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, /// One entry per component the current run has reached, in the order /// it reached them. runs: Vec, /// 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 /// server's log. error: Option, /// 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, /// The component whose step produced that failure. /// /// 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, } /// 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, started: Instant, /// Filled in when the component finishes, so the card can keep showing /// how long it took. took_ms: Option, /// 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, error: Option, } 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, /// 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, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct BuildStatus { pub stale: bool, pub building: bool, pub error: Option, /// 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, /// 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. pub components: Vec, } /// One component's part of a build, as the phone sees it. #[derive(Serialize)] #[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, /// 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 /// number. #[serde(skip_serializing_if = "Option::is_none")] pub progress: Option, /// The last few lines this component printed, oldest first. pub log: Vec, /// Why this component's step failed, if it did. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } /// 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; /// 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 = 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, git_pull: bool, git_ipv4: bool, project_path: PathBuf, shared: Shared, is_self: bool, ) -> Arc { 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)| mine.same_declaration(theirs)) } /// 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, 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. /// /// 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. /// /// `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, component: Option<&str>, record: RecordBuilt) { { let mut inner = self.inner.lock().unwrap(); if inner.building { return; } inner.building = true; inner.error = None; inner.failed = None; inner.unrelated_histories = false; inner.started = Some(Instant::now()); inner.runs.clear(); } let this = Arc::clone(self); let component = component.map(str::to_string); tokio::task::spawn_blocking(move || { this.run_build(component.as_deref(), &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. pub fn freshness(&self, component: &Component) -> Freshness { 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.cwd()) != Some(false) { return Freshness::Unknown; } match crate::git::subtree_head(&self.project_path, component.cwd()) { Some(current) if current == built => Freshness::Current, Some(_) => Freshness::Behind, None => Freshness::Unknown, } } fn component_is_stale( &self, component: &Component, built_from: &HashMap, ) -> bool { if component.build().is_empty() { return false; } // 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.cwd()) .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. Idempotent in the same way as [`Self::trigger_if_needed`]: /// 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. pub fn pull_and_build( self: &Arc, force: bool, may_build: impl Fn() -> bool + Send + 'static, record: RecordBuilt, ) { { let mut inner = self.inner.lock().unwrap(); if inner.building { return; } inner.building = true; inner.error = None; inner.failed = None; inner.unrelated_histories = false; inner.started = Some(Instant::now()); inner.runs.clear(); } let this = Arc::clone(self); tokio::task::spawn_blocking(move || { let outcome = this.pull(force); match outcome { Err(error) => { tracing::error!("pull failed: {}", error.message); this.fail_pull(error); } Ok(pulled) => { // Nothing configured to build, or nothing allowed to: // the pull was the whole job, and reporting success is // all that is left. // `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); } } } }); } /// Fetches and fast-forwards, reporting whether anything arrived. fn pull(&self, force: bool) -> Result { self.begin_project("fetching"); crate::git::fetch(&self.project_path, self.git_ipv4)?; // 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 every component `name` selects, at once. /// /// 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. /// /// 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, name: Option<&str>, 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; } let this = Arc::clone(self); let record = Arc::clone(record); running.push(( component.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; // 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`. 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")), }; match outcome { Ok(is_self) => restart_self |= is_self, Err(message) if error.is_none() => { error = Some(message); failed = Some(name); } Err(_) => {} } } let mut restart = error.is_none() && 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; } self.finish(error, failed); if restart { // Answered and finished first, 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, index: usize, record: &RecordBuilt) -> Result { 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, component: &Component, record: &RecordBuilt, ) -> Result { 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.cwd()) { 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 { 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, 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))); self.run_streaming_command( component.name(), component.build(), component.cwd(), &[], log, ) } fn run_streaming_command( self: &Arc, component: &str, command: &crate::config::Command, cwd: Option<&std::path::Path>, extra: &[&str], log: Option>>, ) -> 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), child .stderr .take() .map(|err| Box::new(err) as Box), ] .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 = 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 { 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 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. 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 => 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, }), } } /// Marks one component as done, with why it stopped if it failed. fn finish_component(&self, component: &str, error: Option) { 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, failed: Option) { 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. /// /// 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 /// 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); } /// 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 the last completed build stopped at this component. /// /// 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. pub fn build_failed(&self, component: &str) -> bool { let inner = self.inner.lock().unwrap(); inner.error.is_some() && inner.failed.as_deref() == Some(component) } 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.building, 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) .map(|(done, total)| Progress { done: done.min(total), total, }), log: run.log.iter().cloned().collect(), error: run.error.clone(), }) .collect(), } } } 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 { 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(), build: crate::config::Command::from_line("true"), cwd: Some(PathBuf::from("server")), stale_when: None, service: None, built_from: None, }, Component::Apk { name: "app".to_string(), build: crate::config::Command::from_line("true"), cwd: Some(PathBuf::from("app")), stale_when: None, strip: false, package: None, built_from: 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(), build: crate::config::Command::from_line("touch built-marker"), cwd: None, stale_when: None, strip: false, package: None, built_from: 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).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(), build: crate::config::Command::from_line("touch backend-built"), cwd: Some(PathBuf::from("server")), stale_when: None, service: None, built_from: None, }, Component::Apk { name: "app".to_string(), build: crate::config::Command::from_line("touch app-built"), cwd: Some(PathBuf::from("app")), stale_when: None, strip: false, package: None, built_from: 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!["app"], "the status only reports the component that actually ran", ); } /// 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(), build: crate::config::Command::from_line("touch a-built"), cwd: Some(PathBuf::from("a")), stale_when: None, strip: false, package: None, built_from: None, }, Component::Apk { name: "b".to_string(), build: crate::config::Command::from_line("touch b-built"), cwd: Some(PathBuf::from("b")), stale_when: None, strip: false, package: None, built_from: 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) -> Arc { 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 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, Some(&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" ); } /// 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(), build: crate::config::Command::from_line("true"), cwd: None, stale_when: None, strip: false, package: None, built_from: Some("0000000000000000000000000000000000000000".to_string()), }], ); 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>>, before_each_read: std::rc::Rc>>, } impl std::io::Read for Chunks { fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { 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" ); } }