Skip to content

max: fix the jitter crash, polyphony, atom conversion; and the golden harness that invented crashes - #184

Merged
jcelerier merged 8 commits into
mainfrom
fix/max-binding-crashes-and-harness
Jul 29, 2026
Merged

max: fix the jitter crash, polyphony, atom conversion; and the golden harness that invented crashes#184
jcelerier merged 8 commits into
mainfrom
fix/max-binding-crashes-and-harness

Conversation

@jcelerier

@jcelerier jcelerier commented Jul 29, 2026

Copy link
Copy Markdown
Member

A tooling/run_max_golden.py sweep over the 112 objects reported 3 crashes and
10 audio mismatches. Investigating found one real crash, two harness bugs
that manufactured the other two, and a series of further defects on both sides.

Result: 3 crashes → 0, 13 mismatches → 8, 75 → 80 matching, and all 112
objects now get a turn (43 never ran before, silently).

Independent of #183 (help patches); this touches only the Max binding, the
harness, and one example object.

The real crash — avnd_test_gpu_buffer_in

jitter_processor.hpp registered matrix_calc as a lambda returning void,
but process_mop() reads that method's return value back through
jit_object_method and feeds it to jit_error_code(). The "error code" was
whatever happened to be left in the return register, and jit_error_code
dereferences codes it does not recognise → access violation inside jitlib.

Minidump: c0000005 reading 0x77f8f67f38e at jit_error_code+0x176, reached
from the external's process_mop frame. Other matrix operators survived only
because the leftover register value happened to be benign.

Also relaxed: an analyzer with matrix inputs but no matrix output gets a null
output list, and the guard rejected it, making every bang an error.

The two that were not crashes — avnd_all_ports_types, avnd_poles

Both pass every case in isolation. Three compounding harness faults:

  1. Max's File.writeline truncates at 32767 characters — it does not split,
    which is what parse_report's comment assumed. Both objects emit longer
    report lines, so their entry was never valid JSON and they never counted as
    done. Long lines now go out in sub-32K writestring chunks, and
    loadExisting rejoins continuation lines.
  2. The breadcrumb was deleted once per invocation, not per launch, so a
    stale one read as "already started" and the 60 s stall clock ran through
    Max's own 30–45 s startup.
  3. Progress was counted in parseable report entries, which for an object hit
    by (1) never appear. It is now the breadcrumb advancing.

The kill then stamped whichever object was current — re-running the same object
blamed a different case number each time, which is what proved it was timing
noise.

Binding defects

  • matrix_calc return type (above).
  • to_atoms wrote every element to atoms[0]. The iterable overload
    advanced the write index only in the integral branch; float, string and enum
    wrote atoms[i] with i never incremented.
  • The bitset overload sized for 2 * N atoms, filled N, sent
    atoms.size()
    — N uninitialised atoms after the data. Pd had the same
    over-allocation but passed N explicitly, so it was latent; corrected in both.
  • perform() handed the adapter fewer channels than the object was prepared
    for
    , so an object iterating its declared channel count read past the end of
    ins[]. A crash dump caught avnd_helpers_per_bus_as_args faulting in its
    perform loop on exactly that. Input-side counterpart of max: fix crash on DSP start with fixed/wider output buses #127. A memory-safety
    fix; it is not what fixed the mismatches.
  • Polyphony was initialised with the compile-time channel count. init() can
    only use the declared count — 1 for a mono object — so with a multichannel
    inlet the container held a single per-channel instance and every channel above
    the first came out an exact copy of channel 0. The golden generator does
    allocate_buffers → init_channels(runtime) → prepare; dsp() now does the
    same. This is what the audio mismatches were. Fixed
    avnd_test_audio_mono, avnd_test_audio_sample_ports,
    avnd_helpers_per_sample_as_args, avnd_helpers_per_sample_as_ports,
    avnd_persample_2.

Harness defects

  • CHANS was 2, the number of buffer~ pairs in the driver patch, and
    anything above was silently truncated: avnd_modular and
    avnd_helpers_per_bus_as_ports_fixed (4 inputs), oscr_AudioSidechainExample
    (3 outputs), avnd_vb_fourses_tilde (4 outputs) were compared on 2 channels
    against goldens using all of them. Now 8 pairs, and the driver warns rather
    than truncating quietly.
  • Texture analyzers were never fed an image. The kind test looked only at
    texture outputs, so an analyzer (matrix in, control out) was built by
    buildControl, which never sends a jit_matrixavnd_test_tex_variable
    reported Width=0 against a golden of 16. Anything with a matrix on either
    side is now a matrix operator.
  • A degenerate golden texture failed instead of being skipped.
    oscr_TextureFilterExample case 4 downscales 16×16 by 32, so the object's
    output is 0×0 — which no host matrix type can represent. Cases 0–3 matched
    exactly all along.

One example object was reading uninitialised memory

AllPortsTypes is struct inputs outputs; with an empty operator(), so the
outputs are never written — and not one port member had an initialiser. The
recorded int output was 1065353216, i.e. 0x3F800000, the bit pattern of
1.0f; running the same golden binary twice produced different values for
array_double/array_int. All 192 members are now value-initialised and the
golden is bit-identical across runs.

Tests

  • tests/objects/polyphony_channels.cpp (Catch2, no host required) pins the
    contract the bindings rely on: N channels means N effect instances, each
    reading its own channel's samples, no shared state. A stateless object catches
    a channel coming out as a copy of another; a stateful one catches instances
    being shared.
  • tooling/tests/ (25 tests) covers report parsing, the stall detector
    (extracted into a StallTracker so it could be tested at all — the key case is
    a breadcrumb advancing while no entry ever becomes parseable), object-kind
    classification including the analyzer regression, and the degenerate-texture
    rule including that one degenerate port does not excuse a wrong sibling.
    Registered with CTest when Python is available.

to_atoms and the Max outlet writers have no unit test. Their symbols live
in MaxAPI.dll, which exists only inside the Max application — a standalone test
binary links but cannot load it (verified). They are covered by the harness.

What still mismatches (8 of 112)

  • Max binding, not fixed here (2)avnd_helpers_ui, avnd_ui declare an
    audio output they never write; the oracle emits silence, Max emits stale buffer
    contents (traced to input channel 1 verbatim). Max should zero output buffers.
  • Not investigated (2)oscr_AudioSidechainExample, avnd_vb_fourses_tilde.
    Both now compare at the correct shape (3ch/3ch, 4ch/4ch), so what remains is a
    real value difference rather than truncation.
  • Oracle-side (3)avnd_persample_1 (golden emits silence; hand-computing
    the first sample confirms Max is right, the generator does not process
    float operator()(float, const inputs&, outputs&, tick)), avnd_test_prepare
    (message objects deliberately prepare with one frame per bang; the oracle
    assumes 64), avnd_test_initialize (Max runs initialize(), the oracle
    does not).
  • Expected (1)avnd_random is a noise generator and can never match.

Verification

  • All 112 Max externals and 98 Pd externals rebuild cleanly; CTest green.
  • Full sweep: 80 match / 8 mismatch / 0 crash, every object reached.
  • No regression: avnd_poles, avnd_lowpass, avnd_peak, avnd_sines,
    avnd_helpers_lowpass, avnd_helpers_per_bus_as_args, avnd_test_audio_poly
    and avnd_test_gpu_buffer_in all still match.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YYaRCC6UEG1spai7orCUBb

jcelerier and others added 8 commits July 29, 2026 14:23
The method registered as "matrix_calc" returned void, but process_mop() reads
its return value back through jit_object_method and passes it to
jit_error_code(). The "error code" was therefore whatever happened to be left in
the return register, and jit_error_code dereferences codes it does not
recognise -- an access violation inside jitlib on every bang.

avnd_test_gpu_buffer_in died this way every time (minidump: c0000005 reading
0x77f8f67f38e at jit_error_code+0x176). Other matrix operators survived only
because the leftover value happened to be benign.

Also stop requiring an output list when the object has no matrix outputs: an
analyzer with matrix inputs that reports through control outlets legitimately
gets a null outputlist, and rejecting it made every bang an error.

After this, avnd_test_gpu_buffer_in runs the golden harness to completion and
matches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYaRCC6UEG1spai7orCUBb
to_atoms' iterable overload advanced the write index only in the integral
branch; the float, string and enum branches all wrote atoms[i] with i never
incremented, so a container of any of those types arrived as its last element
in position 0 and uninitialised atoms after it. Affects attribute getters and
every list-valued output.

The bitset overload sized its buffer for 2 * N atoms but filled N, then sent
atoms.size() -- N uninitialised atoms on the wire after the data. Pd has the
same over-allocation but passes N explicitly, so it was latent there; corrected
in both so the next reader does not inherit the trap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYaRCC6UEG1spai7orCUBb
dsp() prepares the object for max(declared, actual) channels, but perform()
handed the adapter only the numins/numouts buffers Max actually wired. An object
that iterates its *declared* channel count then reads past the end of ins[] --
a crash dump on this machine caught avnd_helpers_per_bus_as_args faulting in
its perform loop on exactly that (movsd from a garbage channel pointer).

This is the input-side counterpart of #127, which fixed the same mistake on the
output side (multichanneloutputs reporting the input count).

Missing input channels now point at a silence buffer and missing outputs at a
scratch sink, so the spans always match what prepare() was told. When the counts
already agree -- the common case -- the pointers are Max's own and behaviour is
unchanged; if dsp() has not run yet we pass through exactly as before rather
than substituting silence.

This is a memory-safety fix. It does NOT change the golden verdicts for the
audio objects that currently mismatch: those feed and expect the same channel
count, so the padding never engages. avnd_poles, avnd_lowpass, avnd_peak and
avnd_helpers_per_bus_as_args still match afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYaRCC6UEG1spai7orCUBb
Three faults combined to blame two innocent objects for crashing Max:

1. Max's File.writeline TRUNCATES at 32767 characters -- it does not split, as
   parse_report's comment assumed. Objects with many outlets or long lists
   (avnd_all_ports_types, avnd_poles) emit longer lines, so their entry was
   never valid JSON and they never counted as done. Long lines now go out in
   sub-32K writestring chunks, and loadExisting rejoins continuation lines
   instead of treating each physical line as its own entry.

2. The breadcrumb was deleted once per invocation, not per launch. A stale one
   reads as "already started" the instant the next launch begins, so the 60s
   stall clock ran through Max's own 30-45s startup.

3. Progress was counted in parseable report entries, which for an object hit by
   (1) never appears. It is now measured by the breadcrumb moving on, which it
   does once per object, seconds apart.

The kill then stamped whatever object was current, which is why the same object
was blamed at a different case number on each run.

With this, avnd_all_ports_types, avnd_poles and avnd_test_gpu_buffer_in all
complete in a single launch: two match, and avnd_all_ports_types reports a real
value mismatch that was previously invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYaRCC6UEG1spai7orCUBb
…lared one

init() can only call init_channels() with the compile-time counts, and for a
mono or per-sample object that is 1. With a multichannel inlet the object then
had a single per-channel instance, so every channel above the first ran through
instance 0 and came out an exact copy of channel 0 -- audibly wrong, and the
reason Max disagreed with the golden oracle on precisely the mono and
per-sample objects.

dsp() now re-does it with the counts it actually negotiated, in the same order
the golden generator uses (allocate_buffers -> init_channels -> prepare).

Fixes the golden diff for avnd_test_audio_mono, avnd_test_audio_sample_ports,
avnd_helpers_per_sample_as_args, avnd_helpers_per_sample_as_ports and
avnd_persample_2. Four objects still differ for other reasons
(avnd_helpers_per_bus_as_ports_fixed, avnd_helpers_ui, avnd_modular,
avnd_persample_1); avnd_random is a noise generator and never matches.
No regression: avnd_poles, avnd_lowpass, avnd_peak, avnd_sines,
avnd_helpers_lowpass, avnd_helpers_per_bus_as_args, avnd_test_audio_poly and
avnd_test_gpu_buffer_in all still match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYaRCC6UEG1spai7orCUBb
…ndling

tests/objects/polyphony_channels.cpp (Catch2, no host needed) pins what the
audio bindings depend on: N channels means N effect instances, each reading its
own channel's samples, with no shared state between them. A stateless object
catches a channel coming out as a copy of another; a stateful one catches
instances being shared. This is the contract the Max binding broke by
initialising polyphony with the compile-time channel count.

tooling/tests/test_run_max_golden.py covers the report parsing and the stall
detector. The stall logic moved into a StallTracker class so it can be tested at
all: the key case is a breadcrumb advancing while no report entry ever becomes
parseable, which is precisely what made the harness kill Max and blame an
innocent object. Also covers rejoining an entry split across physical lines, a
corrupt entry not swallowing the ones after it, the port-name selector, and the
outlet layout. Registered with CTest when a Python interpreter is available.

parse_report also stops leaking the file handle it opens.

Not covered by a unit test: to_atoms and the Max outlet writers. Their symbols
live in MaxAPI.dll, which only exists inside the Max application -- a standalone
test binary links but cannot load. Those are exercised by the golden harness
instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYaRCC6UEG1spai7orCUBb
…lyzers an image

Two harness gaps that produced MISMATCHes indistinguishable from object bugs.

CHANS was 2, the number of buffer~ avin/avout pairs in the driver patch, and
anything above that was silently truncated: avnd_modular and
avnd_helpers_per_bus_as_ports_fixed (4 inputs each), oscr_AudioSidechainExample
(3 outputs) and avnd_vb_fourses_tilde (4 outputs) were all compared on 2
channels against a golden that used all of them. The patch now carries 8 pairs
and the driver warns instead of truncating quietly.

Texture ANALYZERS -- matrix in, control out, no matrix out -- were classified
"control" because the kind test only looked at texture *outputs*. buildControl
never sends a jit_matrix, so they analyzed an image they had never been given:
avnd_test_tex_variable reported Width=0 against a golden of 16. Anything with a
matrix on either side is now a matrix operator. buildTexture gained the control
outlet wiring it needs for that (an analyzer reports only that way), and only
creates the jit.matrix receiver when there really is a matrix output -- outlet 0
of an analyzer is a control outlet.

avnd_modular, avnd_helpers_per_bus_as_ports_fixed and avnd_test_tex_variable now
match. oscr_AudioSidechainExample and avnd_vb_fourses_tilde still differ but now
compare at the right channel count (3ch/3ch and 4ch/4ch), so what is left is a
real value difference rather than a truncation artefact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYaRCC6UEG1spai7orCUBb
AllPortsTypes read uninitialised memory. It is `struct inputs outputs;` with an
empty operator(), so the outputs are never written -- and not one port member
had an initialiser. The recorded "int" output was 1065353216, i.e. 0x3F800000,
the bit pattern of 1.0f; running the same golden binary twice produced different
values for array_double and array_int. All 192 port members are now
value-initialised (108 written "<type> value;" and 84 "<type>value;" without the
space, which a first pass missed), and the golden is bit-identical across runs.

oscr_TextureFilterExample case 4 downscales a 16x16 image by 32, so the object's
output is 0x0. No host matrix type can represent that -- Jitter has no 0x0
matrix -- so the backend necessarily reports something else and the dimension
diff was meaningless. compare_textures now skips a 0-sized *golden* texture
instead of failing it; cases 0-3 of that object were matching exactly all along.

Tests: tooling/tests/test_golden_compare.py covers the degenerate-texture rule,
including that one degenerate port does not excuse a genuinely wrong sibling, and
that the hash stays authoritative in "hash" mode and informational in "dims"
mode. test_run_max_golden.py gains object-kind classification cases: a texture
analyzer is a matrix operator (the bug that left it unfed), and audio still wins
over a matrix port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYaRCC6UEG1spai7orCUBb
@jcelerier
jcelerier merged commit bde96eb into main Jul 29, 2026
23 checks passed
@jcelerier jcelerier changed the title max: fix the jitter matrix_calc crash, atom conversion, channel counts; and the golden harness that invented two crashes max: fix the jitter crash, polyphony, atom conversion; and the golden harness that invented crashes Aug 3, 2026
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