Files
espresso_frame/server/app/config.py
T
tfaour 5e86a20e8b
Build and push server image / build-and-push (push) Successful in 35s
Fix config read-modify-write race and two firmware buffer edge cases
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.
2026-07-19 15:20:26 -04:00

83 lines
2.9 KiB
Python

"""JSON-file-backed config: Immich connection, selected album, and cursor
state (which photo /frame/image serves next)."""
from __future__ import annotations
import json
import os
from contextlib import contextmanager
from pathlib import Path
from threading import RLock
from typing import Iterator
from pydantic import BaseModel
CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "/data/config.json"))
# 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):
immich_url: str = ""
immich_api_key: str = ""
management_token: str = "" # gates the web UI (see main.py); empty = no gate, open on trusted LAN
album_id: str = ""
order: str = "sequential" # or "shuffle"
refresh_interval_s: int = 3600
smart_crop_faces: bool = True
# Current photo + upcoming queue (see app/photo_queue.py). current_asset_set_at
# is what lets the server decide "has it been long enough to advance" on its
# own clock, independent of how/why the device asked for a photo.
current_asset_id: str = ""
current_asset_set_at: float = 0.0
queue: list[str] = []
queue_cursor: int = 0 # internal bookkeeping for sequential queue top-up; not user-facing
queue_target_len: int = 20 # how many upcoming photos to keep queued/shown in the web UI
history: list[str] = [] # bounded stack of previously-current asset ids, most recent last
def load() -> FrameConfig:
with _lock:
if not CONFIG_PATH.exists():
cfg = FrameConfig()
else:
cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text()))
# IMMICH_URL/IMMICH_API_KEY/MANAGEMENT_TOKEN set in the environment
# (e.g. docker-compose.yml, see docker-compose.yml.example) take
# precedence over whatever's saved in CONFIG_PATH, so credentials never
# need to go through the web UI.
env_url = os.environ.get("IMMICH_URL")
env_key = os.environ.get("IMMICH_API_KEY")
env_token = os.environ.get("MANAGEMENT_TOKEN")
if env_url:
cfg.immich_url = env_url
if env_key:
cfg.immich_api_key = env_key
if env_token:
cfg.management_token = env_token
return cfg
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