From b4b8a07dc445cf43bf5a2c4f757aae9ec9928b5a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 9 Nov 2026 10:49:42 +0000 Subject: [PATCH] fix(hosting): never take a copy that would leave the disk nearly full Every node hosts by default since #199, including busy production boxes, and the only guard was the 5 GiB hosting budget: dev2 runs ~6 c0mpute processes that now all host, on a 1.8 TiB disk with 20 GiB free. replicate() now refuses a copy unless max(10 GiB, 5% of the disk, capped at 50 GiB) stays free. Co-Authored-By: Claude Opus 5.5 --- node/crates/c0mpute-core/src/hosting/mod.rs | 41 +++++++++++++++++++++ node/crates/c0mpute-core/src/opendisk.rs | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/node/crates/c0mpute-core/src/hosting/mod.rs b/node/crates/c0mpute-core/src/hosting/mod.rs index 43c41bc1..ffa50e31 100644 --- a/node/crates/c0mpute-core/src/hosting/mod.rs +++ b/node/crates/c0mpute-core/src/hosting/mod.rs @@ -598,6 +598,14 @@ impl Hosting { if self.held_bytes().await - already + rec.size > self.0.max_bytes { bail!("over this node's hosting budget"); } + let (disk_free, disk_total) = crate::opendisk::disk_for(self.0.store.root()); + if !leaves_free_floor(disk_free, disk_total, rec.size.saturating_sub(already)) { + bail!( + "disk nearly full: {} GiB free, a copy must leave at least {} GiB", + disk_free / GIB_BYTES, + free_disk_floor(disk_total) / GIB_BYTES + ); + } let owner_bytes: u64 = self .0 .catalog @@ -1163,6 +1171,21 @@ fn site_root_redirect(site: &str, query: Option<&str>) -> Response { } } +const GIB_BYTES: u64 = 1024 * 1024 * 1024; + +/// Free space a hosting copy must leave on the disk: 10 GiB or 5% of it, +/// whichever is more, capped at 50 GiB. Every node hosts by default, including +/// busy production boxes, so the 5 GiB hosting budget alone could fill a disk +/// that was already nearly full. +fn free_disk_floor(disk_total: u64) -> u64 { + (disk_total / 20).clamp(10 * GIB_BYTES, 50 * GIB_BYTES) +} + +fn leaves_free_floor(disk_free: u64, disk_total: u64, extra: u64) -> bool { + // An unknown disk (0/0) is not refused: the budget check still applies. + disk_total == 0 || disk_free.saturating_sub(extra) >= free_disk_floor(disk_total) +} + pub(crate) fn jitter(d: Duration) -> Duration { let ms = d.as_millis() as u64; let spread = ms / 10; @@ -1263,6 +1286,24 @@ async fn serve_by_host(State(h): State, headers: HeaderMap, uri: axum:: #[cfg(test)] mod tests { + #[test] + fn hosting_copy_must_leave_a_free_disk_floor() { + let g = GIB_BYTES; + // 1.8 TiB disk: floor is capped at 50 GiB. + assert_eq!(free_disk_floor(1843 * g), 50 * g); + // 100 GiB disk: floor is the 10 GiB minimum. + assert_eq!(free_disk_floor(100 * g), 10 * g); + // dev2 on 2026-11-09: 20 GiB free on 1.8 TiB, refuse. + assert!(!leaves_free_floor(20 * g, 1843 * g, g)); + // Plenty free: accept. + assert!(leaves_free_floor(500 * g, 1843 * g, 5 * g)); + // Exactly at the floor after the copy: accept; one byte under: refuse. + assert!(leaves_free_floor(11 * g, 100 * g, g)); + assert!(!leaves_free_floor(11 * g, 100 * g, g + 1)); + // Unknown disk: the hosting budget still guards it. + assert!(leaves_free_floor(0, 0, g)); + } + #[test] fn slashless_site_path_redirects_with_query() { let r = site_root_redirect("53021d6e8d5a0e1aac48", None); diff --git a/node/crates/c0mpute-core/src/opendisk.rs b/node/crates/c0mpute-core/src/opendisk.rs index 70ca593f..a4c120e2 100644 --- a/node/crates/c0mpute-core/src/opendisk.rs +++ b/node/crates/c0mpute-core/src/opendisk.rs @@ -55,7 +55,7 @@ pub struct Capacity { /// The filesystem under `root`: (available, total). Picks the deepest mount /// that contains the root; falls back to the largest disk. -fn disk_for(root: &Path) -> (u64, u64) { +pub(crate) fn disk_for(root: &Path) -> (u64, u64) { let disks = sysinfo::Disks::new_with_refreshed_list(); let mut best: Option<(usize, u64, u64)> = None; let mut largest = (0u64, 0u64);