BareGit

Use long polling for spectator state updates

Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Sep 22 14:54:25 2026 -0700
Commit: 79f836011e557fe4e86b975a819d13a5a7ff3687

Changes

diff --git a/include/game_session.hpp b/include/game_session.hpp
index 3903aa6..062b163 100644
--- a/include/game_session.hpp
+++ b/include/game_session.hpp
@@ -1,6 +1,7 @@
 #pragma once
 
 #include <atomic>
+#include <chrono>
 #include <cstdint>
 #include <filesystem>
 #include <mutex>
@@ -64,6 +65,10 @@ public:
     /// Return the latest state for the spectator server.
     Json snapshot() const;
 
+    /// Wait for a viewer snapshot newer than the given revision.
+    Json waitForSnapshot(std::uint64_t revision,
+                         std::chrono::milliseconds timeout) const;
+
 private:
     ToolResult sendInput(const Json& arguments, Json response,
                          const std::string& expected_kind);
diff --git a/src/game_http_server.cpp b/src/game_http_server.cpp
index c28550d..c542296 100644
--- a/src/game_http_server.cpp
+++ b/src/game_http_server.cpp
@@ -4,6 +4,7 @@
 #include "game_session.hpp"
 #include "mcp_server.hpp"
 
+#include <chrono>
 #include <string>
 #include <string_view>
 
@@ -256,11 +257,20 @@ void GameHttpServer::serveState(const Request& request, Response& response)
         response.status = 403;
         return;
     }
-    const Json state = session_.snapshot();
-    const std::string etag = "\"" +
+    Json state = session_.snapshot();
+    const std::string requested_etag = request.get_header_value(
+        "If-None-Match");
+    std::string etag = "\"" +
         std::to_string(state.value("revision", 0ULL)) + "\"";
+    if(requested_etag == etag)
+    {
+        state = session_.waitForSnapshot(
+            state.value("revision", 0ULL), std::chrono::seconds(15));
+        etag = "\"" +
+            std::to_string(state.value("revision", 0ULL)) + "\"";
+    }
     response.set_header("ETag", etag);
-    if(request.get_header_value("If-None-Match") == etag)
+    if(requested_etag == etag)
     {
         response.status = 304;
         return;
diff --git a/src/game_session.cpp b/src/game_session.cpp
index 9d5301a..0e95375 100644
--- a/src/game_session.cpp
+++ b/src/game_session.cpp
@@ -536,6 +536,12 @@ Json GameSession::snapshot() const
     return observations_.snapshot();
 }
 
+Json GameSession::waitForSnapshot(
+    std::uint64_t revision, std::chrono::milliseconds timeout) const
+{
+    return observations_.waitForRevision(revision, timeout);
+}
+
 ToolResult GameSession::sendInput(const Json& arguments, Json response,
                                   const std::string& expected_kind)
 {
diff --git a/web/viewer.js b/web/viewer.js
index 7aed8c1..190ac0d 100644
--- a/web/viewer.js
+++ b/web/viewer.js
@@ -114,23 +114,28 @@
     }
 
     async function poll() {
+        let retry_delay = 0;
         try
         {
             const headers = etag ? {"If-None-Match": etag} : {};
             const response = await fetch("/api/state", {
                 headers, cache: "no-store",
             });
-            if(response.status === 304) return;
-            if(!response.ok) throw new Error(`HTTP ${response.status}`);
-            etag = response.headers.get("ETag") || "";
-            show(await response.json());
+            if(response.status !== 304)
+            {
+                if(!response.ok) throw new Error(`HTTP ${response.status}`);
+                etag = response.headers.get("ETag") || "";
+                show(await response.json());
+            }
         }
         catch(error)
         {
             lifecycle.textContent = `Disconnected: ${error.message}`;
+            etag = "";
+            retry_delay = 1000;
         }
+        setTimeout(poll, retry_delay);
     }
 
     poll();
-    setInterval(poll, 250);
 })();