Skip to content

fix(runtime): add config file watcher so external edits hot-reload (HOTRELOAD)#857

Merged
github-actions[bot] merged 12 commits into
mainfrom
fix/rc51-hotreload
Jul 22, 2026
Merged

fix(runtime): add config file watcher so external edits hot-reload (HOTRELOAD)#857
github-actions[bot] merged 12 commits into
mainfrom
fix/rc51-hotreload

Conversation

@Dumbris

@Dumbris Dumbris commented Jul 15, 2026

Copy link
Copy Markdown
Member

Problem

Editing mcp_config.json externally (editor save, jq > tmp && mv, CLI commands against a running core) never took effect until restart, even though CLAUDE.md and the swagger docs both claim "the file watcher hot-reloads". Only REST PATCH /api/v1/config worked, because it goes through a different path (ApplyConfig). Found by v0.51.0-rc.1 QA (2026-07-15).

Root cause

The watcher never existed. fsnotify was only an indirect dependency (go.mod:89) with zero imports; the code itself admitted it (internal/runtime/lifecycle.go:1117, :1426, cmd/mcpproxy/telemetry_cmd.go:294 — "there is no fsnotify watcher"). The complete disk-reload path Runtime.ReloadConfiguration() (internal/runtime/lifecycle.go:1081) existed with no automatic caller, and configsvc was built watcher-ready (Update.Source example "file_watcher", configsvc/service.go:29) but nothing ever fed it.

Fix

New internal/runtime/config_watcher.go, started from StartBackgroundInitialization and tied to the runtime app context:

  • Watches the config file's parent directory (never the file), so rename/recreate writes — mv tmp file, atomic-write editors, mcpproxy's own atomicWriteFile — can never orphan the watch. Covers in-place truncate writes too.
  • Debounces events 500 ms and funnels into the canonical disk-reload path Runtime.ReloadConfiguration() (same events / telemetry NotifyConfigChanged / update-check re-gating / ConnectAll + reindex as manual reload).
  • Self-write suppression: disk bytes are compared against the marshaled in-memory snapshot, so mcpproxy's own ApplyConfig/SaveConfiguration saves never echo back into a redundant reload. No loop risk: ReloadConfiguration never writes the file.
  • Invalid JSON keeps the previous config (ReloadFromFile fails before mutating state); the watcher survives and picks up the next valid write.
  • Watcher startup failure degrades gracefully (warning; server keeps running without hot-reload).

Also: promoted fsnotify v1.9.0 indirect → direct (already in go.sum, no new third-party code), updated the three stale "there is no fsnotify watcher" comments, and documented debounce / invalid-JSON / restart-required semantics in docs/configuration.md.

Known limitations (accepted): a config-path change after startup is not re-watched; restart-required fields (listen, data_dir) edited externally reload into memory but still need a restart — same semantics as the manual reload path.

Tests

New internal/runtime/config_watcher_test.go (written first, TDD — failed with startConfigFileWatcher undefined pre-fix):

  • TestConfigWatcher_RenameWriteTriggersReload — atomic tmp+rename edit hot-reloads
  • TestConfigWatcher_TruncateWriteTriggersReload — in-place > edit hot-reloads
  • TestConfigWatcher_InvalidJSONKeepsOldConfigThenRecovers — bad JSON keeps old config + version; watcher recovers
  • TestConfigWatcher_SelfWriteSuppressedApplyConfig's own save produces no file_reload echo
  • TestConfigWatcher_DebounceCoalesces — 10 rapid writes collapse to ≤3 reloads, final value lands
  • TestConfigWatcher_MissingDirGracefulDegradation — missing parent dir errors without panic

Commands run:

  • go test -race ./internal/runtime/... — all pass
  • go test -race ./cmd/mcpproxy/... — pass
  • go test -tags server ./internal/serveredition/... -race — pass
  • go build ./cmd/mcpproxy and go build -tags server ./cmd/mcpproxy — both compile
  • golangci-lint run --config .github/.golangci.yml ./internal/runtime/... ./cmd/mcpproxy/... — 0 issues

…OTRELOAD)

The config file watcher advertised by CLAUDE.md and the swagger docs never
existed: fsnotify was only an indirect dependency and nothing in the code
watched mcp_config.json. External edits (editor saves, jq > tmp && mv, CLI
commands against a running core) silently never took effect until restart,
while REST PATCH worked because it goes through ApplyConfig.

Add internal/runtime/config_watcher.go: an fsnotify watcher on the config
file's parent directory (rename/recreate-safe), started from
StartBackgroundInitialization and tied to the runtime app context. Events
for the config file are debounced 500ms and funneled into the existing
canonical disk-reload path Runtime.ReloadConfiguration (events, telemetry
NotifyConfigChanged, update-check re-gating, ConnectAll + reindex included).

