Audit of 0.10.0, and Phase 8: its findings closed (8.1–8.7) - #35
Merged
Merged
Conversation
docs/AUDIT-2026-10.md: a second full audit after the plan's phases landed, read against what a user's class or Max can do to each promise. Confirmed with probes on the core: worker::stop() blocks forever on a process() that never returns (Max hangs when DSP toggles or the object goes); NaN samples escape when process() raises mid-vector; and os._exit() ends the host, so the ReadMe's "nothing your code does can take Max down" overclaims. By reading: the reserved-name list misses names Max calls with C arguments (dspstate, fileusage, patchlineupdate), user print() and numpy warnings post from the audio thread, the worker thread runs on the platform's default stack, helper modules never hot-reload, and a handful of low items. Records what was attacked and held (the reload/audio race, the worker ring, GIL lifetime, loading, supply chain, CI) and the tests each finding calls for. Release, ASan/UBSan and TSan batteries: 85/85 each. assemble-package.py leaves the audits out of the shipped package, as it does the plan. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
docs/PRODUCTION-PLAN.md gains Phase 8, one PR per item in severity order, each fix against a test that fails first: 8.1 the documents say what is true (the honest limits, helpers, D2) plus the zip-slip and tag-pin hardening; 8.2 NaN sanitized before process()'s early returns and the reserved names Max calls with C arguments; 8.3 a bounded worker stop (async exception, then detach) and a 16 MiB worker stack; 8.4 user console output deferred off the audio thread; 8.5 helper modules reloaded with the class file; 8.6 the type-hint mapping; 8.7 UTF-8 paths on Windows; 8.8 the Mac session that runs the new runtime tests. Sequenced before 1.0; the audit points at it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
8.3: WorkerStopped derives from BaseException so a class's except Exception cannot swallow it; the processor recognizes it and keeps the binding (a slow vector loses one vector, not audio); the detached thread shares the ring and the object leaks its processor only at destruction while such a thread lives. 8.4: lines assembled per thread, so the existing cross-thread interleave goes with the mutex; ordering caveat stated. 8.5: the class modules and compiled modules are excluded from the sys.modules drop; two-version and unchanged-file limits stated. 8.6: mixed unions map to any, not to a preferred member. 8.2: the class_method guard is pinned not to take int/float/symbol/bang/list away; fileusage is a by-hand step. 1.0 gate stated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
The ReadMe's claim that nothing a class does can take Max down becomes what the guards cover: no Python exception, sys.exit() included. Its errors paragraph names what never reaches Python's exception machinery and so is not caught (os._exit(), a crash in a C extension, code that never returns and which thread it freezes), that helper modules load once per Max session, and that a second embedded CPython in the same Max is unsupported. The plan's bar 1 says the same; D2 now describes the loader as built (compile and exec as _tap_python_<name>, no __spec__). CLAUDE.md's honest-limits rule carries the list. assemble-package.py's extract() refuses any entry whose path would leave the destination, through `..`, an absolute path or a symlink an earlier entry made, and any symlink whose target is absolute or resolves outside; checked with crafted zips. style.yml says why the family's own drift check is the one reference pinned by release rather than SHA. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
…arguments A2: every path out of process_samples() goes through one sanitize(io, written, frame_count), the two early returns included, so a vector that held NaNs before the exception no longer lets them through; the block path shares the helper. Pinned by a core scenario on nan_then_raise.py, which rendered three NaNs before the change. A3: the object reserves every message min treats as A_CANT plus Max's dspstate, inputchanged and multichanneloutputs, and the processor takes a host_answers predicate beside the list: answered_by_max() asks object_getmethod(), excluding the messages the object itself added for the previous incarnation's methods, which are still registered while load() runs. A Python method of such a name was registered with the A_GIMME trampoline, which Max then called with a long or a pointer: the filechanged crash of 6.1 in its general form. Pinned by a core scenario for the predicate, a glue scenario that the four C-argument names are absent and int/float/symbol/bang/list/greet present, and new steps in the faults runtime test (DSP toggled, a cord connected by thispatcher, with those methods in the class), for the Mac session. CHANGELOG entries for both and for 8.1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
worker::stop() no longer waits forever for a process() that does not return. It waits 100 ms, then raises WorkerStopped into the thread with PyThreadState_SetAsyncExc: a BaseException defined in the support module, so a class's except Exception cannot swallow it; the processor recognizes it (take_interruption), silences that vector, reports it once per load and keeps the class's audio bound. If the thread still has not returned 250 ms later it is blocked in a call Python cannot interrupt, and stop() detaches it: the ring is shared with the thread, has_abandoned_thread() says one is still running, and the Max object's destructor then leaks its worker and processor rather than free what the thread may yet touch, saying so. The worker thread is made by detail::native_thread (pthread_attr_setstacksize / _beginthreadex) with a 16 MiB stack, what CPython gives its own threads on macOS, where a std::thread's 512 KiB is too little for Python recursing through a C boundary. Pinned by four core scenarios (a process() that never returns; one blocked in time.sleep; a merely slow one, waited for; recursion to the limit through sorted's key on the worker), clean under ASan/UBSan and TSan, and new steps in the worker runtime test for the Mac session. ReadMe and CHANGELOG say what stopping the worker now does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
Console lines are assembled per thread (thread_local buffers, so two threads printing pieces of a line never mix them) and dispatched whole: at once on the host's main thread, or on any thread when the host gives no predicate, and otherwise into detail::console_queue, Vyukov's bounded lock-free queue (256 slots of 480 bytes; a longer line is cut with an ellipsis; a full queue drops and counts), with the host's console_ready called. flush_console() drains it to the sink in order from the main thread and adds one line for what was dropped. runtime_options gained is_main_thread and console_ready; set_console_threading() sets them later. The Max object nominates systhread_ismainthread and one process-wide qelem that flushes, so a print() or a numpy RuntimeWarning in process() no longer takes a lock and posts from the audio thread (or the worker's). Pinned by test_console.cpp: a line from another thread reaches the sink only through the flush, a flush=True partial line queues whole, 80 lines printed in pieces by two threads at a 1 µs switch interval come out unmixed, a flood of 10,000 keeps 256 and counts 9,744 dropped, a 2,000-character line is cut. The battery's other console checks stand unchanged. Release, ASan/UBSan and TSan clean; ReadMe and CHANGELOG say what is deferred. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
Before the loader executes a changed class file it calls forget_helpers(): every source module imported from the scripts folder — by normcase(abspath(__file__)) against the folder initialize() hands the support module, so no file-system call per module — is dropped from sys.modules, except the class modules themselves (_tap_python_*, which the type hints and the loader's cache depend on) and anything that is not a .py (a compiled extension cannot be imported twice). The fresh class then imports the helper's current source. PyConfig's write_bytecode is off, so no .pyc is written for the folder and the stale-bytecode hazard 3.6a closed for class files cannot return for helpers. A helper saved on its own is still not watched; the ReadMe says to save the class file too, with a change. Pinned in test_loading.cpp: the helper's new value after a changed save; another class file's module left in sys.modules and working; the old value after an unchanged save (the documented limit); no __pycache__. Release, ASan/UBSan and TSan clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
In the support module's hint_kind: Annotated[X, ...] and Final[X] unwrap to X, as objects and, by regex, as strings read as written; a union of several kinds maps to 'any', so the value passes as the atom carried it rather than one member being preferred and the others lost (a float | int field was a symbol attribute that stored "" for every number); a type that subclasses bool, int, float or str maps to its base, and numpy's scalar types map by their abstract bases' names in the MRO, without numpy being imported; return_shape sees through Optional and | None, so -> tuple[float, float] | None names two outputs instead of binding one and reporting every sample as not a number. Pinned in test_types.cpp with typed_more.py, typed_strings.py (hints that cannot resolve, read as written, for the string paths) and typed_numpy.py. Release, ASan/UBSan and TSan clean; ReadMe and CHANGELOG record the mapping. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
detail::utf8(path) converts through u8string(): on Windows, std::filesystem::path::string() gives the ANSI code page where Max's path API and console expect UTF-8, so the file watcher failed for a package under a folder with a non-ASCII name and hot reload was silently off. It is used for the watcher's file name, the object's runtime messages and the core's "No file" and "Failed to load" lines; the Python-facing conversions already took wide strings. To verify on Windows in the runbook step; the same bytes on POSIX, where the battery passes unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
…s UTF-8 bytes worker.h (8.3) includes windows.h for _beginthreadex, and windows.h defines min and max as function-like macros: std::min(…), std::max(…) and numeric_limits::max() in the header stopped parsing under MSVC, and every later error in the job was cascade from those. NOMINMAX is defined before the include (the SDK and min-api are cross-platform headers that cannot depend on those macros), every min/max call in the core headers is parenthesized so they compile even in a translation unit that saw windows.h first, and <cerrno> is included for the errno the Windows branch reports. Checked here by compiling worker.h with min and max defined as macros before it, under GCC and clang. The console's ellipsis (8.4) was written as a … escape, which MSVC encodes in its execution charset rather than UTF-8; it is now the three UTF-8 bytes, in the queue, the dropped-lines message and the test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
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.
What this changes
A second adversarial audit of PythonTap (
docs/AUDIT-2026-10.md, findings A1–A12), the plan that closes it (Phase 8 ofdocs/PRODUCTION-PLAN.md, itself audited before adoption), and the seven items of that plan that need no Mac — one commit each, 8.1 to 8.7. Left open: 8.8, the Mac session that runs the new runtime-test steps.Why
The audit read 0.10.0 against what a user's class, or Max, can do to each promise. The design discipline held — the GIL rules, the worker ring, the block-buffer swap,
SystemExit, loading, the supply chain — and ASan/UBSan/TSan agree. The edges did not:worker::stop()joined a thread running aprocess()that never returns, so DSP off, a chain rebuild or deleting the object froze Max. Confirmed with a probe.dspstate,fileusageorpatchlineupdatewas registered with theA_GIMMEtrampoline that Max then calls with C arguments — thefilechangedcrash of 6.1 in its general form.process()raised mid-vector escaped unsanitized. Confirmed with a probe.os._exit()ends the host (confirmed), as does a C-extension crash or an endless loop.print()and numpyRuntimeWarnings inprocess()posted to the console from the audio thread, through a mutex.float | int,Annotated,FinalandOptional[tuple[…]]mapped surprisingly; Windows non-ASCII paths broke the watcher; a symlink zip-slip gap in the package merger; a tag pin; plan D2 described a replaced loader.What each commit does:
assemble-package.py --mergerefuses zip-slip through names and symlinks; the one tag-pinned workflow reference is explained.process_samples()sanitizes before both early returns. The reserved names cover every message min treats asA_CANTplus Max'sdspstate,inputchangedandmultichanneloutputs, andanswered_by_max()reserves any name the Max object already answers (excluding the messages the object itself added for the previous incarnation's methods, still registered whileload()runs).stop()waits 100 ms, raisesWorkerStopped(aBaseException, soexcept Exception:cannot swallow it) into the thread; the processor recognizes it, silences the vector, reports once and keeps audio bound; after 250 ms more the thread is detached and abandoned, the ring shared with it, and the Max object leaks its worker and processor at destruction while such a thread lives.detail::native_threadgives the worker a 16 MiB stack.flush_console()from a process-wide qelem. Two threads printing pieces of a line no longer mix them — a pre-existing bug the spec uncovered.python/(not the class modules, not compiled extensions);write_bytecodeis off.Annotated/Finalunwrap; unions of several kinds map toany(the atom as it comes); subclasses map to their base and numpy scalars by their abstract bases, without importing numpy;return_shapesees throughOptional.u8string().Verification
Run here, on Linux (CPython 3.13.14, numpy 2.5.3, attrs 26.1.0), after every item:
halt_on_error=1) and under TSan (halt_on_error=1): 85/85 before this branch, 98/98 at its head, no sanitizer report at any step.build-linux), with one new scenario: 1/1.clang-format --dry-run --Werrorover every own source, and clang-tidy on each touched translation unit withstyle.yml's invocation (it caught one const mismatch GCC accepted, fixed in 8.2).assemble-package.py's extractor against four crafted zips (a..name, an absolute link, an escaping link, a write through an escaping link), all refused, and an honest zip whosebin/python3link survives.Not run here, and the first real gate for it:
worker.hnow including<windows.h>/<process.h>for_beginthreadex.faultsruntime test's new steps (DSP toggled and a cord connected withdspstate-style methods in the class — the mock kernel'sobject_getmethod()always answers null, so the guard's own effect is seen only in Max), theworkertest'shang 1→mode direct→hang 0steps, whose timing I could not validate, the macOS secondary-thread stack default the 8.3 item asks to measure, Build Collective forfileusageby hand, and the Windows non-ASCII path check from the runbook. Theattributes-and-messagesruntime test is what would show the 8.2 guard over-reserving in a real Max.No performance claim is made; the ReadMe's generated tables are untouched.
Notes for the reviewer
CHANGELOG.md(D5, pre-1.0): more names are reserved (8.2); a stalledprocess()in worker mode is interrupted or abandoned rather than waited for, and a merely slow vector is still waited for (8.3); console output from any thread but Max's main one is deferred and coalesced, so it may appear after main-thread lines printed meanwhile (8.4); helpers reload with a changed class file (8.5); the hint mapping (8.6) — afloat | intfield was a symbol attribute. Consumers: anyone's class files; the shipped examples are unaffected.WorkerStoppedkeeps the binding rather than unbinding, and abandonment leaks on purpose (8.3); only off-main output is deferred, so the battery's synchronous console checks stand (8.4); 8.5 keeps the documented "save the class file too" rule rather than a forced reload or a folder watch — revisit if users ask.MIN_DESCRIPTIONis unchanged, so the reference page needs no regeneration; thefaultsandworkerpatchers were regenerated bymake_patchers.py(the generator is the edit, as the repo requires).docs/AUDIT-*.mdis left out of the package as the plan is.🤖 Generated with Claude Code
https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG
Generated by Claude Code