You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 72d5605
Browse filesBrowse the repository at this point in the historyBrowse 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>
Copy file name to clipboardExpand all lines: services/nvpair-engine-manager/spec.md
+11-1Lines changed: 11 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -157,6 +157,7 @@ Requests (caller → service):
157
157
|`engine:remote-delete-model`|`{ node, engine, model }`| the remote action result |
158
158
|`engine:remote-start`|`{ node, engine, port? }`|`EngineStatus` from the remote node (manifest `runtime.bind`; no per-call bind on the remote path) |
159
159
|`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 |
160
161
|`shutdown`| — |`null`|
161
162
|`log/set-level`|`{ level }`|`{ level }`|
162
163
@@ -248,7 +249,16 @@ The operator starts it: `engine:start {engine:"ollama"}` resolves the manifest r
248
249
249
250
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.
250
251
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.
252
262
253
263
For the `llamacpp-models-sse` adapter, the monitored model must match
254
264
`params.model`. Subscribe before starting, and bound the `POST /models`
Copy file name to clipboardExpand all lines: services/nvpair-engine-manager/testdata/fakeengine/main.go
+11Lines changed: 11 additions & 0 deletions
Original file line number
Diff line number
Diff line change
@@ -130,6 +130,17 @@ func main() {
130
130
}
131
131
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))
132
132
os.Exit(1)
133
+
case"failoncethentouch": // fail the first call, then write a marker — for stop-retry tests
134
+
// args: failoncethentouch <attemptfile> <marker>
135
+
iflen(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
133
144
case"noserve": // run but never bind — used to test readiness timeout
0 commit comments