Safety properties:
- Self-write suppression: disk bytes are compared against the marshaled
  in-memory snapshot, so mcpproxy's own ApplyConfig/SaveConfiguration
  writes never echo back into a redundant reload.
- Invalid JSON keeps the previous configuration (ReloadFromFile fails
  before mutating state) and the watcher survives to pick up the next
  valid write.
- Watcher startup failure degrades gracefully to the previous no-watcher
  behavior with a warning.

Promote fsnotify v1.9.0 from indirect to direct in go.mod (already in
go.sum, no new third-party code). Update stale comments that documented
the watcher's absence, and document debounce/invalid-JSON/restart-required
semantics in docs/configuration.md.

Known limitations: a config-path change after startup is not re-watched;
restart-required fields edited externally reload into memory but still
need a restart to take effect (same semantics as manual reload).
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 15, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8296150
Status: ✅  Deploy successful!
Preview URL: https://ff5a00e4.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-rc51-hotreload.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 67.32673% with 66 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/runtime/config_watcher.go 73.87% 20 Missing and 9 partials ⚠️
internal/runtime/lifecycle.go 48.00% 21 Missing and 5 partials ⚠️
internal/runtime/runtime.go 75.00% 5 Missing and 4 partials ⚠️
internal/upstream/manager.go 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/rc51-hotreload

Available Artifacts

  • archive-darwin-amd64 (28 MB)
  • archive-darwin-arm64 (25 MB)
  • archive-linux-amd64 (16 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (22 MB)
  • installer-dmg-darwin-arm64 (20 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 29894129166 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Dumbris added 7 commits July 15, 2026 22:01
…aves

A restart-required ApplyConfig (listen/data_dir/api_key/TLS change) saves the
new config to disk but intentionally leaves memory on the old one
(requires_restart contract). The watcher's snapshot-based self-write
suppression compared disk against memory, misread that save as an external
edit, and hot-applied the deferred config ~500ms after the API reported
applied_immediately=false.

Record the marshaled bytes of every ApplyConfig save (noteConfigSelfWrite)
and suppress watcher events whose disk content matches the last self-write,
on top of the existing snapshot comparison. Found by cross-model review.
The recorded ApplyConfig self-write was never invalidated, so after an
external edit B hot-reloaded, a later external revert to byte-identical
last-saved bytes (editor undo, git checkout) matched the stale record and
was suppressed — the running config silently stayed on B until restart.

Clear the record whenever a genuine external change proceeds to reload; a
suppression match itself keeps it armed so the restart-required pending
state still suppresses repeat events. Found by cross-model review.
…loads

ReloadConfiguration only updated the configsvc snapshot; the legacy r.cfg
read by Runtime.GetConfig() — which still backs GET/PATCH /api/v1/config,
profiles, tokens and docker-isolation handlers — stayed stale. After a
watcher-triggered reload the API kept serving the old config, and a
subsequent PATCH would deep-merge onto the stale base and save it,
silently reverting the external edit on disk.

Sync r.cfg/r.cfgPath from the reloaded snapshot (the legacy fallback branch
already did this via UpdateConfig). Found by cross-model review.
…ion too

The round-2 fix cleared the recorded self-write only on the reload branch.
An external revert that restores disk to the in-memory config (cancelling a
pending restart-required apply) takes the snapshot-match branch and left the
marker armed, so a later genuine external re-write of those self-saved bytes
was suppressed until restart — its hot-reloadable parts (new servers, limits)
never applied.

Clear the marker whenever observed disk content diverges from it: the file
has moved past our last save. Found by cross-model review.
…elf-write marker pre-save

Two cross-model review findings:

1. ReloadConfiguration never called upstreamManager.SetGlobalConfig, so a
   watcher (or manual) disk reload updated the snapshot/API but running
   managed clients kept resolving the old global config — an external edit
   to health_check_interval or Docker-recovery settings silently never
   reached them, while the identical PATCH did. Propagate the reloaded
   config (parity with ApplyConfig, spec 074) and expose a GlobalConfig
   getter on the manager for the regression test.

2. ApplyConfig recorded the self-write marker only after config.SaveConfig
   returned; a >debounce delay between the atomic rename and the record let
   the watcher misread the API's own save as an external edit — for a
   restart-required PATCH that hot-applies a config the API just reported
   as deferred. Arm the marker before writing; a stale marker from a failed
   save is harmless (only matches byte-identical content, dropped on any
   diverging observation).
…ate opt-out beacon to running daemon

- ApplyConfig pre-arms the config watcher's self-write marker before
  config.SaveConfig; on a FAILED save the marker is now cleared again,
  so a later genuine external write of byte-identical JSON hot-reloads
  instead of being suppressed as a self-write echo.
- 'mcpproxy telemetry disable' now skips its own opt-out beacon when a
  running daemon is detected (socket check, same pattern as the other
  daemon-aware commands): the daemon hot-reloads the changed config via
  the fsnotify watcher and fires the beacon through NotifyConfigChanged,
  and its once-latch is process-local — sending from both processes
  emitted the beacon twice. A stderr note records the delegation.
…nd disk reload; revert beacon delegation

- Extract applyComponentConfigLocked (logging via SetLogConfig, tool-response
  truncator rebuild, observability usage cadence) and call it from BOTH
  ApplyConfig and ReloadConfiguration, so watcher/disk reloads apply the same
  live side effects as API applies instead of only updating the snapshot.
  The helper compares fields directly (mirroring DetectConfigChanges) because
  DetectConfigChanges returns early on restart-required fields and would
  under-report hot changes riding along in the same external edit.
- Revert the telemetry opt-out beacon delegation: the CLI always sends after
  persisting the disable. Accepted trade-off documented at the send site:
  a running daemon may double-send via hot-reload (benign; backend dedupes
  by anon_id), whereas a stat-only socket false positive would drop the only
  send. Removes shouldUseTelemetryDaemon and its delegation test; keeps an
  always-sends-once regression test.
…ed recent-writes set

Back-to-back ApplyConfig saves could evict each other's self-write marker
before the watcher debounce fired: pre-arming save B overwrote the record
of restart-required save A, so the watcher read disk=A, failed the marker
check, and hot-applied A behind the API's back (violating the
requires_restart contract) — and clearing on that 'external' reload then
let B be hot-applied too.

The marker is now a small set (last 4 payloads, 10s TTL — generous vs the
~500ms echo window) keyed by marshaled content. All established semantics
preserved: pre-arm before write, per-payload forget on save error,
keep-on-match within TTL, clear-all on genuine external change and on
divergence past our saves.
@Dumbris

Dumbris commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Review provenance for the merger:

  • 7 Codex cross-model review rounds were run on this PR (rounds 6 and 7 beyond the standard 5-round cap were explicitly human-authorized).
  • 9 findings were verified and fixed across rounds 1-7, most recently: stale self-write marker on failed save, telemetry opt-out beacon double-send (resolved by reverting delegation and documenting the accepted double-send trade-off), disk-reload parity for live component side effects (logging, truncator, observability cadence), and the single-slot self-write marker race.
  • Round 7 left two findings, resolved as follows:
    • Finding 1 (single-slot lastSelfWrite marker race): fixed in c9a369a — the marker is now a bounded recent-self-writes set (last 4 payloads, 10s TTL) with all prior semantics preserved; regression test TestConfigWatcher_BackToBackSelfWritesBothSuppressed reproduces the race.
    • Finding 2 (truncator swap not reaching the already-constructed MCP handler): pre-existing on the ApplyConfig/API path, not introduced by this PR; tracked in Unify ApplyConfig and ReloadConfiguration side-effect application (config dual-path parity) #861 together with the broader config dual-path parity work.
  • Verification at c9a369a: both-edition builds, go test -race on internal/runtime + cmd/mcpproxy, golangci-lint v2 strict config (0 issues), API E2E suite 65/65 green; qa-gate posted success.

Dumbris added 3 commits July 22, 2026 08:14
… can't diverge

ReloadConfiguration updated configSvc (via ReloadFromFile) before taking
r.mu to set r.cfg, while ApplyConfig set r.cfg under r.mu and only updated
configSvc after releasing it. A watcher-triggered reload of config A
interleaving with an API apply of B could end with r.cfg=A while
configSvc=B permanently, so the REST/snapshot surfaces disagreed with the
live components. The new fsnotify config watcher makes this newly reachable.

Add a dedicated configCommitMu held across the whole load-validate-commit
sequence in both paths so they can never interleave. It is always acquired
outside r.mu (consistent ordering, no deadlock); configSvc updates only
notify subscribers over buffered channels, so no callback re-enters the
runtime. Regression test runs both paths concurrently and asserts r.cfg and
configSvc converge.
…igCommitMu too

Codex re-review of PR #857 flagged that UpdateConfig (registry-source CRUD
paths) and SaveConfiguration (enable/quarantine/bulk paths) also commit to
both config stores (configSvc + legacy r.cfg) without configCommitMu, so a
concurrent ApplyConfig/ReloadConfiguration could still leave the two stores
divergent — or SaveConfiguration's snapshot-read-then-write could clobber a
config another path just applied.

