Reduce idle screenshot churn with change-driven SSE
Emit dashboard activity updates only when terminal screen content actually changes, and tighten screenshot 304 fast-path behavior to avoid stale not-modified responses after newer activity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -50,6 +50,7 @@ type Tracker struct {
|
|||||||
screen *te.DiffScreen
|
screen *te.DiffScreen
|
||||||
stream *te.ByteStream
|
stream *te.ByteStream
|
||||||
changeCounter uint64
|
changeCounter uint64
|
||||||
|
lastActivityCounter uint64
|
||||||
lastSnapshotCounter uint64
|
lastSnapshotCounter uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +126,16 @@ func (t *Tracker) Snapshot() Snapshot {
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Tracker) ConsumeActivityChanged() bool {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if t.changeCounter > t.lastActivityCounter {
|
||||||
|
t.lastActivityCounter = t.changeCounter
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func colorToString(color te.Color) string {
|
func colorToString(color te.Color) string {
|
||||||
if color.Name != "" {
|
if color.Name != "" {
|
||||||
name := strings.ToLower(strings.TrimPrefix(color.Name, "#"))
|
name := strings.ToLower(strings.TrimPrefix(color.Name, "#"))
|
||||||
|
|||||||
+54
-6
@@ -190,16 +190,18 @@ type localClientConnector struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *localClientConnector) OnData(data []byte) {
|
func (c *localClientConnector) OnData(data []byte) {
|
||||||
c.server.markRouteActivity(c.routeKey)
|
|
||||||
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, data)
|
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *localClientConnector) OnBinary(payload []byte) {
|
func (c *localClientConnector) OnBinary(payload []byte) {
|
||||||
c.server.markRouteActivity(c.routeKey)
|
|
||||||
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, payload)
|
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *localClientConnector) OnMeta(_ map[string]any) {}
|
func (c *localClientConnector) OnMeta(meta map[string]any) {
|
||||||
|
if changed, ok := meta["screen_changed"].(bool); ok && changed {
|
||||||
|
c.server.markRouteActivity(c.routeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *localClientConnector) OnClose() {
|
func (c *localClientConnector) OnClose() {
|
||||||
c.server.sessionManager.OnSessionEnd(c.sessionID)
|
c.server.sessionManager.OnSessionEnd(c.sessionID)
|
||||||
@@ -589,6 +591,25 @@ func (s *LocalServer) screenshotTTL(routeKey string) time.Duration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func etagMatches(ifNoneMatch, etag string) bool {
|
||||||
|
etag = strings.Trim(strings.TrimSpace(etag), `"`)
|
||||||
|
if etag == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, candidate := range strings.Split(ifNoneMatch, ",") {
|
||||||
|
value := strings.TrimSpace(candidate)
|
||||||
|
if value == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
value = strings.TrimPrefix(value, "W/")
|
||||||
|
value = strings.Trim(value, `"`)
|
||||||
|
if value == etag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
||||||
routeKey := r.URL.Query().Get("route_key")
|
routeKey := r.URL.Query().Get("route_key")
|
||||||
routeKey, session, ok := s.chooseRouteForScreenshot(routeKey)
|
routeKey, session, ok := s.chooseRouteForScreenshot(routeKey)
|
||||||
@@ -616,18 +637,24 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
cached, hasCached := s.screenshotCache[routeKey]
|
cached, hasCached := s.screenshotCache[routeKey]
|
||||||
|
lastActivity := s.routeLastActivity[routeKey]
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) {
|
if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) {
|
||||||
if match := r.Header.Get("If-None-Match"); match != "" && match == cached.etag {
|
if etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
|
||||||
|
if !lastActivity.After(cached.when) {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("ETag", cached.etag)
|
||||||
w.WriteHeader(http.StatusNotModified)
|
w.WriteHeader(http.StatusNotModified)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("ETag", cached.etag)
|
w.Header().Set("ETag", cached.etag)
|
||||||
w.Header().Set("Content-Type", "image/svg+xml")
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||||||
_, _ = io.WriteString(w, cached.svg)
|
_, _ = io.WriteString(w, cached.svg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if s.screenshotForceRedraw {
|
if s.screenshotForceRedraw {
|
||||||
_ = session.ForceRedraw()
|
_ = session.ForceRedraw()
|
||||||
@@ -635,6 +662,12 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
snapshot := session.GetScreenSnapshot()
|
snapshot := session.GetScreenSnapshot()
|
||||||
if hasCached && !snapshot.HasChanges {
|
if hasCached && !snapshot.HasChanges {
|
||||||
|
if etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("ETag", cached.etag)
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("ETag", cached.etag)
|
w.Header().Set("ETag", cached.etag)
|
||||||
w.Header().Set("Content-Type", "image/svg+xml")
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||||||
@@ -662,10 +695,16 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
svg := RenderTerminalSVG(snapshot.Buffer, snapshot.Width, snapshot.Height, "webterm", background, foreground, palette)
|
svg := RenderTerminalSVG(snapshot.Buffer, snapshot.Width, snapshot.Height, "webterm", background, foreground, palette)
|
||||||
hash := sha1.Sum([]byte(svg))
|
hash := sha1.Sum([]byte(svg))
|
||||||
etag := fmt.Sprintf("%x", hash[:])
|
etag := fmt.Sprintf(`"%x"`, hash[:])
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.screenshotCache[routeKey] = screenshotCacheEntry{when: time.Now(), svg: svg, etag: etag}
|
s.screenshotCache[routeKey] = screenshotCacheEntry{when: time.Now(), svg: svg, etag: etag}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
if etagMatches(r.Header.Get("If-None-Match"), etag) {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("ETag", etag)
|
w.Header().Set("ETag", etag)
|
||||||
@@ -878,6 +917,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
const keyIndicatorEl = document.getElementById('key-indicator');
|
const keyIndicatorEl = document.getElementById('key-indicator');
|
||||||
const thumbnailCache = {};
|
const thumbnailCache = {};
|
||||||
const activeObjectURLBySlug = {};
|
const activeObjectURLBySlug = {};
|
||||||
|
const etagBySlug = {};
|
||||||
const refreshQueue = [];
|
const refreshQueue = [];
|
||||||
const queuedRefresh = {};
|
const queuedRefresh = {};
|
||||||
let screenshotRequestInFlight = false;
|
let screenshotRequestInFlight = false;
|
||||||
@@ -1124,8 +1164,16 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
const url = '/screenshot.svg?route_key=' + encodeURIComponent(slug);
|
const url = '/screenshot.svg?route_key=' + encodeURIComponent(slug);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
fetch(url, { cache: 'no-cache', signal: controller.signal })
|
const headers = {};
|
||||||
|
if (etagBySlug[slug]) {
|
||||||
|
headers['If-None-Match'] = etagBySlug[slug];
|
||||||
|
}
|
||||||
|
fetch(url, { cache: 'no-cache', headers, signal: controller.signal })
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
|
const nextETag = resp.headers.get('ETag');
|
||||||
|
if (nextETag) {
|
||||||
|
etagBySlug[slug] = nextETag;
|
||||||
|
}
|
||||||
if (resp.status === 304) return null;
|
if (resp.status === 304) return null;
|
||||||
if (!resp.ok) throw new Error('screenshot fetch failed');
|
if (!resp.ok) throw new Error('screenshot fetch failed');
|
||||||
return resp.blob();
|
return resp.blob();
|
||||||
|
|||||||
@@ -38,10 +38,15 @@ func dispatchSessionOutput(filtered []byte, tracker *terminalstate.Tracker, repl
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
replay.Add(filtered)
|
replay.Add(filtered)
|
||||||
|
hasVisualChange := false
|
||||||
if tracker != nil {
|
if tracker != nil {
|
||||||
_ = tracker.Feed(filtered)
|
_ = tracker.Feed(filtered)
|
||||||
|
hasVisualChange = tracker.ConsumeActivityChanged()
|
||||||
}
|
}
|
||||||
connector.OnData(filtered)
|
connector.OnData(filtered)
|
||||||
|
if hasVisualChange {
|
||||||
|
connector.OnMeta(map[string]any{"screen_changed": true})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshotFromTracker(tracker *terminalstate.Tracker, width, height int) terminalstate.Snapshot {
|
func snapshotFromTracker(tracker *terminalstate.Tracker, width, height int) terminalstate.Snapshot {
|
||||||
|
|||||||
Reference in New Issue
Block a user