Repository navigation
fix: quit promptly by sweeping engines once per shutdown - #137
Open
Noah-Tervalon-Nvidia wants to merge 13 commits into
Open
Noah-Tervalon-Nvidia wants to merge 13 commits into
Noah-Tervalon-Nvidia wants to merge 13 commits into
Conversation
An ordinary quit asked for the engine sweep three times: the desktop sends engine:prepare-shutdown, the broker sends it again from its own teardown, and closing stdin then reaches the EOF path. Each is the right trigger for a different way of being shut down, so all three stay -- but StopAll had no guard, so all three ran it in full. Repeating the sweep is free for an engine we own, which the first sweep stops and the rest skip at doStop's !st.running check. It is not free for an adopted one: its stop can only be declined, so st.running stays true and every sweep re-pays doStop's readiness probe. Measured against a real worker tree holding an externally-managed Ollama, the three sweeps cost 1068ms + 1052ms + 1040ms -- essentially the whole 3.2s quit. The reclaim path's grace+2s wait can push a single sweep to ~7s, which is how quitting reached the ~15s the desktop waits before force-killing the tree. Later callers wait for the sweep in flight rather than returning early. Returning early would report engines stopped while they were still running, and the parent would close stdin and exit, orphaning them. Signed-off-by: Terve <[email protected]>
The ten worker joins were ten separate `defer sup.Stop()` statements, and Go runs deferrals one at a time, so teardown cost the sum of the per-worker exits rather than the slowest one. The proxy and node-info both drain HTTP servers on their way out, so on a loaded tree the sum is the difference between one drain and several. Fanning out is safe because no ordering between workers is left by then: shutdownInferenceStack has already closed ingress at the proxy and waited out the engine sweep, which are teardown's only two dependencies. Joining concurrently also lets graceFor drop its engine-manager reservation. That existed because engine-manager was joined late in the sequence and the workers ahead of it decided how long it got; now they all read the same remaining budget at once, and the reservation had become a penalty that cut healthy workers to the floor. Adds a warn-level per-worker breakdown when teardown is slow. The desktop runs the broker at --log-level warn, so the existing diagnostics were absent from exactly the logs collected when a user reports a slow quit: they only fire for a worker that misses its grace, and a teardown that is merely the sum of prompt exits trips none of them. Signed-off-by: Terve <[email protected]>
The comment claimed startup was bound on "broker + proxy readiness", but initializeConnector awaits only waitUntilReady, and readiness is `isReady && brokerReady` where isReady just means the subprocesses were spawned. Proxy readiness never held the Overview window closed, which matters when reading startup latency: time-to-proxy-ready is not time-to-usable. Signed-off-by: Terve <[email protected]>
system-architecture.mdc said the connector stays `connecting` until broker app:ready "and the required Ollama proxy readiness" arrive. Only app:ready gates it; proxy readiness is an asynchronous capability signal reported afterwards, as tests/modular/modular-supervisor-readiness.test.ts asserts and desktop/docs/services-backend.md already describes correctly. Signed-off-by: Terve <[email protected]>
The unit guard in nvpair-engine-manager proves StopAll runs once, but nothing covered the property that actually matters to a user: that the desktop's real quit sequence, through the real broker and a real engine-manager, produces one engine sweep rather than three. This drives that sequence against an adopted engine -- a listener whose readiness probe succeeds and which engine-manager never launched, so its stop can only be declined and it stays marked running. That is the shape whose repeat sweeps cost real seconds. It counts sweeps rather than milliseconds so it cannot flake on a loaded machine, and fails loudly if the engine was not adopted rather than passing while covering nothing. Against the unguarded sweep it reports three sweeps and a 2s longer teardown. Signed-off-by: Terve <[email protected]>
TestStopAllWaitsForTheSweepInFlight checked the first caller's completion with a non-blocking select. Both callers return when the sweep ends, but the first goroutine's deferred close can land a moment after the second caller returns, so a correct join could fail. It now waits briefly, still far less than the probe an early return would skip. TestQuitSweepsEnginesOnce's fixtures now use http.StatusOK and io.Closer rather than a bare 200 and an anonymous interface, and its setup failures say which step failed. Signed-off-by: Terve <[email protected]>
Both documents said the broker waits for each worker without force-killing it. The joins are concurrent within one teardown budget and escalate a worker that misses its grace, except engine-manager on Windows, and engine-manager now sweeps its engines once however many callers ask. Signed-off-by: Terve <[email protected]>
The stopWorkers comment described what the previous defer order used to allow, and the reportWorkerJoins comment recounted how a slow teardown was first noticed. Neither says anything about the code as it stands; the warn-level rationale is kept in present tense. Signed-off-by: Terve <[email protected]>
With one sweep per process, a stop that failed in it was never attempted again. A failed lms server stop leaves the command-mode engine marked running, and the broker's own engine:prepare-shutdown and the stdin-EOF path used to re-run doStop for it, so LM Studio could now outlive a quit. Later StopAll callers still wait for the sweep and still skip adopted engines whose stop is declined, which keeps quitting prompt. They now retry the engines whose stop failed through a process handle or a stop command, one caller at a time. Signed-off-by: Terve <[email protected]>
The quit test counted every declined stop, so a real Ollama on the host was adopted and declined in the same sweep and the test reported two sweeps where one ran. It now matches the foreign engine by name. It also checks the engine:get-installed reply for an error and, like the desktop, closes stdin only once the engine:prepare-shutdown reply arrives, draining both broker streams meanwhile. Signed-off-by: Terve <[email protected]>
…eardown stopWorkers now stops the proxy before fanning out the joins. On the ordinary path shutdownInferenceStack has already stopped it and the second stop returns at once; on an early Serve return, such as a failed app:ready, it is what keeps inference from arriving while engine-manager stops its engines. The slow-teardown report timed only the concurrent worker joins, which already have their own per-worker warning at the same threshold, and missed the proxy join and the engine-manager sweep ahead of them. It now measures from the start of teardown and names those phases alongside each join. The README's shutdown paragraph now says which waits draw on the budget and that escalation can add up to 4s past it. Signed-off-by: Terve <[email protected]>
TestStopAllWaitsForTheSweepInFlight slept 100ms and assumed the goroutine had taken the sweep by then. If it was scheduled late, the main call owned the sweep and an implementation that returns early still passed. The second call now waits until the first caller has set shuttingDown, which only the sweep does. TestStopAllSweepsOnce's doc now states the constraint without the old timings. Signed-off-by: Terve <[email protected]>
…rrent The spec said engine:prepare-shutdown returns once every running engine has been stopped. It returns once the sweep and any retry of a failed stop have finished; a stop that still fails is logged, not returned. frontend-api.md still said the connection waits for Ollama proxy readiness. Only broker app:ready gates it, as main.ts and the other docs already say. The teardown test docs state what each test checks rather than how teardown used to behave. Signed-off-by: Terve <[email protected]>
Noah-Tervalon-Nvidia
force-pushed
the
fix/parallel-teardown-gh
branch
from
October 2, 2026 20:46
dcf57a0 to
a6639d3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Quitting took seconds longer than it should whenever an engine was running outside PAIR's control, because the engine shutdown sweep ran three times per quit.
Executor.StopAll()had no idempotency guard, and three callers reach it on an ordinary quit: the desktop'sengine:prepare-shutdown, the broker's own teardown, and engine-manager's stdin-EOF path. Each is the right trigger for a different way of being shut down, so all three stay. Repeating the sweep is free for an engine PAIR owns, but an adopted engine's stop can only be declined, so it stays marked running and every sweep re-pays its readiness probe, about a second each against an externally managed Ollama.StopAllnow runs one sweep per process. Later callers wait for it rather than returning early, which would report engines stopped while they were still running and orphan them.The broker also joins its ten workers concurrently instead of through ten sequential
defer sup.Stop()calls, so teardown costs the slowest worker rather than the sum. That letsgraceFordrop the engine-manager reservation that existed only because of join order. When teardown is slow, the broker logs a per-worker breakdown at warn, the level the desktop runs it at.Docs: the desktop startup comment and
system-architecture.mdcsaid proxy readiness gates the startup window, when only brokerapp:readydoes.services-backend.mdandservices-parity.mdsaid the broker never force-kills a worker; they now describe the single sweep and the concurrent, budgeted teardown.Release intent
Changelog title
Quitting is prompt again
Changelog body
Bumps
nvpair-engine-manager(single sweep) andnvpair-ui-broker(concurrent joins, slow-teardown log) change compiled output.Scope
In: the single sweep, concurrent worker joins, the slow-teardown log, and the docs corrections. There is one proxy process, so closing ingress is still a single join and
shutdownInferenceStackis unchanged.Out, as follow-ups for slow relaunch:
nvpair-node-scanner'srefreshPeersLoopdoes no initial sweep, so its first refresh lands at 15s, andprepareManagedLMStudioFacadeWithPortCheckcallsengine:set-portat startup with no timeout.Validation
On macOS (Apple silicon):
TestQuitSweepsEnginesOnceinservices/testsdrives the desktop's quit sequence through the real broker and engine-manager against an adopted engine. Ondevelop: 3 sweeps, 4.13s. On this branch: 1 sweep, 2.01s. Both include a fixed 2s settle, and the test counts sweeps rather than milliseconds so it cannot flake on timing.TestStopAllSweepsOnce,TestStopAllWaitsForTheSweepInFlight,TestStopWorkersJoinsConcurrently,TestStopWorkersSkipsUnstartedTree.cd services/nvpair-ui-broker && go test ./... -count=1, and again with-race: pass.cd services/nvpair-engine-manager && go test ./... -count=1: pass exceptTestUninstallTerminatesRunningInstance, which fails identically ondevelopon macOS and is fixed by fix(engine-manager): Stopping a leftover Ollama engine works on macOS #136.cd services/tests && go test ./... -count=1: pass (354s).verify:build-scripts,service-contracts:check,typecheck,lint,dead-code:check, andtest:unit(231 tests) pass.node scripts/spdx-headers.mjs: 0 missing.Risk
Workers are no longer joined in a fixed order. That is safe because by then ingress is closed and the engine sweep has finished, which are teardown's only two dependencies. One property of the old order goes away: a worker that crashes during teardown may no longer reach
nvpair-errors, whose output is being discarded at that point anyway.Checklist
git commit -s), certifying the Developer Certificate of Origin.services/versions.jsonis written by automation — do not edit it by hand.Review order
services/nvpair-engine-manager/lifecycle.goandexecutor.go: the fix.StopAllruns the sweep through async.Once, so later callers block until the first sweep finishes.services/nvpair-engine-manager/shutdown_test.go:TestStopAllSweepsOnceandTestStopAllWaitsForTheSweepInFlight.services/nvpair-ui-broker/stopwait.go:stopWorkersjoins workers concurrently,graceFordrops the engine-manager reservation, andreportWorkerJoinslogs slow teardowns at warn.services/nvpair-ui-broker/broker.go: mostly mechanical. Eachdefer <sup>.Stop()becomestrackWorker, andServedefersstopWorkers. Worth checking that every started worker is tracked exactly once.services/nvpair-ui-broker/stopwait_test.go: tests for 3 and 4.services/tests/shutdown_latency_test.go: the end-to-end test, driving the desktop's quit sequence against an adopted engine.README.mdandspec.md, brokerREADME.md,desktop/docs/services-backend.md,desktop/docs/services-parity.md, the startup comment indesktop/src/electron/main.ts, and.cursor/rules/system-architecture.mdc.