Extend the serialization to every two-store commit path:
- UpdateConfig now takes configCommitMu; its body moves to updateConfigLocked
  so ReloadConfiguration's legacy configSvc==nil fallback can reuse it without
  re-acquiring the non-reentrant mutex.
- SaveConfiguration takes configCommitMu across its whole snapshot-read ->
  configSvc.Update -> r.cfg.Servers write.
All acquire configCommitMu outside r.mu (consistent ordering, no deadlock).
Broaden the regression test to drive ApplyConfig, ReloadConfiguration,
UpdateConfig and SaveConfiguration concurrently and assert convergence.
…protocol

Codex round-2 review of PR #857 found UpdateListenAddress mutated only
r.cfg.Listen (in place, under r.mu) and never touched configSvc, so after any
listen change the two config stores diverged on Listen permanently — and
because r.cfg shares its pointer with configSvc after UpdateConfig, the
in-place write also raced with lock-free ConfigSnapshot readers. Worse, a
following SaveConfiguration(persist) cloned the stale configSvc snapshot and
persisted the OLD address.

UpdateListenAddress now clones the current snapshot, sets Listen, and commits
to both stores via updateConfigLocked under configCommitMu (acquired before
r.mu) — consistent, race-free, and serialized with the other commit paths.

Also close SaveConfiguration's I/O-failure divergence: sync r.cfg.Servers into
the legacy store BEFORE SaveToFile (new syncServersToLegacyConfig helper) so a
failed disk write leaves the two in-memory stores in agreement rather than
configSvc-updated / r.cfg-stale.

Extend the regression test to drive UpdateListenAddress concurrently and
assert both ToolResponseLimit and Listen converge across all commit paths.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved: Codex cross-model review clean; API E2E pass at this SHA; qa-gate blessed. Arming auto-merge.

@github-actions
github-actions Bot merged commit 3dd1c12 into main Jul 22, 2026
39 of 40 checks passed

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved: Codex cross-model review clean at this SHA (see PR thread); qa-gate green. Arming auto-merge.

Dumbris added a commit that referenced this pull request Jul 22, 2026
…r pointer (#861)

Two live defects surfaced by Codex review of PR #857:

1. Data race: Runtime.Truncator() read r.truncator without holding r.mu
   while ApplyConfig swapped it under the lock (-race detectable). The
   field is now an atomic.Pointer[truncate.Truncator]; the accessor and
   the swap use Load/Store, so reads stay lock-free on the serving hot
   path and are independent of r.mu (composes with #857's configCommitMu
   commit-path serialization).

2. Stale captured pointer: the MCP request handler captured the truncator
   pointer once at construction, so a hot-reloaded tool_response_limit
   never reached live tool-call responses. NewMCPProxyServer now takes a
   func() *truncate.Truncator getter; the serving path resolves the
   truncator at use time via currentTruncator(). Production passes
   rt.Truncator (method value), so a swap takes effect on the next call.

Tests:
- TestTruncatorSwapRace: concurrent Truncator() reads + ApplyConfig swaps
  under -race.
- TestTruncatorHotReloadOnServingPath: a limit change is observed by the
  serving path after reload.

Closes #861 (partial): the two live defects only. The broader
ApplyConfig/ReloadConfiguration side-effect unification remains tracked in
github-actions Bot pushed a commit that referenced this pull request Jul 22, 2026
…r pointer (#861) (#887)

Two live defects surfaced by Codex review of PR #857:

1. Data race: Runtime.Truncator() read r.truncator without holding r.mu
   while ApplyConfig swapped it under the lock (-race detectable). The
   field is now an atomic.Pointer[truncate.Truncator]; the accessor and
   the swap use Load/Store, so reads stay lock-free on the serving hot
   path and are independent of r.mu (composes with #857's configCommitMu
   commit-path serialization).

2. Stale captured pointer: the MCP request handler captured the truncator
   pointer once at construction, so a hot-reloaded tool_response_limit
   never reached live tool-call responses. NewMCPProxyServer now takes a
   func() *truncate.Truncator getter; the serving path resolves the
   truncator at use time via currentTruncator(). Production passes
   rt.Truncator (method value), so a swap takes effect on the next call.

Tests:
- TestTruncatorSwapRace: concurrent Truncator() reads + ApplyConfig swaps
  under -race.
- TestTruncatorHotReloadOnServingPath: a limit change is observed by the
  serving path after reload.

Closes #861 (partial): the two live defects only. The broader
ApplyConfig/ReloadConfiguration side-effect unification remains tracked in
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.

2 participants