Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions .github/scripts/release-mac-capacity-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
class CapacityTests(unittest.TestCase):
def setUp(self):
self.record, self.growth, self.clock, self.commands = {}, 100, 0, []
self.disk = SimpleNamespace(total=1000, free=100)
self.disk = SimpleNamespace(total=1000, free=50)
self.bindings = Mock(return_value={'sdk': 'native', 'clang_sha256': 'a'})
self.images = [{'image': {'state': 'Ready'}}, {'image': {'state': 'Deleting'}}, {}]

Expand All @@ -38,8 +38,8 @@ def query(self, argv, timeout=30):
return ''

def refusal(self, *, status='unavailable', growth=100, kind=ValueError):
error = kind(f'hosted Mac capacity unavailable: free=100 total=1000 planned_growth={growth}')
error.capacity = {'status': status, 'planned_growth_bytes': growth, 'free_bytes_after': 100, 'total_bytes': 1000}
error = kind(f'hosted Mac capacity unavailable: free=50 total=1000 planned_growth={growth}')
error.capacity = {'status': status, 'planned_growth_bytes': growth, 'free_bytes_after': 50, 'total_bytes': 1000}
return error

def run_capacity(self, prepare, **overrides):
Expand Down Expand Up @@ -87,7 +87,7 @@ def test_shortfall_claim_cannot_override_measured_space_or_changed_tools(self):
self.disk.free = 250
with self.assertRaises(ValueError):
self.run_capacity(Mock(side_effect=self.refusal()))
self.disk.free = 100
self.disk.free = 50
self.bindings.side_effect = [{'sdk': 'native'}, {'sdk': 'changed'}]
with self.assertRaisesRegex(ValueError, 'Native build inputs changed'):
self.run_capacity(Mock(side_effect=self.refusal()))
Expand Down Expand Up @@ -133,9 +133,11 @@ def test_second_shortfall_stays_failed(self):
self.assertIs(caught.exception, second)
self.assertNotEqual(self.record.get('status'), 'ready')

def test_reserve_boundary_is_exact(self):
self.assertTrue(controller.enough(SimpleNamespace(total=1000, free=250), 100))
self.assertFalse(controller.enough(SimpleNamespace(total=1000, free=249), 100))
def test_growth_boundary_is_exact(self):
self.assertTrue(controller.enough(SimpleNamespace(total=1000, free=100), 100))
self.assertFalse(controller.enough(SimpleNamespace(total=1000, free=99), 100))
# Volume size alone never refuses growth that fits.
self.assertTrue(controller.enough(SimpleNamespace(total=10**15, free=100), 100))

def test_entrypoint_refuses_local_and_self_hosted_runs(self):
with patch.object(sys, 'argv', ['capacity']), patch.object(controller, 'query') as query:
Expand Down
8 changes: 4 additions & 4 deletions .github/scripts/release-mac-capacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def native_bindings():


def enough(disk, growth):
return (disk.free - growth) * 100 >= disk.total * 15
return disk.free >= growth


def run_capacity(record, growth, prepare, measure, bindings=native_bindings,
Expand All @@ -67,7 +67,7 @@ def run_capacity(record, growth, prepare, measure, bindings=native_bindings,
for key in ('free_bytes_after', 'total_bytes'))
and str(error) == f"hosted Mac capacity unavailable: free={receipt['free_bytes_after']} "
f"total={receipt['total_bytes']} planned_growth={growth}"
and (receipt['free_bytes_after'] - growth) * 100 < receipt['total_bytes'] * 15
and receipt['free_bytes_after'] < growth
and not enough(measure(), growth)):
raise
help_text = query(['/usr/bin/xcrun', 'simctl', 'help', 'runtime'])
Expand All @@ -87,7 +87,7 @@ def inventory():
record['recheck'] = prepare()
record['native_after'] = bindings()
need(record['native_after'] == record['native_before'], 'Native build inputs changed')
need(enough(measure(), growth), 'Capacity below planned growth and 15% reserve')
need(enough(measure(), growth), 'Capacity below planned growth')
record['status'] = 'ready'


