返回 DeepSeek-Reasonix
broadcaster.go
根目录 / internal / serve / broadcaster.go
1 package serve
2
3 import (
4 "encoding/json"
5 "strings"
6 "sync"
7 "time"
8
9 "reasonix/internal/agent"
10 "reasonix/internal/billing"
11 "reasonix/internal/event"
12 "reasonix/internal/eventwire"
13 )
14
15 type subscription struct {
16 all bool
17 }
18
19 const (
20 subscriberBufferSize = 128
21 subscriberPriorityReserve = 32
22 )
23
24 // Broadcaster is the event.Sink the controllers emit to in server mode. It
25 // marshals each event once and fans it out to every connected SSE subscriber.
26 // A slow subscriber's buffer is allowed to drop rather than back-pressure the
27 // agent goroutine — a browser that can't keep up loses intermediate frames, not
28 // the whole session (it can refetch /history).
29 type Broadcaster struct {
30 mu sync.Mutex
31 subs map[chan []byte]subscription
32 ledgers map[string]*billing.Ledger
33 current string
34 displayCurrency string
35 }
36
37 // NewBroadcaster returns an empty Broadcaster ready to accept subscribers.
38 func NewBroadcaster() *Broadcaster {
39 return &Broadcaster{
40 subs: map[chan []byte]subscription{},
41 ledgers: map[string]*billing.Ledger{},
42 }
43 }
44
45 // SetDisplayCurrency rebinds the session ledger to a stored valuation. Empty
46 // keeps automatic mode: a single original currency is selected and mixed
47 // currencies remain buckets.
48 func (b *Broadcaster) SetDisplayCurrency(currency string) {
49 if b == nil {
50 return
51 }
52 b.mu.Lock()
53 b.displayCurrency = billing.NormalizeCurrency(currency)
54 b.mu.Unlock()
55 }
56
57 // sessionRouteKey is the one normalization rule for session references the
58 // broadcaster keys on: final-format identity routes ("session-id:<id>") are not
59 // filesystem paths and stay verbatim, legacy transcript paths take their
60 // canonical form. Every emit, ledger and current-session update goes through
61 // it so an identity route can never be rewritten into a cwd-relative pseudo
62 // path that no subscriber or registry matches.
63 func sessionRouteKey(path string) string {
64 path = strings.TrimSpace(path)
65 if strings.HasPrefix(path, remoteSessionIDQueryPrefix) {
66 return path
67 }
68 return agent.CanonicalSessionPath(path)
69 }
70
71 // hiddenFromCurrentOnly reports whether a routed frame is filtered out for
72 // subscribers that watch only the current session. The filter compares path
73 // keys; identity routes are not path-keyed and are routed by the client from
74 // the frame itself, exactly like identity-tagged live frames (which carry no
75 // path), so they are never dropped here.
76 func hiddenFromCurrentOnly(sessionPath, current string) bool {
77 if sessionPath == "" || strings.HasPrefix(sessionPath, remoteSessionIDQueryPrefix) {
78 return false
79 }
80 return sessionPath != current
81 }
82
83 // SetCurrentSession records the controller shown by current-only subscribers.
84 // Untagged events remain compatible and are attributed to this session.
85 func (b *Broadcaster) SetCurrentSession(path string) {
86 if b == nil {
87 return
88 }
89 path = sessionRouteKey(path)
90 b.mu.Lock()
91 b.current = path
92 b.mu.Unlock()
93 }
94
95 // CurrentSession reports the session currently selected by Serve.
96 func (b *Broadcaster) CurrentSession() string {
97 if b == nil {
98 return ""
99 }
100 b.mu.Lock()
101 defer b.mu.Unlock()
102 return b.current
103 }
104
105 // ResetSession clears the current session usage ledger for legacy callers.
106 func (b *Broadcaster) ResetSession() {
107 b.ResetSessionPath("")
108 }
109
110 // ResetSessionPath clears one session ledger without affecting detached
111 // sessions. Empty selects the current session.
112 func (b *Broadcaster) ResetSessionPath(path string) {
113 if b == nil {
114 return
115 }
116 b.mu.Lock()
117 if path == "" {
118 path = b.current
119 } else {
120 path = sessionRouteKey(path)
121 }
122 delete(b.ledgers, path)
123 b.mu.Unlock()
124 }
125
126 // SessionCostQuote returns the current aggregate quote without repricing.
127 func (b *Broadcaster) SessionCostQuote() billing.CostQuote {
128 return b.SessionCostQuoteFor("")
129 }
130
131 // SessionCostQuoteFor returns one session's aggregate quote. Empty selects
132 // the current session so existing single-session callers keep their contract.
133 func (b *Broadcaster) SessionCostQuoteFor(path string) billing.CostQuote {
134 if b == nil {
135 return billing.AggregateQuotes(nil, "")
136 }
137 b.mu.Lock()
138 defer b.mu.Unlock()
139 return b.ledgerLocked(path).Total(b.displayCurrency)
140 }
141
142 func (b *Broadcaster) ledgerLocked(path string) *billing.Ledger {
143 if path == "" {
144 path = b.current
145 } else {
146 path = sessionRouteKey(path)
147 }
148 ledger := b.ledgers[path]
149 if ledger == nil {
150 ledger = billing.NewLedger()
151 b.ledgers[path] = ledger
152 }
153 return ledger
154 }
155
156 // Emit marshals the event to JSON and delivers it to every subscriber. Drops to
157 // a subscriber whose buffer is full rather than blocking. A marshal failure is
158 // dropped silently — one bad event shouldn't stall the stream.
159 func (b *Broadcaster) Emit(e event.Event) {
160 if e.SessionPath != "" {
161 e.SessionPath = sessionRouteKey(e.SessionPath)
162 }
163 wired := eventwire.ToWire(e)
164 b.mu.Lock()
165 observedCurrent := b.current
166 b.mu.Unlock()
167 wired.SessionCurrent = e.SessionPath != "" && e.SessionPath == observedCurrent
168 data, err := json.Marshal(wired)
169 if err != nil {
170 return
171 }
172 b.mu.Lock()
173 defer b.mu.Unlock()
174 if b.current != observedCurrent {
175 wired.SessionCurrent = e.SessionPath != "" && e.SessionPath == b.current
176 data, err = json.Marshal(wired)
177 if err != nil {
178 return
179 }
180 }
181 if e.Kind == event.Usage && e.Usage != nil && e.CostQuote != nil {
182 b.ledgerLocked(e.SessionPath).Add(*e.CostQuote, billing.UsageTokens{
183 PromptTokens: e.Usage.PromptTokens, CompletionTokens: e.Usage.CompletionTokens,
184 CacheHitTokens: e.Usage.CacheHitTokens, CacheMissTokens: e.Usage.CacheMissTokens,
185 CacheWriteTokens: e.Usage.CacheWriteTokens, CacheWriteBilledTokens: e.Usage.CacheWriteBilledTokens,
186 Estimated: e.Usage.Estimated,
187 }, time.Now().UTC())
188 }
189 for ch, sub := range b.subs {
190 if !sub.all && hiddenFromCurrentOnly(e.SessionPath, b.current) {
191 continue
192 }
193 enqueueSubscriberFrame(ch, data, e.Kind)
194 }
195 }
196
197 // EmitTo delivers an event only to the supplied subscriber. It is used for
198 // connection-local recovery frames, such as replaying a prompt to a browser
199 // that attached after the original event was emitted. Normal runtime events
200 // should continue to use Emit so every subscriber receives them.
201 func (b *Broadcaster) EmitTo(target <-chan []byte, e event.Event) {
202 if e.SessionPath != "" {
203 e.SessionPath = sessionRouteKey(e.SessionPath)
204 }
205 wired := eventwire.ToWire(e)
206 b.mu.Lock()
207 observedCurrent := b.current
208 b.mu.Unlock()
209 wired.SessionCurrent = e.SessionPath != "" && e.SessionPath == observedCurrent
210 data, err := json.Marshal(wired)
211 if err != nil {
212 return
213 }
214 b.mu.Lock()
215 defer b.mu.Unlock()
216 if b.current != observedCurrent {
217 wired.SessionCurrent = e.SessionPath != "" && e.SessionPath == b.current
218 data, err = json.Marshal(wired)
219 if err != nil {
220 return
221 }
222 }
223 for ch, sub := range b.subs {
224 if (<-chan []byte)(ch) != target {
225 continue
226 }
227 if !sub.all && hiddenFromCurrentOnly(e.SessionPath, b.current) {
228 return
229 }
230 enqueueSubscriberFrame(ch, data, e.Kind)
231 return
232 }
233 }
234
235 // eventIsPriority keeps lifecycle and terminal frames out of the high-volume
236 // delta budget. Slow subscribers may recover text/history over HTTP, but they
237 // must still learn that a turn, prompt, or foreground-session transition ended.
238 func eventIsPriority(kind event.Kind) bool {
239 switch kind {
240 case event.Reasoning, event.Text, event.ToolProgress, event.StreamAttempt:
241 return false
242 default:
243 return true
244 }
245 }
246
247 // eventMustReachSubscriber identifies lifecycle truth that cannot be recovered
248 // by refetching history. The reserved queue budget protects these frames from
249 // deltas; if other priority traffic also exhausts that budget, the newest
250 // terminal/routing frame evicts a recoverable queued frame instead of vanishing.
251 func eventMustReachSubscriber(kind event.Kind, data []byte) bool {
252 switch kind {
253 case event.TurnDone, event.SessionChanged:
254 return true
255 case event.Notice:
256 return wireFrameMustReachSubscriber(data)
257 default:
258 return false
259 }
260 }
261
262 func enqueueSubscriberFrame(ch chan []byte, data []byte, kind event.Kind) {
263 priority := eventIsPriority(kind)
264 if !priority && len(ch) >= cap(ch)-subscriberPriorityReserve {
265 return
266 }
267 select {
268 case ch <- data:
269 return
270 default:
271 // A slow subscriber exhausted even the priority reserve. Ordinary
272 // priority events remain lossy, but lifecycle truth gets one slot by
273 // evicting an older frame while Broadcaster.mu serializes producers.
274 if !eventMustReachSubscriber(kind, data) || !evictRecoverableSubscriberFrame(ch) {
275 return
276 }
277 }
278 // Broadcaster.mu excludes every producer, and eviction leaves at least one
279 // slot. A concurrent consumer can only create more capacity, so this send is
280 // bounded while guaranteeing the terminal frame is retained.
281 ch <- data
282 }
283
284 func evictRecoverableSubscriberFrame(ch chan []byte) bool {
285 queued := len(ch)
286 if queued < cap(ch) {
287 return true
288 }
289 frames := make([][]byte, 0, queued)
290 drain:
291 for range queued {
292 select {
293 case frame := <-ch:
294 frames = append(frames, frame)
295 default:
296 break drain
297 }
298 }
299 if len(frames) == 0 {
300 return true
301 }
302 evict := -1
303 for i, frame := range frames {
304 if !wireFrameMustReachSubscriber(frame) {
305 evict = i
306 break
307 }
308 }
309 if evict < 0 {
310 // A bounded queue cannot retain an unbounded run of terminal frames.
311 // Prefer the latest lifecycle truth over an older one in that degenerate
312 // case; normal saturation always finds a recoverable delta/status frame.
313 evict = 0
314 }
315 for i, frame := range frames {
316 if i != evict {
317 ch <- frame
318 }
319 }
320 return true
321 }
322
323 func wireFrameMustReachSubscriber(data []byte) bool {
324 return eventwire.FrameMustReachMirror(data)
325 }
326
327 // EmitWire publishes an externally authored wire frame (same JSON contract as
328 // locally emitted events) to every subscriber. The local-takeover mirror uses
329 // it: a desktop writer that owns the session file pushes its frames through
330 // Serve so the remote tab keeps rendering the conversation live without any
331 // change to its own pipeline.
332 func (b *Broadcaster) EmitWire(wired eventwire.Event) {
333 if wired.SessionPath != "" {
334 wired.SessionPath = sessionRouteKey(wired.SessionPath)
335 }
336 b.mu.Lock()
337 observedCurrent := b.current
338 b.mu.Unlock()
339 wired.SessionCurrent = wired.SessionPath != "" && wired.SessionPath == observedCurrent
340 data, err := json.Marshal(wired)
341 if err != nil {
342 return
343 }
344 b.mu.Lock()
345 defer b.mu.Unlock()
346 if b.current != observedCurrent {
347 wired.SessionCurrent = wired.SessionPath != "" && wired.SessionPath == b.current
348 data, err = json.Marshal(wired)
349 if err != nil {
350 return
351 }
352 }
353 for ch := range b.subs {
354 enqueueSubscriberWireFrame(ch, data, wired.Kind)
355 }
356 }
357
358 // wireKindIsPriority mirrors eventIsPriority for externally supplied frames,
359 // which arrive as wire kind strings rather than typed event.Kind values.
360 func wireKindIsPriority(kind string) bool {
361 return !eventwire.WireKindIsRecoverable(kind)
362 }
363
364 func enqueueSubscriberWireFrame(ch chan []byte, data []byte, kind string) {
365 priority := wireKindIsPriority(kind)
366 if !priority && len(ch) >= cap(ch)-subscriberPriorityReserve {
367 return
368 }
369 select {
370 case ch <- data:
371 return
372 default:
373 if !wireFrameMustReachSubscriber(data) || !evictRecoverableSubscriberFrame(ch) {
374 return
375 }
376 }
377 ch <- data
378 }
379
380 // Subscribe registers a new SSE client and returns its channel plus an
381 // unsubscribe func the handler must call (defer) when the client disconnects.
382 func (b *Broadcaster) Subscribe() (<-chan []byte, func()) {
383 return b.subscribe(false)
384 }
385
386 // SubscribeAll receives tagged frames from current and detached sessions.
387 // Desktop uses it to maintain per-session runtime state; browser clients keep
388 // using Subscribe and see only the selected session.
389 func (b *Broadcaster) SubscribeAll() (<-chan []byte, func()) {
390 return b.subscribe(true)
391 }
392
393 func (b *Broadcaster) subscribe(all bool) (<-chan []byte, func()) {
394 ch := make(chan []byte, subscriberBufferSize)
395 b.mu.Lock()
396 b.subs[ch] = subscription{all: all}
397 b.mu.Unlock()
398 return ch, func() {
399 b.mu.Lock()
400 if _, ok := b.subs[ch]; ok {
401 delete(b.subs, ch)
402 close(ch)
403 }
404 b.mu.Unlock()
405 }
406 }
407
408 // Subscribers reports the current connection count (for diagnostics/tests).
409 func (b *Broadcaster) Subscribers() int {
410 b.mu.Lock()
411 defer b.mu.Unlock()
412 return len(b.subs)
413 }
414
414 lines GO