Skip to content

Palette: reject bad indexes, suppress no-op events, make reads coherent - #23

Merged
tomlm merged 1 commit into
tomlm:mainfrom
JohnCampionJr:fix/palette-index-and-coherent-reads
Aug 24, 2026
Merged

Palette: reject bad indexes, suppress no-op events, make reads coherent#23
tomlm merged 1 commit into
tomlm:mainfrom
JohnCampionJr:fix/palette-index-and-coherent-reads

Conversation

@JohnCampionJr

Copy link
Copy Markdown
Collaborator

Follow-up to #21, which merged before these landed on the branch. Three problems in the colour palette that is now on main — one raised by Copilot on that PR, two found while looking into it.

Out-of-range indexes were clamped

The one I would fix first. Clamp mapped any index outside 0..255 onto an end of the range, so:

palette.SetColor(999, 0x123456);   // quietly rewrote entry 255
palette[-1];                       // answered for entry 0

A caller with an off-by-one got no error and a corrupted palette. Clamping is the single response that produces a plausible wrong answer where there should have been none — it throws ArgumentOutOfRangeException now.

The OSC path is unaffected: InputHandler range-checks before it reaches the palette. That is exactly why it went unnoticed, and why the existing test did not catch it — the test drove OSC 4, which is the guarded path, not the public API. There is now a test for each.

No-op resets raised ColorChanged

ResetAllColors fired whether or not anything had changed, so a bare OSC 104 on an untouched palette told a renderer to repaint for nothing — unlike ResetColor and the fg/bg/cursor resets, which all suppress. ApplyTheme had the same flaw. Both are silent now when the values already match.

A theme change could be observed half applied

ApplyTheme copied into the live array, and Array.Copy is not atomic, so a renderer scanning the palette could paint a frame half in the old theme and half in the new. Bulk changes now build a snapshot and swap one reference, which has no middle.

That alone was not enough, and measuring said so. A reader calling the indexer eight times takes eight separate snapshots and can still straddle the swap. Under a tight toggle that produced 3.5 million mixed reads in three seconds — routine, not a rare race. The atomicity existed but was unreachable through the public API.

So ColorPalette.Take() returns one immutable ColorSnapshot: what to use when reading more than one colour, which for a renderer painting a frame is always. Writes copy on write to keep it immutable — an int store is atomic and would have been safe for the value, but it would move a snapshot somebody is holding, and not moving is the entire point. A palette is 1KB and colour changes are rare.

Reads stay lock free. The lock only serialises writers, because the indexer sits on a per-cell path where locking would be a worse cure than the problem.

Testing

Nine new tests: no-op suppression for both reset paths, out-of-range rejection through the API and continued tolerance through OSC, a snapshot that does not move after later writes, and a reader/writer pair asserting a theme flip is never observed half applied. 665 passed, 0 failed.

Take() and ColorSnapshot are a small public addition, so worth a look.

🤖 Generated with Claude Code

…rent

Three problems, one found in review and two found probing around it.

NO-OP EVENTS. ResetAllColors raised ColorChanged whether or not anything had
changed, so a bare OSC 104 on an untouched palette told a renderer to repaint for
nothing -- and unlike every other setter here, which all suppress. ApplyTheme had
the same flaw. Both are silent now when the values already match.

CLAMPED INDEXES. The bigger one. Clamping mapped any out-of-range index onto 0 or
255, so SetColor(999, ...) quietly rewrote entry 255 and the indexer answered for
entry 0 when asked about -1. A caller with an off-by-one got no error and a
corrupted palette. Clamping is the one response that produces a plausible WRONG
answer where there should have been none; it throws now. The OSC path is
unaffected, because InputHandler range-checks first -- which is exactly why this
went unnoticed, and why the existing test did not catch it.

COHERENT READS. ApplyTheme copied into the live array, and Array.Copy is not
atomic, so a renderer scanning the palette could paint a frame half in the old
theme and half in the new. Bulk changes now build a snapshot and swap one
reference, which has no middle.

That alone was not enough, and measuring said so: a reader calling the indexer
eight times takes eight separate snapshots and can still straddle a swap. Under a
tight toggle that produced three and a half MILLION mixed reads in three seconds --
routine, not a rare race. So Take() hands out one immutable view, which is what to
use when reading more than one colour, and for a renderer painting a frame that is
always. Writes copy on write to keep it immutable: an int store is atomic and would
have been safe for the value, but it would move a snapshot somebody is holding, and
not moving is the entire point. A palette is 1KB and colour changes are rare.

Reads stay lock free. The lock only serialises writers, because the indexer is on a
per-cell path where locking would be a worse cure than the problem.

656 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tomlm
tomlm merged commit 5f6b317 into tomlm:main Aug 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants