Files
yetone 05683637d5 plugins: the Bun the plugin host runs on is kept as magpie-bun, so a proxy app's PROCESS-NAME rule can route the requests plugins (Cursor and the other moved subscriptions) make (#1048)
magpie downloads its own Bun into ~/.cache/magpie/bun/<version>/. It is now
written there as magpie-bun (magpie-bun.exe on Windows), and a Bun downloaded
before is renamed in place the first time it is looked for, so nothing is
downloaded again and no second copy takes disk. The host, bun add and bun
update all run it, so all of their traffic carries the one name. A
$MAGPIE_BUN the user set is run as it is.

Verified: TestBunDownloadedAsMagpieBun, TestBunDownloadedBeforeTakesTheName
and TestHostRunsAsMagpieBun fail without the change (the Bun is .../bun, the
host runs .../bun). TestHostRunsAsMagpieBun starts the real host on a copy of
Bun 1.4.2 and `ps -o comm=` names it magpie-bun on macOS. On the Windows box
the three tests pass with the Windows build of Bun 1.4.2, a running bun.exe
renames to magpie-bun.exe, and Get-Process/Win32_Process name it magpie-bun.
Linux not run here (CI's ubuntu job runs the test with Bun on PATH).
go test -tags nogui ./... green; -race -count=10 on the new tests green.
2026-10-07 01:37:31 +08:00

309 lines
8.8 KiB
Go

package plugin
// Bun kept up to date, as the plugins are: every six hours magpie asks
// GitHub for Bun's newest release and, once it has been out two days (a
// release with a bad bug is usually followed by a fix within them),
// downloads it, checks its checksum, tries it and switches the plugin
// host to it, a reply streaming through the old host finishing first.
// BunVersion is the floor: magpie never runs an older Bun. The Bun it ran
// before stays on disk, and a new one the host won't start on is set
// aside for the one before, never tried again.
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/steady"
"github.com/yetone/magpie/internal/update"
)
// bunSettle is how long a Bun release is out before magpie takes it.
const bunSettle = 48 * time.Hour
// bunState is what magpie keeps about the Bun it runs, in bun/state.json.
type bunState struct {
// Current is the Bun the host runs on, "" for BunVersion.
Current string `json:"current,omitempty"`
// Previous is the one it ran on before, kept to fall back on.
Previous string `json:"previous,omitempty"`
// Bad are versions that failed their try or the host's start.
Bad []string `json:"bad,omitempty"`
Checked time.Time `json:"checked,omitzero"`
}
func bunRoot() string { return filepath.Join(filepath.Dir(catalog.CachePath()), "bun") }
func bunDirOf(v string) string { return filepath.Join(bunRoot(), v) }
// bunExeOf is where magpie keeps Bun v: named magpie-bun, so a proxy app's
// PROCESS-NAME rule can tell the requests plugins make (and their installs)
// from any other Bun's (#1048).
func bunExeOf(v string) string { return filepath.Join(bunDirOf(v), magpieBun()) }
func magpieBun() string { return "magpie-" + bunExe() }
func bunStatePath() string { return filepath.Join(bunRoot(), "state.json") }
func readBunState() bunState {
var s bunState
if b, err := steady.ReadFile(bunStatePath()); err == nil {
_ = json.Unmarshal(b, &s)
}
return s
}
func writeBunState(s bunState) error {
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(bunRoot(), 0o755); err != nil {
return err
}
return writeWhole(bunStatePath(), b)
}
func haveBun(v string) bool {
exe := bunExeOf(v)
if _, err := os.Stat(exe); err == nil {
return true
}
// a Bun downloaded before it was named magpie-bun takes the name
_ = os.Rename(filepath.Join(bunDirOf(v), bunExe()), exe)
_, err := os.Stat(exe)
return err == nil
}
// usable is whether v may be run: no older than the floor, not set aside.
func (s bunState) usable(v string) bool {
return v != "" && !update.Newer(BunVersion, v) && !slices.Contains(s.Bad, v)
}
// BunInUse is the version of the Bun plugins run on (or will, once it is
// downloaded): the newest magpie took, else BunVersion.
func BunInUse() string {
if b := os.Getenv("MAGPIE_BUN"); b != "" {
bunMu.Lock()
defer bunMu.Unlock()
if _, ok := envBunVersion[b]; !ok {
envBunVersion[b] = bunReported(b)
}
return envBunVersion[b]
}
bunMu.Lock()
defer bunMu.Unlock()
return inUseLocked()
}
func inUseLocked() string {
s := readBunState()
if s.usable(s.Current) && haveBun(s.Current) {
return s.Current
}
return BunVersion
}
// envBunVersion is what each $MAGPIE_BUN said it is, asked once.
var envBunVersion = map[string]string{}
var bunVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
// bunReported is the version a bun says it is, "" when it won't say.
func bunReported(exe string) string {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := command(ctx, exe, "--version").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// bunLatest is Bun's newest release and when it was published (tests stub it).
var bunLatest = func(ctx context.Context) (string, time.Time, error) {
b, err := getURL(ctx, "https://api.github.com/repos/oven-sh/bun/releases/latest", 4<<20)
if err != nil {
return "", time.Time{}, err
}
var r struct {
Tag string `json:"tag_name"`
Published time.Time `json:"published_at"`
Draft bool `json:"draft"`
Pre bool `json:"prerelease"`
}
if err := json.Unmarshal(b, &r); err != nil {
return "", time.Time{}, err
}
v := strings.TrimPrefix(r.Tag, "bun-v")
if r.Draft || r.Pre || !bunVersionRe.MatchString(v) {
return "", time.Time{}, fmt.Errorf("Bun's latest release is %q", r.Tag)
}
return v, r.Published, nil
}
// tryBun runs a downloaded Bun on what the host needs of it: it says its
// version, and runs a script with fetch, AsyncLocalStorage and the file
// system (tests stub it).
var tryBun = func(ctx context.Context, exe, v string) error {
if got := bunReported(exe); got != v {
return fmt.Errorf("it says it is %q", got)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
script := `const { AsyncLocalStorage } = require("node:async_hooks"); const fs = require("node:fs"); const s = new AsyncLocalStorage();` +
`s.run(1, async () => { await Promise.resolve(); if (s.getStore() !== 1 || typeof fetch !== "function" || !fs.existsSync(process.execPath)) process.exit(3); console.log("ok") })`
out, err := command(ctx, exe, "-e", script).Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(ee.Stderr)))
}
return err
}
if strings.TrimSpace(string(out)) != "ok" {
return fmt.Errorf("it printed %q", strings.TrimSpace(string(out)))
}
return nil
}
// CheckBun takes Bun's newest release when it is newer than the one in
// use, out two days and not set aside: downloaded, checked, tried, then
// switched to, the host restarted gracefully. It says the version taken,
// "" when none was.
func CheckBun(ctx context.Context) (string, error) {
if os.Getenv("MAGPIE_BUN") != "" {
return "", nil
}
latest, published, err := bunLatest(ctx)
if err != nil {
return "", err
}
bunMu.Lock()
s := readBunState()
s.Checked = time.Now().UTC().Truncate(time.Second)
use := inUseLocked()
if !update.Newer(latest, use) || !s.usable(latest) || time.Since(published) < bunSettle {
err := writeBunState(s)
bunMu.Unlock()
return "", err
}
bunMu.Unlock()
exe := bunExeOf(latest)
if !haveBun(latest) {
if err := downloadBun(ctx, latest, exe); err != nil {
return "", fmt.Errorf("downloading Bun %s: %w", latest, err)
}
}
if err := tryBun(ctx, exe, latest); err != nil {
if ctx.Err() != nil {
return "", err
}
setAside(latest, fmt.Sprintf("its try failed: %s", err))
return "", fmt.Errorf("Bun %s: %w", latest, err)
}
bunMu.Lock()
s = readBunState()
if use != BunVersion || haveBun(BunVersion) {
s.Previous = use
}
s.Current = latest
s.Checked = time.Now().UTC().Truncate(time.Second)
err = writeBunState(s)
if err == nil {
pruneBuns(s)
}
bunMu.Unlock()
if err != nil {
return "", err
}
log.Printf("plugins: Bun %s → %s", use, latest)
if Running() {
Restart()
}
return latest, nil
}
// setAside marks v as not to be run again and goes back to the Bun before
// it, removing it from disk.
func setAside(v, why string) {
log.Printf("plugins: Bun %s set aside, %s", v, why)
bunMu.Lock()
defer bunMu.Unlock()
s := readBunState()
if !slices.Contains(s.Bad, v) {
s.Bad = append(s.Bad, v)
}
if s.Current == v {
s.Current, s.Previous = s.Previous, ""
}
_ = writeBunState(s)
_ = os.RemoveAll(bunDirOf(v))
}
// fallBack is the Bun to start the host on when it wouldn't start on the
// one in use: the one before, when there is one to go back to.
func fallBack() (string, bool) {
bunMu.Lock()
defer bunMu.Unlock()
s := readBunState()
if s.Current == "" || !s.usable(s.Current) {
return "", false
}
prev := s.Previous
if prev == "" {
prev = BunVersion
}
if prev == s.Current || !s.usable(prev) || !haveBun(prev) {
return "", false
}
return bunExeOf(prev), true
}
// pruneBuns removes the Buns neither in use nor kept to fall back on.
func pruneBuns(s bunState) {
keep := map[string]bool{s.Current: true, s.Previous: true}
if s.Previous == "" {
keep[BunVersion] = true
}
es, _ := os.ReadDir(bunRoot())
for _, e := range es {
if e.IsDir() && bunVersionRe.MatchString(e.Name()) && !keep[e.Name()] {
_ = os.RemoveAll(filepath.Join(bunRoot(), e.Name()))
}
}
}
// KeepBunUpdated looks for a newer Bun a little after magpie starts, then
// every bunEvery, while there are plugins to run on it.
func KeepBunUpdated(ctx context.Context) {
t := time.NewTimer(2 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
if len(Load().Plugins) > 0 && HasBun() {
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
if _, err := CheckBun(cctx); err != nil {
log.Printf("plugins: looking for a newer Bun: %s", err)
}
cancel()
}
t.Reset(bunEvery)
}
}