Skip to content

Fix: timer_manager:send_after/3 returns an uncancellable timer#2358

Open
Qata wants to merge 1 commit into
atomvm:mainfrom
Qata:fix/timer-manager-send-after-cancel
Open

Fix: timer_manager:send_after/3 returns an uncancellable timer#2358
Qata wants to merge 1 commit into
atomvm:mainfrom
Qata:fix/timer-manager-send-after-cancel

Conversation

@Qata

@Qata Qata commented Jul 16, 2026

Copy link
Copy Markdown

What

Found while formalising OTP's timer semantics as a typed algebra for a dependently typed BEAM language: send_after timers can't be cancelled.

timer_manager:send_after/3 returned a fresh make_ref() that was never
registered with the timer manager. As a result:

  • timer_manager:cancel_timer/1 on that reference always returned false, and
  • the scheduled message was delivered regardless of any cancellation attempt.

Because erlang:send_after/3 and timer:send_after/2,3 delegate to
timer_manager:send_after/3, they were affected too — a send_after timer was
structurally impossible to cancel.

Why

send_after/3 spawned an anonymous proxy process to deliver the bare message,
but never inserted {TimerRef, Pid} into the manager's timer table and never
gave the proxy a {cancel, _} clause. cancel_timer/1 looks the ref up in that
table, found nothing, and returned false; the proxy had no way to be told to
stop, so it always fired.

How

send_after/3 now routes through the same gen_server path as start_timer/3,
so its timer is registered and cancellable. A delivery Mode (wrapped |
bare) is threaded through do_start_timer/4 and run_timer/6 to preserve the
one real behavioural difference between the two entry points:

  • start_timer/3 delivers {timeout, TimerRef, Msg} (wrapped)
  • send_after/3 delivers the bare Msg (bare)

The throwaway proxy and send_after_timer/3 are gone; there is now a single
timer lifecycle for both APIs.

Testing

Two regression tests added to tests/libs/eavmlib/test_timer_manager.erl:

  • test_cancel_send_after/0 — a send_after ref is present in
    get_timer_refs/0, cancel_timer/1 returns the remaining time (a positive
    integer), the ref is then gone, and a second cancel returns false.
  • test_cancel_send_after_suppresses_message/0 — after cancelling, the message
    does not arrive within the timeout window.

Both fail on main and pass with this change. Verified red→green on host OTP and
on the AtomVM VM via the rebuilt test_eavmlib.avm. Existing
test_timer_manager cases (start_timer, cancel_timer,
cancel_timer_after_expiry, erlang_cancel_timer, send_after) still pass.

A ### Fixed entry has been added to the [0.7.0-beta.0] - Unreleased section
of CHANGELOG.md.

send_after/3 minted a fresh reference with erlang:make_ref/0 and returned
it, but registered the actual timer under a different reference created by
an intermediate proxy process. As a result the returned reference was never
present in the timer manager's table, so cancel_timer/1 always returned
false and the scheduled message was delivered even after a cancel. The proxy
process also had no cancel path, making send_after timers structurally
uncancellable. This affects erlang:send_after/3 and timer:send_after/2,3,
which delegate to it.

Route send_after/3 through the same gen_server timer path as start_timer/3,
tagging the timer with a delivery mode so it delivers the bare message
instead of a {timeout, Ref, Msg} tuple. The returned reference is now
registered and cancellable, and cancel_timer/1 returns the remaining
milliseconds and suppresses delivery.

Add regression tests covering cancellation of a send_after timer and that a
cancelled send_after timer does not deliver its message.

These changes are made under both the "Apache 2.0" and the "GNU Lesser General
Public License 2.1 or later" license terms (dual license).

SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later

Signed-off-by: Made In Heaven <madeinheaven@madeinheaven.com>
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