Skip to content

numchuck 0.3.0

Choose a tag to compare

@shakfu shakfu released this 22 Aug 21:23
· 7 commits to main since this release

Changes since the last release

Security

  • The web IDE bound every interface with no authentication (src/_web.cpp, web/__init__.py, cli/main.py): mg_http_listen was called with a hardcoded http://0.0.0.0:<port>, and there was no token, no origin check and no --host flag -- while the CLI printed http://localhost:<port>, understating the exposure. Since POST /api/compile runs arbitrary ChucK, and ChucK's standard library includes FileIO, anyone who could reach the port could read and write files as the user; the WebSocket REPL additionally exposed spork_file and replace_shred_file against arbitrary paths. Confirmed against a running instance. The listen address is now a property defaulting to 127.0.0.1, numchuck web gained --host, and any non-loopback bind is issued a random token automatically (secrets.token_urlsafe, 24 bytes) unless the caller passes one or explicitly waives it with --token "". The token is required on every /api/ request and on the WebSocket upgrade, accepted as Authorization: Bearer or ?token= (browsers cannot set headers on a WebSocket handshake), and compared without an early exit on the first differing byte so response latency does not leak it a character at a time. WebChuckServer.url carries the token, and the CLI warns when bound wide.

  • numchuck web now serves and opens the IDE in one step, authenticated (cli/main.py, web/__init__.py): the command already opened a browser, but only a non-loopback bind was issued a token, so the default run opened an unauthenticated page. That left a real hole: the origin check constrains browsers, but it only applies to requests that carry an Origin, and a local process sends none -- confirmed by an unauthenticated POST /api/compile from a plain HTTP client, which compiled and ran ChucK. On a shared machine that is every other user on the box. A token is now minted for every bind, loopback included, and the CLI prints and opens the URL carrying it -- the same model Jupyter uses for a localhost bind. --token "" still waives auth deliberately, and now says so loudly. The CLI also reports when it could not open a browser instead of appearing to do nothing.

  • Browsers served a cached page from a different server on the same port (src/_web.cpp): no response carried a Cache-Control header, so browsers fell back to heuristic caching (RFC 9111 4.2.2) and could reuse a response without revalidating. A loopback port is shared ground -- every local tool wants 8080 -- so this meant a document cached for an unrelated server on 127.0.0.1:8080 could be served in place of the IDE. Reported as "the frontend only works in Chrome": Safari was rendering a cached llama.cpp UI on numchuck's port, whose JS then called llama.cpp endpoints and reported "Server unavailable: 404 Not Found". Every response -- documents, static assets, JSON, and the 401/403/404 refusals -- now carries Cache-Control: no-store. VM state was never cacheable either, so the API needed this regardless.

  • The web IDE lost its token in a new tab, and threw on copy/paste outside a secure context (web/static/index.html): two cross-browser defects, both found while investigating a report that the frontend only worked in Chrome. The token was kept in sessionStorage, which is per-tab, so opening the IDE in a second tab authenticated nothing and its WebSocket closed immediately; it now uses localStorage, and a 401 discards the stored token so a stale one from an earlier run cannot wedge later loads. Separately, the REPL key handler called navigator.clipboard unguarded, and that API does not exist outside a secure context -- which any non-loopback bind is -- so Ctrl+C, Ctrl+X and Ctrl+V raised TypeError; both paths are now feature-detected with a fallback. Neither defect was browser-specific: the IDE was verified end to end in Chromium, Firefox and WebKit, and both bugs reproduced identically in all three.

  • The bundled web IDE did not send the auth token (web/static/index.html): the server enforced the token but the shipped page never presented one, so the IDE worked on loopback and was broken in exactly the configuration that requires auth -- every API call would have answered 401 and the WebSocket upgrade would have been refused. The page now takes the token from the ?token= the CLI opens it with, keeps it in sessionStorage so a reload does not lose it, and clears it from the address bar with history.replaceState so it does not linger in history or a screenshot. API calls carry it as Authorization: Bearer; the WebSocket URL carries it as a query parameter, since browsers cannot set headers on a handshake. A 401 or 403 is now reported in the console instead of the call silently doing nothing, and the socket scheme follows the page protocol rather than being hardcoded to ws://. Static files are deliberately still served without a token, because the page has to load before it can read one. Covered by raw WebSocket handshake tests and by a contract test asserting the shipped asset satisfies what the server enforces -- the gap existed because every other test drove the API directly rather than through the page.

  • Cross-site WebSocket hijacking (src/_web.cpp): mg_ws_upgrade was called with no Origin validation. WebSocket connections are not subject to the same-origin policy, so any page the user happened to visit while the IDE was running could open ws://localhost:8080/ws and drive the REPL -- which meant a loopback-only bind would not have been sufficient on its own. Every request carrying an Origin is now checked against its own Host header and refused with 403 on a mismatch, across the API, the static files and the upgrade. Comparing the request against itself rather than against a configured address needs no configuration and works identically for loopback, LAN-IP and hostname binds. Requests with no Origin (curl and other non-browser clients) are allowed; Origin: null, which is what sandboxed iframes and file:// pages send, does not match a Host and is refused.

  • A project-local .numchuck loaded native code without asking (paths.py, api.py, cli/main.py): get_numchuck_dir() preferred ./.numchuck whenever it existed, and Chuck.__init__ independently added ./.numchuck/chugins to the chugin search path. Chugins are native shared libraries loaded into the process, so cloning a repository and running numchuck inside it executed whatever native code that repository shipped, with no prompt and nothing printed. The local directory is now opt-in via --local (on edit, repl, run, watch and web) or NUMCHUCK_LOCAL=1; enable_local_dir() and get_local_numchuck_dir() expose the switch, and Chuck.__init__ consults it before adding the local chugin path.

  • OSC server defaulted to all interfaces (osc.py, constants.py): OSCServer.host defaulted to 0.0.0.0, and OSCController maps incoming messages straight onto global writes and event signals, so the default handed VM control to anything on the network. It now defaults to DEFAULT_OSC_HOST (127.0.0.1); pass host="0.0.0.0" deliberately to accept messages from other machines.

