Skip to content

Commit 72d5605

Browse files
fix(engine-manager): sweep engines once per shutdown (#137)
## Description Quitting took about a second longer than it should whenever an engine was running outside PAIR's control, because the engine shutdown sweep ran twice per quit. Two callers reach `Executor.StopAll()` on an ordinary quit: the broker's `engine:prepare-shutdown` during its teardown, and engine-manager's stdin-EOF path once the broker closes its stdin. Both stay, since each covers a different way of being shut down. Repeating the sweep is free for an engine PAIR owns, but an adopted engine's stop can only be declined, so every sweep re-pays its readiness probe. `StopAll` now runs one sweep per process. Later callers wait for it rather than returning early, which would report engines stopped while they were still running. They do retry an engine whose stop failed through a process handle or a stop command, such as an `lms server stop` that errored. ## Release intent <!-- pair-release-intent:v1 --> ### Changelog title Quitting is prompt again ### Changelog body - Quitting no longer stalls when an engine is running outside PAIR's control. The engine shutdown that runs on the way out was being performed twice, and the repeat cost about a second. ### Bumps - services: patch - nvpair-cluster-manager: none - nvpair-engine-manager: patch - nvpair-errors: none - nvpair-job-scheduler: none - nvpair-manual-nodes: none - nvpair-node-info: none - nvpair-node-scanner: none - nvpair-node-settings: none - nvpair-proxy: none - nvpair-tui: none - nvpair-ui-broker: none - nvpair-workload-manager: none <!-- /pair-release-intent:v1 --> ## Scope In: the single sweep in `nvpair-engine-manager`, the retry of a failed stop, their unit tests, the end-to-end quit test, and the engine-manager `README.md` and `spec.md`. The broker `README.md` gains one paragraph noting that its teardown and the stdin-EOF path reach the same sweep. Out: this PR used to also carry the broker's concurrent worker joins and a startup-gate doc correction. Those are now separate pull requests, "perf(broker): join workers concurrently during teardown" and "docs: correct what gates the startup window". The three touch disjoint files and merge cleanly in any order. ## Validation On macOS (Apple silicon), rebased on current `develop`: - `TestQuitSweepsEnginesOnce` in `services/tests` drives the broker's `shutdown` request and stdin close against an adopted engine. On `develop`: 2 sweeps, 2.12s teardown, test fails. On this branch: 1 sweep, 1.05s, test passes. It counts sweeps rather than milliseconds, so it cannot flake on timing. - New unit tests: `TestStopAllSweepsOnce`, `TestStopAllWaitsForTheSweepInFlight`, `TestStopAllRetriesAStopCommandThatFailed`. - `cd services/nvpair-engine-manager && go test ./... -count=1`: pass except `TestUninstallTerminatesRunningInstance`, which fails identically on `develop` on macOS. - `cd services/tests && go test ./... -count=1`: pass (336s). - `npm run service-contracts:check`: pass. ## Risk A caller of `StopAll` that arrives while the sweep is running now blocks until it finishes. That is the point, since returning early orphans engines, and the broker's wait for `engine:prepare-shutdown` is already bounded by its teardown budget. ## Checklist - [x] I have read the [Contributing Guidelines](https://github.com/NVIDIA/Personal-AI-Router/blob/main/CONTRIBUTING.md). - [x] Every commit is signed off (`git commit -s`), certifying the [Developer Certificate of Origin](https://developercertificate.org/). - [x] New or existing tests cover the change. - [x] Relevant documentation is updated. - [x] I checked the diff, changed filenames, and commit messages for credentials, private data, internal URLs, internal issue identifiers, and generated artifacts. - [x] I recorded the validation commands and results above. - [x] I declared version bumps in the release-intent block above. `services/versions.json` is written by automation — do not edit it by hand. ## Review order 1. `services/nvpair-engine-manager/lifecycle.go` and `executor.go`: `StopAll` runs the sweep through a `sync.Once`, and later callers retry only the failed stops it recorded. 2. `services/nvpair-engine-manager/shutdown_test.go` and the `failoncethentouch` mode in `testdata/fakeengine/main.go`. 3. `services/tests/shutdown_latency_test.go`: the end-to-end test. 4. Docs: engine-manager `README.md` and `spec.md`, and the broker `README.md` paragraph. --------- Signed-off-by: Terve <ntervalon@nvidia.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6036bf0 commit 72d5605

9 files changed

Lines changed: 466 additions & 13 deletions

File tree

‎services/nvpair-engine-manager/README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,3 +353,17 @@ termination, console hiding) are the only build-tagged Go
353353
Shuts down on stdin EOF (parent closed the pipe), `SIGINT`/`SIGTERM`, or a
354354
`shutdown` JSON-RPC request — stopping any running engines first so none
355355
are orphaned.
356+
357+
That engine sweep (`StopAll`) runs **exactly once per process**, and every
358+
caller returns only once it has finished. An ordinary quit asks for it twice:
359+
the broker sends `engine:prepare-shutdown` from its own teardown, and then
360+
closing stdin reaches the EOF path. Each is the right trigger for a different
361+
way of being shut down, so both stay.
362+
363+
Later callers **wait** for the sweep in flight rather than returning early:
364+
returning early would report engines stopped before they were, and the broker
365+
would close stdin and exit while they were still running. They do not repeat
366+
the sweep, which would re-pay the readiness probe for every adopted engine whose
367+
stop is declined. They do retry any engine whose stop failed through a process
368+
handle or a stop command, such as an `lms server stop` that errored, because
369+
another attempt can succeed where a declined stop cannot.

‎services/nvpair-engine-manager/executor.go‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,17 @@ type Executor struct {
127127
reservedPort atomic.Int32
128128
// StopAll is terminal for an Executor; the gate closes its start/snapshot race.
129129
shuttingDown atomic.Bool
130-
mu sync.Mutex
131-
engines map[string]*engineState
130+
// stopAllOnce collapses the shutdown entry points into a single sweep.
131+
// See StopAll.
132+
stopAllOnce sync.Once
133+
// failedStops holds the engines whose stop failed through a process handle
134+
// or a stop command, which later StopAll callers retry. The sweep writes it
135+
// without retryMu, since stopAllOnce returns to no caller before the sweep
136+
// finishes; retryMu serializes the retries that follow.
137+
retryMu sync.Mutex
138+
failedStops []string
139+
mu sync.Mutex
140+
engines map[string]*engineState
132141
}
133142

134143
func NewExecutor(reg *Registry, reporter *Reporter, emit func(string, any), baseDir string) *Executor {

‎services/nvpair-engine-manager/lifecycle.go‎

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -751,32 +751,84 @@ func (e *Executor) Restart(ctx context.Context, engine string) error {
751751

752752
// StopAll rejects new starts and terminates every running engine during
753753
// shutdown without changing the user's saved ON/OFF intent.
754+
//
755+
// Two entry points ask for it on an ordinary quit — the
756+
// engine:prepare-shutdown handler, which the broker calls from its own
757+
// teardown, and the stdin-EOF path in Run, which follows when the broker then
758+
// closes stdin — because each is the right trigger for a different way of
759+
// being shut down: a broker ordering its teardown, and a parent that simply
760+
// severed the pipe.
761+
//
762+
// One sweep runs per process, and every caller returns only once it has
763+
// finished. A caller that returned while the sweep was still running would
764+
// report engines stopped before they were: the broker would close stdin,
765+
// engine-manager would exit, and the engines it launched would be orphaned.
766+
// Repeating the sweep would re-pay doStop's readiness probe for every adopted
767+
// engine whose stop it declines, each time.
768+
//
769+
// A later caller does retry the engines whose stop failed through a process
770+
// handle or a stop command, such as an `lms server stop` that errored, because
771+
// another attempt can succeed where a declined stop cannot.
754772
func (e *Executor) StopAll() {
773+
swept := false
774+
e.stopAllOnce.Do(func() {
775+
swept = true
776+
e.stopAllNow()
777+
})
778+
if !swept {
779+
e.retryFailedStops()
780+
}
781+
}
782+
783+
func (e *Executor) stopAllNow() {
755784
e.shuttingDown.Store(true)
756785
e.mu.Lock()
757786
names := make([]string, 0, len(e.engines))
758787
for n := range e.engines {
759788
names = append(names, n)
760789
}
761790
e.mu.Unlock()
762-
// Stop engines concurrently. Each doStop can wait up to the manifest grace
763-
// (default 5s) for its engine to exit, so a sequential sweep would take
764-
// sum(grace) across engines and let a slow first engine delay — or, if the
765-
// parent severs us mid-sweep, entirely prevent — every later engine from
766-
// even being signaled. Running them in parallel bounds the whole sweep to
767-
// ~one grace and guarantees every engine gets its stop signal immediately.
768-
// Each engine still serializes on its own opMu, so this is safe.
791+
e.failedStops = e.stopEngines(names)
792+
}
793+
794+
// retryFailedStops tries again to stop the engines whose stop failed in the
795+
// sweep. Callers take turns, so two retries never stop one engine at once.
796+
func (e *Executor) retryFailedStops() {
797+
e.retryMu.Lock()
798+
defer e.retryMu.Unlock()
799+
if len(e.failedStops) == 0 {
800+
return
801+
}
802+
e.failedStops = e.stopEngines(e.failedStops)
803+
}
804+
805+
// stopEngines stops the named engines and returns those whose stop failed
806+
// through a process handle or a stop command, the failures a later attempt can
807+
// fix. Without either, doStop declines a foreign listener or has already
808+
// escalated against PAIR's own orphan, and a retry would only repeat that.
809+
//
810+
// Engines stop concurrently. Each doStop can wait up to the manifest grace
811+
// (default 5s) for its engine to exit, so a sequential sweep would take
812+
// sum(grace) across engines and let a slow first engine delay — or, if the
813+
// parent severs us mid-sweep, entirely prevent — every later engine from even
814+
// being signaled. Running them in parallel bounds the whole sweep to ~one grace
815+
// and guarantees every engine gets its stop signal immediately. Each engine
816+
// still serializes on its own opMu, so this is safe.
817+
func (e *Executor) stopEngines(names []string) []string {
769818
var wg sync.WaitGroup
770-
for _, n := range names {
819+
// Each goroutine writes only its own index, so no lock is needed.
820+
retry := make([]bool, len(names))
821+
for i, n := range names {
771822
st, err := e.state(n)
772823
if err != nil {
773824
continue
774825
}
775826
wg.Add(1)
776-
go func(st *engineState, n string) {
827+
go func(i int, st *engineState, n string) {
777828
defer wg.Done()
778829
st.mu.Lock()
779830
cancel := st.startCancel
831+
retryable := st.plat.Runtime.modeOrDefault() == "command" || st.proc != nil
780832
st.mu.Unlock()
781833
if cancel != nil {
782834
cancel()
@@ -785,10 +837,18 @@ func (e *Executor) StopAll() {
785837
defer st.opMu.Unlock()
786838
if err := e.doStop(st, n); err != nil {
787839
slog.Warn("engine stop during shutdown failed", "engine", n, "err", err)
840+
retry[i] = retryable
788841
}
789-
}(st, n)
842+
}(i, st, n)
790843
}
791844
wg.Wait()
845+
var failed []string
846+
for i, n := range names {
847+
if retry[i] {
848+
failed = append(failed, n)
849+
}
850+
}
851+
return failed
792852
}
793853

794854
func (e *Executor) runHealth(ctx context.Context, st *engineState, engine string, port int, gen int64) {

‎services/nvpair-engine-manager/shutdown_test.go‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ import (
99
"errors"
1010
"net"
1111
"net/http"
12+
"net/http/httptest"
13+
"net/url"
1214
"os"
1315
"path/filepath"
1416
"strconv"
1517
"strings"
18+
"sync"
1619
"testing"
1720
"time"
1821

22+
"github.com/stretchr/testify/assert"
1923
"github.com/stretchr/testify/require"
2024
)
2125

@@ -50,6 +54,179 @@ func TestStopAll_StopsEveryEngine(t *testing.T) {
5054
require.ErrorIs(t, ex.Start(ctx, names[0]), context.Canceled, "start after StopAll error")
5155
}
5256

57+
// adoptedEngineOnLivePort seeds an engine that this process did not launch and
58+
// cannot stop: running, adopted, no owned proc, and a real listener on its port
59+
// so doStop's readiness probe can never confirm it went away. That is the one
60+
// shape whose stop is refused, leaving st.running true — so a repeated sweep
61+
// does the whole thing again instead of hitting the !st.running early return.
62+
// Its bin path is outside the managed install dir, so the port-reclaim branch
63+
// declines it rather than terminating a stranger's process.
64+
func adoptedEngineOnLivePort(t *testing.T, ex *Executor) {
65+
t.Helper()
66+
ln, err := net.Listen("tcp", "127.0.0.1:0")
67+
require.NoError(t, err, "listen")
68+
t.Cleanup(func() { _ = ln.Close() })
69+
70+
st, err := ex.state("fake")
71+
require.NoError(t, err)
72+
st.mu.Lock()
73+
st.running = true
74+
st.adopted = true
75+
st.proc = nil
76+
st.port = ln.Addr().(*net.TCPAddr).Port
77+
st.binPath = filepath.Join(t.TempDir(), "someone-elses-engine")
78+
st.mu.Unlock()
79+
}
80+
81+
// TestStopAll_RunsOneSweepPerProcess is the quit-latency guard. An ordinary quit
82+
// asks for StopAll twice — the broker's engine:prepare-shutdown during its
83+
// teardown, and the stdin-EOF path in Run once the broker closes stdin. An
84+
// engine whose stop can only be declined stays running, so each repeat sweep
85+
// would re-pay doStop's readiness probe for it.
86+
func TestStopAll_RunsOneSweepPerProcess(t *testing.T) {
87+
ex := newTestExecutor(t, testEngineManifest(fakeEngineBin))
88+
adoptedEngineOnLivePort(t, ex)
89+
90+
first := time.Now()
91+
ex.StopAll()
92+
sweep := time.Since(first)
93+
require.GreaterOrEqual(t, sweep, 500*time.Millisecond,
94+
"the first sweep was too fast to have probed, so this no longer covers the repeat")
95+
96+
// The engine is still there and still un-stoppable, so an unguarded sweep
97+
// would spend the probe again.
98+
repeat := time.Now()
99+
ex.StopAll()
100+
ex.StopAll()
101+
assert.LessOrEqual(t, time.Since(repeat), sweep/2, "two further StopAll calls must not repeat the sweep")
102+
}
103+
104+
// TestStopAll_WaitsForTheSweepInFlight is the other half, and the reason this
105+
// is a joining guard rather than a plain skip. A caller that returned while the
106+
// first sweep was still running would report engines stopped before they were:
107+
// the broker would close stdin, engine-manager would exit, and the engines it
108+
// launched would be left running with nothing owning them.
109+
func TestStopAll_WaitsForTheSweepInFlight(t *testing.T) {
110+
ex := newTestExecutor(t, testEngineManifest(fakeEngineBin))
111+
adoptedEngineOnLivePort(t, ex)
112+
st, err := ex.state("fake")
113+
require.NoError(t, err)
114+
115+
// Holding the engine's opMu parks the sweep before doStop, so it cannot
116+
// finish until the test lets it, however the goroutines are scheduled.
117+
st.opMu.Lock()
118+
releaseSweep := sync.OnceFunc(st.opMu.Unlock)
119+
t.Cleanup(releaseSweep)
120+
sweepDone := make(chan struct{})
121+
go func() {
122+
defer close(sweepDone)
123+
ex.StopAll()
124+
}()
125+
126+
// Join behind the first caller only once it owns the sweep: only the sweep
127+
// sets shuttingDown, and it does so before stopping anything.
128+
require.Eventually(t, ex.shuttingDown.Load, 5*time.Second, 5*time.Millisecond,
129+
"the first StopAll caller never started the sweep")
130+
secondDone := make(chan struct{})
131+
go func() {
132+
defer close(secondDone)
133+
ex.StopAll()
134+
}()
135+
136+
require.Never(t, func() bool { return isClosed(secondDone) }, 200*time.Millisecond, 10*time.Millisecond,
137+
"the second caller returned while the first sweep was still running")
138+
139+
releaseSweep()
140+
require.Eventually(t, func() bool { return isClosed(sweepDone) }, 10*time.Second, 10*time.Millisecond,
141+
"the first StopAll caller never returned once the sweep was released")
142+
require.Eventually(t, func() bool { return isClosed(secondDone) }, 10*time.Second, 10*time.Millisecond,
143+
"the second StopAll caller never returned once the sweep was released")
144+
}
145+
146+
// isClosed reports whether done has been closed, without blocking.
147+
func isClosed(done <-chan struct{}) bool {
148+
select {
149+
case <-done:
150+
return true
151+
default:
152+
return false
153+
}
154+
}
155+
156+
// commandDaemon stands in for a command-mode engine's daemon: it serves the
157+
// readiness endpoint once startMarker exists and closes its listener once
158+
// stopMarker does, as a daemon told to stop by its control CLI would. It
159+
// returns the daemon's port.
160+
func commandDaemon(t *testing.T, startMarker, stopMarker string) int {
161+
t.Helper()
162+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
163+
if fileExists(startMarker) && !fileExists(stopMarker) {
164+
w.WriteHeader(http.StatusOK)
165+
return
166+
}
167+
w.WriteHeader(http.StatusServiceUnavailable)
168+
}))
169+
t.Cleanup(srv.Close)
170+
stopWatch := make(chan struct{})
171+
t.Cleanup(func() { close(stopWatch) })
172+
go func() {
173+
for {
174+
select {
175+
case <-stopWatch:
176+
return
177+
case <-time.After(50 * time.Millisecond):
178+
if fileExists(stopMarker) {
179+
srv.Close()
180+
return
181+
}
182+
}
183+
}
184+
}()
185+
u, err := url.Parse(srv.URL)
186+
require.NoError(t, err, "parse daemon URL")
187+
port, err := strconv.Atoi(u.Port())
188+
require.NoError(t, err, "parse daemon port")
189+
return port
190+
}
191+
192+
// TestStopAll_RetriesAFailedStopCommand checks that a later StopAll caller
193+
// retries an engine whose stop command failed during the sweep, so a stop
194+
// command that errors once does not leave the engine running after quit.
195+
func TestStopAll_RetriesAFailedStopCommand(t *testing.T) {
196+
dir := t.TempDir()
197+
startMarker := filepath.Join(dir, "started")
198+
stopAttempt := filepath.Join(dir, "stop-attempted")
199+
stopMarker := filepath.Join(dir, "stopped")
200+
port := commandDaemon(t, startMarker, stopMarker)
201+
ex := newTestExecutor(t, &Manifest{
202+
Engine: "daemon", DisplayName: "Daemon Engine", ManifestVersion: 1,
203+
Platforms: map[string]Platform{
204+
hostKey(): {
205+
Detect: []string{fakeEngineBin},
206+
Runtime: Runtime{
207+
Mode: "command",
208+
Port: port,
209+
Start: [][]string{{fakeEngineBin, "touch", startMarker}},
210+
Stop: &StopSpec{Cmd: []string{fakeEngineBin, "failoncethentouch", stopAttempt, stopMarker}},
211+
Ready: &Probe{HTTP: "http://127.0.0.1:{port}/", Status: http.StatusOK, TimeoutS: 10},
212+
},
213+
},
214+
},
215+
})
216+
require.NoError(t, ex.Start(context.Background(), "daemon"))
217+
ex.StopAll()
218+
st, err := ex.Status("daemon")
219+
require.NoError(t, err)
220+
require.True(t, st.Running, "the failed stop must leave the engine running after the sweep")
221+
222+
ex.StopAll()
223+
224+
assert.FileExists(t, stopMarker, "the later StopAll must run the stop command again")
225+
st, err = ex.Status("daemon")
226+
require.NoError(t, err)
227+
assert.False(t, st.Running, "the retried stop must leave the engine stopped")
228+
}
229+
53230
func TestStopAll_CancelsCommandStartWithoutError(t *testing.T) {
54231
pidFile := filepath.Join(t.TempDir(), "fake.pid")
55232
t.Setenv("FAKE_PID_FILE", pidFile)

‎services/nvpair-engine-manager/spec.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ Requests (caller → service):
157157
| `engine:remote-delete-model` | `{ node, engine, model }` | the remote action result |
158158
| `engine:remote-start` | `{ node, engine, port? }` | `EngineStatus` from the remote node (manifest `runtime.bind`; no per-call bind on the remote path) |
159159
| `engine:remote-stop` | `{ node, engine }` | `EngineStatus` from the remote node |
160+
| `engine:prepare-shutdown` | — | `null` once the shutdown sweep, and any retry of a failed stop, has finished; per-engine stop failures are logged, not returned |
160161
| `shutdown` | — | `null` |
161162
| `log/set-level` | `{ level }` | `{ level }` |
162163

@@ -248,7 +249,16 @@ The operator starts it: `engine:start {engine:"ollama"}` resolves the manifest r
248249

249250
The operator stops it: `engine:stop {engine:"ollama"}` signals a process the service owns. For an **adopted** engine (no owned process), it resolves the PID bound to the port and terminates it only when that process is running the binary we manage — reclaiming an orphan a prior run left on our own managed port; a genuinely foreign listener (a different image on a different port) is declined with an error naming its PID and image. A user-initiated `stop` records the OFF intent regardless (even when the RPC returns an error), so the health loop and restore-on-restart don't flip the engine back on — clients must not treat a stop error as proof the OFF choice was discarded. The cluster `ec` stop endpoint shares this semantics and may return HTTP 500 while OFF is persisted.
250251

251-
The operator pulls a model: `engine:action {engine:"ollama", action:"pull_model", params:{name:"llama3.2"}}` issues the manifest-declared `POST 127.0.0.1:{port}/api/pull`. Because the action is `pull_model`, the request is routed through the streaming pull path (not the buffered `engine:action` reader): each `/api/pull` status line is emitted as an `engine:pull-progress` notification — so a local pull shows live download progress just like a remote pull's `engine:remote-progress` — and the request settles with the pull's terminal result line. Frames are coalesced (only a change in `stage` or `percent` is emitted) so a chatty engine that streams many byte-progress lines per layer doesn't flood subscribers. Streaming Ollama and llama.cpp pulls use a 30-minute inactivity watchdog that is refreshed only when a layer/file completed-byte count advances, allowing active downloads to exceed 30 minutes without letting duplicate progress or heartbeat frames keep a stalled pull alive. The engine's terminal `{"status":"success"}` surfaces as a `stage:"success"` frame; a **failed** pull emits a terminal `stage:"error", percent:-1, message:<why>` frame in addition to the JSON-RPC error, so a UI whose synchronous call already timed out on a long download still converges off "pulling". A CLI-driven pull (LM Studio's `lms get`) has no line-level progress, so it emits one `stage:"pulling"` marker and retains the fixed 30-minute action timeout before returning the command's result. On `shutdown` (or stdin EOF) the service stops every running engine first, so none are orphaned.
252+
The operator pulls a model: `engine:action {engine:"ollama", action:"pull_model", params:{name:"llama3.2"}}` issues the manifest-declared `POST 127.0.0.1:{port}/api/pull`.
253+
Because the action is `pull_model`, the request is routed through the streaming pull path (not the buffered `engine:action` reader): each `/api/pull` status line is emitted as an `engine:pull-progress` notification — so a local pull shows live download progress just like a remote pull's `engine:remote-progress` — and the request settles with the pull's terminal result line.
254+
Frames are coalesced (only a change in `stage` or `percent` is emitted) so a chatty engine that streams many byte-progress lines per layer doesn't flood subscribers.
255+
Streaming Ollama and llama.cpp pulls use a 30-minute inactivity watchdog that is refreshed only when a layer/file completed-byte count advances, allowing active downloads to exceed 30 minutes without letting duplicate progress or heartbeat frames keep a stalled pull alive.
256+
The engine's terminal `{"status":"success"}` surfaces as a `stage:"success"` frame; a **failed** pull emits a terminal `stage:"error", percent:-1, message:<why>` frame in addition to the JSON-RPC error, so a UI whose synchronous call already timed out on a long download still converges off "pulling".
257+
A CLI-driven pull (LM Studio's `lms get`) has no line-level progress, so it emits one `stage:"pulling"` marker and retains the fixed 30-minute action timeout before returning the command's result.
258+
On `shutdown` (or stdin EOF) the service stops every running engine first, so none are orphaned.
259+
`engine:prepare-shutdown` requests that same sweep while the service is still fully up, which is how the parent drains engines before it starts tearing the worker tree down.
260+
The sweep runs **once per process** no matter how many of these paths ask for it, and a caller that arrives while it is already running waits for it to finish — responding earlier would report engines stopped while they were still running, and the parent would exit and orphan them.
261+
A later caller does not repeat the sweep; it retries only an engine whose stop failed through a process handle or a stop command, not an adopted engine whose stop was declined.
252262

253263
For the `llamacpp-models-sse` adapter, the monitored model must match
254264
`params.model`. Subscribe before starting, and bound the `POST /models`

‎services/nvpair-engine-manager/testdata/fakeengine/main.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,17 @@ func main() {
130130
}
131131
fmt.Fprintf(os.Stderr, "Error: Failed to resolve artifact %q: The artifact does not exist or you do not have permission to read it\n", strings.ToLower(arg))
132132
os.Exit(1)
133+
case "failoncethentouch": // fail the first call, then write a marker — for stop-retry tests
134+
// args: failoncethentouch <attemptfile> <marker>
135+
if len(os.Args) < 4 {
136+
os.Exit(2)
137+
}
138+
if _, err := os.Stat(os.Args[2]); err != nil {
139+
_ = os.WriteFile(os.Args[2], []byte("1"), 0o644)
140+
os.Exit(1)
141+
}
142+
_ = os.WriteFile(os.Args[3], []byte("ok"), 0o644)
143+
return
133144
case "noserve": // run but never bind — used to test readiness timeout
134145
time.Sleep(time.Hour)
135146
return

0 commit comments

Comments
 (0)