The Looper/Handler Pattern: Message-Passing at the Core of Every Haiku App
How Haiku BLoopers own dispatch contexts, BHandlers receive typed messages, BMessengers cross threads, and locking preserves state.
Haiku’s Application Kit organizes event processing around BLooper, BHandler, BMessage, and BMessenger. A looper provides a serialized dispatch context, handlers implement behavior within it, messages carry typed named fields, and messengers address targets across threads or teams. BApplication and BWindow are important looper subclasses, which is why a native application naturally has more than one message-processing thread.
The pattern reduces direct cross-thread calls but does not eliminate concurrency. Two loopers can execute simultaneously, synchronous sends can deadlock, a handler can block its whole dispatch queue, and shared model state still needs ownership. The architecture works when messages cross boundaries and each looper keeps its own operations short.
BMessage is a typed envelope
A BMessage has a what code and zero or more named fields. Fields retain type and can hold repeated values under one name. Standard system messages define their required content, while applications can define private or public protocols with their own codes.
Receivers call FindString(), FindInt32(), FindRef(), or other type-specific methods and check the returned status. The presence of a familiar field name does not prove its type or validity. Messages can cross process boundaries, so lengths, indices, paths, and flattened data are untrusted.
Use message fields as value transfer, not a hiding place for raw pointers whose owner may disappear. A pointer can be safe in a tightly controlled same-team synchronous protocol, but it becomes invalid across teams and fragile across asynchronous lifetime. Stable IDs, entry_ref values, strings, or flattened documented objects make stronger contracts.
Copying and posting a message gives the receiver its own queued representation. That separates sender stack lifetime from later dispatch. If a field refers to external shared memory, however, the message copy does not clone that external data; the protocol still needs ownership and lifetime.
BHandler defines behavior inside a looper
BHandler is the base message-receiving object. A subclass overrides MessageReceived(BMessage*), handles the codes it owns, and delegates unknown messages to the base class. Haiku’s Messaging Foundations warns that skipping the base implementation can break handler chains or internal system behavior.
A handler is associated with a looper through AddHandler() or through framework-managed attachment, as with views in a window. Handlers can form a next-handler chain, and a looper can identify a preferred handler. Routing is therefore explicit; every handler does not inspect every message.
Association defines thread context. MessageReceived() runs on the looper’s dispatch thread unless code invokes the method directly—which should not be used to imitate queued delivery. UI handlers can safely mutate their window-owned state because dispatch is serialized, provided outside threads do not bypass the same ownership rule.
Removing a handler requires lifecycle coordination. No messenger should continue targeting it, queued messages must be considered, and the owning looper must be locked when its handler list is changed from another thread. Deleting a handler while a message can dispatch to it is a use-after-free.
BLooper supplies queue and dispatch
BLooper inherits from BHandler and owns a message queue plus a set of handlers. Calling Run() starts its event loop according to the class contract; BApplication and BWindow arrange their loopers through their normal lifecycle. Incoming messages are dequeued and dispatched synchronously one at a time within that looper.
Serialization is local, not global. While one window’s handler processes a message, another window or application looper can run on another CPU. This supports responsiveness between independent windows but exposes races when they share an ordinary C++ object.
PostMessage() is asynchronous: it queues work and returns before handling. Queue order gives useful sequencing for messages delivered through the same path, but do not assume total order across different senders, loopers, or direct shared-memory writes. Include sequence/generation values when stale updates must be rejected.
Looper message intake ultimately uses a bounded kernel port before messages move into the userland queue. Flooding a target can create backpressure or memory growth. Coalesce progress and state-change messages, and never post one message per byte or pixel.
BMessenger addresses local and remote targets
BMessenger encapsulates enough information to send to a handler in the same team or another application. It can target an application by signature and can send requests that expect replies. Call IsValid() and handle target death or timeout as normal outcomes.
Asynchronous delivery is the safe default for notifications. Synchronous SendMessage() variants block until the target handles the request and replies or a timeout/error occurs. They are useful for bounded queries, but dangerous when the receiver may synchronously call back into the blocked sender.
A classic deadlock is looper A holding its lock while synchronously messaging looper B, whose handler needs A’s lock before replying. Avoid holding locks across remote/synchronous calls. Prefer asynchronous request IDs and reply messages, or impose a documented acyclic call direction.
Replies need a schema and exactly one completion path. Timeouts do not guarantee that the receiver abandoned the operation; it may still execute later. Mutating requests should carry an idempotency key or status query when retry could duplicate work.
Locking protects looper-owned objects
BLooper::Lock(), LockWithTimeout(), and Unlock() protect access from outside the looper thread. Window and view APIs commonly require the window lock when called by a worker. The lock serializes access; it does not make a long operation appropriate on the UI object.
Acquire the lock only to read or apply a small state change, then release it before file I/O, networking, waits, or callbacks into unknown code. A worker that holds a window lock during decoding can block drawing and input even though decoding happens on another thread.
Message passing often removes the need to lock. A worker can post an immutable result to the window, and the window handler applies it on its own thread. This makes ownership obvious and prevents the worker from touching controls after the window closes.
If shared state truly spans loopers, define one owner or a dedicated lock and acquisition order. Do not alternate casually between direct locked access and message updates; that creates two synchronization systems with unclear ordering.
Message filters, runners, and invokers
BMessageFilter can inspect, redirect, skip, or dispatch messages according to the filter API. Common filters apply to a looper and handler filters apply more narrowly. Filters execute in dispatch-sensitive paths, so they must remain bounded and must not become hidden business logic for every event.
BMessageRunner schedules messages at intervals to a messenger target. It is appropriate for timers and periodic UI updates without a polling thread. Timer messages can be delayed behind earlier handler work, so it is not a hard real-time clock; handlers should compare actual time when precision matters.
BInvoker binds a message and target; Interface Kit controls inherit or use this pattern to send command messages when invoked. The control need not know the receiver class. The message contract becomes the separation between presentation and application action.
These helpers reinforce the design: behavior is addressed and queued rather than coupled through direct calls. They still use the same finite looper capacity and need cancellation/lifetime rules.
Keeping a looper responsive
Treat MessageReceived() as an event boundary. Validate input, update small state, schedule necessary work, and return. File enumeration, DNS, downloads, media decoding, compression, and large queries belong on workers or asynchronous APIs.
Workers send progress sparingly and completion exactly once. Include an operation ID so the handler can ignore results from a canceled or superseded task. On window close, signal cancellation and invalidate the target; the worker must tolerate a failed post rather than dereference UI state.
Avoid nested event loops as a way to “stay responsive” during synchronous work. Reentrancy lets unrelated messages observe half-finished state. Model the operation explicitly with start, progress, completion, cancellation, and error messages.
Measure queue delay as well as handler duration. A fast handler can still receive stale input after an earlier slow message. Logging what, operation ID, enqueue/dispatch time, and thread identifies whether latency came from production, queueing, or execution.
A reliable protocol checklist
Document each what code, fields, types, ownership, target, reply, timeout, and version. Validate missing and extra data safely. Use status codes rather than relying only on log text. Fuzz malformed messages within resource limits.
Test two loopers concurrently, target shutdown, queue pressure, canceled workers, synchronous timeout, and reordered progress/completion. Use Thread/Team inspection to confirm handlers run on intended threads. A responsive single-window demo is not proof of a correct multi-window protocol.
The Looper/Handler pattern has endured because it makes concurrency boundaries concrete. A looper owns serialized execution, handlers own responsibilities, and messages cross boundaries as data. It succeeds not by making threads disappear, but by giving Haiku applications a disciplined alternative to uncontrolled shared-object calls.
Related:
- Haiku’s Kit-Based API: Application, Interface, Storage, and Media Kits
- The Haiku Kernel: A Modular, Pervasively Multithreaded Design
Sources: