phraser: the startup timeout is a config field, and the arm has a test (V-323)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 11:38:51 +04:00
parent 1fe03f7a51
commit 82bd160c0d
2 changed files with 52 additions and 2 deletions
+21 -2
View File
@@ -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()))
}
}
+31
View File
@@ -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) {