From 82bd160c0de5280beab6e810ba51eca21910a892 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 11:38:51 +0400 Subject: [PATCH] phraser: the startup timeout is a config field, and the arm has a test (V-323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 60s wait for llama-server's listen line was hardcoded, so the last arm of the startup race could not be tested without waiting a real minute, and a box where a cold 1.7B loads off spinning disk had no way to raise it. Config.StartupTimeout, defaulted to 60s. The test drives the arm at 200ms against a fake server that never listens, and asserts the child is killed and reaped — that arm leaks a llama-server still loading a model otherwise. startLlamaProc 90.9% → 96.0%, package 76.9% → 77.6%. Co-Authored-By: Claude Opus 5 --- internal/phraser/llmphraser.go | 23 +++++++++++++++++++++-- internal/phraser/spawn_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 2c5e28d..1fcfd4f 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -85,6 +85,15 @@ type Config struct { NCtx int Timeout time.Duration + // StartupTimeout bounds the wait for llama-server to print the address it + // listens on. A config field and not a constant because the box may + // legitimately need longer: a cold 1.7B loading off a spinning disk can + // outrun a minute, and until this existed that returned "server did not + // start within 60s" with no way to raise it. + // + // 0 ⇒ defaultStartupTimeout. + StartupTimeout time.Duration + // CacheRAMMiB bounds llama-server's prompt cache, which is what actually ate // this box. Measured on homesrv 2026-08-03: the server's own default limit is // 8192 MiB, it stores the full KV state of every idle slot it evicts (112 kiB @@ -134,6 +143,8 @@ func DefaultConfig(modelPath string) Config { // 512 MiB caps total RSS near 1 GB and still holds several recent prompts. CacheRAMMiB: 512, Timeout: 30 * time.Second, + + StartupTimeout: defaultStartupTimeout, } } @@ -260,7 +271,15 @@ func llamaArgs(cfg Config) []string { return args } +// defaultStartupTimeout — the wait for llama-server's listen line when Config +// does not set one. A cold model load off disk is the slow part. +const defaultStartupTimeout = 60 * time.Second + func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) { + startupTimeout := cfg.StartupTimeout + if startupTimeout <= 0 { + startupTimeout = defaultStartupTimeout + } p := &llamaProc{} cmd := exec.CommandContext(ctx, cfg.BinPath, llamaArgs(cfg)...) // Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by @@ -338,8 +357,8 @@ func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) { return fail(fmt.Errorf("llm: server output: %w; last output: %s", err, tail.String())) case <-ctx.Done(): return fail(ctx.Err()) - case <-time.After(60 * time.Second): - return fail(fmt.Errorf("llm: server did not start within 60s; last output: %s", tail.String())) + case <-time.After(startupTimeout): + return fail(fmt.Errorf("llm: server did not start within %s; last output: %s", startupTimeout, tail.String())) } } diff --git a/internal/phraser/spawn_test.go b/internal/phraser/spawn_test.go index 636a7e9..e89b7f8 100644 --- a/internal/phraser/spawn_test.go +++ b/internal/phraser/spawn_test.go @@ -181,6 +181,37 @@ exit 1`) t.Fatalf("err = %v, want context.Canceled", err) } }) + + // The last arm of the startup race, and the one most likely to leak: a + // llama-server still loading a model is alive, so giving up on it without + // killing and reaping it orphans a process holding the GPU. Testable at all + // because Config.StartupTimeout replaced a hardcoded 60s (Vikunja #323). + t.Run("startup timeout", func(t *testing.T) { + pidPath := filepath.Join(t.TempDir(), "pid") + bin := fakeLlama(t, fmt.Sprintf(`echo $$ > %s +while : ; do sleep 1 ; done`, pidPath)) + cfg := testCfg(bin) + cfg.StartupTimeout = 200 * time.Millisecond + + _, err := startLlamaProc(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "did not start within 200ms") { + t.Fatalf("err = %v, want the startup-timeout arm naming the timeout", err) + } + + raw, readErr := os.ReadFile(pidPath) + if readErr != nil { + t.Fatalf("fake server never recorded its pid: %v", readErr) + } + pid, convErr := strconv.Atoi(strings.TrimSpace(string(raw))) + if convErr != nil { + t.Fatalf("pid file = %q: %v", raw, convErr) + } + // Killed, and reaped: a zombie still answers signal 0, so this asserts + // the Wait ran too. + if err := syscall.Kill(pid, 0); err == nil { + t.Errorf("llama-server %d survived the startup timeout", pid) + } + }) } func TestNewLLMPhraserSpawns(t *testing.T) {