Skip to content

Restore 4.10.0 ABI of Sleep/Delay/Timeout nodes; ignore stale timer handlers - #1283

Open
facontidavide wants to merge 3 commits into
masterfrom
fix/abi-timer-layout
Open

facontidavide wants to merge 3 commits into
masterfrom
fix/abi-timer-layout

Conversation

@facontidavide

Copy link
Copy Markdown
Collaborator

Summary

Restores the 4.10.0 ABI of SleepNode, DelayNode and TimeoutNode, keeps the use-after-free fix from #1180, and fixes a remaining stale-timer-handler race.

1. ABI (aec7ab5)

#1180 (9b63b50) fixed a use-after-free on destruction by moving TimerQueue<> timer_ to the last member, so the timer is destroyed, and its worker joined, before the mutex the handler locks. That changed every member offset against 4.10.0 (sizeof stayed the same). These classes have inline constructors and destructors in the headers, while tick()/halt() live in the library, and the soname is unversioned. So a plugin built against 4.10.0 that derives from or directly constructs one of them would crash with a newer library.

  • The member order is now exactly as in 4.10.0 again, and a comment marks it as frozen.
  • New inline TimerQueue::stop() holds the former destructor body (cancel all, finish, notify, join). It is idempotent, and ~TimerQueue() calls it. TimerQueue's layout is unchanged.
  • The node destructors call timer_.stop() first, so every pending or aborted handler finishes while the node's mutexes are still alive. Destruction order now comes from code instead of member order.

A sizeof/offsetof probe, compiled against the 4.10.0 headers and this branch, shows identical layouts for all three nodes and for TimerQueue.

Note: on glibc, std::mutex has a trivial destructor, so #1180's regression test can't catch the bug on Linux. With libc++ and a real ~mutex (as on macOS), the test aborts on every run with "mutex lock failed" when stop() is removed, and passes 5/5 with it.

2. Stale timer handlers (5743c18)

TimerQueue::cancel() neither prevents nor waits for a handler the worker has already popped. That handler then runs with aborted=false and acts on the restarted node:

  • a restarted Sleep returns SUCCESS early;
  • Delay ticks its child with no delay;
  • Timeout halts its new child.

Each handler now captures the id it was created for and, under the node mutex, returns unless that id still equals timer_id_. halt() and the Timeout completion path reset timer_id_ to 0. No header changes. New test Decorator.TimeoutRestartedIgnoresStaleTimerHandler failed 13/13 on the old code. Sleep and Delay are fixed the same way, but can't be tested deterministically without hooks in the headers.

Tests

Full suite passes. TSan ×10 and ASan+UBSan ×5 on the Sleep/Delay/Timeout/Timer tests are clean.

🤖 Generated with Claude Code

facontidavide and others added 3 commits November 7, 2026 18:09
…fix

#1180 fixed a use-after-free of the node mutex on destruction by moving
TimerQueue<> timer_ from the first to the last data member of SleepNode,
DelayNode and TimeoutNode, so that ~TimerQueue() (which joins the worker
thread running the handlers that lock delay_mutex_ / timeout_mutex_) runs
before those mutexes are destroyed. The reorder changed every member offset
vs 4.10.0 while constructors/destructors are inline, breaking the ABI for
plugins built against 4.10.0.

Restore the 4.10.0 member order and keep the fix by joining the worker
explicitly: TimerQueue gets an idempotent stop() (the former destructor
body: cancel all, drain the handlers, join), and the three node destructors
call it before any member is destroyed. ~TimeoutNode still resets
timeout_started_ under timeout_mutex_ (#1241) but releases the lock before
stop(), since the handler takes it.

sizeof and all member offsets of the three nodes and TimerQueue are again
identical to 4.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TimerQueue::cancel() neither prevents nor waits for a handler that the
worker thread already popped: it returns 0 and the handler then runs with
aborted == false. If the node was halted (or its child completed) and
restarted in that window, the stale handler acted on the new run: a
restarted Sleep returned SUCCESS early, a restarted Delay ticked its child
without delay, a restarted Timeout halted its new child.

Each handler now checks that it belongs to the current timer, reusing the
existing timer_id_ member (no layout change). The node mutex is held across
add() and the assignment of timer_id_ (and of the handler's own id), so the
handler, which takes the same mutex before comparing, never sees a half-set
state. halt() and Timeout's completion path reset timer_id_ to 0 under the
mutex. No code waits for a handler while holding its mutex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Nov 7, 2026

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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