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.
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:
APluginNameparameter onSubscribe+ bulk-Unsubscribe key.- Wildcard
'*'event type. OnBroadcastexternal-listener tap.- Snapshot-iterate-outside-lock (safe re/un-subscribe + recursive
Firefrom inside callbacks). - Per-handler exception isolation via
try/exceptinside the iteration. TCriticalSectionsynchronisation (notTMonitor/ 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 callbacks —
Session.OnFileBegin := @MyHandleron a fpc-binkp session. Type-safe, no JSON marshalling, the publisher knows its consumers. Used by fpc-binkp'sbp.events, fpc-comet'scm.events, fpc-cron'scron.events. -
fpc-events — a single
TEventBusinstance receivesFire(...)calls from many publishers; manySubscribe(...)d consumers (web-admin WebSocket, audit log, monitoring) listen without each publisher knowing they exist. Cost:ADatais 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, copyFSubscriptionsto 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 innertry/except. - If
Loggeris 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.