diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..6790161 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,94 @@ +name: Bug report +description: The operator behaves incorrectly in TouchDesigner +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a report. If this is a **security** + issue, please use private reporting instead — see [SECURITY.md](../blob/main/SECURITY.md). + + - type: textarea + id: summary + attributes: + label: What happened? + description: What did you expect, and what did you get instead? + placeholder: | + Tasks added with add_task() stop running after the Reset pulse, but I expected ... + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Reproduction + description: > + The smallest script that shows the problem, run against an AsyncioDAT + operator. It will be rendered as Python automatically. If it needs a + `.toe` to reproduce, attach a minimal one as well. + render: python + placeholder: | + import asyncio + + adat = op('Asyncio1') + + async def repro(): + await asyncio.sleep(1.0) + print("done") + + adat.add_task(repro()) + validations: + required: true + + - type: textarea + id: status + attributes: + label: Operator status output + description: > + The rows from the AsyncioDAT operator's status table, plus anything + relevant from the textport. + validations: + required: false + + - type: input + id: version + attributes: + label: AsyncioDAT version + description: Release tag, or the commit SHA you built from. + placeholder: "v0.4.0, or 1a2b3c4" + validations: + required: true + + - type: input + id: td_version + attributes: + label: TouchDesigner version + placeholder: "2025.32820" + validations: + required: true + + - type: input + id: environment + attributes: + label: Platform + placeholder: "Windows 11, or macOS 15 (Apple Silicon)" + validations: + required: true + + - type: dropdown + id: origin + attributes: + label: How did you get the operator? + options: + - Downloaded a release asset + - Built from source + validations: + required: true + + - type: textarea + id: notes + attributes: + label: Anything else? + description: Stack traces, crash dumps, or a failing test case. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..665d08b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Usage, parameters, and the Python API + url: https://github.com/Actualize-Interactive/AsyncioDAT#readme + about: The README covers installation, every parameter, and the full Python API — worth checking before filing. + - name: Report a security vulnerability + url: https://github.com/Actualize-Interactive/AsyncioDAT/security/advisories/new + about: Please report security issues privately, not as a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..62c6428 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,37 @@ +name: Feature request +description: Suggest a capability, parameter, or API addition +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What problem would this solve? + description: > + Describe the use case rather than the implementation. What are you + trying to do in TouchDesigner that the operator makes hard or + impossible today? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed API + description: > + If you have a shape in mind, sketch it — a Python method, a getset, an + operator parameter, or a callback. Additive changes are much easier to + accept than changes to existing signatures or parameter names, which + break saved `.toe` files. + render: python + validations: + required: false + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: > + Anything you tried, or workarounds you are using now — including + whether it can already be done through `get_event_loop()`. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d7c0b8b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,42 @@ + + +## What does this change? + + + +## Why? + + + +## How was it tested? + + + +## Checklist + +- [ ] The project builds on my platform (`.\build.ps1` or `./build.sh`). +- [ ] The unit suites pass (`cmake --workflow --preset dev`). +- [ ] The TouchDesigner integration suite passes, or is not affected + (`.\run_td_tests.ps1` / `./run_td_tests.sh`). +- [ ] New or changed behavior is covered by tests. +- [ ] `README.md` / `TESTING.md` updated for any change to parameters, the + Python API, or the callbacks. +- [ ] `CHANGELOG.md` has an entry under `[Unreleased]`, if user-visible. + +## Breaking changes + + + +None diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2b4152d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +version: 2 + +updates: + # Keep the workflow actions current. The only package-manager manifest in the + # repository is the pytest requirements file below: the C++ dependency + # (toml11) is pinned as a FetchContent git tag in CMakeLists.txt, which + # Dependabot cannot parse, so it is bumped by hand. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "ci" + groups: + github-actions: + patterns: + - "*" + + # Test-only Python dependencies (pytest and friends). + - package-ecosystem: "pip" + directory: "/tests/python" + schedule: + interval: "monthly" + commit-message: + prefix: "test" + groups: + pytest: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53b4a15..4adcf02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,73 +5,67 @@ on: branches: [ main ] pull_request: branches: [ main ] + workflow_dispatch: -# Some third-party JS actions (e.g. setup-cmake) still ship on Node.js 20; opt -# their JS actions into Node 24 ahead of the forced cutover (2026-06-02). -# Remove once all actions ship Node 24. -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true +permissions: + contents: read jobs: build: + runs-on: ${{ matrix.os }} + timeout-minutes: 20 strategy: - fail-fast: false matrix: os: [windows-latest, macos-latest] - build_type: [Release] - - runs-on: ${{ matrix.os }} + fail-fast: false steps: - - name: Checkout code - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - - name: Setup CMake - uses: jwlawson/actions-setup-cmake@v2 + - name: Setup cmake + uses: lukka/get-cmake@v4.4.0 with: - cmake-version: '3.25' + cmakeVersion: '4.4.0' # TouchDesigner embeds CPython 3.11, so we build against 3.11. setup-python - # provides the headers + import library that CMake's Development.Module - # component needs (Python itself is not vendored in the repo). + # provides the headers and import library that CMake's Development.Module + # component needs (Python itself is not vendored in the repository). - name: Setup Python 3.11 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.11' - name: Configure CMake run: > - cmake -B build - -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} + cmake -B build -S . + -DCMAKE_BUILD_TYPE=Release -DPython3_ROOT_DIR="${{ env.pythonLocation }}" -DASYNCIODAT_BUILD_TESTS=ON - - name: Build project - run: cmake --build build --config ${{ matrix.build_type }} - - - name: List build outputs - shell: bash - run: ls -R build/bin + - name: Build + run: cmake --build build --config Release --parallel 2 - # Unit tests (Catch2 + pytest) — no TouchDesigner required. + # tests/cpp (Catch2) and tests/python (pytest) only — the tests/td + # integration suite needs a licensed TouchDesigner and a GPU, so it runs + # locally via run_td_tests.ps1 / run_td_tests.sh instead. - name: Install test dependencies run: python -m pip install --upgrade pip -r tests/python/requirements.txt - - name: Run unit tests - run: ctest --test-dir build -C ${{ matrix.build_type }} --output-on-failure + - name: Test + run: ctest --test-dir build -C Release --output-on-failure - name: Upload build artifact (Windows) if: runner.os == 'Windows' uses: actions/upload-artifact@v7 with: - name: AsyncioDAT-windows-${{ matrix.build_type }} - path: build/bin/${{ matrix.build_type }}/AsyncioDAT.dll + name: AsyncioDAT-windows + path: build/bin/Release/AsyncioDAT.dll if-no-files-found: error - name: Upload build artifact (macOS) if: runner.os == 'macOS' uses: actions/upload-artifact@v7 with: - name: AsyncioDAT-macos-${{ matrix.build_type }} + name: AsyncioDAT-macos path: build/bin/AsyncioDAT.plugin if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58bc7ed..cbdd879 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,60 +1,146 @@ name: Release on: - release: - types: [published] + push: + tags: + - 'v*.*.*' + workflow_dispatch: -# Some third-party JS actions (e.g. setup-cmake, action-gh-release) still ship -# on Node.js 20; opt their JS actions into Node 24 ahead of the forced cutover -# (2026-06-02). Remove once all actions ship Node 24. -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true +permissions: + contents: read jobs: - build-and-release: + # Build, test, and package on every platform as the release gate. Nothing is + # published unless this passes on both. + build: strategy: - fail-fast: false matrix: os: [windows-latest, macos-latest] - + fail-fast: false runs-on: ${{ matrix.os }} + timeout-minutes: 20 steps: - - name: Checkout code - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - - name: Setup CMake - uses: jwlawson/actions-setup-cmake@v2 + - name: Setup cmake + uses: lukka/get-cmake@v4.4.0 with: - cmake-version: '3.25' + cmakeVersion: '4.4.0' - name: Setup Python 3.11 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.11' - name: Configure CMake run: > - cmake -B build + cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DPython3_ROOT_DIR="${{ env.pythonLocation }}" + -DASYNCIODAT_BUILD_TESTS=ON + + - name: Build + run: cmake --build build --config Release --parallel 2 - - name: Build project - run: cmake --build build --config Release + - name: Install test dependencies + run: python -m pip install --upgrade pip -r tests/python/requirements.txt + + - name: Test + run: ctest --test-dir build -C Release --output-on-failure - # Package the platform artifact into a single uploadable file. - name: Package (Windows) if: runner.os == 'Windows' shell: pwsh run: Compress-Archive -Path build/bin/Release/AsyncioDAT.dll -DestinationPath AsyncioDAT-windows.zip + # ditto, not zip: the .plugin is a bundle, and ditto preserves the symlinks + # and resource forks a plain zip would flatten. - name: Package (macOS) if: runner.os == 'macOS' run: ditto -c -k --keepParent build/bin/AsyncioDAT.plugin AsyncioDAT-macos.zip - - name: Upload Release Asset - uses: softprops/action-gh-release@v2 + - name: Upload package + uses: actions/upload-artifact@v7 + with: + name: package-${{ runner.os }} + path: AsyncioDAT-*.zip + if-no-files-found: error + + # Publish the release from the artifacts the gate produced. + # + # Only runs for tag pushes. A release cannot be published from a branch, so on + # workflow_dispatch this job is skipped and the workflow acts as a dry run of + # the cross-platform build, test, and packaging gate above. + release: + if: startsWith(github.ref, 'refs/tags/') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + # Squash-merging a pull request rewrites the commit, so a tag pushed to the + # pre-merge branch tip builds and tests green while pointing at a commit + # reachable only from the tag. Publishing that produces a release whose + # history is not on main, which is only noticed much later by whoever pins + # it. Refuse the tag instead. + - name: Verify the tag is on main + run: | + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main; then + echo "::error::$GITHUB_REF_NAME ($GITHUB_SHA) is not an ancestor of main. Delete the tag, then re-tag the merged commit on main." >&2 + exit 1 + fi + echo "$GITHUB_REF_NAME is on main." + + - name: Download Windows package + uses: actions/download-artifact@v8 + with: + name: package-Windows + path: dist + + - name: Download macOS package + uses: actions/download-artifact@v8 + with: + name: package-macOS + path: dist + + # Use the CHANGELOG entry for this tag as the release body, so the notes are + # written once and reviewed in the pull request that introduced them. + - name: Extract release notes from CHANGELOG + run: | + version="${GITHUB_REF_NAME#v}" + # Match the heading by prefix rather than by regex: a version string + # interpolated into a regex turns "[0.4.0]" into a character class, and + # whether the escaping survives depends on the awk in use (mawk keeps + # the backslash, gawk drops it and silently matches nothing). + awk -v head="## [$version]" ' + index($0, head) == 1 { found = 1; next } # heading for this version + found && index($0, "## [") == 1 { exit } # next version heading + found && /^\[.*\]: / { exit } # link reference block + found && index($0, " + +[Unreleased]: https://github.com/Actualize-Interactive/AsyncioDAT/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/Actualize-Interactive/AsyncioDAT/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/Actualize-Interactive/AsyncioDAT/compare/v0.2.2...v0.3.0 +[0.2.2]: https://github.com/Actualize-Interactive/AsyncioDAT/compare/v0.2.1...v0.2.2 +[0.2.1]: https://github.com/Actualize-Interactive/AsyncioDAT/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/Actualize-Interactive/AsyncioDAT/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/Actualize-Interactive/AsyncioDAT/releases/tag/v0.1.0 diff --git a/CMakeLists.txt b/CMakeLists.txt index 37427e2..8c15a94 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.25) -project(AsyncioDAT VERSION 1.0.0 LANGUAGES CXX) +project(AsyncioDAT VERSION 0.4.0 LANGUAGES CXX) set(VERBOSE_STATUS OFF CACHE BOOL "Verbose status messages" FORCE) set(SUPPRESS_NOT_REFERENCED_WARNINGS ON CACHE BOOL "Suppress not referenced warnings" FORCE) @@ -165,6 +165,7 @@ elseif(APPLE) BUNDLE TRUE BUNDLE_EXTENSION "plugin" MACOSX_BUNDLE_GUI_IDENTIFIER "com.actualize.asyncio-dat" + MACOSX_BUNDLE_BUNDLE_NAME "AsyncioDAT" MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION} MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist.in" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 821dfe1..706c193 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,13 +5,16 @@ bug reports, fixes, docs, and features — are welcome. ## Reporting issues -Open a GitHub issue and include: +Open a GitHub issue using the bug report form, which asks for: - Your TouchDesigner version (e.g. 2025.32820) and OS. - What you expected to happen vs. what happened. - A minimal `.toe` or script that reproduces the problem, if possible. - Relevant output from the AsyncioDAT operator's status table / textport. +Security issues go through [private reporting](SECURITY.md) instead, not a +public issue. + ## Development setup AsyncioDAT is a C++ TouchDesigner Custom Operator (a DAT) that embeds an @@ -65,11 +68,38 @@ changed is covered by or verified against the test scripts before opening a PR. ## Pull requests - Branch off `main` and keep PRs focused. +- Use clear, imperative commit messages with a type prefix (`fix:`, `feat:`, + `docs:`, `test:`, `chore:`, `ci:`). - Match the existing code style (tabs in C++ sources, existing naming). - Update `README.md` / `TESTING.md` when you change behavior, parameters, or the Python API. +- Add an entry under `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md) for any + user-visible change, and call out breaking changes explicitly. - Describe how you tested the change. +**Operator parameter names and the Python API are stable.** A parameter's +internal name (`Autopoll`, `Maxstatusrows`, …) is what saved `.toe` files store, +so renaming one silently drops the user's setting on load. Prefer additive +changes; if a break is unavoidable, say so in the PR and in the changelog. + +## Releasing + +Releases are cut from `main` by pushing a tag: + +1. In a PR, bump `project(AsyncioDAT VERSION …)` in `CMakeLists.txt` and move + the `[Unreleased]` changelog entries under a `## [x.y.z] - YYYY-MM-DD` + heading, adding the compare link at the bottom of the file. +2. After it merges, tag the merge commit on `main` and push the tag: + + ```bash + git tag vx.y.z && git push origin vx.y.z + ``` + +The Release workflow builds, tests, and packages on Windows and macOS, refuses +the tag if it is not an ancestor of `main`, and publishes the release using the +changelog entry for that version as the release notes. It fails if no such entry +exists. + ## License By contributing, you agree that your contributions are licensed under the diff --git a/README.md b/README.md index c4787d1..8c09050 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # AsyncioDAT - Asyncio Event Loop for TouchDesigner +[![CI](https://github.com/Actualize-Interactive/AsyncioDAT/actions/workflows/ci.yml/badge.svg)](https://github.com/Actualize-Interactive/AsyncioDAT/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +![C++20](https://img.shields.io/badge/C%2B%2B-20-blue.svg) +![TouchDesigner 2025.32820](https://img.shields.io/badge/TouchDesigner-2025.32820-orange.svg) + AsyncioDAT is a C++ TouchDesigner operator that provides a managed asyncio event loop for Python scripting within TouchDesigner. It enables truly asynchronous programming without blocking the main TouchDesigner thread. ## Features @@ -10,16 +15,56 @@ AsyncioDAT is a C++ TouchDesigner operator that provides a managed asyncio event - **Easy Python Integration**: Simple Python API for adding coroutines and managing async tasks - **Comprehensive Error Handling**: Robust error handling and recovery mechanisms +## Requirements + +- **TouchDesigner 2025.32820** or newer. The operator is built against Custom + Operator SDK v4 and TouchDesigner's embedded CPython **3.11**. +- **Windows 10/11**, or **macOS on Apple Silicon**. The macOS build is arm64 + only, matching TouchDesigner. + ## Installation -1. Build the project using the provided PowerShell script: - ```powershell - .\build.ps1 +### From a release (no build required) + +1. Download the asset for your platform from the + [latest release](https://github.com/Actualize-Interactive/AsyncioDAT/releases/latest) + and extract it: + + | Platform | Asset | Contents | + | --- | --- | --- | + | Windows | `AsyncioDAT-windows.zip` | `AsyncioDAT.dll` | + | macOS | `AsyncioDAT-macos.zip` | `AsyncioDAT.plugin` bundle | + +2. Put the operator in a **`Plugins/` folder beside your `.toe`**. TouchDesigner + loads Custom Operators from a `Plugins/` directory next to the project file; + there is no way to point it at an arbitrary directory. (A `Plugins/` folder + in your TouchDesigner user directory — `Documents/Derivative/Plugins/` — + works too, and makes the operator available to every project.) + + ```text + MyProject/ + ├── MyProject.toe + └── Plugins/ + └── AsyncioDAT.dll # or AsyncioDAT.plugin on macOS ``` -2. The DLL will be automatically copied to the `tests/td/Plugins/` directory +3. **macOS only:** the downloaded bundle carries a quarantine attribute and is + not signed or notarized, so TouchDesigner will refuse to load it until you + clear the attribute: -3. Open the test TouchDesigner file: `tests/td/test.toe` + ```bash + xattr -dr com.apple.quarantine Plugins/AsyncioDAT.plugin + ``` + +4. Open your project. On first load, TouchDesigner shows a modal asking you to + **approve/trust** the Custom Operator — accept it. Then add an **Asyncio** + DAT from the operator palette. + +### From source + +See [Building from Source](#building-from-source) below. `build.ps1` / +`build.sh` copy the freshly built operator into `tests/td/Plugins/`, so the +bundled test project (`tests/td/test.toe`) picks it up. ## Configuration @@ -113,8 +158,48 @@ loop = asyncio_op.get_event_loop() # Use the loop directly for advanced operations if loop: loop.call_later(5.0, lambda: print("Called after 5 seconds")) + +# Number of callbacks currently ready to run on the loop +pending = asyncio_op.get_callback_count() ``` +#### Plugin Registry + +The operator holds a dictionary of named Python objects that outlives any single +script — useful for keeping a client, a connection pool, or a service object +alive across cooks. Register them from `on_start` (see +[Lifecycle Callbacks](#lifecycle-callbacks)) and reach them from anywhere. + +```python +asyncio_op = op('Asyncio1') + +# Register / replace. Returns True on success; re-registering a name replaces +# the previous object and releases the reference to it. +asyncio_op.set_plugin('my_service', MyService()) + +# Look up. Returns None if the name is not registered. +service = asyncio_op.get_plugin('my_service') + +asyncio_op.has_plugin('my_service') # -> True +asyncio_op.del_plugin('my_service') # -> True, or False if it wasn't there +asyncio_op.clear_plugins() # remove all of them +``` + +The `plugins` property is an attribute-style view over the same registry, and +`plugin_names` lists the registered names: + +```python +asyncio_op.plugins.my_service = MyService() # same as set_plugin +service = asyncio_op.plugins.my_service # AttributeError if not registered +del asyncio_op.plugins.my_service # same as del_plugin + +'my_service' in asyncio_op.plugins # -> bool +asyncio_op.plugin_names # -> list of names +``` + +These methods act on whichever AsyncioDAT instance owns the event loop, so +calling them before one is active raises `RuntimeError`. + ### Example Usage #### Simple Async Task @@ -290,7 +375,7 @@ async def robust_task(): - Try using the Reset pulse parameter 2. **Tasks Not Executing** - - Verify "Auto Process Events" is enabled + - Verify "Auto Poll" is enabled - Check the operator's status output for error messages - Ensure the operator is cooking every frame @@ -306,12 +391,16 @@ async def robust_task(): ### Debug Information -The operator's output provides real-time status: -- Execute count (frames processed) -- Asyncio initialization status -- Event loop running state -- Auto-processing state -- Available methods list +The operator's output table carries the most recent status messages (how many is +set by **Max Status Rows**), and the same messages go to the textport. Attach an +Info CHOP to the operator for live counters: + +| Channel | Meaning | +| --- | --- | +| `event_loop_active` | 1 while asyncio is initialized | +| `event_loop_auto_poll` | 1 while **Auto Poll** is on | +| `event_loop_poll_count` | Frames polled since initialization | +| `event_loop_poll_duration` | Time spent in the last poll, in milliseconds | ## Building from Source @@ -346,18 +435,23 @@ headers are included under `ext/td/include/` (see [NOTICE](NOTICE)). ### Continuous Integration The project includes GitHub Actions workflows for: -- **CI**: Automatic builds on Windows and macOS for every push and pull request to `main` -- **Release**: Automatic building and publishing of artifacts when a release is published +- **CI**: builds and runs the unit suites on Windows and macOS for every push and + pull request to `main` +- **Release**: triggered by pushing a `v*.*.*` tag. It builds, tests, and + packages on both platforms before publishing anything, and takes the release + notes from the matching [CHANGELOG.md](CHANGELOG.md) entry. See + [CONTRIBUTING.md](CONTRIBUTING.md#releasing). Release artifacts (`AsyncioDAT-windows.zip` containing `AsyncioDAT.dll`, and -`AsyncioDAT-macos.zip` containing the `AsyncioDAT.plugin` bundle) are built and -attached to GitHub releases for easy download. +`AsyncioDAT-macos.zip` containing the `AsyncioDAT.plugin` bundle) are attached +to GitHub releases for easy download. ### Development The project structure: - `src/asyncio_dat.cpp/h`: Main operator implementation - `src/py_bindings.cpp/h`: Python C API bindings +- `src/config.cpp/h`: `config.toml` parsing - `ext/td/include/`: TouchDesigner Custom Operator SDK headers (see [NOTICE](NOTICE)) - `tests/cpp/`: Catch2 unit tests (no TouchDesigner required) - `tests/python/`: pytest suite against a compiled test extension (no TouchDesigner required) @@ -366,11 +460,16 @@ The project structure: ## License -This project is licensed under the MIT License. See LICENSE file for details. +This project is licensed under the MIT License — see [LICENSE](LICENSE). Bundled +and third-party components are attributed in [NOTICE](NOTICE). ## Contributing -Contributions are welcome! Please feel free to submit pull requests or open issues for bugs and feature requests. +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the build, +test, and pull request expectations. Security issues go through +[private reporting](SECURITY.md), not a public issue. + +Release-by-release changes are recorded in [CHANGELOG.md](CHANGELOG.md). ## Support diff --git a/SECURITY.md b/SECURITY.md index 6aaace7..9ebe8a4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,24 +2,60 @@ ## Supported versions -AsyncioDAT is maintained on a rolling basis. Security fixes target the latest -release and the `main` branch. +AsyncioDAT is pre-1.0 and is developed on a single line. Security fixes are +applied to the latest release only; there are no maintained backport branches. -## Reporting a vulnerability - -Please report security issues privately rather than opening a public issue. - -- Use GitHub's **"Report a vulnerability"** (Security → Advisories) on this - repository, or -- Email **keith@actualize.vision** with details and reproduction steps. +| Version | Supported | +| --- | --- | +| 0.4.x | ✅ | +| < 0.4 | ❌ | -We will acknowledge your report and keep you updated on remediation. Thank you -for helping keep AsyncioDAT and its users safe. - -## Scope notes +## Reporting a vulnerability -AsyncioDAT runs Python coroutines inside TouchDesigner's interpreter and can -load callback modules from disk (see the `config.toml` `callback_module_path` -and `[main].paths` settings). Treat `.toe` files and any configured callback -modules / `sys.path` entries as trusted code: a malicious project file can run -arbitrary Python, just as a native TouchDesigner project can. +**Please do not report security issues through public GitHub issues.** + +Report privately through GitHub's +[private vulnerability reporting](https://github.com/Actualize-Interactive/AsyncioDAT/security/advisories/new). +The form is the only reporting channel; it lets us discuss and fix the issue +with you before anything becomes public, and it requires nothing more than a +GitHub account. + +Please include: + +- the affected version or commit, and your TouchDesigner version, +- a description of the issue and its impact, +- the steps, script, or minimal `.toe` needed to reproduce it, +- and any suggested fix, if you have one. + +You can expect an acknowledgement within a few business days. We will keep you +informed as we investigate, and will credit you in the release notes when the +fix ships unless you prefer otherwise. + +## Scope + +AsyncioDAT is a native operator loaded into TouchDesigner's process. It has no +network or process boundary of its own: it drives an asyncio event loop on +behalf of the Python code in the hosting project. The issues most relevant here +are memory-safety and lifetime problems in the operator itself, such as: + +- crashes or memory corruption reachable from the Python API (`add_task`, + `create_task`, `run_coroutine`, the plugin registry) with unusual but + legitimate arguments, +- reference-counting errors on Python objects that lead to use-after-free, +- crashes triggered by the operator's own error paths, by a malformed + `config.toml`, or by shutting the loop down while tasks are in flight. + +### Out of scope by design + +AsyncioDAT runs Python coroutines inside TouchDesigner's interpreter and loads +callback modules from disk — see the `config.toml` `callback_module_path` and +`[main].paths` settings. Treat `.toe` files and any configured callback modules +or `sys.path` entries as **trusted code**: a malicious project file can run +arbitrary Python, exactly as a native TouchDesigner project can. A report whose +premise is that an untrusted `.toe` or callback module executes code is +describing the intended design, not a vulnerability. + +Because the operator trusts its hosting project by design, a report that +depends on the project passing deliberately corrupt state is likely to be +treated as a normal bug rather than a vulnerability. Report it as a regular +issue and we will still fix it. diff --git a/TESTING.md b/TESTING.md index 4eeeb2b..cdb3289 100644 --- a/TESTING.md +++ b/TESTING.md @@ -271,10 +271,4 @@ asyncio_op.add_task(heartbeat()) ## Performance Monitoring -The AsyncioDAT operator provides real-time statistics: -- Execute count (frames processed) -- Event loop status -- Auto-processing state -- Available methods - -Monitor these values to ensure proper operation and identify potential issues. +Attach an Info CHOP to the AsyncioDAT operator for real-time statistics: `event_loop_active`, `event_loop_auto_poll`, `event_loop_poll_count`, and `event_loop_poll_duration` (milliseconds spent in the last poll). Monitor these values to ensure proper operation and identify potential issues — a poll duration that climbs with the number of tasks is the signal that something in a coroutine is blocking rather than awaiting. diff --git a/src/asyncio_dat.cpp b/src/asyncio_dat.cpp index 0392cea..792577f 100644 --- a/src/asyncio_dat.cpp +++ b/src/asyncio_dat.cpp @@ -173,8 +173,10 @@ AsyncioDAT::execute(DAT_Output* output, const OP_Inputs* inputs, void* reserved1 auto active = static_cast(inputs->getParInt("Active")); if (active && !m_asyncioInitialized) { - if (!initializeAsyncio()) { - if (m_status_messages.empty() || strcmp(m_warning, m_status_messages.back().c_str()) != 0) { + // m_warning is null unless initializeAsyncio() took the one path that + // does not log its own failure. + if (!initializeAsyncio() && m_warning) { + if (m_status_messages.empty() || m_status_messages.back() != m_warning) { addStatusMessage(m_warning); } }