mirror of
https://github.com/ollama/ollama.git
synced 2026-09-21 13:38:14 -05:00
app: synchronize macOS app handoff (#18056)
This commit is contained in:
+3
-1
@@ -175,7 +175,9 @@ func main() {
|
||||
|
||||
// Check if another instance is already running
|
||||
// On Windows, focus the existing instance; on other platforms, kill it
|
||||
handleExistingInstance(startHidden)
|
||||
if !handleExistingInstance(startHidden) {
|
||||
return
|
||||
}
|
||||
|
||||
// on macOS, offer the user to create a symlink
|
||||
// from /usr/local/bin/ollama to the app bundle
|
||||
|
||||
+265
-10
@@ -26,6 +26,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
@@ -39,6 +40,7 @@ import (
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/internal/modelref"
|
||||
"github.com/ollama/ollama/internal/proxy"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var ollamaPath = func() string {
|
||||
@@ -213,9 +215,259 @@ func maybeMoveAndRestart() appMove {
|
||||
return status
|
||||
}
|
||||
|
||||
// handleExistingInstance handles existing instances on macOS
|
||||
func handleExistingInstance(_ bool) {
|
||||
C.killOtherInstances()
|
||||
type appProcessIdentity struct {
|
||||
pid int
|
||||
startedAt int64
|
||||
}
|
||||
|
||||
func (p appProcessIdentity) sameProcess(other appProcessIdentity) bool {
|
||||
return p.pid == other.pid && p.startedAt == other.startedAt
|
||||
}
|
||||
|
||||
func (p appProcessIdentity) startedAfter(other appProcessIdentity) bool {
|
||||
if p.startedAt != other.startedAt {
|
||||
return p.startedAt > other.startedAt
|
||||
}
|
||||
return p.pid > other.pid
|
||||
}
|
||||
|
||||
type appProcessStopMode uint8
|
||||
|
||||
const (
|
||||
appProcessStopForHandoff appProcessStopMode = iota
|
||||
appProcessStopGracefully
|
||||
appProcessStopForcefully
|
||||
)
|
||||
|
||||
type appProcessController struct {
|
||||
discover func() ([]appProcessIdentity, error)
|
||||
running func(appProcessIdentity) (bool, error)
|
||||
stop func(appProcessIdentity, appProcessStopMode) error
|
||||
}
|
||||
|
||||
type appSyncBarrierConfig struct {
|
||||
handoffTimeout time.Duration
|
||||
terminateTimeout time.Duration
|
||||
killTimeout time.Duration
|
||||
pollInterval time.Duration
|
||||
settlePeriod time.Duration
|
||||
}
|
||||
|
||||
// runAppSyncBarrier elects the newest launch, stops every older instance, and
|
||||
// returns only after no other instances remain.
|
||||
func runAppSyncBarrier(self appProcessIdentity, controller appProcessController, config appSyncBarrierConfig) error {
|
||||
started := time.Now()
|
||||
handoffDeadline := started.Add(config.handoffTimeout)
|
||||
terminateDeadline := handoffDeadline.Add(config.terminateTimeout)
|
||||
deadline := terminateDeadline.Add(config.killTimeout)
|
||||
sawEmpty := false
|
||||
|
||||
for {
|
||||
processes, err := controller.discover()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Overlapping launches are ordered by process age. Only the newest
|
||||
// candidate may terminate existing instances.
|
||||
for _, process := range processes {
|
||||
if process.startedAfter(self) {
|
||||
return fmt.Errorf("%w: pid %d", errNewerAppInstance, process.pid)
|
||||
}
|
||||
}
|
||||
|
||||
// Require two consecutive empty snapshots so a process that is still
|
||||
// appearing in NSWorkspace cannot slip through the barrier.
|
||||
if len(processes) == 0 {
|
||||
if sawEmpty {
|
||||
return nil
|
||||
}
|
||||
sawEmpty = true
|
||||
if !time.Now().Before(deadline) {
|
||||
return fmt.Errorf("timed out waiting for app instances to exit")
|
||||
}
|
||||
time.Sleep(config.settlePeriod)
|
||||
continue
|
||||
}
|
||||
sawEmpty = false
|
||||
|
||||
for _, process := range processes {
|
||||
if err := controller.stop(process, appProcessStopForHandoff); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
exited, err := waitForAppProcesses(processes, controller, handoffDeadline, config.pollInterval)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exited {
|
||||
for _, process := range processes {
|
||||
if err := controller.stop(process, appProcessStopGracefully); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
exited, err = waitForAppProcesses(processes, controller, terminateDeadline, config.pollInterval)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !exited {
|
||||
// Graceful shutdown owns most of the deadline. Force only exact
|
||||
// surviving identities so one stuck instance cannot block update.
|
||||
for _, process := range processes {
|
||||
if err := controller.stop(process, appProcessStopForcefully); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
exited, err = waitForAppProcesses(processes, controller, deadline, config.pollInterval)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !exited {
|
||||
return fmt.Errorf("timed out waiting for app instances to exit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForAppProcesses(processes []appProcessIdentity, controller appProcessController, deadline time.Time, pollInterval time.Duration) (bool, error) {
|
||||
for {
|
||||
running := false
|
||||
for _, process := range processes {
|
||||
alive, err := controller.running(process)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
running = running || alive
|
||||
}
|
||||
if !running {
|
||||
return true, nil
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return false, nil
|
||||
}
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
appSyncBarrierHandoffTimeout = 5 * time.Second
|
||||
appSyncBarrierTerminateTimeout = 30 * time.Second
|
||||
appSyncBarrierKillTimeout = 5 * time.Second
|
||||
appSyncBarrierPollInterval = 50 * time.Millisecond
|
||||
appSyncBarrierSettlePeriod = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
var errNewerAppInstance = errors.New("newer app instance owns the handoff")
|
||||
|
||||
var killOtherInstances = runDarwinAppSyncBarrier
|
||||
|
||||
// Once a replacement handoff starts, later shutdown signals must not restore
|
||||
// the Claude profile out from under the new app.
|
||||
var appHandoffInProgress atomic.Bool
|
||||
|
||||
func darwinProcessIdentityForPID(pid int) (appProcessIdentity, error) {
|
||||
process, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
|
||||
if err != nil {
|
||||
if errors.Is(err, unix.EIO) && errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) {
|
||||
return appProcessIdentity{}, syscall.ESRCH
|
||||
}
|
||||
return appProcessIdentity{}, err
|
||||
}
|
||||
if int(process.Proc.P_pid) != pid {
|
||||
return appProcessIdentity{}, syscall.ESRCH
|
||||
}
|
||||
return appProcessIdentity{
|
||||
pid: pid,
|
||||
startedAt: process.Proc.P_starttime.Sec*1_000_000 + int64(process.Proc.P_starttime.Usec),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func darwinOtherOllamaProcesses() ([]appProcessIdentity, error) {
|
||||
var discovered *C.AppProcessIdentity
|
||||
var count C.size_t
|
||||
if !C.otherOllamaProcesses(&discovered, &count) {
|
||||
return nil, errors.New("discover other Ollama app processes")
|
||||
}
|
||||
defer C.free(unsafe.Pointer(discovered))
|
||||
|
||||
identities := unsafe.Slice(discovered, int(count))
|
||||
processes := make([]appProcessIdentity, len(identities))
|
||||
for i, process := range identities {
|
||||
processes[i] = appProcessIdentity{pid: int(process.pid), startedAt: int64(process.started_at)}
|
||||
}
|
||||
return processes, nil
|
||||
}
|
||||
|
||||
func darwinAppProcessRunning(expected appProcessIdentity) (bool, error) {
|
||||
actual, err := darwinProcessIdentityForPID(expected.pid)
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect Ollama app process %d: %w", expected.pid, err)
|
||||
}
|
||||
return actual.sameProcess(expected), nil
|
||||
}
|
||||
|
||||
func stopDarwinAppProcess(process appProcessIdentity, mode appProcessStopMode) error {
|
||||
running, err := darwinAppProcessRunning(process)
|
||||
if err != nil || !running {
|
||||
return err
|
||||
}
|
||||
processSignal := syscall.SIGUSR1
|
||||
switch mode {
|
||||
case appProcessStopGracefully:
|
||||
processSignal = syscall.SIGTERM
|
||||
case appProcessStopForcefully:
|
||||
processSignal = syscall.SIGKILL
|
||||
}
|
||||
slog.Info("signaling Ollama app process", "pid", process.pid, "signal", processSignal)
|
||||
if err := syscall.Kill(process.pid, processSignal); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return fmt.Errorf("signal Ollama app process %d: %w", process.pid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDarwinAppSyncBarrier() bool {
|
||||
// NSWorkspace snapshots are not atomic, so two concurrent launches can both
|
||||
// pass if neither is visible yet. This edge case is intentionally unhandled.
|
||||
self, err := darwinProcessIdentityForPID(os.Getpid())
|
||||
if err == nil {
|
||||
err = runAppSyncBarrier(self, appProcessController{
|
||||
discover: darwinOtherOllamaProcesses,
|
||||
running: darwinAppProcessRunning,
|
||||
stop: stopDarwinAppProcess,
|
||||
}, appSyncBarrierConfig{
|
||||
handoffTimeout: appSyncBarrierHandoffTimeout,
|
||||
terminateTimeout: appSyncBarrierTerminateTimeout,
|
||||
killTimeout: appSyncBarrierKillTimeout,
|
||||
pollInterval: appSyncBarrierPollInterval,
|
||||
settlePeriod: appSyncBarrierSettlePeriod,
|
||||
})
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errNewerAppInstance):
|
||||
slog.Info("newer Ollama app instance owns the handoff")
|
||||
case err != nil:
|
||||
slog.Warn("app instance sync barrier failed, continuing startup", "error", err)
|
||||
}
|
||||
return continueAfterBarrierError(err)
|
||||
}
|
||||
|
||||
// continueAfterBarrierError reports whether startup may proceed after the sync
|
||||
// barrier. Losing the election to a newer instance is the only reason to block
|
||||
// launch; any other failure leaves at most a stale instance running, so the
|
||||
// app warns and continues rather than refusing to start.
|
||||
func continueAfterBarrierError(err error) bool {
|
||||
return err == nil || !errors.Is(err, errNewerAppInstance)
|
||||
}
|
||||
|
||||
// handleExistingInstance handles existing instances on macOS.
|
||||
func handleExistingInstance(_ bool) bool {
|
||||
if !isApp {
|
||||
return true
|
||||
}
|
||||
return killOtherInstances()
|
||||
}
|
||||
|
||||
func installSymlink() {
|
||||
@@ -268,8 +520,7 @@ func osRun(_ func(), hasCompletedFirstRun, startHidden, showOnboarding bool, _ s
|
||||
select {
|
||||
case <-handoffSignal:
|
||||
slog.Info("received app handoff signal, shutting down")
|
||||
stopClaudeAppProxy()
|
||||
C.quit()
|
||||
quitForHandoff()
|
||||
case <-handoffDone:
|
||||
}
|
||||
}()
|
||||
@@ -1585,18 +1836,22 @@ func stopClaudeAppProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
func quitForHandoff() {
|
||||
appHandoffInProgress.Store(true)
|
||||
quit()
|
||||
}
|
||||
|
||||
func quit() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), claudeShutdownTimeout)
|
||||
defer cancel()
|
||||
handoff := bool(C.otherOllamaInstanceRunning())
|
||||
if err := restoreClaudeAppForTermination(ctx, handoff); err != nil {
|
||||
if err := restoreClaudeAppForTermination(ctx, appHandoffInProgress.Load()); err != nil {
|
||||
slog.Warn("failed to restore Claude before quitting", "error", err)
|
||||
}
|
||||
C.quit()
|
||||
}
|
||||
|
||||
func restoreClaudeBeforeQuit(ctx context.Context, handoff, configured bool, restore func(context.Context) error) error {
|
||||
if handoff || !configured {
|
||||
func restoreClaudeBeforeQuit(ctx context.Context, configured bool, restore func(context.Context) error) error {
|
||||
if !configured {
|
||||
return nil
|
||||
}
|
||||
return restore(ctx)
|
||||
@@ -1611,7 +1866,7 @@ func restoreClaudeAppForTermination(ctx context.Context, handoff bool) error {
|
||||
return nil
|
||||
}
|
||||
configured := claudeDesktop.UsesOllamaGateway()
|
||||
err := restoreClaudeBeforeQuit(ctx, handoff, configured, claudeDesktop.RestoreForShutdown)
|
||||
err := restoreClaudeBeforeQuit(ctx, configured, claudeDesktop.RestoreForShutdown)
|
||||
if !claudeDesktop.UsesOllamaGateway() {
|
||||
stopClaudeAppProxy()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Security/Security.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
@interface AppDelegate : NSObject <NSApplicationDelegate>
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
|
||||
@@ -17,8 +19,11 @@ enum AppMove
|
||||
};
|
||||
|
||||
void run(bool showOnboarding, bool startHidden);
|
||||
void killOtherInstances();
|
||||
bool otherOllamaInstanceRunning(void);
|
||||
typedef struct {
|
||||
int pid;
|
||||
int64_t started_at;
|
||||
} AppProcessIdentity;
|
||||
bool otherOllamaProcesses(AppProcessIdentity **processes, size_t *count);
|
||||
enum AppMove askToMoveToApplications();
|
||||
int createSymlinkWithAuthorization();
|
||||
int installSymlink(const char *cliPath);
|
||||
|
||||
+68
-29
@@ -8,7 +8,10 @@
|
||||
#import <ServiceManagement/ServiceManagement.h>
|
||||
#import <WebKit/WebKit.h>
|
||||
#import <objc/runtime.h>
|
||||
#include <errno.h>
|
||||
#include <libproc.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
extern NSString *SystemWidePath;
|
||||
|
||||
@@ -1561,38 +1564,74 @@ static BOOL isOllamaApplication(NSRunningApplication *app) {
|
||||
[bundleId isEqualToString:@"com.electron.ollama"];
|
||||
}
|
||||
|
||||
bool otherOllamaInstanceRunning(void) {
|
||||
pid_t myPid = getpid();
|
||||
for (NSRunningApplication *app in
|
||||
[[NSWorkspace sharedWorkspace] runningApplications]) {
|
||||
if (isOllamaApplication(app) && app.processIdentifier > 0 &&
|
||||
app.processIdentifier != myPid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// killOtherInstances kills all other instances of the app currently
|
||||
// running. This way we can ensure that only the most recently started
|
||||
// instance of Ollama is running
|
||||
void killOtherInstances() {
|
||||
bool otherOllamaProcesses(AppProcessIdentity **processes, size_t *count) {
|
||||
pid_t myPid = getpid();
|
||||
NSArray *apps = [[NSWorkspace sharedWorkspace] runningApplications];
|
||||
|
||||
for (NSRunningApplication *app in apps) {
|
||||
if (isOllamaApplication(app)) {
|
||||
pid_t pid = app.processIdentifier;
|
||||
if (pid != myPid && pid > 0) {
|
||||
appLogInfo([NSString stringWithFormat:@"terminating other ollama instance %d", pid]);
|
||||
// Preserve the Claude profile while the replacement instance
|
||||
// takes ownership of the local gateway.
|
||||
kill(pid, SIGUSR1);
|
||||
} else if (pid == -1) {
|
||||
appLogInfo([NSString stringWithFormat:@"skipping app with invalid pid: %@", app.bundleIdentifier]);
|
||||
}
|
||||
}
|
||||
AppProcessIdentity *result = calloc(apps.count, sizeof(*result));
|
||||
if (result == NULL && apps.count > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t resultCount = 0;
|
||||
for (NSRunningApplication *app in apps) {
|
||||
pid_t pid = app.processIdentifier;
|
||||
if (!isOllamaApplication(app) || pid == myPid) {
|
||||
continue;
|
||||
}
|
||||
if (pid <= 0) {
|
||||
appLogInfo([NSString stringWithFormat:
|
||||
@"skipping app with invalid pid: %@", app.bundleIdentifier]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tie the NSWorkspace match to the kernel process. Re-read the start
|
||||
// time after confirming the current app so PID reuse is rejected.
|
||||
struct proc_bsdinfo before = {0};
|
||||
int size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &before,
|
||||
sizeof(before));
|
||||
if (size != sizeof(before)) {
|
||||
if (kill(pid, 0) != 0 && errno == ESRCH) {
|
||||
continue;
|
||||
}
|
||||
appLogInfo([NSString stringWithFormat:
|
||||
@"unable to inspect ollama instance %d", pid]);
|
||||
free(result);
|
||||
return false;
|
||||
}
|
||||
|
||||
NSRunningApplication *current =
|
||||
[NSRunningApplication runningApplicationWithProcessIdentifier:pid];
|
||||
if (current == nil || current.isTerminated ||
|
||||
!isOllamaApplication(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
struct proc_bsdinfo after = {0};
|
||||
size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &after, sizeof(after));
|
||||
if (size != sizeof(after)) {
|
||||
if (kill(pid, 0) != 0 && errno == ESRCH) {
|
||||
continue;
|
||||
}
|
||||
appLogInfo([NSString stringWithFormat:
|
||||
@"unable to confirm ollama instance %d", pid]);
|
||||
free(result);
|
||||
return false;
|
||||
}
|
||||
if (before.pbi_start_tvsec != after.pbi_start_tvsec ||
|
||||
before.pbi_start_tvusec != after.pbi_start_tvusec) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[resultCount++] = (AppProcessIdentity){
|
||||
.pid = pid,
|
||||
.started_at = (int64_t)after.pbi_start_tvsec * 1000000 +
|
||||
after.pbi_start_tvusec,
|
||||
};
|
||||
}
|
||||
|
||||
*processes = result;
|
||||
*count = resultCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Move the source bundle to the system-wide applications location
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -2662,9 +2663,182 @@ func TestClaudeGatewayLocalSelectionCatalogPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAppSyncBarrierStopsOlderInstances(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
exitOn appProcessStopMode
|
||||
wantStops []appProcessStopMode
|
||||
}{
|
||||
{name: "handoff", exitOn: appProcessStopForHandoff, wantStops: []appProcessStopMode{appProcessStopForHandoff}},
|
||||
{name: "graceful", exitOn: appProcessStopGracefully, wantStops: []appProcessStopMode{appProcessStopForHandoff, appProcessStopGracefully}},
|
||||
{name: "forced", exitOn: appProcessStopForcefully, wantStops: []appProcessStopMode{appProcessStopForHandoff, appProcessStopGracefully, appProcessStopForcefully}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
self := appProcessIdentity{pid: 20, startedAt: 20}
|
||||
older := appProcessIdentity{pid: 10, startedAt: 10}
|
||||
alive := true
|
||||
var stops []appProcessStopMode
|
||||
controller := appProcessController{
|
||||
discover: func() ([]appProcessIdentity, error) {
|
||||
if alive {
|
||||
return []appProcessIdentity{older}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
running: func(process appProcessIdentity) (bool, error) {
|
||||
return alive && process.sameProcess(older), nil
|
||||
},
|
||||
stop: func(process appProcessIdentity, mode appProcessStopMode) error {
|
||||
if !process.sameProcess(older) {
|
||||
t.Fatalf("stopped process %+v, want %+v", process, older)
|
||||
}
|
||||
stops = append(stops, mode)
|
||||
if mode == test.exitOn {
|
||||
alive = false
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := runAppSyncBarrier(self, controller, appSyncBarrierConfig{
|
||||
killTimeout: time.Second,
|
||||
pollInterval: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(stops, test.wantStops) {
|
||||
t.Fatalf("stop modes = %v, want %v", stops, test.wantStops)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAppSyncBarrierStopsEveryOlderInstance(t *testing.T) {
|
||||
self := appProcessIdentity{pid: 30, startedAt: 30}
|
||||
graceful := appProcessIdentity{pid: 10, startedAt: 10}
|
||||
stubborn := appProcessIdentity{pid: 20, startedAt: 20}
|
||||
late := appProcessIdentity{pid: 25, startedAt: 25}
|
||||
alive := map[appProcessIdentity]bool{
|
||||
graceful: true,
|
||||
stubborn: true,
|
||||
}
|
||||
type stoppedProcess struct {
|
||||
process appProcessIdentity
|
||||
mode appProcessStopMode
|
||||
}
|
||||
var stops []stoppedProcess
|
||||
lateDiscovered := false
|
||||
controller := appProcessController{
|
||||
discover: func() ([]appProcessIdentity, error) {
|
||||
if !alive[graceful] && !alive[stubborn] && !lateDiscovered {
|
||||
alive[late] = true
|
||||
lateDiscovered = true
|
||||
}
|
||||
var processes []appProcessIdentity
|
||||
for _, process := range []appProcessIdentity{graceful, stubborn, late} {
|
||||
if alive[process] {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
return processes, nil
|
||||
},
|
||||
running: func(process appProcessIdentity) (bool, error) {
|
||||
return alive[process], nil
|
||||
},
|
||||
stop: func(process appProcessIdentity, mode appProcessStopMode) error {
|
||||
if !alive[process] {
|
||||
return nil
|
||||
}
|
||||
stops = append(stops, stoppedProcess{process: process, mode: mode})
|
||||
if !process.sameProcess(stubborn) || mode == appProcessStopForcefully {
|
||||
alive[process] = false
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := runAppSyncBarrier(self, controller, appSyncBarrierConfig{
|
||||
killTimeout: time.Second,
|
||||
pollInterval: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []stoppedProcess{
|
||||
{process: graceful, mode: appProcessStopForHandoff},
|
||||
{process: stubborn, mode: appProcessStopForHandoff},
|
||||
{process: stubborn, mode: appProcessStopGracefully},
|
||||
{process: stubborn, mode: appProcessStopForcefully},
|
||||
{process: late, mode: appProcessStopForHandoff},
|
||||
}
|
||||
if !slices.Equal(stops, want) {
|
||||
t.Fatalf("stops = %+v, want %+v", stops, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAppSyncBarrierDefersToNewerInstance(t *testing.T) {
|
||||
self := appProcessIdentity{pid: 10, startedAt: 10}
|
||||
newer := appProcessIdentity{pid: 20, startedAt: 20}
|
||||
stopped := false
|
||||
err := runAppSyncBarrier(self, appProcessController{
|
||||
discover: func() ([]appProcessIdentity, error) {
|
||||
return []appProcessIdentity{newer}, nil
|
||||
},
|
||||
running: func(appProcessIdentity) (bool, error) { return true, nil },
|
||||
stop: func(appProcessIdentity, appProcessStopMode) error {
|
||||
stopped = true
|
||||
return nil
|
||||
},
|
||||
}, appSyncBarrierConfig{killTimeout: time.Second})
|
||||
if !errors.Is(err, errNewerAppInstance) {
|
||||
t.Fatalf("barrier error = %v, want newer-instance error", err)
|
||||
}
|
||||
if stopped {
|
||||
t.Fatal("older launch stopped the newer instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAppSyncBarrierRequiresSettledEmptyList(t *testing.T) {
|
||||
queries := 0
|
||||
err := runAppSyncBarrier(appProcessIdentity{pid: 1, startedAt: 1}, appProcessController{
|
||||
discover: func() ([]appProcessIdentity, error) {
|
||||
queries++
|
||||
return nil, nil
|
||||
},
|
||||
}, appSyncBarrierConfig{killTimeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queries != 2 {
|
||||
t.Fatalf("discovery queries = %d, want 2", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueAfterBarrierErrorOnlyBlocksNewerInstance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "success", err: nil, want: true},
|
||||
{name: "discovery failure", err: errors.New("discover other Ollama app processes"), want: true},
|
||||
{name: "handoff timeout", err: errors.New("timed out waiting for app instances to exit"), want: true},
|
||||
{name: "newer instance", err: fmt.Errorf("%w: pid 2", errNewerAppInstance), want: false},
|
||||
{name: "wrapped newer instance", err: fmt.Errorf("barrier: %w", fmt.Errorf("%w: pid 2", errNewerAppInstance)), want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := continueAfterBarrierError(tt.err); got != tt.want {
|
||||
t.Fatalf("continueAfterBarrierError(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreClaudeBeforeQuit(t *testing.T) {
|
||||
called := false
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), false, false, func(context.Context) error {
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), false, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -2674,7 +2848,7 @@ func TestRestoreClaudeBeforeQuit(t *testing.T) {
|
||||
t.Fatal("restore called while Claude was not configured")
|
||||
}
|
||||
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), false, true, func(context.Context) error {
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), true, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -2685,21 +2859,74 @@ func TestRestoreClaudeBeforeQuit(t *testing.T) {
|
||||
}
|
||||
|
||||
wantErr := errors.New("restore failed")
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), false, true, func(context.Context) error {
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), true, func(context.Context) error {
|
||||
return wantErr
|
||||
}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("restore error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
called = false
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), true, true, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
func TestRestoreClaudeAppForTerminationPreservesHandoffProfile(t *testing.T) {
|
||||
previousDesktop := claudeDesktop
|
||||
fake := &fakeClaudeDesktopController{configured: true}
|
||||
claudeDesktop = fake
|
||||
t.Cleanup(func() { claudeDesktop = previousDesktop })
|
||||
|
||||
if err := restoreClaudeAppForTermination(context.Background(), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.restoreCalls != 0 || !fake.configured {
|
||||
t.Fatalf("handoff restore calls/configured = %d/%v, want 0/true", fake.restoreCalls, fake.configured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleExistingInstanceSkipsDevelopmentBuild(t *testing.T) {
|
||||
oldIsApp := isApp
|
||||
oldKillOtherInstances := killOtherInstances
|
||||
t.Cleanup(func() {
|
||||
isApp = oldIsApp
|
||||
killOtherInstances = oldKillOtherInstances
|
||||
})
|
||||
|
||||
isApp = false
|
||||
called := false
|
||||
killOtherInstances = func() bool {
|
||||
called = true
|
||||
return false
|
||||
}
|
||||
|
||||
if !handleExistingInstance(false) {
|
||||
t.Fatal("development instance did not continue startup")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("restore called during an app replacement handoff")
|
||||
t.Fatal("development instance entered the packaged app handoff barrier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleExistingInstanceReturnsBarrierResult(t *testing.T) {
|
||||
oldIsApp := isApp
|
||||
oldKillOtherInstances := killOtherInstances
|
||||
t.Cleanup(func() {
|
||||
isApp = oldIsApp
|
||||
killOtherInstances = oldKillOtherInstances
|
||||
})
|
||||
|
||||
isApp = true
|
||||
for _, want := range []bool{true, false} {
|
||||
killOtherInstances = func() bool { return want }
|
||||
if got := handleExistingInstance(false); got != want {
|
||||
t.Fatalf("handleExistingInstance() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarwinAppProcessRunningReportsExitedProcess(t *testing.T) {
|
||||
running, err := darwinAppProcessRunning(appProcessIdentity{pid: 1 << 30, startedAt: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if running {
|
||||
t.Fatal("nonexistent process reported as running")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,11 +74,12 @@ func maybeMoveAndRestart() appMove {
|
||||
}
|
||||
|
||||
// handleExistingInstance checks for existing instances and optionally focuses them
|
||||
func handleExistingInstance(startHidden bool) {
|
||||
func handleExistingInstance(startHidden bool) bool {
|
||||
if wintray.CheckAndFocusExistingInstance(!startHidden) {
|
||||
slog.Info("existing instance found, exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func installSymlink() {}
|
||||
|
||||
Reference in New Issue
Block a user