Skip to content

Diagnostics: let an object report its condition to the host - #193

Open
jcelerier wants to merge 2 commits into
mainfrom
feature/diagnostics
Open

Diagnostics: let an object report its condition to the host#193
jcelerier wants to merge 2 commits into
mainfrom
feature/diagnostics

Conversation

@jcelerier

Copy link
Copy Markdown
Member

Objects currently have no way to say "I am misconfigured right now".

avnd::logger is a stream of lines aimed at the developer — OFX draws the same
line between its Log and Message types — and a state rendered as a stream
becomes one identical console line per cook. TouchDesigner exposes exactly this
as node state (getErrorString / getWarningString / getInfoPopupString),
stubbed empty in every binding until now, and it feeds the Errors Dialog, the
Error DAT, op.errors() and OP Execute DAT callbacks. Houdini, Nuke, Blender
geometry nodes, OFX persistent messages and Node-RED all model the same thing.

Object-side API

struct MyObject
{
  halp::diagnostics diagnostics;          // the entire opt-in

  void operator()()
  {
    if(!connected)
      diagnostics.warning("no sender on port {}", port);
    diagnostics.info("{} points", count);
  }
};

Diagnostics are scoped to one run and cleared by the binding beforehand,
exactly as TouchDesigner's own addError/addWarning are ("only valid if added
while the operator is cooking") and as Blender and Houdini work. That is what
keeps the API this small — it removes:

  • identifiers to manage — you restate what still holds
  • clear() — not stating a condition is clearing it
  • thread-safety — an object that learns something on a worker thread already
    has to publish that state for its outputs, and the cook reads it

Optional stable ids

For hosts that suppress or localise by id, for structured codes (GStreamer
domains), and for tests:

diagnostics.error<"port_in_use">("port {} is already in use", port);
REQUIRE(obj.diagnostics.has<"port_in_use">());

A template argument rather than a leading string, because
error("id", "fmt {}", x) is ambiguous with error("fmt {}", "x"). Compile-time
because a stable id is by definition not computed at runtime, so only a pointer
to a literal is stored. Users who do not want ids never type <...>.

Properties

  • Fixed capacity throughout: raising a diagnostic never allocates and is usable
    from an audio callback.
  • Text truncates at the capacity; when the entry array is full the lowest
    severity is evicted
    , so the most serious condition is never the one lost,
    and the drop count is reported.
  • Severity vocabulary follows OFX: info / warning / error / fatal.
  • Zero cost for objects that do not declare the member.

Contents

file
avnd/concepts/diagnostics.hpp severity enum + has_diagnostics
halp/diagnostics.hpp basic_diagnostics<MaxEntries, Capacity>
avnd/binding/touchdesigner/diagnostics.hpp shared diagnostics_state, reusable by every TD family
.../pop/particle_processor.hpp POP wiring: clear before cook, render the three channels
examples/Helpers/Diagnostics.hpp example

Tested

Entries and severities, ids attached only where given, has<>, clear(),
eviction under overflow (12 info + 1 error → the error survives), and
truncation of a 1000-char message into a 128-byte buffer. The POP binding
compiles both for an object that opts in and one that does not.

Not done here

  • The other TD families (TOP/CHOP/SOP/DAT) reuse diagnostics_state the same
    way — three lines each.
  • Stream backends should emit on change: Max object_post/_warn/_error(x, …),
    Pd logpost/pd_error(x, …), CLAP clap_host_log, Godot
    push_error/push_warning.

Surveying the field for this also turned up four independent one-line defects in
the existing loggers, which I have deliberately left out of this PR:

  1. TouchDesigner logging does nothinglogger::log_func / error_func are
    nullptr and never assigned anywhere in the tree, so every method
    early-returns.
  2. Pd uses the wrong callerror() routes to ::bug(), which upstream Pd
    reserves for internal Pd faults; the object-facing call is pd_error(x, …).
  3. Max and Pd pass NULL as the object, losing the console's
    click-to-highlight — the main reason object_error takes one.
  4. CLAP discards messages it could deliverclap_host_log exists with a
    full severity enum, but the backend uses halp::no_logger.

Happy to fix those in a separate PR.

🤖 Generated with Claude Code

jcelerier and others added 2 commits August 17, 2026 23:28
Objects had no way to say "I am currently misconfigured". avnd::logger is a
stream of lines for the developer -- OFX draws the same line between its
"Log" and "Message" types -- and a state rendered as a stream becomes one
identical console line per cook.

TouchDesigner exposes exactly this as node state (getErrorString /
getWarningString / getInfoPopupString), stubbed empty in every binding until
now, and it feeds the Errors Dialog, the Error DAT, op.errors() and the OP
Execute DAT callbacks. Houdini, Nuke, Blender geometry nodes, OFX persistent
messages and Node-RED all model the same thing.

The object side is one member:

    struct MyObject
    {
      halp::diagnostics diagnostics;
      void operator()()
      {
        if(!connected)
          diagnostics.warning("no sender on port {}", port);
      }
    };

Diagnostics are scoped to one run and cleared by the binding beforehand, as
TouchDesigner's own addError/addWarning are ("only valid while the operator
is cooking") and as Blender and Houdini work. That removes all the
bookkeeping: no identifiers to manage, no clear() to call, and no
thread-safety burden, since an object that learns something on a worker
thread already has to publish that state for its outputs.

An identifier can be attached for hosts that suppress or localise by id, for
structured codes, and for tests:

    diagnostics.error<"port_in_use">("port {} is already in use", port);
    REQUIRE(obj.diagnostics.has<"port_in_use">());

It is a template argument because a leading string would be ambiguous with
the format string, and compile-time because a stable id is by definition not
computed at runtime -- only a pointer to a literal is stored.

Fixed capacity throughout: raising a diagnostic never allocates and is usable
from an audio callback. Text is truncated at the capacity; when the entry
array is full the lowest severity is evicted, so the most serious condition
is never the one lost, and the drop count is reported.

Verified: entries, ids, has<>, clear, eviction under overflow and truncation
of an oversized message all behave; the POP binding compiles both for an
object that opts in and one that does not.

Still to do: the other TD processor families reuse diagnostics_state the same
way, and the stream backends (max, pd, clap, godot) should emit on change.
Design notes and the survey behind this are in AVENDISH_DIAGNOSTICS_DESIGN.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The TouchDesigner binding appended the "... and N more" notice to the error
channel whichever severity had overflowed, so an object chatty enough to drop
an informational message would turn its node red. The dropped severity is now
tracked and the notice routed accordingly.

Also bound MaxEntries with a static_assert -- count and the loop indices are
uint8_t, so a larger instantiation would have silently misbehaved -- and widen
the dropped counter, which wrapped to zero after 256 drops in one run and
suppressed the notice entirely.

Verified: infos overflowing keep severity info, an error losing its slot
raises it to error, and clear() resets both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant