#17030·streamlit

`heartbeat_ack` is discarded when a script run starts before the queue flushes

Author: codingthatCreated Sep 18, 2026Updated Sep 30, 2026
Labelstype:bugarea:networkstatus:confirmedpriority:P3feature:embeddingarea:backendfeature:runtime

Checklist

  • I have searched the existing issues for similar issues.
  • I added a very descriptive title to this issue.
  • I have provided sufficient information below to help reproduce this issue.

Summary

ForwardMsgQueue.clear(retain_lifecycle_msgs=True) keeps only new_session, script_finished, session_status_changed, parent_message, page_info_changed and toast deltas (forward_msg_queue.py lines 135-146). heartbeat_ack is not in that set, so an ack that is already sitting in the queue is destroyed instead of delivered. AppSession._clear_queue calls this on every SCRIPT_STARTED, and the runtime flushes queues on a short timer, so an ack enqueued in the window between a heartbeat arriving and the next script run starting never reaches the browser. A host that sends one SEND_APP_HEARTBEAT and waits for the ack can wait forever.

Reproducible Code Example

python
from streamlit.proto.ForwardMsg_pb2 import ForwardMsg
from streamlit.runtime.forward_msg_queue import ForwardMsgQueue

queue = ForwardMsgQueue()

ack = ForwardMsg()
ack.heartbeat_ack = True
finished = ForwardMsg()
finished.script_finished = ForwardMsg.FINISHED_SUCCESSFULLY

queue.enqueue(ack)
queue.enqueue(finished)
[m.WhichOneof("type") for m in queue._queue]  # ['heartbeat_ack', 'script_finished']

queue.clear(retain_lifecycle_msgs=True)
[m.WhichOneof("type") for m in queue._queue]  # ['script_finished']  <- ack is gone

Steps To Reproduce

  1. streamlit run (or uv run streamlit run) the snippet.
  2. Observe that the second print no longer contains heartbeat_ack.

End to end: open an app in an iframe host that sends SEND_APP_HEARTBEAT exactly once while a rerun is being triggered (for example, immediately after a widget interaction). The ack for that heartbeat is never received.

Expected Behavior

A heartbeat ack is a one-shot reply to a client request, like a toast is a one-shot notification. It should survive the interrupted-run queue clear, or be sent directly rather than queued, so that one heartbeat always produces one ack.

Current Behavior

The ack is silently removed from the queue when a script run starts before the queue is flushed. Nothing is logged. The client sees no reply.

Is this a regression?

  • Yes, this used to work in a previous version.

Debug info

  • Streamlit version: 1.63.0 and 1.64.0, both reproduced
  • Python version: 3.14.6
  • Operating System: Linux Mint 22.3 Zena 64-bit
  • Browser: Version 152.0.7977.82 (Official Build) for Linux Mint (x86_64) but not browser-specific; reproduced as a pure function of ForwardMsgQueue

Additional Information

The toast case (#7740) was fixed by adding _is_toast_delta(msg) to the retain predicate (forward_msg_queue.py line 146); the same shape would fix this (msg.WhichOneof("type") == "heartbeat_ack"). We hit this from a test harness that uses a heartbeat round trip to prove a WebSocket reconnect completed; sending heartbeats on an interval rather than once is the workaround.