Repository navigation
Expand file tree
/
Copy pathhost.go
More file actions
1879 lines (1766 loc) · 52.9 KB
/
Copy pathhost.go
File metadata and controls
1879 lines (1766 loc) · 52.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package plugin
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
_ "embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/filememo"
"github.com/yetone/magpie/internal/lastgood"
"github.com/yetone/magpie/internal/netproxy"
"github.com/yetone/magpie/internal/settings"
)
//go:embed host.js
var hostJS []byte
// piJS loads pi's extensions as plugins; host.js imports it when a plugin
// is pi's.
//
//go:embed pi.js
var piJS []byte
// message is a line the host writes.
type message struct {
ID int64 `json:"id"`
Result json.RawMessage `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
Event string `json:"event"`
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Data string `json:"data"`
// raw is Data decoded, filled by push so the body reads it without
// decoding the same frame a second time. It is never on the wire.
raw []byte
Provider string `json:"provider"`
Account string `json:"account"`
Said string `json:"said"`
Level string `json:"level"`
Message string `json:"message"`
Title string `json:"title"`
Variant string `json:"variant"`
Count int `json:"count"`
}
type callState uint8
const (
callSending callState = iota
callActive
callDraining
callAborting
callReleased
)
type callEvent uint8
const (
callAccepted callEvent = iota
callAnswered
callCancelled
callConsumed
callAbortWritten
callHostFailed
)
type call struct {
done chan message // the answer
closed atomic.Bool // done has been answered
// A fetch's head and chunks wait here, in the order they came, until
// the body's reader takes them: the host's reader queues them without
// ever waiting on one request's consumer (see push).
stream bool
cancellable bool // a gateway request owns asynchronous I/O, without a stream slot
mu sync.Mutex
queue []message
queued int // the queued chunks' decoded bytes
over error // why the call was given up on
stopped bool // the call is over for good: nothing more is queued
wake chan struct{} // buffered 1: something was queued, or the call is over
// window is this call's credit in charged bytes (a frame's payload plus
// frameOverhead) and owed is how much of it the child has spent and the
// reader has not yet given back: the child sends no more than window-owed,
// so the queue stays within the window however slow the reader is. A child
// that sends past it broke the protocol (see push).
window int
owed int
// headed is whether the reply's head has been queued: a second one is a
// broken child, not another free line.
headed bool
// h and id are how the call is given up: the child is told to stop its
// fetch only while the call is still the host's.
h *host
id int64
// The host lock owns lifecycle transitions; the queue lock also protects
// the persistent answer so notifications never own the vendor outcome.
state callState
terminal *message
// stopCancel stops the context.AfterFunc that gives the call up when its
// request's context ends, so a body nothing reads or closes still stops
// the upstream fetch. It is cleared the once it is called.
stopCancel func() bool
}
// The credit window bounds one call's queue; the budget bounds every live
// call's windows together, so the host admits at most streamBudget/streamWindow
// streaming calls at once (see begin). Both count charged bytes: a frame's
// payload plus frameOverhead, so the window bounds a backlog's frame metadata
// as well as its bytes (the base64 on the wire, about 4/3 of the payload, is
// held besides). The child splits its frames to maxFrame and spends its window
// frame by frame, so a compliant child never sends past the window. callLimit
// bounds every live or retiring call together, so the control backlog (at most
// one entry a call) is bounded too. Tests lower them.
var (
streamWindow = 512 << 10
streamBudget = 64 << 20
maxFrame = 64 << 10
// frameOverhead is what one queued frame costs the window besides its
// bytes: the message struct, its base64 string header and the decoded
// slice behind it. Without it a producer of 1-byte frames could keep a
// window's worth of tiny structs queued — bytes are not the only memory
// a backlog holds. It is charged in the same units as the window, so both
// sides count one number.
frameOverhead = 2048
rpcReserve = 64
// maxWireLine bounds one line the child writes: a frame's base64 is well
// under it, and so is any RPC result, so a child that never ends a line
// fails the host rather than making it allocate without bound.
maxWireLine = 32 << 20
// maxHeadBytes and maxHeadEntries are magpie's own head-envelope policy,
// not HTTP's: a fetch's head carries at most maxHeadBytes of header bytes
// (the UTF-8 bytes of every name and value, plus headEntryCost an entry)
// and maxHeadEntries entries, counted the same on both sides. It keeps a
// vendor's unbounded header set from being a free 0-charge line. A head
// handed to its caller is the caller's to hold besides.
maxHeadBytes = 64 << 10
maxHeadEntries = 256
headEntryCost = 4 // a name/value pair's JSON punctuation
errTooManyCalls = errors.New("too many plugin calls are in flight; try again shortly")
errProtocolOverrun = errors.New("the plugin sent more of a reply than the host credited; that request was given up on")
errHostGone = errors.New("the plugin host is gone")
errLineTooLong = errors.New("the plugin host sent a line longer than the host reads")
)
// callLimit is the most calls (streaming, RPC or retiring) the host holds at
// once. It follows from the budget: the streaming share of it plus a reserve
// for RPCs, so the control backlog is bounded by it.
func callLimit() int { return streamLimit() + rpcReserve }
// streamLimit is how many streaming calls the budget's windows allow at once:
// each holds one window of credit, so their queues together stay within the
// budget. It is counted apart from the call limit, so the RPC reserve is never
// spent on one more stream.
func streamLimit() int { return streamBudget / streamWindow }
// push queues m for the call's reader, without waiting for it. It is false
// when the call was given up on and m was not taken: a compliant child never
// sends past the call's window, so this is a call already stopped, or a child
// that broke the protocol (see errProtocolOverrun).
func (c *call) push(m message) bool {
c.h.mu.Lock()
if c.state != callActive {
c.h.mu.Unlock()
return false
}
c.mu.Lock()
if c.stopped || c.over != nil {
c.mu.Unlock()
c.h.mu.Unlock()
return false
}
cost := 0
switch m.Event {
case "head":
// exactly one head, and it is free: a repeat would be an unbounded
// run of zero-cost lines. Its headers are capped by magpie's own
// envelope, so an unbounded header set is not a free line either.
if c.headed || len(m.Headers) > maxHeadEntries || headCost(m.Headers) > maxHeadBytes {
return c.rejectFrameLocked()
}
c.headed = true
case "chunk":
// a chunk carries the reply's bytes: it must be base64 of one to
// maxFrame bytes. An empty, malformed or oversized frame is refused
// before it is queued, so a flood of empty chunks cannot grow the
// queue without spending the window. The frame is decoded once, here,
// so the body reads it without decoding the same bytes again.
raw, err := base64.StdEncoding.DecodeString(m.Data)
if err != nil || len(raw) == 0 || len(raw) > maxFrame {
return c.rejectFrameLocked()
}
m.raw = raw
cost = len(raw) + frameOverhead
default:
// an event the host does not know: refuse it rather than queue it for
// free
return c.rejectFrameLocked()
}
if c.window > 0 && c.owed+cost > c.window {
// more than the window ahead of the reader: the child broke the
// protocol. The call is given up on and its queue dropped, rather
// than growing without bound.
return c.rejectFrameLocked()
}
c.queue = append(c.queue, m)
c.queued += len(m.raw)
c.owed += cost
c.mu.Unlock()
c.h.mu.Unlock()
c.notify()
return true
}
func (c *call) rejectFrameLocked() bool {
c.mu.Unlock()
stop := c.transitionLocked(callCancelled, errProtocolOverrun, nil)
c.h.mu.Unlock()
if stop != nil {
stop()
}
return false
}
// pop takes the next chunk queued for the call; false when none is queued.
func (c *call) pop() (message, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.queue) == 0 {
return message{}, false
}
m := c.queue[0]
c.queue[0] = message{} // let the chunk's data go with the chunk
c.queue = c.queue[1:]
if len(c.queue) == 0 {
c.queue = nil
}
c.queued -= len(m.raw)
return m, true
}
// credit gives back the bytes the reader took, so the child may send that much
// more of this call's reply. It is what keeps a slow reader's upstream paused
// and a fast reader's flowing, without the host ever cutting a reply short.
func (c *call) credit(n int) {
if n <= 0 {
return
}
c.mu.Lock()
if c.stopped || c.over != nil {
c.mu.Unlock()
return
}
if n > c.owed {
n = c.owed
}
c.owed -= n
c.mu.Unlock()
if n > 0 {
c.h.credit(c, n)
}
}
// givenUp is why the call's backlog passed its limit, nil for one it didn't.
func (c *call) givenUp() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.over
}
// watchCtx arms the context watcher that gives the call up when its request's
// context ends, so a body nothing reads or closes still stops the upstream
// fetch. The watcher is registered under h.mu and the callback settles outside
// it, so a context already cancelled can fire before stopCancel is set without
// racing it.
func (c *call) watchCtx(ctx context.Context) {
stop := context.AfterFunc(ctx, func() { c.settle(context.Cause(ctx)) })
c.h.mu.Lock()
if c.state == callReleased || c.state == callAborting {
c.h.mu.Unlock()
stop()
return
}
c.stopCancel = stop
c.h.mu.Unlock()
}
// transitionLocked owns cleanup under h.mu, always taking c.mu after it.
// The returned watcher detachment runs outside both locks. A retiring call
// stays registered until its abort write is acknowledged, even when the
// control loop has already taken its work out of the control map.
func (c *call) transitionLocked(event callEvent, why error, answer *message) func() bool {
if c.state == callReleased {
return nil
}
if event == callAccepted {
if c.state == callSending {
c.state = callActive
}
return nil
}
if event == callAnswered && c.state != callActive ||
event == callHostFailed && c.state == callDraining ||
event == callAbortWritten && c.state != callAborting ||
event == callConsumed && c.state == callAborting ||
event == callCancelled && c.state == callAborting {
return nil
}
c.mu.Lock()
if event == callAnswered {
c.terminal = answer
c.state = callDraining
} else {
if c.state != callDraining && c.over == nil {
c.over = why
}
c.queue, c.queued, c.owed = nil, 0, 0
if c.terminal == nil && why != nil {
m := message{Error: &struct {
Message string `json:"message"`
}{c.over.Error()}}
c.terminal = &m
}
if event == callCancelled && c.state == callActive && (c.stream || c.cancellable) {
c.state = callAborting
} else {
c.state = callReleased
}
}
c.stopped = true
if c.terminal != nil && c.closed.CompareAndSwap(false, true) {
c.done <- *c.terminal
}
c.mu.Unlock()
c.h.ctrlMu.Lock()
delete(c.h.ctrl, c.id)
if c.state == callAborting {
c.h.ctrl[c.id] = &ctrl{c: c, abort: true}
c.h.wakeCtrl()
}
c.h.ctrlMu.Unlock()
if c.state != callAborting {
delete(c.h.calls, c.id)
}
if c.state == callReleased {
c.h.releaseSlot(c)
}
// Readers may consume wake, so lifecycle changes broadcast to every
// writer-room waiter rather than competing for a reader notification.
if c.h.outSpace != nil {
close(c.h.outSpace)
c.h.outSpace = nil
}
c.notify()
if c.state == callDraining {
return nil
}
stop := c.stopCancel
c.stopCancel = nil
return stop
}
func (c *call) transition(event callEvent, why error, answer *message) {
c.h.mu.Lock()
stop := c.transitionLocked(event, why, answer)
c.h.mu.Unlock()
if stop != nil {
stop()
}
}
func (c *call) settle(why error) { c.transition(callCancelled, why, nil) }
func (c *call) release() { c.transition(callConsumed, nil, nil) }
func (c *call) result() *message {
c.mu.Lock()
defer c.mu.Unlock()
return c.terminal
}
// notify wakes the call's reader, without waiting for it.
func (c *call) notify() {
select {
case c.wake <- struct{}{}:
default:
}
}
// host is one Bun process running host.js.
type host struct {
cmd *exec.Cmd
in io.WriteCloser
// out carries messages to the single writer goroutine that owns the
// child's stdin. A caller enqueues and returns, so it is never parked in
// a blocked write; the queue is bounded by the call limit, so a wedged
// child applies backpressure instead of growing the backlog.
out chan writeReq
outSpace chan struct{}
mu sync.Mutex
calls map[int64]*call
next int64
dead chan struct{}
err error
loaded []Loaded
// renewing is how many sign-ins the host is renewing: stop lets them
// end, their new tokens saved, before it kills the host
renewing atomic.Int32
// slots bounds every live or retiring call together (see callLimit), so
// neither the calls nor the control backlog can grow without bound.
slots chan struct{}
// streams bounds the streaming calls to the budget's windows (see
// streamLimit), counted apart from slots so the RPC reserve is kept.
streams chan struct{}
// ctrl is the control work waiting for the child: credit to give back
// and fetches to abort. One goroutine (ctrlLoop) writes it, so a fetch's
// reader never blocks on the child's stdin; the backlog is bounded by
// slots, one entry a call, so no control message is dropped.
ctrlMu sync.Mutex
ctrl map[int64]*ctrl
ctrlWake chan struct{}
// ctx ends when the host does, so a control write waiting on the child
// gives up rather than parking on a host that is gone.
ctx context.Context
cancel context.CancelFunc
// maxLine overrides maxWireLine for this host; tests lower it.
maxLine int
// failOnce makes the one failure that ends the host run once, whatever
// noticed it (the stdout reader or the stdin writer).
failOnce sync.Once
// proxy is the proxy the host was started with (hostProxy), which
// host.js keeps for its life; proxyAt is when get last compared it
// with magpie's now (proxyMoved), zero for a host not started here.
proxy string
proxyAt time.Time
}
// lineLimit is the longest line this host reads from the child.
func (h *host) lineLimit() int {
if h.maxLine > 0 {
return h.maxLine
}
return maxWireLine
}
// headCost is a head's header cost: the UTF-8 bytes of every name and value,
// plus headEntryCost an entry. The child's side counts the same, so neither
// counts JSON escaping the other does not (a Link header's angle brackets, say)
// and a head one side takes is not refused by the other.
func headCost(h map[string]string) int {
n := 0
for k, v := range h {
n += len(k) + len(v) + headEntryCost
}
return n
}
// ctrl is a call's pending control work: credit coalesces, an abort is once.
type ctrl struct {
c *call
abort bool
credit int
}
// writeReq is one message for the writer: its result goes to done, when the
// sender needs it (a control abort, whose slot comes back once it is written).
type writeReq struct {
b []byte
done chan error
}
// Loaded is how a plugin fared when the host loaded it.
type Loaded struct {
Spec string `json:"spec"`
Error string `json:"error,omitempty"`
// Provides are the provider ids its auth hooks sign in to
Provides []string `json:"provides,omitempty"`
// ServedBy is, for each of them another plugin signs in to and
// serves, that plugin's spec: the host runs one plugin per provider
// id (Prefer)
ServedBy map[string]string `json:"servedBy,omitempty"`
// Tools are the tools its tool hook gives (tools.go)
Tools []Tool `json:"tools,omitempty"`
}
// Clash is a provider id more than one plugin signs in to. The host runs
// one of them for it: the one the user picked (Prefer), else the last in
// plugins.json, as OpenCode does. The others' sign-in to it goes unused.
type Clash struct {
ID string `json:"id"` // the provider id, OpenCode's
By string `json:"by"` // the spec of the plugin that serves it
// With are the other plugins that sign in to it
With []string `json:"with"`
}
// Clashes are the provider ids each loaded plugin shares with another,
// by the plugin's spec.
func Clashes(loaded []Loaded) map[string][]Clash {
who := map[string][]string{}
var ids []string
for _, l := range loaded {
for _, id := range l.Provides {
if who[id] == nil {
ids = append(ids, id)
}
who[id] = append(who[id], l.Spec)
}
}
out := map[string][]Clash{}
for _, id := range ids {
specs := who[id]
if len(specs) < 2 {
continue
}
by := ""
for _, l := range loaded {
if slices.Contains(specs, l.Spec) && l.ServedBy[id] == "" {
by = l.Spec
}
}
for _, s := range specs {
out[s] = append(out[s], Clash{ID: id, By: by, With: slices.DeleteFunc(slices.Clone(specs), func(x string) bool { return x == s })})
}
}
return out
}
var (
hostMu sync.Mutex
current *host
// generation counts the hosts started, so a caller can tell a
// restart happened (the provider cache is then stale)
generation atomic.Int64
// onChange are told a sign-in or the plugins changed.
onChange []func()
onChangeMu sync.Mutex
// onChangeNow are run before changed or listed returns (OnChangeNow)
onChangeNow []func()
)
// telling counts the hooks told of a change or of a sign-in (OnChange,
// OnSignIn) that are still running, for Told. It is a count, not a WaitGroup: a host can tell a change while
// Told waits.
var telling struct {
sync.Mutex
*sync.Cond
n int
}
func init() { telling.Cond = sync.NewCond(&telling.Mutex) }
// OnChange registers f to be told when a plugin sign-in or the plugins
// change (a sign-in saved or refreshed, a plugin added).
func OnChange(f func()) {
onChangeMu.Lock()
onChange = append(onChange, f)
onChangeMu.Unlock()
}
// OnChangeNow registers f to be run at once when a plugin sign-in, the
// plugins or their listing change, before anything after it is answered:
// an "auth" event is read before the reply to the sign-in that saved it,
// so a look made once the sign-in returns doesn't get what was built
// before it (#2449). OnChange's are told in the background, where a busy
// machine may run them after that look. f must be quick: the host's
// replies wait for it.
func OnChangeNow(f func()) {
onChangeMu.Lock()
onChangeNow = append(onChangeNow, f)
onChangeMu.Unlock()
}
var (
onSignInMu sync.Mutex
onSignIn func(provider, account, said string)
)
// OnSignIn registers f to be told what a plugin said, outside an answer,
// its account's sign-in is: "expired", "kept" or "renewed" (a models
// hook's error saying it).
func OnSignIn(f func(provider, account, said string)) {
onSignInMu.Lock()
onSignIn = f
onSignInMu.Unlock()
}
var (
onListedMu sync.Mutex
onListed []func()
)
// OnListed registers f to be told when the plugins have listed their
// providers and models anew and the list is another than the one kept
// before: a plugin signed in to lists its account's models only once the
// host has answered, after OnChange was told of the sign-in.
func OnListed(f func()) {
onListedMu.Lock()
onListed = append(onListed, f)
onListedMu.Unlock()
}
func listed() {
changedNow()
onListedMu.Lock()
fs := append([]func(){}, onListed...)
onListedMu.Unlock()
for _, f := range fs {
tell(f)
}
}
func changed() {
forgetProviders()
changedNow()
onChangeMu.Lock()
fs := append([]func(){}, onChange...)
onChangeMu.Unlock()
for _, f := range fs {
tell(f)
}
}
func changedNow() {
// the sign-ins are read again, even by a request holding the files it
// read (filememo.Hold): a sign-in just saved is in what comes next
filememo.Look(AuthPath())
onChangeMu.Lock()
fs := append([]func(){}, onChangeNow...)
onChangeMu.Unlock()
for _, f := range fs {
f()
}
}
// tell runs a hook in the background, counted in telling until it ends.
func tell(f func()) {
telling.Lock()
telling.n++
telling.Unlock()
go func() {
defer func() {
telling.Lock()
if telling.n--; telling.n == 0 {
telling.Broadcast()
}
telling.Unlock()
}()
f()
}()
}
// Restart stops the host, so the next call starts one with the plugins
// as they are now. A host answering calls (a reply streaming, a sign-in
// waiting for its browser) finishes them first, while the calls made
// from now on go to the new one: a plugin updating never cuts a reply.
func Restart() {
// the next host loads the plugins as they are now: what this magpie
// installed is not another magpie's change (checkList)
listSeen.Lock()
listSeen.stamp, listSeen.set = listStamp(), true
listSeen.Unlock()
hostMu.Lock()
h := current
current = nil
hostMu.Unlock()
if h != nil {
h.retire()
}
changed()
}
// retireWait is how long a retired host may go on answering its calls.
var retireWait = 10 * time.Minute
// retiring are the hosts finishing their calls before they stop.
var retiring = map[*host]bool{}
// retire stops h now when it answers no call, else once it has answered
// them (retireWait at most), in the background.
func (h *host) retire() {
if !h.busy() {
h.stop()
return
}
hostMu.Lock()
retiring[h] = true
hostMu.Unlock()
go func() {
defer func() {
hostMu.Lock()
delete(retiring, h)
hostMu.Unlock()
}()
end := time.Now().Add(retireWait)
for h.busy() && time.Now().Before(end) {
select {
case <-h.dead:
return
case <-time.After(250 * time.Millisecond):
}
}
h.stop()
}()
}
// busy is whether h is answering a call: a slot is held by every live or
// retiring call, so this counts a call whose body is still draining too.
func (h *host) busy() bool {
return len(h.slots) > 0
}
// Running is whether a host is up.
func Running() bool {
hostMu.Lock()
defer hostMu.Unlock()
return current != nil && current.alive()
}
func (h *host) alive() bool {
select {
case <-h.dead:
return false
default:
return true
}
}
// stopWait is how long a stopped host has to finish its calls; while it
// is renewing a sign-in it has up to stopRenewing in all, since a vendor
// that rotates its refresh token has already spent the old one, and the
// account is signed out unless the new one is saved.
var (
stopWait = 2 * time.Second
stopRenewing = 15 * time.Second
)
func (h *host) stop() {
h.in.Close()
start := time.Now()
wait := time.NewTimer(stopWait)
defer wait.Stop()
select {
case <-h.dead:
return
case <-wait.C:
}
tick := time.NewTicker(20 * time.Millisecond)
defer tick.Stop()
for h.renewing.Load() > 0 && time.Since(start) < stopRenewing {
select {
case <-h.dead:
return
case <-tick.C:
}
}
if h.cmd.Process != nil {
h.cmd.Process.Kill()
}
}
// hostFile is host.js written where Bun can run it.
func hostFile() (string, error) { return hostScript("host", hostJS) }
// hostScript is a script of the host written where Bun can run it, named
// by what it holds.
func hostScript(name string, js []byte) (string, error) {
sum := sha256.Sum256(js)
dir := filepath.Join(filepath.Dir(catalog.CachePath()), "plugin-host")
p := filepath.Join(dir, name+"-"+hex.EncodeToString(sum[:6])+".js")
if b, err := os.ReadFile(p); err == nil && string(b) == string(js) {
return p, nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, js, 0o644); err != nil {
return "", err
}
return p, os.Rename(tmp, p)
}
// get is the running host, started (Bun downloaded, the plugins loaded)
// when there is none.
func get(ctx context.Context) (*host, error) {
checkList()
hostMu.Lock()
defer hostMu.Unlock()
return getLocked(ctx)
}
// getLocked requires hostMu. Gateway hooks bound both the lock wait and
// startup with their request-hook budget.
func getLocked(ctx context.Context) (*host, error) {
if hostStale.Swap(false) && current != nil {
go current.retire()
current = nil
}
if current != nil && current.alive() && current.proxyMoved() {
go current.retire()
current = nil
}
if current != nil && current.alive() {
return current, nil
}
h, err := start(ctx)
if err != nil {
return nil, err
}
current = h
return h, nil
}
func start(ctx context.Context) (*host, error) {
bun, err := Bun(ctx)
if err != nil {
return nil, err
}
h, crashed, err := startOn(ctx, bun)
if crashed {
err = whyDied(ctx, bun, err)
}
if err == nil || !crashed || os.Getenv("MAGPIE_BUN") != "" {
// a Bun that can't be started at all isn't fallen back from: the
// one before is the same build for this system, and setting this
// one aside would mark a good version bad (#1877)
return h, cantStart(bun, err)
}
// the Bun magpie last took died starting the host: the one before it,
// and the new one set aside when that one starts it
prev, ok := fallBack()
if !ok {
return nil, err
}
h, _, perr := startOn(ctx, prev)
if perr != nil {
return nil, err
}
setAside(filepath.Base(filepath.Dir(bun)), fmt.Sprintf("the plugin host died on it: %s", err))
return h, nil
}
var (
catalogSyncing sync.WaitGroup
catalogCancelMu sync.Mutex
catalogCancels = map[int]context.CancelFunc{}
catalogCancelID int
syncCatalog = catalog.SyncIfMissing // a var for tests
)
func cancelCatalogSyncs() {
catalogCancelMu.Lock()
cancels := make([]context.CancelFunc, 0, len(catalogCancels))
for _, c := range catalogCancels {
cancels = append(cancels, c)
}
catalogCancels = map[int]context.CancelFunc{}
catalogCancelMu.Unlock()
for _, c := range cancels {
c()
}
catalogSyncing.Wait()
}
// startOn starts the host on the bun given; crashed is whether it died
// before it started.
func startOn(ctx context.Context, bun string) (*host, bool, error) {
js, err := hostFile()
if err != nil {
return nil, false, err
}
pi, err := hostScript("pi", piJS)
if err != nil {
return nil, false, err
}
if err := os.MkdirAll(settings.Dir(), 0o700); err != nil {
return nil, false, err
}
if catalog.Source() == "" {
// a plugin's provider has the models models.dev lists for it, as in
// OpenCode; sync in the background so starting the host never stalls
// on models.dev (#1922, #1928).
cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
catalogCancelMu.Lock()
catalogCancelID++
id := catalogCancelID
catalogCancels[id] = cancel
catalogCancelMu.Unlock()
catalogSyncing.Add(1)
go func() {
defer catalogSyncing.Done()
defer func() {
catalogCancelMu.Lock()
delete(catalogCancels, id)
catalogCancelMu.Unlock()
cancel()
}()
if err := syncCatalog(cctx); err == nil {
forgetProviders()
}
}()
}
// the host outlives the request that started it
proxy := hostProxy(env()) // what bunCommand gives it
cmd := bunCommand(context.Background(), bun, settings.Dir(), "run", js)
cmd.Env = hostEnv(cmd.Env)
in, err := cmd.StdinPipe()
if err != nil {
return nil, false, err
}
out, err := cmd.StdoutPipe()
if err != nil {
return nil, false, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, false, err
}
if err := cmd.Start(); err != nil {
return nil, false, err
}
hctx, hcancel := context.WithCancel(context.Background())
h := &host{
cmd: cmd, in: in,
calls: map[int64]*call{},
dead: make(chan struct{}),
slots: make(chan struct{}, callLimit()),
streams: make(chan struct{}, streamLimit()),
out: make(chan writeReq, callLimit()),
ctrl: map[int64]*ctrl{},
ctrlWake: make(chan struct{}, 1),
ctx: hctx,
cancel: hcancel,
proxy: proxy,
proxyAt: time.Now(),
}
go drainStderr(stderr)
go h.read(out)
go h.writerLoop()
go h.ctrlLoop()
generation.Add(1)
l := Load()
type item struct {
Spec string `json:"spec"`
Target string `json:"target"`
Options map[string]any `json:"options,omitempty"`
Gateway string `json:"gateway,omitempty"`
}
var items []item
for _, e := range l.Plugins {
// a plugin that is only gateway middleware or an agent runs in
// magpie (internal/middleware, internal/agentplug), not here
if !e.Off && !InMagpieOnly(Target(e.Spec)) {
items = append(items, item{e.Spec, Target(e.Spec), e.Options, Gateway(Target(e.Spec))})
}
}
var res struct {
Plugins []Loaded `json:"plugins"`
}
ictx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
catalogPath := catalog.Source()
if catalogPath == "" {
catalogPath = catalog.CachePath()
}
err = h.call(ictx, "init", map[string]any{
"authPath": AuthPath(),
"modelsDevPath": catalogPath,
"piPath": pi,
"directory": settings.Dir(),
"config": l.Config,
"prefer": l.Prefer,
"options": settings.Load().PluginOptions,