Skip to content

v3.2.0

Choose a tag to compare

@github-actions github-actions released this 30 Jun 07:22
· 324 commits to master since this release
f7af651

Added

  • Three per-kind marginal registration benchmark scenarios
    (UntargetedRegistration_Marginal, TargetedRegistration_Marginal,
    BroadcastRegistration_Marginal) that measure the GC-allocation cost of an additional
    same-type registration on an already-warm bus -- the surface the registration
    allocation reductions target -- so the published performance tables now show per-kind
    registration cost (allocation count + bytes), not just dispatch throughput. Each
    registers 1000 more handlers of one warmed message type using distinct, pre-built
    handler delegates, so the measured window captures only the registration machinery
    (never the handler delegate, and never a same-handler refcount bump). They are
    wall-clock (zero-throughput) rows whose allocation columns populate on the
    profiler-bearing in-editor PlayMode/Mono leg and read n/a on the published Standalone
    IL2CPP Release leg (which strips the profiler), exactly like the existing
    registration-flood rows.
  • Benchmarks now report the total allocated BYTES per measurement batch (gcAllocatedBytes)
    alongside the existing managed-allocation CALL count (gcAllocations). Bytes are
    measured from a before/after delta of the live Unity
    ProfilerRecorder(ProfilerCategory.Memory, "GC Allocated In Frame").CurrentValue
    -- a within-frame GC.Alloc-hook byte accumulator that SUMS allocation-hook bytes
    rather than measuring a heap-size difference, so it is exact (verified: 100 x
    byte[10000] reads 1,003,200 bytes, run-to-run identical) and immune to mid-window
    collections (a heavy-churn region that swung a GC.GetTotalMemory delta to -133 MB
    read a stable 8,000,000 bytes here). The byte counter is profiler-dependent
    like the count probe: in the editor and development players it is functional, and on
    the published non-development Standalone IL2CPP Release leg -- where the profiler is
    stripped -- BOTH metrics report the Unmeasured sentinel (rendered n/a), never a
    fabricated 0. Bytes are INFORMATIONAL -- the perf-delta PR comment renders byte
    deltas goodness-signed (N fewer bytes / N more bytes) -- while the regression gate
    stays on the allocation COUNT, which remains the canonical signal (Unity's own
    Performance Testing package likewise reads the alloc-CALL count, not bytes). The
    cross-library comparison tables now also surface this byte metric as a dedicated
    GC-allocated-bytes matrix, and the Unity SendMessage comparison boxes its value
    payload on every dispatch (as any real SendMessage(value) call must) so its
    allocation columns show that real per-call cost instead of a misleading zero. New API:
    AllocationProbe.MeasureWithBytes returning AllocationProbe.AllocationSample,
    AllocationProbe.Window.SampleBytes()/SampleBoth(),
    AllocationProbe.BytesFunctional, BenchmarkMeasurement.GcAllocatedBytes,
    ColdLatencyMeasurement.MedianGcAllocatedBytes, and
    MinimumMeasurement<T>.GcAllocatedBytes.
  • New "What's New in 3.x" documentation page (Getting Started) that summarizes
    the user-visible improvements across the 3.x line -- faster zero-allocation
    dispatch, the base-call analyzer and inspector overlay, project-wide settings,
    memory-reclamation controls, ReregisterOnEnableAfterRelease, the clarified
    ToggleMessageHandler semantics, Unity 6.4+/6.5 compatibility, and the
    dependency-injection helpers -- with links to the authoritative changelog and
    the migration guide.

