Skip to content

sequencer: reject a tag equal to max_sequencer_tags (one-past-the-end write) - #1016

Closed
bwhitman wants to merge 1 commit into
mainfrom
fix/sequencer-tag-bounds
Closed

sequencer: reject a tag equal to max_sequencer_tags (one-past-the-end write)#1016
bwhitman wants to merge 1 commit into
mainfrom
fix/sequencer-tag-bounds

Conversation

@bwhitman

Copy link
Copy Markdown
Collaborator

The bug

sequences is a malloc'd array of max_sequences entries, so the last valid tag is max_sequences - 1. sequencer_add_event() guarded with:

if (tag > max_sequences) {

which lets tag == max_sequences through and writes a whole sequence_info_t — a pointer and two uint32_t — one element past the end of the allocation.

The message the check prints on rejection has always read "is greater than or eq max_sequences", so the intent was never in doubt; only the comparison was.

Verified as a real heap corruption, not a policy question: with the old comparison, tests/test_sequencer_bounds segfaults.

How you'd hit it

Not from the audio suite, which never sends a tag near the ceiling. Not from a well-behaved host either — an allocator that hands out tags below the limit can't produce one. It takes a single hand-written message:

amy.send(sequence="0,16,256")     # with the default max_sequencer_tags of 256

The change

  • tag > max_sequencestag < 0 || tag >= max_sequences.

    The negative half matters too: SEQUENCE_TAG arrives as an unsigned field and lands in an int32_t, so anything past INT32_MAX reads as negative and would have indexed backwards out of the array.

  • tests/test_sequencer_bounds.c, added to CTESTS. It checks that tag 0 and tag max−1 are accepted, that max, max+1, a far-out tag and a negative-reading tag are all rejected, and that a completely full table still refuses the one past the end.

Testing

  • make ctest — passes with the fix, segfaults without it
  • make test — 122 tests pass, sequencer cases all at err=-100.0 dB

One thing worth flagging separately

CI (c-cpp.yml) runs make test but not make ctest, so neither this test nor the existing test_clock_wrap runs there. Worth wiring up, but it touches .github/workflows and needs workflow scope on the token, so I've left it out of this PR.

🤖 Generated with Claude Code

`sequences` is a malloc'd array of max_sequences entries, so the last
valid tag is max_sequences - 1. The guard read

    if (tag > max_sequences)

which let tag == max_sequences through and wrote a whole
sequence_info_t -- a pointer and two uint32_t -- one element past the end
of the allocation. The message it prints on rejection has always said
"greater than or eq", so the intent was never in question.

Verified: with the old comparison, tests/test_sequencer_bounds segfaults.
It is a real heap corruption, not a policy question.

Not reachable from the audio suite, which never sends a tag near the
ceiling, and not reachable from a well-behaved host either -- an
allocator that hands out tags below the limit cannot produce one. It
takes a single hand-written message:

    amy.send(sequence="0,16,256")     # with max_sequencer_tags 256

Negative tags are rejected here too. SEQUENCE_TAG arrives as an unsigned
field and lands in an int32_t, so anything past INT32_MAX reads as
negative and would have indexed backwards out of the array.

tests/test_sequencer_bounds.c is added to CTESTS. Note that CI's
c-cpp.yml runs `make test` but not `make ctest`, so neither this nor the
existing clock-wrap test runs there -- worth wiring up separately, but
that touches .github/workflows and needs `workflow` scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🎛️ AMY HW CI (AMYboard bench)

Flashed this PR's AMY (LoadTestChord: 6-voice Juno patch=1, one held note every 2 s) onto the physical AMYboard and measured the smoothed render load as the chord grows — back-to-back with the same sketch built at the PR's merge base, so Δ is this PR's own cost.

PASS — the bench ran the test to completion.

notes held main @ 211ebfb this PR Δ
1 1014 1014 +0
2 1184 1179 -5
3 1765 1762 -3
4 1934 1930 -4
5 2562 2558 -4
6 2758 2753 -5

Full chord settled render μs: 2756 (was 2759, Δ -0.1%) (peak 2763, 39 samples)

⬇️ Artifacts: serial log · load trace · report

Self-hosted bench (amyboardci). FAIL means only that the test could not run — the load values are informational, with no threshold and no audio compare. See tools/arduino_loadsweep/.

@bwhitman

bwhitman commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Obsolete after the sequencer rework on main: sequencer_add_event's int32_t tag / tag > max_sequences check is gone. The replacement, sequencer_add_wire(), keeps the tag as uint32_t and rejects with tag >= (uint32_t)max_sequences — that closes both halves of this bug: the off-by-one (tag == max would now land on anonymous slot 0, and the >= check refuses it before that), and the negative-reindex (a tag past INT32_MAX stays a huge unsigned value and fails the same unsigned compare, so it can never index backwards). Verified against main @ 187735a. The bounds regression test moves into the reworked #1017 alongside its active-list test.

@bwhitman bwhitman closed this Aug 2, 2026
bwhitman added a commit that referenced this pull request Aug 2, 2026
sequencer_process_tick() swept 0..highest_tag, a high-water mark that
never came down once raised. The anonymous ticks= pool made that the
common case rather than a corner: anonymous one-shots are allocated
round-robin at indices past max_sequences, so a burst of scheduled
events pinned the mark at the very end of the table permanently -- with
the default 256 tags, a 512-entry sweep on every tick for the rest of
the session.

The occupied slots (user tags and anonymous entries alike) are now
threaded through the table as an ascending list: one int32_t next_active
per entry plus a first_active head. Per-tick cost tracks what is
actually scheduled. Ascending order keeps same-tick firing in slot
order, which is what the sweep did, so nothing sounds different. Link
mutations all happen under the amy lock (add, one-shot delete, reset);
the tick walk stays lockless, which is safe because the links are
indices into a fixed array -- a splice publishes in one aligned store,
and every link is greater than the slot holding it, so a stale read can
skip or revisit an entry for one tick but can't cycle, hang, or leave
the array.

Also folds in the tag bounds regression coverage from #1016 (the fix
itself already landed with the wire rework: sequencer_add_wire checks
tag >= max_sequences unsigned), plus one fix it flushed out: sprint_event
printed ticks values as signed, so a C-API tag past INT32_MAX serialized
with a '-' that stopped the unsigned list parser early and silently
turned the (invalid) 3-value ticks into a 2-value anonymous entry.
ticks now prints unsigned and the out-of-range tag is rejected.

tests: test_sequencer_active (out-of-order add/clear, anonymous
one-shots leave the list empty, and a lone sequence at tag 4095 costs
the same as at tag 0 -- 15x apart under the old sweep) and
test_sequencer_bounds (boundary tags, huge tags, and no clobbering of
the anonymous pool), both in make ctest. 122 WAV tests pass bit-exact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant