Fix config read-modify-write race and two firmware buffer edge cases
Build and push server image / build-and-push (push) Successful in 35s

Found by a thorough code review:

- server/app/config.py's load()/save() each locked only their own file
  I/O, not the full read-modify-write cycle each route does around
  them. Since uvicorn dispatches sync routes to a thread pool, two
  concurrent requests (e.g. the device's own poll landing alongside a
  web UI edit) could each load() the same on-disk state and the
  second's save() silently clobber the first's changes. Added
  config.locked() (backed by an RLock, since load()/save() also take
  the lock internally) and wrapped every mutating route's
  load/mutate/save span in it -- kept outside the lock wherever a route
  also does slow Immich network I/O, re-loading fresh state right
  before the actual mutation instead. Verified with a new concurrency
  stress test (many concurrent /api/queue/promote and /api/config
  calls) alongside the existing scratch suite.
- firmware/main/root.html's SSID/password/toolsserver/access-token
  inputs had no maxlength, so pasting something longer than the
  matching NVS buffer (wifi_provisioning.h's FRAME_CFG_*_MAX_LEN) was
  silently truncated with no indication why the device later can't
  connect or gets 401s.
- frame_client.c's share_url buffer (256 bytes) could be too small in
  the worst case -- toolsserver (128) + "/frame/share/" + asset_id (47)
  + "?token=" + access_token (64) can reach ~266 bytes, silently
  dropping the token off a request that would then just 401 with no
  obvious cause. Widened to 320.
This commit is contained in:
2026-07-19 15:20:26 -04:00
parent d5de882b1e
commit 5e86a20e8b
4 changed files with 91 additions and 48 deletions
+19 -2
View File
@@ -5,14 +5,18 @@ from __future__ import annotations
import json
import os
from contextlib import contextmanager
from pathlib import Path
from threading import Lock
from threading import RLock
from typing import Iterator
from pydantic import BaseModel
CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "/data/config.json"))
_lock = Lock()
# Reentrant so load()/save() can each take it internally for their own I/O
# while a caller also holds it for a whole locked() span (see below).
_lock = RLock()
class FrameConfig(BaseModel):
@@ -63,3 +67,16 @@ def save(cfg: FrameConfig) -> None:
with _lock:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(cfg.model_dump_json(indent=2))
@contextmanager
def locked() -> Iterator[None]:
"""Serializes an entire load-mutate-save cycle. load()/save() each
only lock their own I/O, which isn't enough by itself: uvicorn
dispatches sync routes to a thread pool, so two concurrent requests
(e.g. the device's own poll landing alongside a web UI edit) can each
load() the same on-disk state and the second save() silently clobber
the first's changes. Route handlers that mutate config should wrap
their whole load/mutate/save span in this."""
with _lock:
yield