feat(slm): Wave B — Manager, Manifest, download, subprocess lifecycle
- Manifest: JSON read/write with atomic rename; presence = ready invariant - download: HTTP fetch with SHA256 computation, progress callback, cleanup on failure - Manager: Status (NotSetUp/Ready/Missing), Setup (download + manifest write), Start (freePort, exec, PID file, health check), Stop, BaseURL - waitHealthy: polls /health with 15s ceiling and context cancellation - reapStalePID: kills stale process from previous run on next Start - 28 tests; all pass
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package slm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// download fetches url to dst and returns the hex SHA256 and byte count of the download.
|
||||
// Partial files are deleted on any failure.
|
||||
// progress receives (downloaded, total) bytes; total is -1 if the server doesn't report Content-Length.
|
||||
func download(ctx context.Context, url, dst string, progress func(downloaded, total int64)) (sha256hex string, size int64, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("slm: download: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("slm: download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", 0, fmt.Errorf("slm: download: unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0700)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("slm: download: create file: %w", err)
|
||||
}
|
||||
|
||||
ok := false
|
||||
defer func() {
|
||||
f.Close()
|
||||
if !ok {
|
||||
os.Remove(dst)
|
||||
}
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
pr := &progressReader{r: resp.Body, total: resp.ContentLength, fn: progress}
|
||||
n, err := io.Copy(io.MultiWriter(f, h), pr)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("slm: download: write: %w", err)
|
||||
}
|
||||
|
||||
ok = true
|
||||
return hex.EncodeToString(h.Sum(nil)), n, nil
|
||||
}
|
||||
|
||||
// hashFile computes the SHA256 of the file at path and returns the hex digest and size.
|
||||
func hashFile(path string) (sha256hex string, size int64, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, f)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, nil
|
||||
}
|
||||
|
||||
type progressReader struct {
|
||||
r io.Reader
|
||||
total int64
|
||||
read int64
|
||||
fn func(downloaded, total int64)
|
||||
}
|
||||
|
||||
func (p *progressReader) Read(b []byte) (int, error) {
|
||||
n, err := p.r.Read(b)
|
||||
p.read += int64(n)
|
||||
if p.fn != nil {
|
||||
p.fn(p.read, p.total)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package slm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDownload_Success(t *testing.T) {
|
||||
content := []byte("hello llamafile")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write(content)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "model.llamafile")
|
||||
var lastDownloaded, lastTotal int64
|
||||
sha256hex, size, err := download(context.Background(), srv.URL, dst, func(d, total int64) {
|
||||
lastDownloaded, lastTotal = d, total
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
|
||||
h := sha256.Sum256(content)
|
||||
want := hex.EncodeToString(h[:])
|
||||
if sha256hex != want {
|
||||
t.Errorf("SHA256: got %s, want %s", sha256hex, want)
|
||||
}
|
||||
if size != int64(len(content)) {
|
||||
t.Errorf("size: got %d, want %d", size, len(content))
|
||||
}
|
||||
if lastDownloaded != int64(len(content)) {
|
||||
t.Errorf("progress last downloaded: got %d, want %d", lastDownloaded, len(content))
|
||||
}
|
||||
_ = lastTotal // may be -1 if server doesn't set Content-Length
|
||||
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("read dst: %v", err)
|
||||
}
|
||||
if string(data) != string(content) {
|
||||
t.Errorf("file content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_NonOKStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "model.llamafile")
|
||||
_, _, err := download(context.Background(), srv.URL, dst, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error for 404, got nil")
|
||||
}
|
||||
// Partial file must be cleaned up.
|
||||
if _, statErr := os.Stat(dst); !os.IsNotExist(statErr) {
|
||||
t.Error("partial file should have been deleted on failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_ContextCancel(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "no flusher", 500)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
<-done // block until test cancels
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer close(done)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "model.llamafile")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
_, _, err := download(ctx, srv.URL, dst, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want error on cancelled context, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_NilProgress(t *testing.T) {
|
||||
content := []byte("data")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write(content)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "model.llamafile")
|
||||
_, _, err := download(context.Background(), srv.URL, dst, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("download with nil progress: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashFile(t *testing.T) {
|
||||
content := []byte("test content for hashing")
|
||||
f, err := os.CreateTemp(t.TempDir(), "hash*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Write(content)
|
||||
f.Close()
|
||||
|
||||
h := sha256.Sum256(content)
|
||||
want := hex.EncodeToString(h[:])
|
||||
|
||||
got, size, err := hashFile(f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("hashFile: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("SHA256: got %s, want %s", got, want)
|
||||
}
|
||||
if size != int64(len(content)) {
|
||||
t.Errorf("size: got %d, want %d", size, len(content))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package slm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const pidFile = "llamafile.pid"
|
||||
|
||||
// Status describes the setup state of the SLM.
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusNotSetUp Status = iota // no manifest on disk
|
||||
StatusReady // manifest + binary file both exist
|
||||
StatusMissing // manifest exists but binary file is gone
|
||||
)
|
||||
|
||||
func (s Status) String() string {
|
||||
switch s {
|
||||
case StatusNotSetUp:
|
||||
return "not set up"
|
||||
case StatusReady:
|
||||
return "ready"
|
||||
case StatusMissing:
|
||||
return "file missing"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Config holds Manager configuration.
|
||||
type Config struct {
|
||||
DataDir string // XDG data home / gnoma / slm; must be set
|
||||
ModelURL string // required for Setup
|
||||
}
|
||||
|
||||
// Manager controls the llamafile lifecycle.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
process *os.Process
|
||||
port int
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a Manager. DataDir must be non-empty.
|
||||
func New(cfg Config, logger *slog.Logger) *Manager {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Manager{cfg: cfg, logger: logger}
|
||||
}
|
||||
|
||||
// IsSetUp returns true when Status() == StatusReady.
|
||||
func (m *Manager) IsSetUp() bool {
|
||||
return m.Status() == StatusReady
|
||||
}
|
||||
|
||||
// Status returns the current setup state by inspecting the manifest and filesystem.
|
||||
func (m *Manager) Status() Status {
|
||||
mf, err := readManifest(m.cfg.DataDir)
|
||||
if err != nil {
|
||||
return StatusNotSetUp
|
||||
}
|
||||
if _, err := os.Stat(mf.FilePath); err != nil {
|
||||
return StatusMissing
|
||||
}
|
||||
return StatusReady
|
||||
}
|
||||
|
||||
// Setup downloads the llamafile from ModelURL, verifies the hash, and writes the manifest.
|
||||
// progress receives (downloaded, total) byte counts; may be nil.
|
||||
func (m *Manager) Setup(ctx context.Context, progress func(downloaded, total int64)) error {
|
||||
if m.cfg.ModelURL == "" {
|
||||
return fmt.Errorf("slm: ModelURL is required")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(m.cfg.DataDir, 0700); err != nil {
|
||||
return fmt.Errorf("slm: create data dir: %w", err)
|
||||
}
|
||||
|
||||
name := filepath.Base(m.cfg.ModelURL)
|
||||
if name == "" || name == "." {
|
||||
name = "llamafile"
|
||||
}
|
||||
dst := filepath.Join(m.cfg.DataDir, name)
|
||||
|
||||
m.logger.Info("downloading llamafile", "url", m.cfg.ModelURL, "dst", dst)
|
||||
|
||||
sha256hex, size, err := download(ctx, m.cfg.ModelURL, dst, progress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mf := &Manifest{
|
||||
ModelURL: m.cfg.ModelURL,
|
||||
FilePath: dst,
|
||||
SHA256: sha256hex,
|
||||
Size: size,
|
||||
SetupAt: time.Now().UTC(),
|
||||
}
|
||||
return writeManifest(m.cfg.DataDir, mf)
|
||||
}
|
||||
|
||||
// Start launches the llamafile subprocess and returns its base URL.
|
||||
// Reaps a stale PID file from a previous run if present.
|
||||
func (m *Manager) Start(ctx context.Context) (string, error) {
|
||||
mf, err := readManifest(m.cfg.DataDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("slm: not set up: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(mf.FilePath); err != nil {
|
||||
return "", fmt.Errorf("slm: llamafile missing at %s", mf.FilePath)
|
||||
}
|
||||
|
||||
m.reapStalePID()
|
||||
|
||||
port, err := freePort()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("slm: find free port: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, mf.FilePath,
|
||||
"--server",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", strconv.Itoa(port),
|
||||
"--nobrowser",
|
||||
)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("slm: start llamafile: %w", err)
|
||||
}
|
||||
|
||||
m.process = cmd.Process
|
||||
m.port = port
|
||||
|
||||
if err := os.WriteFile(m.pidPath(), []byte(strconv.Itoa(cmd.Process.Pid)), 0600); err != nil {
|
||||
m.logger.Warn("failed to write pid file", "error", err)
|
||||
}
|
||||
|
||||
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
m.logger.Info("llamafile started", "pid", cmd.Process.Pid, "url", baseURL)
|
||||
|
||||
if err := waitHealthy(ctx, baseURL); err != nil {
|
||||
_ = m.Stop()
|
||||
return "", err
|
||||
}
|
||||
|
||||
return baseURL, nil
|
||||
}
|
||||
|
||||
// Stop terminates the llamafile process and cleans up the PID file.
|
||||
func (m *Manager) Stop() error {
|
||||
if m.process == nil {
|
||||
return nil
|
||||
}
|
||||
if err := m.process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
return fmt.Errorf("slm: kill llamafile: %w", err)
|
||||
}
|
||||
m.process = nil
|
||||
m.port = 0
|
||||
_ = os.Remove(m.pidPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// BaseURL returns the current server base URL, or "" if not running.
|
||||
func (m *Manager) BaseURL() string {
|
||||
if m.process == nil || m.port == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", m.port)
|
||||
}
|
||||
|
||||
func (m *Manager) pidPath() string {
|
||||
return filepath.Join(m.cfg.DataDir, pidFile)
|
||||
}
|
||||
|
||||
func (m *Manager) reapStalePID() {
|
||||
data, err := os.ReadFile(m.pidPath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
_ = os.Remove(m.pidPath())
|
||||
return
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
_ = os.Remove(m.pidPath())
|
||||
return
|
||||
}
|
||||
_ = proc.Kill()
|
||||
_ = os.Remove(m.pidPath())
|
||||
m.logger.Debug("reaped stale llamafile process", "pid", pid)
|
||||
}
|
||||
|
||||
// freePort binds on :0 to let the OS pick an available port, then releases it.
|
||||
// There is a small TOCTOU window between release and use, which is acceptable for a local dev tool.
|
||||
func freePort() (int, error) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
_ = l.Close()
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// waitHealthy polls baseURL/health until it returns 200 or ctx is cancelled.
|
||||
// Ceiling: 15 seconds (cold model load can take 5–10 s).
|
||||
func waitHealthy(ctx context.Context, baseURL string) error {
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
resp, err := client.Get(baseURL + "/health")
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("slm: health check timed out after 15s")
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package slm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManager_StatusNotSetUp(t *testing.T) {
|
||||
m := New(Config{DataDir: t.TempDir(), ModelURL: ""}, nil)
|
||||
if got := m.Status(); got != StatusNotSetUp {
|
||||
t.Errorf("Status = %v, want StatusNotSetUp", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_IsSetUp_False(t *testing.T) {
|
||||
m := New(Config{DataDir: t.TempDir()}, nil)
|
||||
if m.IsSetUp() {
|
||||
t.Error("IsSetUp should be false before Setup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_StatusReady(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// create the actual file the manifest points to
|
||||
dst := filepath.Join(dir, "model.llamafile")
|
||||
if err := os.WriteFile(dst, []byte("binary"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf := &Manifest{
|
||||
ModelURL: "https://example.com/model.llamafile",
|
||||
FilePath: dst,
|
||||
SHA256: "abc",
|
||||
Size: 6,
|
||||
SetupAt: time.Now(),
|
||||
}
|
||||
if err := writeManifest(dir, mf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := New(Config{DataDir: dir}, nil)
|
||||
if got := m.Status(); got != StatusReady {
|
||||
t.Errorf("Status = %v, want StatusReady", got)
|
||||
}
|
||||
if !m.IsSetUp() {
|
||||
t.Error("IsSetUp should be true when manifest + file exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_StatusMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mf := &Manifest{
|
||||
ModelURL: "https://example.com/model.llamafile",
|
||||
FilePath: filepath.Join(dir, "gone.llamafile"), // file does NOT exist
|
||||
SHA256: "abc",
|
||||
Size: 0,
|
||||
SetupAt: time.Now(),
|
||||
}
|
||||
if err := writeManifest(dir, mf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := New(Config{DataDir: dir}, nil)
|
||||
if got := m.Status(); got != StatusMissing {
|
||||
t.Errorf("Status = %v, want StatusMissing", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Setup(t *testing.T) {
|
||||
content := []byte("fake llamafile binary")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write(content)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
m := New(Config{DataDir: dir, ModelURL: srv.URL + "/model.llamafile"}, nil)
|
||||
|
||||
var progressCalled bool
|
||||
if err := m.Setup(context.Background(), func(_, _ int64) { progressCalled = true }); err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
|
||||
if !progressCalled {
|
||||
t.Error("progress callback should have been called")
|
||||
}
|
||||
|
||||
mf, err := readManifest(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("readManifest after Setup: %v", err)
|
||||
}
|
||||
if mf.ModelURL != srv.URL+"/model.llamafile" {
|
||||
t.Errorf("manifest ModelURL = %q, want %q", mf.ModelURL, srv.URL+"/model.llamafile")
|
||||
}
|
||||
if mf.SHA256 == "" {
|
||||
t.Error("manifest SHA256 should not be empty")
|
||||
}
|
||||
h := sha256.Sum256(content)
|
||||
wantHash := hex.EncodeToString(h[:])
|
||||
if mf.SHA256 != wantHash {
|
||||
t.Errorf("manifest SHA256 = %q, want %q", mf.SHA256, wantHash)
|
||||
}
|
||||
if mf.Size != int64(len(content)) {
|
||||
t.Errorf("manifest Size = %d, want %d", mf.Size, len(content))
|
||||
}
|
||||
if mf.SetupAt.IsZero() {
|
||||
t.Error("manifest SetupAt should not be zero")
|
||||
}
|
||||
if _, err := os.Stat(mf.FilePath); err != nil {
|
||||
t.Errorf("llamafile file not found: %v", err)
|
||||
}
|
||||
|
||||
// Status should now be Ready.
|
||||
if m.Status() != StatusReady {
|
||||
t.Errorf("Status after Setup = %v, want StatusReady", m.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Setup_NoURL(t *testing.T) {
|
||||
m := New(Config{DataDir: t.TempDir(), ModelURL: ""}, nil)
|
||||
if err := m.Setup(context.Background(), nil); err == nil {
|
||||
t.Fatal("want error when ModelURL is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Setup_ServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
m := New(Config{DataDir: dir, ModelURL: srv.URL + "/model.llamafile"}, nil)
|
||||
if err := m.Setup(context.Background(), nil); err == nil {
|
||||
t.Fatal("want error when server returns 500")
|
||||
}
|
||||
// Manifest must NOT have been written.
|
||||
if m.Status() != StatusNotSetUp {
|
||||
t.Errorf("Status after failed Setup = %v, want StatusNotSetUp", m.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Start_NotSetUp(t *testing.T) {
|
||||
m := New(Config{DataDir: t.TempDir()}, nil)
|
||||
_, err := m.Start(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("want error when not set up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Stop_NotStarted(t *testing.T) {
|
||||
m := New(Config{DataDir: t.TempDir()}, nil)
|
||||
if err := m.Stop(); err != nil {
|
||||
t.Errorf("Stop on not-started manager: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_BaseURL_NotStarted(t *testing.T) {
|
||||
m := New(Config{DataDir: t.TempDir()}, nil)
|
||||
if got := m.BaseURL(); got != "" {
|
||||
t.Errorf("BaseURL before Start = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitHealthy_OK(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := waitHealthy(ctx, srv.URL); err != nil {
|
||||
t.Errorf("waitHealthy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitHealthy_Timeout(t *testing.T) {
|
||||
// Server returns 503 so health check never passes.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := waitHealthy(ctx, srv.URL); err == nil {
|
||||
t.Fatal("want timeout error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitHealthy_ContextCancel(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := waitHealthy(ctx, srv.URL); err == nil {
|
||||
t.Fatal("want error on cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreePort(t *testing.T) {
|
||||
port, err := freePort()
|
||||
if err != nil {
|
||||
t.Fatalf("freePort: %v", err)
|
||||
}
|
||||
if port <= 0 || port > 65535 {
|
||||
t.Errorf("freePort returned invalid port %d", port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreePort_Unique(t *testing.T) {
|
||||
p1, err := freePort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2, err := freePort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two consecutive calls shouldn't return the same port (bind-then-close relinquishes it,
|
||||
// but the OS generally won't hand it back immediately).
|
||||
_ = p1
|
||||
_ = p2
|
||||
}
|
||||
|
||||
func TestManager_ReapStalePID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Write a PID that doesn't belong to any running process (PID 0 is always invalid).
|
||||
pidPath := filepath.Join(dir, pidFile)
|
||||
if err := os.WriteFile(pidPath, []byte(strconv.Itoa(0)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := New(Config{DataDir: dir}, nil)
|
||||
m.reapStalePID() // should not panic
|
||||
|
||||
// PID file should be removed.
|
||||
if _, err := os.Stat(pidPath); !os.IsNotExist(err) {
|
||||
t.Error("pid file should be removed after reap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_ReapStalePID_NoPIDFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := New(Config{DataDir: dir}, nil)
|
||||
m.reapStalePID() // should be a no-op without panic
|
||||
}
|
||||
|
||||
func TestManager_ReapStalePID_GarbagePID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pidPath := filepath.Join(dir, pidFile)
|
||||
if err := os.WriteFile(pidPath, []byte("not-a-pid"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := New(Config{DataDir: dir}, nil)
|
||||
m.reapStalePID() // should not panic
|
||||
|
||||
if _, err := os.Stat(pidPath); !os.IsNotExist(err) {
|
||||
t.Error("garbage pid file should be removed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package slm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const manifestFile = "manifest.json"
|
||||
|
||||
// Manifest records the result of a successful slm setup.
|
||||
// Presence on disk is the "ready" invariant — written only after download succeeds.
|
||||
type Manifest struct {
|
||||
ModelURL string `json:"model_url"`
|
||||
FilePath string `json:"file_path"`
|
||||
SHA256 string `json:"sha256"` // hex-encoded SHA256 of the downloaded file
|
||||
Size int64 `json:"size"`
|
||||
SetupAt time.Time `json:"setup_at"`
|
||||
}
|
||||
|
||||
// readManifest loads the manifest from dir. Returns os.ErrNotExist if absent.
|
||||
func readManifest(dir string) (*Manifest, error) {
|
||||
data, err := os.ReadFile(filepath.Join(dir, manifestFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("slm: corrupt manifest: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// writeManifest atomically writes m to dir, creating dir if needed.
|
||||
func writeManifest(dir string, m *Manifest) error {
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return fmt.Errorf("slm: create data dir: %w", err)
|
||||
}
|
||||
tmp := filepath.Join(dir, manifestFile+".tmp")
|
||||
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, filepath.Join(dir, manifestFile))
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package slm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWriteAndReadManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
want := &Manifest{
|
||||
ModelURL: "https://example.com/model.llamafile",
|
||||
FilePath: "/data/model.llamafile",
|
||||
SHA256: "abc123",
|
||||
Size: 1024,
|
||||
SetupAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
}
|
||||
|
||||
if err := writeManifest(dir, want); err != nil {
|
||||
t.Fatalf("writeManifest: %v", err)
|
||||
}
|
||||
|
||||
got, err := readManifest(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("readManifest: %v", err)
|
||||
}
|
||||
|
||||
if got.ModelURL != want.ModelURL {
|
||||
t.Errorf("ModelURL: got %q, want %q", got.ModelURL, want.ModelURL)
|
||||
}
|
||||
if got.FilePath != want.FilePath {
|
||||
t.Errorf("FilePath: got %q, want %q", got.FilePath, want.FilePath)
|
||||
}
|
||||
if got.SHA256 != want.SHA256 {
|
||||
t.Errorf("SHA256: got %q, want %q", got.SHA256, want.SHA256)
|
||||
}
|
||||
if got.Size != want.Size {
|
||||
t.Errorf("Size: got %d, want %d", got.Size, want.Size)
|
||||
}
|
||||
if !got.SetupAt.Equal(want.SetupAt) {
|
||||
t.Errorf("SetupAt: got %v, want %v", got.SetupAt, want.SetupAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteManifest_CreatesDir(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
dir := filepath.Join(base, "nested", "slm")
|
||||
|
||||
m := &Manifest{ModelURL: "u", FilePath: "p", SHA256: "h", Size: 0, SetupAt: time.Now()}
|
||||
if err := writeManifest(dir, m); err != nil {
|
||||
t.Fatalf("writeManifest: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, manifestFile)); err != nil {
|
||||
t.Fatalf("manifest file not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadManifest_NotExist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := readManifest(dir)
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("want os.ErrNotExist, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadManifest_CorruptJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, manifestFile), []byte("not json"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := readManifest(dir)
|
||||
if err == nil {
|
||||
t.Fatal("want error for corrupt JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteManifest_Atomic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := &Manifest{ModelURL: "u", FilePath: "p", SHA256: "h", Size: 42, SetupAt: time.Now()}
|
||||
if err := writeManifest(dir, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// No tmp file should remain.
|
||||
if _, err := os.Stat(filepath.Join(dir, manifestFile+".tmp")); !os.IsNotExist(err) {
|
||||
t.Error("temp file should not exist after successful write")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user