Skip to content

refactor(server): clock URL through the runtime-settings overlay - #176

Merged
tarakanof merged 4 commits into
mainfrom
refactor/175-clock-url-overlay
Sep 26, 2026
Merged

tarakanof merged 4 commits into
mainfrom
refactor/175-clock-url-overlay

Conversation

@tarakanof

Copy link
Copy Markdown
Owner

Closes #175

Spec summary: #175 (comment)

What changed

The device clock URL is now a settings-overlay registration, and a single function, Config.clockURL(), owns its precedence.

Structure

Before:

PUT /v1/device/config ─► applyDeviceBaseURL ─► cfg.AWTRIX.HTTPBaseURL = url + store raw string
rediscoverClock ──────────────────────────────► cfg.AWTRIX.HTTPBaseURL = url, deviceAutoPicked=true
/admin/reload ─► fileURLChanged? rewrite newCfg URL / deviceBaseline / loadPersisted (after Store)
deviceSource ─► compare live URL vs store entry vs deviceBaseline vs deviceAutoPicked
readers ──────► cfg.AWTRIX.HTTPBaseURL  (file value or live URL, depending on who wrote last)

After:

Config.AWTRIX: HTTPBaseURL (file baseline) · clockOverride (overlay) · clockDiscovered (in memory)
                              │
                 Config.clockURL() → (url, source)   discovered > store > config > none
                              │
  clockAccess · doctor · /v1/device/config + /discover · clock health · callback hosts

PUT /v1/device/config ─► settings.clock.putWith(patch, clear swap if base_url named)
rediscoverClock ───────► swapDiscoveredClock(from, to)   (CAS: loses to a racing PUT/reload)
/admin/reload ─────────► carryClockURL(old, &new) inside cfgMu, then settings.reapply()

Discovery sets a swap only after the effective URL fails its probes. So "discovered wins" is the documented "store override > reachable baseline > mDNS auto-pick" rule, expressed as state.

Files outside the issue's own

These hunks are minimal:

  • config.go: two unexported fields on AWTRIXConfig.
  • app.go: removed deviceBaseline/deviceAutoPicked.
  • admin.go: reload block.
  • One-line reader swaps in boot_ping.go, device_buttons.go, device_capabilities.go, clock_health_http.go and doctor.go.
  • clock_parity_test.go: dropped the app.deviceBaseline = srv.URL line. The goldens are unchanged, and the harness passes as-is.

Behaviour changes

  • PUT /v1/device/config {} (or {"base_url":null}, or unrelated keys only): 200 with no change, instead of 400. This is the merge semantics every other …/config PUT already has. {"base_url":""} and bad schemes are still 400. There is no clear, the same as before.
  • /admin/reload changed_fields lists awtrix.http_base_url exactly when the file value changed. Before, the running URL was compared.
  • A reload that changes the file URL no longer has a short window where the new file URL is live over a store override.
  • A discovery swap computed against a URL that a concurrent PUT or reload has since replaced no longer lands on top of that choice.

Unchanged:

  • GET/PUT bodies for real clients: the macOS app always sends base_url, and DeviceSettingsModel.use and Discover Clocks behave the same.
  • The stored bytes: device_base_url is still the raw URL, via the overlay's new encode/decode, so there is no migration and a downgrade stays safe.
  • source values.
  • Reload semantics.

Tests

clock_url_test.go:

  • precedence table;
  • override survives a restart and a swap doesn't;
  • raw store format, both legacy read and write;
  • an invalid stored value is ignored;
  • PUT merge semantics and 400s;
  • PUT re-pins over a swap, and {} leaves the swap;
  • a swap loses to a concurrent pin;
  • reload with a changed file URL drops the swap and the override still wins;
  • in-memory override kept across reload;
  • changed_fields tracks the file URL;
  • concurrent PUT against a discovery swap under -race;
  • HTTPBaseURL guard test (checked that it fails on a planted read).

The existing device, rediscover, hardening, reload and parity tests moved to the new interface.

Checks run locally:

The clock URL was the last runtime setting outside the overlay. Five places
held pieces of its precedence: a hand-written load/apply pair, deviceSource
reverse-engineering the tier from the store, deviceBaseline and
deviceAutoPicked, and a fileURLChanged special case in /admin/reload.
AWTRIX.HTTPBaseURL meant the file value in one place and the live URL in
another.

Now the tiers live in one Config value: the file baseline (HTTPBaseURL, never
overwritten at runtime), the menu override (an overlay registration), and an
in-memory discovery swap. Config.clockURL() is the only precedence rule, and
every reader asks it: clock access, doctor, the device config/discover
bodies, clock health, and the callback hosts. A guard test keeps it that way.

The overlay gains an optional encode/decode pair, so the store keeps its raw
URL format, and putWith, so a PUT naming base_url clears a discovery swap in
the same critical section while reapply doesn't. Reload carries the runtime
tiers with carryClockURL instead of rewriting the file value.
A discovery swap now lands only if the URL it probed is still effective, so
it can't overwrite a PUT that raced it.

Behaviour change: PUT {} (or base_url null) is a 200 no-op instead of a 400.
An explicit "" is still a 400.
ARCHITECTURE, STYLE and the AGENTS endpoint list still called the clock URL the overlay's exception and PUT /v1/device/config a required body.

