This is an automated email from the ASF dual-hosted git repository.

He-Pin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git


The following commit(s) were added to refs/heads/main by this push:
     new 005e242489 fix(actor-typed): discard stale ReceiveTimeout after 
cancelReceiveTimeout to avoid NPE (#3086)
005e242489 is described below

commit 005e242489cc5fd46c9938795555224a2afac130
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Fri Jun 19 10:16:59 2026 +0800

    fix(actor-typed): discard stale ReceiveTimeout after cancelReceiveTimeout 
to avoid NPE (#3086)
    
    * fix(actor-typed): cancelReceiveTimeout 后丢弃 stale ReceiveTimeout 避免 NPE
    
    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 竞态下
    不再崩溃, 行为栈对调用方透明。
    
    * style(actor-typed-tests): 修正 CancelReceiveTimeoutSpec 的 license header 
为标准 Apache
---
 .../actor/typed/CancelReceiveTimeoutSpec.scala     | 106 +++++++++++++++++++++
 .../typed/internal/adapter/ActorAdapter.scala      |   6 +-
 2 files changed, 111 insertions(+), 1 deletion(-)

diff --git 
a/actor-typed-tests/src/test/scala/org/apache/pekko/actor/typed/CancelReceiveTimeoutSpec.scala
 
b/actor-typed-tests/src/test/scala/org/apache/pekko/actor/typed/CancelReceiveTimeoutSpec.scala
new file mode 100644
index 0000000000..c342ba9cfd
--- /dev/null
+++ 
b/actor-typed-tests/src/test/scala/org/apache/pekko/actor/typed/CancelReceiveTimeoutSpec.scala
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.actor.typed
+
+import scala.concurrent.duration._
+import scala.reflect.ClassTag
+
+import org.apache.pekko
+import pekko.actor.testkit.typed.scaladsl.LogCapturing
+import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
+import pekko.actor.testkit.typed.scaladsl.TestProbe
+import pekko.actor.typed.scaladsl.Behaviors
+import pekko.actor.typed.scaladsl.adapter._
+
+import org.scalatest.wordspec.AnyWordSpecLike
+
+class CancelReceiveTimeoutSpec extends ScalaTestWithActorTestKit with 
AnyWordSpecLike with LogCapturing {
+
+  sealed trait Command
+  case object SetAndCancelTimeout extends Command
+  case object Timeout extends Command
+  case object Ping extends Command
+  case object Pong extends Event
+
+  sealed trait Event
+  case object Done extends Event
+
+  // A no-op interceptor that deepens the behavior stack so the message
+  // passes through InterceptorImpl.receive — the exact code path where
+  // msg.getClass throws NPE on a null message.
+  private def noopInterceptor[T: ClassTag] = new BehaviorInterceptor[T, T] {
+    override def aroundReceive(
+        ctx: TypedActorContext[T],
+        msg: T,
+        target: BehaviorInterceptor.ReceiveTarget[T]): Behavior[T] =
+      target(ctx, msg)
+  }
+
+  "A typed actor with receive timeout" must {
+
+    // Regression test for #3084: cancelReceiveTimeout() sets 
receiveTimeoutMsg to
+    // null, but a classic ReceiveTimeout already enqueued in the mailbox 
cannot be
+    // retracted. Before the fix the stale ReceiveTimeout was forwarded to the 
typed
+    // behavior stack as a null message, causing NPE in 
InterceptorImpl.receive.
+    "silently discard a stale ReceiveTimeout after cancelReceiveTimeout" in {
+      val probe = TestProbe[Event]()
+
+      def behavior: Behavior[Command] =
+        Behaviors.setup { context =>
+          // Wrap in an interceptor so the message traverses 
InterceptorImpl.receive,
+          // the exact location where msg.getClass throws NPE on a null 
message.
+          Behaviors.intercept[Command, Command](() => 
noopInterceptor[Command]) {
+            Behaviors.receiveMessage {
+              case SetAndCancelTimeout =>
+                // Set a very long timeout (won't actually fire), then 
immediately
+                // cancel it. This leaves receiveTimeoutMsg as null.
+                context.setReceiveTimeout(1.hour, Timeout)
+                context.cancelReceiveTimeout()
+                probe.ref ! Done
+                Behaviors.same
+
+              case Timeout =>
+                // Should never reach here: the timeout was cancelled and we 
send
+                // the classic ReceiveTimeout ourselves to simulate the stale 
case.
+                Behaviors.unhandled
+
+              case Ping =>
+                probe.ref ! Pong
+                Behaviors.same
+            }
+          }
+        }
+
+      val ref = spawn(behavior)
+
+      // Step 1: actor sets and cancels the receive timeout → 
receiveTimeoutMsg is now null
+      ref ! SetAndCancelTimeout
+      probe.expectMessage(Done)
+
+      // Step 2: simulate a stale classic ReceiveTimeout arriving in the 
mailbox.
+      // This is what happens when the scheduler fires ReceiveTimeout before
+      // cancelReceiveTimeout() is processed but the actor dequeues them in the
+      // opposite order.
+      ref.toClassic ! pekko.actor.ReceiveTimeout
+
+      // Step 3: verify the actor is still alive and responsive (no NPE crash).
+      ref ! Ping
+      probe.expectMessage(Pong)
+    }
+  }
+}
diff --git 
a/actor-typed/src/main/scala/org/apache/pekko/actor/typed/internal/adapter/ActorAdapter.scala
 
b/actor-typed/src/main/scala/org/apache/pekko/actor/typed/internal/adapter/ActorAdapter.scala
index 5b7b131b6c..7bee9f44f1 100644
--- 
a/actor-typed/src/main/scala/org/apache/pekko/actor/typed/internal/adapter/ActorAdapter.scala
+++ 
b/actor-typed/src/main/scala/org/apache/pekko/actor/typed/internal/adapter/ActorAdapter.scala
@@ -100,7 +100,11 @@ import pekko.util.OptionVal
             } else Terminated(ActorRefAdapter(ref))
           handleSignal(msg)
         case classic.ReceiveTimeout =>
-          handleMessage(ctx.receiveTimeoutMsg)
+          // cancelReceiveTimeout() sets receiveTimeoutMsg to null, but a 
classic ReceiveTimeout
+          // that was already enqueued in the mailbox before the cancel cannot 
be retracted.
+          // Discard the stale timeout to avoid passing null into the typed 
behavior stack (#3084).
+          val timeoutMsg = ctx.receiveTimeoutMsg
+          if (timeoutMsg != null) handleMessage(timeoutMsg)
         case wrapped: AdaptMessage[Any, T] @unchecked =>
           withSafelyAdapted(() => wrapped.adapt()) {
             case AdaptWithRegisteredMessageAdapter(msg) =>


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to