Drafting record for taking tap.python~ from a working sketch to a shippable 1.0. It grew out of
a four-dimension audit (threading/RT safety, CPython C-API use, build/CI/supply chain,
tests/docs/conventions) in September 2026; the findings are summarized per phase below with
file:line anchors as of commit b6dc003 (since Phase 0.1, the CPython-facing code those anchors
point at lives in core/include/tap/python/processor.h and runtime.h). Keep this file current as phases land — tick items and
note the PR that closed them.
- User Python cannot take Max down. No crash, no process exit, no hang from anything a user
script does that reaches Python's exception machinery (
sys.exit(), exceptions, non-numeric returns, NaN, pathological names). What never reaches it is outside the guard and said so in the ReadMe (8.1): a process exit below Python (os._exit()), a crash in a C extension, and code that never returns — which freezes the thread it runs on (main for a message, audio forprocess(); worker mode confines the latter to the worker thread and, since 8.3, stops it or abandons it). - The audio thread is protected. No crash, no allocation or console posting on the hot path, and a defined, documented behavior (silence, not a stall) when Python misbehaves.
- Every documented behavior is pinned by a test — unit (mock kernel) where possible, in-Max runtime tests where not.
- A reproducible, verifiable package ships from CI, with pinned and hash-checked dependencies.
- Behavior is validated in a real Max, not only against the mock kernel.
| # | Decision | Choice |
|---|---|---|
| D1 | Where process() runs |
Direct now, worker later. A block path called once per vector on the audio thread (zero latency); the per-sample path stays as a documented slow path; an opt-in worker-thread mode (@mode worker, lock-free FIFO, ≥1 vector latency, audio thread never takes the GIL) follows. |
| D1a | Opting into the block path | By type hint. process(self, x: np.ndarray) -> np.ndarray is called per vector; process(self, x: float) -> float per sample. Detected at bind time. |
| D2 | How [tap.python~ name] loads code |
File-based. python/<name>.py is loaded by path — its source compiled and executed by the loader in the support module (3.5), never imported, so no .pyc is read or written for it (3.6a) — as a fresh module registered in sys.modules as _tap_python_<name> (with __file__ only: no __spec__ or __package__, so no relative imports; the folder is flat by design); name must be an identifier; python/ is appended to sys.path so sibling-helper imports keep working without shadowing the stdlib. (Revised with 8.1: the first draft named importlib.util.spec_from_file_location and _tap_python_user.<name>.) |
| D3 | How users get the runtime | Bundled in the release. CI assembles a complete per-platform package (externals, support/ with CPython + attrs + numpy, python/, help, docs, licenses). scripts/install-runtime.* stays for source builds. Signing/notarization steps are wired but skip cleanly until credentials exist (none yet). |
| D4 | Python version policy | Pin 3.13; upgrade deliberately. One CPython minor per release; a move (e.g. to 3.14's deferred annotations) is its own PR with tests. No free-threaded or subinterpreter builds until numpy supports them. |
| D5 | Compatibility before 1.0 | Breaking changes to the class contract are allowed where they buy correctness (reserved names, file-based loading, signature dispatch). Each is recorded in a CHANGELOG.md; the shipped examples are updated in the same PR. |
| D6 | Architecture | A host-independent core plus a thin Max wrapper, the family's kernel/wrapper split (TapTools / TapTools-Max). Everything that talks to CPython — interpreter start-up, thread state, module loading, class introspection, value conversion, process() binding and reload, exception handling — lives in core/ (tap::python, plain C++20 + CPython, no Max or min-api). The external maps the core's attribute and message descriptions onto object_addattr/object_addmethod and owns only Max concerns (package paths, the file watcher, atoms). Linux is the first test platform for the core: real audio/main threads and sanitizers in CI and in cloud sessions. A plugin front end (CLAP or VST3) is optional later work over the same core (Phase 7), not a test vehicle — the Max glue still needs its own tests. |
| D7–D11 | A second object, tap.python, without audio |
Decided 2026-10-02 in docs/TAP-PYTHON-PLAN.md, revised 2026-10-09 for its audit (Phase 9 below): the same core, folder, loader and class contract minus audio; a method's return value is the output, its return hint the outlet count; one inlet; a message runs on the thread it arrives on; and one binary registers both Max classes — two externals would each start the interpreter, and the second aborts Max — the second a plain SDK class (min allows one class per translation unit), found through the package's init/ mapping. |
Every later phase needs proof, and today the one unit test passes on either branch (below). The core split (D6) makes the CPython layer buildable and testable on Linux, so fixes can be reproduced and pinned there first.
- 0.1 Extract the core (D6).
core/include/tap/python/—runtime.h(initialization,gil_lock, the console module),value.h(the Max-atom value model and its coercion rules),processor.h(load/reload, introspection, attribute/message dispatch,process()). The extraction is behavior-preserving except where noted in its PR; the known bugs are fixed in Phase 1 against tests that reproduce them. - 0.2 Linux core battery.
core/tests/(Catch2, the family's FetchContent pin) against CPython 3.13 fromfind_package(Python3): runtime start-up and the console, the coercion table, load/reload/error paths, attribute and message dispatch,process(), a real audio thread racing main-thread messages, and the shipped examples. Alinux-coreCI job, plus ASan/UBSan and TSan rows. The Max test target moved to C++20 with it (the core needsstd::span). Found on the way: a newly created processor gets a module this process already imported, even if its file changed since (the first load goes throughsys.modules) — pinned as an honest limit, fixed by file-based loading (3.5). - 0.3 A package root the Max test can find. Under
MIN_TEST,package_root()is the folder above the test binary (<repo>/tests/) on every platform; it also makes the loader's path absolute (a binary launched as./nameresolved to an empty root). Before this, it walked five levels up from the test binary on macOS and landed outside the repo, so the macOS job never started Python. Also: the external and its mock-kernel test now build and run on Linux against a system CPython 3.13 (linux-max-gluein CI) — the spike this item used to be succeeded. - 0.4 No either-way assertions. Every build has a runtime (configure fails without one), so
the Max test now requires the interpreter to start and
python/default.pyto run. - 0.5 Max-glue coverage —
attr_set/attr_getround-trips through float, long and symbol atoms, multi-atom refusal, and thefloat/intmessages and arity refusal throughmessage_gimme(the C trampolines that forward to it are not driven directly yet; their per-type conversion is covered by the core coercion tests). Reserved-name refusal is tested with 1.3. - 0.6
CLAUDE.md— runtime prerequisite, the class contract, GIL/thread rules, the core/wrapper split, TapHouse sync rules, and what must move together (maxref, help patcher, notebook, this plan).
Each fix landed against a core test that reproduced the bug first (core/tests/test_safety.cpp):
before the fixes, sys.exit(3) in a message ended the test process with status 3, and reloading
while audio ran segfaulted in 5 of 5 runs.
| # | Fix | Pinned by |
|---|---|---|
| ✅ 1.1 | report_exception() (runtime.h) replaces every PyErr_Print(): it displays the exception via PyErr_DisplayException — a SystemExit is reported, never honored, and sys.last_exc is not retained |
sys.exit() in a message, in process(), in an attribute setter, at module top level and in the constructor → the process survives and the traceback reaches the console |
| ✅ 1.2 | The reload/perform race: load() builds the new binding (instance, process function, attributes, messages) completely, then swaps it in with no Python call in between; process() holds its own references for the whole vector and calls through PyObject_Vectorcall (no shared args tuple); call()/set_attribute()/get_attribute() hold their own references and copies before anything that could yield the GIL. A successful reload no longer silences the audio |
200 reloads against a slow process() with a 1 µs switch interval: no crash, no silent or partial vector (40/40 runs; ASan/UBSan and TSan clean) |
| ✅ 1.3 | Reserved message names: the processor takes a host list and refuses those methods with a diagnostic; the external passes Max's (filechanged, dsp64, notify, assist, anything, …) |
Methods named like reserved host messages are not exposed |
| ✅ 1.4 | gil_lock gives each thread one Python thread state for its lifetime (detail::thread_state_keeper), instead of PyGILState_Ensure/Release creating and freeing one per vector |
threading.local on the audio thread survives across vectors |
| ✅ 1.5 | Conversions NULL-checked before use; a strong reference to the class across __init__; every C trampoline, the file-watcher handler and the perform routine catch C++ exceptions |
Unit tests; review |
| ✅ 1.6 | m_instance/m_process_function are std::atomic<PyObject*> (written only under the GIL), so the audio thread's lock-free check and loaded() are well-defined |
TSan on the linux-core leg |
- 2.1 Nothing prints on the audio thread. process() raising, a non-numeric or non-finite
result, and a block of the wrong length are recorded on the audio thread (the exception object
and flags, no allocation or printing) and the host's
report_readycallback fires — in Max aqueue<>(qelem) — soflush_reports()prints them from the main thread, each kind once per load. Non-finite output is zeroed. Honest limit: the per-sample path still allocates a Python float per sample (CPython's free list); the block path allocates nothing per sample on our side (what the user's numpy code allocates is theirs). - 2.2 Block path (D1a). A first parameter hinted
np.ndarraybinds process() per vector: a processor-ownednp.zeros(vs)input array, reused (its buffer held, so its memory cannot move), sized atprepare()and resized only if a vector arrives in another size; the result is read through the buffer protocol, ornp.ascontiguousarray(…, float64)for other dtypes and lists; a wrong length orNoneis reported and silenced. Rebuilding the buffer while audio runs is swap-safe (200prepare()calls against a running block path: no corrupt vector, TSan clean). - 2.3
prepare(self, sample_rate, vector_size), from min'sdspsetup; also called on each newly loaded instance before it is published, so no vector ever runs on an unprepared instance.allpass.pyuses it (and its α range is now open, per 5.4). - 2.4 Multiple inlets/outlets —
processarguments define signal inlets and a tuple return defines outlets. Decided (2026-09-30): when a save changes the counts, the object adapts its inlets and outlets in place with Max's dynamic inlets and outlets (dynlet_begin/dynlet_endarounddsp_resizeandoutlet_append/outlet_delete, as Cycling '74 describes in its developer forum) rather than asking for the object to be re-created. Three steps: (a) the core — done:processor::process()takes N input and M output channels; the positional parameters are the inputs (allnp.ndarrayor all per sample; none makes a generator), the return hint the outputs (tuple[float, float]for two; a tuple of unsaid length,*argsor mixed hints are reported and not bound);input_count()andoutput_count()tell the host; the host's channels are matched to the class's (a missing input reads as silence, an extra output is silent); a result of the wrong length is silenced and reported once per load. (b) the Max object's inlets and outlets from the class at creation — done: the constructor adds aninlet<>per input after the first (its help naming the parameter:processor::input_names()) and anoutlet<>per output after the first, before min makes the Max ports from its lists; after a reload that changes the counts it says so, once, and the core matches the channels. New examplestereo_width.py(two in, two out;width). Mock test:[tap.python~ stereo_width]has two of each and processes both (the test file now stands in a faithfulattr_args_offset, and the object reads a one-symbol argument directly, not throughatom_gettext— the mock's gives nothing); runtime testchannels(stereo_width, a generator, a save that changes the shape). (c) adapting them on reload (dynlets) — done: after a reload that changes the counts,adapt_ports()finds the object's box (#B) and, betweendynlet_beginanddynlet_end, sets the signal inlets withdsp_resizeand deletes or appends outlets at the end (outlet_nth/outlet_delete,outlet_append); min's own inlet and outlet lists follow at once (its dsp64 reads a connection count for each, its assist the help text, now renamed with the parameters on every reload); thendspchain_setbroken(dspchain_fromobject())has the signal chain rebuilt. Without a box the object keeps its ports and says so, as in (b). Mock test: the dynlet calls recorded against a pretend box, for a save that grows, shrinks, renames and breaks the class; runtime testchannels: grown from two to three, the old cords carry on and cords thatthispatcherconnects to the new inlet and outlet carry signal (this check fails against (b)); shrunk to one, the removed outlets' cords go with them. - 2.5 Worker mode (D1) —
@mode worker: Python on a worker thread, lock-free FIFO, latency reported to Max, underrun → silence. Design (decided 2026-09-30):- What it buys. In direct mode the audio thread takes the GIL, so anything else holding it — a reload compiling a large file (2.6's limit), a message handler, a GC pass — delays the buffer. In worker mode the audio thread never takes the GIL or calls CPython: it copies vectors in and out of a queue, and Python runs on a thread of its own, a fixed latency behind.
- In the core (D6), beside
processor: aworkerthat owns the thread and a ring of slots, each one vector of every host input and output channel. The audio thread writes vector k's inputs into a slot and reads vector k − L's outputs; the worker takes slots in order and runs the existingprocessor::process()on them — per sample or per vector, with 2.4's channel matching — so the class contract does not change. Slot states are atomics (single producer, single consumer, no locks); the audio thread wakes the worker with a C++20 atomic notify, which never blocks. The worker's thread is created and joined on the main thread. - Latency L is whole vectors, set by
@latencyin milliseconds (default 30, rounded up to whole vectors, at least one — revised twice after measuring, below: from "vectors, default 2", then from 10 ms), and reported in samples (L × vector size) by the read-only@latencysamples, which a patch can read to align other paths. Max has no documented call for an MSP object to report latency to the host, so none is used; the output is primed with L vectors of silence. - Underruns. A slot not done when its outputs are due is output as silence and counted; the
worker still processes it when it gets there (so the class's state stays continuous) and
discards its outputs, catching up through the backlog, so the latency stays L. If it falls
a whole ring behind (the ring holds L + a margin), new inputs are dropped until it catches
up, which the class sees as a gap. Both are reported once per load from the main thread (
flush_reports()), never printed on the audio thread. prepare()and the mode. The ring is sized when the chain compiles (dspsetup: vector size, and the object's inlet and outlet counts), with the worker stopped and restarted around it;prepare()runs as now, on the main thread.@modeand@latencytake effect at the next compile — setting them marks the chain broken (as 2.4 does), so that is at once while audio runs.- Reloads, attributes and messages are unchanged: they take the GIL on the main or scheduler thread, and the worker picks up a new binding at its next vector, as the audio thread does now. An attribute change is heard L vectors later.
- Tests. Core battery (Linux, under TSan too): worker output equals direct output delayed by
L vectors, per sample and per vector, several channels; a worker stalled by a class that
sleeps underruns to silence, is reported once, and comes back at the same latency; reload
under a running worker; resizing in
prepare(); destruction joins the thread. Runtime test in Max:@mode workeragainstdelay~of L vectors, a reload under audio, and@latencysamples. - Decided: latency in whole vectors, default 2; an underrun is silence with the latency kept;
one Python call per host vector — batching several per call would cut the per-call overhead
further (worker mode's other win) at more latency, a later option (
@block) if measurements show it pays. - (a) the core — done:
core/include/tap/python/worker.h. The ring is single producer, single consumer, with a sequence number per slot: the audio thread queues a vector only into a slot the worker has finished with, so a full ring drops the new vector rather than racing the worker for an old one; the margin is a quarter of a second, at least 16 vectors. The audio thread borrows the ring for each vector by taking an atomic pointer, sostart()andstop()— which a host may call while its old signal chain still runs — wait at most one vector, and a vector arriving meanwhile is silence. Reports are once per load, by the processor's newload_count(). Core battery: the output is the direct output L vectors later (per sample and per vector, L = 1–3, two channels); the host's channels matched to the ring; a stall goes silent, is reported once, comes back at the same latency, and is reported again after a reload; a stall past the ring drops exactly the vectors beyond it; restart, stop, and stop during a stall; ten reloads under an audio thread. Clean under TSan (locally, macOS) — the Linux CI job runs it too. (b) the Max object:@mode,@latency,@latencysamples, the ring sized indspsetup; runtime test in Max — done. Found in Max: an ordinary worker thread was late for vectors in 2 of 5 quiet seconds even with 32 vectors (21 ms) of latency, on a busy machine (load average 6–11); the worker now gets the real-time scheduling of an audio thread — a hook the core calls on the thread as it starts (thread_setup), where the wrapper sets Mach's time-constraint policy (macOS: a period of one vector, half of it computation) orTHREAD_PRIORITY_TIME_CRITICAL(Windows). With it, at 96 kHz and 64-sample vectors, the seconds with a late vector: 10 of 10 at 2 vectors (1.3 ms), 10 of 10 at 4, 3 of 10 at 8 (5.3 ms), none at 16 (10.7 ms) — and a reload's hold on the GIL (2.6: about 3.5 ms) is longer than 2 vectors there. Hence a latency in milliseconds, which stays the same delay whatever the vector size. Then: Max computes a whole I/O vector of signal vectors back to back (here 512 samples, so 8 at a time), and the worker has only the latency beyond that burst to do them in — which is why 8 vectors (512 samples) failed and 16 did not. 10 ms (960 samples here, 4.7 ms beyond the burst) still left late vectors in 2 of 11 seconds, and at 44.1 or 48 kHz with Max's usual 512-sample I/O vector it would leave nothing. Decided:@latencystays the whole delay, default 30 ms, documented as having to exceed the I/O vector's duration (measuring the burst and adding to it was the alternative). Fields as well as methods are now checked against the host's reserved names (mode,latencyandlatencysamplesamong them).
- 2.6 Shorter reload stalls — compile outside the swap and hold the GIL only for the swap.
Measured first (
core/bench/reload_bench.cpp: an audio thread with Core Audio's real-time scheduling computes 512-sample buffers of 64-sample vectors at 96 kHz while the main thread saves and reloads the class every 100 ms): a reload of the allpass examples holds the GIL for about 3.5 ms, and with CPython's 5 ms switch interval the audio thread waited for all of it — late buffers in most runs, the worst 45 ms (the reloading thread descheduled while holding the GIL). Done: the runtime sets a 0.5 ms switch interval (runtime_options::switch_interval): no late buffer in three runs, the 99th percentile 0.7–2 ms, the median unchanged. Splittingload()into phases was not needed for files this size: what a switch cannot interrupt is a single C call — compiling the file, above all — which is short for them; a very large class file would still compile in one piece (2.5, worker mode, is the answer to that).
- 3.1 Signature-based dispatch (
inspect.signature): required/optional arity, defaults,*args, keyword-only parameters; unannotated parameters convert by incoming atom type. Today arity is the count of annotated parameters (tap.python_tilde.h:190, 522-540). Done: the support module'sdescribe()(inspect.signature + hints) gives positional types, the required count and*args; messages call the bound method, so classmethods/staticmethods work; a required keyword-only parameter keeps a method from being exposed;methods()usesinspect.getattr_static, so describing a class runs no property getter. - 3.2 Types —
bool→ long attribute; unwrapOptional/Unionviatyping.get_origin/get_args(today they fall through to symbol and store"");list[float]→ list attribute; 64-bit ints (PyLong_FromLongLong,t_atom_long) so Windows doesn't truncate; getters never report-1as a value. Done, exceptlist[float]attributes (still symbols — a list attribute needs its own Max-side design);ClassVaris not a field. - 3.3 Hint failures are reported, with a per-field fallback via
inspect.get_annotations, instead of silently producing no attributes (tap.python_tilde.h:366-368, 437-440). Done: the exception is displayed and the annotations are read as written (a string hint by name;Optional[...]/| Noneunwrapped). - 3.4 Reload reconciles attributes — delete removed fields, re-type changed ones, and
carry the patcher's current values onto the new instance (
tap.python_tilde.h:392-395, 424). Done: the core snapshots the attribute values before a load (kept across a failed one) and sets them on the new instance before it is published — same name and type only; a value the new class rejects is reported. The external removes Max attributes the class dropped (object_deleteattr) and recreates retyped ones. - 3.5 File-based loading (D2); the source argument must be an identifier. Done: a loader
created at start-up compiles
<scripts>/<name>.pyitself and executes it as a fresh_tap_python_<name>module registered insys.modules(typing/attrs resolve annotations there); the scripts folder is appended tosys.path. Fixes the pinned "new processor reuses a stale module" limit. - 3.6 One reload per module shared by all instances of that file, debounced (today each instance's watcher re-executes the module). Done: the loader caches modules by source bytes, so the first instance to load a save executes it and the rest reuse it — no timer needed.
- 3.6a Bytecode staleness on fast saves. Reload goes through the normal source loader,
whose
.pyccheck is mtime (1 s resolution) + size, so two same-size saves within a second can reload stale bytecode. Disable bytecode writing for user scripts, or load them uncached (D2). Done: the loader compiles the source directly; no.pycis read or written for class files. - 3.7 Console streams — a real
flush(), plusencoding/errors/isattyfor libraries that probe them (_runtime.h:184-194). Done:io.TextIOBasesubclasses;s#so NULs pass. - 3.8 Namespace — move
python_attr,python_messageand the trampolines intotap::python; correct TapHouse's README, which says PythonTap has no namespace of its own. The move is done (and the headers lost their file-scopeusing namespace), and the core also exports the conventionaltap::pythonCMake alias (keepingtap::python_core); the TapHouse README correction — adding PythonTap to its namespace table — is a separate PR in tap/taphouse. Done there too: TapHouse's README liststap::python(tap/taphouse349b1a4).
- 4.1 CMake — the macOS arch detection cached with
FORCEand never re-detected: it now remembers its own value and re-detects on every configure whileCMAKE_OSX_ARCHITECTURESstill holds it, so a reinstalled runtime (native ↔ universal) is picked up by an existing build folder; a value you set is kept but refused at configure if wider thanlipo -archsof the runtime. (C++20 on the test target landed with Phase 0.)BUILD_WITH_INSTALL_RPATHon the external, so only the@loader_pathrpath is embedded — CI now fails on any absolute rpath. - 4.2 Loadable without a runtime — libpython is weakly linked on macOS and delay-loaded
on Windows, and the object checks the runtime before its first Python call, printing what is
missing and how to install it. MSVC refuses to delay-load a DLL whose data a module imports, and
the core used four (
Py_None,PyExc_RuntimeError,PyFunction_Type,PyMethod_Type); it now reaches them only through function calls (Py_GetConstantBorrowed, a builtins lookup, attributes), andscripts/check-data-imports.shgates the Linux build on that. On macOS the check reads the weak binding itself (dyld never binds it later), so a runtime installed while Max runs needs a restart; on Windows the DLL is loaded by full path fromsupport/, so the next object picks it up. CI checks the link shape on both (LC_LOAD_WEAK_DYLIB, delay-load dependencies); that Max then loads the external without a runtime is a Phase 6 check. - 4.3 Installers —
scripts/runtime.lock(per-triple asset + SHA256) andscripts/requirements.lock(attrs/numpy wheels,--require-hashes --only-binary :all:), both generated byscripts/update-locks.py; no more trusting the release's ownSHA256SUMSor unpinned PyPI, and nopip --upgrade pip. Both installers build the new runtime in a scratch folder, move the old one aside and restore it on any failure; the.ps1checks$LASTEXITCODEafter every native command; the.shdetects Apple Silicon under Rosetta (hw.optional.arm64), rejects unknown flags and refuses non-macOS. Exercised on Linux with stand-ins for the Mac tools: both archives verified, a pip failure rolled back to the previous runtime, a tampered hash stopped before touchingsupport/, a stalesupport.previousleft alone. - 4.4 CI hardening — third-party actions pinned by commit SHA;
permissions: contents: read; superseded runs cancelled;support/cached on the lock files and installer; the Linux jobs install the examples' packages from the same lock; the tidy gate fails on a clang-tidy failure, not only on a finding (checked with a broken invocation); tidy against 3.13 headers landed with Phase 0. - 4.5 Release workflow (D3) —
release.yml, on avX.Y.Ztag (or by hand, artifacts only): builds and tests per platform, assembles the package withscripts/assemble-package.py, zips it with SHA256 checksums, and attaches everything to a draft release. The runtime is per architecture (only libpython is universal, for linking), so macOS ships two packages — arm64 and x86_64, each with the universal external — built on runners of their own architecture, which also runs the tests on Intel. Signing (scripts/release/sign-macos.sh: every Mach-O, hardened runtime, timestamp, then notarytool + staple;sign-windows.ps1: Authenticode on every unsigned binary) runs when the secrets exist and is skipped with a warning when they do not (none exist yet); the scripts are parse-checked on every run. Unexercised until the first tag: the workflow itself, and signing until there are credentials — a Phase 6 item. - 4.6 Licenses —
License.mdnow lists the Max SDK, credits the Min-API to its authors, names numpy's bundled OpenBLAS/LAPACK/GCC runtime licenses, and fixes the Windows path of CPython'sLICENSE.txt. Release packages carrylicenses/: a copy of every license file that ships — Min-API, the Max SDK, CPython's, and each installed package's own (PEP 639dist-info/licenses/, where numpy lists what it bundles) — with an index, collected from the package's actual contents; CI runs the collection on Linux. - 4.7 uv for development and release tooling — Discussed (2026-09-30): use uv where it
replaces work we do by hand; keep the shipped runtime a python-build-standalone archive pinned
by SHA256 in
runtime.lock. For uv:uv pip compile --universal --generate-hashesin place of most ofupdate-locks.py's PyPI handling;uv pip install --python-platform <triple> --target …to install any platform's wheels from any machine (what 4.8 needs);uv python install 3.13for the Linux fast loop's CPython with headers;uv runwith inline script metadata for the scripts. Against using it for the runtime:uv python installpins only through the uv version, installs in its own layout, and may mark the interpreter externally managed (to check) — and the work that matters (the@rpathinstall name, re-signing, the universal libpython) is ours either way. Users never need uv; the ReadMe may mentionuv pip install --python support/bin/python3 …beside pip. Evaluated (2026-10-01, uv 0.12.21), and it came down to documentation:uv pip compile --generate-hasheslists every distribution of each pin — 68 hashes for attrs and numpy, every Python version and platform, even with--python-platform— whereupdate-locks.pypins exactly the 8 wheels we ship, so it stays; 4.8 can merge the platform builds CI already makes, so it needs no cross-platform install (which does work: Windows' numpy wheels installed on a Mac against our lock, hashes enforced); the scripts use only the standard library, souv runadds nothing; anduv python installmarks its interpreterEXTERNALLY-MANAGED(pip then refuses to install into it) and installed 3.13.15 against our pinned 3.13.14 — confirming the runtime stays ours. What uv is good for here is now documented:uv python install 3.13for the Linux loop's CPython with headers (ReadMe, CLAUDE.md), anduv pip install --python support/bin/python3for users adding packages to the runtime (it is not externally managed). - 4.8 One package for every platform on each tag — Decided (2026-09-30): a tag also
attaches a single
PythonTap-<version>.zipholding every platform's external and runtime, and v0.x tags publish as pre-releases automatically (1.0 and later stay drafts until signing exists). Needs one runtime per platform side by side —support/macos-arm64/,support/macos-x86_64/,support/windows-x64/(one folder cannot hold both: Windows'Lib/and the Mac'slib/collide on a case-insensitive disk) — with the macOS external choosing by the architecture it runs as (each slice of the universal binary can carry its own rpath) and the Windows external loadingpython313.dllfrom its folder by full path before the first delay-loaded call (Max adds onlysupport/itself to the DLL search path); and a lastrelease.ymljob that merges the platform builds into onePythonTap/and attaches it with its checksum (about 130 MB, against 37–55 MB per platform zip today). Done: the external takessupport/<runtime_platform()>when it exists andsupport/otherwise (runtime_home(), mock-tested); the universal macOS external carries an rpath per slice tosupport/macos-<arch>/libahead ofsupport/lib(-Xarch_<arch>, for each architecture built); Windows already loaded the DLL by full path from the runtime folder.assemble-package.py --merge platform=zip …unzips each release zip itself (backslash entry names as folders; the macOS runtime's symlinks and executable bits kept), copies what must be the same in all of them once (compared with line endings aside: a Windows checkout has CRLF), every external, each runtime intosupport/<platform>and each platform's licenses intolicenses/<platform>; theall platformsjob runs it on the three zips, and the release job attaches the result, publishing 0.x tags as pre-releases. Checked in Max: a merged package holding the Intel runtime assupport/macos-x86_64, installed in Packages, passed the whole runtime suite (including the sessions that move the runtime aside).
- 5.1 Rewrite
docs/tap.python~.maxref.xmlagainst the real contract. Done — at the source: min regenerates the page from the object's metadata whenever the external is newer than it (so a hand edit is lost on the next build), so the contract now lives inMIN_DESCRIPTIONand the argument andfilechangeddescriptions, and the committed page is the one min's owndoc_generatewrites from them (run against the mock kernel on Linux). The Python-defined attributes and messages depend on each user's class, so the page describes them generically. - 5.2 Rebuild
help/tap.python~.maxhelpin Max 9 — basics, messages, allpass, hot reload, errors,mc.usage. Done by hand: the patcher gained a title, the class contract, message boxes forgreet/float/int/filechanged, and pointers to thenumpy_gainandallpassexamples (JSON checked: ids unique, every line connects). Checked in Max 9 (2026-09-29): the layout sits right and every message box works; kept as committed rather than re-saved. Tabs for the numpy and allpass examples andmc.usage would still be welcome. - 5.3
ReadMe.md—@gainnotgain; exactly what reload keeps and resets; the threading and performance model; the block path. Done across Phases 1–3 and this pass. - 5.4 Examples —
allpass.pytakes its sample rate fromprepare()and its α range is open (Phase 2);numpy_gain.pyis the block example;default.pyputs itsint/floatmethods last (afterdef float,floatin the class body is the method) with a comment saying why;allpass-doc.ipynbis rewritten as an executed document of the example — what the object exposes,prepare()then per-sampleprocess(), unity energy and a flat magnitude response, the delay following the sample rate, α rejected at 1 — on CPython 3.13 with the locked attrs and numpy. - 5.5
CHANGELOG.mdrecording each contract change (D5) — started with Phase 1.
Runbook for a session on a Mac with Max 9. Up to Phase 5 everything was built and tested on Linux (core battery under sanitizers, the external against min's mock kernel) and in CI (macOS and Windows builds, link-shape checks). The first Mac session (2026-09-27, Max 9.0.8, Intel) set up the package, added the runtime tests (6.1) and ran them green; they found a crash on every save of a class file, now fixed. A second session (2026-09-29) made the hand checks of steps 2 and 4: all passed; a third ran the soak (6.2) and measured performance (6.3). To continue:
-
Set up. Clone into (or symlink into)
~/Documents/Max 9/Packages/PythonTapwith--recursive, run./scripts/install-runtime.sh(native: fastest to build; add--universalonly to check a universal build), thencmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build && ctest --test-dir build. The external lands inexternals/. When Max loads an external newer thandocs/tap.python~.maxref.xml, min rewrites the page from the object's metadata (6.8: the build dates the.mxofor it, andrun.pysays when it happened); commit Max's page when it differs. (A symlinked package works for externals, but Max loads a package's extensions only from a real folder — whyrun.pyinstalls the harness as a copy.) -
5.2 — the help patcher. Open
help/tap.python~.maxhelp: its new boxes were added by hand (as JSON, in Max's layout), so check they sit sensibly and every message box works, then re-save it in Max 9 and commit. Tabs for the numpy and allpass examples andmc.are welcome. Done 2026-09-29: the layout and every message box check out; kept as committed (5.2). -
Run the runtime tests: quit Max,
python3 runtime-tests/run.py(seeruntime-tests/README.md). It covers 6.1 and the macOS half of 6.4 below;--session soak(an hour) and--session perfcover 6.2 and 6.3. -
Behavior the tests cannot show — check by hand and note the result here:
- a
boolfield's attribute shows as on/off (a toggle in the inspector and in attrui); - removing a field while an attrui displays it looks right (the tests check that nothing breaks and the object keeps working);
- saving the file repeatedly while audio runs gives no audible dropout beyond the swap (the tests check every sample the object outputs, not whether the audio device underruns while a reload holds the GIL — plan 2.6);
- changing the sample rate in Audio Status calls
prepare()again (the tests change it throughpoly~'sup, which does not touch the device).
Checked 2026-09-29, Max 9 on macOS: all four as described — the toggle shows, the attrui removal looks right, no audible dropout while saving under audio, and
prepare()runs again on a sample-rate change in Audio Status. Repeat them on the release packages (step 6). - a
-
6.4 on Windows: with
support/renamed aside, create[tap.python~]: one console line naming the missing runtime; rename it back and create another: it should work without a restart. -
6.5 — the first release. Tag a pre-1.0 version (
v0.9.0), which runsrelease.ymlfor the first time, including themacos-15-intelrunner; install each draft zip intoPackages/(clearing quarantine per the ReadMe) and repeat step 4's quick checks. v0.9.0 is tagged and its draft built; the Intel zip is checked (see 6.5). For each other zip: unzip it intoPackages/asPythonTapin place of the checkout (move the checkout's link out ofPackages/meanwhile), thenpython3 runtime-tests/run.py --package ~/Documents/Max\ 9/Packages/PythonTap.
- 6.1 Runtime tests with the
max-testharness —runtime-tests/: Cycling '74's max-test as a submodule, the test patchers generated bymake_patchers.pyfrom scripts of steps, fixtures inruntime-tests/python/, andrun.py, which installs the harness, launches Max, drives it over OSC and reports from the harness's database (a local gate on a Mac, not CI). Nine patchers, 75 assertions: loading (no argument, numpy,@-arguments,int/float), every attribute kind set and read throughgetattr, messages by signature (and a bad call reported), reload by message and by saving through the file watcher (values kept, a field removed under an attrui, a broken file silencing and its fix restoring audio and values), reload every 50 ms for 3 s on both paths with every sample checked, 20 instances on each path pluspoly~churn between 20 voices and 1,sys.exit()/NaN/exception each reported once, andprepare()before the firstprocess()and again on a sample-rate and a vector-size change. Found: every save of a class file crashed Max — min registeredfilechangedwith its A_GIMME wrapper, which Max's file watcher calls with C arguments; the watcher now belongs to a nobox helper that forwards the save as a typed message (tap.python_tilde_filewatch.h). Also found 6.6. - 6.2 Stress/soak — an hour of audio with a reload every second; many instances of one
module; a sample-rate change mid-run. Done:
runtime-tests/run.py --session soak(the patcher is generated with the others; not in the quick suite). Passed on 2026-09-29: 24 per-sample instances of one class in apoly~, one more at top level and 8 block-path instances of another, the device at 96 kHz; every second both files saved with a new revision and every instance told to reload; thepoly~at twice the rate (up 2) for the middle twenty minutes. Every output sample exact in all three phases, the console clean, 3,600 saves running the module 3,600 times (147,684 object reloads), Python's tracked objects flat (18.8–22.1 thousand, no trend). Max's memory grew 70 MB (615 → 685 MB, steadily): not the object's — the core alone stays flat over 75,000 reloads, and Max grows as much from[print]posting the same number of lines, cleared window or not — but Max's console keeping the two lines each object posts per reload (6.7). Found on the way: Max's file watcher coalesces saves made close together (saving once a second, about half are delivered, some seconds late) — now in the ReadMe. - 6.3 Performance budget — CPU per sample (per-sample path) and per vector (block path) at
48/96 kHz, recorded in the ReadMe as measured numbers. Done:
core/benchtimesprocessor::process()as the audio thread calls it — interleaved rounds, each measurement's fastest kept and its median reported — andscripts/update-perf-docs.pywrites the ReadMe's table from it;--maxadds Max's own CPU meter under load (run.py --session perf) beside the benchmark's prediction. Measured: the per-sample bridge costs about 70 ns a call (a third of a percent of a core at 48 kHz), so a per-sample class's cost is its Python code (allpass.py, more than twenty times that); the block path's call is under a microsecond per vector. In Max the per-sample path matches the benchmark and the block path reads about half again more. Taken on a busy machine (the note records the load average); regenerate on an idle one. Then:numpy_allpass.py, the allpass written per vector — identical output, checked sample for sample in the core battery — measured besideallpass.py: 11× less CPU at 48 kHz and 18× less at 96 kHz in 64-sample vectors, and about 16× less by Max's own meter. - 6.4 Loading without a runtime (4.2) — with
support/moved aside the external loads and says what is missing, on macOS and Windows; installing the runtime then works after a Max restart (macOS) or for the next object (Windows). macOS: done —run.py's without-runtime session checks both halves, including that a runtime put back while Max runs still asks for a restart. Windows: by hand (runbook step 5). - 6.5 The first release (4.5) — tag a pre-1.0 version, check the draft's three zips install
and run from
Packages/on Apple Silicon, Intel (or Rosetta) and Windows; later, with credentials, that signed and notarized packages load without the quarantine step. Taggedv0.9.0(2026-09-30):release.yml's first run passed all four jobs and made the draft (three zips, unsigned). Intel checked: the x86_64 zip, its checksum verified and unzipped intoPackages/, passes the whole runtime suite on an Intel Mac with Max 9.1.5 —runtime-tests/run.py --packagetests an installed package. Still to check: the arm64 zip on Apple Silicon, the Windows zip on Windows, and the quarantine step (a zip fetched withghis not quarantined). Found: the release attachedSHA256SUMSbut not the per-zip.sha256the ReadMe promises (release.ymlfixed; the draft's added by hand); the external's bundle identifier is min's template, unexpanded —com.74objects.${PRODUCT_NAME:rfc1034identifier}— to fix before signing and notarizing; anddocs/PRODUCTION-PLAN.mdships inside the package. Both fixed since: max-sdk-base leaves the identifier for Xcode to expand, which no other generator does (every sibling Max package ships it the same way), so the object's CMakeLists expands it —com.74objects.tap.python-tilde;assemble-package.pyleaves the plan out. Observed, not explained: with the release installed, Max's file database took 8–11 minutes to report ready at each launch (seconds with the linked checkout; Max had also just been updated to 9.1.5 and rebuilt its database) — the runner no longer waits for it, as tests opened by name do not need it; and Max rewrote the installed package's reference page, which it never did through the linked checkout (a clue for 6.8). - 6.6 Text files default to ASCII — found by 6.1: the interpreter is configured with
PyConfig_InitIsolatedConfig, whose pre-configuration leaves the locale unconfigured and UTF-8 mode off, so in Maxopen()andPath.read_text()withoutencoding=decode as ASCII, whateverLANGsays (as in any host that leaves the C locale in place); a class reading a UTF-8 file fails withUnicodeDecodeError. Proposed: pre-initialize with UTF-8 mode on (PEP 686 makes it the default from Python 3.15), pinned first by a core test — a contract change for the CHANGELOG. Done:initialize()pre-initializes the isolated configuration withutf8_mode = 1;test_runtime.cppchecks the flag and thatopen()reads and writes UTF-8 without anencoding, and failed before the change. - 6.7 Console lines per reload — found by 6.2: every instance posts two lines on every
reload ("Source file update detected. Reloading." and "Audio process() bound: …"), so a save
with 25 instances of a class posts 50. Max's console keeps every line, even after the window is
cleared (about 0.24 KB each, measured), so an extreme reload rate grows Max's memory — 70 MB
over the soak's 147,684 reloads, where a realistic rate (20 instances, a save a minute) costs
about half a megabyte an hour. Proposed: one line per save of a file, from whichever instance reloads
first, and the binding line only when it changes; a user-visible change for the CHANGELOG.
Done: the processor whose
load()runs the file (the loader reports it) posts one line,Loaded name.py: …with howprocess()is bound, and announces the class's diagnostics (hints, reserved names, an unbindableprocess()) with it; the others sharing the file post nothing, and the external's own reload line is gone. Errors particular to an instance are unchanged. Pinned by a core test (two processors, a change, an unchanged reload) and a runtime test (five objects in Max: the class's diagnostic once per run of the file). - 6.8 The reference page from Max — runbook step 1 expects min to rewrite
docs/tap.python~.maxref.xmlwhen Max loads an external newer than it; in the Mac sessions it did not. Max's standard output had "file not found" and "failed to get date modified" lines at start-up — probably min'sdoc_updatefailing to resolve a path, not yet shown to come from this object. Find out why, and whether the committed page (generated against the mock kernel) is the one Max would write. Done: min'sdoc_updatedates the external by its.mxofolder, and a rebuild changes only the files inside it — the checkout's folder dated from its first build (2026-08-05), older than the page, so the page never looked stale; a freshly unzipped release has a new folder, which is why Max rewrote the installed package's page. The macOS build now touches the folder after each link (a Windows.mxe64is one file, dated by its link already), andrun.pysays when Max has rewritten the page, to commit it. Max's page matches the committed one but for the description, which predated 2.4's text — now committed. The start-up lines are not this object's: they still appear while its page is written. - 6.9 The help patcher and
numpy_allpass.py— the help patcher points to thenumpy_gainandallpassexamples but not tonumpy_allpass, the one that shows what the block path is for; add it in Max (and re-save), perhaps with the measured comparison. Done: the examples note namesnumpy_allpassas the same filter per vector, to swap in and watch the patcher's CPU meter (the ReadMe's tables are the measured comparison); and a new note says what 2.4 made true —process()'s parameters are the inlets, its return hint the outlets,stereo_widthhas two of each, and a save that changes them changes the object. Checked open in Max 9.1.5. - 6.10 One traceback per broken save — a file that fails to load is not cached, so every
object sharing it runs it and prints the traceback: 25 objects, 25 tracebacks. 6.7 left this
alone, because the obvious fix — remember the failing source and stay quiet — would also hide
the error from an object created later (a patch reopened with the file still broken). Needs a
rule that reports once per save without that. Done: the loader still runs the file on every
load, but marks a failure of the same source it reported less than two seconds ago
(
_REPORT_WINDOW), and the processor stays quiet for a marked failure — a save, or a patch opening many objects, reports once; an object made later reports again. Pinned by a core test (two processors; another broken save; a later object) and the runtime testannounce-once(a broken save, five objects in Max, one report — five before the change).
docs/AUDIT-2026-10.md (findings A1–A12) read the shipped 0.10.0 adversarially: the design
discipline held, the edges of the contract did not. This phase closes them, one PR each as below,
highest severity first, each fix landing against a test that fails before it (the house rule), and
each contract change recorded in CHANGELOG.md (D5). The honest limits that remain are written
down rather than discovered again. This plan was itself audited before being adopted
(2026-10-01); what that changed is marked "revised".
- 8.1 Say what is true (A4, A7a, A12 — docs and small hardening, no behavior change).
ReadMe lines 17 and 101, and bar 1 above: no Python exception,
sys.exit()included, takes Max down or stops the object — what happens below Python (os._exit(), a C extension crashing) or never returns to it (an endless loop in a message freezes Max's main thread; inprocess()the audio thread, and until 8.3 the main thread too in worker mode) is not caught. Helper modules inpython/are imported once per Max session and are not reloaded by a save (until 8.5). Another external that embeds its own CPython in the same Max is untested and unsupported. Add these to CLAUDE.md's honest-limits rule. Correct D2 (the loader compiles andexecs the source as_tap_python_<name>;__file__only, no__spec__, so no relative imports). Hardenassemble-package.py'sextract()against symlink zip-slip (A10: refuse a link target that is absolute or resolves outside the destination, and check every written path), and pin the taphouse drift workflow by SHA or note the exception beside the policy comment (A11). Done when: the three documents agree with each other and with the code;--mergeof a crafted zip with a../symlink entry fails. Done: the ReadMe's two sentences and bar 1 now name what the guards cover and what they cannot; helpers and a second interpreter are stated; D2 corrected; CLAUDE.md carries the rule.extract()refuses an entry whose path leaves the destination (through.., an absolute path, or a symlink an earlier entry made) and a symlink whose target is absolute or resolves outside — checked with 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. The taphouse drift check stays pinned by release, with the reason beside the policy comment. - 8.2 Crash and contract fixes in the core and the glue (A2, A3).
A2:
process_samples()sanitizes the samples already written before each of its two early returns (input conversion failing; the call raising). Test first, intest_realtime.cpp: the audit'snan_then_raise.py(NaN until an input ≥ 0.75, then an exception) renders0 0 0 0 0 0, notnan nan nan 0 0 0. A3: one list of reserved names, in the object (reserved_messages()), extended with every message min registers asA_CANT(c74_min_message.h'smessage_type::cantnames and theMIN_WRAPPER_ADDMETHODtable:dspstate,fileusage,patchlineupdate,edclose,okclose,oksize,paint, the mouse, focus and key messages,mousewheel,getplaystate, the*_setupnames,setup,dictionary) plus Max's mc-erainputchangedandmultichanneloutputs; and a general guard — a name the Max class already answers (class_method(object_class(maxobj()), gensym(name))non-null) is reserved too, so min's own registrations never need listing by hand. Revised: the guard must not take away what is promised: a glue test assertsint,float,symbol,bang,listand an ordinary name stay exposed with the guard on (min registersint/float/bangonly for classes that declare them; this one does not — the test is the proof, since Max's own class setup is not ours to read). Tests: the core's reserved-name scenario stays (it takes any list); a glue test instantiates[tap.python~ maxtest_mock_reserved]whose class definesdspstate,fileusage,patchlineupdateandinputchanged, and checks none is registered and each is announced once; thefaultsruntime test gains a step that toggles DSP and connects a cord with such a class loaded (the crash 6.1 found, in its general form).fileusageis sent only by Build Collective/Application, so it is a runbook step by hand, not a runtime test. CHANGELOG: the new reserved names. Done: every path out ofprocess_samples()goes through onesanitize(io, written, frame_count); the fixture renders0 0 0 0 0 0where it rendered three NaNs, and reports both the exception and the non-finite output. The processor takes ahost_answerspredicate beside the list; the object'sreserved_messages()carries min'sA_CANTnames plusdspstate,inputchangedandmultichanneloutputs, andanswered_by_max()asksobject_getmethod()— excluding the messages the object itself added for the previous incarnation's methods, which are still registered whileload()runs and must not reserve the class's own names on a reload. Pinned by a core scenario for the predicate, the glue scenario withmaxtest_mock_reserved(the four C-argument names absent,int/float/symbol/bang/list/greetpresent, audio bound), and thefaultsruntime test's new steps (DSP toggled, a cord connected bythispatcher, with those methods in the class) — to run in the Mac session (8.8), as is Build Collective forfileusage. In the mock kernelobject_getmethod()always answers null, so the guard's own effect is seen only in Max: the existingattributes-and-messagesruntime test is what would show it over-reserving. And it did over-reserve (found 2026-10-02 in Max, 1.0.0 released with it): for a name the object does not answer Max returnsmethod_false(), a function — the SDK says so — not null, so the guard reserved every field and method of every class; the object loaded, audio ran, and nothing else worked. Fixed in 1.0.1:found_method()treats null andmethod_false()alike as "not found", and the glue test's kernel now answers as Max does (method_false()for an unknown name, a method for the names min registers and for one the test's class defines on purpose), so the guard's effect is pinned without Max. The lesson for 8.8: a tag with a Mac session outstanding ships what only Max can show. And on Windows it still did (found 2026-10-08 on the 1.0.1 package, which was not released): 1.0.1 compared with&method_false, which in a Windows DLL is its own import thunk — the SDK declares the function withoutdllimport— never the function Max returns, so every name was still "found". Fixed in 1.0.2:not_found_method()asks Max what it answers for a name no class can have (it has spaces), andfound_method()compares with that; the glue test's kernel answers an unknown name with a function other than themethod_falsethe module can take the address of, as Windows does, and every attribute and message scenario fails without the fix. Verified on Windows by the maintainer with the branch's package. - 8.3 Worker mode never hangs Max (A1, A6 —
worker.h). A1:stop()bounds its join. The worker records its OS thread identifier (PyThread_get_thread_ident(), no GIL needed) as it starts; if the thread has not finished within 100 ms ofstoppingbeing set,stop()takes the GIL briefly (the hung thread yields it every 0.5 ms) and injectsPyThreadState_SetAsyncExc(ident, WorkerStopped), which raises at the next bytecode of a pure-Python loop. Revised, three ways. (i)WorkerStoppedderives fromBaseException, likeKeyboardInterrupt, so a class'sexcept Exception:does not swallow it and keep looping; it is defined in the support module and looked up by name (no CPython data symbols). (ii) The processor recognizesWorkerStoppedand does not unbind: it is our interruption, not the class's fault — aprocess()that was merely slow (a 200 ms numpy call when the chain recompiled) loses that vector and carries on; it is reported once as "process() was interrupted while the worker stopped". (iii) If the thread still has not finished after another 250 ms (a blocking C call;time.sleep, which delivers the exception only when it returns),stop()detaches it. The ring is then shared (shared_ptr, the thread holding one reference), so a thread that wakes later frees it; the processor it runs stays owned by the object — attributes, messages and a fixing save keep working, and the nextdspsetupstarts a fresh worker — and the object's destructor hands the processor to a process-wide leak list while a detached thread is still alive (an atomic count the thread decrements as it exits), rather than destroy what the thread may still touch. The console says once that the class stalled and was abandoned, and that it keeps costing a core until Max restarts (it still holds the GIL in 0.5 ms slices).stop()thus holds Max's main thread for at most ~350 ms. Direct mode is unchanged (8.1 documents it). Tests (test_worker.cpp):never_returns.py(while True: pass, insidetry: … except Exception: pass) —stop()returns within 1 s,flush_reports()prints the interruption, audio is still bound and the next vector runs;time.sleep(5)inprocess()—stop()returns within 1 s having detached, a newstart()works, and the test process exits cleanly (leaks are off under ASan already); aprocess()that takes 50 ms —stop()waits for it and injects nothing. Runtime test (worker): the same classes in Max, DSP toggled, patch closed. A6: the worker thread is created with an explicit stack of 16 MiB (CPython's ownTHREAD_STACK_SIZEon macOS) throughpthread_attr_setstacksize/_beginthreadex, in a smallnative_threadhelper that keepsstd::thread's join/detach shape. Measure first on the Mac: the secondary-thread default from a freshpthread_attr_t, and the depth at which a fixture recursing through a C boundary (sorted(key=…)calling itself) crashes on astd::threadagainst athreading.Thread; then pin that fixture running tosys.getrecursionlimit()on the worker. CHANGELOG: a stalled class in worker mode is interrupted or abandoned, never waited for. Done: as designed —WorkerStopped(BaseException)in the support module;processor::take_interruption()recognizes it, records "interrupted" (once per load) and keeps the binding;worker::stop()pollsfinishedfor 100 ms, injects withPyThreadState_SetAsyncExcunder a briefgil_lock, polls 250 ms more, then detaches; the ring is ashared_ptrthe thread also holds,has_abandoned_thread()prunes the finished ones, and the Max object's destructor leaks the worker and processor while one lives.detail::native_thread(pthread /_beginthreadex, 16 MiB) replacesstd::thread. Four core scenarios:hangs.py(while self.foreverinsideexcept Exception) — stop() in 0.5 s, interruption reported, audio bound, the class runs on;sleeps.py(time.sleep(3)) — stop() in under a second, abandoned and said so, a new worker runs the class (the harness is leaked, as a host must);stalls.pyat 50 ms — waited for, nothing reported;deep_recursion.py(sorted's key recursing to the limit) — passes on the worker. Release, ASan/UBSan and TSan clean. Theworkerruntime test gainshang 1→mode direct→hang 0, expecting one "interrupted" line and two errors in all — step timing to confirm in the Mac session (8.8), with the stack measurement the item asks for. - 8.4 Nothing of the user's prints on the audio thread either (A5).
runtime_optionsgainsis_main_thread(the host's predicate —systhread_ismainthreadin Max; the default, with no host predicate, is "always", which keeps the core battery's synchronousconsole()checks as they are) andconsole_ready(real-time safe; the object sets itsm_reportsqelem). Revised: lines are assembled per thread in athread_localbuffer — today the two per-stream buffers are shared under one mutex, so partial writes from two threads already interleave — and only a complete line is dispatched: on a main thread straight to the sink; on any other thread into a bounded lock-free MPSC queue (dropping and counting when full, never blocking), withconsole_readycalled. The host'sflush_console(), from its main thread, drains the queue to the sink in order and appends "… and N lines dropped" when it had to. The audio-thread tests callflush_console(). Consequences to document: deferred lines arrive after main-thread lines printed later (ordering across threads is not kept); the scheduler thread's prints are deferred too (harmless); what still happens on the audio thread is Python's own formatting of a warning (linecachereads the file once). Tests:print()from an audio thread reaches the sink only on the nominated thread; two threads printing half-lines never produce a mixed line; a flood of 10,000 lines drops with a count and never blocks the producer (timed). CHANGELOG: console output fromprocess()is deferred and coalesced. Done:runtime_optionsgainedis_main_threadandconsole_ready(andset_console_threading()for a test or a host that decides later);detail::console_queueis Vyukov's bounded queue, 256 slots of 480 bytes (a longer line is cut with an ellipsis), with a dropped count; lines are assembled inthread_localbuffers and dispatched whole;flush_console()drains to the sink in order and adds the dropped line. The Max object nominatessysthread_ismainthreadand sets one process-wide qelem that callsflush_console(). Four scenarios in the newtest_console.cpp: a line from another thread reaches the sink only throughflush_console()(and aflush=Truepartial line queues whole); two threads printing pieces give 80 unmixed lines at a 1 µs switch interval; a flood of 10,000 keeps 256 and counts 9,744 dropped, in bounded time; a 2,000-character line is cut. The battery's other console checks run on the test's main thread with no predicate and stand as they were. - 8.5 Helper modules follow the class file (A7b). When a load executes the class file
(the loader says so), it first drops from
sys.modulesevery module whose__file__is a.pyunderscripts_directory()— revised: except the class modules themselves (_tap_python_*, whichtyping.get_type_hintsand the loader's cache depend on) and anything that is not a source module (a compiled extension cannot be re-imported) — so the fresh execution imports the helpers afresh; the runtime setssys.dont_write_bytecode = Trueat start-up, so no.pycis written for anything underpython/and 3.6a's hazard cannot return for helpers (the shipped runtime and installed wheels carry their.pycalready, so nothing slows). Two honest limits to state: a helper shared by two class files runs in two versions until each class file has been re-executed (module-level state is not shared between them meanwhile); and only a save that changes the class file re-executes it — an unchanged file hits the source cache — so "edit the helper, then save the class file" works only if the class file changed too. Decide in the PR whetherfilechangedsent by a patcher (as against the watcher's) should force re-execution — the watcher would then send a distinct, undocumented message — or whether the folder should be watched. Test: a class importinghelper.py, the helper edited, the class file changed and loaded → the new helper runs; edited alone → it does not, and the ReadMe says so. CHANGELOG: helpers reload with the class file. Done:forget_helpers()in the support module, called byload()before it executes a changed file, comparesnormcase(abspath(__file__))against the scripts folderinitialize()hands it (no file-system calls per module), skips_tap_python_*and anything not a.py;PyConfig.write_bytecode = 0. One scenario intest_loading.cpp: the helper's new value after a changed save, another class file's module left insys.modulesand working, the old value after an unchanged save (the documented limit), no__pycache__. Decided: the rule stands as documented — "save the class file too" — rather than a forced reload onfilechangedor a folder watch; revisit if users ask. - 8.6 Type hints that map as a reader expects (A8). In
hint_kind: unwrapAnnotatedandFinalto their first argument; revised: aUnionof several members other thanNonemaps toany— the atom passes as it is, as for an unannotated parameter — rather than to one preferred member, since preferringfloatforstr | floatwould silently lose strings as preferringstrloses numbers today (on the Max side ananyattribute is a symbol attribute, as now); a hint that is a subclass offloat,int,boolorstrmaps to the base (np.float64subclassesfloat), and the numpy scalar families that do not (np.int64,np.bool_) are recognized by their__mro__names without the support module importing numpy. Inreturn_shape: see throughOptional[tuple[…]]to the tuple. Test:typed_more.pyintest_types.cppwith the audit's table. CHANGELOG: the mapping changes (afloat | intfield was a symbol attribute). Done:_unwrap()forAnnotated/Final(objects and, by regex, strings); unions of several kinds →'any'(_one_kind);isinstance(hint, type)thenissubclassagainst bool, int, float, str in that order, then numpy's abstract bases by__mro__name and module;return_shapesees throughOptional/| None(objects and strings). Fixturestyped_more.py,typed_strings.py(hints that cannot resolve, read as written) andtyped_numpy.py; two scenarios intest_types.cpp. - 8.7 Windows: paths meet Max as UTF-8 (A9). Every
path.string()handed to a Max call or a console line (locatefile_extendedin the constructor, the core's "No file …" and "Failed to load …") goes throughu8string()(C++20:reinterpret_castitsc_str()). Verify in the runbook's Windows step with the package under a folder named with a non-ASCII character: the watcher starts, a save reloads. A bug fix with no contract change: the one item here that may follow 1.0. Done (code):detail::utf8(path)inruntime.h(u8string()), used for the watcher's file name, the object's three runtime messages, and the core's two file diagnostics;path_to_unicode()andPyConfigalready took wide strings on Windows. The core tests'.string()expectations are the same bytes on POSIX. To verify in the runbook's Windows step (8.8): the package under a folder named with a non-ASCII character, the watcher starts, a save reloads. - 8.8 The Mac session for this phase. Runtime tests for 8.2 (
faults) and 8.3 (worker); 8.3's stack measurement; the macOS half of 8.7 is not needed (POSIX paths are UTF-8). Then repeat the runbook's step 4 on the release packages, and tagv0.11.0. Run 2026-10-07 (Intel Mac, Max 9, on main with 1.0.1's fix): the suite found a second bug in 8.2's guard —reloadfailed: the attributes the object adds for a class's fields answer their names in Max, so on a reload the guard took them for Max's own and reserved every field ("the field gain is reserved by the host", then "doesn't understand gain");worker'shangfailed the same way after its reload, silently (an unchanged file announces nothing). Fixed for 1.0.1 (answered_by_max()leaves outm_python_attributesas it didm_python_messages), against a glue scenario that fails without it — the test's kernel now answers an added attribute's name, as Max does, while the scenario asks.faultspasses (DSP toggled and a cord connected with the C-argument names in the class).worker's console count was wrong, not the worker: the reload beforehang 1begins a new load, so the 500 ms hang is reported late again, and it outlasts the ring (latency + 0.25 s), so input is dropped too — four lines, not two; the test now expects each. Whole suite: 12 patchers pass. Stack measured: a freshpthread_attr_t(std::thread's) has 512 KiB here, and recursion throughsorted's key crashes there after about 98 levels — short ofdeep_recursion.py's 450; at the worker's 16 MiB it reaches CPython's own recursion limit (about 2,500 levels), asthreading.Threaddoes. Still by hand: step 4 on the release packages, and Build Collective forfileusage.
The 1.0 gate (revised): 8.1–8.6 change what the ReadMe promises or what a class can rely on, so
they land before 1.0, where D5 still allows them; 8.7 may follow it. Decided 2026-10-01 (the maintainer):
1.0.0 is tagged with 8.1–8.7 merged (#35) and 8.8 outstanding — the contract changes are in, and
what 8.8 verifies in Max is behavior the core battery and the glue test already pin on Linux. 8.8,
the Windows half of 6.4 and the unchecked zips of 6.5 are the first 1.0.x work; the release is a
draft until signing exists, as release.yml has it.
- 7.1 A CLAP (MIT, simpler) or VST3 wrapper over the core. Needs its own answer to fixed parameter lists — e.g. a fixed bank of N parameters the Python class declares — and to sharing one interpreter with other plugins that embed Python in the same host process.
The design — decisions D7–D11, the class contract, the output rules, what changes in the core, the
object and the package — is docs/TAP-PYTHON-PLAN.md, audited in docs/AUDIT-TAP-PYTHON-PLAN.md
(two blockers, six major findings) and revised for every finding on 2026-10-09; keep both current
where a PR decides differently. One PR per item, in this order, each against tests that fail
before it. 9.0 runs in Max on a Mac and on Windows before anything else: three releases in a
row shipped what only a host platform could show.
- 9.0 The spike, in Max on both platforms: a throwaway second class in
tap.python~'s binary answers what only Max can — one binary, two classes (D11) and theinit/objectfilemapping (or the stub external);outlet_insert_afterbefore an obex-stored dumpout andget<attr>through it; the dispatch order of a class-levelanythingagainst instance methods and attributes, andobject_getmethod()on such a class; the thread of ametro-driven message under Overdrive and under Scheduler in Audio Interrupt;symboloranythingfor astrreturn. The answers go into the plan before 9.1. Done 2026-10-09: run on a Mac (#43) and on Windows (#45), Max 9.1.5, the same answers on both, in TAP-PYTHON-PLAN.md under 9.0: theinit/mapping works;get<attr>needs the class'sdumpoutmethod;int,float,bangandlistnever reach a class-levelanything; astris output assymbol <s>. - 9.1 The core (#46): output items and their conversion in
value.h;message_info::return_count(depth-aware for string hints),outlet_count(),call_with_output(); thebind_audiooption, gating the tuple rule; a last parameter hintedlist[…]ornp.ndarraytaking the remaining atoms (both objects; the old behavior pinned first, the change recorded — a contract change, so the release is 2.0.0, decided 2026-10-09); announce-once per (file, kind); theLoadedline per binding.test_output.cpp, the output table over its edges, the new examples on known inputs, and a bench row for a message call.tap.python~unchanged and green. - 9.2 The shared glue (#47): the attribute, message, trampoline, file-watcher and package headers
as
python_glue<Host>;reserved_messages()parameterized by the audio names. A pure move. - 9.3 The object (#48):
tap.python.hin the project, a plain SDK class registered by the project'sext_main— ports with the dumpout and the object's lock, the output mapping, theanythingforwarder, dynamic outlets on reload, the guard, the Scheduler-in-Audio-Interrupt notice;init/tap.python.txt; the glue test with faithful stubs; the examples (euclid.py,scale.py,note_name.py,default.py'sbang); packaging;linux-max-gluegains sanitizer rows for the lock and the reload race; CHANGELOG 2.0.0 started. - 9.4 Documentation, in Max's own system and the ReadMe (#49): the ReadMe section, output
table and limits, CLAUDE.md;
docs/tap.python.maxref.xmlby hand (a plain SDK class has no min generator); a vignette and three tutorials with patchers for the Documentation window; an Extras overview named as the landing patcher; help patchers with tabs for both objects; CI checks for the hand-made XML and JSON. - 9.5 Runtime tests (#50): the
tap.python.*patchers inmake_patchers.py;run.pyaware of the second object; the soak session gains ametro-driventap.pythonunder saves. - 9.6 The release session, Mac and Windows (#51): the whole suite on the Mac, the help patcher
checked and re-saved, Max's page for
tap.python~committed, the hand checks; the package by hand on Windows; then tagv2.0.0. - 9.7 The book (#52): after 2.0.0, the family's mdBook under
book/, published by CI, with the examples and performance tables pulled from what ships; the ReadMe becomes the front door.
- Phase 0.1–0.2 — the core split and the Linux battery; then 0.3–0.6.
- Phase 1 — crash/safety fixes.
- Phase 4.1–4.4 — build/installers/CI (independent; can run in parallel with 2).
- Phase 2.1–2.3 — RT hygiene, block path,
prepare(). - Phase 3 — type bridge (likely split: dispatch + types; reload reconciliation; file-based loading).
- Phase 5 — docs/examples once the contract has settled.
- Phase 4.5–4.6 — release packaging and licenses.
- Phase 2.4–2.6 — multichannel, worker mode, reload stalls (features; may follow 1.0).
- Phase 6 — in-Max validation before tagging 1.0.
- Phase 4.7–4.8 — uv for the tooling, then one package for every platform on each tag.
- Phase 8 — the audit's findings, in the order above: 8.1 (docs and hardening, first, so the ReadMe stops overclaiming while the fixes land), 8.2, 8.3, 8.4, 8.5, 8.6, 8.7 (#35), then the Mac session 8.8 — decided on 2026-10-01 to follow 1.0.0 rather than gate it.
- Phase 9 —
tap.python: 9.0 (the spike in Max, on a Mac and on Windows, first), then 9.1 (the core, no Max change), 9.2 (the shared glue, a pure move), 9.3 (the object), 9.4 and 9.5 (docs and runtime tests, independent of each other), then the release session 9.6 on both platforms andv2.0.0; then 9.7, the book. 8.8 ran on 2026-10-07; its two by-hand items can ride along with 9.6.
- Apple Developer ID Application certificate + notarization credentials, and a Windows signing certificate, as CI secrets — not yet available; release artifacts are unsigned until then.
- A Mac with a licensed Max for Phase 6.