From c26dd4ae194f3c785b3128bcf250f61a76ca1a24 Mon Sep 17 00:00:00 2001 From: Adam Jeniski Date: Tue, 20 Jan 2026 18:07:47 -0500 Subject: [PATCH] Force full terminal frame every 5 seconds for client resync Clients can get stuck in outdated state due to dropped packets or missed WebSocket updates. Now forces a full frame refresh every 5 seconds instead of only sending diffs, ensuring clients resync. Co-Authored-By: Claude Opus 4.5 --- server/src/spiceflow/terminal/diff.clj | 56 ++++++++++++++++++++------ 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/server/src/spiceflow/terminal/diff.clj b/server/src/spiceflow/terminal/diff.clj index 43b6e63..d0765dc 100644 --- a/server/src/spiceflow/terminal/diff.clj +++ b/server/src/spiceflow/terminal/diff.clj @@ -14,9 +14,12 @@ ;; Cache storing terminal state per session ;; Key: session-name (string) -;; Value: {:lines [vector of strings] :hash int :frame-id long} +;; Value: {:lines [vector of strings] :hash int :frame-id long :last-full-ms long} (defonce ^:private terminal-cache (ConcurrentHashMap.)) +;; Interval for forcing full frame refresh (5 seconds) +(def ^:private full-frame-interval-ms 5000) + ;; Global frame counter - auto-incrementing sequence for ordering frames ;; Uses AtomicLong for thread safety. Max value is 2^53-1 (JS MAX_SAFE_INTEGER) ;; to ensure safe handling on the client side @@ -71,6 +74,13 @@ (< (/ change-count max-count) 0.5)) diff))))) +(defn- should-force-full-frame? + "Check if enough time has passed since last full frame to force a new one. + This helps resync clients that may have missed updates." + [cached] + (when-let [last-full-ms (:last-full-ms cached)] + (> (- (System/currentTimeMillis) last-full-ms) full-frame-interval-ms))) + (defn- compute-diff "Compute diff between cached state and new content. @@ -80,11 +90,14 @@ - {:type :full :lines [lines] :total-lines n :frame-id n} - full refresh needed All responses include :frame-id for ordering. Unchanged responses use - the cached frame-id since content hasn't changed." + the cached frame-id since content hasn't changed. + + Forces a full frame every 5 seconds to help resync out-of-sync clients." [cached new-content] (let [new-lines (content->lines new-content) new-count (count new-lines) - new-hash (hash new-content)] + new-hash (hash new-content) + force-full? (should-force-full-frame? cached)] (cond ;; No cached state - full refresh (nil? cached) @@ -93,33 +106,46 @@ :lines new-lines :total-lines new-count :hash new-hash - :frame-id frame-id}) + :frame-id frame-id + :forced false}) ;; Content unchanged (fast path via hash) - (= (:hash cached) new-hash) + ;; But still force full frame periodically for resync + (and (= (:hash cached) new-hash) (not force-full?)) {:type :unchanged :total-lines new-count :hash new-hash :frame-id (:frame-id cached)} ;; Reuse cached frame-id for unchanged - ;; Compute line diff + ;; Force full frame for resync even if unchanged + (and (= (:hash cached) new-hash) force-full?) + (let [frame-id (next-frame-id)] + {:type :full + :lines new-lines + :total-lines new-count + :hash new-hash + :frame-id frame-id + :forced true}) + + ;; Compute line diff (unless forcing full) :else (let [old-lines (:lines cached) - line-diff (compute-line-diff old-lines new-lines) + line-diff (when-not force-full? (compute-line-diff old-lines new-lines)) frame-id (next-frame-id)] - (if line-diff + (if (and line-diff (not force-full?)) ;; Partial diff is efficient {:type :diff :changes line-diff :total-lines new-count :hash new-hash :frame-id frame-id} - ;; Too many changes - send full + ;; Too many changes or forcing full - send full {:type :full :lines new-lines :total-lines new-count :hash new-hash - :frame-id frame-id}))))) + :frame-id frame-id + :forced force-full?}))))) (defn capture-with-diff "Capture terminal content and compute diff from cached state. @@ -135,13 +161,17 @@ [session-name capture-fn] (let [new-content (capture-fn session-name) cached (.get terminal-cache session-name) - diff-result (compute-diff cached new-content)] - ;; Update cache if changed (store frame-id for unchanged responses) + diff-result (compute-diff cached new-content) + is-full? (= :full (:type diff-result)) + now (System/currentTimeMillis)] + ;; Update cache if changed or full frame sent (when (not= :unchanged (:type diff-result)) (.put terminal-cache session-name {:lines (content->lines new-content) :hash (:hash diff-result) - :frame-id (:frame-id diff-result)})) + :frame-id (:frame-id diff-result) + ;; Track when last full frame was sent for periodic resync + :last-full-ms (if is-full? now (or (:last-full-ms cached) now))})) {:content new-content :diff diff-result :changed (not= :unchanged (:type diff-result))}))