fix(actor-typed): discard stale ReceiveTimeout after cancelReceiveTimeout to avoid NPE#3086
Merged
Conversation
Motivation: cancelReceiveTimeout() 将 receiveTimeoutMsg 置 null, 但已通过 scheduleOnce 入队邮箱的 classic ReceiveTimeout 无法撤回; stale 消息以 null 传入 typed 行为栈, 导致 InterceptorImpl.receive 中 msg.getClass 抛出 NullPointerException。 Modification: ActorAdapter.aroundReceive 在转发 receiveTimeoutMsg 前做 null 检查, stale timeout 直接丢弃; 新增 CancelReceiveTimeoutSpec 回归测试。 Result: typed actor 在 cancelReceiveTimeout 与 stale ReceiveTimeout 竞态下 不再崩溃, 行为栈对调用方透明。
pjfanning
reviewed
Jun 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
When
context.cancelReceiveTimeout()is called while a classicReceiveTimeoutmessage has already been enqueued in the mailbox by the scheduler, the typed actor crashes with aNullPointerExceptioninsideInterceptorImpl.receive.Root cause trace:
setReceiveTimeout(d, msg)→ setsreceiveTimeoutMsg = msg, schedulesclassic.ReceiveTimeoutviascheduleOnceReceiveTimeout, another message triggerscancelReceiveTimeout()cancelReceiveTimeout()setsreceiveTimeoutMsg = nulland callsclassicActorContext.setReceiveTimeout(Duration.Undefined)classic.ReceiveTimeoutcannot be retracted from the mailbox (theCancellable.cancel()only prevents future scheduling, not already-delivered messages)ActorAdapter.aroundReceivematchesclassic.ReceiveTimeoutand callshandleMessage(ctx.receiveTimeoutMsg)— with nullBehavior.interpretMessage→InterceptorImpl.receive→msg.getClassthrows NPEThis bug was observed in both 2.8.3 and 2.6.21 of the upstream project. A reproduction is available at https://github.com/lolboxen/akka-receive-timeout-bug.
Modification
ActorAdapter.aroundReceive: null-checkctx.receiveTimeoutMsgbefore forwarding tohandleMessage. If null (timeout was cancelled), the staleclassic.ReceiveTimeoutis silently discarded.CancelReceiveTimeoutSpec: a deterministic regression test that directly sendsclassic.ReceiveTimeoutto a typed actor aftercancelReceiveTimeout()has been called, verifying the actor survives without NPE. The test uses an interceptor in the behavior stack to exercise the exactInterceptorImpl.receivecode path where the NPE manifests.Result
Typed actors no longer crash when
cancelReceiveTimeout()races with an already-enqueued staleReceiveTimeout. The fix is a single null-guard at the adapter layer — the typed behavior stack never sees a null message.Tests
The regression test was verified to fail without the fix (NPE at
InterceptorImpl.scala:94) and pass with the fix.References