From 6f05068291599b31f9cf3d79f8f2019476018227 Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:40:33 -0600 Subject: [PATCH 1/3] chore(release): v0.4.0 --- CHANGELOG.md | 12 ++++++++++++ README.md | 2 +- docs/roadmap.md | 4 ++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e0e4a..c1d73a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ has a matching GitHub release whose body comes from its section below. ## [Unreleased] +## [v0.4.0] - 2026-10-29 + +One berthd now governs every card on a machine. `berth run --gpu gpu1` puts an engine on one card, +`--gpu gpu0,gpu1` holds one lease on several, and `berth run --pinned` keeps a training run's cards. The gateway, +`berth status`, `berth notify`, `/metrics` and the harbor name the card, and one card can go to a VM while the +others serve. Engines with no offload call of their own, llama.cpp and NInfer included, park in RAM through +`cuda-checkpoint`. `whole_machine = true` is for an engine such as Strata that needs the card, the RAM and the CPU, +so nothing parks beside it. A Docker Compose project runs as one engine +([#172](https://github.com/T-Crypt/berth/issues/172)). An engine that can shrink a cache can lend part of its card +and keep serving (experimental, [#189](https://github.com/T-Crypt/berth/issues/189)). berthd uses less CPU, and the +AMD probe no longer stops berthd on a desktop session ([#196](https://github.com/T-Crypt/berth/issues/196)). + ### Added - The harbor names the model each lease serves on its card's panel: the model its engine reports loaded or ready, or diff --git a/README.md b/README.md index be577f3..c3206c4 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ claim. When another engine holds it, Berth: The numbered markers on the timeline are those four steps. A process that holds VRAM without a lease shows up as a stowaway: Berth reports its PID and memory and leaves it running. -> **Status: alpha, v0.3.6.** Runs daily on an RTX 4090 workstation beside llama-swap, NInfer, +> **Status: alpha, v0.4.0.** Runs daily on an RTX 4090 workstation beside llama-swap, NInfer, > Strata, ComfyUI and games, and testers run it on Windows. The [docs](docs/README.md) cover what works and has tests; the > [roadmap](docs/roadmap.md) lists the phases and what is still missing. diff --git a/docs/roadmap.md b/docs/roadmap.md index b81d310..1a2faf3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -61,9 +61,9 @@ desktop notifications on Linux, `berth skill install` adds a ComfyUI skill, and the card is free ([#172](https://github.com/T-Crypt/berth/issues/172)). [Swap numbers](benchmarks.md#swap-numbers) now has page cache, park and `cuda-checkpoint` timings from the RTX 4090. -## Shipped: v0.4, many cards and parks for any engine +## Shipped: v0.4, many cards and parks for any engine (2026-10-29) -v0.4 puts a whole machine under one berthd, first as pre-releases (`v0.4.0-rc.1` to `rc.7`, see +v0.4.0 puts a whole machine under one berthd. Testers ran it first as pre-releases (`v0.4.0-rc.1` to `rc.8`, see [Testing a pre-release](prerelease.md)). - **Many cards.** berthd and `berth service install` govern every card by default (`--gpu all`). From a95995df870e6191b8501bf42aa801c34fc43122 Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:53:41 -0600 Subject: [PATCH 2/3] fix(run): return only after the listener removes its unprotected record When berthd went away mid-run, the listener wrote an unprotected-run record and then waited to re-attach. If the engine exited in that wait, Run returned before the listener removed the record. A test that removed its temp dir right after Run returned failed with "directory not empty", which turned CI red on main after the v0.4.0 merge. Run now hangs up the first connection when the engine exits and waits for the listener to finish before it returns. --- internal/runner/park_test.go | 24 ++++++++++++++++++++++++ internal/runner/runner.go | 6 ++++++ 2 files changed, 30 insertions(+) diff --git a/internal/runner/park_test.go b/internal/runner/park_test.go index a7683ad..1dea18d 100644 --- a/internal/runner/park_test.go +++ b/internal/runner/park_test.go @@ -30,6 +30,7 @@ import ( "github.com/T-Crypt/berth/internal/park" "github.com/T-Crypt/berth/internal/procs" "github.com/T-Crypt/berth/internal/proto" + "github.com/T-Crypt/berth/internal/runs" "github.com/T-Crypt/berth/internal/server" ) @@ -601,6 +602,29 @@ func TestDrainOfAParkedEngineSaysWhy(t *testing.T) { // A parked engine whose daemon restarts comes back parked: the new daemon // grants a whole-card waiter beside its residual, and the engine is never // drained, where an active lease would be drained and stopped. +// An engine that exits while its daemon is gone leaves no unprotected-run +// record behind once Run returns. +func TestRunReturnsWithItsLostRecordGone(t *testing.T) { + card := &parkCard{total: 24 << 30} + sock := filepath.Join(t.TempDir(), "berth.sock") + b, stop := parkDaemonAt(t, sock, card, 16<<30) + exited, _ := runPark(t, sock, "vllm", "http://127.0.0.1:1", false) + pid := attachAndMeasure(t, b, card) + stopAtEnd(t, pid, exited) + + stop() + dir := runs.Dir(sock) + waitFor(t, "the lost record", func() bool { + left, _ := os.ReadDir(dir) + return len(left) > 0 + }) + _ = syscall.Kill(-pid, syscall.SIGKILL) + <-exited + if left, _ := os.ReadDir(dir); len(left) != 0 { + t.Fatalf("records left after Run returned: %v", left) + } +} + func TestParkedEngineReattachesParked(t *testing.T) { card := &parkCard{total: 24 << 30} sock := filepath.Join(t.TempDir(), "berth.sock") diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 2f60ec6..4cbc9bd 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -335,7 +335,12 @@ func Run(o Options) int { }() } + // Run returns only after the listener does, so an unprotected-run record + // it wrote is gone by then. + listening := make(chan struct{}) + go func(c net.Conn) { <-done; c.Close() }(raw) go func() { + defer close(listening) for { err := listen(conn, func(m proto.Msg) { switch { @@ -388,6 +393,7 @@ func Run(o Options) int { _ = line.send(proto.Msg{Op: proto.OpFinished}) } close(done) + <-listening return code } From b4c7823e6655734aa81e4eb607d25fca7c611eff Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:56:12 -0600 Subject: [PATCH 3/3] test(engines): date the Strata leaderboard fixture an hour ago The test pinned its requests to 2026-10-28 21:35 MDT. The watcher keeps 24 hours of requests, so from 2026-10-30 03:35 UTC it dropped both rows and the test failed on every branch. The fixture now sits an hour before the test runs, with the same zone and microseconds. --- internal/engines/strata_requests_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/engines/strata_requests_test.go b/internal/engines/strata_requests_test.go index 27dfbf4..ee84c7d 100644 --- a/internal/engines/strata_requests_test.go +++ b/internal/engines/strata_requests_test.go @@ -42,7 +42,9 @@ func strataServer(t *testing.T, model string, at time.Time, finish string) *http // under the model their /health names, whatever port they run on, and a // second poll of the same request list counts nothing twice. func TestStrataRequestsReachTheLeaderboard(t *testing.T) { - at := time.Date(2026, 10, 28, 21, 35, 21, 155911000, time.FixedZone("MDT", -6*60*60)) + // An hour ago, inside the watcher's 24h request window. + sec := time.Now().Add(-time.Hour).Unix() + at := time.Unix(sec, 155911000).In(time.FixedZone("MDT", -6*60*60)) iq4 := strataServer(t, "qwen3.8-flash-next-ud-iq4_xs", at, "stop") sc117 := strataServer(t, "qwen3.8-flash-next-sc117-iq3_s", at.Add(time.Second), "cancel") w := &Watcher{Engines: []config.Engine{ @@ -54,10 +56,10 @@ func TestStrataRequestsReachTheLeaderboard(t *testing.T) { got := w.Requests(time.Time{}) want := []proto.Request{ - {ID: "strata/1793244921.155911", Engine: "strata", Model: "qwen3.8-flash-next-ud-iq4_xs", T: at, Status: 200, + {ID: fmt.Sprintf("strata/%d.155911", sec), Engine: "strata", Model: "qwen3.8-flash-next-ud-iq4_xs", T: at, Status: 200, Prompt: 32000, Cache: 31552, Output: 388, Draft: 277, DraftAcc: 214, PrefillTPS: 448 / 0.6211, DecodeTPS: 95.5, DurationMS: 4700, PrefillMS: 621.1}, - {ID: "strata-sc117/1793244922.155911", Engine: "strata-sc117", Model: "qwen3.8-flash-next-sc117-iq3_s", T: at.Add(time.Second), Status: 499, + {ID: fmt.Sprintf("strata-sc117/%d.155911", sec+1), Engine: "strata-sc117", Model: "qwen3.8-flash-next-sc117-iq3_s", T: at.Add(time.Second), Status: 499, Prompt: 32000, Cache: 31552, Output: 388, Draft: 277, DraftAcc: 214, PrefillTPS: 448 / 0.6211, DecodeTPS: 95.5, DurationMS: 4700, PrefillMS: 621.1}, }