Expand Down Expand Up @@ -118,7 +118,7 @@ def prepare():
finally:
capacity.cli_reclaim_xcode = original
record = {'status': 'unavailable', 'source_commit': os.environ['SOURCE_COMMIT'],
'source_tree': os.environ['SOURCE_TREE'], 'planned_growth_bytes': growth, 'reserve_percent': 15}
'source_tree': os.environ['SOURCE_TREE'], 'planned_growth_bytes': growth}
try:
run_capacity(record, growth, prepare, lambda: shutil.disk_usage(root))
finally:
Expand Down
22 changes: 11 additions & 11 deletions capsules/ipfs-provider/src/directory_hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,14 +643,13 @@ fn capacity_observation(
available_bytes: u64,
required_bytes: u64,
) -> io::Result<CapacityObservation> {
let remaining_bytes = available_bytes
.checked_sub(required_bytes)
.ok_or_else(|| invalid("insufficient backend capacity"))?;
if required_bytes > available_bytes {
return Err(invalid("insufficient backend capacity"));
}
if volume_id == 0
|| capacity_bytes == 0
|| available_bytes > capacity_bytes
|| !(1..=super::MAX_CAPACITY_REQUIRED_BYTES).contains(&required_bytes)
|| remaining_bytes < capacity_bytes.div_ceil(10)
{
return Err(invalid("invalid or insufficient backend capacity"));
}
Expand Down Expand Up @@ -1330,7 +1329,7 @@ mod tests {
assert_eq!(capacity["data"]["required_bytes"], 1);
let total = capacity["data"]["capacity_bytes"].as_u64().unwrap();
let available = capacity["data"]["available_bytes"].as_u64().unwrap();
assert!(total > 0 && available <= total && available > total.div_ceil(10));
assert!(total > 0 && available <= total && available >= 1);
assert!(
!child_data.join("ipfs-repo").exists(),
"capacity must observe the daemon's repo, not the child's default"
Expand Down Expand Up @@ -1595,12 +1594,13 @@ mod tests {
}

#[test]
fn private_capacity_arithmetic_preserves_ten_percent_floor() {
let exact = capacity_observation(7, 1000, 110, 10).unwrap();
assert_eq!(exact.available_bytes - exact.required_bytes, 100);
assert!(capacity_observation(7, 1000, 110, 11).is_err());
assert!(capacity_observation(7, 1001, 110, 10).is_err());
assert!(capacity_observation(7, 1001, 111, 10).is_ok());
fn private_capacity_arithmetic_requires_the_work_to_fit_available_bytes() {
let exact = capacity_observation(7, 1000, 110, 110).unwrap();
assert_eq!(exact.available_bytes, exact.required_bytes);
assert!(capacity_observation(7, 1000, 110, 111).is_err());
// Capacity size alone never refuses work that fits the available bytes.
assert!(capacity_observation(7, 1_000_000, 10, 10).is_ok());
assert!(capacity_observation(7, 1000, 1000, 1000).is_ok());
for (volume, total, available, required) in [
(0, 1000, 110, 10),
(7, 0, 0, 1),
Expand Down
13 changes: 8 additions & 5 deletions docs/CONTENT_CAPSULE_DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,9 @@ staging enters admission by exclusive same-filesystem rename. Exact-CID reuse
retains one artifact charge with separate actor/request bindings.

Private native/Registry capacity observation uses the ready backend's actual
repository and same-volume datastores. It returns bounded facts and enforces the 10%
free-space floor. Current-owner backend directories may use `0755` with no
repository and same-volume datastores. It returns bounded facts; Runtime admits
the work only when its bytes plus the shared 2 GiB free-space reserve fit in the
available space. Current-owner backend directories may use `0755` with no
special or group/world write bits; staging remains owner-only `0700`.
This filesystem observation is separate from the inventory reservation.
Isolated process tests exercise fresh Use through real Content, Registry,
Expand Down Expand Up @@ -395,8 +396,9 @@ payload digests before admission; a range receipt alone cannot establish them.
Runtime owns one private staging operation for the exact package identity and
admission record. Bound catalog/manifest bytes, file count, each file, total
bytes and time from validated policy and exact package facts. Account for
backend pin storage, staging and final placement, preserving at least 10% free
space on every affected volume before and during preparation. Verify ownership,
backend pin storage, staging and final placement, and require those bytes plus
the shared 2 GiB free-space reserve to fit in the available space on every
affected volume before and during preparation. Verify ownership,
mode, symlink/hard-link refusal and containment. Atomically admit only the full
verified closure; restart reconciles the same record and removes only its owned
partial staging. Retry neither duplicates admission nor repeats a completed
Expand Down Expand Up @@ -470,7 +472,8 @@ Implemented source boundaries and remaining acceptance:
JSON array. Large-model bootstrap must use bounded operator/provider import
or a separately verified publisher repair. Capacity admission covers the
complete proof layout, including an additional publisher backend copy when
used, while preserving the 10% free-space floor.
used, and requires that layout plus the shared 2 GiB reserve to fit in the
available space.
Verify actual
artifact/receipt parity and human behavior, then publish code/tests/docs/
manifests only after explicit authorization. Passkey ceremonies require the
Expand Down
8 changes: 8 additions & 0 deletions elastos/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ All notable changes to the public ElastOS Runtime repository.
version probe. Failed launches, timeouts, excess output, and unexpected version
output preserve the previous installation.

- Disk-space checks no longer demand a fixed share (10% or 15%) of the volume.
Each step now requires that its own bytes fit in the free space. Update
staging, the installer, model and content downloads and Browser image
preparation also keep one shared 2 GiB reserve, so a disk is never filled to
zero, and refuse with "not enough free space: this needs N GB plus 2 GB kept
free". An update that does not fit keeps the previous release. Source-home
setup keeps its 16 GiB minimum and the media tools build its 4 GiB minimum.

### Added

- An internal offline update foundation stages and verifies release files with
Expand Down
91 changes: 91 additions & 0 deletions elastos/crates/elastos-common/src/free_space.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! The one free-space rule for user-facing writes.

/// Free space a user-facing write keeps on its volume on top of its own bytes,
/// so a disk is never filled to zero. Update staging, the installer, model and
/// content downloads and Browser image preparation all use it;
/// `scripts/install.sh` repeats the value and a test keeps the two equal.
pub const FREE_SPACE_RESERVE_BYTES: u64 = 2 * GIB as u64;

const GIB: u128 = 1 << 30;

/// The write's bytes plus [`FREE_SPACE_RESERVE_BYTES`] do not fit in the
/// volume's available space.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct NotEnoughFreeSpace {
pub needed: u128,
}

impl std::fmt::Display for NotEnoughFreeSpace {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Round up to a tenth of a GB so a small write never reads as "0 GB".
let tenths = self.needed.saturating_mul(10).div_ceil(GIB);
let (whole, tenth) = (tenths / 10, tenths % 10);
let needed = if tenth == 0 {
whole.to_string()
} else {
format!("{whole}.{tenth}")
};
let reserve = u128::from(FREE_SPACE_RESERVE_BYTES) / GIB;
write!(
formatter,
"not enough free space: this needs {needed} GB plus {reserve} GB kept free"
)
}
}

impl std::error::Error for NotEnoughFreeSpace {}

/// Admits a write of `needed` bytes only when `needed + FREE_SPACE_RESERVE_BYTES <= available`.
pub fn require_free_space(available: u128, needed: u128) -> Result<(), NotEnoughFreeSpace> {
let fits = needed
.checked_add(u128::from(FREE_SPACE_RESERVE_BYTES))
.is_some_and(|total| total <= available);
if !fits {
return Err(NotEnoughFreeSpace { needed });
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn write_fits_only_with_the_reserve_kept_free() {
let reserve = u128::from(FREE_SPACE_RESERVE_BYTES);
for needed in [0, 1, 5 * GIB / 2, u128::from(u64::MAX)] {
assert!(require_free_space(needed + reserve, needed).is_ok());
assert_eq!(
require_free_space(needed + reserve - 1, needed),
Err(NotEnoughFreeSpace { needed })
);
}
// Volume size never matters, and the sum cannot wrap into a fit.
assert!(require_free_space(u128::MAX, u128::MAX).is_err());
}

#[test]
fn refusal_states_the_bytes_and_the_reserve_in_gb() {
for (needed, text) in [
(3 * GIB, "3"),
(5 * GIB / 2, "2.5"),
(64 * 1024, "0.1"),
(0, "0"),
] {
assert_eq!(
NotEnoughFreeSpace { needed }.to_string(),
format!("not enough free space: this needs {text} GB plus 2 GB kept free")
);
}
}

#[test]
fn installer_keeps_the_same_free_space_reserve() {
let installer = include_str!("../../../../scripts/install.sh");
let line = format!("FREE_SPACE_RESERVE_BYTES={FREE_SPACE_RESERVE_BYTES}\n");
assert!(
installer.contains(&line),
"install.sh must keep FREE_SPACE_RESERVE_BYTES equal to the Runtime's"
);
}
}
2 changes: 2 additions & 0 deletions elastos/crates/elastos-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ pub mod browser_protocol;
pub mod chat_protocol;
pub mod collaboration_protocol;
mod error;
mod free_space;
pub mod localhost;
mod manifest;
pub mod protected_content;
pub mod timestamp;
mod types;

pub use error::{ElastosError, Result};
pub use free_space::{require_free_space, NotEnoughFreeSpace, FREE_SPACE_RESERVE_BYTES};
pub use manifest::{
elastos_bus_v1_wit_sha256, validate_model_content_path, AffordanceApprovalMode,
AffordanceAuditMode, AffordanceRisk, CapsuleAffordanceDescriptor, CapsuleExecution,
Expand Down
38 changes: 27 additions & 11 deletions elastos/crates/elastos-runtime/src/provider/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1464,6 +1464,7 @@ impl ProviderRegistry {

/// Observe the actual Ready backend volume through the exact local provider.
/// The inventory owns reservations; this result grants no retention or admission.
/// Content and model downloads keep the shared free-space reserve on the backend volume.
pub async fn check_local_ipfs_capacity(
&self,
required_bytes: u64,
Expand All @@ -1486,13 +1487,14 @@ impl ProviderRegistry {
|| observation.capacity_bytes == 0
|| observation.available_bytes > observation.capacity_bytes
|| observation.required_bytes != required_bytes
|| observation
.available_bytes
.checked_sub(required_bytes)
.is_none_or(|remaining| remaining < observation.capacity_bytes.div_ceil(10))
{
return Err(private_ipfs_unavailable());
}
elastos_common::require_free_space(
u128::from(observation.available_bytes),
u128::from(required_bytes),
)
.map_err(|error| ProviderError::Provider(error.to_string()))?;
Ok(observation)
}

Expand Down Expand Up @@ -2965,7 +2967,9 @@ mod tests {
serde_json::json!({"status":"ok","data":{"cid":"bafybeihgnsjhpoktqbyspaqv6moblyny3txs5nkjdxfx7wm346odxkhlrm"}})
}
"runtime_check_capacity" => serde_json::json!({"status":"ok","data":{
"volume_id":7,"capacity_bytes":1000,"available_bytes":110,"required_bytes":request["required_bytes"]
"volume_id":7,"capacity_bytes":1_u64 << 40,
"available_bytes":elastos_common::FREE_SPACE_RESERVE_BYTES + 110,
"required_bytes":request["required_bytes"]
}}),
_ => panic!("unexpected operation"),
})
Expand Down Expand Up @@ -3362,15 +3366,28 @@ mod tests {
observed.available_bytes,
observed.required_bytes
),
(7, 1000, 110, 10)
(
7,
1 << 40,
elastos_common::FREE_SPACE_RESERVE_BYTES + 110,
10
)
);
assert_eq!(
provider.requests.lock().await.as_slice(),
&[serde_json::json!({"op":"runtime_check_capacity","required_bytes":10})]
);
assert!(registry.check_local_ipfs_capacity(11).await.is_err());
// Exactly the work plus the shared reserve fits; one byte less does not.
assert!(registry.check_local_ipfs_capacity(110).await.is_ok());
let refused = registry.check_local_ipfs_capacity(111).await.unwrap_err();
assert_eq!(
refused.to_string(),
ProviderError::Provider(elastos_common::NotEnoughFreeSpace { needed: 111 }.to_string())
.to_string()
);
let valid = serde_json::json!({"status":"ok","data":{
"volume_id":7,"capacity_bytes":1000,"available_bytes":110,"required_bytes":10
"volume_id":7,"capacity_bytes":1_u64 << 40,
"available_bytes":elastos_common::FREE_SPACE_RESERVE_BYTES + 110,"required_bytes":10
}});
let mut malformed = vec![
serde_json::json!({"status":"ok"}),
Expand All @@ -3380,9 +3397,8 @@ mod tests {
("volume_id", serde_json::json!(0)),
("volume_id", serde_json::json!("private-repo-path")),
("capacity_bytes", serde_json::json!(0)),
("capacity_bytes", serde_json::json!(1001)),
("available_bytes", serde_json::json!(1001)),
("available_bytes", serde_json::json!(9)),
("capacity_bytes", serde_json::json!(109)),
("available_bytes", serde_json::json!((1_u64 << 40) + 1)),
("required_bytes", serde_json::json!(11)),
("required_bytes", serde_json::json!(u64::MAX)),
("repo_path", serde_json::json!("private-repo-path")),
Expand Down
Loading
Loading