Files
magpie/internal/plugin/host.go
T
Snowyy c38728145b plugin: a slow reader pauses its stream instead of racing a watchdog (#812)
* plugin: a slow reader pauses its stream instead of racing a watchdog

#664 gave a plugin reply a soft watermark (softQueuedBytes 4 MiB, softQueuedEvents 4096) and a 10-second idle watchdog: past the watermark, a reader that made no progress for stallIdle had its call failed with errStalledReader, and hardQueuedBytes/hardQueuedEvents (64 MiB / 65536) cut the reply outright. Both are guesses at how long a reader may take, and both end a reply that reader was still consuming.

The host now hands each fetch a credit window instead. Go grants streamWindow (512 KiB) up front and returns credit as the reader takes bytes, so the child stops producing at the window instead of being timed out; the window is charged per delivered byte plus frameOverhead (2048), so a run of tiny frames cannot fill it with metadata. streamBudget (64 MiB) and maxFrame (64 KiB) bound a whole reply and a single frame, and a child that sends past its window is a protocol violation (errProtocolOverrun) rather than a slow reader.

The plugin side grew the matching gate (makeGate, gate.take/spend/add, waitDrain): a fetch holds at the window until Go credits it, and its pending writes are cancellable. Cancellation and shutdown now have one owner per call - a five-state lifecycle (sending, active, draining, aborting, released) whose single transition helper disposes the queue, detaches the watcher, commits the terminal answer, creates the abort and returns capacity exactly once - so a host failure, a Close, a cancelled context and an already-delivered reply can no longer race each other over who cleans up. A reply the host already delivered keeps its bytes and its vendor outcome when the host dies.

Tests: the JS cancellation cases run the production sections with controlled stdout backpressure (stopped gate, split chunk, terminal suppression, acquired-body cancel, 30,000 cancelled bootstrap waits with no retained waiter or ready reaction); the Go cases drive lifecycle orderings directly (terminal-before-failure, failure-before-terminal, abort acknowledged before capacity returns, blocked enqueue), and the shutdown, body-close and mid-setup-cancel cases block on explicit channels instead of sleeping. TestFlowBigReplyProd reads 100 MiB non-SSE and SSE through the real host and checks lengths and SHA-256, including a 12 s reader pause. CI installs Bun 1.4.2 on Linux and runs that case in its own step, since the plugin sandbox skips when bun is not on PATH.

* plugin: the stream-head credit test waits for the credits before the reply ends (CI, macos-latest: TestHostStreamHeadChunksFinal failed on the macOS runner with "the credits were given back didn't happen", while the same package passed on ubuntu-latest and locally) — the test sent the child's final answer together with its 50 chunks, so the call could be marked stopped and its pending credits dropped before waitFor saw them; a completed fetch needs no credit, so waiting longer could not recover them and the expectation was a race, not a real accounting bug. The final answer is now withheld until io.ReadFull has taken the 140 payload bytes in order and exactly 140 + 49*frameOverhead credits have been observed with frameOverhead still owed, and only then does the child answer and the test assert a zero-byte EOF read. No production code changed.

Tests: go test -run '^TestHostStreamHeadChunksFinal$' -count=500 ./internal/plugin/ and -count=200 -race pass; go test -count=1 and -race -count=1 ./internal/plugin/... pass; gofmt -l and git diff --check clean.
2026-10-05 01:36:20 +08:00

1493 lines
41 KiB
Go

package plugin
import (
"bufio"
"context"
"crypto/sha256"
_ "embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/yetone/magpie/internal/catalog"
"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
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.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
}
// 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"`
}
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
)
// 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()
}
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()
}
func changed() {
forgetProviders()
onChangeMu.Lock()
fs := append([]func(){}, onChange...)
onChangeMu.Unlock()
for _, f := range fs {
go 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() {
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()
if hostStale.Swap(false) && current != nil {
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 err == nil || !crashed || os.Getenv("MAGPIE_BUN") != "" {
return h, 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
}
// 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; a magpie that has never fetched them does first
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
_ = catalog.Sync(cctx)
cancel()
}
// the host outlives the request that started 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,
}
go func() {
sc := bufio.NewScanner(stderr)
sc.Buffer(make([]byte, 64<<10), 4<<20)
for sc.Scan() {
log.Printf("plugin: %s", sc.Text())
}
}()
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"`
}
var items []item
for _, e := range l.Plugins {
if !e.Off {
items = append(items, item{e.Spec, Target(e.Spec), e.Options})
}
}
var res struct {
Plugins []Loaded `json:"plugins"`
}
ictx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
err = h.call(ictx, "init", map[string]any{
"authPath": AuthPath(),
"modelsDevPath": catalog.Source(),
"piPath": pi,
"directory": settings.Dir(),
"config": l.Config,
"plugins": items,
}, &res)
if err != nil {
crashed := !h.alive()
h.stop()
return nil, crashed, fmt.Errorf("starting plugins: %w", err)
}
h.loaded = res.Plugins
for _, p := range res.Plugins {
if p.Error != "" {
log.Printf("plugin %s didn't load: %s", p.Spec, p.Error)
}
}
return h, false, nil
}
func (h *host) read(out io.Reader) {
r := bufio.NewReaderSize(out, 1<<20)
for {
line, err := readLine(r, h.lineLimit())
if len(line) > 0 {
var m message
if json.Unmarshal(line, &m) == nil {
h.dispatch(m)
}
}
if errors.Is(err, errLineTooLong) {
// a child that never ends a line: stop it rather than allocate
h.fail(fmt.Errorf("the plugin host sent a line longer than %d bytes", h.lineLimit()))
if h.cmd.Process != nil {
_ = h.cmd.Process.Kill()
}
break
}
if err != nil {
break
}
}
err := h.cmd.Wait()
if err == nil {
err = errors.New("the plugin host quit")
}
h.fail(fmt.Errorf("the plugin host quit: %v", err))
}
// readLine reads one '\n'-terminated line, at most max bytes, so a child that
// never ends a line cannot make the host allocate without bound. It is
// errLineTooLong once max is passed.
func readLine(r *bufio.Reader, max int) ([]byte, error) {
var line []byte
for {
part, err := r.ReadSlice('\n')
if len(line)+len(part) > max {
return nil, errLineTooLong
}
line = append(line, part...)
if errors.Is(err, bufio.ErrBufferFull) {
continue
}
return line, err
}
}
func (h *host) dispatch(m message) {
if m.ID == 0 {
switch m.Event {
case "auth":
changed()
case "renewing":
h.renewing.Store(int32(m.Count))
case "signIn":
onSignInMu.Lock()
f := onSignIn
onSignInMu.Unlock()
if f != nil {
go f(m.Provider, m.Account, m.Said)
}
case "toast":
log.Printf("plugin: %s %s", m.Title, m.Message)
case "log":
log.Printf("plugin [%s]: %s", m.Level, m.Message)
}
return
}
h.mu.Lock()
c := h.calls[m.ID]
h.mu.Unlock()
if c == nil {
return
}
if m.Event != "" {
if c.stream {
c.push(m)
}
return
}
c.transition(callAnswered, nil, &m)
}
// enqueue hands one message to the writer goroutine. It waits only for room in
// the bounded queue, honoring ctx and the host's own end, so a caller is never
// parked in a blocked write and a host that is gone makes it return. Messages
// keep their order, so an abort is written after the fetch it belongs to.
func (h *host) enqueue(ctx context.Context, v any) error {
return h.enqueueDone(ctx, v, nil)
}
// enqueueDone is enqueue, asking the writer to report the write's result on
// done (nil for a write that succeeded).
func (h *host) enqueueDone(ctx context.Context, v any, done chan error) error {
return h.enqueueCall(ctx, v, done, nil)
}
// enqueueCall commits acceptance with the lifecycle while holding h.mu.
// Waiting for writer room owns no lifecycle lock, so cancellation can
// release an unsent call and the next attempt cannot enqueue it.
func (h *host) enqueueCall(ctx context.Context, v any, done chan error, c *call) error {
return h.enqueueOwned(ctx, v, done, c, callSending)
}
func (h *host) enqueueOwned(ctx context.Context, v any, done chan error, c *call, state callState) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
req := writeReq{b: append(b, '\n'), done: done}
for {
h.mu.Lock()
if !h.alive() {
h.mu.Unlock()
return errHostGone
}
if err := ctx.Err(); err != nil {
h.mu.Unlock()
return err
}
if c != nil && c.state != state {
h.mu.Unlock()
if err := c.givenUp(); err != nil {
return err
}
return errHostGone
}
select {
case h.out <- req:
if c != nil && state == callSending {
c.transitionLocked(callAccepted, nil, nil)
}
h.mu.Unlock()
return nil
default:
}
if h.outSpace == nil {
h.outSpace = make(chan struct{})
}
space := h.outSpace
h.mu.Unlock()
select {
case <-space:
case <-ctx.Done():
return ctx.Err()
case <-h.dead:
return errHostGone
}
}
}
func (h *host) tookWrite() {
h.mu.Lock()
if h.outSpace != nil {
close(h.outSpace)
h.outSpace = nil
}
h.mu.Unlock()
}
// writerLoop is the one goroutine that writes the child's stdin, until the
// host is gone or a write fails. A failed write fails the host, so nobody
// waiting on a write result is left waiting for a host that is already gone.
func (h *host) writerLoop() {
for {
select {
case <-h.dead:
return
case req := <-h.out:
h.tookWrite()
if !h.alive() {
if req.done != nil {
req.done <- errHostGone
}
return
}
_, err := h.in.Write(req.b)
if req.done != nil {
req.done <- err
}
if err != nil {
h.fail(fmt.Errorf("writing to the plugin host: %w", err))
return
}
}
}
}
// fail ends the host once: its context and dead channel, every message still
// queued for the writer answered with err, and every incomplete call let go —
// so no caller waits for a write that will never come, and a body nothing reads
// or closes cannot keep its buffer or slot.
func (h *host) fail(err error) {
h.failOnce.Do(func() {
h.mu.Lock()
if h.err == nil {
h.err = err
}
why := h.err
close(h.dead)
var stops []func() bool
for _, c := range h.calls {
if stop := c.transitionLocked(callHostFailed, why, nil); stop != nil {
stops = append(stops, stop)
}
}
h.mu.Unlock()
for _, stop := range stops {
stop()
}
h.cancel()
h.dropCtrl()
drain:
for {
select {
case req := <-h.out:
if req.done != nil {
req.done <- err
}
default:
break drain
}
}
})
}
// credit queues the bytes the reader gave back for a call. The control loop
// coalesces them with any already waiting, so a fast reader's many small
// credits become one write.
func (h *host) credit(c *call, n int) {
h.mu.Lock()
defer h.mu.Unlock()
h.ctrlMu.Lock()
if !h.alive() || c.state != callActive {
h.ctrlMu.Unlock()
return
}
ct := h.ctrl[c.id]
if ct == nil {
ct = &ctrl{c: c}
h.ctrl[c.id] = ct
}
ct.credit += n
h.ctrlMu.Unlock()
h.wakeCtrl()
}
// abort queues a fetch to stop. It is not dropped: the call keeps its slot
// until the abort is written (or the host dies), so a child that stopped
// reading its stdin cannot grow the backlog past the call limit.
func (h *host) abort(c *call) {
c.settle(context.Canceled)
}
func (h *host) wakeCtrl() {
select {
case h.ctrlWake <- struct{}{}:
default:
}
}
// ctrlLoop is the one goroutine that writes control work to the child, until
// the host is gone.
func (h *host) ctrlLoop() {
for {
select {
case <-h.dead:
h.dropCtrl()
return
case <-h.ctrlWake:
}
h.pumpCtrl()
}
}
// pumpCtrl writes the control work waiting, an abort before a credit, until
// none is left. A call settling with an abort owed keeps its slot until that
// abort's write is known (written, or failed with the host), so a child that
// stopped reading its stdin cannot grow the backlog past the call limit.
func (h *host) pumpCtrl() {
for {
h.ctrlMu.Lock()
var ct *ctrl
for id, v := range h.ctrl {
ct = v
delete(h.ctrl, id)
break
}
h.ctrlMu.Unlock()
if ct == nil {
return
}
if ct.abort {
done := make(chan error, 1)
if err := h.enqueueOwned(h.ctx, map[string]any{"method": "abort", "params": map[string]any{"id": ct.c.id}}, done, ct.c, callAborting); err == nil {
select {
case <-done:
case <-h.dead:
}
}
ct.c.transition(callAbortWritten, nil, nil)
}
if ct.credit > 0 {
_ = h.enqueueOwned(h.ctx, map[string]any{"method": "credit", "params": map[string]any{"id": ct.c.id, "n": ct.credit}}, nil, ct.c, callActive)
}
}
}
// dropCtrl drops work after lifecycle transitions have released failed calls.
func (h *host) dropCtrl() {
h.ctrlMu.Lock()
defer h.ctrlMu.Unlock()
for id := range h.ctrl {
delete(h.ctrl, id)
}
}
// begin takes a call's admission slot and registers it. It is errTooManyCalls
// when the host already holds its limit of live and retiring calls, so a new
// call fails clearly rather than a legitimate accepted reply being cut short.
func (h *host) begin(stream bool) (int64, *call, error) {
h.mu.Lock()
defer h.mu.Unlock()
if !h.alive() {
return 0, nil, errHostGone
}
c := &call{done: make(chan message, 1), stream: stream, wake: make(chan struct{}, 1), state: callSending}
if stream {
c.window = streamWindow
// a stream holds one window of the budget as well as a call slot, so
// the RPC reserve is never spent on one more stream
select {
case h.streams <- struct{}{}:
default:
return 0, nil, errTooManyCalls
}
}
select {
case h.slots <- struct{}{}:
default:
if stream {
<-h.streams
}
return 0, nil, errTooManyCalls
}
h.next++
id := h.next
c.h, c.id = h, id
h.calls[id] = c
return id, c, nil
}
// releaseSlot is called only by the transition that enters released.
func (h *host) releaseSlot(c *call) {
<-h.slots
if c.stream {
<-h.streams
}
}
// call asks the host method with params and reads its answer into out.
func (h *host) call(ctx context.Context, method string, params, out any) error {
id, c, err := h.begin(false)
if err != nil {
return err
}
if err := h.enqueueCall(ctx, map[string]any{"id": id, "method": method, "params": params}, nil, c); err != nil {
c.settle(err)
return err
}
select {
case m := <-c.done:
c.release()
if m.Error != nil {
return errors.New(m.Error.Message)
}
if out != nil && len(m.Result) > 0 {
return json.Unmarshal(m.Result, out)
}
return nil
case <-ctx.Done():
c.settle(ctx.Err())
return ctx.Err()
case <-h.dead:
// the host is gone: a waiter must not be left waiting for an answer
// that will never come, even with a context that never ends
m := c.result()
c.release()
if m != nil {
if m.Error != nil {
return errors.New(m.Error.Message)
}
if out != nil && len(m.Result) > 0 {
return json.Unmarshal(m.Result, out)
}
return nil
}
return errHostGone
}
}
// Call starts the host if need be and asks it method.
func Call(ctx context.Context, method string, params, out any) error {
return callWithTimeout(ctx, method, params, out, 0)
}
// Background listings allow the host its own startup budget. The optional
// timeout begins only after initialization and bounds just the requested RPC.
func callWithTimeout(ctx context.Context, method string, params, out any, timeout time.Duration) error {
startup := ctx
if timeout > 0 {
startup = context.WithoutCancel(ctx)
}
h, err := get(startup)
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
return h.call(ctx, method, params, out)
}
// Plugins is how each plugin fared when the host loaded it, starting the
// host if need be.
func Plugins(ctx context.Context) ([]Loaded, error) {
h, err := get(ctx)
if err != nil {
return nil, err
}
return h.loaded, nil
}
// FetchRequest is a request a provider's plugin makes.
type FetchRequest struct {
Provider string `json:"provider"`
Account string `json:"account,omitempty"` // the provider's first when ""
Model string `json:"model"`
NPM string `json:"npm"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Body []byte `json:"-"`
Session string `json:"session,omitempty"`
// Proxy is the provider's or the account's own proxy (netproxy.With):
// "direct", or its URL; "" follows magpie's. Fetch takes it from ctx
// when it isn't set.
Proxy string `json:"proxy,omitempty"`
}
// proxyOf is the proxy ctx names for the host: "" when it names none,
// "direct", or the proxy's URL (a SOCKS5 one bridged, as Bun can't use it).
func proxyOf(ctx context.Context) string { return forHost(netproxy.Choice(ctx)) }
// forHost is a proxy choice (netproxy.With's) as the host takes it.
func forHost(choice string) string {
switch c := strings.TrimSpace(choice); c {
case "", "direct":
return c
default:
if u, err := netproxy.Parse(c); err == nil {
return netproxy.ForBun(u.String())
}
return c
}
}
// hostEnv is env for the host, its *_PROXY named MAGPIE_*_PROXY: Bun
// reads *_PROXY once and puts every fetch through them, so a provider set
// to "direct" couldn't go around them. host.js gives each fetch the proxy
// they name instead. A SOCKS5 one, which Bun can't use, is bridged.
func hostEnv(env []string) []string {
out := make([]string, 0, len(env))
for _, e := range env {
k, v, _ := strings.Cut(e, "=")
switch strings.ToUpper(k) {
case "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY":
out = append(out, "MAGPIE_"+k+"="+netproxy.ForBun(v))
case "NO_PROXY":
out = append(out, "MAGPIE_"+k+"="+v)
default:
out = append(out, e)
}
}
return out
}
// Fetch sends r through the provider's plugin — its loader's fetch, or
// Bun's own when it gives none — and streams back the reply.
func Fetch(ctx context.Context, r FetchRequest) (*http.Response, error) {
h, err := get(ctx)
if err != nil {
return nil, err
}
if r.Proxy == "" {
r.Proxy = proxyOf(ctx)
}
id, c, err := h.begin(true)
if err != nil {
return nil, err
}
params := struct {
FetchRequest
Body []byte `json:"body,omitempty"`
Window int `json:"window"`
}{r, r.Body, streamWindow}
c.watchCtx(ctx)
err = h.enqueueCall(ctx, map[string]any{"id": id, "method": "fetch", "params": params}, nil, c)
if err != nil {
c.settle(err)
return nil, err
}
head, answer, err := c.headRead(ctx)
if err != nil {
// Settling either releases a completed answer or stops an active fetch.
c.settle(err)
return nil, err
}
b := &body{h: h, id: id, c: c, ctx: ctx, answer: answer, closed: make(chan struct{})}
res := &http.Response{
StatusCode: head.Status,
Status: fmt.Sprintf("%d %s", head.Status, http.StatusText(head.Status)),
Header: http.Header{},
Body: b,
Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1,
}
for k, v := range head.Headers {
res.Header.Set(k, v)
}
// the body is decoded already: fetch takes the Content-Encoding off
res.Header.Del("Content-Encoding")
res.Header.Del("Content-Length")
return res, nil
}
// errBodyClosed is what a read after the body was closed gets.
var errBodyClosed = errors.New("the reply's body was closed")
// headRead waits for the call's reply head, giving back the answer it read on
// the way: the body has to end with that answer too, error and all.
func (c *call) headRead(ctx context.Context) (head message, answer *message, err error) {
for {
if m, ok := c.pop(); ok {
return m, nil, nil
}
if err := c.givenUp(); err != nil {
return message{}, nil, err
}
if m := c.result(); m != nil {
if m.Error != nil {
return message{}, m, errors.New(m.Error.Message)
}
return message{}, m, errors.New("the plugin answered no response")
}
select {
case <-c.wake:
case m := <-c.done:
// the answer came with the head: keep it for the body
if hm, ok := c.pop(); ok {
return hm, &m, nil
}
if m.Error != nil {
return message{}, &m, errors.New(m.Error.Message)
}
return message{}, &m, errors.New("the plugin answered no response")
case <-ctx.Done():
return message{}, nil, ctx.Err()
case <-c.h.dead:
// the host is gone: no head is coming, so the fetch gives up
// rather than waiting on a context that may never end
continue
}
}
}
// body is a fetch's reply body. Its reader takes the chunks the call queued,
// in the order they came; whoever reads it does the waiting, so nothing of
// magpie's is parked while a consumer that stopped reading holds one. A
// cancelled ctx is noticed in Read and Close; a consumer that neither reads
// nor closes is not waited on, and the stall watchdog gives it up when its
// backlog passes the watermark.
type body struct {
h *host
id int64
c *call
ctx context.Context
buf []byte // the chunk in hand, not yet read
frame int // the frame in hand's overhead, credited when it is done
answer *message // the call's answer, once it came
closed chan struct{} // closed when the body is closed or given up on
once sync.Once
}
func (b *body) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil // a read that asks for nothing reads nothing
}
for {
// the end comes first, every time round: a closed body reads
// nothing more, and a cancel is not left behind what is queued
select {
case <-b.closed:
return 0, errBodyClosed
default:
}
if err := b.ctx.Err(); err != nil {
b.giveUp()
return 0, err
}
if len(b.buf) > 0 {
n := copy(p, b.buf)
b.buf = b.buf[n:]
// the bytes are the reader's now: give the child that much
// credit back, so a slow reader pauses its upstream and a fast
// one keeps it flowing
b.c.credit(n)
return n, nil
}
if m, ok := b.c.pop(); ok {
if b.frame > 0 {
// the frame that was in hand is done: its overhead comes back
// with the bytes already credited for it
b.c.credit(b.frame)
}
b.buf = m.raw
b.frame = frameOverhead
continue
}
if err := b.c.givenUp(); err != nil {
b.giveUp()
return 0, err
}
if b.answer != nil {
// the answer ends the reply: its error, or the end of it
b.c.release()
if b.answer.Error != nil {
return 0, errors.New(b.answer.Error.Message)
}
return 0, io.EOF
}
if m := b.c.result(); m != nil {
b.answer = m
continue
}
select {
case <-b.c.wake:
case m := <-b.c.done:
// the chunks came before the answer: what is still queued is
// read first, at the top of the loop
b.answer = &m
case <-b.closed:
return 0, errBodyClosed
case <-b.ctx.Done():
b.giveUp()
return 0, b.ctx.Err()
case <-b.h.dead:
continue
}
}
}
// Close gives the call up: what a consumer that stopped reading, or is
// through, calls. It never waits on the child.
func (b *body) Close() error {
b.giveUp()
return nil
}
// giveUp ends the call once, however many of the body's paths get there: what
// it queued is dropped, and the child is told to stop the fetch if this is
// what took the call out of the host's hands.
func (b *body) giveUp() {
b.once.Do(func() {
close(b.closed)
why := errBodyClosed
if err := context.Cause(b.ctx); err != nil {
why = err
}
b.c.settle(why)
})
}