Skip to content

Audit of 0.10.0, and Phase 8: its findings closed (8.1–8.7) - #35

Merged
tap merged 11 commits into
mainfrom
ccr-34c45a78-kfxhky
Oct 1, 2026
Merged

tap merged 11 commits into
mainfrom
ccr-34c45a78-kfxhky

Conversation

@tap

@tap tap commented Oct 1, 2026

Copy link
Copy Markdown
Owner

What this changes

A second adversarial audit of PythonTap (docs/AUDIT-2026-10.md, findings A1–A12), the plan that closes it (Phase 8 of docs/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:

  • A1 (high): worker::stop() joined a thread running a process() that never returns, so DSP off, a chain rebuild or deleting the object froze Max. Confirmed with a probe.
  • A3 (high, by reading min and the SDK): a Python method named dspstate, fileusage or patchlineupdate was registered with the A_GIMME trampoline that Max then calls with C arguments — the filechanged crash of 6.1 in its general form.
  • A2 (medium): NaNs written before process() raised mid-vector escaped unsanitized. Confirmed with a probe.
  • A4 (medium): the ReadMe's "nothing your code does can take Max down" overclaimed; os._exit() ends the host (confirmed), as does a C-extension crash or an endless loop.
  • A5 (medium): print() and numpy RuntimeWarnings in process() posted to the console from the audio thread, through a mutex.
  • A6 (medium): the worker thread ran on the platform's default stack (512 KiB on macOS, where CPython gives its own threads 16 MiB).
  • A7–A12 (low): helpers never hot-reloaded; float | int, Annotated, Final and Optional[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:

  • 8.1 The ReadMe, bar 1 and CLAUDE.md say what the guards cover and what they cannot; D2 corrected; assemble-package.py --merge refuses zip-slip through names and symlinks; the one tag-pinned workflow reference is explained.
  • 8.2 process_samples() sanitizes before both early returns. The reserved names cover every message min treats as A_CANT plus Max's dspstate, inputchanged and multichanneloutputs, and answered_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 while load() runs).
  • 8.3 stop() waits 100 ms, raises WorkerStopped (a BaseException, so except 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_thread gives the worker a 16 MiB stack.
  • 8.4 Console lines are assembled per thread and dispatched whole: at once on the host's main thread, else into a bounded lock-free queue (256 × 480 bytes, drop-and-count) drained by 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.
  • 8.5 Before executing a changed class file the loader drops every source module imported from python/ (not the class modules, not compiled extensions); write_bytecode is off.
  • 8.6 Annotated/Final unwrap; unions of several kinds map to any (the atom as it comes); subclasses map to their base and numpy scalars by their abstract bases, without importing numpy; return_shape sees through Optional.
  • 8.7 Every path shown to Max or the console goes through u8string().

Verification

Run here, on Linux (CPython 3.13.14, numpy 2.5.3, attrs 26.1.0), after every item:

  • The core battery in Release, under ASan/UBSan (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.
  • The Max glue against min's mock kernel (build-linux), with one new scenario: 1/1.
  • clang-format --dry-run --Werror over every own source, and clang-tidy on each touched translation unit with style.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 whose bin/python3 link survives.
  • The three audit probes that confirmed A1, A2 and A4 (recorded in the audit); the A1 and A2 ones are now tests.

Not run here, and the first real gate for it:

  • CI's macOS and Windows jobs — the universal build, the link-shape checks, the Windows link with worker.h now including <windows.h>/<process.h> for _beginthreadex.
  • The Mac session (8.8): the faults runtime test's new steps (DSP toggled and a cord connected with dspstate-style methods in the class — the mock kernel's object_getmethod() always answers null, so the guard's own effect is seen only in Max), the worker test's hang 1 → mode direct → hang 0 steps, whose timing I could not validate, the macOS secondary-thread stack default the 8.3 item asks to measure, Build Collective for fileusage by hand, and the Windows non-ASCII path check from the runbook. The attributes-and-messages runtime 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

  • Contract change. Several, all recorded under Unreleased in CHANGELOG.md (D5, pre-1.0): more names are reserved (8.2); a stalled process() 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) — a float | int field was a symbol attribute. Consumers: anyone's class files; the shipped examples are unaffected.
  • Decisions made inside the items, each written into the plan: WorkerStopped keeps 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.
  • One branch, seven items. The plan says one PR per item; this session could push only its designated branch, so they are one commit each here and split cleanly if preferred.
  • Max/Pd package. The object's MIN_DESCRIPTION is unchanged, so the reference page needs no regeneration; the faults and worker patchers were regenerated by make_patchers.py (the generator is the edit, as the repo requires).
  • Ships nothing new: docs/AUDIT-*.md is left out of the package as the plan is.

🤖 Generated with Claude Code

https://claude.ai/code/session_014sfhCxUoBQmLNnYBSn1ozG


Generated by Claude Code

tap and others added 11 commits October 1, 2026 13:46
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
@tap
tap merged commit bb9485d into main Oct 1, 2026
18 checks passed
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