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 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 18:07:47 -05:00
parent 66f072a5e6
commit c26dd4ae19
+43 -13
View File
@@ -14,9 +14,12 @@
;; Cache storing terminal state per session ;; Cache storing terminal state per session
;; Key: session-name (string) ;; 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.)) (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 ;; Global frame counter - auto-incrementing sequence for ordering frames
;; Uses AtomicLong for thread safety. Max value is 2^53-1 (JS MAX_SAFE_INTEGER) ;; Uses AtomicLong for thread safety. Max value is 2^53-1 (JS MAX_SAFE_INTEGER)
;; to ensure safe handling on the client side ;; to ensure safe handling on the client side
@@ -71,6 +74,13 @@
(< (/ change-count max-count) 0.5)) (< (/ change-count max-count) 0.5))
diff))))) 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 (defn- compute-diff
"Compute diff between cached state and new content. "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 - {:type :full :lines [lines] :total-lines n :frame-id n} - full refresh needed
All responses include :frame-id for ordering. Unchanged responses use 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] [cached new-content]
(let [new-lines (content->lines new-content) (let [new-lines (content->lines new-content)
new-count (count new-lines) new-count (count new-lines)
new-hash (hash new-content)] new-hash (hash new-content)
force-full? (should-force-full-frame? cached)]
(cond (cond
;; No cached state - full refresh ;; No cached state - full refresh
(nil? cached) (nil? cached)
@@ -93,33 +106,46 @@
:lines new-lines :lines new-lines
:total-lines new-count :total-lines new-count
:hash new-hash :hash new-hash
:frame-id frame-id}) :frame-id frame-id
:forced false})
;; Content unchanged (fast path via hash) ;; 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 {:type :unchanged
:total-lines new-count :total-lines new-count
:hash new-hash :hash new-hash
:frame-id (:frame-id cached)} ;; Reuse cached frame-id for unchanged :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 :else
(let [old-lines (:lines cached) (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)] frame-id (next-frame-id)]
(if line-diff (if (and line-diff (not force-full?))
;; Partial diff is efficient ;; Partial diff is efficient
{:type :diff {:type :diff
:changes line-diff :changes line-diff
:total-lines new-count :total-lines new-count
:hash new-hash :hash new-hash
:frame-id frame-id} :frame-id frame-id}
;; Too many changes - send full ;; Too many changes or forcing full - send full
{:type :full {:type :full
:lines new-lines :lines new-lines
:total-lines new-count :total-lines new-count
:hash new-hash :hash new-hash
:frame-id frame-id}))))) :frame-id frame-id
:forced force-full?})))))
(defn capture-with-diff (defn capture-with-diff
"Capture terminal content and compute diff from cached state. "Capture terminal content and compute diff from cached state.
@@ -135,13 +161,17 @@
[session-name capture-fn] [session-name capture-fn]
(let [new-content (capture-fn session-name) (let [new-content (capture-fn session-name)
cached (.get terminal-cache session-name) cached (.get terminal-cache session-name)
diff-result (compute-diff cached new-content)] diff-result (compute-diff cached new-content)
;; Update cache if changed (store frame-id for unchanged responses) is-full? (= :full (:type diff-result))
now (System/currentTimeMillis)]
;; Update cache if changed or full frame sent
(when (not= :unchanged (:type diff-result)) (when (not= :unchanged (:type diff-result))
(.put terminal-cache session-name (.put terminal-cache session-name
{:lines (content->lines new-content) {:lines (content->lines new-content)
:hash (:hash diff-result) :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 {:content new-content
:diff diff-result :diff diff-result
:changed (not= :unchanged (:type diff-result))})) :changed (not= :unchanged (:type diff-result))}))