max / alloy
1 file changed,
+194 insertions,
-60 deletions
| @@ -9,15 +9,17 @@ | |||
| 9 | 9 | //! | |
| 10 | 10 | //! The shell's loop is synchronous by design and this does not change that. A | |
| 11 | 11 | //! reader thread per stream pushes lines into a channel; the view drains the | |
| 12 | - | //! channel on the shell's tick. Nothing here blocks for longer than it takes to | |
| 13 | - | //! empty a queue, so the frame keeps rendering at the tick rate whether the | |
| 14 | - | //! child is talkative or silent for a minute. | |
| 12 | + | //! channel on the shell's tick. Nothing here waits on the child, and the one | |
| 13 | + | //! place that waits on its output waits for [`DRAIN_SLICE`] at a time, so the | |
| 14 | + | //! frame keeps rendering at the tick rate whether the child is talkative or | |
| 15 | + | //! silent for a minute. | |
| 15 | 16 | ||
| 16 | 17 | use std::collections::VecDeque; | |
| 17 | 18 | use std::io::{BufRead, BufReader}; | |
| 18 | 19 | use std::process::{Child, ExitStatus}; | |
| 19 | - | use std::sync::mpsc::{Receiver, TryRecvError, channel}; | |
| 20 | + | use std::sync::mpsc::{Receiver, RecvTimeoutError, TryRecvError, channel}; | |
| 20 | 21 | use std::thread; | |
| 22 | + | use std::time::{Duration, Instant}; | |
| 21 | 23 | ||
| 22 | 24 | use crate::cli::{CommandLog, Invocation}; | |
| 23 | 25 | ||
| @@ -28,6 +30,20 @@ | |||
| 28 | 30 | /// the interesting lines during a failure are the last ones. | |
| 29 | 31 | const SCROLLBACK: usize = 500; | |
| 30 | 32 | ||
| 33 | + | /// How long one [`poll`](Sequence::poll) may wait for the output a reaped | |
| 34 | + | /// command left in the pipe. | |
| 35 | + | /// | |
| 36 | + | /// Long enough that the usual case costs a single tick: the pump thread is | |
| 37 | + | /// sitting in a blocking read, so lines already written arrive in well under a | |
| 38 | + | /// millisecond. Short enough to be invisible against the shell's tick. | |
| 39 | + | const DRAIN_SLICE: Duration = Duration::from_millis(50); | |
| 40 | + | ||
| 41 | + | /// How long the sequence keeps waiting after a reaped command has gone quiet. | |
| 42 | + | /// | |
| 43 | + | /// Measured from the last line rather than from the exit, so a command that is | |
| 44 | + | /// still producing is never cut off. Only silence ends the wait. | |
| 45 | + | const DRAIN_GRACE: Duration = Duration::from_millis(500); | |
| 46 | + | ||
| 31 | 47 | /// A command, and optionally what its output decides. | |
| 32 | 48 | /// | |
| 33 | 49 | /// The installer needs this because two of its arguments cannot be known when | |
| @@ -104,18 +120,53 @@ | |||
| 104 | 120 | /// terminal, and a progress line on stdout followed by its error on stderr | |
| 105 | 121 | /// is only legible in that order. | |
| 106 | 122 | lines: Receiver<String>, | |
| 123 | + | /// Set once the child has been reaped, `None` while it runs. | |
| 124 | + | exited: Option<Exited>, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// A reaped child, and how long the sequence will go on collecting the output | |
| 128 | + | /// it left behind in the pipe. | |
| 129 | + | struct Exited { | |
| 130 | + | status: ExitStatus, | |
| 131 | + | /// When to stop waiting. Pushed back by every line that arrives, so this | |
| 132 | + | /// is a deadline on silence rather than on the command. | |
| 133 | + | quiet_by: Instant, | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// What a drain found. | |
| 137 | + | struct Drained { | |
| 138 | + | /// Every line has been forwarded: both pump threads reached EOF and | |
| 139 | + | /// dropped their senders. | |
| 140 | + | complete: bool, | |
| 141 | + | /// How many lines this drain took, which is what extends the grace. | |
| 142 | + | lines: usize, | |
| 107 | 143 | } | |
| 108 | 144 | ||
| 109 | 145 | impl Running { | |
| 110 | 146 | /// Drain whatever has arrived, without waiting for more. | |
| 111 | - | fn drain(&mut self, into: &mut Vec<String>) { | |
| 147 | + | fn drain(&mut self, into: &mut Vec<String>) -> Drained { | |
| 148 | + | let mut lines = 0; | |
| 112 | 149 | loop { | |
| 113 | 150 | match self.lines.try_recv() { | |
| 114 | - | Ok(line) => self.take(line, into), | |
| 151 | + | Ok(line) => { | |
| 152 | + | self.take(line, into); | |
| 153 | + | lines += 1; | |
| 154 | + | } | |
| 155 | + | Err(TryRecvError::Empty) => { | |
| 156 | + | return Drained { | |
| 157 | + | complete: false, | |
| 158 | + | lines, | |
| 159 | + | }; | |
| 160 | + | } | |
| 115 | 161 | // Disconnected means both readers are done, which is a fact | |
| 116 | 162 | // about the pipes rather than about the process: the child may | |
| 117 | 163 | // still be exiting. `finished` is what decides that. | |
| 118 | - | Err(TryRecvError::Empty | TryRecvError::Disconnected) => return, | |
| 164 | + | Err(TryRecvError::Disconnected) => { | |
| 165 | + | return Drained { | |
| 166 | + | complete: true, | |
| 167 | + | lines, | |
| 168 | + | }; | |
| 169 | + | } | |
| 119 | 170 | } | |
| 120 | 171 | } | |
| 121 | 172 | } | |
| @@ -134,22 +185,53 @@ | |||
| 134 | 185 | self.captured.join("\n") | |
| 135 | 186 | } | |
| 136 | 187 | ||
| 137 | - | /// Drain everything the command will ever produce, blocking until the | |
| 138 | - | /// readers are done. | |
| 188 | + | /// Drain for up to `slice`, stopping early once every line has arrived. | |
| 139 | 189 | /// | |
| 140 | - | /// Only correct once the child has exited, and only safe then: reaping the | |
| 141 | - | /// child is not the same event as its output arriving. `try_wait` can reap | |
| 142 | - | /// a process whose last lines are still sitting in the pipe, unread by the | |
| 143 | - | /// pump threads, and a `try_recv` at that moment reports `Empty` and takes | |
| 144 | - | /// the lines to the grave with the `Running` that gets dropped next. | |
| 190 | + | /// For the window after the child has been reaped. Reaping is not the same | |
| 191 | + | /// event as the output arriving: `try_wait` can reap a process whose last | |
| 192 | + | /// lines are still sitting in the pipe, unread by the pump threads, and a | |
| 193 | + | /// `try_recv` at that moment reports `Empty` and takes the lines to the | |
| 194 | + | /// grave with the `Running` that gets dropped next. So this waits, which a | |
| 195 | + | /// drain during the run must never do. | |
| 145 | 196 | /// | |
| 146 | - | /// Blocking to `Disconnected` waits for the one event that means all | |
| 147 | - | /// output has been forwarded: both pump threads reaching EOF and dropping | |
| 148 | - | /// their senders. The wait is bounded because the child is already dead, | |
| 149 | - | /// so both pipes are closed and EOF is already on its way. | |
| 150 | - | fn drain_to_end(&mut self, into: &mut Vec<String>) { | |
| 151 | - | while let Ok(line) = self.lines.recv() { | |
| 152 | - | self.take(line, into); | |
| 197 | + | /// `Disconnected` is the one event that means all output has been | |
| 198 | + | /// forwarded, but waiting for it outright is what the caller must not do. | |
| 199 | + | /// A pipe reaches EOF when every holder of the write end closes it, and the | |
| 200 | + | /// child's descendants inherit that end: `bootc install to-disk` drives | |
| 201 | + | /// podman, ostree and bootupd, so a grandchild outliving its parent holds | |
| 202 | + | /// the pipe open with the parent already reaped. Blocking there froze the | |
| 203 | + | /// frame for as long as the grandchild lived, which on the run screen is | |
| 204 | + | /// indistinguishable from a hung install. A slice at a time, across ticks, | |
| 205 | + | /// costs the same in the ordinary case and cannot freeze in that one. | |
| 206 | + | fn drain_briefly(&mut self, into: &mut Vec<String>, slice: Duration) -> Drained { | |
| 207 | + | let until = Instant::now() + slice; | |
| 208 | + | let mut lines = 0; | |
| 209 | + | loop { | |
| 210 | + | let left = until.saturating_duration_since(Instant::now()); | |
| 211 | + | if left.is_zero() { | |
| 212 | + | return Drained { | |
| 213 | + | complete: false, | |
| 214 | + | lines, | |
| 215 | + | }; | |
| 216 | + | } | |
| 217 | + | match self.lines.recv_timeout(left) { | |
| 218 | + | Ok(line) => { | |
| 219 | + | self.take(line, into); | |
| 220 | + | lines += 1; | |
| 221 | + | } | |
| 222 | + | Err(RecvTimeoutError::Timeout) => { | |
| 223 | + | return Drained { | |
| 224 | + | complete: false, | |
| 225 | + | lines, | |
| 226 | + | }; | |
| 227 | + | } | |
| 228 | + | Err(RecvTimeoutError::Disconnected) => { | |
| 229 | + | return Drained { | |
| 230 | + | complete: true, | |
| 231 | + | lines, | |
| 232 | + | }; | |
| 233 | + | } | |
| 234 | + | } | |
| 153 | 235 | } | |
| 154 | 236 | } | |
| 155 | 237 | ||
| @@ -237,51 +319,68 @@ | |||
| 237 | 319 | } | |
| 238 | 320 | ||
| 239 | 321 | if let Some(running) = &mut self.current { | |
| 240 | - | let mut lines = std::mem::take(&mut self.output); | |
| 241 | - | running.drain(&mut lines); | |
| 242 | - | self.output = lines; | |
| 322 | + | if running.exited.is_none() { | |
| 323 | + | let mut lines = std::mem::take(&mut self.output); | |
| 324 | + | running.drain(&mut lines); | |
| 325 | + | self.output = lines; | |
| 243 | 326 | ||
| 244 | - | match running.finished() { | |
| 245 | - | None => return, | |
| 246 | - | Some(Err(err)) => { | |
| 247 | - | self.fail(format!("could not wait for the command: {err}")); | |
| 248 | - | return; | |
| 249 | - | } | |
| 250 | - | Some(Ok(status)) => { | |
| 251 | - | // The pipes can still hold output written just before exit, | |
| 252 | - | // so drain once more after reaping. Without this the last | |
| 253 | - | // lines of a failing command — the ones that say why — are | |
| 254 | - | // the ones that get lost, and a resolver reading this | |
| 255 | - | // command's output parses whatever happened to arrive in | |
| 256 | - | // time. Blocking, because a non-blocking drain here is a | |
| 257 | - | // race against the pump threads that the pumps often lose. | |
| 258 | - | let mut lines = std::mem::take(&mut self.output); | |
| 259 | - | let mut finished = self.current.take().expect("current was Some"); | |
| 260 | - | finished.drain_to_end(&mut lines); | |
| 261 | - | self.output = lines; | |
| 262 | - | self.done_count += 1; | |
| 263 | - | ||
| 264 | - | if !status.success() { | |
| 265 | - | self.fail(format!("command exited with {status}")); | |
| 327 | + | match running.finished() { | |
| 328 | + | None => return, | |
| 329 | + | Some(Err(err)) => { | |
| 330 | + | self.fail(format!("could not wait for the command: {err}")); | |
| 266 | 331 | return; | |
| 267 | 332 | } | |
| 333 | + | Some(Ok(status)) => { | |
| 334 | + | running.exited = Some(Exited { | |
| 335 | + | status, | |
| 336 | + | quiet_by: Instant::now() + DRAIN_GRACE, | |
| 337 | + | }); | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | } | |
| 268 | 341 | ||
| 269 | - | if let Some(resolve) = finished.then.take() { | |
| 270 | - | let captured = finished.captured(); | |
| 271 | - | match resolve(&captured) { | |
| 272 | - | // Pushed to the front: what a discovery decides | |
| 273 | - | // runs before whatever was queued behind it. | |
| 274 | - | Ok(stages) => { | |
| 275 | - | for stage in stages.into_iter().rev() { | |
| 276 | - | self.queue.push_front(stage); | |
| 277 | - | } | |
| 278 | - | } | |
| 279 | - | Err(message) => { | |
| 280 | - | self.fail(message); | |
| 281 | - | return; | |
| 282 | - | } | |
| 342 | + | // Reaped, so what is left is whatever was written just before exit | |
| 343 | + | // and is still in flight. Without collecting it, the lines that get | |
| 344 | + | // lost are the last ones a failing command wrote, which are the | |
| 345 | + | // ones that say why, and a resolver reading this command's output | |
| 346 | + | // parses whatever happened to arrive in time. | |
| 347 | + | let mut lines = std::mem::take(&mut self.output); | |
| 348 | + | let drained = running.drain_briefly(&mut lines, DRAIN_SLICE); | |
| 349 | + | self.output = lines; | |
| 350 | + | ||
| 351 | + | let exited = running.exited.as_mut().expect("reaped"); | |
| 352 | + | if drained.lines > 0 { | |
| 353 | + | exited.quiet_by = Instant::now() + DRAIN_GRACE; | |
| 354 | + | } | |
| 355 | + | // Not done, but still producing or recently so: come back next tick | |
| 356 | + | // rather than holding the frame here. | |
| 357 | + | if !drained.complete && Instant::now() < exited.quiet_by { | |
| 358 | + | return; | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | let mut finished = self.current.take().expect("current was Some"); | |
| 362 | + | let status = finished.exited.take().expect("reaped").status; | |
| 363 | + | self.done_count += 1; | |
| 364 | + | ||
| 365 | + | if !status.success() { | |
| 366 | + | self.fail(format!("command exited with {status}")); | |
| 367 | + | return; | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | if let Some(resolve) = finished.then.take() { | |
| 371 | + | let captured = finished.captured(); | |
| 372 | + | match resolve(&captured) { | |
| 373 | + | // Pushed to the front: what a discovery decides runs before | |
| 374 | + | // whatever was queued behind it. | |
| 375 | + | Ok(stages) => { | |
| 376 | + | for stage in stages.into_iter().rev() { | |
| 377 | + | self.queue.push_front(stage); | |
| 283 | 378 | } | |
| 284 | 379 | } | |
| 380 | + | Err(message) => { | |
| 381 | + | self.fail(message); | |
| 382 | + | return; | |
| 383 | + | } | |
| 285 | 384 | } | |
| 286 | 385 | } | |
| 287 | 386 | } | |
| @@ -339,6 +438,7 @@ | |||
| 339 | 438 | captured: Vec::new(), | |
| 340 | 439 | then: None, | |
| 341 | 440 | lines: receiver, | |
| 441 | + | exited: None, | |
| 342 | 442 | }) | |
| 343 | 443 | } | |
| 344 | 444 | ||
| @@ -438,6 +538,40 @@ | |||
| 438 | 538 | assert_eq!(sequence.output(), ["a", "b", "c"]); | |
| 439 | 539 | } | |
| 440 | 540 | ||
| 541 | + | // A grandchild that outlives its parent holds the write end of the pipe, | |
| 542 | + | // so EOF does not arrive when the parent is reaped. Waiting for it froze | |
| 543 | + | // the render thread for as long as the grandchild lived: on the run screen | |
| 544 | + | // the frame stops and the install looks hung, which is the one screen where | |
| 545 | + | // the user's alternative is power-cycling a half-written disk. `bootc | |
| 546 | + | // install to-disk` drives podman, ostree and bootupd, so this is the | |
| 547 | + | // ordinary case rather than a contrived one. | |
| 548 | + | // | |
| 549 | + | // The sleep is far longer than the sequence should ever take, so a | |
| 550 | + | // regression here shows up as elapsed time rather than as a wrong value. | |
| 551 | + | #[test] | |
| 552 | + | fn a_grandchild_holding_the_pipe_does_not_stall_the_sequence() { | |
| 553 | + | let mut log = CommandLog::new(); | |
| 554 | + | let mut sequence = Sequence::new(vec![Stage::Run( | |
| 555 | + | Invocation::new("sh").args(["-c", "sleep 30 & echo parent-done"]), | |
| 556 | + | )]); | |
| 557 | + | ||
| 558 | + | let started = Instant::now(); | |
| 559 | + | drive(&mut sequence, &mut log); | |
| 560 | + | let elapsed = started.elapsed(); | |
| 561 | + | ||
| 562 | + | assert_eq!(sequence.outcome(), Some(&Ok(()))); | |
| 563 | + | // The parent's own output is still complete. | |
| 564 | + | assert!( | |
| 565 | + | sequence.output().iter().any(|l| l == "parent-done"), | |
| 566 | + | "{:?}", | |
| 567 | + | sequence.output() | |
| 568 | + | ); | |
| 569 | + | assert!( | |
| 570 | + | elapsed < Duration::from_secs(5), | |
| 571 | + | "the sequence waited on the grandchild for {elapsed:?}" | |
| 572 | + | ); | |
| 573 | + | } | |
| 574 | + | ||
| 441 | 575 | #[test] | |
| 442 | 576 | fn a_command_that_does_not_exist_fails_the_sequence() { | |
| 443 | 577 | let mut log = CommandLog::new(); |