Skip to content

fix: bind upstream workers and supervisor actions to the server generation (Spec 116 PR3; #1563) - #1608

Merged
Dumbris merged 13 commits into
mainfrom
fix/116-pr3-stale-workers
Oct 13, 2026
Merged

Dumbris merged 13 commits into
mainfrom
fix/116-pr3-stale-workers

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 13, 2026

Copy link
Copy Markdown
Member

Scope

Spec 116 PR3 only (specs/116-config-commit-consistency/plan.md §PR3, tasks.md T060-T077): every connect/disconnect worker, supervisor action, RestartServer, ForceReconnectAll/Docker recovery and ConnectAll is bound to the server's commit generation (and, for an action on an existing instance, to that instance) and re-checks it inside the manager's lock before acting. A stale one is inert, so it can no longer re-install an old config, re-create a removed server, unquarantine a quarantined one or disconnect a replacement after a newer commit (#1563).

Not in this PR: the dial gate / retirement barrier incl. OAuth (PR4, #1564) and the OAuth credential binding (#1607).

Design

  • Commit step 7 calls upstream.Manager.AdvanceGenerations (replacing PR1's RestampRetained) under captureMu W + Manager.mu: it records every configured server's committed generation in the manager's desired view (a tombstone for a removed name), unregisters the instance of every removed or replaced server and of every server whose connection settings, enabled or quarantined changed, and restamps retained instances. This happens before the snapshot is published; the unregistered instances are disconnected after the commit lock is released.
  • Bound manager API: AddServerConfigAt/AddServerAt, RemoveServerAt, ConnectServerAt (generation), DisconnectServerAt(name, gen, InstanceID) and ReplaceInstanceAt(name, gen, expected, cfg) (generation + exact instance). Each returns ErrStaleGeneration (= commitview.ErrStaleGeneration) and does nothing unless the check passes in the same critical section as the change. Callers log it at debug level.
  • Instance identity: managed.Client.InstanceID() (process-wide, never reused). Instances are stamped with their generation at construction (managed.NewClientAt). Every removal from Manager.clients goes through unregisterLocked (and every insert through installLocked).
  • Callers: the commit's post-unlock workers (connectWorker/disconnectWorker) use the generation of their commit and connect the exact instance they installed. The supervisor binds each action to the generation of the snapshot it was planned from; ActionDisconnect also binds to the InstanceID it observed (ActorPoolSimple no longer resolves by name). RestartServer, ForceReconnectAll and Docker recovery replace the instance at the same generation through ReplaceInstanceAt. ConnectAll skips an instance that is not at its committed generation. The OAuth logout disconnect is bound to the instance it marked logged out (the hazard recorded on bug: stale connect/disconnect workers and supervisor actions re-install old server configs after a newer commit #1563); the token clear stays name-keyed (Spec 116 PR2b: bind OAuth token reads/writes to the server incarnation #1607).
  • Discovery/capture: D15's generation-0 exemption is removed: a result from an instance whose generation is not the stored row's is dropped. Last-good tool snapshots carry the generation of the instance they were listed from, so an approval-driven reindex re-lists after an endpoint change instead of reapplying the old endpoint's tools (FR-021b). The commit's step 7 now always takes captureMu for writing, so it waits for a capture section in progress (T3.5).
  • CI guard: scripts/check-upstream-write-sites.sh also fails on an unbound manager mutation (AddServerConfig/AddServer/RemoveServer/DisconnectServer on the manager) in internal/runtime or internal/server, on an internal legacy call (m.X(...)) in internal/upstream, and on any m.clients write outside unregisterLocked/installLocked (self-test extended).
  • Live QA hooks: internal/qahooks (-tags qa_hooks only; nil in release builds): MCPPROXY_QA_PARK_WORKER, MCPPROXY_QA_PARK_SUPERVISOR_DISCONNECT, MCPPROXY_QA_RELEASE_DIR.

Deviations from the spec text (documented in tasks.md PR3 implementation note)

  1. unregisterLocked also marks the instance unregistered: Connect on it returns managed.ErrInstanceUnregistered, a dial in flight when it was unregistered discards its result, and its state transitions are no longer notified under the server name. Without this a stale holder (a worker, the reconnect loop, ConnectAll) could reconnect an instance the manager no longer serves and leak its process (the baseline live run shows such leaks), and a superseded instance's late Disconnected notification could overwrite the replacement's Connected status (seen live, fixed in the last commit). This is a connect-level latch only; the byte-level Retire barrier remains PR4.
  2. AdvanceGenerations receives the whole committed server set (plus removals), not only the changes, so desired is complete after the boot and admission-gate commits. It returns AdvanceResult{Unregistered, Restamped}.
  3. ReplaceInstanceAt takes an optional config and returns the new instance, which the caller connects (Manager.ConnectInstance).
  4. ActionReconnect has no disconnect leg since fix: disabling a server removes its tools from search again (regression from #1590) #1603, so only ActionDisconnect carries an InstanceID. ActionRemove uses RemoveServerAt(name, 0), which acts only while the committed configuration has no such name.
  5. The legacy unbound AddServerConfig/AddServer/RemoveServer stay for tests and CLI-only managers; for a name a commit recorded they install at the committed generation and never move it. The daemon never calls them (CI grep).
  6. Last-good snapshots are stamped with the generation only; the incarnation is already covered by PR2's purge sequence.
  7. actor_pool_complex_reference.go (unreferenced, unbound manager calls) is deleted. The test-only discovery wrappers moved to a _test.go file and bind to the committed generation.

Lock order

Unchanged from plan.md §Lock order: L0 configCommitMu → L1 approval locks (released before step 7) → L2 storage; L0 → L3 captureMu (W) → L4 Manager.mu in step 7 (AdvanceGenerations). The bound methods take L3 W → L4 for registry changes and L4 R for checks; none holds L3/L4 while connecting or disconnecting, and none calls into the runtime. Capture sections (L3 R → L1) are unchanged; the commit still never holds L1 and L3 together. TestLockOrder_* pass at -race -count=20, GOMAXPROCS=1 and 4.

Compatibility

No config, storage, REST or CLI change. Behaviour visible to users: an edit that changes a server's connection settings, enabled or quarantined unregisters the old instance when the commit publishes (the commit's worker installs the new one), and a restart replaces the instance atomically. The manager API gains the bound methods; the unbound ones remain for tests/CLI.

Verification

  • Failing first (commit 190e9b4d6, seams only): T3.1 (remove/replace/quarantine), T3.2, T3.3 (real ActorPoolSimple + upstream.Manager; remove/replace/quarantine), T3.4, T3.5b, T3.6, T3.7, T3.9 all fail on main. T064/T3.8/T3.10 (internal/upstream/manager_generation_test.go) use the new API.
  • go test -race -tags server -timeout 30m -skip "E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint" ./internal/...: all packages ok, no data race.
  • PR3 tests + TestLockOrder* + T1.19 at -race -count=20, GOMAXPROCS=1 and 4: ok. TestE2E_DisableServerRemovesToolsFromSearch -count=20: 20/20.
  • golangci-lint v2 bare and --build-tags server (--new-from-rev=origin/main): 0 issues. check-upstream-write-sites.test.sh: 39/39. Both editions and -tags qa_hooks build.
  • make build + ./scripts/test-api-e2e.sh on scratch ports 19005/19006: 70/70.
  • Live L3 on fresh qa070 scratch daemons (big profile + custom servers, -tags qa_hooks), baseline main vs PR3:
Check main PR3
Enable worker parked, server removed, released removed server's process resurrected no client, no process
Worker of an older args edit parked, newer edit connected, released stale args re-installed (racy: 2 of 3 runs) newest args only, connected
Supervisor disconnect of instance I1 parked, same-generation restart installs I2, released I2 killed I2 keeps running
15 rapid edits / disable-enable / restarts on a slow-connecting server 3-4 leaked fixture processes one process with the latest args, stable for 30 s, connected
Quarantined server capture on demand 12 tools in review 12 tools in review

Evidence: docs/qa/0.70.0-ux-2026-10-10/remediation/evidence/116pr3/EVIDENCE.md (not committed).

Fixes #1563

🤖 Generated with Claude Code

…or actions (#1563)

Adds the PR3 test seams (beforeWorkerAct/afterWorkerAct in the post-commit
and RestartServer workers, the supervisor beforeExecuteAction hook) and
T3.1-T3.7 and T3.9, which fail on main: a parked worker, restart worker or
supervisor action re-installs an old config, removes a re-enabled instance
or disconnects a replacement after a newer commit.
…ation (Spec 116 PR3; #1563)

The config commit's step 7 now calls upstream.Manager.AdvanceGenerations,
which records every server's committed generation in the manager (a
tombstone for a removed one), unregisters the instance of every removed,
replaced or reconnecting server before the snapshot publishes, and restamps
retained instances (replacing PR1's RestampRetained).

Every asynchronous registry change or connect is bound to the generation it
was scheduled for and re-checked under captureMu + Manager.mu in the same
critical section: the commit's connect/disconnect workers (AddServerAt,
RemoveServerAt), supervisor actions (generation from their planning
snapshot; ActionDisconnect also bound to the observed InstanceID),
RestartServer, ForceReconnectAll and Docker recovery (ReplaceInstanceAt at
the same generation), ConnectAll and the OAuth logout disconnect. A stale
call returns ErrStaleGeneration and does nothing. Instances leave the
registry only through unregisterLocked, which marks them unregistered so they
never connect again; workers connect the exact instance they installed.

Instances are stamped with their generation at construction (NewClientAt),
D15 drops the generation-0 exemption, and last-good tool snapshots carry the
instance generation so an approval reindex re-lists after an endpoint change
instead of reapplying old tools. The write-site check forbids unbound manager
mutations in internal/runtime and internal/server and any m.clients write
outside unregisterLocked/installLocked. qa_hooks adds the L3 park points.
…nder the server name

The Disconnect of an instance a commit or replacement unregistered runs after
the commit lock is released, concurrently with the replacement's connect, and
its Disconnected notification (keyed by server name) could overwrite the
replacement's Connected status. Superseded instances are now silent; seen
live as connected=false for a connected server after a same-generation
restart raced an endpoint edit.
…thod values and multiline registry mutations; align the L3 recipe (Spec 116 PR3 Sol round 1)

- managed.Client.ConfigAt returns (config, generation) from one atomic load.
  ForceReconnectAll uses it, and when only a restamp moved the generation of
  the same registered instance it re-reads the pair and retries, so Docker
  recovery never installs the pre-restamp config at the new generation.
- check-upstream-write-sites.sh: the FR-020 scan flags legacy manager method
  values, calls split across lines and multiline m.clients deletes and
  assignments; planted cases added to the self-test.
- quickstart L3 now names the servers, park target, release file and PID
  assertions the shipped live script uses.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1b69a14
Status: ✅  Deploy successful!
Preview URL: https://efa28463.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-116-pr3-stale-workers.mcpproxy-docs.pages.dev

View logs

…et and comment-hidden registry writes (Spec 116 PR3 Sol round 2)

Every registry mutation takes Manager.limiterPubMu before releasing mu and
holds it across the Spec 093 limiter publication, so a stale action that
validated its generation and lost the CPU after the unlock can no longer
retire or overwrite the limiter a newer commit published. The FR-020
scanner finds assignments by operator (multi-target LHS included), matches
clear(m.clients), and tracks the enclosing function on lexed code from
column-0 declarations only.
…es and select receive cases (Spec 116 PR3 Sol round 3)
…ndex expressions split across lines (Spec 116 PR3 Sol round 4)
…l inside an index keeps the m.clients LHS (Spec 116 PR3 Sol round 5)
…he FR-020 scan (parenthesized, alias, method expression) (Spec 116 PR3 Sol round 6)
…ear in the FR-020 scan (parenthesized, dereferenced, alias) (Spec 116 PR3 Sol round 7)
…ge-level func literal after installLocked/unregisterLocked is scanned (Spec 116 PR3 Sol round 8)
… with Spec 117 PR-2/PR-3 and held-change review UX)
@codecov-commenter

codecov-commenter commented Oct 13, 2026 •

Copy link
Copy Markdown

…he current instance in the inventory stress test (Spec 116 PR3 resync)

Under Spec 116 PR3 a commit that changes Quarantined unregisters the
instance under captureMu (retiring it) and the commit's connect worker
installs the instance of the new generation asynchronously. The #1609
stress test required GetClient to succeed synchronously after the final
unquarantine, which fails whenever the last toggle left the server
quarantined (5/20 under GOMAXPROCS=2). Wait for the current instance to
be registered and connected; the final capture and the index/approval
consistency check are unchanged.
@github-actions

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/116-pr3-stale-workers

Available Artifacts

  • archive-darwin-amd64 (31 MB)
  • archive-darwin-arm64 (28 MB)
  • archive-linux-amd64 (19 MB)
  • archive-linux-arm64 (17 MB)
  • archive-windows-amd64 (31 MB)
  • archive-windows-arm64 (28 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (27 MB)
  • installer-dmg-darwin-arm64 (25 MB)
  • smart-mcp-proxymcpproxy-goVNPMTQ.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 38347737392 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@Dumbris
Dumbris merged commit adcd74e into main Oct 13, 2026
57 of 58 checks passed
Dumbris added a commit that referenced this pull request Oct 13, 2026
…ows PR Build timeout) (#1613)

## Problem

`internal/runtime` took ~472s in the PR Build `Build Binaries
(windows-latest, windows, amd64, …)` job, whose `go test` runs with the
default 10m timeout. It timed out once on #1608 and passed on rerun.

Timings from run 38347737392 (Windows job 115142245158):

| Test | Windows |
|---|---|
| `TestConfigCommit_CommitPathsConverge` | 92.0s |
| `TestReviewPayload_MasksSecretsInsideShellCommandArgs` | 11.6s |
| `TestUnquarantineViaConfigReload_LeavesIndexAlone` | 8.2s |
| … 78 more tests ≥1s | 277.7s total for tests ≥1s |

The package is I/O-bound rather than CPU-bound: on macOS it ran 263s at
18% CPU. Nearly every test builds a `Runtime`, and each one writes the
config file (temp file + fsync + rename), commits to `config.db` (bbolt
fsync per commit) and creates a Bleve index (scorch `root.bolt`).
`FlushFileBuffers` is very expensive on the Windows runner.
`TestConfigCommit_CommitPathsConverge` runs 200 rounds of five fsynced
commit paths.

## Fix

- `config.DisableWriteSyncForTesting()` makes `atomicWriteFileMode` skip
the fsync before the rename.
- `storage.DisableSyncForTesting()` makes `NewBoltDB` open with bbolt
`NoSync`.
- A `TestMain` in `internal/runtime` calls both and also sets
`bbolt.DefaultOptions.NoSync`, which Bleve's scorch copies for
`root.bolt`. A second `TestMain` in `internal/storage` (~147s on
Windows) calls `DisableSyncForTesting`.

Production code paths are unchanged: both switches default off, and only
`_test.go` files call them. No test is skipped, shortened or retimed,
and no timeout is raised. Writes are still visible in-process to every
reader; only crash durability is dropped, and no test in these packages
simulates a crash.

## Local results (macOS)

| | Before | After |
|---|---|---|
| `internal/runtime` (no race) | 263s, 18% CPU | 121s |
| `TestConfigCommit_CommitPathsConverge` | 16.3s | 0.5s |
| `go test -race` runtime/storage/config | — | all pass (runtime 259s) |

## Windows results (PR Build, windows-latest amd64)

| | Before (run 38347737392) | After (run 38359460609) |
|---|---|---|
| Job duration | 13m45s | 7m04s |
| `internal/runtime` | 471.7s | 166.5s |
| `internal/storage` | 147.4s | 9.6s |
| `TestConfigCommit_CommitPathsConverge` | 92.0s | 1.0s |

No test failures in the job.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.

bug: stale connect/disconnect workers and supervisor actions re-install old server configs after a newer commit

2 participants