@tarakanof tarakanof left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review (read-only). Checked against origin/main: applyDeviceBaseURL, loadPersistedDeviceBaseURL, deviceSource, rediscoverClock, StartDeviceWatch, and the reload block.

Verdict: merge after the doc fix (1). The code preserves main's effective-URL behaviour.

Precedence: what main actually does

On main the effective URL is one live field, and the last writer wins. The store pin is laid on at boot (initDeviceDiscovery → loadPersistedDeviceBaseURL). rediscoverClock probes whatever is live, whatever its source, and if it fails 2 probes it swaps in cands[0], even over a menu pin. So yes: when a pinned clock changes IP, discovery on main overrides the pin, in memory, until a PUT or a reload that changes the file URL. The store row is never touched. In tier terms that is exactly discovered > store > config, where discovered is set only when the tier below failed its probes. The PR's clockURL() matches main.

Scenarios traced, all with the same effective URL as main:

  • boot pin
  • pin unreachable → swap
  • PUT over a swap, including re-pinning the same URL
  • reload with an unchanged file URL: swap and override kept (main kept the running URL, and the overlay reapply doesn't clear the swap)
  • reload with a changed file URL: swap dropped, override wins (main did the same, plus a short window that this PR removes)
  • invalid stored value ignored
  • boot order: settings.reapply() (main.go:79) runs before initDeviceDiscovery (:110)

Findings

  1. should-fix, docs. The phrase "store override > reachable config.json baseline > mDNS auto-pick" is wrong on main, and still wrong after this PR. An unreachable store override is replaced by a discovery swap too. ARCHITECTURE.md now says "discovered > store > config … so this is the documented 'store override > reachable baseline > mDNS auto-pick'", which calls the two orders the same. They aren't: the old phrase says a pin can't be beaten. It is still in AGENTS.md:121 and in the main.go:105 comment. Suggested single wording: "Effective = menu override, else config.json baseline. If that fails its probes, an in-memory mDNS swap replaces it (the pin included) until a PUT naming base_url or a reload that changes the file URL."
  2. nit, source difference. Pin A dies → swap to B → B dies → discovery finds A again. Main reports source:"store", because the store value equals cur. The PR reports "discovered". The effective URL is the same, and arguably the PR is more honest. The PR says "source values unchanged", so either mention this or ignore it.
  3. nit, empty row. A PUT {} with no override now persists device_base_url="": persist always runs, and encode of a nil view is "". Harmless: decode("") → {}, and main's loader skips "", so a downgrade is safe. But it's a new empty row.
  4. nit, guard test. TestHTTPBaseURLOnlyReadAsBaseline allowlists whole files. admin.go is large and a future stray read there would pass. Consider an allowlist of function names (e.g. handleAdminReload), or accept it as is.

Verified OK

  • encode/decode: keeps the raw-URL bytes both ways, so no migration and a downgrade stays safe.
  • carryClockURL: runs inside cfgMu before validateConfig/diffConfig. The unexported fields are skipped by diffStructFields, so changed_fields tracks the file URL only (the stated change).
  • CAS in swapDiscoveredClock: compares the effective URL under the overlay lock. It loses only to a real change. Re-pinning the identical URL still gets swapped, which is the same as main.
  • putWith: clears the swap only when base_url is named. Reapply goes through put and never clears it.
  • PUT {}/null → 200: fine. "" and bad schemes are still 400 via validDeviceURL.
  • All former HTTPBaseURL readers: converted. The remaining ones are config load/sanitize, printconfig and the reload required-field check.
  • Parity goldens: untouched, and the branch is up to date with main.
  • go test ./... -race -count=3: green (all packages).

…rows

When discovery lands back on the pinned URL, main reported source store; the swap tier now clears in that case so it still does. A PUT {} with no override no longer writes an empty device_base_url row. The HTTPBaseURL guard now allowlists functions, not whole files.
"store override > reachable baseline > mDNS auto-pick" implied a pin can't be beaten; an unreachable pin is swapped in memory until a PUT naming base_url or a file-URL-changing reload.
@tarakanof

Copy link
Copy Markdown
Owner Author

Review addressed in 5c0704e and 7e8e370:

  1. Docs. The rule now says what the server actually does: effective URL is the menu override, else the config.json baseline. If that fails its probes, an in-memory mDNS swap replaces it (the pin included) until a PUT naming base_url or a reload that changes the file URL. Updated in ARCHITECTURE.md, AGENTS.md, RUNBOOK.md, the main.go comment and the clock_url.go comment.
  2. Source after a swap back. If discovery lands back on the pinned URL, the swap is cleared and the source reads store, as on main (TestRediscoverClock_BackOnThePinReportsStore). So source values now match main.
  3. Empty row. PUT {} with no override writes no row: persist skips an empty encoded form (TestDeviceConfigPut_EmptyObjectWritesNoRow).
  4. Guard test. The allowlist is now per function (handleAdminReload, sanitizeConfigBaseline, validateConfig, applyDefaults, redactConfig, clockURL, carryClockURL) instead of whole files.

gofmt (1.26), vet and go test ./... -race pass locally, and CI is green.

@tarakanof
tarakanof merged commit e403d6d into main Sep 26, 2026
3 checks passed
@tarakanof
tarakanof deleted the refactor/175-clock-url-overlay branch September 26, 2026 20:06
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.

refactor(server): clock URL through the runtime-settings overlay

1 participant