1package daemon
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "slices"
8 "sync"
9 "time"
10
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"
13)
14
15const (
16 remotePreviewCacheTTL = 5 * time.Second
17 remotePreviewCooldown = 1 * time.Second
18 remotePreviewCacheMax = 64
19 remotePreviewSlots = 4
20)
21
22var errRemotePreviewCooldown = errors.New("remote preview request is cooling down")
23
24type remotePreviewCacheKey struct {
25 Endpoint string
26 DisplayOrigin string
27 LifecycleID domain.SessionLifecycleID
28 SessionName string
29 LiveTabID domain.TabStableID
30 Stopped bool
31 StoppedTab domain.TabSelector
32 Width uint16
33 Height uint16
34}
35
36type remotePreviewCacheEntry struct {
37 Preview ports.RemotePreview
38 Fetched time.Time
39 Used time.Time
40}
41
42type remotePreviewFlight struct {
43 done chan struct{}
44 preview ports.RemotePreview
45 err error
46}
47
48type remotePreviewState struct {
49 mu sync.Mutex
50 cache map[remotePreviewCacheKey]remotePreviewCacheEntry
51 flights map[remotePreviewCacheKey]*remotePreviewFlight
52 cooldowns map[remotePreviewCacheKey]time.Time
53 slots chan struct{}
54}
55
56func cloneRemotePreview(preview ports.RemotePreview) ports.RemotePreview {
57 preview.Cells = slices.Clone(preview.Cells)
58 return preview
59}
60
61func (s *remotePreviewState) initializeLocked() {
62 if s.cache == nil {
63 s.cache = make(map[remotePreviewCacheKey]remotePreviewCacheEntry)
64 }
65 if s.flights == nil {
66 s.flights = make(map[remotePreviewCacheKey]*remotePreviewFlight)
67 }
68 if s.cooldowns == nil {
69 s.cooldowns = make(map[remotePreviewCacheKey]time.Time)
70 }
71 if s.slots == nil {
72 s.slots = make(chan struct{}, remotePreviewSlots)
73 }
74}
75
76func remotePreviewKeyFor(target domain.RemoteSessionTarget, width, height uint16) remotePreviewCacheKey {
77 return remotePreviewCacheKey{
78 Endpoint: target.Endpoint, DisplayOrigin: target.DisplayOrigin,
79 LifecycleID: target.LifecycleID, SessionName: target.SessionName,
80 LiveTabID: target.LiveTabID, Stopped: target.Stopped,
81 StoppedTab: target.StoppedTab, Width: width, Height: height,
82 }
83}
84
85func (d *Daemon) fetchRemotePreview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) {
86 if d == nil || d.remotePreviewClient == nil {
87 return ports.RemotePreview{}, errors.New("remote preview client unavailable")
88 }
89 if ctx == nil {
90 ctx = context.Background()
91 }
92 if err := ctx.Err(); err != nil {
93 return ports.RemotePreview{}, err
94 }
95 request := ports.RemotePreviewRequest{
96 Version: ports.RemotePreviewSchemaVersion,
97 Target: target,
98 Width: width,
99 Height: height,
100 }
101 if err := ports.ValidateRemotePreviewRequest(request); err != nil {
102 return ports.RemotePreview{}, err
103 }
104
105 key := remotePreviewKeyFor(target, width, height)
106 now := d.clock.Now()
107 d.remotePreview.mu.Lock()
108 d.remotePreview.initializeLocked()
109
110 if cached, ok := d.remotePreview.cache[key]; ok {
111 cached.Used = now
112 d.remotePreview.cache[key] = cached
113 if now.Sub(cached.Fetched) <= remotePreviewCacheTTL {
114 preview := cloneRemotePreview(cached.Preview)
115 d.remotePreview.mu.Unlock()
116 return preview, nil
117 }
118
119 // Expired data remains useful to the picker. Revalidation is detached
120 // from the navigation request so cursor movement never waits on remote
121 // I/O, and cooldowns prevent a failed host from being hammered.
122 if until, cooling := d.remotePreview.cooldowns[key]; cooling && now.Before(until) {
123 preview := cloneRemotePreview(cached.Preview)
124 d.remotePreview.mu.Unlock()
125 return preview, nil
126 }
127 if flight := d.remotePreview.flights[key]; flight != nil {
128 preview := cloneRemotePreview(cached.Preview)
129 d.remotePreview.mu.Unlock()
130 return preview, nil
131 }
132 flight := &remotePreviewFlight{done: make(chan struct{})}
133 d.remotePreview.flights[key] = flight
134 preview := cloneRemotePreview(cached.Preview)
135 d.remotePreview.mu.Unlock()
136 go d.runRemotePreviewFlight(d.remotePreviewContext(), key, target, width, height, flight)
137 return preview, nil
138 }
139
140 if until, cooling := d.remotePreview.cooldowns[key]; cooling && now.Before(until) {
141 d.remotePreview.mu.Unlock()
142 return ports.RemotePreview{}, errRemotePreviewCooldown
143 }
144 if flight := d.remotePreview.flights[key]; flight != nil {
145 d.remotePreview.mu.Unlock()
146 return waitRemotePreviewFlight(ctx, flight)
147 }
148 flight := &remotePreviewFlight{done: make(chan struct{})}
149 d.remotePreview.flights[key] = flight
150 d.remotePreview.mu.Unlock()
151
152 d.runRemotePreviewFlight(ctx, key, target, width, height, flight)
153 return cloneRemotePreview(flight.preview), flight.err
154}
155
156func (d *Daemon) remotePreviewContext() context.Context {
157 if d != nil && d.serveCtx != nil {
158 return d.serveCtx
159 }
160 return context.Background()
161}
162
163func waitRemotePreviewFlight(ctx context.Context, flight *remotePreviewFlight) (ports.RemotePreview, error) {
164 select {
165 case <-flight.done:
166 return cloneRemotePreview(flight.preview), flight.err
167 case <-ctx.Done():
168 return ports.RemotePreview{}, ctx.Err()
169 }
170}
171
172// awaitRemotePreviewRefresh lets a picker display expired content immediately
173// while still publishing the refreshed result once the same-key flight finishes.
174// A completed flight is read from the cache without starting a second remote
175// request.
176func (d *Daemon) awaitRemotePreviewRefresh(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) {
177 key := remotePreviewKeyFor(target, width, height)
178 d.remotePreview.mu.Lock()
179 flight := d.remotePreview.flights[key]
180 d.remotePreview.mu.Unlock()
181 if flight != nil {
182 return waitRemotePreviewFlight(ctx, flight)
183 }
184 return d.fetchRemotePreview(ctx, target, width, height)
185}
186
187func (d *Daemon) runRemotePreviewFlight(ctx context.Context, key remotePreviewCacheKey, target domain.RemoteSessionTarget, width, height uint16, flight *remotePreviewFlight) {
188 select {
189 case d.remotePreview.slots <- struct{}{}:
190 case <-ctx.Done():
191 d.finishRemotePreviewFlight(key, flight, ports.RemotePreview{}, ctx.Err())
192 return
193 }
194 var preview ports.RemotePreview
195 var err error
196 defer func() {
197 if recovered := recover(); recovered != nil {
198 err = fmt.Errorf("remote preview flight panicked: %v", recovered)
199 preview = ports.RemotePreview{}
200 d.finishRemotePreviewFlight(key, flight, preview, err)
201 panic(recovered)
202 }
203 if err != nil {
204 preview = ports.RemotePreview{}
205 }
206 d.finishRemotePreviewFlight(key, flight, preview, err)
207 }()
208 defer func() { <-d.remotePreview.slots }()
209
210 preview, err = d.remotePreviewClient.Preview(ctx, target, width, height)
211 if err == nil {
212 if validationErr := ports.ValidateRemotePreview(preview); validationErr != nil {
213 err = validationErr
214 } else if preview.Status != ports.RemotePreviewOK {
215 err = fmt.Errorf("remote preview returned status %d", preview.Status)
216 } else if preview.LifecycleID != target.LifecycleID || preview.TabID != target.LiveTabID {
217 err = errors.New("remote preview identity changed")
218 }
219 }
220}
221
222func (d *Daemon) finishRemotePreviewFlight(key remotePreviewCacheKey, flight *remotePreviewFlight, preview ports.RemotePreview, err error) {
223 now := d.clock.Now()
224 d.remotePreview.mu.Lock()
225 for cooldownKey, until := range d.remotePreview.cooldowns {
226 if !now.Before(until) {
227 delete(d.remotePreview.cooldowns, cooldownKey)
228 }
229 }
230 flight.preview = cloneRemotePreview(preview)
231 flight.err = err
232 delete(d.remotePreview.flights, key)
233 if err == nil {
234 d.remotePreview.cache[key] = remotePreviewCacheEntry{
235 Preview: cloneRemotePreview(preview), Fetched: now, Used: now,
236 }
237 delete(d.remotePreview.cooldowns, key)
238 for len(d.remotePreview.cache) > remotePreviewCacheMax {
239 var oldestKey remotePreviewCacheKey
240 var oldest time.Time
241 for candidate, entry := range d.remotePreview.cache {
242 used := entry.Used
243 if used.IsZero() {
244 used = entry.Fetched
245 }
246 if oldest.IsZero() || used.Before(oldest) {
247 oldestKey, oldest = candidate, used
248 }
249 }
250 delete(d.remotePreview.cache, oldestKey)
251 }
252 } else if !errors.Is(err, context.Canceled) {
253 d.remotePreview.cooldowns[key] = now.Add(remotePreviewCooldown)
254 }
255 close(flight.done)
256 d.remotePreview.mu.Unlock()
257}