Skip to content

Don't allocate oscs on the ingest path - #1190

Open
dpwe wants to merge 2 commits into
mainfrom
claude/amy-pr-1185-osc-allocd-kao5og
Open

dpwe wants to merge 2 commits into
mainfrom
claude/amy-pr-1185-osc-allocd-kao5og

Conversation

@dpwe

@dpwe dpwe commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

An alternative to #1185 for the same crash: a store through NULL in reset_osc, or writes into freed memory, when a synth's patch is reloaded on a dual-core ESP32-P4.

The race. amy_event_to_deltas_queue() allocated the osc an event addresses, and any chained/mod osc it references, on whatever thread sent the event. It did this without the queue lock. FREE_OSC frees oscs on the render thread inside flush_due_deltas(), which does hold the lock. A patch load queues frees for the old voice's oscs and then allocates the same osc numbers for the new voice. So the render thread could free an osc while the sending thread was still inside alloc_osc() / reset_osc().

Why remove rather than lock. #1185 takes the lock around those calls. This PR deletes them, because they aren't needed:

  • Nothing on the ingest path reads synth[] after them, and the return value of the ingest ensure was ignored.
  • play_delta already calls ensure_osc_allocd, under the lock, for everything it touches: the addressed osc, chained_osc, mod_source, algo sources and breakpoint growth.
  • In the patch-load case the ingest allocation didn't even save a render-thread malloc: the queued FREE_OSC freed it, and play_delta allocated it again.
  • They date from before play_delta did its own ensures. 85fd9de had already limited them to live-queue events, noting that "play_delta already ensures at execution for everything that plays".

With them gone, ingest never allocates an osc, so there's nothing to lock. The one tradeoff: the first allocation of a new osc now always happens on the render thread, under the lock. That was already true for chained, mod and algo oscs.

Test change. Two cases in tests/test_voice_osc_range.c send an event whose fields are all refused and then dereference synth[base]. They only passed because ingest had allocated that osc. They now also accept an unallocated osc, which is at its defaults.

Testing

  • make ctest passes, including after merging current main.
  • make test fails the same 43 tests on main and on this branch in my environment (small numeric differences against the reference audio), so the change doesn't affect them.
  • Not yet tested on the ESP32-P4 reload setup where the crash was seen. That's the real check before merging.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UW4jqUQ4Xc1sdoqzUPL2YV

amy_event_to_deltas_queue() allocated the addressed osc (and a referenced
chained/mod osc) on whatever thread sent the event, without the queue
lock, while FREE_OSC frees oscs on the render thread inside
flush_due_deltas(), which holds it. A patch load queues frees for the old
voice's oscs and then allocates the same osc numbers for the new voice,
so the render thread could free an osc mid alloc_osc()/reset_osc() on
the sending thread: a store through NULL or writes into freed memory
(seen on a dual-core ESP32-P4 reloading a synth's patch, #1185).

Those allocations were vestigial. Nothing on the ingest path touches
synth[] after them, and play_delta already ensures every osc it touches
(the addressed osc, chained_osc, mod_source, algo sources, breakpoint
growth) under the lock. In the patch-load case the ingest allocation was
freed by the queued FREE_OSC and re-allocated by play_delta anyway.
Removing them takes the cross-thread allocation out entirely, rather than
locking around it as #1185 does.

test_voice_osc_range read synth[base] after an event whose fields were
all refused, relying on ingest having allocated it; it now accepts an
unallocated osc (which is at its defaults).

make ctest passes. make test gives the same 90 pass / 43 fail as main
here (the failures are small numeric diffs vs reference audio).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UW4jqUQ4Xc1sdoqzUPL2YV
@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 @ 44bf70d this PR Δ
1 980 985 +5
2 1135 1139 +4
3 1707 1706 -1
4 1875 1866 -9
5 2469 2479 +10
6 2604 2612 +8

Full chord settled render μs: 2610 (was 2601, Δ +0.3%) (peak 2614, 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/.

This branch has not been deployed

No deployments
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