Skip to content

Latest commit

 

History

History
864 lines (827 loc) · 77.2 KB

PRODUCTION-PLAN.md

File metadata and controls

864 lines (827 loc) · 77.2 KB

tap.python~ — production-readiness plan

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.

The bar

  1. 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 for process(); worker mode confines the latter to the worker thread and, since 8.3, stops it or abandons it).
  2. 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.
  3. Every documented behavior is pinned by a test — unit (mock kernel) where possible, in-Max runtime tests where not.
  4. A reproducible, verifiable package ships from CI, with pinned and hash-checked dependencies.
  5. Behavior is validated in a real Max, not only against the mock kernel.

Decisions (settled)

# 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.

Phase 0 — the core split and a test foundation that can fail

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 from find_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. A linux-core CI job, plus ASan/UBSan and TSan rows. The Max test target moved to C++20 with it (the core needs std::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 through sys.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 ./name resolved 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-glue in 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.py to run.
  • 0.5 Max-glue coverage — attr_set/attr_get round-trips through float, long and symbol atoms, multi-atom refusal, and the float/int messages and arity refusal through message_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).

Phase 1 — crash and safety fixes

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

Phase 2 — the real-time model

  • 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_ready callback fires — in Max a queue<> (qelem) — so flush_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.ndarray binds process() per vector: a processor-owned np.zeros(vs) input array, reused (its buffer held, so its memory cannot move), sized at prepare() and resized only if a vector arrives in another size; the result is read through the buffer protocol, or np.ascontiguousarray(…, float64) for other dtypes and lists; a wrong length or None is reported and silenced. Rebuilding the buffer while audio runs is swap-safe (200 prepare() calls against a running block path: no corrupt vector, TSan clean).
  • 2.3 prepare(self, sample_rate, vector_size), from min's dspsetup; also called on each newly loaded instance before it is published, so no vector ever runs on an unprepared instance. allpass.py uses it (and its α range is now open, per 5.4).
  • 2.4 Multiple inlets/outlets — process arguments 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_end around dsp_resize and outlet_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 (all np.ndarray or all per sample; none makes a generator), the return hint the outputs (tuple[float, float] for two; a tuple of unsaid length, *args or mixed hints are reported and not bound); input_count() and output_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 an inlet<> per input after the first (its help naming the parameter: processor::input_names()) and an outlet<> 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 example stereo_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 faithful attr_args_offset, and the object reads a one-symbol argument directly, not through atom_gettext — the mock's gives nothing); runtime test channels (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, between dynlet_begin and dynlet_end, sets the signal inlets with dsp_resize and 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); then dspchain_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 test channels: grown from two to three, the old cords carry on and cords that thispatcher connects 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: a worker that 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 existing processor::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 @latency in 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. @mode and @latency take 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 worker against delay~ 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, so start() and stop() — 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 new load_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 in dspsetup; 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) or THREAD_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: @latency stays 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, latency and latencysamples among 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. Splitting load() 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).

Phase 3 — the type bridge and class contract

  • 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's describe() (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() uses inspect.getattr_static, so describing a class runs no property getter.
  • 3.2 Types — bool → long attribute; unwrap Optional/Union via typing.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 -1 as a value. Done, except list[float] attributes (still symbols — a list attribute needs its own Max-side design); ClassVar is 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[...]/| None unwrapped).
  • 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>.py itself and executes it as a fresh _tap_python_<name> module registered in sys.modules (typing/attrs resolve annotations there); the scripts folder is appended to sys.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 .pyc check 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 .pyc is read or written for class files.
  • 3.7 Console streams — a real flush(), plus encoding/errors/isatty for libraries that probe them (_runtime.h:184-194). Done: io.TextIOBase subclasses; s# so NULs pass.
  • 3.8 Namespace — move python_attr, python_message and the trampolines into tap::python; correct TapHouse's README, which says PythonTap has no namespace of its own. The move is done (and the headers lost their file-scope using namespace), and the core also exports the conventional tap::python CMake alias (keeping tap::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 lists tap::python (tap/taphouse 349b1a4).

