Skip to content

fix: quit promptly by sweeping engines once per shutdown - #137

Open
Noah-Tervalon-Nvidia wants to merge 13 commits into
developfrom
fix/parallel-teardown-gh
Open

Noah-Tervalon-Nvidia wants to merge 13 commits into
developfrom
fix/parallel-teardown-gh

Conversation

@Noah-Tervalon-Nvidia

@Noah-Tervalon-Nvidia Noah-Tervalon-Nvidia commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

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's engine: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.

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 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 lets graceFor drop 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.mdc said proxy readiness gates the startup window, when only broker app:ready does. services-backend.md and services-parity.md said 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

  • 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 three times over, and each repeat cost about a second.
  • The background services now shut down in parallel rather than one at a time, so a busy machine no longer pays each service's shutdown in turn.

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: patch
  • nvpair-workload-manager: none

nvpair-engine-manager (single sweep) and nvpair-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 shutdownInferenceStack is unchanged.

Out, as follow-ups for slow relaunch: nvpair-node-scanner's refreshPeersLoop does no initial sweep, so its first refresh lands at 15s, and prepareManagedLMStudioFacadeWithPortCheck calls engine:set-port at startup with no timeout.

Validation

On macOS (Apple silicon):

  • New TestQuitSweepsEnginesOnce in services/tests drives the desktop's quit sequence through the real broker and engine-manager against an adopted engine. On develop: 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.
  • New unit tests: 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 except TestUninstallTerminatesRunningInstance, which fails identically on develop on 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).
  • Desktop: verify:build-scripts, service-contracts:check, typecheck, lint, dead-code:check, and test: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

  • I have read the Contributing Guidelines.
  • Every commit is signed off (git commit -s), certifying the Developer Certificate of Origin.
  • New or existing tests cover the change.
  • Relevant documentation is updated.
  • I checked the diff, changed filenames, and commit messages for credentials, private data, internal URLs, internal issue identifiers, and generated artifacts.
  • I recorded the validation commands and results above.
  • 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: the fix. StopAll runs the sweep through a sync.Once, so later callers block until the first sweep finishes.
  2. services/nvpair-engine-manager/shutdown_test.go: TestStopAllSweepsOnce and TestStopAllWaitsForTheSweepInFlight.
  3. services/nvpair-ui-broker/stopwait.go: stopWorkers joins workers concurrently, graceFor drops the engine-manager reservation, and reportWorkerJoins logs slow teardowns at warn.
  4. services/nvpair-ui-broker/broker.go: mostly mechanical. Each defer <sup>.Stop() becomes trackWorker, and Serve defers stopWorkers. Worth checking that every started worker is tracked exactly once.
  5. services/nvpair-ui-broker/stopwait_test.go: tests for 3 and 4.
  6. services/tests/shutdown_latency_test.go: the end-to-end test, driving the desktop's quit sequence against an adopted engine.
  7. Docs and comments: engine-manager README.md and spec.md, broker README.md, desktop/docs/services-backend.md, desktop/docs/services-parity.md, the startup comment in desktop/src/electron/main.ts, and .cursor/rules/system-architecture.mdc.

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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant