From f8f7cec8fa2ef8bfc42c33b9fc488043c35fbef4 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 21 Oct 2026 13:09:07 +0100 Subject: [PATCH] fix(box): login agents on a Mac run as interactive work, not background launchd throttled the CPU and disk of a Background job and of everything it started, so agents and their builds on a Mac ran slow. The plist now says Interactive. berthd and the laptop agent update an older plist of their own in place when they start (the file only; launchd reads it at the next load), so a berthd upgraded from the app catches up and the byte-for-byte checks (doctor's "starts at boot", ensureAgent) keep passing. Co-Authored-By: Claude Opus 5.5 --- cmd/berth/agentprocess.go | 5 ++++ cmd/berthd/main.go | 3 +++ docs/changelog.mdx | 10 ++++++++ internal/service/service.go | 40 +++++++++++++++++++++++++++++- internal/service/service_test.go | 42 ++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) diff --git a/cmd/berth/agentprocess.go b/cmd/berth/agentprocess.go index 491c58be6..de01022ce 100644 --- a/cmd/berth/agentprocess.go +++ b/cmd/berth/agentprocess.go @@ -97,6 +97,11 @@ func runAgent(l laptop) error { ctx, stop := signalContext() defer stop() cfg := agent.Config{Dir: l.dir, Socket: l.socket()} + if spec, err := agentService(l); err == nil { + if _, err := service.Migrate(spec); err != nil { + fmt.Fprintf(os.Stderr, "berth agent: updating its service: %v\n", err) + } + } // The app's Copy diagnostics shows what `berth doctor` would (GET /v1/doctor). cfg.Doctor = func(ctx context.Context) []doctor.Check { return laptopChecks(ctx, l) } // A second agent (a test, a throwaway home) needs its own ports. diff --git a/cmd/berthd/main.go b/cmd/berthd/main.go index 21194a018..949bab896 100644 --- a/cmd/berthd/main.go +++ b/cmd/berthd/main.go @@ -268,6 +268,9 @@ func serve(b boxHome, args []string) error { // A plist from an older install gives launchd's bare PATH, without // Homebrew's tmux; find it anyway. service.AugmentPATH() + if _, err := service.Migrate(daemonService(b, "")); err != nil { + fmt.Fprintf(os.Stderr, "berthd: updating its launchd plist: %v\n", err) + } } utf8Locale() if *listen == "" { diff --git a/docs/changelog.mdx b/docs/changelog.mdx index c1fad8604..e5058e21d 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -9,6 +9,16 @@ upgrades with `berth upgrade BOX`, or by running the [install command](/getting-started/add-a-box#with-the-install-command) again. +## Unreleased + +- **Agents on a Mac run at full speed.** Shipyard's login agents (the + Mac app's agent, and `berthd` on a Mac box) used to ask macOS to run + them as background work, which slows their CPU and disk, and with them + every agent and build they start. They now run as interactive work. + Updating the app applies it to this Mac right away. A Mac box updated + with **Update all** gets it the next time it restarts or you log in + there; running the install command again applies it at once. + ## v0.3.29 - **Removing a worktree no longer asks first.** Remove worktree in the diff --git a/internal/service/service.go b/internal/service/service.go index a8a968d6d..fdab1605e 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -152,7 +152,7 @@ func Render(s Spec) ([]byte, error) { RunAtLoad KeepAliveSuccessfulExit ThrottleInterval5 -ProcessTypeBackground +ProcessTypeInteractive `) if s.KeepChildren { b.WriteString("AbandonProcessGroup\n") @@ -191,6 +191,44 @@ func Render(s Spec) ([]byte, error) { func esc(s string) string { return html.EscapeString(s) } +// Lines older releases wrote, and what Render writes in their place now. +var migrations = []struct{ old, new string }{ + // Background throttles the CPU and disk of the service and of everything + // it starts, agents and their builds included, as they inherit it. + {"ProcessTypeBackground\n", "ProcessTypeInteractive\n"}, +} + +// Migrate brings the unit an older release wrote for s up to date in place, +// so it is Installed again, and says whether it changed it. A unit for +// another program or BERTH_HOME (another install, a test's) is left alone. +// Only the file changes: launchd reads it at the next load (a login, a +// reinstall), and a running service is never restarted for it. A berthd +// upgraded from the app swaps its binary without reinstalling, so this is +// how its plist catches up. +func Migrate(s Spec) (bool, error) { + u, ok, err := Read(s.Name) + if err != nil || !ok || u.Program != s.Program || u.Env["BERTH_HOME"] != s.Env["BERTH_HOME"] { + return false, err + } + have, err := os.ReadFile(u.Path) + if err != nil { + return false, err + } + path := u.Path + data := string(have) + for _, m := range migrations { + data = strings.ReplaceAll(data, m.old, m.new) + } + if data == string(have) { + return false, nil + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(data), 0o644); err != nil { + return false, err + } + return true, os.Rename(tmp, path) +} + func systemdArgs(args []string) string { quoted := make([]string, len(args)) for i, a := range args { diff --git a/internal/service/service_test.go b/internal/service/service_test.go index b5ecac16c..0956b6610 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -44,6 +44,7 @@ func TestUnitsRestartOnlyAfterFailure(t *testing.T) { "RunAtLoad", "/opt/berth/bin/berth\nagent", "BERTH_HOME/Users/alex/Library/Application Support/berth", + "ProcessTypeInteractive", } { if !strings.Contains(string(plist), want) { t.Errorf("plist missing %q:\n%s", want, plist) @@ -327,3 +328,44 @@ func TestPreflightOnAMacNeedsALoginSession(t *testing.T) { t.Errorf("without a GUI session: %v", err) } } + +func TestMigrateBringsAnOlderPlistUpToDateWithoutReloading(t *testing.T) { + home, calls := stub(t, "darwin") + plist, err := Render(spec) + if err != nil { + t.Fatal(err) + } + old := strings.Replace(string(plist), "Interactive", "Background", 1) + path := filepath.Join(home, "Library", "LaunchAgents", spec.Name+".plist") + os.MkdirAll(filepath.Dir(path), 0o755) + os.WriteFile(path, []byte(old), 0o644) + + other := spec + other.Program = "/somewhere/else/berth" + if changed, err := Migrate(other); err != nil || changed { + t.Fatalf("another program's plist: changed=%v err=%v", changed, err) + } + if Installed(spec) { + t.Fatal("an older plist counts as installed before Migrate") + } + if changed, err := Migrate(spec); err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + if !Installed(spec) { + data, _ := os.ReadFile(path) + t.Fatalf("after Migrate the plist is not what Render writes:\n%s", data) + } + if changed, err := Migrate(spec); err != nil || changed { + t.Fatalf("a second Migrate: changed=%v err=%v", changed, err) + } + if len(*calls) != 0 { + t.Fatalf("Migrate ran %v; it must not reload the service", *calls) + } +} + +func TestMigrateWithoutAPlist(t *testing.T) { + stub(t, "darwin") + if changed, err := Migrate(spec); err != nil || changed { + t.Fatalf("changed=%v err=%v", changed, err) + } +}