Fixed

  • Chugins were unloaded while their own threads were still running (thirdparty/chuck/core/chuck_dl.cpp, scripts/patches/0004-chugin-rtld-nodelete.patch): Chuck_DLL::load() called dlopen() without RTLD_NODELETE, so ~Chuck_DLL() genuinely unmapped a chugin's text. Every chugin is deleted by Chuck_ImportRegistry::shutdown(), which ChucK::shutdown() reaches via CK_SAFE_DELETE(m_carrier->compiler) -- but nothing guarantees the objects a chugin created are already gone by then, and a chugin may have started a thread of its own. ConvRev does: ConvRev::tick() spawns a std::thread per FFT block whose only joins are the next block boundary and ~ConvRev(), so the last block's worker outlives chuck.run() and can still be inside FFTConvolver::process() when the unmap lands. The next instruction fetch then faults. This was the "intermittent SIGSEGV on a background thread during ConvRev playback" tracked in TODO.md, at 7 crashes in 152 runs of pytest tests/test_api.py tests/test_examples.py. Seven captured faults were identical and unambiguous: SEGV_MAPERR with si_addr == RIP, the faulting thread never the main one, ConvRev.chug absent from /proc/self/maps while ~20 sibling chugins remained mapped, and both RIP and the return addresses on that thread's stack sitting in the hole it left -- resolving to .text+0xcda9, .text+0xcfcd and .text+0xcabe, that is ComplexMultiplyAccumulate called from ComplexMultiplyAccumulate called from FFTConvolver::process. Chugins are now opened with RTLD_NODELETE, and the Windows dlopen shim -- which ignored mode entirely -- honors it by pinning the module, since FreeLibrary() has the same hazard. Not unmapping costs only address space, and unloading plugin code that can outlive its own unload is unsafe in general, which is why CPython never dlcloses extension modules either. Verified over 400 runs of the reproducer.

  • faulthandler's re-raise made every crash address look like a corrupted pointer: worth recording because it sent the previous investigation down a blind alley. CPython's fatal-signal handler prints its traceback and then calls raise(signum), which glibc implements as tgkill(), so anything downstream -- a chained handler, AddressSanitizer -- sees si_code == SI_TKILL and an si_addr that is not a fault address at all but the si_pid/si_uid pair overlaid on the same union. That is the whole story behind the SEGV on unknown address 0x03e80002964c recorded in TODO.md: 0x3e8 is uid 1000 and 0x2964c is pid 169548. It was never a corrupted pointer. Running with -p no:faulthandler and installing a handler ahead of it yields the real SEGV_MAPERR and a usable RIP.

  • The OTF listener thread outlived the VM and segfaulted (thirdparty/chuck/core/chuck.cpp, util_network.c, scripts/patches/0002-otf-thread-join.patch): ChucK::shutdown() called pthread_cancel on the on-the-fly command thread and then abandoned it -- there is no pthread_join anywhere in the file. Cancellation only takes effect at a cancellation point, so otf_recv_cb() kept running while shutdown freed otf_socket and went on to tear down the VM, the compiler and the carrier, all of which that thread dereferences. It surfaced as an intermittent SIGSEGV in whatever test happened to be running, roughly one full-suite run in seven, which read as a flaky chugin test rather than a shutdown bug. gdb put it beyond doubt: the faulting instruction is cmpl $0x1,0x4(%rdi) at ck_accept+33 -- that is sock->prot -- with %rdi zero and a fault address of 0x4, so the thread had read carrier->otf_socket back after shutdown nulled it. The loop checks the shutdown flag only after calling ck_accept, so it could not notice in time. Shutdown now clears the flag first, cancels, and joins before freeing anything the thread can reach; ck_accept() also gained the NULL guard its sibling ck_close() already had. The trigger in-tree is tests/test_api.py, which constructs a VM with otf_enable=True and never shuts it down, so teardown happens at GC time -- arbitrarily later, which is what made it look unrelated to OTF. Verified over 100 runs of the reproducer: no OTF crash, no deadlock (the real risk when joining a cancelled thread), and no change in suite runtime.

  • Audio buffers allocated with new[] were freed with scalar delete (thirdparty/chuck/host/chuck_audio.cpp, scripts/patches/0003-audio-buffer-delete-array.patch): m_buffer_in and m_buffer_out are allocated as new SAMPLE[...] in ChuckAudio::initialize() but released with CK_SAFE_DELETE rather than CK_SAFE_DELETE_ARRAY, so every audio teardown ended in undefined behaviour on a 4096-byte allocation. Found with an AddressSanitizer build, which names it exactly: alloc-dealloc-mismatch (operator new [] vs operator delete), allocated at chuck_audio.cpp:1121, freed at chuck_audio.cpp:1313. The other CK_SAFE_DELETE calls in that file are on scalars and are correct.

    This did not fix the remaining crash it was suspected of causing. An intermittent segfault survives at roughly 3-5 runs in 100, always while tests/test_examples.py::test_chugin_convrev_example is executing. ASAN caught the signal itself -- SEGV on unknown address 0x03e80002964c ... T107, a read through a corrupted pointer on a background thread, not the main one -- but its reporter died before printing frames (nested bug in the same thread, aborting), and the race does not reproduce under gdb. The same ASAN run also reports a genuine stack over-read in the GVerb chugin (gverbdefs.h:114, an 8-byte read of a 4-byte stack float, ten occurrences per run); it is real undefined behaviour but a read of adjacent stack, so it does not explain a segfault and is left unfixed. Tracked in TODO.md.

  • NHHall did not compile on GCC 15 (thirdparty/chugins/NHHall/nh_hall.hpp, scripts/patches/chugins/0001-nhhall-cstdint.patch): the header uses uint16_t and uint32_t without including <cstdint>, and built only because older libstdc++ pulled that header in transitively via <cstdlib>/<memory>. libstdc++ 15 no longer does, so the build failed outright with 'uint32_t' has not been declared. The include is now explicit. Since this is vendored upstream code that scripts/update.sh overwrites wholesale, the fix is carried as the first entry in a new scripts/patches/chugins/ directory -- which incidentally repairs a latent bug in apply_patches, whose unguarded *.patch glob left the pattern literal and failed git apply while that directory did not exist.

  • Globals access on a VM that was not running segfaulted (src/_numchuck.cpp): ChucK::globals() returns NULL until the VM is running, not merely initialized -- it checks m_carrier->vm->running() before handing back the manager. Thirty of the thirty-three binding call sites called straight through the returned pointer, so any set_global_*, get_global_*, event or VM-message call on a VM that had been init()'d but never start()'d was a plain null dereference: SIGSEGV, no traceback, nothing the caller could catch. A binding should not be able to take the process down. require_globals() now fetches and validates in one place and every site goes through it, raising a RuntimeError that distinguishes "not initialized" from "initialized but not started" because the remedy differs. Three sites allocated before the check ran (a Chuck_Msg, the sample buffer in get_ugen_samples); the check moved ahead of the allocation so raising cannot leak. get_all_globals() previously soft-failed to an empty list, reporting "there are no globals" when the truth was "I cannot tell you", and now raises like the rest -- both in-tree callers already caught RuntimeError. Regression tests exercise all 27 reachable bindings in both states.

  • Global access on a freshly constructed Chuck segfaulted (api.py): every global accessor reaches into the VM's globals manager, which cannot be used on a VM that has never been started -- and run() was the only thing that started one implicitly. So Chuck().set_int("x", 1), Chuck().get_int("x"), Chuck().signal_event("e") and chuck.raw.get_all_globals() all took the process down with SIGSEGV before any audio was rendered, which is the first thing a reader of the documentation would try. Chuck.__init__ now calls start() alongside init(). Found while verifying the documentation examples against the build. The low-level ChucK still requires start() (or a first run()) before any set_global_*/get_global_* call and still crashes rather than raising if you skip it; the binding has 33 such call sites and guarding them is tracked separately in TODO.md. Regression tests cover the accessors that used to crash.

  • WebChuckServer.stop() deadlocked the interpreter (src/_web.cpp): WebServer::stop() joined the server thread while the calling Python thread still held the GIL, and that thread blocks on nb::gil_scoped_acquire before every API callback -- so neither side could proceed. The freeze was total and unrecoverable: no other Python thread ran, so not even a watchdog could observe it, and KeyboardInterrupt could not be delivered. It reproduced on the first stop whenever any other Python work was runnable alongside an in-flight request; a single-threaded test passes either way, which is why the existing coverage missed it. start() and stop() now release the GIL around the condition-variable wait and the join. PyGILState_Check is outside the stable ABI this extension targets, so the release is unconditional, and the invariant that makes it safe -- every path into start/stop comes from a bound method or the nanobind type destructor, both of which hold the GIL, while the server thread calls neither -- is documented at the call site. Verified over 20 stop-under-traffic cycles.

  • Blocking audio teardown held the GIL (src/_numchuck.cpp): start_audio, stop_audio and shutdown_audio waited on the audio thread with the GIL held. Under a workload that saturates the VM this froze the whole interpreter rather than merely being slow. Measured with a spork storm (~30,000 sporks/sec, 22,406 shreds resident and not draining): stop_audio() takes 45.5s, because the shreduler's sorted insert is linear in the pending set and the audio callback grows correspondingly expensive. That part is the VM being overwhelmed by a pathological workload, not a defect -- but holding the GIL across the wait turned it into a hang that could not be observed, interrupted or diagnosed from inside Python. All three now carry nb::call_guard<nb::gil_scoped_release>; nothing in them touches Python state. The wait can still be long, but other threads keep running and a supervisor can act.

  • clear failed in a session with no audio running (services/shreds.py): clear_vm() posts a CK_MSG_CLEARVM message that the VM only collects while it is being driven -- during real-time audio, or inside run(). Offline, the message was never collected and the REPL reported a bare "Failed to clear VM" for what is an ordinary offline session. ShredService.clear_vm() now falls back to remove_all_shreds(), which is a direct call, and logs that it did. tests/test_repl_stdin.py::test_clear_command is no longer skipped.

  • Web API reported failures as HTTP 200 (src/_web.cpp, web/__init__.py): every API response was replied with a hardcoded 200, so a client checking status codes treated {"error": ...} as success. The Python handler now returns (status, body) and the C++ layer uses it. Related fixes in the same path: a non-numeric shred id in /api/shred/<id> raised an uncaught ValueError and surfaced as 500, and is now a 400 naming the offending value; unknown endpoints are 404 rather than 200; a missing API handler is 503; and error text is JSON-escaped in C++ instead of being concatenated into a string literal, where a quote or backslash in an exception message produced a document the browser could not parse.

  • A second web server displaced the first (src/_web.cpp): the mongoose event handler dispatched through a single process-wide g_server pointer, so constructing a second WebServer took over the first's connections, and stopping either cleared the pointer for both. The listening connection now carries this as fn_data, which mongoose copies onto every accepted connection, and the global is gone. Covered by a test that runs two servers with different tokens and checks each rejects the other's.

  • cibuildwheel built cp39 wheels the package rejects (pyproject.toml): the build list was cp39-* ... cp313-* while requires-python is >=3.10, so a cp39 wheel would be built and then refused at install time. Narrowed to cp310-* through cp313-*.

  • make produced archives with a stale version (Makefile): VERSION was restated as 0.1.9 while the package was at 0.2.1, and it feeds DIST_NAME. It is now read from src/numchuck/_version.py.

  • Unbounded tap allocation (src/_numchuck.cpp): add_tap checked that capacity_frames was positive but had no upper bound, and allocates ring plus staging, so add_tap("x", 8, 2**30) asked for roughly 64 GB. Capped at CK_TAP_MAX_CAPACITY (2^22 frames), which raises instead of hanging or being OOM-killed.

