Jelajahi Sumber

fix: live blocks without reload — hidden terminal tabs release their SSE stream (per-origin HTTP/1.1 cap starvation was the culprit), task pages refresh on focus and log refresh failures instead of swallowing them, README documents the SSE budget; regression test covers daemon-raised block -> pulse -> sealed GET refresh roundtrip

clanker 1 bulan lalu
induk
melakukan
b0ae5dd17d

+ 12 - 0
README.md

@@ -218,6 +218,18 @@ the effective `rules` array of `/start`, project notes with the
 `/api/saddle/note` callback and the payload's `project_notes`/`callback`
 caps, and raw-log polling into the terminal view.
 
+## Realtime channels and the browser's SSE budget
+
+Browsers cap ~6 concurrent connections per origin on HTTP/1.1, and SSE
+streams hold theirs forever: Statum's realtime channel (`/_statum/channel`,
+one per browser via its SharedWorker — one per tab where no SharedWorker
+is available) plus one `/terminal-stream` per open terminal tab. Deep
+tab-stacking on HTTP/1.1 can starve the pulse channel silently (task
+pages then miss live block updates). Mitigations built in: hidden
+terminal tabs close their stream until focused, and task pages refresh
+their state on focus in addition to each pulse. Serving the app over
+HTTP/2 removes the cap entirely.
+
 ## Deployment (USM)
 
 `MANIFEST.usm` + `usm-scripts/` package the app as `bin:clanker-saddle`

+ 22 - 4
src/pages/task_detail.html

@@ -14,11 +14,29 @@
     if (window.statum) {
         (function () {
             'use strict';
-            statum.addStateListener('pulse', function () {
+
+            function refreshTask(source) {
                 var task = statum.read('task');
-                if (task && task.refresh) {
-                    statum.act(task.refresh).catch(function () { });
-                }
+                if (!task || !task.refresh) return;
+                statum.act(task.refresh).catch(function (error) {
+                    // Never silently: a failed refresh means the page is
+                    // drifting from the server (e.g. the realtime channel
+                    // starved by the browser's per-origin SSE cap).
+                    console.warn('[task] live refresh (' + source + ') failed:', error);
+                });
+            }
+
+            statum.addStateListener('pulse', function () {
+                refreshTask('pulse');
+            });
+            // Belt and braces for SSE starvation: browsers cap ~6
+            // connections per origin over HTTP/1.1, and every open
+            // terminal tab holds a /terminal-stream EventSource — when
+            // the budget is exhausted the pulse channel is silently
+            // starved. Refreshing on focus recovers without a manual
+            // reload.
+            document.addEventListener('visibilitychange', function () {
+                if (document.visibilityState === 'visible') refreshTask('focus');
             });
             if (window.initQuoteComments) {
                 window.initQuoteComments({

+ 22 - 0
src/pages/task_terminal.html

@@ -10,6 +10,21 @@
 
                 var connected = false;
 
+                // Free the browser's per-origin SSE budget (HTTP/1.1 caps
+                // ~6 connections): a hidden terminal tab nobody watches
+                // must not starve Statum's realtime channel. Reconnect on
+                // focus — the stream replays and the client dedupes by
+                // seq/id.
+                document.addEventListener('visibilitychange', function () {
+                    if (!activeSource) return;
+                    if (document.visibilityState === 'hidden') {
+                        try { activeSource.close(); } catch (e) { }
+                    } else if (connected) {
+                        try { activeSource.close(); } catch (e) { }
+                        connect(activeSession, activeRaw);
+                    }
+                });
+
                 statum.addStateListener('terminal', function () {
                     var state = statum.read('terminal');
                     if (!connected && state && state.exists && state.session_id > 0) {
@@ -169,10 +184,17 @@
                     };
                 }
 
+                var activeSource = null;
+                var activeSession = null;
+                var activeRaw = false;
+
                 function connect(sessionId, rawMode) {
                     connected = true;
+                    activeSession = sessionId;
+                    activeRaw = rawMode;
                     var log = document.getElementById(rawMode ? 'term-raw' : 'term-log');
                     var source = new EventSource('/terminal-stream/' + sessionId);
+                    activeSource = source;
                     var ansi = rawMode ? makeAnsiRenderer() : null;
                     var lastId = 0;
                     var lastSeq = 0;

TEMPAT SAMPAH
test/__pycache__/web_stack_test.cpython-314.pyc


+ 45 - 0
test/web_stack_test.py

@@ -2445,6 +2445,51 @@ def main():
         check(wait_task_done(auth, race_task, timeout=60) is not None,
               "race forensics task completes")
 
+
+        print("== R11: live blocks arrive without a reload ==")
+        # The browser flow on each pulse: statum.act(task.refresh) — the
+        # sealed GET action must rehydrate the task slot with the new
+        # block. (The channel chain itself — entrypoint subscribe on
+        # load, SSE set events on bump — was verified end-to-end against
+        # a real channel client during the investigation.)
+        fake.state = "working"
+        fake.hold_state = True
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Live block task",
+            "priority": "2",
+        })
+        live_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                         if t["title"] == "Live block task"][0])
+        check(wait_for(lambda: task_state(auth, live_task).get("status") == "working"
+                       or False, timeout=60, what="live task working") is not None,
+              "live-block task reaches working")
+        conn = db()
+        live_session = conn.execute(
+            "select id from sessions where task_id = ?", (live_task,)).fetchone()[0]
+        conn.close()
+        status, _ = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
+                               {"type": "block", "session_id": live_session, "data": {
+                                   "question": "Pick one", "type": "question",
+                                   "options": ["A", "B"]}})
+        check(status == 200, "block raised via the daemon")
+        check(wait_for(lambda: any(b.get("question") == "Pick one"
+                                   for b in task_state(auth, live_task).get("blocks", []))
+                       or False, timeout=30, what="block lands in state") is not None,
+              "the raised block reaches the task state")
+        detail = task_state(auth, live_task)
+        check(detail.get("refresh") is not None,
+              "the sealed GET refresh action is still authored")
+        status, _ = http("GET", detail["refresh"]["uri"], timeout=15)
+        check(status in (200, 401),
+              "the refresh URI resolves as a GET (client roundtrip shape)")
+        act(auth, detail["answer_action"] if "answer_action" in detail
+            else detail["blocks"][0]["answer_action"], {"answer": "A"})
+        fake.hold_state = False
+        fake.state = "idle"
+        check(wait_task_done(auth, live_task, timeout=60) is not None,
+              "live-block task completes after answering")
+
         # The daemon vanishing mid-task: three refused polls → offline,
         # task requeued with a bot comment, UI reflects.
         act(auth, slot_public(auth, "/tasks", "tasks")["create"], {