Fixed

  • The token-creation allocation guard no longer flakes in a warm editor. The
    diagnostics-lazy win (token Create allocating 7 managed objects instead of 11) was
    guarded by an absolute GC.Alloc recorder COUNT budget, but a warm, long-lived editor
    domain attributes background allocations to whatever measurement window is open --
    measured on the host editor, the minimum over 64 windows was ~19 allocations for that
    7-allocation operation (median ~51), well above any budget tight enough to catch the
    revert -- so the guard false-failed run-to-run. A measure-first probe also disproved the
    assumption that the swing came from the DxPools collection pools: the steady refcount
    registration path does not rent from them at all (hits = misses = 0), so the noise is
    pure background-editor GC.Alloc pollution that no pool pre-warm can remove. The guard
    is now a DETERMINISTIC state assertion -- after Create with diagnostics off, the lazy
    _callCounts/_emissionBuffer backing fields must still be null -- which uses no
    allocation probe, never flakes, and runs in the per-PR EditMode correctness leg rather
    than the weekly perf-gated Allocation scope.
  • Two more memory-reclamation/registration allocation guards no longer flake in a warm
    editor. The forced-trim guard measured a GC.Alloc COUNT budget over a 32-trim window
    and the dirty-target reuse guard a per-batch count budget; both false-failed run-to-run
    on the same warm-editor ambient-noise floor that the token-creation guard hit. The
    forced-trim guard is now a DETERMINISTIC assertion on the exact IMessageBus.TrimResult
    eviction counts (the first force-trim reclaims; every subsequent one is an idempotent
    no-op that evicts nothing and leaves the live slot count stable) plus a DxPools
    Misses-flat check (repeated no-op trims rent no fresh pooled collection); the
    dirty-target guard now relies solely on its existing exact pool Hits/Misses assertions
    (renting the warmed collection, never allocating a fresh one). Neither uses an allocation
    probe, so neither flakes. The two deterministic registration STORAGE structural guards
    (the by-value metadata parameter and the staging-function map value type) were also moved
    from the weekly perf-gated Allocation suite into the per-PR EditMode correctness leg, so
    they protect every PR. Internal test/quality change only; no public API or runtime
    behavior change.
  • Benchmark and library-comparison allocation reporting is now honest. The harness
    measured allocations with GC.GetAllocatedBytesForCurrentThread(), which returns
    0 for every allocation under Unity's Boehm GC (verified: a forced 1 MB array
    allocation read back a 0-byte delta), so the published "allocated bytes" column
    was a vacuous 0 for every technology -- hiding real per-operation allocations
    (Unity SendMessage, for example, boxes its value-type payload once per call).
    The metric is now a COUNT of managed allocations from the reliable GC.Alloc
    profiler recorder (new AllocationProbe), renamed gcAllocations, and reports an
    Unmeasured sentinel (rendered n/a) -- never a fabricated 0 -- when no probe
    is available on a backend. The perf-doc/PR-comment renderers, the regression gate,
    and the methodology docs were updated accordingly; the doc's allocation column
    reads n/a until the next CI run repopulates real counts.
  • Source generators ([DxUntargetedMessage], [DxTargetedMessage],
    [DxBroadcastMessage], [DxAutoConstructor]) now work in projects that do not use
    assembly definitions. The generator and analyzer DLLs previously shipped under the
    editor-only Editor/Analyzers/ folder, which Unity scopes to the package's editor
    assembly and assemblies that reference it -- so a consumer's runtime code in
    Assembly-CSharp (or a runtime asmdef referencing only the runtime assembly) never
    received the generator, and [Dx*Message] types failed to implement their generated
    interface with cryptic CS0315/CS0452 errors. The labeled DLLs now ship under
    Runtime/Analyzers/ (governed by the all-platforms runtime assembly), so Unity
    applies the generator to the DxMessaging runtime assembly and every assembly that
    references it, including the predefined Assembly-CSharp. No assembly definition is
    required. Closes GitHub issue #229.
  • Documentation no longer teaches the obsolete IMessageBus.GlobalDiagnosticsMode
    API. The reference, patterns, glossary, and migration guides now use
    IMessageBus.GlobalDiagnosticsTargets with the DiagnosticsTarget flags enum
    (matching the canonical diagnostics guide), and the API reference no longer shows a
    write to the read-only IMessageBus.DiagnosticsMode property. A source-derived
    drift-guard (DocsObsoleteApiReferenceTests) now fails the build if any published
    doc references a member marked [Obsolete] in the runtime, so this cannot regress.