Phase 4 — build, supply chain, distribution

  • 4.1 CMake — the macOS arch detection cached with FORCE and never re-detected: it now remembers its own value and re-detects on every configure while CMAKE_OSX_ARCHITECTURES still 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 than lipo -archs of the runtime. (C++20 on the test target landed with Phase 0.) BUILD_WITH_INSTALL_RPATH on the external, so only the @loader_path rpath 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), and scripts/check-data-imports.sh gates 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 from support/, 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) and scripts/requirements.lock (attrs/numpy wheels, --require-hashes --only-binary :all:), both generated by scripts/update-locks.py; no more trusting the release's own SHA256SUMS or unpinned PyPI, and no pip --upgrade pip. Both installers build the new runtime in a scratch folder, move the old one aside and restore it on any failure; the .ps1 checks $LASTEXITCODE after every native command; the .sh detects 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 touching support/, a stale support.previous left 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 a vX.Y.Z tag (or by hand, artifacts only): builds and tests per platform, assembles the package with scripts/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.md now 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's LICENSE.txt. Release packages carry licenses/: a copy of every license file that ships — Min-API, the Max SDK, CPython's, and each installed package's own (PEP 639 dist-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-hashes in place of most of update-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.13 for the Linux fast loop's CPython with headers; uv run with inline script metadata for the scripts. Against using it for the runtime: uv python install pins 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 @rpath install name, re-signing, the universal libpython) is ours either way. Users never need uv; the ReadMe may mention uv 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-hashes lists every distribution of each pin — 68 hashes for attrs and numpy, every Python version and platform, even with --python-platform — where update-locks.py pins 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, so uv run adds nothing; and uv python install marks its interpreter EXTERNALLY-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.13 for the Linux loop's CPython with headers (ReadMe, CLAUDE.md), and uv pip install --python support/bin/python3 for 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>.zip holding 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's lib/ 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 loading python313.dll from its folder by full path before the first delay-loaded call (Max adds only support/ itself to the DLL search path); and a last release.yml job that merges the platform builds into one PythonTap/ and attaches it with its checksum (about 130 MB, against 37–55 MB per platform zip today). Done: the external takes support/<runtime_platform()> when it exists and support/ otherwise (runtime_home(), mock-tested); the universal macOS external carries an rpath per slice to support/macos-<arch>/lib ahead of support/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 into support/<platform> and each platform's licenses into licenses/<platform>; the all platforms job 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 as support/macos-x86_64, installed in Packages, passed the whole runtime suite (including the sessions that move the runtime aside).

Phase 5 — documentation and examples

  • 5.1 Rewrite docs/tap.python~.maxref.xml against 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 in MIN_DESCRIPTION and the argument and filechanged descriptions, and the committed page is the one min's own doc_generate writes 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~.maxhelp in Max 9 — basics, messages, allpass, hot reload, errors, mc. usage. Done by hand: the patcher gained a title, the class contract, message boxes for greet/float/int/filechanged, and pointers to the numpy_gain and allpass examples (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 and mc. usage would still be welcome.
  • 5.3 ReadMe.md — @gain not gain; 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.py takes its sample rate from prepare() and its α range is open (Phase 2); numpy_gain.py is the block example; default.py puts its int/float methods last (after def float, float in the class body is the method) with a comment saying why; allpass-doc.ipynb is rewritten as an executed document of the example — what the object exposes, prepare() then per-sample process(), 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.md recording each contract change (D5) — started with Phase 1.

Phase 6 — validation in a real Max

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:

  1. Set up. Clone into (or symlink into) ~/Documents/Max 9/Packages/PythonTap with --recursive, run ./scripts/install-runtime.sh (native: fastest to build; add --universal only to check a universal build), then cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build && ctest --test-dir build. The external lands in externals/. When Max loads an external newer than docs/tap.python~.maxref.xml, min rewrites the page from the object's metadata (6.8: the build dates the .mxo for it, and run.py says 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 — why run.py installs the harness as a copy.)

  2. 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 and mc. are welcome. Done 2026-09-29: the layout and every message box check out; kept as committed (5.2).

  3. Run the runtime tests: quit Max, python3 runtime-tests/run.py (see runtime-tests/README.md). It covers 6.1 and the macOS half of 6.4 below; --session soak (an hour) and --session perf cover 6.2 and 6.3.

  4. Behavior the tests cannot show — check by hand and note the result here:

    • a bool field'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 through poly~'s up, 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).

  5. 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. 6.5 — the first release. Tag a pre-1.0 version (v0.9.0), which runs release.yml for the first time, including the macos-15-intel runner; install each draft zip into Packages/ (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 into Packages/ as PythonTap in place of the checkout (move the checkout's link out of Packages/ meanwhile), then python3 runtime-tests/run.py --package ~/Documents/Max\ 9/Packages/PythonTap.

  • 6.1 Runtime tests with the max-test harness — runtime-tests/: Cycling '74's max-test as a submodule, the test patchers generated by make_patchers.py from scripts of steps, fixtures in runtime-tests/python/, and run.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 through getattr, 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 plus poly~ churn between 20 voices and 1, sys.exit()/NaN/exception each reported once, and prepare() before the first process() and again on a sample-rate and a vector-size change. Found: every save of a class file crashed Max — min registered filechanged with 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 a poly~, 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; the poly~ 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/bench times processor::process() as the audio thread calls it — interleaved rounds, each measurement's fastest kept and its median reported — and scripts/update-perf-docs.py writes the ReadMe's table from it; --max adds 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 beside allpass.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. Tagged v0.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 into Packages/, passes the whole runtime suite on an Intel Mac with Max 9.1.5 — runtime-tests/run.py --package tests an installed package. Still to check: the arm64 zip on Apple Silicon, the Windows zip on Windows, and the quarantine step (a zip fetched with gh is not quarantined). Found: the release attached SHA256SUMS but not the per-zip .sha256 the ReadMe promises (release.yml fixed; 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; and docs/PRODUCTION-PLAN.md ships 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.py leaves 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 Max open() and Path.read_text() without encoding= decode as ASCII, whatever LANG says (as in any host that leaves the C locale in place); a class reading a UTF-8 file fails with UnicodeDecodeError. 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 with utf8_mode = 1; test_runtime.cpp checks the flag and that open() reads and writes UTF-8 without an encoding, 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 how process() is bound, and announces the class's diagnostics (hints, reserved names, an unbindable process()) 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.xml when 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's doc_update failing 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's doc_update dates the external by its .mxo folder, 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 .mxe64 is one file, dated by its link already), and run.py says 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 the numpy_gain and allpass examples but not to numpy_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 names numpy_allpass as 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_width has 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 test announce-once (a broken save, five objects in Max, one report — five before the change).

Phase 8 — the October 2026 audit's findings (before 1.0)

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; in process() the audio thread, and until 8.3 the main thread too in worker mode) is not caught. Helper modules in python/ 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 and execs the source as _tap_python_<name>; __file__ only, no __spec__, so no relative imports). Harden assemble-package.py's extract() 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; --merge of 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 whose bin/python3 link 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, in test_realtime.cpp: the audit's nan_then_raise.py (NaN until an input ≥ 0.75, then an exception) renders 0 0 0 0 0 0, not nan nan nan 0 0 0. A3: one list of reserved names, in the object (reserved_messages()), extended with every message min registers as A_CANT (c74_min_message.h's message_type::cant names and the MIN_WRAPPER_ADDMETHOD table: dspstate, fileusage, patchlineupdate, edclose, okclose, oksize, paint, the mouse, focus and key messages, mousewheel, getplaystate, the *_setup names, setup, dictionary) plus Max's mc-era inputchanged and multichanneloutputs; 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 asserts int, float, symbol, bang, list and an ordinary name stay exposed with the guard on (min registers int/float/bang only 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 defines dspstate, fileusage, patchlineupdate and inputchanged, and checks none is registered and each is announced once; the faults runtime 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). fileusage is 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 of process_samples() goes through one sanitize(io, written, frame_count); the fixture renders 0 0 0 0 0 0 where it rendered three NaNs, and reports both the exception and the non-finite output. The processor takes a host_answers predicate beside the list; the object's reserved_messages() carries min's A_CANT names plus dspstate, inputchanged and multichanneloutputs, and 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 and must not reserve the class's own names on a reload. Pinned by a core scenario for the predicate, the glue scenario with maxtest_mock_reserved (the four C-argument names absent, int/float/symbol/bang/ list/greet present, audio bound), and the faults runtime test's new steps (DSP toggled, a cord connected by thispatcher, with those methods in the class) — to run in the Mac session (8.8), as is Build Collective for fileusage. In the mock kernel object_getmethod() always answers null, so the guard's own effect is seen only in Max: the existing attributes-and-messages runtime 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 returns method_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 and method_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 without dllimport — 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), and found_method() compares with that; the glue test's kernel answers an unknown name with a function other than the method_false the 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 of stopping being set, stop() takes the GIL briefly (the hung thread yields it every 0.5 ms) and injects PyThreadState_SetAsyncExc(ident, WorkerStopped), which raises at the next bytecode of a pure-Python loop. Revised, three ways. (i) WorkerStopped derives from BaseException, like KeyboardInterrupt, so a class's except 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 recognizes WorkerStopped and does not unbind: it is our interruption, not the class's fault — a process() 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 next dspsetup starts 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, inside try: … 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) in process() — stop() returns within 1 s having detached, a new start() works, and the test process exits cleanly (leaks are off under ASan already); a process() 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 own THREAD_STACK_SIZE on macOS) through pthread_attr_setstacksize / _beginthreadex, in a small native_thread helper that keeps std::thread's join/detach shape. Measure first on the Mac: the secondary-thread default from a fresh pthread_attr_t, and the depth at which a fixture recursing through a C boundary (sorted(key=…) calling itself) crashes on a std::thread against a threading.Thread; then pin that fixture running to sys.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() polls finished for 100 ms, injects with PyThreadState_SetAsyncExc under a brief gil_lock, polls 250 ms more, then detaches; the ring is a shared_ptr the 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) replaces std::thread. Four core scenarios: hangs.py (while self.forever inside except 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.py at 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. The worker runtime test gains hang 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_options gains is_main_thread (the host's predicate — systhread_ismainthread in Max; the default, with no host predicate, is "always", which keeps the core battery's synchronous console() checks as they are) and console_ready (real-time safe; the object sets its m_reports qelem). Revised: lines are assembled per thread in a thread_local buffer — 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), with console_ready called. The host's flush_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 call flush_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 (linecache reads 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 from process() is deferred and coalesced. Done: runtime_options gained is_main_thread and console_ready (and set_console_threading() for a test or a host that decides later); detail::console_queue is Vyukov's bounded queue, 256 slots of 480 bytes (a longer line is cut with an ellipsis), with a dropped count; lines are assembled in thread_local buffers and dispatched whole; flush_console() drains to the sink in order and adds the dropped line. The Max object nominates systhread_ismainthread and sets one process-wide qelem that calls flush_console(). Four scenarios in the new test_console.cpp: a line from another thread reaches the sink only through flush_console() (and a flush=True partial 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.modules every module whose __file__ is a .py under scripts_directory() — revised: except the class modules themselves (_tap_python_*, which typing.get_type_hints and 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 sets sys.dont_write_bytecode = True at start-up, so no .pyc is written for anything under python/ and 3.6a's hazard cannot return for helpers (the shipped runtime and installed wheels carry their .pyc already, 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 whether filechanged sent 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 importing helper.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 by load() before it executes a changed file, compares normcase(abspath(__file__)) against the scripts folder initialize() hands it (no file-system calls per module), skips _tap_python_* and anything not a .py; PyConfig.write_bytecode = 0. One scenario 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__. Decided: the rule stands as documented — "save the class file too" — rather than a forced reload on filechanged or a folder watch; revisit if users ask.
  • 8.6 Type hints that map as a reader expects (A8). In hint_kind: unwrap Annotated and Final to their first argument; revised: a Union of several members other than None maps to any — the atom passes as it is, as for an unannotated parameter — rather than to one preferred member, since preferring float for str | float would silently lose strings as preferring str loses numbers today (on the Max side an any attribute is a symbol attribute, as now); a hint that is a subclass of float, int, bool or str maps to the base (np.float64 subclasses float), and the numpy scalar families that do not (np.int64, np.bool_) are recognized by their __mro__ names without the support module importing numpy. In return_shape: see through Optional[tuple[…]] to the tuple. Test: typed_more.py in test_types.cpp with the audit's table. CHANGELOG: the mapping changes (a float | int field was a symbol attribute). Done: _unwrap() for Annotated/Final (objects and, by regex, strings); unions of several kinds → 'any' (_one_kind); isinstance(hint, type) then issubclass against bool, int, float, str in that order, then numpy's abstract bases by __mro__ name and module; return_shape sees through Optional/| None (objects and strings). Fixtures typed_more.py, typed_strings.py (hints that cannot resolve, read as written) and typed_numpy.py; two scenarios in test_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_extended in the constructor, the core's "No file …" and "Failed to load …") goes through u8string() (C++20: reinterpret_cast its c_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) in runtime.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() and PyConfig already 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 tag v0.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 — reload failed: 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's hang failed the same way after its reload, silently (an unchanged file announces nothing). Fixed for 1.0.1 (answered_by_max() leaves out m_python_attributes as it did m_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. faults passes (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 before hang 1 begins 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 fresh pthread_attr_t (std::thread's) has 512 KiB here, and recursion through sorted's key crashes there after about 98 levels — short of deep_recursion.py's 450; at the worker's 16 MiB it reaches CPython's own recursion limit (about 2,500 levels), as threading.Thread does. Still by hand: step 4 on the release packages, and Build Collective for fileusage.

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.

Phase 7 — plugin front ends (optional, post-1.0)

  • 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.

Phase 9 — tap.python, a Python class as a Max object without audio (after 1.0)

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 the init/ objectfile mapping (or the stub external); outlet_insert_after before an obex-stored dumpout and get<attr> through it; the dispatch order of a class-level anything against instance methods and attributes, and object_getmethod() on such a class; the thread of a metro-driven message under Overdrive and under Scheduler in Audio Interrupt; symbol or anything for a str return. 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: the init/ mapping works; get<attr> needs the class's dumpout method; int, float, bang and list never reach a class-level anything; a str is output as symbol <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(); the bind_audio option, gating the tuple rule; a last parameter hinted list[…] or np.ndarray taking 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); the Loaded line 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.h in the project, a plain SDK class registered by the project's ext_main — ports with the dumpout and the object's lock, the output mapping, the anything forwarder, 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's bang); packaging; linux-max-glue gains 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.xml by 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 in make_patchers.py; run.py aware of the second object; the soak session gains a metro-driven tap.python under 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 tag v2.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.

Sequencing (one PR each)

  1. Phase 0.1–0.2 — the core split and the Linux battery; then 0.3–0.6.
  2. Phase 1 — crash/safety fixes.
  3. Phase 4.1–4.4 — build/installers/CI (independent; can run in parallel with 2).
  4. Phase 2.1–2.3 — RT hygiene, block path, prepare().
  5. Phase 3 — type bridge (likely split: dispatch + types; reload reconciliation; file-based loading).
  6. Phase 5 — docs/examples once the contract has settled.
  7. Phase 4.5–4.6 — release packaging and licenses.
  8. Phase 2.4–2.6 — multichannel, worker mode, reload stalls (features; may follow 1.0).
  9. Phase 6 — in-Max validation before tagging 1.0.
  10. Phase 4.7–4.8 — uv for the tooling, then one package for every platform on each tag.
  11. 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.
  12. 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 and v2.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.

External prerequisites

  • 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.