sequencer: reject a tag equal to max_sequencer_tags (one-past-the-end write) - #1016
sequencer: reject a tag equal to max_sequencer_tags (one-past-the-end write)#1016bwhitman wants to merge 1 commit into
Conversation
`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>
🎛️ AMY HW CI (AMYboard bench)Flashed this PR's AMY (LoadTestChord: 6-voice Juno ✅ PASS — the bench ran the test to completion.
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 |
|
Obsolete after the sequencer rework on main: |
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>
The bug
sequencesis a malloc'd array ofmax_sequencesentries, so the last valid tag ismax_sequences - 1.sequencer_add_event()guarded with:which lets
tag == max_sequencesthrough and writes a wholesequence_info_t— a pointer and twouint32_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_boundssegfaults.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:
The change
tag > max_sequences→tag < 0 || tag >= max_sequences.The negative half matters too:
SEQUENCE_TAGarrives as an unsigned field and lands in anint32_t, so anything pastINT32_MAXreads as negative and would have indexed backwards out of the array.tests/test_sequencer_bounds.c, added toCTESTS. 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 itmake test— 122 tests pass, sequencer cases all aterr=-100.0 dBOne thing worth flagging separately
CI (
c-cpp.yml) runsmake testbut notmake ctest, so neither this test nor the existingtest_clock_wrapruns there. Worth wiring up, but it touches.github/workflowsand needsworkflowscope on the token, so I've left it out of this PR.🤖 Generated with Claude Code