app/server: fix desktop app startup killing active ollama launch sessions

This commit is contained in:
Eva Ho
2026-04-17 17:08:02 -04:00
parent 57653b8e42
commit 8c7f95d82f
4 changed files with 138 additions and 2 deletions

View File

@@ -18,11 +18,15 @@ import (
"strings"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/app/logrotate"
"github.com/ollama/ollama/app/store"
)
const restartDelay = time.Second
const externalServerHeartbeatTimeout = 2 * time.Second
const externalServerHeartbeatInterval = time.Second
const externalServerStartupWait = 5 * time.Second
// Server is a managed ollama server process
type Server struct {
@@ -152,6 +156,49 @@ func stop(proc *os.Process) error {
}
}
func externalServerAvailable(ctx context.Context) bool {
client, err := api.ClientFromEnvironment()
if err != nil {
slog.Debug("failed to create ollama client", "err", err)
return false
}
heartbeatCtx, cancel := context.WithTimeout(ctx, externalServerHeartbeatTimeout)
defer cancel()
if err := client.Heartbeat(heartbeatCtx); err != nil {
slog.Debug("external ollama server heartbeat failed", "err", err)
return false
}
return true
}
func waitForExternalServerAvailable(ctx context.Context, timeout time.Duration) bool {
deadline := time.NewTimer(timeout)
defer deadline.Stop()
ticker := time.NewTicker(externalServerHeartbeatInterval)
defer ticker.Stop()
if externalServerAvailable(ctx) {
return true
}
for {
select {
case <-ctx.Done():
return false
case <-deadline.C:
return false
case <-ticker.C:
if externalServerAvailable(ctx) {
return true
}
}
}
}
func (s *Server) Run(ctx context.Context) error {
l, err := openRotatingLog()
if err != nil {
@@ -189,6 +236,12 @@ func (s *Server) Run(ctx context.Context) error {
if err = cmd.Wait(); err != nil && !errors.Is(err, context.Canceled) {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 && !s.dev && !reaped {
if waitForExternalServerAvailable(ctx, externalServerStartupWait) {
slog.Info("using existing ollama server")
<-ctx.Done()
return ctx.Err()
}
reaped = true
// This could be a port conflict, try to kill any existing ollama processes
if err := reapServers(); err != nil {

View File

@@ -4,6 +4,8 @@ package server
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
@@ -205,6 +207,37 @@ func TestServerCmdCloudSettingEnv(t *testing.T) {
}
}
func TestExternalServerAvailable(t *testing.T) {
t.Run("healthy server", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodHead || r.URL.Path != "/" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if !externalServerAvailable(t.Context()) {
t.Fatal("expected external server to be available")
}
})
t.Run("unhealthy server", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusInternalServerError)
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if externalServerAvailable(t.Context()) {
t.Fatal("expected external server to be unavailable")
}
})
}
func TestGetInferenceInfo(t *testing.T) {
tests := []struct {
name string

View File

@@ -46,7 +46,22 @@ func terminated(pid int) (bool, error) {
return false, nil
}
// reapServers kills all ollama processes except our own
func ollamaServeProcess(pid int) bool {
output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "args=").Output()
if err != nil {
slog.Debug("failed to inspect ollama process", "pid", pid, "err", err)
return false
}
args := strings.Fields(strings.TrimSpace(string(output)))
if len(args) < 2 {
return false
}
return filepath.Base(args[0]) == "ollama" && args[1] == "serve"
}
// reapServers kills external ollama serve processes except our own.
func reapServers() error {
// Get our own PID to avoid killing ourselves
currentPID := os.Getpid()
@@ -82,6 +97,10 @@ func reapServers() error {
if pid == currentPID {
continue
}
if !ollamaServeProcess(pid) {
slog.Debug("skipping non-server ollama process", "pid", pid)
continue
}
proc, err := os.FindProcess(pid)
if err != nil {

View File

@@ -101,7 +101,34 @@ func terminated(pid int) (bool, error) {
return true, nil
}
// reapServers kills all ollama processes except our own
func ollamaServeProcess(pid int) bool {
cmd := exec.Command("wmic", "process", "where", fmt.Sprintf("ProcessId=%d", pid), "get", "CommandLine", "/value")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
output, err := cmd.Output()
if err != nil {
slog.Debug("failed to inspect ollama process", "pid", pid, "err", err)
return false
}
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
commandLine, ok := strings.CutPrefix(line, "CommandLine=")
if !ok {
continue
}
fields := strings.Fields(strings.ToLower(commandLine))
for i, field := range fields {
if strings.Trim(field, `"`) == "serve" && i > 0 {
return true
}
}
}
return false
}
// reapServers kills external ollama serve processes except our own.
func reapServers() error {
// Get current process ID to avoid killing ourselves
currentPID := os.Getpid()
@@ -138,6 +165,10 @@ func reapServers() error {
if pid == currentPID {
continue
}
if !ollamaServeProcess(pid) {
slog.Debug("skipping non-server ollama process", "pid", pid)
continue
}
cmd := exec.Command("taskkill", "/F", "/PID", pidStr)
if err := cmd.Run(); err != nil {