Fix config read-modify-write race and two firmware buffer edge cases
Build and push server image / build-and-push (push) Successful in 35s
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:
+56
-41
@@ -122,21 +122,22 @@ def api_config_save(
|
||||
# docker-compose.yml.example) -- config.load() already applies them,
|
||||
# and this handler doesn't touch cfg.immich_url/immich_api_key at all,
|
||||
# so there's nothing here that could overwrite or clear them.
|
||||
cfg = config.load()
|
||||
if album_id != cfg.album_id:
|
||||
# A newly selected album starts clean -- the old current photo and
|
||||
# queue don't mean anything in the new album's context.
|
||||
cfg.current_asset_id = ""
|
||||
cfg.current_asset_set_at = 0.0
|
||||
cfg.queue = []
|
||||
cfg.queue_cursor = 0
|
||||
cfg.history = []
|
||||
cfg.album_id = album_id
|
||||
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
||||
cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
|
||||
cfg.smart_crop_faces = smart_crop_faces
|
||||
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
||||
config.save(cfg)
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
if album_id != cfg.album_id:
|
||||
# A newly selected album starts clean -- the old current photo and
|
||||
# queue don't mean anything in the new album's context.
|
||||
cfg.current_asset_id = ""
|
||||
cfg.current_asset_set_at = 0.0
|
||||
cfg.queue = []
|
||||
cfg.queue_cursor = 0
|
||||
cfg.history = []
|
||||
cfg.album_id = album_id
|
||||
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
||||
cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
|
||||
cfg.smart_crop_faces = smart_crop_faces
|
||||
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
||||
config.save(cfg)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@@ -189,8 +190,10 @@ def frame_image():
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
if photo_queue.get_current(cfg, assets):
|
||||
config.save(cfg)
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
if photo_queue.get_current(cfg, assets):
|
||||
config.save(cfg)
|
||||
|
||||
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
|
||||
|
||||
@@ -206,8 +209,10 @@ def frame_advance():
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
photo_queue.advance_forced(cfg, assets)
|
||||
config.save(cfg)
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
photo_queue.advance_forced(cfg, assets)
|
||||
config.save(cfg)
|
||||
|
||||
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
|
||||
|
||||
@@ -226,8 +231,10 @@ def frame_back():
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
photo_queue.back_forced(cfg, assets)
|
||||
config.save(cfg)
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
photo_queue.back_forced(cfg, assets)
|
||||
config.save(cfg)
|
||||
|
||||
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
|
||||
|
||||
@@ -314,8 +321,10 @@ def frame_photo_info():
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
if photo_queue.get_current(cfg, assets):
|
||||
config.save(cfg)
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
if photo_queue.get_current(cfg, assets):
|
||||
config.save(cfg)
|
||||
|
||||
if not cfg.current_asset_id:
|
||||
raise HTTPException(404, "No current photo")
|
||||
@@ -379,8 +388,10 @@ def frame_face_labels():
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
if photo_queue.get_current(cfg, assets):
|
||||
config.save(cfg)
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
if photo_queue.get_current(cfg, assets):
|
||||
config.save(cfg)
|
||||
|
||||
if not cfg.current_asset_id:
|
||||
return {"count": 0}
|
||||
@@ -418,11 +429,13 @@ def api_queue():
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
current_changed = photo_queue.get_current(cfg, assets)
|
||||
queue_before = list(cfg.queue)
|
||||
photo_queue.sync_queue_length(cfg, assets)
|
||||
if current_changed or cfg.queue != queue_before:
|
||||
config.save(cfg)
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
current_changed = photo_queue.get_current(cfg, assets)
|
||||
queue_before = list(cfg.queue)
|
||||
photo_queue.sync_queue_length(cfg, assets)
|
||||
if current_changed or cfg.queue != queue_before:
|
||||
config.save(cfg)
|
||||
|
||||
def entry(asset_id: str) -> dict:
|
||||
return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"}
|
||||
@@ -445,12 +458,13 @@ def api_queue_reorder(body: QueueReorderRequest):
|
||||
the client sent that's no longer actually queued is dropped, and any
|
||||
ID the server has that the client didn't know about is appended
|
||||
rather than lost."""
|
||||
cfg = config.load()
|
||||
current_set = set(cfg.queue)
|
||||
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
|
||||
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
|
||||
cfg.queue = reordered
|
||||
config.save(cfg)
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
current_set = set(cfg.queue)
|
||||
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
|
||||
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
|
||||
cfg.queue = reordered
|
||||
config.save(cfg)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@@ -465,11 +479,12 @@ def api_queue_promote(body: QueuePromoteRequest):
|
||||
client supplying a full, exactly-current snapshot of the queue at
|
||||
all, so it can't fail due to the queue having shifted server-side
|
||||
since the browser's last fetch."""
|
||||
cfg = config.load()
|
||||
if body.asset_id not in cfg.queue:
|
||||
raise HTTPException(400, "That photo is no longer in the upcoming queue")
|
||||
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
|
||||
config.save(cfg)
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
if body.asset_id not in cfg.queue:
|
||||
raise HTTPException(400, "That photo is no longer in the upcoming queue")
|
||||
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
|
||||
config.save(cfg)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user