Skip to main content

onevcs/pool/
maintain.rs

1//! `pool maintain`: run the identity's maintenance command in each idle slot, one slot
2//! at a time, and record the attempt on the slot.
3//!
4//! `onevcs` holds **no schedule**. What maintenance *is* — the argv and its bound — is
5//! the host's, per identity, in `workspaces.yml`; *when* it runs is the caller's — a
6//! driver's idle branch, a cron, a person — and what this crate keeps is the fact of
7//! the last attempt, on the slot record, so every caller reads the same clock. That is
8//! what makes the verb idempotent and cheap: a slot maintained inside the caller's own
9//! `older_than` is reported `NotDue` and costs no process, and a slot never maintained
10//! is always due.
11//!
12//! **One slot per identity at a time.** Maintenance takes a slot out of service, and
13//! a run that took every idle slot at once would turn a pool's headroom into a queue.
14//! So an identity is maintained under an exclusive lock keyed on it — a second run
15//! meeting it answers `Claimed` naming the holder and touches nothing — and its slots
16//! are visited in number order, each claimed for exactly the duration of its own
17//! command: the claim the previous amendment declared is written under the placement
18//! lock, so an `open` reads it as `Maintaining` and goes elsewhere, and is cleared with
19//! the outcome before the next slot is looked at. Never waiting is the placement
20//! order's promise, and this verb keeps it by never holding the placement lock across
21//! a command.
22//!
23//! **The attempt is what is recorded.** `last_maintained` is stamped whether the
24//! command succeeded, failed or timed out, and `last_outcome` says which: a command
25//! that fails is not retried on every tick until `older_than` has elapsed again, which
26//! is what keeps a broken maintenance script from running on every idle beat of every
27//! driver — and `pool status` shows the outcome beside the stamp so a person can see it.
28
29use std::path::{Path, PathBuf};
30use std::process::{Child, Command, Stdio};
31use std::sync::mpsc;
32use std::time::{Duration, Instant, SystemTime};
33
34use serde::{Deserialize, Serialize};
35
36use super::{
37    placement_identity, pool_dir, shed, state_of, survey, write_record, Claim, MaintenanceOutcome,
38    SlotRecord, SlotState, Timestamp,
39};
40use crate::error::{self, Result};
41use crate::event::ArtifactId;
42use crate::session::{Lifecycle, Scope, SessionToken};
43use crate::store::{self, Resolution};
44use crate::workspaces::{self, Maintenance, Overrides, Span};
45use crate::{git, ids, lock, processes, stream, workspace};
46
47/// The longest a run waits between looks at its command's exit, where neither pipe
48/// reports an ending — the same ceiling a bounded git command sleeps under.
49const POLL: Duration = Duration::from_millis(10);
50
51/// What `pool maintain` did: every identity in scope, and what became of each.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct MaintainReport {
54    /// One entry per identity in scope, in identity order.
55    pub identities: Vec<IdentityMaintenance>,
56}
57
58impl MaintainReport {
59    /// Whether every command this run ran succeeded — what decides the exit code:
60    /// `0` when nothing ran or every command succeeded, `1` when any failed or timed
61    /// out.
62    pub(crate) fn every_command_succeeded(&self) -> bool {
63        self.identities
64            .iter()
65            .all(|identity| match &identity.outcome {
66                IdentityOutcome::Slots(slots) => slots.iter().all(|slot| {
67                    !matches!(
68                        slot.outcome,
69                        SlotOutcome::Ran {
70                            outcome: MaintenanceOutcome::Failed { .. }
71                                | MaintenanceOutcome::TimedOut,
72                            ..
73                        }
74                    )
75                }),
76                _ => true,
77            })
78    }
79}
80
81/// One identity, and what maintaining it came to.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct IdentityMaintenance {
84    /// The identity key.
85    pub identity: String,
86    /// What became of it.
87    pub outcome: IdentityOutcome,
88}
89
90/// What became of one identity.
91///
92/// Externally tagged in kebab case, the way [`MaintenanceOutcome`] is: `"no-slots"`,
93/// `{"claimed": {"by_pid": N}}`, `{"slots": [...]}`.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "kebab-case")]
96pub enum IdentityOutcome {
97    /// The resolved policy names no `maintain`, so there is nothing to run.
98    NoMaintainCommand,
99    /// The repository configuration could not be read; nothing ran.
100    MaintenanceUnreadable {
101        /// The refusal naming the checkout or document.
102        reason: String,
103    },
104    /// Nothing has cut a slot for this identity.
105    NoSlots,
106    /// Another `pool maintain` holds this identity right now; nothing was touched.
107    Claimed {
108        /// The process holding it — `0` where it had not stamped its pid yet.
109        by_pid: u32,
110    },
111    /// Every slot, in number order, and what became of each.
112    Slots(Vec<SlotMaintenance>),
113}
114
115/// One slot, and what became of it.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct SlotMaintenance {
118    /// The slot's number.
119    pub number: u32,
120    /// What became of it.
121    pub outcome: SlotOutcome,
122}
123
124/// What became of one slot.
125///
126/// Externally tagged in kebab case, the way [`IdentityOutcome`] is.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "kebab-case")]
129pub enum SlotOutcome {
130    /// Maintained inside the caller's `older_than`, so not run.
131    NotDue {
132        /// When it was last maintained, RFC3339.
133        last_maintained: String,
134    },
135    /// An open session record names it, so not run.
136    InUse {
137        /// The session working in it.
138        session: SessionToken,
139    },
140    /// Something else has it right now: a live claim this run did not write, an
141    /// occupancy it could not take, or a process working inside it.
142    ///
143    /// A healthy slot that is busy, and a later run finds it clear — which is what
144    /// separates it from [`SlotOutcome::Broken`], so a consumer reads breakage off
145    /// the variant rather than off a reason it has to parse.
146    Unavailable {
147        /// What holds it: the claim, the occupancy, or the processes inside it.
148        holder: String,
149    },
150    /// It could not be claimed because its clone, worktree or record is not usable —
151    /// nothing waiting clears.
152    Broken {
153        /// What is wrong with it.
154        reason: String,
155    },
156    /// The command ran in it, and how that ended.
157    Ran {
158        /// How the command ended.
159        outcome: MaintenanceOutcome,
160        /// How long it ran, in milliseconds.
161        duration_ms: u64,
162        /// Everything it wrote, stored as one artifact — `None` where storing it
163        /// failed, which is warned about on stderr.
164        log: Option<ArtifactId>,
165        /// Allocated worktree bytes before and after the command.
166        disk: Option<SlotDisk>,
167    },
168}
169
170/// Allocated bytes held by a slot's worktree around maintenance.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct SlotDisk {
173    /// Allocated bytes immediately before the command.
174    pub before_bytes: u64,
175    /// Allocated bytes immediately after the command.
176    pub after_bytes: u64,
177}
178
179/// The lock identity one identity's maintenance is serialized under.
180///
181/// Keyed on the pool directory the way placement is, and separate from the placement
182/// lock on purpose: this one is held for as long as every command takes, and the
183/// placement lock is what `open` must never wait on.
184fn maintenance_identity(pool: &Path) -> String {
185    format!("maintain:{}", pool.display())
186}
187
188/// Maintain the idle slots of every identity in `scope`.
189///
190/// `older_than` skips a slot maintained within that span; absent, every idle slot is
191/// due. A slot never maintained is always due.
192pub fn pool_maintain(scope: Scope, older_than: Option<Span>) -> Result<MaintainReport> {
193    let registry = store::load()?;
194    let resolutions: Vec<Resolution> = match &scope {
195        Scope::Repo(repo) => vec![store::resolve(&registry, repo)?],
196        Scope::All => {
197            // Once per identity rather than once per checkout of one, and in a
198            // stable order, so two callers over the whole host visit identities the
199            // same way round.
200            let mut identities: Vec<&str> = registry
201                .checkouts
202                .values()
203                .map(|checkout| checkout.identity.as_str())
204                .collect();
205            identities.sort_unstable();
206            identities.dedup();
207            identities
208                .into_iter()
209                .map(|identity| store::resolve(&registry, identity))
210                .collect::<Result<Vec<_>>>()?
211        }
212    };
213    let mut report = MaintainReport {
214        identities: Vec::new(),
215    };
216    for resolution in resolutions {
217        let outcome = maintain_identity(&resolution, older_than)?;
218        report.identities.push(IdentityMaintenance {
219            identity: resolution.key,
220            outcome,
221        });
222    }
223    Ok(report)
224}
225
226/// Maintain one identity: cheap answers first, then the identity lock, then each slot.
227fn maintain_identity(resolution: &Resolution, older_than: Option<Span>) -> Result<IdentityOutcome> {
228    let resolving = Instant::now();
229    let resolved = workspaces::resolve_for(resolution, Overrides::default())?;
230    let maintenance = match crate::repository_config::resolve(resolution, &resolved) {
231        crate::repository_config::MaintenanceSource::Declared { maintenance, .. }
232        | crate::repository_config::MaintenanceSource::HostRule { maintenance, .. } => maintenance,
233        crate::repository_config::MaintenanceSource::Undeclared { .. } => {
234            return Ok(IdentityOutcome::NoMaintainCommand)
235        }
236        // llmlint: ignore-block[cli_output_contract] The approved contract keeps command-based exits: nothing ran, so exit 0. Unreadable maintenance is the identity's structured outcome, which the engine reads and surfaces; a nonzero exit would change the contract without approval (manager ruling).
237        crate::repository_config::MaintenanceSource::Unreadable { reason } => {
238            return Ok(IdentityOutcome::MaintenanceUnreadable { reason })
239        } // llmlint: ignore-end[cli_output_contract]
240    };
241    let resolving_seconds = resolving.elapsed().as_secs_f64();
242    let pool = pool_dir(&workspace::identity_dir(&resolution.key)?);
243    if !pool.is_dir() {
244        return Ok(IdentityOutcome::NoSlots);
245    }
246    let held = maintenance_identity(&pool);
247    let Some(_maintaining) = lock::try_exclusive(&held)? else {
248        return Ok(IdentityOutcome::Claimed {
249            by_pid: lock::stamped_owner(&held)?.unwrap_or(0),
250        });
251    };
252    // Surplus is shed exactly as `open` sheds it, under the placement lock and
253    // before any slot is looked at; only the numbers survive the lock, because a
254    // state read here is stale by the time an earlier slot's command has run.
255    let numbers: Vec<u32> = {
256        let _serial = lock::exclusive(&placement_identity(&pool))?;
257        let mut survey = survey(&resolution.key, &pool)?;
258        shed(&mut survey, resolved.file_pool, &resolution.publication);
259        survey.slots.iter().map(|slot| slot.number).collect()
260    };
261    if numbers.is_empty() {
262        return Ok(IdentityOutcome::NoSlots);
263    }
264    let mut slots = Vec::with_capacity(numbers.len());
265    for number in numbers {
266        let outcome = maintain_slot(
267            &resolution.key,
268            &pool,
269            number,
270            &maintenance,
271            older_than,
272            resolving_seconds,
273        )?;
274        slots.push(SlotMaintenance { number, outcome });
275    }
276    Ok(IdentityOutcome::Slots(slots))
277}
278
279/// A slot this run has claimed: its directory, the record carrying the claim, and the
280/// exclusive take that proves nothing else is inside it — held until the outcome is
281/// written.
282struct Claimed {
283    dir: PathBuf,
284    record: SlotRecord,
285    _held: lock::Guard,
286}
287
288/// Maintain one slot: claim it under the placement lock, run the command with the
289/// lock released, and write the outcome.
290fn maintain_slot(
291    identity: &str,
292    pool: &Path,
293    number: u32,
294    maintenance: &Maintenance,
295    older_than: Option<Span>,
296    resolving_seconds: f64,
297) -> Result<SlotOutcome> {
298    let claiming = Instant::now();
299    let claimed = match claim(identity, pool, number, older_than)? {
300        Ok(claimed) => claimed,
301        Err(kept) => return Ok(kept),
302    };
303    let claiming_seconds = claiming.elapsed().as_secs_f64();
304    let worktree = claimed.dir.join("worktree");
305    let measure = || match disk_bytes(&worktree) {
306        Ok(measured) => Some(measured),
307        Err(failure) => {
308            eprintln!("onevcs: warning: slot {number} of {identity}: cannot measure worktree {}: {failure}", worktree.display());
309            None
310        }
311    };
312    let measuring = Instant::now();
313    let before = measure();
314    let before_seconds = measuring.elapsed().as_secs_f64();
315    let ran = run(&worktree, maintenance);
316    let measuring = Instant::now();
317    let after = measure();
318    let after_seconds = measuring.elapsed().as_secs_f64();
319    let recording = Instant::now();
320    let disk = before
321        .zip(after)
322        .map(|((before_bytes, _), (after_bytes, _))| SlotDisk {
323            before_bytes,
324            after_bytes,
325        });
326    let log = match stream::store_artifact("log", &ran.output) {
327        Ok(stored) => Some(ArtifactId(stored.id)),
328        Err(failure) => {
329            eprintln!(
330                "onevcs: warning: the maintenance of slot {number} of {identity} is recorded \
331                 without what it wrote: {failure}"
332            );
333            None
334        }
335    };
336    let mut record = claimed.record;
337    record.maintaining = None;
338    record.last_maintained = Some(Timestamp::now());
339    record.last_outcome = Some(ran.outcome);
340    write_record(&claimed.dir, &record)?;
341    if let Some(path) = std::env::var_os("ONEVCS_POOL_MAINTAIN_TELEMETRY") {
342        let telemetry = serde_json::json!({
343            "resolving_seconds": resolving_seconds,
344            "claiming_seconds": claiming_seconds,
345            "before_seconds": before_seconds,
346            "after_seconds": after_seconds,
347            "recording_seconds": recording.elapsed().as_secs_f64(),
348            "before_entries": before.map(|(_, entries)| entries),
349            "after_entries": after.map(|(_, entries)| entries),
350        });
351        let path = PathBuf::from(path);
352        let valid = path.is_absolute()
353            && path.parent().is_some_and(Path::is_dir)
354            && match std::fs::symlink_metadata(&path) {
355                Ok(metadata) => metadata.is_file() && !metadata.is_symlink(),
356                Err(failure) => failure.kind() == std::io::ErrorKind::NotFound,
357            };
358        let recorded = if valid {
359            std::fs::write(&path, telemetry.to_string())
360        } else {
361            Err(std::io::Error::new(std::io::ErrorKind::InvalidInput,
362                "telemetry requires an absolute regular file path with an existing parent directory"))
363        };
364        if let Err(failure) = recorded {
365            eprintln!(
366                "onevcs: warning: cannot record maintenance telemetry at {}: {failure}",
367                path.display()
368            );
369        }
370    }
371    Ok(SlotOutcome::Ran {
372        outcome: ran.outcome,
373        duration_ms: u64::try_from(ran.duration.as_millis()).unwrap_or(u64::MAX),
374        log,
375        disk,
376    })
377}
378
379/// Claim one slot, or say why it is kept.
380///
381/// Under the placement lock, and with the slot's state read *now* rather than at the
382/// survey: an earlier slot's command may have run for the whole of its bound, and a
383/// slot idle then may be a session's by this time. The claim is written before the
384/// lock is released, so the next `open` reads it.
385fn claim(
386    identity: &str,
387    pool: &Path,
388    number: u32,
389    older_than: Option<Span>,
390) -> Result<std::result::Result<Claimed, SlotOutcome>> {
391    let _serial = lock::exclusive(&placement_identity(pool))?;
392    let dir = pool.join(number.to_string());
393    let open: Vec<workspace::Record> = workspace::all()?
394        .into_iter()
395        .filter(|record| record.identity == identity && record.state == Lifecycle::Open)
396        .collect();
397    let broken = |reason: String| Ok(Err(SlotOutcome::Broken { reason }));
398    let held_by = |holder: String| Ok(Err(SlotOutcome::Unavailable { holder }));
399    let (record, state) = state_of(&dir, number, identity, &open);
400    let mut record = match (record, state) {
401        (_, SlotState::InUse { session }) => return Ok(Err(SlotOutcome::InUse { session })),
402        (_, SlotState::Broken { reason }) => return broken(reason),
403        (_, SlotState::Maintaining { pid, since }) => {
404            return held_by(format!(
405                "a maintenance run this one did not start (pid {pid}) has claimed it since {since}"
406            ))
407        }
408        (Some(record), SlotState::Idle) => record,
409        // llmlint: ignore[changed_behavior_has_e2e] unreachable by construction:
410        // `state_of` answers `Idle` only after it has read the record, and it hands
411        // that record back beside the state. Said as a kept slot rather than a panic,
412        // because the answer to a reader that could not say is always "kept".
413        (None, SlotState::Idle) => return broken("its record could not be read".to_owned()),
414    };
415    if let (Some(span), Some(last)) = (older_than, &record.last_maintained) {
416        if !due(last, span) {
417            return Ok(Err(SlotOutcome::NotDue {
418                last_maintained: last.clone().into(),
419            }));
420        }
421    }
422    // The exclusive take proves nothing is inside the slot right now, and holding it
423    // through the run is what a prune meeting the slot then refuses on.
424    let Some(held) = lock::try_exclusive(&workspace::occupancy_identity(&dir))? else {
425        return held_by("a command is working in it right now".to_owned());
426    };
427    // A slot whose last session closed without returning it may still have that
428    // session's worker inside; the census `open` skips it on keeps a maintenance
429    // command from running under a build.
430    if !super::returned(&dir.join("worktree")) {
431        let holders = processes::holding(&dir);
432        if !holders.is_empty() {
433            let named: Vec<String> = holders.iter().map(ToString::to_string).collect();
434            return held_by(format!(
435                "a process is still working inside it: {}",
436                named.join("; ")
437            ));
438        }
439    }
440    let pid = std::process::id();
441    let Some(started) = workspace::process_started(pid) else {
442        // llmlint: ignore[changed_behavior_has_e2e] uncovered: every host this crate
443        // runs its journeys on answers its own process's start time; one that does not
444        // could write a claim no reader could tell from a dead process's, so it is
445        // refused rather than written.
446        return Err(error::invalid(format!(
447            "this host cannot identify process {pid} by its start time, so a maintenance \
448             claim it wrote could not be told from one a dead process left"
449        )));
450    };
451    record.maintaining = Some(Claim {
452        pid,
453        started,
454        since: Timestamp::now(),
455    });
456    write_record(&dir, &record)?;
457    Ok(Ok(Claimed {
458        dir,
459        record,
460        _held: held,
461    }))
462}
463
464/// Whether a slot last maintained at `last` is due again under `older_than`.
465///
466/// A stamp of the right shape naming no instant — which the record's own conversion
467/// cannot refuse — reads as due: what it fails to prove is that the slot was
468/// maintained recently, and a slot nothing can prove maintained is one that is not.
469/// A stamp in the future is the clock having moved backwards, which is *within* any
470/// span.
471fn due(last: &Timestamp, older_than: Span) -> bool {
472    let Some(instant) = ids::instant_of(&String::from(last.clone())) else {
473        return true;
474    };
475    let elapsed = SystemTime::now()
476        .duration_since(instant)
477        .unwrap_or(Duration::ZERO);
478    elapsed >= older_than.as_duration()
479}
480
481/// What one command did: how it ended, how long it took, and everything it wrote.
482struct Ran {
483    outcome: MaintenanceOutcome,
484    duration: Duration,
485    output: String,
486}
487
488/// Run the maintenance command in `worktree`, bounded by its timeout.
489///
490/// No shell, this process's environment, stdin closed, both streams captured — read
491/// as they arrive, the way every bounded command in this crate is read — and the
492/// whole process group ended when the bound fires, so a command's own children do
493/// not outlive the run that started them. A command that cannot be started at all
494/// is a failed one whose log says why: the attempt is what is recorded, and a
495/// missing program is not retried on every tick any more than a failing one is.
496/// There is no argv to find missing — [`workspaces::MaintenanceCommand`] carries the program
497/// beside its arguments, so the empty one cannot arrive here.
498fn run(worktree: &Path, maintenance: &Maintenance) -> Ran {
499    let started = Instant::now();
500    let bound = maintenance.timeout.as_duration();
501    let program = &maintenance.command.program;
502    let executable = if program.chars().any(std::path::is_separator) {
503        std::path::absolute(worktree).map(|root| root.join(program))
504    } else {
505        Ok(PathBuf::from(program))
506    };
507    let spawned = executable.and_then(|executable| {
508        let mut command = Command::new(executable);
509        command
510            .args(&maintenance.command.args)
511            .current_dir(worktree)
512            .stdin(Stdio::null())
513            .stdout(Stdio::piped())
514            .stderr(Stdio::piped());
515        git::detach_process_group(&mut command);
516        command.spawn()
517    });
518    let mut child = match spawned {
519        Ok(child) => child,
520        Err(failure) => {
521            return Ran {
522                outcome: MaintenanceOutcome::Failed { exit: None },
523                duration: started.elapsed(),
524                output: format!("{program} could not be started: {failure}\n"),
525            }
526        }
527    };
528    let (ended, endings) = mpsc::channel();
529    let out_reader = git::PipeCapture::start(
530        child.stdout.take().expect("stdout was piped"),
531        ended.clone(),
532    );
533    let err_reader = git::PipeCapture::start(child.stderr.take().expect("stderr was piped"), ended);
534    let exited = exited_within(&mut child, started, bound, &endings);
535    let Some(status) = exited else {
536        git::terminate_group(&child);
537        let _ = child.kill();
538        let _ = child.wait();
539        let duration = started.elapsed();
540        let mut output = combined(out_reader.finish().bytes, err_reader.finish().bytes);
541        output.push_str(&format!(
542            "\n[onevcs: timed out after {:.3}s, bound {}]\n",
543            duration.as_secs_f64(),
544            maintenance.timeout
545        ));
546        return Ran {
547            outcome: MaintenanceOutcome::TimedOut,
548            duration,
549            output,
550        };
551    };
552    let duration = started.elapsed();
553    let output = combined(out_reader.finish().bytes, err_reader.finish().bytes);
554    let outcome = match status {
555        Ok(status) if status.success() => MaintenanceOutcome::Succeeded,
556        Ok(status) => MaintenanceOutcome::Failed {
557            exit: status.code(),
558        },
559        // llmlint: ignore[changed_behavior_has_e2e] uncovered: a child this process
560        // spawned and cannot ask about, which no interface produces; it is the failure
561        // it is, with the reason in the log.
562        Err(_) => MaintenanceOutcome::Failed { exit: None },
563    };
564    Ran {
565        outcome,
566        duration,
567        output,
568    }
569}
570
571/// Collect the child's exit within the bound its pipes are read under, waiting on
572/// the readers rather than on the clock between looks.
573fn exited_within(
574    child: &mut Child,
575    started: Instant,
576    bound: Duration,
577    endings: &mpsc::Receiver<()>,
578) -> Option<std::io::Result<std::process::ExitStatus>> {
579    loop {
580        match child.try_wait() {
581            Ok(Some(status)) => return Some(Ok(status)),
582            Err(failure) => return Some(Err(failure)),
583            Ok(None) if started.elapsed() >= bound => return None,
584            Ok(None) => git::await_an_ending(endings, POLL),
585        }
586    }
587}
588
589/// Everything the command wrote, standard output first then diagnostics — the one
590/// artifact the report names. Interleaving is not recoverable from two captured
591/// pipes; what matters is that the whole run survives.
592fn combined(stdout: Vec<u8>, stderr: Vec<u8>) -> String {
593    let mut output = String::from_utf8_lossy(&stdout).into_owned();
594    output.push_str(&String::from_utf8_lossy(&stderr));
595    output
596}
597
598/// Walk every entry without following links, counting allocated blocks once per inode.
599#[cfg(unix)]
600fn disk_bytes(root: &Path) -> std::io::Result<(u64, u64)> {
601    use std::os::unix::fs::MetadataExt;
602    let mut seen = std::collections::HashSet::new();
603    let mut pending = vec![root.to_path_buf()];
604    let mut bytes = 0u64;
605    let mut entries = 0u64;
606    while let Some(path) = pending.pop() {
607        let metadata = std::fs::symlink_metadata(&path)?;
608        entries += 1;
609        if !seen.insert((metadata.dev(), metadata.ino())) {
610            continue;
611        }
612        bytes = bytes.saturating_add(metadata.blocks().saturating_mul(512));
613        if metadata.is_dir() {
614            for entry in std::fs::read_dir(&path)? {
615                pending.push(entry?.path());
616            }
617        }
618    }
619    Ok((bytes, entries))
620}
621
622#[cfg(not(unix))]
623fn disk_bytes(_root: &Path) -> std::io::Result<(u64, u64)> {
624    Err(std::io::Error::new(
625        std::io::ErrorKind::Unsupported,
626        "allocated block measurement is unavailable on this platform",
627    ))
628}