Skip to content

Fix text measurement cache collisions - #173

Draft
taras wants to merge 1 commit into
bombshell-dev:mainfrom
taras:codex/text-measure-cache-collisions
Draft

taras wants to merge 1 commit into
bombshell-dev:mainfrom
taras:codex/text-measure-cache-collisions

Conversation

@taras

@taras taras commented Nov 10, 2026 •

Copy link
Copy Markdown

What does this PR do?

Fixes incorrect text widths caused by hash collisions in Clay’s measurement cache. Two public ASCII strings with widths 57 and 91 share a 32-bit cache ID; measuring them together or across calls can return 57 for both and lose a drawn text tail.

Refs nicbarker/clay#664, which includes a standalone C reproduction against current Clay main.

The Clay patch verifies text/config equality after a hash match. tty owns the cached key bytes per instance and copies them only when a cache entry is published. Every copy checks remaining storage; oversized frames borrow their own immutable input bytes and clear those keys after presentation. The existing Clay patch mechanism and submodule revision are retained. Public APIs are unchanged.

Each Term reserves 2,031,616 bytes for keys, matching its nominal transfer budget. Independent local measurements found 2 MiB additional allocated WASM memory at 80×24, about 3.4 µs added ordinary-frame cost, and near-capacity rendering increasing from 7.93 to 22.14 ms. Startup samples overlapped. These are measured correctness trade-offs, not performance improvements.

Five new regressions cover same-frame/cross-frame collisions, retained storage reuse, large frames after resize and capacity-refusal input safety. The corrected width cases pass 9/9; all five new cases fail on pristine upstream. Removing storage bounds makes the rollover test trap, confirming the safety guard is needed.

Local validation at signed b1f96bbcae7ebf8be8c88f5072a3e4e985a79145:

  • WASM build, full Deno suite (29 tests, 396 steps), lint and explicit module/test typecheck pass.
  • deno task fmt, deno task fmt:check and deno task verify pass.
  • Upstream Verify CI passed on Linux, macOS and Windows, including npm build and publish dry run (run). Benchmark build/walltime, preview and security checks also pass.
  • Size Report completed both builds and measurements, then failed to post its report because the fork token received HTTP 403 (run).
  • The changeset bot returned a neutral check and a TODO replacement after its inference service received HTTP 401; the existing package scope matches both manifests and the consumer-facing changeset is retained.

The independent acceptance/code review passed at this exact commit, including an equal-byte collision probe that fails if only the borrowed-key reset is removed. The final independent Fable review also found no blocking defect, but its unchanged report was rejected by the workflow because the summary is 1504 characters against a 1,500-character limit. Its original result and rejection are retained; no schema exception or replacement review was applied. This PR remains draft with that review-record hold and the Size Report permission failure visible. No check was bypassed or rerun.

Type of change

  • Bug fix
  • Feature
  • Refactor (no behavior change)
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • All tests pass (deno task test)
  • Files are formatted (deno task fmt, deno task fmt:check)
  • I have added/updated tests for my changes
  • I have added a changeset

AI-generated code disclosure

  • This PR includes AI-generated code

Clay looked up cached text measurements by hash alone, so two strings
whose hashes collide shared one measurement: the second was sized and
wrapped as the first, in the same frame or a later one.

A Clay patch now also compares the text bytes and config on lookup. Each
Term keeps copies of cached keys in its own storage, because a frame's
wire bytes do not outlive its render. Storage is reclaimed by resetting
Clay's cache before a layout that might not fit, and a frame larger than
the storage keys on its own bytes, with the cache cleared once it has
been presented.
@bombshell-cooper

Copy link
Copy Markdown
Contributor

Changeset suggestion

The current changeset no longer matches this PR. This review contains a corrected replacement.

Why: Changeset package scope does not match the affected packages confidently. Changeset is not sufficiently consumer-focused.

View the proposed changeset
---
'@bomb.sh/tty': patch
---

TODO: Write changeset message (inference failed: HTTP 401: {"error":{"message":"User not found.","code":401}})

Review this changeset manually

If this draft is correct, react with 🚀 and Cooper will commit it to this branch.

@@ -0,0 +1,5 @@
---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset needs revision.

Changeset package scope does not match the affected packages confidently. Changeset is not sufficiently consumer-focused.

View the proposed replacement
---
'@bomb.sh/tty': patch
---

TODO: Write changeset message (inference failed: HTTP 401: {"error":{"message":"User not found.","code":401}})

@pkg-pr-new

pkg-pr-new Bot commented Nov 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@bomb.sh/tty@173

commit: b1f96bb

@cowboyd cowboyd left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a look at xxhash.com as suggested by the original Clay author in one of the linked issues, and ran a prototype to see if I could get it to work. It did.

Could we use XXH3-128 for the measurement cache instead of retaining copies of the text? We could pin xxHash as a git submodule and include its header through the existing Clay patch mechanism.

The patch would store and compare the full 128-bit fingerprint, along with text length and the relevant font settings. Clay could still derive its bucket index from part of the hash.

This would have two main benefits:

• Significantly lower memory usage: approximately 192 KiB of additional cache storage at maximum capacity, avoiding the roughly 2 MiB text-copy arena.
• The fix stays entirely within Clay: apart from adding the dependency and patch, no changes to tty’s renderer or memory-management code would be needed. There would be no key-copying callback, storage rollover, or borrowed-key lifetime to manage.

In total, it looks like it would add around 3 KiB to the bundled wasm.ts.

The tradeoff is that a 128-bit fingerprint makes accidental collisions possible, but extraordinarily unlikely.... as in like really unlikely, whereas comparing the original bytes guarantees correct collision handling. If that tradeoff is acceptable, then it would be substantially simpler to maintain.

Comment thread specs/renderer-spec.md
Comment on lines +1259 to +1267
**Measurement cache.** Clay caches each text's measurement under a hash of its
content and text config. A local Clay patch reuses a cached measurement only
when the text bytes and config also match, so strings whose hashes collide are
each measured as themselves. A frame's text bytes do not outlive its render, so
each renderer instance copies the keys of cached measurements into a fixed
region of its own state, clearing Clay's cache before any layout whose text
might not fit there. A frame too large for that region keys its measurements on
its own bytes, and the cache is cleared once that frame is presented.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell, this is all a private implementation detail and so should not be in the render-spec

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants