Skip to content

engine: faults accepted into a per-FB inbox and executed only by the FB's owner - #185

Merged
ritzylab-dev merged 9 commits into
devfrom
fix/fault-inbox-ownership
Nov 2, 2026
Merged

ritzylab-dev merged 9 commits into
devfrom
fix/fault-inbox-ownership

Conversation

@ritzylab-dev

@ritzylab-dev ritzylab-dev commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

The bugs (post-merge review of #182 and #184)

A fault reported on a Functional Block before its init_fn ran executed fault_fn on uninitialised resources. A
reporter descheduled between the shutdown checks and its enqueue could post after the final drain, orphaning the fault
until the next dispatch_boot; the 100 ms in-flight wait diagnosed that stall without preserving delivery. Two FBs with
dispatch threads and full HIGH queues reporting on each other from their fault handlers deadlocked on each other's
execution locks.

What breaks, and when

A fault handler dereferencing resources that do not exist yet; a fault silently deferred across a shutdown; two FBs —
and then shutdown — hung for ever under a fault storm.

What changed and why it fixes it

Accepting a fault and executing fault_fn are now separate steps. Every FB has a framework-owned fault inbox, written
and read only inside the OSAL critical section together with the FB's owner state (boot, none, dispatch). A report is
accepted in constant time and never waits for queue capacity or for another FB's lock. Execution belongs to the owner:
the boot thread after init_fn returns, the dispatch thread when one runs, otherwise the reporting thread — which
acquires the execution lock blocking only if it holds no execution lock itself, and by trylock otherwise, leaving the
fault for the current holder to execute on exit. Owner transitions happen inside the critical section and each executor
drains after the transition that made it the executor, so no report is missed; no thread that holds an execution lock
ever waits for another, so no cycle exists. After releasing an execution lock the holder repeats drain pass, release,
check until a check that follows a release finds the inbox empty, so a reason whose trylock was refused during a pass is
never stranded. A thread acts on an owner it read before taking the lock, so every drain pass re-checks the owner under
the critical section, with its budget and before every reason, and executes only in a role that owner allows: a reporter
whose owner read went stale (boot failed, or dispatch_boot handed the FB to its dispatch thread) can win the lock but
executes nothing. The timed in-flight wait and the bus post path for faults are removed.

Also: a failed boot executes no latched fault (one EMBEDIQ_FAULT_BOOT_ABORTED event per FB counts them) and
dispatch_boot does nothing after a failed boot; an FB whose dispatch thread cannot be created keeps having its faults
executed by reporters; the OSAL gains embediq_osal_mutex_try(), whose refusal is an expected outcome and is not
observed as a fault.

Evidence

Seven new engine tests and one OSAL test failing on dev and passing after; the reviewer's three probes rebuilt
(before-init callbacks 1 → 0; delayed reporter executed before report_fault returns, shutdown 100 ms → 0 ms;
cross-faults completed 0 → 2); boot/shutdown cycling under eight reporters with executed == reported − counted
overflow; a six-link self-reporting chain and a report refused during the holder's second pass, both executed before
the reporter returns (before the loop: 2 of 6 and 2 of 3); full suite under ThreadSanitizer: 0 findings.

Limits stated

Beyond EMBEDIQ_FB_FAULT_INBOX_DEPTH pending reasons on one FB, further reports are counted and reported as one
PENDING_OVERFLOW event per drain pass (its 16-bit count clamps at 65535); the FB enters FAULT when its first pending
reason executes. A fault_fn that blocks on the bus (publishing HIGH into a full queue) blocks the thread executing it —
the dispatch thread, or the reporter's thread for an FB without one; inside fault_fn, publish only with
embediq_bus_post_async(), which never waits for capacity (R-sub-33) — the execution-lock argument excludes lock cycles,
not bus-capacity waits. A fault accepted before or during init_fn is executed before the FB is marked RUNNING. A report
accepted in the closing window — after that drain and before the boot thread releases the lock — is executed immediately
after; the trace may then show RUNNING followed by FAULT for that FB. The state never returns to RUNNING.

🤖 Generated with Claude Code

@ritzylab-dev

Copy link
Copy Markdown
Owner Author

Evidence

New tests on dev (d29fb99) — commit cef510a

Test On dev After
fault before init runs after init_fn calls=1 before_init=1 order: init=2 fault=1 — fault_fn ran before init_fn before_init=0 order: init=1 fault=2 running_seen=0 state=FAULT
delayed reporter executed before shutdown returns reporter_held=0 — dev has no acceptance point to hold; the reviewer's probe shows the dev behaviour (shutdown_ms=100, executed only after restart) reporter_held=1 shutdown=0 ms executed_at_shutdown_return=1 after_restart=1
cross faults between dispatch FBs with full queues deadlock — the 2 s alarm ends the binary (exit 14) entered=2 completed=2, both other-reasons executed in 2 ms, shutdown 0
report from fault_fn never blocks on other FB report_on_busy_fb=1999887 us (blocked until the holder's gate timed out) 0 us, executed by the holder thread on release
inbox overflow counted once executed=18 overflow_events=0 executed=5 (1 + depth) overflow_events=1 counted=13
failed boot: latched fault reported, not executed fault_fn_runs=1 boot_aborted_events=0 dispatch_threads_created=1 fault_fn_runs=0 boot_aborted_events=1 counted=1 dispatch_threads_created=0
task-create failure keeps owner none executed_before_return=0 executed_before_return=1 on_calling_thread=1
OSAL: mutex_try refusal is not a fault osal_fault_records=1 (the only non-waiting lock on dev is observed as a timeout) free=1 held=0 osal_fault_records=0

Rewritten existing test: test_report_fault_never_waits_for_queue_capacity now asserts the reporter returns while the FB's handler is still running and the FB's own dispatch thread executes the fault (dev: the reporter waited for the handler). The storm test now asserts executed + counted overflow == reported.

Reviewer probes rebuilt against this branch (the delayed probe's pause moved from the removed post function to the new acceptance point via the host test hook; sequence and output unchanged)

Probe dev this branch
pre-init callbacks=1 fault_callbacks_before_init=1 state=3 callbacks=1 fault_callbacks_before_init=0 state=3
delayed reporter shutdown_ms=100 … after_reporter_return=0, only_after_restart=1 shutdown_ms=0 … after_reporter_return=1, only_after_restart=1 (executed once, before shutdown returned)
cross-fault entered=2 completed=0 entered=2 completed=2

Adversarial probes (normal and ThreadSanitizer builds; 0 TSan reports)

  • 8 reporters × 50 boot/shutdown cycles × continuous publisher: 2,170,473 reports, 121,939 executed + 2,048,534 counted overflow, 0 mismatched cycles, max shutdown 1 ms.
  • Reporter hammering from a platform FB's init_fn on an application-phase FB: 200,000 reports, 0 callbacks before init_fn, executed + overflow == reported.
  • fault_fn reporting on itself 10 times: 5 executed (1 + depth), one overflow event counting 6.
  • Mutual reports from inside run_fn (not fault_fn) between two dispatch FBs with full HIGH queues: both faults executed within 3 ms; shutdown 0 ms.

Verification

  • Full suite 32/32; under ThreadSanitizer 32/32 with 0 report files (suppression file has no rules). ASan clean: test_fb_engine 32/32, test_dispatch_shutdown 6/6, test_fb_watchdog 14/14, test_osal_posix 23/23.
  • No g_fault_posts_inflight, SHUTDOWN_INFLIGHT, message_bus_post_ep, is_framework_fault in core/; no exec_lock, 0 trylocks; no delay_ms/time_ms in fb_engine.c.
  • validator, boundary_checker, check_fb_calls, check_osal_obs --strict, check_hal_obs --strict: clean. embediq_fb.h, embediq_config.h, embediq_osal.h, embediq_bus.h compile standalone.
  • Thermostat, 45 s guard, .iqtrace capture: 339 events, sequence 10–348, 0 inversions, 0 gaps, 0 OSAL_FAULT records.
  • Engine dispatch cost: 115 ns/message over a direct call (was 41 ns; bound 2 µs) — two short critical sections per message for the inbox check.

Notes for the reviewer

  • embediq_bus.h: one comment edited — the framework endpoint's example said MSG_FB_FAULT is posted by report_fault, which is no longer true.
  • Failed boot returns every FB to the latch-only state, not only the FBs after the failure point, so no FB code runs after a failed boot; a retried boot also re-latches before each init_fn.
  • Overflow counter is 32-bit inside the engine; the event's 16-bit msg_id clamps at 65535 (the boot-hammer probe latched 199,996 drops before init, which a 16-bit counter undercounted).
  • Dispatch-thread creation failure is observed by the OSAL (OSAL_FAULT, task source), as for a real pthread_create failure; the engine does not emit a second record.
  • check_osal_obs.py: the marker exempts one function's return false lines. The script's obligation is file-level today (a file with any EMBEDIQ_OSAL_OBS_ macro passes as a whole), so the exemption takes effect only in a file without those macros; verified on a scratch tree (marked function exempt, unmarked function flagged).
  • ARCHITECTURE.md gained one sentence in commit 3 (the one-recheck limit) beside the commit-1 text.

… and refused reports during the recheck (failing)

Two reproductions fail against the single recheck in exec_exit:
- a fault_fn that reports on its own FB once per invocation: one
  lock-free report executes two links of a six-link chain and returns;
- a report refused (trylock) during the holder's recheck pass stays in
  the inbox after the holder's report_fault returns.

A storm of lock-free and lock-holding reporters on an FB without a
dispatch thread checks that every report is executed or counted as
overflow and that the inbox is empty afterwards.
…e boot closing window, count clamp, failed-boot residual and R-sub-33

exec_exit rechecked the inbox once after releasing an FB's execution
lock. A reason pushed during that recheck pass by a thread whose trylock
was refused had no executor until the FB's next report: a fault_fn that
reports on its own FB lost every link after the second, and a report made
from another FB's handler could stay latched indefinitely.

exec_exit now repeats drain pass, release, check until a check that
follows a release finds the inbox empty. A push after that check finds
the lock free and executes itself, or is refused only because a new
holder exists, which runs the same loop.

Contracts now state what the code keeps:
- a report accepted in the boot closing window (after the post-init
  drain, before the boot thread releases the lock) runs immediately after
  RUNNING is published; the state never returns to RUNNING;
- PENDING_OVERFLOW and BOOT_ABORTED counts saturate at 65535, and one
  PENDING_OVERFLOW event is emitted per drain pass that observed drops;
- a fault_fn executing when boot fails runs to completion; later reports
  are latched until the next boot failure, a retried boot, or reset;
- R-sub-33: inside fault_fn, publish only with embediq_bus_post_async().
  The execution-lock argument excludes lock cycles, not bus-capacity
  waits.
@ritzylab-dev

Copy link
Copy Markdown
Owner Author

Review of cfb5530: the release/recheck loop and the prior before-init/delayed-shutdown/cross-fault reproductions pass, but one ownership gap remains. Please hold merge for this P1.

[P1] Revoke stale NONE-owner execution authority when boot aborts

report_fault() snapshots fault_owner at line 378, then may be descheduled before taking the execution lock. boot_abort() can meanwhile reset that FB to BOOT ownership. The old reporter still follows its cached NONE branch, acquires the lock, and calls exec_exit(), whose first action drains without checking the new owner.

Deterministic reproduction using the PR's existing report-delay hook and unchanged libraries:

  1. Initialize a platform-phase target (owner NONE).
  2. Pause a reporter after accepting reason 11 and reading NONE.
  3. A later application-phase dependency cycle makes boot fail; boot_abort() discards reason 11 and sets owner BOOT.
  4. Report reason 22 after boot returns -1. It is correctly latched under BOOT.
  5. Resume the old reporter: it executes reason 22 after the failed boot.
boot_rc=-1 callbacks_before_resuming_reporter=0 callbacks_after=1 executed_reason=22

The same reproduction runs under TSan with zero reports: this is a protocol race. No fault handler was executing when boot failed, so the documented allowance for an already-running handler does not cover it. Reason 22 should stay latched until a valid boot retry (or be counted/discarded according to the failed-boot policy), not invoke FB code in a failed system.

Please coordinate execution permission with current owner/boot generation after acquiring the execution lock, and prevent ordinary reporter/drainer paths from consuming BOOT-owned inboxes. Retain the new no-stranding loop, and add this delayed-NONE-reporter → boot-abort → post-abort-report regression.

Independent checks: 11 relevant ordinary host tests pass; local TSan engine 35/35 and OSAL 23/23 pass with zero reports; five relevant checkers and standalone FB header pass. Linux CI run 41515493654 is green at this exact head, 33/33 in both jobs, zero TSan findings/aborts. Those tests do not cover this ownership transition.

… across a dispatch_boot flip (failing)

A reporter reads the FB's owner when it accepts a reason and acquires the
execution lock afterwards. Two reproductions park a reporter between the
two steps with the report delay hook:
- boot fails meanwhile; a reason reported after the failure is latched
  under BOOT, yet the resumed reporter executes it;
- dispatch_boot makes the dispatch thread the owner meanwhile; a thread
  that took the lock under the old owner executes the reason instead of
  the dispatch thread.
…contract states what it executes; saturating boot count; cost bound only in uninstrumented builds

report_fault reads an FB's owner inside the critical section but takes
the execution lock outside it. A reporter that read owner NONE and was
descheduled could take the lock after a failed boot and execute a reason
latched under BOOT, or execute after dispatch_boot made the dispatch
thread the owner.

A drain pass now runs in a role (dispatch thread, or any other thread)
and checks the owner against it inside the critical section, with its
budget and again before every reason, and before taking the overflow
count. A thread whose owner read went stale can win the lock but drains
nothing; the release-then-check loop wakes the dispatch thread, or leaves
a BOOT-owned reason for the boot thread to execute or count. The loop
retakes the lock only in the reporter role under NONE.

Also:
- dispatch_shutdown's contract states what it executes: the faults of
  each FB whose execution lock it can take; a fault in progress on a
  reporting thread completes there and that thread's loop carries the
  rest; shutdown never waits for an execution lock;
- the BOOT_ABORTED count adds the inbox and overflow counts with
  saturation;
- the dispatch-cost test asserts its wall-clock bound only in builds
  without a sanitizer; every correctness assertion is unchanged.
@ritzylab-dev

Copy link
Copy Markdown
Owner Author

Reply to the review of cfb5530: fixed in bce11d1. The owner is now re-checked under the critical section before every reason: each drain pass runs in a role (the FB's dispatch thread, or any other thread) and executes only while the current owner allows that role, checked together with its budget, before each pop and before taking the overflow count. A reporter whose NONE read went stale can still win the lock but drains nothing. BOOT-owned reports stay latched until the boot thread executes them after init_fn on a retried boot, or a failed boot counts them in EMBEDIQ_FAULT_BOOT_ABORTED. The release/check loop is unchanged, except that it retakes the lock only in the reporter role under NONE; a pending reason under DISPATCH still wakes the dispatch thread. Your abort-probe.c, rebuilt against the fixed libraries, now prints boot_rc=-1 callbacks_before_resuming_reporter=0 callbacks_after=0 executed_reason=0 in both the normal and the TSan build, with 0 TSan reports. The before-init, delayed and cross probes still pass. The scenario is a regression test (test_stale_reporter_cannot_execute_after_failed_boot), alongside a second test for a stale NONE read across dispatch_boot.

@ritzylab-dev

Copy link
Copy Markdown
Owner Author

Verified the follow-up at bce11d1. The P1 in my previous review is resolved; no new blocking findings in this delta. PR #185 is acceptable to merge into dev at this head.

The original failed-boot reproduction, rebuilt against the new normal and TSan libraries, now gives:

boot_rc=-1 callbacks_before_resuming_reporter=0 callbacks_after=0 executed_reason=0

The role check covers taking the initial budget, popping each reason, and taking the overflow count. BOOT permits neither drain role; the boot thread drains only after handing ownership to NONE. This is consistent with the intended protocol, and the added dispatch-owner regression verifies thread affinity across takeover. The overflow-count check appropriately leaves accounting to the valid owner. Retaining only two roles is justified by the actual call sites.

Also independently reran the earlier pre-init, delayed-shutdown, and full-HIGH mutual-fault reproductions: before-init callbacks 0; shutdown 0 ms with the fault executed; both cross-fault reporters complete. Local relevant ordinary suite: 11/11. Local TSan engine: 37/37; OSAL: 23/23; both zero reports. Five relevant checkers and the standalone FB header pass. The sanitizer-specific performance-test adjustment retains correctness assertions and the uninstrumented timing bound.

GitHub run 41526709828 tests this exact head and is successful: 33/33 in both Linux jobs; zero TSan findings and runtime aborts. I did not independently repeat the full local ASan suite or thermostat-capture benchmark. This closes the engine ownership finding, not the separately tracked TLS callback and persistence work. Nothing was merged by the reviewer.

@ritzylab-dev

Copy link
Copy Markdown
Owner Author

Final documentation recheck at cd53066: no new blocking findings; the recommendation to merge #185 into dev stands. Reviewed both cd53066 and the intervening 80d35a9 since the previously verified bce11d1. The cumulative changes affect comments/documentation only, with no executable-code changes. The taken-reason rule matches the critical-section dequeue linearization point, and shutdown wording now correctly describes the release/check loop without promising a fixed total bound under continuing reports. Both build and tsan checks are successful for cd53066 itself (run 41533052844). No tests were repeated locally for this documentation-only delta; nothing was merged by the reviewer.

@ritzylab-dev
ritzylab-dev merged commit 2479515 into dev Nov 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant