Zephyr zbus Listener Reentry: Prevent a Publish Path from Locking Its Own Channel

A zbus listener must not wait for a channel whose release depends on that listener returning. In the same-channel case, a nested locking read or publication can wait on the semaphore already held by its outer publication. Across channels, listener chains can form the same dependency cycle. Draw the nested call graph before changing timeout values.

This guide pins Zephyr 3.7.0 with ZBUS enabled, static enabled listeners, thread-context publishers, priority boost disabled and message-subscriber/runtime-observer features disabled. Assertions use ordinary behavior, with CONFIG_ZBUS_ASSERT_MOCK disabled. No Zephyr runtime or target deadlock test was executed; the tables are source-derived expectations for proposed isolated qualification.

Locate the lock around the callback

The tagged zbus implementation takes the channel semaphore, copies the new message, invokes observer dispatch and releases the semaphore afterward. A listener callback is called directly within that dispatch. Its return therefore lies on the path required to release the outer channel.

This semaphore is not a recursive mutex that recognizes the publishing thread as an owner entitled to take it again. A nested zbus_chan_read, zbus_chan_claim or zbus_chan_pub on the same channel attempts another take. The exact API path matters: a listener-safe access to the already locked message is different from a function that attempts to acquire the channel again.

Same-channel publication holds A while its listener waits for A; a cross-channel chain holds A then B before a B listener waits for A. Deferred work breaks the synchronous call chain only if listeners do not wait for that work.
The cycle is a release dependency. A finite nested timeout can return an error, but it does not make the dependency disappear.

Predict the same-channel result precisely

Assume a valid nested message, no validator rejection and no unrelated code incorrectly giving or resetting the channel semaphore. The 3.7.0 semaphore API defines the underlying take results:

Nested timeout Expected result while outer publication holds the channel Application consequence
K_NO_WAIT -EBUSY Record rejection; do not treat nested publication as completed
Finite timeout -EAGAIN after the semaphore wait expires Listener and outer publication are delayed until that return can execute
K_FOREVER No timeout breaks the self-dependency Listener cannot return to release its own prerequisite

The finite row is not an exact wall-clock return bound. Scheduling can delay execution after expiry. More importantly, the outer publish timeout does not preempt application callback code. A listener can start its own longer wait, loop forever or block on an unrelated dependency despite a short timeout supplied by the original publisher.

Do not mistake the outer return for nested success

In this release, the listener callback returns void. The dispatcher calls it and then continues; it does not automatically receive an error code from a nested publication performed by the listener. A nested no-wait call can fail while the outer publication still returns zero in a listener-only configuration.

Instrument both operations independently. Record outer channel, listener identity, inner operation, inner channel and inner result. A single “publish succeeded” log at the outer call cannot establish that a derived message reached its channel. If derived publication is part of an application transaction, define its result channel explicitly instead of borrowing the outer zbus result.

Extend the graph beyond direct recursion

A single thread can publish A, enter A’s listener, publish B, enter B’s listener and attempt to read or publish A. At that point it holds A and B while waiting for A. Searching only for direct self-calls misses this indirect cycle.

Two publisher threads can create a second pattern: one holds A and requests B, while another holds B and requests A. Include application mutexes, completion waits and queue backpressure in the graph as well. A listener holding A while waiting for a worker that must read A creates a cycle even without a second nested publication.

A consistent channel order can help an intentionally nested design, but every normal, callback and error path must obey it. It cannot permit reacquiring the same nonrecursive channel already on the stack. Treat a graph cycle as a design finding, not a reason to raise the publisher’s priority.

Break the dependency with owned deferred work

The tagged zbus header provides zbus_chan_const_msg for accessing the current message. In the already locked listener context, copy the required fields into independently owned storage, enqueue bounded work without waiting for channel-dependent progress, and return.

The worker may run before the publisher releases the channel, so it still needs normal contention handling. The important change is that the listener does not wait for the worker; it can return and let the outer publication unlock. Never retain a borrowed message pointer as if it were an immutable snapshot. Also define what happens when the deferred queue or pool is full.

A hypothetical derived-state pipeline

Suppose a hypothetical sensor channel A triggers calculation of status B. B’s listener consults A for additional fields. A synchronous A-to-B-to-A chain creates the cycle above. A repair copies the required A inputs into one bounded work record and lets a worker calculate and publish B after the listener returns.

Include the source sequence in that work record so the result identifies the input version it used. Decide whether multiple pending inputs must all be processed or may be coalesced. Those are application requirements; moving work asynchronously does not automatically preserve every update.

Qualification gates

First test the same-channel no-wait rejection and finite-wait error with compact, nonblocking trace records. Verify inner and outer results separately. Then exercise the indirect A-to-B-to-A path and a controlled two-thread opposite-order case using finite bounds. Do not deploy an indefinite-deadlock experiment in production merely to confirm the graph.

Finally, test the deferred repair with a full work queue, rapid publications and a worker delayed by other activity. Acceptance requires no listener dependency cycle, explicit handling of enqueue failure and correct ownership of copied inputs. Keep target latency measurements separate from this source-based lock analysis.

Similar Posts