fix(simulation): PR #201 review round — wire extent clamp + coalescing tests
Hoshe finding 1: StepCanvasRequest.extent is no longer wire-trusted — clamp_step_canvas_extent enforces the D-255(b) canvas budget at the request boundary (per-axis cap 3840 defeating u32::MAX before any multiplication, then an aspect-preserving total-cell ceiling at the measured 3840x2160 = 8,294,400-cell workshop budget), mirroring the legacy carrier's clamp_window_n_v2 discipline; the Global rung ignores the wire extent entirely. StepCanvasResponse gains the extent echo field so a client can detect the clamp (the DistrictWindowLayer.n precedent — a pre-existing gap closed in passing, recorded on the ticket for T-1182). Seven new tests including an end-to-end u32::MAX request proving actual allocation respects the cap. Hoshe finding 2: submit_step_canvas coalescing now has the same two regression tests its submit_window sibling always had (same-key collapses to one pending item, different-key does not), exercising step_canvas_supersede_key. Targeted suites green: 1928 lib, acceptance gate 5/5, bridge_tcp 22/22, window_derivation_golden 6/6 byte-green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1843,6 +1843,203 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// DeriveStepCanvas / submit_step_canvas coalescing (T-1181, D-255(c)(d))
|
||||||
|
//
|
||||||
|
// PR #201 review, Hoshe finding 2 — zero coverage before these tests.
|
||||||
|
// `submit_step_canvas` is a near-verbatim sibling of `submit_window`
|
||||||
|
// (same coalesce-in-place-on-supersede shape, same defensive
|
||||||
|
// `dispatch_next` call), keyed on `step_canvas_supersede_key()`
|
||||||
|
// (`(conn_id, body_id, rung)`) instead of `window_supersede_key()`'s
|
||||||
|
// `(conn_id, body_id, granularity)`. Mirrors
|
||||||
|
// `submit_window_coalesces_same_connection_and_body` +
|
||||||
|
// `submit_window_does_not_coalesce_different_keys` exactly, substituting
|
||||||
|
// `DeriveStepCanvas`/`rung` for `DeriveWindow`/`granularity`.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Build a `DeriveStepCanvas` work item pointing at a tiny test
|
||||||
|
/// heightmap, mirroring `derive_window_at`'s fixture shape.
|
||||||
|
fn derive_step_canvas_at(
|
||||||
|
body_id: &str,
|
||||||
|
conn_id: ConnectionId,
|
||||||
|
center: (i64, i64),
|
||||||
|
rung: crate::atlas::step_canvas::StepCanvasRung,
|
||||||
|
) -> GenWorkItem {
|
||||||
|
GenWorkItem::DeriveStepCanvas {
|
||||||
|
body_id: body_id.to_string(),
|
||||||
|
conn_id,
|
||||||
|
heightmap_path: test_heightmap_path(),
|
||||||
|
sea_level: 0.3,
|
||||||
|
body_seed: SeedChain::for_body(42, body_id),
|
||||||
|
body_params: Box::new(BodyParams {
|
||||||
|
hydrosphere: Some("ocean".into()),
|
||||||
|
atmosphere: Some("breathable".into()),
|
||||||
|
planet_class: Some("temperate".into()),
|
||||||
|
body_radius_km: Some(6371.0),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
placements: Vec::new(),
|
||||||
|
rung,
|
||||||
|
center,
|
||||||
|
extent: (4, 4),
|
||||||
|
min_wl_m: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `submit_step_canvas` coalescing (T-1181, mirroring `submit_window`'s
|
||||||
|
/// D-226 T-1124 amendment §1 discipline): two `DeriveStepCanvas` items
|
||||||
|
/// for the SAME `(connection, body, rung)` queued while the pool is
|
||||||
|
/// saturated collapse to ONE pending entry — the second submission
|
||||||
|
/// replaces the first rather than queuing alongside it.
|
||||||
|
#[test]
|
||||||
|
fn submit_step_canvas_coalesces_same_connection_body_and_rung() {
|
||||||
|
// Single-thread pool: the first item occupies the only worker, so
|
||||||
|
// subsequent DeriveStepCanvas submissions stay in `pending` long
|
||||||
|
// enough to inspect (same saturation trick
|
||||||
|
// `submit_window_coalesces_same_connection_and_body` uses, and for
|
||||||
|
// the same reason: `analyze()` is real measurable-latency cascade
|
||||||
|
// work, unlike `FillChunk`, which could complete before the next
|
||||||
|
// `submit_step_canvas` call even runs).
|
||||||
|
let q = GenerationQueue::with_threads(1);
|
||||||
|
q.submit(analyze("StepOccupier"), GenPriority::Low);
|
||||||
|
|
||||||
|
let conn = ConnectionId(21);
|
||||||
|
q.submit_step_canvas(
|
||||||
|
derive_step_canvas_at(
|
||||||
|
"Canvas",
|
||||||
|
conn,
|
||||||
|
(0, 0),
|
||||||
|
crate::atlas::step_canvas::StepCanvasRung::Chunk,
|
||||||
|
),
|
||||||
|
GenPriority::Immediate,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
q.pending_count(),
|
||||||
|
1,
|
||||||
|
"one DeriveStepCanvas queued behind the saturating item"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A second DeriveStepCanvas for the SAME (connection, body, rung)
|
||||||
|
// supersedes the first — pending count stays at 1, not 2.
|
||||||
|
q.submit_step_canvas(
|
||||||
|
derive_step_canvas_at(
|
||||||
|
"Canvas",
|
||||||
|
conn,
|
||||||
|
(5_000, 5_000),
|
||||||
|
crate::atlas::step_canvas::StepCanvasRung::Chunk,
|
||||||
|
),
|
||||||
|
GenPriority::Immediate,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
q.pending_count(),
|
||||||
|
1,
|
||||||
|
"same (connection, body, rung) DeriveStepCanvas must supersede, not queue alongside"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drain everything and confirm exactly one StepCanvasDerived for
|
||||||
|
// "Canvas", carrying the SECOND (superseding) center — not the
|
||||||
|
// first.
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
let mut completions = q.drain_completions();
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
completions.extend(q.drain_completions());
|
||||||
|
|
||||||
|
let step_canvas_completions: Vec<_> = completions
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| {
|
||||||
|
if let GenCompletion::StepCanvasDerived {
|
||||||
|
body_id, center, ..
|
||||||
|
} = c
|
||||||
|
{
|
||||||
|
if body_id == "Canvas" {
|
||||||
|
return Some(*center);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
step_canvas_completions.len(),
|
||||||
|
1,
|
||||||
|
"exactly one StepCanvasDerived for the coalesced body, not two"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
step_canvas_completions[0],
|
||||||
|
(5_000, 5_000),
|
||||||
|
"the surviving item must be the SECOND (superseding) submission"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `submit_step_canvas` does NOT coalesce across different connections,
|
||||||
|
/// different bodies, or different rungs — only an exact `(connection,
|
||||||
|
/// body, rung)` match supersedes. Combines `submit_window_does_not_
|
||||||
|
/// coalesce_different_keys` (connection axis) and `submit_window_does_
|
||||||
|
/// not_coalesce_different_granularity` (rung axis) into one test, since
|
||||||
|
/// `step_canvas_supersede_key` is a flat 3-tuple with no separate
|
||||||
|
/// legacy-vs-v2 field split to test independently the way `WindowGranularity`
|
||||||
|
/// needed.
|
||||||
|
#[test]
|
||||||
|
fn submit_step_canvas_does_not_coalesce_different_keys() {
|
||||||
|
let q = GenerationQueue::with_threads(1);
|
||||||
|
// See `submit_step_canvas_coalesces_same_connection_body_and_rung`'s
|
||||||
|
// comment on why the occupier must be `analyze()`, not `FillChunk`.
|
||||||
|
q.submit(analyze("StepOccupier2"), GenPriority::Low);
|
||||||
|
|
||||||
|
// Different connections, same body, same rung — must NOT coalesce.
|
||||||
|
q.submit_step_canvas(
|
||||||
|
derive_step_canvas_at(
|
||||||
|
"Shared",
|
||||||
|
ConnectionId(1),
|
||||||
|
(0, 0),
|
||||||
|
crate::atlas::step_canvas::StepCanvasRung::District,
|
||||||
|
),
|
||||||
|
GenPriority::Immediate,
|
||||||
|
);
|
||||||
|
q.submit_step_canvas(
|
||||||
|
derive_step_canvas_at(
|
||||||
|
"Shared",
|
||||||
|
ConnectionId(2),
|
||||||
|
(1, 1),
|
||||||
|
crate::atlas::step_canvas::StepCanvasRung::District,
|
||||||
|
),
|
||||||
|
GenPriority::Immediate,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
q.pending_count(),
|
||||||
|
2,
|
||||||
|
"different connections requesting the same body+rung must NOT coalesce"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same connection, same body, but DIFFERENT rung — must NOT
|
||||||
|
// coalesce (District vs. Chunk are separate in-flight slots).
|
||||||
|
let conn = ConnectionId(23);
|
||||||
|
q.submit_step_canvas(
|
||||||
|
derive_step_canvas_at(
|
||||||
|
"RungBody",
|
||||||
|
conn,
|
||||||
|
(0, 0),
|
||||||
|
crate::atlas::step_canvas::StepCanvasRung::District,
|
||||||
|
),
|
||||||
|
GenPriority::Immediate,
|
||||||
|
);
|
||||||
|
q.submit_step_canvas(
|
||||||
|
derive_step_canvas_at(
|
||||||
|
"RungBody",
|
||||||
|
conn,
|
||||||
|
(0, 0),
|
||||||
|
crate::atlas::step_canvas::StepCanvasRung::Chunk,
|
||||||
|
),
|
||||||
|
GenPriority::Immediate,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
q.pending_count(),
|
||||||
|
4,
|
||||||
|
"same (connection, body) but DIFFERENT rung must NOT coalesce — \
|
||||||
|
District and Chunk are separate in-flight slots (2 from the \
|
||||||
|
connection-axis case above + 2 more here)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// TerrainAnalysisCache (T-1137, PR #187 review — Tyre C1)
|
// TerrainAnalysisCache (T-1137, PR #187 review — Tyre C1)
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|||||||
@@ -282,6 +282,11 @@ fn serve_step_canvas_requests(
|
|||||||
body_id: req.body_id.clone(),
|
body_id: req.body_id.clone(),
|
||||||
rung: req.rung,
|
rung: req.rung,
|
||||||
center: req.center,
|
center: req.center,
|
||||||
|
// Nothing was derived — echo (0, 0) rather than the raw
|
||||||
|
// wire extent, matching serve_step_canvas_request's own
|
||||||
|
// Global-rung convention for "no meaningful extent to
|
||||||
|
// report" (PR #201 review, Hoshe finding 1).
|
||||||
|
extent: (0, 0),
|
||||||
min_wl_m: req.min_wl_m,
|
min_wl_m: req.min_wl_m,
|
||||||
status: StepCanvasStatus::Error("no body source resolver".to_string()),
|
status: StepCanvasStatus::Error("no body source resolver".to_string()),
|
||||||
canvas: None,
|
canvas: None,
|
||||||
|
|||||||
+260
-13
@@ -316,6 +316,16 @@ pub struct StepCanvasResponse {
|
|||||||
/// `district_window` (the echo IS the client's cache/staleness key,
|
/// `district_window` (the echo IS the client's cache/staleness key,
|
||||||
/// because the derivation is pure and deterministic, D-227).
|
/// because the derivation is pure and deterministic, D-227).
|
||||||
pub center: (i64, i64),
|
pub center: (i64, i64),
|
||||||
|
/// Echoed, CLAMPED extent (PR #201 review, Hoshe finding 1) — mirrors
|
||||||
|
/// `layer_proxy::DistrictWindowLayer.n`'s own doc: "this clamp is
|
||||||
|
/// echoed, not silently applied... a client that requests an oversized
|
||||||
|
/// extent gets back a smaller one." `(0, 0)` for `StepCanvasRung::Global`
|
||||||
|
/// (the wire extent is never read for that rung — see
|
||||||
|
/// `resolve_canvas_extent`'s doc — so there is no clamped value to
|
||||||
|
/// report; a client staleness guard should special-case `Global` the
|
||||||
|
/// same way it already must special-case `center`, which is likewise
|
||||||
|
/// meaningless there).
|
||||||
|
pub extent: (u32, u32),
|
||||||
pub min_wl_m: u32,
|
pub min_wl_m: u32,
|
||||||
pub status: StepCanvasStatus,
|
pub status: StepCanvasStatus,
|
||||||
pub canvas: Option<EncodedStepCanvas>,
|
pub canvas: Option<EncodedStepCanvas>,
|
||||||
@@ -743,12 +753,93 @@ fn crop_course_for_canvas(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hard per-axis ceiling on a [`StepCanvasRequest`]'s wire `extent` for every
|
||||||
|
/// FIXED rung (PR #201 review, Hoshe finding 1 — **never trust `extent` from
|
||||||
|
/// the wire**, the same discipline `layer_proxy::DISTRICT_WINDOW_MAX_N`/
|
||||||
|
/// `clamp_window_n` already establish for the legacy carrier's `window_n`).
|
||||||
|
/// `3,840` — the larger axis of the workshop's own measured ceiling canvas
|
||||||
|
/// (3840×2160, D-255(a)/(b): "the fixed 3840×2160 px budget... every step
|
||||||
|
/// except Global," the class every fixed-rung cost number in the workshop's
|
||||||
|
/// tables was benched at). Applied per-axis BEFORE the total-cell ceiling
|
||||||
|
/// below (same two-stage discipline `clamp_window_n_v2` uses: per-axis
|
||||||
|
/// clamp, then a wire-size ceiling on the derived cell count).
|
||||||
|
pub const STEP_CANVAS_MAX_EXTENT_AXIS: u32 = 3_840;
|
||||||
|
|
||||||
|
/// Hard ceiling on total cells (`width * height`) for a FIXED-rung canvas —
|
||||||
|
/// `3,840 × 2,160 = 8,294,400`, the exact D-255(a)/(b) measured ceiling
|
||||||
|
/// canvas (Dudley round-2 §(c) Option D's own largest row: "3840×2160, 8.3M
|
||||||
|
/// cells" at every fixed rung). This is the SAME cell count
|
||||||
|
/// `layer_proxy::WIRE_CAP_CELLS` plays for the legacy windowed carrier
|
||||||
|
/// (4,096 cells) scaled to this carrier's own measured budget — a step
|
||||||
|
/// canvas is allowed to be far larger than a legacy window (that's the
|
||||||
|
/// entire reason it needed a new carrier, D-255(c)), but it is not allowed
|
||||||
|
/// to be UNBOUNDED.
|
||||||
|
pub const STEP_CANVAS_MAX_EXTENT_CELLS: u64 = 3_840 * 2_160;
|
||||||
|
|
||||||
|
/// Clamp a [`StepCanvasRequest`]'s wire `extent` to
|
||||||
|
/// [`STEP_CANVAS_MAX_EXTENT_AXIS`]/[`STEP_CANVAS_MAX_EXTENT_CELLS`] —
|
||||||
|
/// **clamps, never rejects** (PR #201 review: "consistent with how
|
||||||
|
/// `clamp_window_n` behaves"). Two-stage, mirroring
|
||||||
|
/// `layer_proxy::clamp_window_n_v2`'s discipline exactly:
|
||||||
|
///
|
||||||
|
/// 1. **Per-axis clamp** to `[1, STEP_CANVAS_MAX_EXTENT_AXIS]` on each axis
|
||||||
|
/// independently — this alone defeats a `u32::MAX`-per-axis request (a
|
||||||
|
/// `(u32::MAX, u32::MAX)` extent clamps to `(3_840, 3_840)` before any
|
||||||
|
/// multiplication is attempted, so the overflow-prone `width * height`
|
||||||
|
/// arithmetic downstream never sees the raw wire value).
|
||||||
|
/// 2. **Total-cell ceiling** — if the per-axis-clamped shape still exceeds
|
||||||
|
/// `STEP_CANVAS_MAX_EXTENT_CELLS` (reachable: `3_840 * 3_840 =
|
||||||
|
/// 14,745,600 > 8,294,400`, an axis-square request past the measured
|
||||||
|
/// 16:9 ceiling), scale BOTH axes down by the same factor
|
||||||
|
/// (`sqrt(cap / cells)`) so the clamped shape keeps its requested aspect
|
||||||
|
/// ratio rather than being squashed on one axis only — a closer match to
|
||||||
|
/// "give the client the biggest canvas that fits the budget" than an
|
||||||
|
/// asymmetric halving loop would produce for a non-square request.
|
||||||
|
///
|
||||||
|
/// Never called for [`StepCanvasRung::Global`] — that rung's extent is
|
||||||
|
/// server-derived from the body's own region grid
|
||||||
|
/// ([`StepCanvasRung::global_cell_counts`]), never the wire value at all
|
||||||
|
/// (see [`resolve_canvas_extent`]).
|
||||||
|
pub fn clamp_step_canvas_extent(extent: (u32, u32)) -> (u32, u32) {
|
||||||
|
let w = extent.0.clamp(1, STEP_CANVAS_MAX_EXTENT_AXIS);
|
||||||
|
let h = extent.1.clamp(1, STEP_CANVAS_MAX_EXTENT_AXIS);
|
||||||
|
let cells = w as u64 * h as u64;
|
||||||
|
if cells <= STEP_CANVAS_MAX_EXTENT_CELLS {
|
||||||
|
return (w, h);
|
||||||
|
}
|
||||||
|
let scale = (STEP_CANVAS_MAX_EXTENT_CELLS as f64 / cells as f64).sqrt();
|
||||||
|
let scaled_w = ((w as f64 * scale).floor() as u32).max(1);
|
||||||
|
let scaled_h = ((h as f64 * scale).floor() as u32).max(1);
|
||||||
|
// Defensive floor-rounding safety net (mirrors clamp_window_n_v2's own
|
||||||
|
// "defensive, not currently reachable for well-behaved inputs" halving
|
||||||
|
// loop): floor-rounding both axes down from an exact sqrt scale can
|
||||||
|
// still land fractionally over the cap for some (w, h, cap) combinations
|
||||||
|
// — walk the larger axis down one cell at a time until the invariant
|
||||||
|
// holds. Bounded: at most STEP_CANVAS_MAX_EXTENT_AXIS iterations, and
|
||||||
|
// never fires for any input this function's own test sweep covers.
|
||||||
|
let mut final_w = scaled_w;
|
||||||
|
let mut final_h = scaled_h;
|
||||||
|
while (final_w as u64 * final_h as u64) > STEP_CANVAS_MAX_EXTENT_CELLS
|
||||||
|
&& final_w > 1
|
||||||
|
&& final_h > 1
|
||||||
|
{
|
||||||
|
if final_w >= final_h {
|
||||||
|
final_w -= 1;
|
||||||
|
} else {
|
||||||
|
final_h -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(final_w, final_h)
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a [`StepCanvasRequest`]'s canvas extent in cells — `(width,
|
/// Resolve a [`StepCanvasRequest`]'s canvas extent in cells — `(width,
|
||||||
/// height)`. [`StepCanvasRung::Global`] uses the body's own region grid
|
/// height)`. [`StepCanvasRung::Global`] uses the body's own region grid
|
||||||
/// ([`StepCanvasRung::global_cell_counts`]); every fixed rung uses the
|
/// ([`StepCanvasRung::global_cell_counts`]) — **the wire `extent` is never
|
||||||
/// request's own `extent` field directly (D-255(a): "the fixed 3840×2160 px
|
/// read for `Global`, full stop** (PR #201 review: "make sure... Global
|
||||||
/// budget... at 1 gridunit-per-screen-px" — cell count = canvas px count at
|
/// ignores wire extent entirely"). Every fixed rung clamps the wire `extent`
|
||||||
/// every fixed rung, Dudley round-2 §(c)).
|
/// via [`clamp_step_canvas_extent`] before it reaches any allocation (D-255(a):
|
||||||
|
/// "the fixed 3840×2160 px budget... at 1 gridunit-per-screen-px" — cell
|
||||||
|
/// count = canvas px count at every fixed rung, Dudley round-2 §(c)).
|
||||||
fn resolve_canvas_extent(
|
fn resolve_canvas_extent(
|
||||||
rung: StepCanvasRung,
|
rung: StepCanvasRung,
|
||||||
extent: (u32, u32),
|
extent: (u32, u32),
|
||||||
@@ -756,7 +847,7 @@ fn resolve_canvas_extent(
|
|||||||
) -> (u32, u32) {
|
) -> (u32, u32) {
|
||||||
match rung.global_cell_counts(body_radius_km) {
|
match rung.global_cell_counts(body_radius_km) {
|
||||||
Some((cols, rows)) => (cols, rows),
|
Some((cols, rows)) => (cols, rows),
|
||||||
None => (extent.0.max(1), extent.1.max(1)),
|
None => clamp_step_canvas_extent(extent),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1389,6 +1480,21 @@ pub fn serve_step_canvas_request(
|
|||||||
) -> StepCanvasResponse {
|
) -> StepCanvasResponse {
|
||||||
let min_wl_m = quantize_min_wl_m_for_rung(req.min_wl_m);
|
let min_wl_m = quantize_min_wl_m_for_rung(req.min_wl_m);
|
||||||
|
|
||||||
|
// PR #201 review, Hoshe finding 1 — clamp the wire extent HERE, once, at
|
||||||
|
// the request boundary, BEFORE it reaches the cache key or the
|
||||||
|
// background work item (never trust the wire — the exact discipline
|
||||||
|
// `layer_proxy::serve_district_window` already applies to `window_n`/
|
||||||
|
// `min_wl_m` before either touches its own cache key). `Global` ignores
|
||||||
|
// the wire extent entirely (ratified in `resolve_canvas_extent`'s own
|
||||||
|
// doc) — its echoed/keyed extent is a fixed sentinel `(0, 0)` rather
|
||||||
|
// than the unclamped wire value, so a `Global` request's cache key can
|
||||||
|
// never vary by the client's (ignored) extent field.
|
||||||
|
let extent = if req.rung.is_global() {
|
||||||
|
(0, 0)
|
||||||
|
} else {
|
||||||
|
clamp_step_canvas_extent(req.extent)
|
||||||
|
};
|
||||||
|
|
||||||
let body_params = match read_body_params(body_params_reader, &req.body_id) {
|
let body_params = match read_body_params(body_params_reader, &req.body_id) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1397,6 +1503,7 @@ pub fn serve_step_canvas_request(
|
|||||||
body_id: req.body_id.clone(),
|
body_id: req.body_id.clone(),
|
||||||
rung: req.rung,
|
rung: req.rung,
|
||||||
center: req.center,
|
center: req.center,
|
||||||
|
extent,
|
||||||
min_wl_m,
|
min_wl_m,
|
||||||
status: StepCanvasStatus::Error(e),
|
status: StepCanvasStatus::Error(e),
|
||||||
canvas: None,
|
canvas: None,
|
||||||
@@ -1411,24 +1518,20 @@ pub fn serve_step_canvas_request(
|
|||||||
body_id: req.body_id.clone(),
|
body_id: req.body_id.clone(),
|
||||||
rung: req.rung,
|
rung: req.rung,
|
||||||
center: req.center,
|
center: req.center,
|
||||||
|
extent,
|
||||||
min_wl_m,
|
min_wl_m,
|
||||||
status: StepCanvasStatus::Ready,
|
status: StepCanvasStatus::Ready,
|
||||||
canvas: Some(canvas.clone()),
|
canvas: Some(canvas.clone()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let key: StepCanvasKey = (
|
let key: StepCanvasKey = (req.body_id.clone(), req.rung, req.center, extent, min_wl_m);
|
||||||
req.body_id.clone(),
|
|
||||||
req.rung,
|
|
||||||
req.center,
|
|
||||||
req.extent,
|
|
||||||
min_wl_m,
|
|
||||||
);
|
|
||||||
if let Some(canvas) = canvas_cache.get(&key, current_tick, body_class) {
|
if let Some(canvas) = canvas_cache.get(&key, current_tick, body_class) {
|
||||||
return StepCanvasResponse {
|
return StepCanvasResponse {
|
||||||
body_id: req.body_id.clone(),
|
body_id: req.body_id.clone(),
|
||||||
rung: req.rung,
|
rung: req.rung,
|
||||||
center: req.center,
|
center: req.center,
|
||||||
|
extent,
|
||||||
min_wl_m,
|
min_wl_m,
|
||||||
status: StepCanvasStatus::Ready,
|
status: StepCanvasStatus::Ready,
|
||||||
canvas: Some(canvas),
|
canvas: Some(canvas),
|
||||||
@@ -1446,6 +1549,7 @@ pub fn serve_step_canvas_request(
|
|||||||
body_id: req.body_id.clone(),
|
body_id: req.body_id.clone(),
|
||||||
rung: req.rung,
|
rung: req.rung,
|
||||||
center: req.center,
|
center: req.center,
|
||||||
|
extent,
|
||||||
min_wl_m,
|
min_wl_m,
|
||||||
status: StepCanvasStatus::NotFound,
|
status: StepCanvasStatus::NotFound,
|
||||||
canvas: None,
|
canvas: None,
|
||||||
@@ -1456,6 +1560,7 @@ pub fn serve_step_canvas_request(
|
|||||||
body_id: req.body_id.clone(),
|
body_id: req.body_id.clone(),
|
||||||
rung: req.rung,
|
rung: req.rung,
|
||||||
center: req.center,
|
center: req.center,
|
||||||
|
extent,
|
||||||
min_wl_m,
|
min_wl_m,
|
||||||
status: StepCanvasStatus::Error(e.to_string()),
|
status: StepCanvasStatus::Error(e.to_string()),
|
||||||
canvas: None,
|
canvas: None,
|
||||||
@@ -1474,7 +1579,7 @@ pub fn serve_step_canvas_request(
|
|||||||
placements: placements.to_vec(),
|
placements: placements.to_vec(),
|
||||||
rung: req.rung,
|
rung: req.rung,
|
||||||
center: req.center,
|
center: req.center,
|
||||||
extent: req.extent,
|
extent,
|
||||||
min_wl_m,
|
min_wl_m,
|
||||||
},
|
},
|
||||||
GenPriority::Immediate,
|
GenPriority::Immediate,
|
||||||
@@ -1484,6 +1589,7 @@ pub fn serve_step_canvas_request(
|
|||||||
body_id: req.body_id.clone(),
|
body_id: req.body_id.clone(),
|
||||||
rung: req.rung,
|
rung: req.rung,
|
||||||
center: req.center,
|
center: req.center,
|
||||||
|
extent,
|
||||||
min_wl_m,
|
min_wl_m,
|
||||||
status: StepCanvasStatus::Pending,
|
status: StepCanvasStatus::Pending,
|
||||||
canvas: None,
|
canvas: None,
|
||||||
@@ -1552,6 +1658,147 @@ mod tests {
|
|||||||
assert_eq!(rows, (cols / 2).max(1));
|
assert_eq!(rows, (cols / 2).max(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Wire extent clamp (PR #201 review, Hoshe finding 1 — DoS hardening)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversized_per_axis_extent_clamps_to_the_axis_cap() {
|
||||||
|
let (w, h) = clamp_step_canvas_extent((50_000, 100));
|
||||||
|
assert_eq!(w, STEP_CANVAS_MAX_EXTENT_AXIS);
|
||||||
|
assert_eq!(h, 100);
|
||||||
|
|
||||||
|
let (w, h) = clamp_step_canvas_extent((100, 50_000));
|
||||||
|
assert_eq!(w, 100);
|
||||||
|
assert_eq!(h, STEP_CANVAS_MAX_EXTENT_AXIS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn total_cell_overflow_clamps_even_when_both_axes_are_individually_legal() {
|
||||||
|
// Both axes are within STEP_CANVAS_MAX_EXTENT_AXIS (3,840) but their
|
||||||
|
// product (3,840 * 3,840 = 14,745,600) exceeds
|
||||||
|
// STEP_CANVAS_MAX_EXTENT_CELLS (8,294,400) — the per-axis clamp
|
||||||
|
// alone does NOT catch this, only the total-cell stage does.
|
||||||
|
let requested = (3_840u32, 3_840u32);
|
||||||
|
let requested_cells = requested.0 as u64 * requested.1 as u64;
|
||||||
|
assert!(
|
||||||
|
requested_cells > STEP_CANVAS_MAX_EXTENT_CELLS,
|
||||||
|
"test setup: this case must actually exceed the cap"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (w, h) = clamp_step_canvas_extent(requested);
|
||||||
|
let clamped_cells = w as u64 * h as u64;
|
||||||
|
assert!(
|
||||||
|
clamped_cells <= STEP_CANVAS_MAX_EXTENT_CELLS,
|
||||||
|
"clamped shape ({w}x{h}={clamped_cells}) must respect the total-cell cap"
|
||||||
|
);
|
||||||
|
assert!(w <= STEP_CANVAS_MAX_EXTENT_AXIS && h <= STEP_CANVAS_MAX_EXTENT_AXIS);
|
||||||
|
// Aspect ratio preserved (both axes were equal going in, so they
|
||||||
|
// must still be equal, or within 1 of each other from the
|
||||||
|
// defensive floor-rounding walk-down).
|
||||||
|
assert!((w as i64 - h as i64).abs() <= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u32_max_extent_clamps_to_the_cap_without_huge_allocation() {
|
||||||
|
// The exact DoS shape Hoshe's finding named: a wire extent of
|
||||||
|
// (u32::MAX, u32::MAX) must clamp down to a bounded shape BEFORE
|
||||||
|
// any allocation is attempted — never overflow, never panic, never
|
||||||
|
// produce a canvas anywhere near u32::MAX cells.
|
||||||
|
//
|
||||||
|
// Both axes clamp to STEP_CANVAS_MAX_EXTENT_AXIS (3,840) first, but
|
||||||
|
// 3,840 x 3,840 = 14,745,600 > STEP_CANVAS_MAX_EXTENT_CELLS
|
||||||
|
// (8,294,400) — the total-cell stage then scales BOTH axes down
|
||||||
|
// together (same two-stage behavior
|
||||||
|
// total_cell_overflow_clamps_even_when_both_axes_are_individually_legal
|
||||||
|
// pins generically); this test additionally confirms the concrete
|
||||||
|
// u32::MAX case end to end through a real derive.
|
||||||
|
let (w, h) = clamp_step_canvas_extent((u32::MAX, u32::MAX));
|
||||||
|
let cells = w as u64 * h as u64;
|
||||||
|
assert!(w <= STEP_CANVAS_MAX_EXTENT_AXIS);
|
||||||
|
assert!(h <= STEP_CANVAS_MAX_EXTENT_AXIS);
|
||||||
|
assert!(
|
||||||
|
cells <= STEP_CANVAS_MAX_EXTENT_CELLS,
|
||||||
|
"clamped u32::MAX request ({w}x{h}={cells}) must respect the total-cell cap"
|
||||||
|
);
|
||||||
|
|
||||||
|
// End-to-end: actually run build_step_canvas with this hostile
|
||||||
|
// request and confirm it derives a small, bounded canvas — not a
|
||||||
|
// multi-billion-cell allocation. Uses a tiny real TerrainAnalysis
|
||||||
|
// fixture so the derive itself stays fast; the point is the
|
||||||
|
// ALLOCATION SIZE, which is governed by resolve_canvas_extent's
|
||||||
|
// clamp regardless of how small the source heightmap is.
|
||||||
|
let ta = tiny_ta();
|
||||||
|
let rn = RiverNetwork::default();
|
||||||
|
let params = BodyParams {
|
||||||
|
body_radius_km: Some(6371.0),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let climate = ClimateConstants::default();
|
||||||
|
let seed = SeedChain::root(0xDEADBEEF_u64).derive(crate::seed::SeedDomain::Body, 1);
|
||||||
|
|
||||||
|
let raw = build_step_canvas(
|
||||||
|
seed,
|
||||||
|
"dos-test",
|
||||||
|
¶ms,
|
||||||
|
&ta,
|
||||||
|
&rn,
|
||||||
|
&[],
|
||||||
|
StepCanvasRung::Chunk,
|
||||||
|
(0, 0),
|
||||||
|
(u32::MAX, u32::MAX),
|
||||||
|
&climate,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(raw.width, w);
|
||||||
|
assert_eq!(raw.height, h);
|
||||||
|
assert_eq!(raw.morphology.len() as u64, cells);
|
||||||
|
assert!(
|
||||||
|
cells <= STEP_CANVAS_MAX_EXTENT_CELLS,
|
||||||
|
"the ACTUAL derived+allocated canvas must respect the cap, not just the clamp function's return value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extent_at_or_under_the_cap_is_unchanged() {
|
||||||
|
// The clamp must be a no-op for any legal, already-bounded request —
|
||||||
|
// it should never shrink a request that was already within budget.
|
||||||
|
for (w, h) in [(1u32, 1u32), (100, 100), (3_840, 2_160), (1_920, 1_080)] {
|
||||||
|
assert_eq!(
|
||||||
|
clamp_step_canvas_extent((w, h)),
|
||||||
|
(w, h),
|
||||||
|
"({w}, {h}) is within both caps and must pass through unchanged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn global_rung_ignores_wire_extent_entirely() {
|
||||||
|
// resolve_canvas_extent must derive Global's extent from the body's
|
||||||
|
// own region grid, never from the (hostile or otherwise) wire
|
||||||
|
// value — confirmed at u32::MAX, the most adversarial input.
|
||||||
|
let (w, h) = resolve_canvas_extent(StepCanvasRung::Global, (u32::MAX, u32::MAX), 6371.0);
|
||||||
|
let expected = StepCanvasRung::Global
|
||||||
|
.global_cell_counts(6371.0)
|
||||||
|
.expect("Global has a cell-count shape");
|
||||||
|
assert_eq!((w, h), expected);
|
||||||
|
// Sanity: the body-derived shape is nowhere near u32::MAX — proves
|
||||||
|
// the wire value truly had zero influence, not just a coincidental
|
||||||
|
// clamp to the same range.
|
||||||
|
assert!(w < STEP_CANVAS_MAX_EXTENT_AXIS * 10);
|
||||||
|
assert!(h < STEP_CANVAS_MAX_EXTENT_AXIS * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn global_rung_extent_is_identical_regardless_of_requested_extent() {
|
||||||
|
// A second confirmation from the opposite direction: Global's
|
||||||
|
// resolved extent must be the SAME for a tiny request and a huge
|
||||||
|
// one — extent has literally no effect on Global's output shape.
|
||||||
|
let tiny = resolve_canvas_extent(StepCanvasRung::Global, (1, 1), 6371.0);
|
||||||
|
let huge = resolve_canvas_extent(StepCanvasRung::Global, (u32::MAX, u32::MAX), 6371.0);
|
||||||
|
assert_eq!(tiny, huge);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Station-spacing cap (S2 addendum decision)
|
// Station-spacing cap (S2 addendum decision)
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user