Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

The zero-allocation contract requirement is violated by the only API the crate exposes

Источник: dev/fera-research/docs/oc2/zero-allocation-requirement-violated.md (read-only; сгенерировано scripts/sync-docs.mjs)

The zero-allocation contract requirement is violated by the only API the crate exposes

Section titled “The zero-allocation contract requirement is violated by the only API the crate exposes”
  • kind: problem
  • area: perf
  • severity: mid
  • evidence:
    • ~/dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md:27-28 — “Zero per-frame allocation on the hot path. Transport is an arena / ring buffer; the list is rewritten in place frame-to-frame.”
    • ~/dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md:49 — “consumed once, then either reused (frame N+1 rewrites the same arena) or diffed against the previous frame”
    • ~/dev/fera/chroix-project/UI-FRAMEWORK-IDEAS-V2.md:183 — “Arena transport (§5): display lists move in arenas / ring buffers, no per-frame allocation.”
    • ~/dev/fera/chroix-project/UI-FRAMEWORK-IDEAS-V2.md:193 — “Rule: optimization is a contract requirement, not an emergent property. A design that regresses a frame budget or adds per-frame allocation fails review.”
    • ~/dev/fera/chroix-contract/src/encode.rs:20 — pub fn encode(&self) -> Vec<u8>
    • ~/dev/fera/chroix-contract/src/encode.rs:29 — let mut op_stream = Vec::new();
    • ~/dev/fera/chroix-contract/src/list.rs:20,28,34,40 — public API is exactly new, push_op, frame_id, ops — no clear, reset, retain, or arena entry point
    • ~/dev/fera/chroix-contract/src/damage.rs:21,26,37,45,50 — public API is exactly new, push, count, is_empty, iter

Three places in the corpus state the same requirement, and one of them makes it a review gate rather than a goal: V2 §19:193 — “A design that regresses a frame budget or adds per-frame allocation fails review.”

The implementation currently adds two fresh allocations per frame and offers no way not to:

// chroix-contract/src/encode.rs:20
#[must_use]
pub fn encode(&self) -> Vec<u8> { // <-- new Vec<u8> every call
...
// encode.rs:29
let mut op_stream = Vec::new(); // <-- second Vec, every call
OpStream::write(&mut op_stream, self.ops());

And the type being encoded exposes no reuse entry point at all:

// chroix-contract/src/list.rs — the complete public API
pub fn new(frame_id: u64) -> Self
pub fn push_op(&mut self, op: Op)
pub fn frame_id(&self) -> u64
pub fn ops(&self) -> &[Op]

There is no clear, no reset, no encode_into(&mut [u8]), no arena, no ring buffer. So “frame N+1 rewrites the same arena” is not merely unimplemented — the API shape makes it impossible without a breaking change, because encode() hands ownership of a new buffer to the caller each time.

This is worth flagging precisely because of when it will hurt. Today the crate is 60% real and its backends decode buffers that nothing yet produces at volume, so two allocations per frame cost nothing measurable. The trap is that this is an API-level decision, and the cost of fixing it later scales with everything built on top: once pipelines, conformance fixtures (TODO.md 2g) and a reference scene exist, changing encode() -> Vec<u8> into encode_into(&mut DisplayListBuf) is a breaking change across every crate.

Adjacent, same file cluster: damage has no merge logic either

Section titled “Adjacent, same file cluster: damage has no merge logic either”

While measuring I checked the other half of the same requirement. damage.rs’s complete public API is new, push, count, is_empty, iter — there is no union, no overlap test, no coalescing, no area/box helper, and no merge threshold, and none is marked TODO.

CHROIX-DISPLAY-LIST.md:334-336 §12 lists “Damage merge threshold” as the one remaining open question, and :232-233 states the contract carries a merged list. So the merge step is specified, listed as open, and simply absent — which is fine while damage is inert (it is hardcoded 0 at encode.rs:47-48, already recorded as TODO.md G7), but it means TODO.md’s Stage 2e (“Damage list on the wire”) will ship a raw, unmerged rect list and look done.

  • A. Change the API now, while it is cheap. Add encode_into(&mut Vec<u8>) (or a small DisplayListBuf with an internal Vec<u8> + length cursor that is cleared and refilled), keep encode() -> Vec<u8> as a thin convenience wrapper for tests. Cost: a small additive change now; a breaking change across four crates later.
  • B. Defer to the same stage as the side tables. TODO.md Stage 2 already rewrites the encoder to emit brushes/transforms/strings/damage; fold the buffer-reuse change in there. Cost: zero extra work if Stage 2 happens; but Stage 2’s stated purpose is the wire, not allocation, so the requirement stays invisible until someone remembers it.
  • C. Do nothing. Cost: the V2 §19 review gate is violated silently, and the violation becomes structural.

A, folded into TODO.md Stage 2 (i.e. B + A). The two are the same edit region, so:

  1. Add encode_into(&mut DisplayListBuf) where DisplayListBuf owns the bytes and exposes clear() + len(). Keep encode() -> Vec<u8> as a wrapper so existing tests and the DisplayList::decode(&[u8]) round-trip path keep working unchanged.
  2. Do it inside Stage 2 rather than as separate work — Stage 2 rewrites encode() anyway to stop hardcoding the six side-table offsets to 0. Adding reuse at the same time is marginal cost, and it gets the change in while the API has one consumer per backend.
  3. Add the damage merge as an explicit item before Stage 2e’s “damage on the wire” is called done — otherwise Stage 2 ships a raw rect list that looks like the spec’s merged list.
  4. Whichever way it goes, the requirement should be written into the task list next to the spec quote, because V2 §19:193 makes it a review gate and nothing in the current plan mentions allocation.

Honest sizing: this is severity: mid, not high. Nothing regresses today because nothing produces display lists at volume yet. It is listed mid because the fix is an API change and the cost of deferral grows with every crate added — and because the corpus explicitly promises this will fail review, so shipping it silently contradicts a stated gate.