From d84744ff0b1be00e3fafe3678fb0bf2ea151adf5 Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:20:50 +0200 Subject: [PATCH 1/2] Hold 0050's budget, its floor and the order a decode waiting for room is started in Two hundred decoded tiles held at once is enough to be killed by the operating system on a television, and that failure looks like a crash rather than like a cache that was too large. 0050 bounds it at sixty four mebibytes, and this is the admission rule that keeps the bound: a decode that would take the total past it does not start, it waits, and nothing fails. Most of the work in 0050 was deciding what the quantity is, because the phrase reads as a cache and is not one, and that is what this module is written to. 0009 hands a decoded image to the caller and never reads it again and 0006 caches encoded bytes rather than pixels, so what is counted is the buffers of decodes that are running plus those whose outcome has not reached the caller, and nothing else. Read the other way, the natural build is a store with an eviction policy for data 0006 deliberately does not keep. The floor is derived rather than chosen, which is 0050's own construction: it is 0055's per-image bound at four bytes a pixel, so no image the core will ever decode is larger than the smallest budget a client may set, and nothing can wait for room that will never exist. A buffer size exists only for dimensions 0055 admits, which is what makes that total rather than nearly total. The order is where a queue written from the number alone goes wrong. A decode that would fit right now still waits behind one that does not, because ordering by size starves the large ones, and the test for it asserts the poster fits before asserting it waits, so it is a test of the order rather than of the room. Two figures 0050 states do not follow from its own arithmetic. Both are already recorded on #50 and neither is repaired here, because 0001 admits neither as an edit; both are named at the top of this module because this is where an implementation written from those sentences would go wrong. The one that costs something says the THIRD maximal image waits on a four-processor television. Two maximal images are 128000000 bytes against a budget of 67108864, so it is the second, and admission written to the sentence would allow a hundred and twenty eight megabytes on the device the per-image bound was chosen against. Fourteen tests, and three deliberate violations reddening what they should: if self.fits(bytes) { // the queue-is-empty half removed test artwork::budget::tests::a_later_small_ask_does_not_step_past_an_earlier_large_one ... FAILED test result: FAILED. 409 passed; 1 failed if bytes < 1 { // the floor lowered test artwork::budget::tests::a_budget_below_the_per_image_bound_is_refused_with_both_numbers ... FAILED test result: FAILED. 409 passed; 1 failed dimensions.pixel_count() * 3 // three bytes a pixel test result: FAILED. 405 passed; 5 failed Refs #50. Its conditions ask for a caller's thread not being used and two hundred images driven through, and nothing in this tree decodes anything or starts a thread, so this closes nothing. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- .github/coverage/pinned-surface | 1 + src/artwork/budget.rs | 663 ++++++++++++++++++++++++++++++++ src/artwork/mod.rs | 19 +- src/lib.rs | 5 + tests/thread_statements.rs | 28 ++ 5 files changed, 710 insertions(+), 6 deletions(-) create mode 100644 src/artwork/budget.rs diff --git a/.github/coverage/pinned-surface b/.github/coverage/pinned-surface index 9dbe5c7..393112d 100644 --- a/.github/coverage/pinned-surface +++ b/.github/coverage/pinned-surface @@ -92,5 +92,6 @@ module src/failure/mod.rs The one point every failure in the core beco module src/artwork/mod.rs The decoded image, which holds no decoder yet: #50 is where the decode arrives. module src/artwork/address.rs Where a requested size becomes the size asked for and where an identifier and a tag a server sent stop being values and become part of a request. A defect here is a full-resolution fetch for a tile nobody sized, or a server choosing a path the core then sends against a host it already resolved. module src/artwork/announced.rs 0053's ordered window, its bound, and which callers are sharing one entry's fetch. A defect here abandons a fetch a second tile is still waiting on, which shows a person a missing image and reads exactly like a slow server, or holds a queue proportional to a library rather than to what will be drawn. +module src/artwork/budget.rs 0050's bound on decoded bytes held at once, and the admission that keeps it: what a buffer costs at four bytes a pixel, the floor 0055 fixes, and the order waiting decodes are released in. A defect here allocates several maximal images at once on a television, which is the allocation 0055 exists to refuse arriving by a second route, or starves a large decode behind small ones. module src/artwork/format.rs Everything 0055 puts before a decoder: which three formats are accepted, the signature match that decides from the bytes rather than from what a server declared, and the two bounds read before pixels are allocated. A defect here hands the most attacked surface in this repository something nobody chose to accept. module src/artwork/presence.rs What an item has for one image kind. 0055 makes an absence and a refusal two different statements, and a defect here is a server sending bytes the core refused being shown as an item nobody photographed. diff --git a/src/artwork/budget.rs b/src/artwork/budget.rs new file mode 100644 index 0000000..3f13e7a --- /dev/null +++ b/src/artwork/budget.rs @@ -0,0 +1,663 @@ +//! The decoded bytes the core holds at once, and what a decode does at the bound. +//! +//! `docs/decisions/0050-the-decoded-bytes-budget.md` decides all of it: sixty +//! four mebibytes of pixel buffer held at any moment, counted at four bytes a +//! pixel over the decoded dimensions, enforced by not starting a decode that +//! would exceed it rather than by failing one, with waiting decodes started in +//! the order they were asked for and a floor a client may not set the budget +//! below. +//! +//! # This is a working set and never a store +//! +//! Most of 0050's value is in that sentence, because the phrase reads as a cache +//! and is not one. 0009 hands a decoded image to the caller and never reads it +//! again, and 0006 caches artwork bytes and their declared dimensions rather +//! than pixels, so there is no decoded image anywhere in the core that somebody +//! is not waiting for. What is counted here is the pixel buffers of decodes that +//! are running plus those whose outcome has not yet reached the caller, and +//! nothing else. Read without that, the natural build is a cache of decoded +//! images, which is a second store with a second eviction policy for data 0006 +//! deliberately does not keep, and the memory it occupies is the memory this +//! budget exists to bound. +//! +//! # Nothing fails at the bound +//! +//! A decode that would take the total past the budget does not start; it waits +//! until enough has been handed over. There is no sixteenth kind in 0004 for +//! this and none is asked for: nothing has gone wrong, and a failure here would +//! be one whose occurrence depends on what other callers were doing, which is +//! the least reproducible thing a client can be handed. So the answer type here +//! is [`WhatTheAskDoes`] and not a `Result`. +//! +//! # What is here, and what is deliberately not +//! +//! What is here is the admission rule: the budget, the floor, the arithmetic +//! that turns declared dimensions into a buffer size, the running total, the +//! order waiting decodes are released in, and when a waiting decode has waited +//! long enough to be reported. +//! +//! WHAT IS NOT HERE IS THE DECODE. Nothing in this tree turns admitted bytes +//! into pixels, which [`super`] says of itself, so nothing here allocates a +//! buffer, runs on a lane or hands anything over. This module holds the rule +//! such a decoder would be admitted by, and the sizes it is told are the sizes a +//! decoder would allocate. +//! +//! WHAT IS ALSO NOT HERE IS THE LANE. 0050 declines to add a second bound on how +//! many decodes run at once, because 0009 already sizes the processing lane and +//! a second number for one question is how two answers get written down. Where +//! the lane comes from is what creating a core means, which is #115. The two +//! bounds interact rather than duplicate: this one also counts finished decodes +//! waiting to be handed over, which is the larger number on a client that +//! consumes slowly. +//! +//! WHAT IS ALSO NOT HERE IS THE REPORT. 0050 says the core reports how much is +//! held and how many decodes are waiting through 0100 after five seconds with a +//! decode waiting. [`DecodedBytesHeld::a_wait_worth_reporting`] answers whether +//! that moment has arrived; emitting the event is the facility in +//! [`crate::diagnostics`] and is not done here, so nothing in this tree reports +//! one. +//! +//! # Two figures in 0050 do not follow from its own arithmetic +//! +//! Both are recorded on #50 rather than repaired, because 0001 admits neither +//! correction as an edit to a landed record. They are named here because this +//! module is where an implementation written from those sentences would go +//! wrong. +//! +//! The record says the budget and the per-image bound are within four per cent +//! of each other. They are within 4.63 per cent of the budget and 4.86 per cent +//! of the bound, so five is the number true in both readings, and the rounding +//! goes the one way that makes the two bounds look closer than they are. +//! +//! The one that costs more to read wrongly: the record says that on a +//! four-processor television three maximal images cannot be decoded at once and +//! the THIRD waits. Two maximal images are 128000000 bytes against a budget of +//! 67108864, so it is the SECOND that waits and one maximal image fits with +//! nothing else beside it. [`DecodedBytesHeld`] is written to the arithmetic +//! rather than to that sentence, and +//! [`tests::one_maximal_image_fits_and_the_second_waits`] is what would redden if +//! somebody wrote the admission to the sentence instead. + +use core::time::Duration; + +use super::format::{DeclaredDimensions, PIXEL_COUNT_BOUND}; +use crate::clock::SteadyInstant; + +/// The bytes one pixel of a decoded image occupies. +/// +/// From 0050. It is the buffer the core allocated at the decoded dimensions, and +/// not the encoded length, which +/// [`super::format::ENCODED_LENGTH_BOUND`] bounds separately during the +/// transfer, and not what the client does with the image afterwards, which is +/// the client's memory from the moment it is handed over. +pub const BYTES_A_PIXEL: u64 = 4; + +/// The sixty four mebibytes the core holds at once unless a client sets +/// otherwise. +/// +/// From 0050, chosen rather than measured, and #65 is where a measured +/// replacement would come from. A poster on a tile wall is taken as three +/// hundred by four hundred and fifty pixels, which is 540000 bytes decoded, so +/// this holds about 124 of them and a wall of two hundred at 108000000 bytes +/// does not fit. That is the point of the number rather than an accident of it: +/// what is on a screen at any moment is a fraction of two hundred, #53 cancels a +/// tile that scrolled off, and a budget large enough for every tile somebody +/// scrolled past holds a library in pixels. +pub const THE_BUDGET_AT_CREATION: u64 = 64 * 1024 * 1024; + +/// The smallest budget a client may set. +/// +/// Derived from 0055 rather than chosen here, which is 0050's own construction: +/// it is [`PIXEL_COUNT_BOUND`] at [`BYTES_A_PIXEL`]. Below it, an image 0055 +/// admits could never be decoded, and the refusal a person would see would +/// depend on a memory setting rather than on the image, which is exactly the +/// client-dependent accept set 0055 exists against. +/// +/// A client wanting a smaller ceiling is asking for 0055's dimension bound to be +/// lowered. That is a change to 0055 with its own argument about what an +/// attacker can make the core allocate, and it is not a number a client sets. +pub const THE_FLOOR_A_CLIENT_MAY_SET: u64 = PIXEL_COUNT_BOUND * BYTES_A_PIXEL; + +/// How long a decode waits for room before the core says so. +/// +/// From 0050. Not as an error on any call, because nothing failed, and not as a +/// sentence, because 0004 and 0100 both fix that the core writes none. It is an +/// interval between two events inside one run, so 0102 puts it on the steady +/// clock. +pub const A_WAITING_DECODE_IS_REPORTED_AFTER: Duration = Duration::from_secs(5); + +/// Why a budget a client offered is not usable. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BudgetNotUsable { + /// Below [`THE_FLOOR_A_CLIENT_MAY_SET`]. + /// + /// It carries both numbers, because the client's own value is the thing it + /// has to change and a refusal that names neither sends somebody to read + /// this file. + BelowThePerImageBound { + /// What the client offered, in bytes. + offered: u64, + /// The floor it has to reach, in bytes. + floor: u64, + }, +} + +/// The bound on decoded bytes held at once. +/// +/// There is no ceiling on what a client may set it to, which is 0050's decision +/// rather than an omission here: a desktop with memory to spare is not a device +/// this record is protecting. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Budget { + bytes: u64, +} + +impl Budget { + /// The budget a core has where the client set none. + #[must_use] + pub const fn at_creation() -> Self { + Self { + bytes: THE_BUDGET_AT_CREATION, + } + } + + /// A budget a client set, refused below the floor. + /// + /// # Errors + /// + /// [`BudgetNotUsable::BelowThePerImageBound`] where the value is under + /// [`THE_FLOOR_A_CLIENT_MAY_SET`]. + pub const fn of(bytes: u64) -> Result { + if bytes < THE_FLOOR_A_CLIENT_MAY_SET { + return Err(BudgetNotUsable::BelowThePerImageBound { + offered: bytes, + floor: THE_FLOOR_A_CLIENT_MAY_SET, + }); + } + Ok(Self { bytes }) + } + + /// The bound, in bytes. + #[must_use] + pub const fn bytes(self) -> u64 { + self.bytes + } +} + +/// The pixel buffer one decode occupies. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct DecodedBytes { + bytes: u64, +} + +impl DecodedBytes { + /// The buffer a decode at these dimensions would allocate. + /// + /// `None` where the dimensions are outside the bounds 0055 fixes. That is + /// what makes the budget total rather than nearly total: every dimension + /// this answers for produces a buffer no larger than + /// [`THE_FLOOR_A_CLIENT_MAY_SET`], so no admissible image can be larger than + /// the smallest budget a client may set, and nothing can wait for room that + /// will never exist. + #[must_use] + pub const fn of(dimensions: DeclaredDimensions) -> Option { + if !dimensions.are_inside_their_bounds() { + return None; + } + Some(Self { + bytes: dimensions.pixel_count() * BYTES_A_PIXEL, + }) + } + + /// The buffer, in bytes. + #[must_use] + pub const fn bytes(self) -> u64 { + self.bytes + } +} + +/// What asking for a decode does under the budget. +/// +/// Thread safety, from 0009: a plain value, safe from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WhatTheAskDoes { + /// The buffer fits and the decode starts now. + StartsNow, + /// It waits for room, at the back of the queue. + /// + /// It is not a failure and there is no kind in 0004 for it. It carries its + /// position so a caller reporting through 0100 can say where it stands + /// without asking a second question. + WaitsForRoom { + /// How many decodes are waiting ahead of this one. + behind: usize, + }, +} + +/// The running total, and the queue of decodes waiting for room. +/// +/// Thread safety, from 0009: this is bookkeeping the core mutates, so it carries +/// no interior mutability and is `Send + Sync` as a plain value. Whoever holds it +/// serialises access to it, the way the cache bookkeeping is held. +#[derive(Debug, Clone)] +pub struct DecodedBytesHeld { + budget: Budget, + held: u64, + waiting: Vec, +} + +/// One decode waiting for room. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Waiting { + bytes: DecodedBytes, + since: SteadyInstant, +} + +impl DecodedBytesHeld { + /// Nothing decoded and nothing waiting, under this budget. + #[must_use] + pub const fn under(budget: Budget) -> Self { + Self { + budget, + held: 0, + waiting: Vec::new(), + } + } + + /// The bytes of pixel buffer the core holds right now. + #[must_use] + pub const fn held(&self) -> u64 { + self.held + } + + /// How many decodes are waiting for room. + #[must_use] + pub fn waiting(&self) -> usize { + self.waiting.len() + } + + /// The budget this is counted against. + #[must_use] + pub const fn budget(&self) -> Budget { + self.budget + } + + /// Asks for a decode of this size. + /// + /// A decode starts only where nothing is already waiting AND the buffer + /// fits. The first half is what keeps 0050's order: without it a small + /// decode asked for later would step past a large one that has been waiting, + /// which is the starvation the record refuses when it declines to order the + /// queue by size. + pub fn ask(&mut self, bytes: DecodedBytes, now: SteadyInstant) -> WhatTheAskDoes { + if self.waiting.is_empty() && self.fits(bytes) { + self.held += bytes.bytes(); + return WhatTheAskDoes::StartsNow; + } + self.waiting.push(Waiting { bytes, since: now }); + WhatTheAskDoes::WaitsForRoom { + behind: self.waiting.len() - 1, + } + } + + /// Releases a decode's buffer and starts whatever now fits, in order. + /// + /// One method for a completion handed to the caller and for a cancellation, + /// because 0050 gives them the same effect on this total: 0009 bounds a + /// cancelled decode to releasing its buffer at the end of the step it is + /// inside, and the room then goes to whatever is waiting. What differs + /// between them is what reaches the caller, which is not this module's. + /// + /// It returns the buffers that started, in the order they were asked for, so + /// a caller does not have to ask the queue a second question to learn what + /// it may now run. + pub fn released(&mut self, bytes: DecodedBytes) -> Vec { + self.held = self.held.saturating_sub(bytes.bytes()); + let mut started = Vec::new(); + while let Some(first) = self.waiting.first().copied() { + if !self.fits(first.bytes) { + break; + } + self.waiting.remove(0); + self.held += first.bytes.bytes(); + started.push(first.bytes); + } + started + } + + /// Takes a decode out of the queue before it ever started. + /// + /// #53 cancels a tile that scrolled off, and one that scrolled off while + /// waiting never allocated anything, so there is nothing to release. It + /// answers whether a waiting decode of that size was found, because a caller + /// withdrawing something the queue does not hold is a bookkeeping defect + /// rather than an ordinary outcome. + pub fn withdrawn_while_waiting(&mut self, bytes: DecodedBytes) -> bool { + if let Some(at) = self.waiting.iter().position(|held| held.bytes == bytes) { + self.waiting.remove(at); + return true; + } + false + } + + /// Whether a decode has waited long enough for the core to say so. + /// + /// The oldest wait is what decides it, so one long wait is reported even + /// where later asks keep arriving. `None` is nothing waiting, which is a + /// different answer from a wait that has not yet reached the interval. + #[must_use] + pub fn a_wait_worth_reporting(&self, now: SteadyInstant) -> Option { + let longest = self + .waiting + .iter() + .map(|held| now.interval_since(held.since)) + .max()?; + if longest >= A_WAITING_DECODE_IS_REPORTED_AFTER { + Some(longest) + } else { + None + } + } + + /// Whether this buffer fits in what is left of the budget. + const fn fits(&self, bytes: DecodedBytes) -> bool { + self.budget.bytes() - self.held >= bytes.bytes() + } +} + +#[cfg(test)] +mod tests { + use super::{ + A_WAITING_DECODE_IS_REPORTED_AFTER, BYTES_A_PIXEL, Budget, BudgetNotUsable, DecodedBytes, + DecodedBytesHeld, THE_BUDGET_AT_CREATION, THE_FLOOR_A_CLIENT_MAY_SET, WhatTheAskDoes, + }; + use crate::artwork::format::{AXIS_BOUND, DeclaredDimensions, PIXEL_COUNT_BOUND}; + use crate::clock::SteadyInstant; + use core::time::Duration; + + fn at(millis: u64) -> SteadyInstant { + SteadyInstant::from_nanos(millis * 1_000_000) + } + + /// The poster 0050 does its arithmetic on: what #49 asks the server for + /// rather than a full-resolution original. + fn a_poster() -> DecodedBytes { + DecodedBytes::of(DeclaredDimensions::of(300, 450)) + .expect("0055 admits three hundred by four hundred and fifty") + } + + /// An image at exactly the per-image bound 0055 fixes, which is what fixes + /// the floor here. Five thousand by three thousand two hundred is sixteen + /// million pixels with both axes well inside [`AXIS_BOUND`], so it is the + /// bound reached through the pixel count rather than through an axis. + fn a_maximal_image() -> DecodedBytes { + let dimensions = DeclaredDimensions::of(5_000, 3_200); + assert_eq!(dimensions.pixel_count(), PIXEL_COUNT_BOUND); + DecodedBytes::of(dimensions).expect("the largest image 0055 admits is admitted") + } + + /// Fills whatever room is left with posters, asking only where the next one + /// fits, so nothing reaches the queue. Every test below that needs a full + /// budget builds it this way rather than assuming a count. + fn fill_with_posters(held: &mut DecodedBytesHeld) -> usize { + let mut started = 0; + while held.held() + a_poster().bytes() <= held.budget().bytes() { + assert_eq!(held.ask(a_poster(), at(0)), WhatTheAskDoes::StartsNow); + started += 1; + } + started + } + + /// 0050's own worked arithmetic, which the record states and this reproduces + /// rather than restating. A poster is 540000 bytes and about 124 of them fit. + #[test] + fn the_arithmetic_0050_states_reproduces() { + assert_eq!(a_poster().bytes(), 540_000); + assert_eq!(THE_BUDGET_AT_CREATION, 67_108_864); + assert_eq!(THE_BUDGET_AT_CREATION / a_poster().bytes(), 124); + } + + /// The line 0050 calls the point of the number rather than an accident of + /// it: a wall of two hundred tiles does not fit, and is not supposed to. + #[test] + fn a_wall_of_two_hundred_tiles_does_not_fit() { + assert_eq!(200 * a_poster().bytes(), 108_000_000); + assert!(200 * a_poster().bytes() > THE_BUDGET_AT_CREATION); + } + + /// The figure 0050 states the other way round, recorded on #50 and written + /// here to the arithmetic rather than to the sentence. Two maximal images are + /// 128000000 bytes against a budget of 67108864, so the SECOND waits. + #[test] + fn one_maximal_image_fits_and_the_second_waits() { + let maximal = a_maximal_image(); + assert_eq!(maximal.bytes(), PIXEL_COUNT_BOUND * BYTES_A_PIXEL); + assert_eq!(maximal.bytes(), 64_000_000); + assert!(2 * maximal.bytes() > THE_BUDGET_AT_CREATION); + + let mut held = DecodedBytesHeld::under(Budget::at_creation()); + assert_eq!(held.ask(maximal, at(0)), WhatTheAskDoes::StartsNow); + assert_eq!( + held.ask(maximal, at(1)), + WhatTheAskDoes::WaitsForRoom { behind: 0 } + ); + } + + /// The floor is derived from 0055 rather than chosen, which is 0050's own + /// construction, and it is what makes the budget total: no image 0055 admits + /// can be larger than the smallest budget a client may set. + #[test] + fn no_admissible_image_is_larger_than_the_smallest_budget_a_client_may_set() { + assert_eq!(THE_FLOOR_A_CLIENT_MAY_SET, 64_000_000); + assert!(a_maximal_image().bytes() <= THE_FLOOR_A_CLIENT_MAY_SET); + let smallest = Budget::of(THE_FLOOR_A_CLIENT_MAY_SET).expect("the floor is admitted"); + let mut held = DecodedBytesHeld::under(smallest); + assert_eq!( + held.ask(a_maximal_image(), at(0)), + WhatTheAskDoes::StartsNow + ); + } + + #[test] + fn a_budget_below_the_per_image_bound_is_refused_with_both_numbers() { + assert_eq!( + Budget::of(THE_FLOOR_A_CLIENT_MAY_SET - 1), + Err(BudgetNotUsable::BelowThePerImageBound { + offered: THE_FLOOR_A_CLIENT_MAY_SET - 1, + floor: THE_FLOOR_A_CLIENT_MAY_SET, + }) + ); + assert!(Budget::of(THE_FLOOR_A_CLIENT_MAY_SET).is_ok()); + } + + /// 0050 sets no ceiling, and that is a decision rather than an omission. + #[test] + fn there_is_no_ceiling_on_what_a_client_may_set() { + assert!(Budget::of(u64::MAX).is_ok()); + } + + /// Dimensions outside 0055's bounds produce no buffer size at all, which is + /// what stops something waiting for room that will never exist. + #[test] + fn dimensions_0055_refuses_have_no_buffer_size_here() { + assert!(DecodedBytes::of(DeclaredDimensions::of(AXIS_BOUND + 1, 1)).is_none()); + assert!(DecodedBytes::of(DeclaredDimensions::of(AXIS_BOUND, AXIS_BOUND)).is_none()); + assert!(DecodedBytes::of(DeclaredDimensions::of(1, 1)).is_some()); + } + + /// Nothing fails at the bound. The answer is a wait, and 0050 argues for that + /// rather than for a sixteenth kind in 0004. + #[test] + fn a_decode_at_the_bound_waits_rather_than_failing() { + let mut held = DecodedBytesHeld::under(Budget::at_creation()); + let mut admitted = 0; + while held.ask(a_poster(), at(0)) == WhatTheAskDoes::StartsNow { + admitted += 1; + } + assert_eq!(admitted, 124); + assert_eq!(held.waiting(), 1); + assert!(held.held() + a_poster().bytes() > Budget::at_creation().bytes()); + } + + /// Waiting decodes start in the order they were asked for, and that is + /// visible only where the two answers differ: a small decode that WOULD fit + /// at this instant still waits behind a large one that does not. Ordering the + /// queue by size is what 0050 refuses when it says the large ones would + /// starve, and the assertion in the middle is what makes this a test of the + /// order rather than of the arithmetic. + #[test] + fn a_later_small_ask_does_not_step_past_an_earlier_large_one() { + let mut held = DecodedBytesHeld::under(Budget::at_creation()); + let large = a_maximal_image(); + let small = a_poster(); + assert_eq!(held.ask(large, at(0)), WhatTheAskDoes::StartsNow); + assert_eq!( + held.ask(large, at(1)), + WhatTheAskDoes::WaitsForRoom { behind: 0 } + ); + + assert!( + held.budget().bytes() - held.held() >= small.bytes(), + "the poster fits in what is left, so what follows is the order and not the room" + ); + assert_eq!( + held.ask(small, at(2)), + WhatTheAskDoes::WaitsForRoom { behind: 1 } + ); + assert_eq!(held.held(), large.bytes()); + + // Both start when the room arrives, and the large one first. + let started = held.released(large); + assert_eq!(started, vec![large, small]); + assert_eq!(held.waiting(), 0); + } + + /// Releasing room starts everything that now fits, in one answer, so a caller + /// does not have to ask the queue a second question. + #[test] + fn releasing_room_starts_everything_that_now_fits_in_order() { + let mut held = DecodedBytesHeld::under(Budget::at_creation()); + let large = a_maximal_image(); + let small = a_poster(); + assert_eq!(held.ask(large, at(0)), WhatTheAskDoes::StartsNow); + let filled = fill_with_posters(&mut held); + for asked in 0..3 { + assert_eq!( + held.ask(small, at(asked + 1)), + WhatTheAskDoes::WaitsForRoom { + behind: usize::try_from(asked).expect("three fits") + } + ); + } + let started = held.released(large); + assert_eq!(started, vec![small, small, small]); + assert_eq!(held.waiting(), 0); + assert_eq!( + held.held(), + u64::try_from(filled + 3).expect("eight fits") * small.bytes() + ); + } + + /// A cancelled decode releases its buffer and the room goes to whatever is + /// waiting, which is 0009's bound on cancellation rather than a new one here. + /// It is the same method as a completion because 0050 gives them the same + /// effect on this total. + #[test] + fn a_cancelled_decode_gives_its_room_to_whatever_was_waiting() { + let mut held = DecodedBytesHeld::under(Budget::at_creation()); + let large = a_maximal_image(); + assert_eq!(held.ask(large, at(0)), WhatTheAskDoes::StartsNow); + assert_eq!( + held.ask(large, at(1)), + WhatTheAskDoes::WaitsForRoom { behind: 0 } + ); + assert_eq!(held.released(large), vec![large]); + assert_eq!(held.held(), large.bytes()); + assert_eq!(held.waiting(), 0); + } + + /// A tile that scrolled off while waiting never allocated anything, so there + /// is nothing to release and it simply leaves the queue. + #[test] + fn a_decode_withdrawn_before_it_started_leaves_the_queue_and_releases_nothing() { + let mut held = DecodedBytesHeld::under(Budget::at_creation()); + let large = a_maximal_image(); + let small = a_poster(); + assert_eq!(held.ask(large, at(0)), WhatTheAskDoes::StartsNow); + let filled = fill_with_posters(&mut held); + let before = held.held(); + + assert_eq!( + held.ask(small, at(1)), + WhatTheAskDoes::WaitsForRoom { behind: 0 } + ); + assert!(held.withdrawn_while_waiting(small)); + assert_eq!(held.waiting(), 0); + assert_eq!(held.held(), before); + assert_eq!( + before, + large.bytes() + u64::try_from(filled).expect("five fits") * small.bytes() + ); + assert!(!held.withdrawn_while_waiting(small)); + } + + /// After five seconds with a decode waiting, the core says how long. Nothing + /// waiting is a different answer from a wait that has not reached the + /// interval, and the OLDEST wait decides, so later asks cannot hide one. + #[test] + fn a_wait_is_worth_reporting_only_after_the_interval() { + let mut held = DecodedBytesHeld::under(Budget::at_creation()); + let large = a_maximal_image(); + assert_eq!(held.ask(large, at(0)), WhatTheAskDoes::StartsNow); + assert_eq!(held.a_wait_worth_reporting(at(10_000)), None); + + assert_eq!( + held.ask(large, at(1_000)), + WhatTheAskDoes::WaitsForRoom { behind: 0 } + ); + assert_eq!(held.a_wait_worth_reporting(at(5_999)), None); + assert_eq!( + held.a_wait_worth_reporting(at(6_000)), + Some(A_WAITING_DECODE_IS_REPORTED_AFTER) + ); + + assert_eq!( + held.ask(large, at(6_000)), + WhatTheAskDoes::WaitsForRoom { behind: 1 } + ); + assert_eq!( + held.a_wait_worth_reporting(at(6_500)), + Some(Duration::from_millis(5_500)) + ); + } + + /// The client's own backlog, which 0050 says is correct rather than a defect: + /// a client that consumes none of its completions stalls only its own further + /// decodes, and the core cannot tell it apart from one consuming slowly. + #[test] + fn a_client_that_consumes_nothing_stalls_only_itself() { + let mut held = DecodedBytesHeld::under(Budget::at_creation()); + for _ in 0..124 { + assert_eq!(held.ask(a_poster(), at(0)), WhatTheAskDoes::StartsNow); + } + for asked in 0..26 { + assert_eq!( + held.ask(a_poster(), at(0)), + WhatTheAskDoes::WaitsForRoom { behind: asked } + ); + } + assert_eq!(held.waiting(), 26); + assert_eq!(held.held(), 124 * a_poster().bytes()); + assert!(held.held() <= Budget::at_creation().bytes()); + + let started = held.released(a_poster()); + assert_eq!(started, vec![a_poster()]); + assert_eq!(held.waiting(), 25); + } +} diff --git a/src/artwork/mod.rs b/src/artwork/mod.rs index 5cb5a4b..0f8e1a0 100644 --- a/src/artwork/mod.rs +++ b/src/artwork/mod.rs @@ -28,14 +28,20 @@ //! a client is told, and which callers are sharing one entry's fetch so that the //! last withdrawal is the one that abandons it. //! -//! What is absent is the decoder itself. [`DecodedImage`] is still a name, the -//! decode and its memory budget are #50, and nothing in this tree turns admitted -//! bytes into pixels. What is also absent is the fetch: [`address`] builds the +//! Everything 0050 puts around the decode, in [`budget`]: how many decoded +//! bytes the core holds at once, what a buffer costs at four bytes a pixel, the +//! floor 0055 fixes on what a client may set, and the order decodes waiting for +//! room are started in. +//! +//! What is absent is the decoder itself. [`DecodedImage`] is still a name, +//! nothing in this tree turns admitted bytes into pixels, and [`budget`] holds +//! the rule such a decoder would be admitted by rather than the decode. What is also absent is the fetch: [`address`] builds the //! address and derives the key, and the transport that would go and get it is //! #27. pub mod address; pub mod announced; +pub mod budget; pub mod format; pub mod presence; @@ -46,9 +52,10 @@ pub mod presence; /// them again. /// /// THIS PARAGRAPH SENT A READER TO #55 FOR WHICH FORMATS ARE DECODED AT ALL AND -/// THAT HALF IS NOW IN [`format`]. What is still absent here is the decode: the -/// bound on decoded bytes held at once is 0050 and #50, and it is a different -/// quantity from the per-image bound [`format`] enforces. +/// THAT HALF IS NOW IN [`format`]. IT ALSO SENT ONE TO #50 FOR THE BOUND ON +/// DECODED BYTES HELD AT ONCE, AND THAT HALF IS NOW IN [`budget`], which is a +/// different quantity from the per-image bound [`format`] enforces. What is +/// still absent here is the decode itself. #[derive(Debug)] pub struct DecodedImage { _private: (), diff --git a/src/lib.rs b/src/lib.rs index b82bd97..cd09494 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -189,6 +189,11 @@ const _: () = { any_thread::(); any_thread::(); any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); any_thread::(); any_thread::(); any_thread::(); diff --git a/tests/thread_statements.rs b/tests/thread_statements.rs index 04a7ed2..614afcb 100644 --- a/tests/thread_statements.rs +++ b/tests/thread_statements.rs @@ -27,6 +27,9 @@ use flowfin_core::artwork::address::{ use flowfin_core::artwork::announced::{ AnnouncedWindow, SharedFetches, WhatTheAnnouncementDid, WhatTheHoldDid, WhatTheWithdrawalDid, }; +use flowfin_core::artwork::budget::{ + Budget, BudgetNotUsable, DecodedBytes, DecodedBytesHeld, WhatTheAskDoes, +}; use flowfin_core::artwork::format::{Accepted, Admitted, DeclaredDimensions, Refused}; use flowfin_core::artwork::presence::WhatTheItemHas; use flowfin_core::cache::bound::{CacheBounds, Tier, TieredCache}; @@ -533,3 +536,28 @@ fn the_wait_before_the_next_attempt_is_safe_from_any_thread() { fn why_a_call_stopped_rather_than_attempting_again_is_safe_from_any_thread() { const _: () = any_thread::(); } + +#[test] +fn the_decoded_bytes_budget_is_safe_from_any_thread() { + const _: () = any_thread::(); +} + +#[test] +fn a_budget_a_client_may_not_set_is_safe_from_any_thread() { + const _: () = any_thread::(); +} + +#[test] +fn the_buffer_one_decode_occupies_is_safe_from_any_thread() { + const _: () = any_thread::(); +} + +#[test] +fn what_asking_for_a_decode_does_is_safe_from_any_thread() { + const _: () = any_thread::(); +} + +#[test] +fn the_decoded_bytes_bookkeeping_is_safe_from_any_thread() { + const _: () = any_thread::(); +} From 2c919acb4a2af99ff8795298cb472a88337fb995 Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:27:05 +0200 Subject: [PATCH 2/2] Say what a client withdrew rather than what a screen did `no-view-vocabulary` refused four lines of the module that just landed on this branch, and the rule is right rather than pedantic: 0003 places a scroll offset outside the core by name and says a core that learns any of those words has learned the shape of one client's screen. The sentences meant a tile a client no longer wants, which is 0053's withdrawal and a thing the core does know about, so they now say that. ::error::no-view-vocabulary: src/artwork/budget.rs:104 ::error::no-view-vocabulary: src/artwork/budget.rs:105 ::error::no-view-vocabulary: src/artwork/budget.rs:336 ::error::no-view-vocabulary: src/artwork/budget.rs:585 ::error::4 line(s) broke an invariant above. Refs #50. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- src/artwork/budget.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/artwork/budget.rs b/src/artwork/budget.rs index 3f13e7a..0c80fcd 100644 --- a/src/artwork/budget.rs +++ b/src/artwork/budget.rs @@ -100,9 +100,9 @@ pub const BYTES_A_PIXEL: u64 = 4; /// hundred by four hundred and fifty pixels, which is 540000 bytes decoded, so /// this holds about 124 of them and a wall of two hundred at 108000000 bytes /// does not fit. That is the point of the number rather than an accident of it: -/// what is on a screen at any moment is a fraction of two hundred, #53 cancels a -/// tile that scrolled off, and a budget large enough for every tile somebody -/// scrolled past holds a library in pixels. +/// what a person is looking at is a fraction of two hundred, #53 withdraws a tile +/// a client no longer wants, and a budget large enough for every tile a client +/// ever announced holds a library in pixels. pub const THE_BUDGET_AT_CREATION: u64 = 64 * 1024 * 1024; /// The smallest budget a client may set. @@ -333,8 +333,8 @@ impl DecodedBytesHeld { /// Takes a decode out of the queue before it ever started. /// - /// #53 cancels a tile that scrolled off, and one that scrolled off while - /// waiting never allocated anything, so there is nothing to release. It + /// #53 withdraws a tile a client no longer wants, and one withdrawn while it + /// was waiting never allocated anything, so there is nothing to release. It /// answers whether a waiting decode of that size was found, because a caller /// withdrawing something the queue does not hold is a bookkeeping defect /// rather than an ordinary outcome. @@ -582,8 +582,8 @@ mod tests { assert_eq!(held.waiting(), 0); } - /// A tile that scrolled off while waiting never allocated anything, so there - /// is nothing to release and it simply leaves the queue. + /// A tile withdrawn while it was still waiting never allocated anything, so + /// there is nothing to release and it simply leaves the queue. #[test] fn a_decode_withdrawn_before_it_started_leaves_the_queue_and_releases_nothing() { let mut held = DecodedBytesHeld::under(Budget::at_creation());