Changed

  • The bison/flex parser is generated into the build tree, not the source tree (thirdparty/chuck/core/CMakeLists.txt): the generation step ran with WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}, so every CMake configure rewrote the committed chuck.tab.c, chuck.tab.h, chuck.yy.c and chuck.output in place. Any build on a machine whose bison differs from the one that produced the committed files left the vendored tree permanently dirty -- upstream generated them with bison 2.3, so a modern bison restates roughly 3,900 lines -- and that in turn blocked scripts/update.sh, which refuses to update a tree with uncommitted changes to upstream-owned files. Output now goes to ${CMAKE_CURRENT_BINARY_DIR}/parser, and the committed copies stay in core/ as the fallback for hosts without bison or flex; that path was verified by forcing the tools to be missing. Three details went with it: the generated sources still #include "chuck_absyn.h", which no longer resolves relative to the including file, so core/ joined the target's include path; the macOS -Wno-shorten-64-to-32 suppression matched chuck.yy.c by relative name and would have silently stopped applying, so it follows the path variable now; and the two tools were passed as two COMMANDs to a single execute_process, which pipes them rather than sequencing them and hid each tool's exit status, so they are now separate calls with their results checked.

  • The web IDE runs the same command executor as the TUI (web/__init__.py): the browser REPL carried its own if/elif dispatcher over the same parsed Command objects as tui.commands.CommandExecutor, and called the Chuck wrapper directly instead of going through the services layer that exists for exactly this. The two had drifted: shell, watch, record, midi, osc and recordings all parsed cleanly in the browser and then fell through to "Unknown command". WebChuckServer now builds a ChuckSession and a CommandExecutor over shared ShredService and GlobalsService instances, capturing executor output per-thread (threading.local) so a command can return what it printed. The module lost roughly 150 statements and the REST endpoints moved onto the same services, so the API and the REPL act on one session rather than two.

    The one deliberate difference is now explicit rather than accidental. _DENIED_COMMANDS names the commands the browser must not reach and why: those that start a process on the server host (shell, edit_shred, open_editor) and those that only terminate at a terminal (watch, an infinite loop broken by KeyboardInterrupt, which would have pinned mongoose's single-threaded event loop forever). A test derives that hazard set from the executor's own source and fails if anything new is missing from the list.

  • Shred listing reconciles with the VM only while the VM is running (web/__init__.py): a spork is a queued message, so with no audio running the VM's shred-id list stays empty. Syncing the session against it unconditionally discarded every shred the session legitimately knew about, making /api/status disagree with the REPL's own listing. sync_shreds() is now called only when audio is running, which is when the VM's list is meaningful.

  • A tap test measured wall-clock time and assumed a frame count (tests/test_ugen_tap.py): test_tap_keeps_more_history_than_one_block slept 0.5s and asserted the whole 8192-frame window was captured audio, on the reasoning that 0.5s at 44100 Hz is far more than 8192 frames. That holds on hardware, which is clocked to real time, and not on a virtual device: CI's PulseAudio null sink delivers audio but more slowly, so the oldest part of the window was still zero padding and the test failed. It now polls until the window actually fills -- which is the property being claimed, stated directly rather than inferred from a sleep -- and skips with the measured frame count if the device is too slow. The same reasoning applies to a loaded machine with real hardware, so this is more robust everywhere, not only on CI.

  • The tap regression test could pass on silence (tests/test_ugen_tap.py): test_realtime_tap_reads_are_never_torn asserts that no sample-to-sample step exceeds the sine's own maximum -- which an all-zero buffer satisfies trivially, since np.diff of silence is silence. Running it in CI for the first time made that visible: the runner's audio device opens and then delivers nothing, and the test went green having verified no property at all. It now also asserts the tap carried an audible signal, and the tests that need real audio go through a require_audible_tap() helper that waits for signal and skips with an explicit reason if none arrives. Verified both ways: the four tap tests pass against real hardware, and the helper skips against a deliberately silenced UGen.

  • Four latent CI defects, exposed by turning CI back on (.github/workflows/ci.yml, pyproject.toml): re-enabling the workflows surfaced problems that had been invisible while nothing ran.

    Missing link dependency. Every Linux job died at cannot find -lsndfile. Only the cibuildwheel path installed libsndfile1-dev; the five jobs in ci.yml did not. wheels.yml already had it, which is why wheel builds worked and tests never had.

    A step that failed whenever the tests passed. "Verify clean exit" assigned exit_code only on failure, then compared the unset variable against "0" -- so a successful run compared "" against "0", took the failure branch, and printed Tests failed with exit code with nothing after it. All four macOS jobs failed this way while reporting 1134 passed. The step also re-ran the entire suite, which the preceding step already does: a run: step fails on any non-zero exit, including a crash during interpreter teardown, which is what it was guarding. Removed rather than repaired.

    Lint rules that only existed on the runner. The lint job passed --select E,F,W,I on the command line while make lint ran bare ruff check, which uses ruff's smaller default set. Import sorting was therefore enforced only in CI, where 26 violations appeared that no local command would report. The rules now live in [tool.ruff.lint] in pyproject.toml and both invocations inherit them; a make lint-check target runs exactly what CI runs. The 26 violations are fixed.

    An audio device the runners cannot provide. The realtime job loaded snd-dummy, which the Azure runner images have no kernel modules for. It now tries snd-dummy, falls back to a PulseAudio null sink (no kernel support needed), and reports which route worked. If neither is available it emits a warning annotation and skips the test step rather than failing the build on a runner limitation -- loud about not having tested rather than quietly appearing to. Setting the null sink as the default output was what made it actually carry audio; with that in place the tap tests, including the seqlock tear check, run for real on the runner rather than skipping.

    Lint that only reproduced on the runner, again. The lint job installed ruff unpinned, so it ran whatever version had shipped that day -- 0.16.3 against the locked 0.14.10 -- and the formatter is not stable across minor versions. ruff format --check failed on a lambda that the local, locked ruff leaves alone. The job now installs from the lockfile (uv sync --only-group dev --no-install-project), so the version is the same one contributors have.

  • CI runs again, and can fail (.github/workflows/): all three workflows were workflow_dispatch-only, with the real triggers commented out, so nothing ran on push, pull request or tag. Restored push/pull_request on ci.yml and the v* tag trigger on wheels.yml. The lint job had continue-on-error: true on both its steps and so could not fail a run; removed. mypy was installed in the lint job and never invoked; it now runs --strict in a dedicated typecheck job that builds the extension first, which it needs. submodules: recursive was dropped from all three workflows -- thirdparty/ is vendored and there is no .gitmodules, so the setting was inert and implied otherwise.

  • Documentation corrected against the tree (docs/architecture.md, CLAUDE.md, README.md): the architecture file structure predated services/, web/, midi.py, osc.py, recorder.py, render.py, watcher.py and config.py, and pointed at tui/chuck_lexer.py and tui/paths.py, which have moved to lang/lexer.py and the package root. Its security section now describes the web trust model, the local-directory opt-in and the OSC default rather than a generic "no sandboxing" note. CLAUDE.md and docs/architecture.md described thirdparty/ as git submodules; it is vendored. The README documents the web IDE's trust model beside the commands that launch it, and the user-directory section reflects that the local directory is opt-in.

Added

  • Documentation moved from Sphinx to MkDocs, and wired to a build (mkdocs.yml, docs/, Makefile, .github/workflows/ci.yml): the Sphinx tree under docs/api/ had no build wiring at all -- no docs target, no CI job, no Read the Docs config -- so nothing had ever checked whether it built, and it had drifted badly. It documented numchuck.ChucK, numchuck.PARAM_SAMPLE_RATE, numchuck.start_audio() and friends, none of which exist at those paths: the low-level class and the constants live on numchuck._numchuck, start_audio takes the instance as its first argument, and the high-level Chuck that __init__.py recommends was barely covered. Every autosummary entry in index.rst named a missing attribute.

    Now MkDocs with the Material theme and mkdocstrings, which introspects both the pure-Python package and the compiled extension for docstrings. make docs builds, make docs-serve previews, make docs-deploy publishes, make qa includes the build, and a CI job runs it. Everything runs with --strict, so a broken cross-reference or a page missing from the nav fails rather than shipping a dead link -- which immediately caught a stale source link in numchuck_home.md and a symbol documented on two pages.

    The prose was rewritten against the real API rather than transliterated, and every example was executed against the build before being committed. That turned up the array-shape claim (run() returns flat interleaved samples, not (frames, channels)) and the segfault recorded under Fixed. The existing developer notes in docs/ -- architecture, chugins, the fix write-ups -- join the same site under a Development section instead of being markdown nobody rendered. docs/numchuck_home.md was corrected too: it documented a numchuck.cli.paths module that does not exist (it is numchuck.paths), and described config.toml as a planned feature.

  • thirdparty/VERSIONS.md: vendoring left no record of where each tree came from. This file names the upstream, the version recoverable from the source, and the local patches, and flags what cannot be recovered -- the upstream commits predate the file, so the ref column is to be filled in on the next scripts/update.sh run, which prints the SHA of each clone it makes. It also records two things worth attention: mongoose is pinned at 7.9 and is the only vendored component that parses untrusted input from a socket, and ChucK is a -dev snapshot rather than a release.

  • Real-time tests run in CI (.github/workflows/ci.yml): the tap seqlock is the most intricate code in the project and its only regression test is marked realtime, which every CI job filtered out -- so it ran nowhere but a developer's machine. A realtime job loads snd-dummy on the Ubuntu runner and runs pytest -m realtime against it. The job asserts the device exists before running, so the tests cannot skip themselves into a green run that checked nothing.

  • AudioService.sync_state() (services/audio.py): the service tracked a private _running flag, but audio can be started or stopped without going through it -- the REPL command executor calls start_audio()/stop_audio() directly -- so is_running went stale for any front-end mixing the two paths, and stop() would short-circuit on it. sync_state() reconciles the flag with is_audio_running() and returns the result.

  • Web security and REPL test suites (tests/test_web_security.py, tests/test_web_repl.py): 58 tests covering bind address and loopback classification, token issuance and enforcement, the Origin/Host check, API status codes, JSON-validity of error bodies, teardown with requests in flight, two servers coexisting, the deny-list's completeness against the executor, and the commands that used to fall through to "Unknown command". Coverage of web/__init__.py went from 20% to 60%, over a module that also shrank by a quarter.

Removed

  • The Sphinx documentation tree (docs/api/conf.py, docs/api/requirements.txt, docs/api/*.rst): replaced by the MkDocs site described under Added. Nothing referenced the removed files, and no build had ever consumed them.

  • paths.get_themes_dir() and paths.get_keybindings_dir(): neither had a caller outside tests/test_paths.py. Themes and keybindings are configured through config.toml, not by scanning a directory, so these were dead API whose passing tests implied otherwise. The directories are still created by ensure_numchuck_directories(), which now works from a single NUMCHUCK_SUBDIRS declaration rather than restating the layout. config.get_config_path() was a second, disagreeing definition of the config location and now delegates to paths.get_config_file().