Changed

  • Registration now allocates one fewer managed object per handler in the common case. The
    registration token stored every handle's de-registration in a per-handle holder object,
    but the common case is EXACTLY ONE de-registration per handle, so the single
    de-registration delegate is now stored INLINE in the token's de-registration map (with no
    holder object); a holder is allocated only when a rare second de-registration accumulates
    on the same handle (the re-entrant retarget-recovery replay). The ordering,
    partial-failure, and rollback-baseline semantics are unchanged (pinned by the existing
    PendingDeregistrationStorageTests and the full re-entrancy suite), and a new
    RegistrationStorageStructuralGuardTests.DeregistrationsStoreInlineActionNotPerHandleHolder
    deterministically guards the inline storage against regression. Internal change only; no
    public API or behavior change.
  • BREAKING (v4): the IMessageBus registration contract now returns an opaque,
    zero-allocation handle instead of a deregistration delegate. The 14
    IMessageBus.Register*<T> methods return a new readonly struct
    DxMessaging.Core.MessageBus.MessageBusRegistration (previously System.Action), and a
    new void IMessageBus.Deregister<T>(in MessageBusRegistration registration) where T : IMessage
    undoes a registration (call it with the same T you registered with; for
    RegisterGlobalAcceptAll, any T : IMessage works since the global slot is not
    type-keyed). This removes the per-registration bus-side deregistration closure (the
    prior Action plus its display class) from the handler registration path -- roughly
    two fewer managed allocations per registration -- by packing the deregistration
    snapshot into the returned value handle and re-expressing the bus's deregistration logic
    against it (behaviour and the four reentrancy invariants -- generation guard, identity
    liveness / over-deregistration, token idempotency, no counter underflow -- are unchanged).
    Custom IMessageBus implementers that wrap MessageBus (the
    DelegatingMessageBus-style extension point) just forward the new members; from-scratch
    implementers mint handles via the public MessageBusRegistration(long, object)
    constructor and read them back in their own Deregister<T>.
    The Unity-facing surface is unaffected: MessageRegistrationToken and
    MessageAwareComponent keep their existing public shapes. The MessageHandler.Register*
    facades were narrowed from public to internal (they now return the internal
    HandlerDeregistration, still implicitly an Action) as part of this bus<->handler
    boundary rework -- see the dedicated entry below; only code that called those facades
    directly is affected. The larger per-registration allocation reduction (collapsing the
    token/handler closures onto a single per-handle Registration object) landed on top of this
    contract change -- see the entries below.
  • The published performance report no longer prints columns of n/a. The
    Standalone IL2CPP leg runs in a Release player whose stripped profiler cannot
    measure GC allocations or bytes, so the renderer now OMITS a memory column from a
    per-scope dispatch table when every row is unmeasured (the Standalone table is
    throughput-only), omits a whole cross-library memory matrix when no leg measured
    that metric, and drops the unmeasured allocation/byte segment from each per-PR
    delta cell -- instead of filling them with a wall of n/a. The real allocation
    and byte numbers still publish from the in-editor PlayMode (Mono) leg, and n/a
    now appears only as a genuine per-row or per-library cell (a metric measured for
    the scope in general but missing for that one entry).
  • Registration allocates less. Each registration token's diagnostics-only
    call-count and emission-history collections are now created lazily instead of
    eagerly, so a token whose owner never enables diagnostics (the default) no longer
    pays for them -- token creation drops from 11 to 7 managed allocations. The
    per-registration metadata is now passed by value instead of through a closure
    factory that was invoked immediately, removing one delegate allocation per handler
    registration. Steady-state dispatch stays allocation-free, and diagnostics, the
    inspector overlay, and all lifecycle behavior are unchanged.
  • Registering a handler OR a post-processor with the convenience Action<T> /
    Action<InstanceId, T> overloads now allocates one fewer closure per registration.
    This covers the handler registers (RegisterUntargeted, RegisterTargeted,
    RegisterTargetedWithoutTargeting, the sourced-broadcast register, and
    RegisterBroadcastWithoutSource) and the matching post-processor registers
    (RegisterTargetedPostProcessor, RegisterTargetedWithoutTargetingPostProcessor, the
    GameObject/Component/source broadcast post-processors, and
    RegisterBroadcastWithoutSourcePostProcessor). Each such registration previously built
    two delegates -- a diagnostics-augmented Action wrapper plus a separate by-ref
    FastHandler adapter for the flat-dispatch path -- and now folds diagnostics into a
    single by-ref FastHandler closure that is the dispatch target directly (also removing a
    per-dispatch indirection). The public API is unchanged; the optimization is internal and
    verified by differential allocation guards that pin the Action registration cost to the
    already-optimal FastHandler registration cost. Diagnostics, deduplication, and dispatch
    ordering are preserved.
  • Every registration kind now allocates about two fewer managed objects. The token used to
    wrap each staged registration in a per-registration parameterless Action (a delegate plus
    its display class) whose only job was to re-bundle the handle, the staging function, and the
    de-registration bookkeeping; the token now stores each staging function directly and pairs it
    with its handle in the replay queue, so that wrapper -- and the closure InternalRegister
    needed to build it -- is gone. Measured cold-registration floor (FastHandler, diagnostics off):
    untargeted drops from 14.69 to 12.69 managed allocations per registration (a clean -2.00), with
    the same ~2-allocation reduction across targeted, broadcast, without-targeting/source, and
    post-processor registrations (~12% fewer registration allocations overall). The de-registration
    replay, rollback-on-failure, re-entrancy, and equal-priority registration-order semantics are
    unchanged; the public API is unchanged. Pinned structurally by a deterministic guard that the
    token stores the staging function (not an Action wrapper).
  • Every active registration now allocates about two fewer managed objects again. The token
    tracked each handle's live de-registration in a per-handle holder that eagerly allocated a
    List (plus its backing array) to hold what is almost always a single de-registration; the
    holder now keeps that one de-registration inline and only allocates an overflow list on the
    rare second de-registration for the same handle. Measured cold-registration floor (FastHandler,
    diagnostics off): untargeted drops from 13.29 to 11.29 managed allocations per registration (a
    clean -2.00), with the same ~2-allocation reduction across targeted and broadcast. The
    insertion-order, partial-failure-retryable, and rollback-baseline de-registration semantics are
    unchanged (verified by a differential simulation across every count/failure/start-index
    combination); the public API is unchanged.
  • Each handler/post-processor registration now allocates one fewer managed object by replacing
    its per-registration de-registration closure with a per-handle object. The typed
    handler used to hand back a captured parameterless Action (a delegate plus the display class
    holding the slot, cache key, priority, and generation it closed over) as the thing the token
    would later invoke to tear the registration down; that closure is now a single per-handle
    HandlerDeregistration object whose Deregister() instance method carries the same captured
    state as plain fields (and converts implicitly to Action, so the token's replay/rollback
    machinery is untouched). To let one non-generic object tear down a generic
    HandlerActionCache<TU> without re-introducing a per-handler-delegate-type closure, the erased
    IHandlerActionCache gained three non-generic operations (ContainsEntry, BumpVersion,
    DeregisterEntry). The de-registration order (generation guard, slot-version liveness,
    keyed-vs-scalar split, identity/over-de-registration check, version bump, bus de-register,
    refcount decrement) is a faithful re-expression of the prior closure body, pinned by the 19
    arbitrary-order MixedOrderDeregistrationTests and the full re-entrancy suite. Because these
    MessageHandler.Register* facades now return the internal HandlerDeregistration (still
    implicitly an Action), they were narrowed from public to internal -- this is part of the
    v4 bus<->handler boundary rework and does NOT touch the Unity-facing surface
    (MessageRegistrationToken, MessageAwareComponent); only code calling
    MessageHandler.Register* directly is affected.
  • Every registration kind now allocates roughly half as many managed objects again by
    collapsing the token's per-handle staging closures into one unified per-handle object.
    Each registration previously staged a Func<handle, HandlerDeregistration> (a delegate plus
    its display class capturing the target/source, user handler, and priority) whose nested
    AugmentedHandler local function became a second delegate (the diagnostics-augmented flat
    invoker). Both are now a single per-handle Registration object stored in _registrations:
    its fields hold the captured staging state, and its diagnostics-augmented invoker is an
    instance method bound to the object -- so MessageHandler still receives a FastHandler<T>
    delegate and the hot dispatch path stays delegate-based (no virtual/interface call per
    dispatch). A Register() method runs a kind-switch to call the matching
    MessageHandler.Register* and reproduce the exact prior staging body per kind. Because the
    constrained MessageHandler.Register*<T> calls require T : ITargetedMessage /
    IUntargetedMessage / IBroadcastMessage (which a single Registration<T> where T : IMessage
    cannot satisfy), the object is realized as three constraint-family generic subclasses plus one
    non-generic global-accept-all subclass over a non-generic Registration base -- still a
    unified per-handle object with a kind-switch, not a per-method subclass explosion. Measured
    marginal cost (cold, FastHandler, diagnostics off) drops from ~9.3 to ~4.6 managed allocations
    per registration (about half). The equal-priority registration-order dispatch, idempotent
    double-deregister, partial-failure rollback, generation/slot-version guards, refcount handlers,
    and diagnostics call-counts/emission semantics are all unchanged (pinned by the 19
    MixedOrderDeregistrationTests and the full re-entrancy suite); the public API is unchanged.
    Pinned structurally by a deterministic guard that _registrations stores the unified
    Registration object, not a Func/Action wrapper.
  • Deregistration is now O(1) on the steady path. The v4 bus Deregister<T> had been
    re-resolving the sink from the registration method (a switch plus ScalarSinkForMethod /
    ContextSinkForMethod) and re-walking the whole sink -> type -> [context ->] priority ->
    handler chain on every call -- even though the leaf handler-cache is already captured on the
    registration handle. It now operates on that captured cache DIRECTLY, deferring the
    re-resolution to a cold fallback taken only when the handler is not found there (the rare
    post-sweep or over-deregistration case, which the fallback classifies as a silent no-op vs an
    error). This removes the per-deregistration re-resolution that had regressed the
    deregistration-heavy teardown path, with no change to dispatch, registration allocations, the
    generation/sweep-staleness/over-deregistration guards, or the public API; the throw-safe
    ordering (the handler-cache mutation stays after the throwing IMessageBus.Deregister) is
    unchanged. A stale over-deregistration whose (type, priority) slot was reused by a different
    handler now leaves the live registration completely untouched (new
    StaleOverDeregistrationTests regression coverage). The token's de-registration store also
    records the common first-registration in a single dictionary operation (TryAdd) instead of a
    probe-then-insert pair.
  • The bug-report issue template now offers the package version as a dropdown of
    released versions (with an Other fallback) instead of a free-text field, so
    reports carry an exact, valid version. The list is generated from
    package.json, CHANGELOG.md, and git tags, kept in sync by
    npm run check:issue-template-versions (gated in validate:all), and
    self-heals on the default branch via the Update Issue Template Versions
    workflow. Closes GitHub issue #230.
  • The Roslyn source generator no longer copies its DLLs into the consumer's
    Assets/Plugins/Editor/ folder on editor load; it (with its pinned Roslyn dependency
    DLLs) ships ready-to-use under the package's Runtime/Analyzers/ folder
    (RoslynAnalyzer-labeled, excluded from player builds). Projects upgrading from an
    earlier version have the redundant in-project copy removed automatically during asset
    import, before script compilation, so the package's copy and the old in-project copy
    never both run the generator (which would otherwise duplicate generated members). No
    manual action is required: the cleanup only removes the
    Assets/Plugins/Editor/WallstopStudios.DxMessaging folder when it contains the
    first-party source-generator DLL plus exact known legacy analyzer/dependency DLL names
    the package created, and leaves any foreign DLL, foreign .meta, subfolder, or
    other content untouched. If a mixed or incomplete legacy payload is detected, the
    editor logs one warning with manual cleanup guidance instead of deleting the folder
    silently.

Install

  • Import the attached .unitypackage directly into your Unity project, or
  • add com.wallstop-studios.dxmessaging through the Unity Package Manager, pinning this
    release as com.wallstop-studios.dxmessaging@3.2.0 (scoped npm registry) or the
    matching Git tag.

Each release attaches the npm tarball and the .unitypackage, each with a
.sha256 checksum; verify a download with sha256sum -c <file>.sha256.