max: fix the jitter crash, polyphony, atom conversion; and the golden harness that invented crashes - #184
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A
tooling/run_max_golden.pysweep over the 112 objects reported 3 crashes and10 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_injitter_processor.hppregisteredmatrix_calcas a lambda returningvoid,but
process_mop()reads that method's return value back throughjit_object_methodand feeds it tojit_error_code(). The "error code" waswhatever happened to be left in the return register, and
jit_error_codedereferences codes it does not recognise → access violation inside
jitlib.Minidump:
c0000005reading0x77f8f67f38eatjit_error_code+0x176, reachedfrom the external's
process_mopframe. Other matrix operators survived onlybecause 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_polesBoth pass every case in isolation. Three compounding harness faults:
File.writelinetruncates at 32767 characters — it does not split,which is what
parse_report's comment assumed. Both objects emit longerreport lines, so their entry was never valid JSON and they never counted as
done. Long lines now go out in sub-32K
writestringchunks, andloadExistingrejoins continuation lines.stale one read as "already started" and the 60 s stall clock ran through
Max's own 30–45 s startup.
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_calcreturn type (above).to_atomswrote every element toatoms[0]. The iterable overloadadvanced the write index only in the integral branch; float, string and enum
wrote
atoms[i]withinever incremented.2 * Natoms, filledN, sentatoms.size()— N uninitialised atoms after the data. Pd had the sameover-allocation but passed
Nexplicitly, so it was latent; corrected in both.perform()handed the adapter fewer channels than the object was preparedfor, so an object iterating its declared channel count read past the end of
ins[]. A crash dump caughtavnd_helpers_per_bus_as_argsfaulting in itsperform 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.
init()canonly 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 thesame. 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
CHANSwas 2, the number ofbuffer~pairs in the driver patch, andanything above was silently truncated:
avnd_modularandavnd_helpers_per_bus_as_ports_fixed(4 inputs),oscr_AudioSidechainExample(3 outputs),
avnd_vb_fourses_tilde(4 outputs) were compared on 2 channelsagainst goldens using all of them. Now 8 pairs, and the driver warns rather
than truncating quietly.
texture outputs, so an analyzer (matrix in, control out) was built by
buildControl, which never sends ajit_matrix—avnd_test_tex_variablereported
Width=0against a golden of 16. Anything with a matrix on eitherside is now a matrix operator.
oscr_TextureFilterExamplecase 4 downscales 16×16 by 32, so the object'soutput is 0×0 — which no host matrix type can represent. Cases 0–3 matched
exactly all along.
One example object was reading uninitialised memory
AllPortsTypesisstruct inputs outputs;with an emptyoperator(), so theoutputs are never written — and not one port member had an initialiser. The
recorded
intoutput was1065353216, i.e.0x3F800000, the bit pattern of1.0f; running the same golden binary twice produced different values forarray_double/array_int. All 192 members are now value-initialised and thegolden is bit-identical across runs.
Tests
tests/objects/polyphony_channels.cpp(Catch2, no host required) pins thecontract 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
StallTrackerso it could be tested at all — the key case isa 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_atomsand the Max outlet writers have no unit test. Their symbols livein
MaxAPI.dll, which exists only inside the Max application — a standalone testbinary links but cannot load it (verified). They are covered by the harness.
What still mismatches (8 of 112)
avnd_helpers_ui,avnd_uideclare anaudio output they never write; the oracle emits silence, Max emits stale buffer
contents (traced to input channel 1 verbatim). Max should zero output buffers.
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.
avnd_persample_1(golden emits silence; hand-computingthe 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 runsinitialize(), the oracledoes not).
avnd_randomis a noise generator and can never match.Verification
avnd_poles,avnd_lowpass,avnd_peak,avnd_sines,avnd_helpers_lowpass,avnd_helpers_per_bus_as_args,avnd_test_audio_polyand
avnd_test_gpu_buffer_inall still match.🤖 Generated with Claude Code
https://claude.ai/code/session_01YYaRCC6UEG1spai7orCUBb