Skip to content

[Bug] rt_event_send can race with an in-flight thread timeout and resume a thread twice #11622

Description

@illustriousness

RT-Thread Version

master at 997de9433af42aacb1988489ccb08b6688f440cc.

The affected code is still present in current master:

Relevant configuration:

RT_TICK_PER_SECOND=1000
RT_USING_EVENT=y
single-core Cortex-M
hard thread timers (RT_USING_TIMER_SOFT is disabled)

Hardware Type/Architectures

GD32F303ZKT6, Cortex-M4 / ARMv7E-M, single core.

The same race should apply to other single-core targets where an interrupt that calls rt_event_send() can preempt the system tick handler.

Interrupt priorities in the failing system:

SysTick/PendSV effective priority: 0xF0 (lowest)
USART IRQ priority:                0x00
SCB->SHPR3 at failure:             0xf0f00000

Develop Toolchain

GCC: arm-none-eabi-gcc 14.2.1 20241119

Describe the bug

A thread waits on an event with a finite timeout. When its thread timer expires, _timer_check() removes the timer from the timer list and clears RT_TIMER_FLAG_ACTIVATED, then temporarily restores interrupts before calling _thread_timeout().

A higher-priority ISR can enter this window and call rt_event_send() more than once. The first wakeup sees sched_flag_ttmr_set == 1, but rt_timer_stop() fails because the timer has already been removed/deactivated by _timer_check(). rt_sched_thread_timer_stop() nevertheless clears sched_flag_ttmr_set.

A second event send can then see sched_flag_ttmr_set == 0 and successfully make the same thread ready/running. When execution returns to SysTick, _thread_timeout() is still called for the old timer and fails this assertion:

RT_ASSERT(rt_sched_thread_is_suspended(thread));

Observed backtrace

#0  rt_assert_handler
#1  _thread_timeout        src/thread.c
#2  _timer_check           src/timer.c
#3  rt_timer_check         src/timer.c
#4  rt_tick_increase       src/clock.c
#5  SysTick_Handler

Thread/timer state captured with GDB

thread.name                = "run_mtr"
thread.stat                = 3     (RT_THREAD_RUNNING)
sched_flag_ttmr_set        = 0
thread_timer.parent.flag   = 0x08  (THREAD_TIMER set, ACTIVATED cleared)
thread_timer.timeout_tick  = 735028
current_tick               = 735028
thread.event_set           = 0x02
thread.event_info          = 0x06  (OR | CLEAR)

A context switch from idle to the event waiter had been requested but PendSV had not executed yet:

rt_interrupt_from_thread        = &idle_thread.sp
rt_interrupt_to_thread          = &event_waiter.sp
rt_thread_switch_interrupt_flag = 1

This rules out a stack overflow or a corrupted thread object: the event path had already marked the waiter running while the timeout callback was still in flight.

Race sequence

SysTick: _timer_check()
  remove thread timer from timer list
  clear RT_TIMER_FLAG_ACTIVATED
  restore interrupts
                         |
                         +-- higher-priority ISR
                         |     rt_event_send(event)
                         |       rt_timer_stop() -> -RT_ERROR
                         |       sched_flag_ttmr_set is cleared anyway
                         |       rt_sched_thread_ready() failure is ignored
                         |
                         |     rt_event_send(event) again
                         |       sched_flag_ttmr_set == 0
                         |       thread is moved to READY/RUNNING
                         |
SysTick resumes
  _thread_timeout(thread)
  assert(thread is suspended) fails

Hardware stress reproducer

A minimal application can use one event waiter and a hardware timer IRQ whose priority is higher than SysTick. Use a hardware timer period close to, but not exactly equal to, one OS tick so its phase sweeps through the timeout window.

static struct rt_event race_event;

static void race_waiter(void *parameter)
{
    rt_uint32_t received;

    RT_UNUSED(parameter);

    while (1)
    {
        received = 0;
        (void)rt_event_recv(&race_event,
                            0x01,
                            RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR,
                            1,
                            &received);
    }
}

void HIGH_PRIORITY_TIMER_IRQHandler(void)
{
    rt_interrupt_enter();
    clear_hardware_timer_irq();

    /* Two producers or two notifications are required to expose the
       incorrect clearing of sched_flag_ttmr_set. */
    (void)rt_event_send(&race_event, 0x01);
    (void)rt_event_send(&race_event, 0x01);

    rt_interrupt_leave();
}

Suggested setup:

OS tick:             1000 us
hardware timer:      approximately 1001 us
hardware timer IRQ:  higher priority than SysTick
wait timeout:        1 tick

Deterministic regression test

For an upstream kernel regression test, timing dependence can be removed with a test-only hook immediately after _timer_check() restores interrupts and immediately before t->timeout_func() is called:

rt_spin_unlock_irqrestore(lock, level);

/* Test-only fault-injection hook. */
thread_timer_expiry_window_hook(t);

t->timeout_func(t->parameter);

The hook sends the waited event twice. Before the fix, this deterministically makes the second send resume the thread and _thread_timeout() asserts. After the fix, timeout remains the single owner, the callback completes, and the pending event is consumed by the next rt_event_recv().

Expected behavior

Only one path should win:

  • If event wakeup stops the timer successfully, the event path resumes the thread.
  • If _timer_check() has already taken the timer, all competing wakeups must leave the thread suspended until _thread_timeout() completes.
  • An event must only be cleared and reported as delivered when rt_sched_thread_ready() succeeds.

Proposed fix

I tested the following three coordinated changes locally:

  1. In rt_sched_thread_timer_stop(), clear sched_flag_ttmr_set only when rt_timer_stop() succeeds. A failed stop keeps later wakeups from bypassing the in-flight timeout callback.
  2. In _thread_timeout(), clear sched_flag_ttmr_set after acquiring the scheduler lock and confirming the thread is suspended. The timeout callback now owns completion of the timer.
  3. In rt_event_send(), only clear the event, set thread->error = RT_EOK, and request scheduling when rt_sched_thread_ready() returns RT_EOK.

Core logic of the patch:

--- a/src/scheduler_comm.c
+++ b/src/scheduler_comm.c
@@
     error = rt_timer_stop(&thread->thread_timer);
-    RT_SCHED_CTX(thread).sched_flag_ttmr_set = 0;
+    if (error == RT_EOK)
+        RT_SCHED_CTX(thread).sched_flag_ttmr_set = 0;

--- a/src/thread.c
+++ b/src/thread.c
@@
     RT_ASSERT(rt_sched_thread_is_suspended(thread));
+    RT_SCHED_CTX(thread).sched_flag_ttmr_set = 0;

--- a/src/ipc.c
+++ b/src/ipc.c
@@
-    rt_sched_thread_ready(thread);
-    thread->error = RT_EOK;
-    need_schedule = RT_TRUE;
+    status = rt_sched_thread_ready(thread);
+    if (status == RT_EOK)
+    {
+        /* Consume/clear the event here, after successful resume. */
+        thread->error = RT_EOK;
+        need_schedule = RT_TRUE;
+    }

The complete local firmware builds successfully with this change. Simply removing the assertion or lowering the producer IRQ priority hides the failure but does not fix event consumption or the duplicate-resume race.

Other additional context

The original failure occurred naturally with two independent high-priority UART RX sources that both notify the same event waiter. It occurred about 735 seconds after boot.

No application memory corruption was observed:

  • The thread object type and object-list links were valid.
  • The defunct list was empty.
  • The 1 KiB waiter stack had approximately 668 bytes free.
  • The captured timer and pending PendSV state consistently identify the event/timeout race described above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugThis PR/issue is a bug in the current code.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions