Files
kenjreno 128ddcb4df v0.1.0: thread-safe pub/sub event bus
Verbatim port of Fastway-Server's TFWEventBus from fw_plugin_host.pas
per feedback_copy_dont_reinterpret.md.  Adjustments limited to:

  - Type renames (TFW* -> T*).
  - uses clause: drop fw_log; add log.types from fpc-log so the
    optional Logger property uses the canonical ecosystem-wide
    TLogProc shape, matching every other fpc-* library.
  - Per-handler exception logging now calls Logger with
    Level=llError, Category='events', and includes the source
    plugin (ASourcePlugin parameter) in the message text so the
    canonical signature stays meaningful.

Behaviours preserved verbatim: APluginName bulk-Unsubscribe key,
wildcard '*' subscriber, OnBroadcast external-listener tap,
snapshot-iterate-outside-lock pattern, per-handler exception
isolation, TCriticalSection.

docs/DEVELOPER_GUIDE.md added covering threading, payload
ownership, recursive Fire, OnBroadcast, logger plumbing, and
the relationship between fpc-events (ecosystem-wide pub/sub)
and per-library typed observer callbacks (bp.events / cm.events
pattern).

Tests: 44 assertions across 14 scenarios pass on x86_64-linux.
Pre-tag -vh audit on src/ev.bus.pas reports zero hints/warnings.
2026-05-05 18:13:10 -07:00

6.3 KiB

fpc-events — architecture

Layers

        ┌──────────────────────────────────────────────────┐
        │  Caller (BBS, plugin, daemon, web-admin, …)      │
        └──────────────────────────────────────────────────┘
                              │
                              ▼
        ┌──────────────────────────────────────────────────┐
        │  events.bus    (TEventBus class)                     │
        ├──────────────────────────────────────────────────┤
        │  events.version  (semver constants)                  │
        ├──────────────────────────────────────────────────┤
        │  log.types   (logger callback type only,         │
        │               from sibling fpc-log)              │
        ├──────────────────────────────────────────────────┤
        │  RTL: Classes (TCriticalSection), fpjson         │
        └──────────────────────────────────────────────────┘

Two source units, no platform-specific code. No backends, no formats, no I/O — TEventBus is a pure in-memory pub/sub registry.

Origin

Verbatim port of TFWEventBus from Fastway-Server's fw_plugin_host.pas (lines 188-203 + 1626-1753). Preserved behaviours:

  • APluginName parameter on Subscribe + bulk-Unsubscribe key.
  • Wildcard '*' event type.
  • OnBroadcast external-listener tap.
  • Snapshot-iterate-outside-lock (safe re/un-subscribe + recursive Fire from inside callbacks).
  • Per-handler exception isolation via try/except inside the iteration.
  • TCriticalSection synchronisation (not TMonitor / RW lock).

The only structural change: fw_log.Log.Error(...) calls in the canonical handler-error path were replaced with the optional Logger: log.types.TLogProc callback to drop the Fastway-only fw_log dependency.

Where it fits in the fpc-* family

        ┌────────────────────────┐  ┌────────────────────────┐
        │  fpc-events            │  │  fpc-binkp / fpc-comet │
        │  ecosystem-wide        │  │  / fpc-emsi            │
        │  pub/sub               │  │  per-library typed     │
        │  (one bus, N pubs,     │  │  observer callbacks    │
        │   M subs)              │  │  (one publisher, N     │
        │                        │  │   consumers)           │
        └────────────────────────┘  └────────────────────────┘
                  ▲                            ▲
                  │                            │
                  └────────── pick one ────────┘
                          (or use both — they're orthogonal)

These are different patterns for different problems:

  • Per-library typed callbacksSession.OnFileBegin := @MyHandler on a fpc-binkp session. Type-safe, no JSON marshalling, the publisher knows its consumers. Used by fpc-binkp's bp.events, fpc-comet's cm.events, fpc-cron's cron.events.

  • fpc-events — a single TEventBus instance receives Fire(...) calls from many publishers; many Subscribe(...)d consumers (web-admin WebSocket, audit log, monitoring) listen without each publisher knowing they exist. Cost: AData is JSON-bagged (no static typing); event names are strings.

A common hybrid: a library exposes typed callbacks AND a small bridge class (consumer-written) that forwards selected events to a TEventBus. fpc-events doesn't impose either pattern — it's the substrate when the second pattern is what you need.

Threading model

Single TCriticalSection (FLock) protects FSubscriptions.

  • Subscribe, Unsubscribe, UnsubscribeCallback, GetSubscriptionCount — acquire lock, mutate or read, release.
  • Fire — acquire lock, copy FSubscriptions to a local array, release lock, then iterate the copy outside the lock.

The snapshot-iterate pattern is load-bearing: a callback that calls Subscribe / Unsubscribe / Fire recursively does so without contending for the lock, and its modifications don't affect the in-flight delivery (the snapshot was taken before the mutation).

The bus does no thread marshalling. Subscribers run on whichever thread called Fire. Consumers wanting cross-thread dispatch (UI updates, message-queue posting) do that themselves inside the handler.

Memory ownership

The AData: TJSONObject parameter on Fire is owned by the caller. Subscribers may read fields and copy values out, but must not call AData.Free.

Standard publisher pattern:

Data := TJSONObject.Create;
try
  Data.Add('user_id', 42);
  Bus.Fire('auth', 'user.login', Data);
finally
  Data.Free;
end;

If a subscriber needs to retain the data beyond the Fire call, it must clone:

FRetained := TJSONObject(AData.Clone);

Logger plumbing

Bus.Logger: TLogProc (from log.types) handles the exception-isolation log path. When a subscriber callback or the OnBroadcast tap raises:

  • The exception is caught by Fire's inner try/except.
  • If Logger is assigned, it's called with (llError, 'events', formatted-message).
  • The exception is then swallowed; delivery continues to the next subscriber.

If Logger is nil (the default), exceptions are silently swallowed. This matches canonical Fastway behaviour for callers without an fw_log.Log configured.

TLogProc is defined in fpc-log's log.types — the same shape fpc-binkp, fpc-comet, fpc-emsi, fpc-cron use. One log sink wires to all of them.