Conversation
The API negotiate path reported `action: "conflict"` in its response but never announced it on the socket, and nothing in the frontend consumed any `sync:*` event, so a save conflict was invisible to the user. Emit `sync:conflict` from negotiate for each conflicting operation, and add the v2 consumer that shows a warning toast naming the game. The emit is fail-open: an unreachable Redis must not block a client's launch. The toast dedupes per page session, because a conflict is re-reported on every negotiation until it is resolved. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Greptile SummaryThis PR surfaces save-sync conflicts from REST negotiation through Socket.IO and installs a global v2 toast consumer.
Confidence Score: 4/5The PR should not merge until distinct conflicts from different devices can each reach the user; the Redis latency concern is additional non-blocking hardening. Conflict state is tracked and emitted per device, but the frontend dedupes only by ROM and filename, so one device's event can suppress another device's only user-visible notification. Files Needing Attention: frontend/src/v2/composables/useSyncConflictToast/index.ts, frontend/src/v2/composables/useSyncConflictToast/index.test.ts, backend/endpoints/sync.py Important Files Changed
Prompt To Fix All With AI### Issue 1
frontend/src/v2/composables/useSyncConflictToast/index.ts:36-38
**Device conflicts share dedupe key**
Conflict detection is device-specific, but this page-session dedupe key omits `device_id`. If two devices conflict on the same ROM and file, the first event adds this key and the second device's distinct conflict is silently discarded. Because the toast is currently the user's only conflict surface, the second conflict receives no notification. Include the device identity in the key and cover this case with a test.
### Issue 2
backend/endpoints/sync.py:344-355
**Redis can delay negotiation**
The notification is described as fail-open, but every conflict is emitted sequentially through a newly created Redis manager and awaited before the negotiation response, without an explicit timeout. A slow or unavailable Redis connection can therefore delay negotiation once per conflict, even though exceptions are caught. Bound the notification attempt or move it outside the response-critical path so notification infrastructure cannot block emulator launch.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "feat(sync): surface save conflicts detec..." | Re-trigger Greptile |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect event-loop safety, notification latency, and conflict-toast correctness.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds end-to-end visibility for save conflicts detected during REST sync negotiation, from backend Socket.IO events to frontend warning toasts.
Changes:
- Emits fail-open
sync:conflictevents during negotiation. - Adds cached ROM-name resolution and per-session toast deduplication.
- Adds backend/frontend tests and translations across supported locales.
Review findings:
backend/endpoints/sync.py— Critical (3 votes): Blocking database work now runs on the async event loop; retain threadpool execution or offload the blocking work.backend/endpoints/sync.py— Moderate (3 votes): Redis notification timeouts can repeat per conflict; reuse the manager or apply a bounded total timeout.frontend/src/v2/composables/useSyncConflictToast/index.ts— Moderate (1 vote): Empty names bypass the cachedfs_namefallback.frontend/src/v2/composables/useSyncConflictToast/index.ts— Moderate (1 vote): Deduplication omits save slots and can suppress distinct or later conflicts.
File summaries
| File | Summary |
|---|---|
frontend/src/v2/layouts/AppLayout.vue |
Installs global conflict-toast handling. |
frontend/src/v2/composables/useSyncConflictToast/index.ts |
Subscribes to conflict events, resolves cached names, and displays deduplicated warnings. |
frontend/src/v2/composables/useSyncConflictToast/index.test.ts |
Tests naming, fallback, and deduplication. |
frontend/src/locales/zh_TW/rom.json |
Adds Traditional Chinese conflict messages. |
frontend/src/locales/zh_CN/rom.json |
Adds Simplified Chinese conflict messages. |
frontend/src/locales/tr_TR/rom.json |
Adds Turkish conflict messages. |
frontend/src/locales/ru_RU/rom.json |
Adds Russian conflict messages. |
frontend/src/locales/ro_RO/rom.json |
Adds Romanian conflict messages. |
frontend/src/locales/pt_BR/rom.json |
Adds Brazilian Portuguese conflict messages. |
frontend/src/locales/pl_PL/rom.json |
Adds Polish conflict messages. |
frontend/src/locales/ko_KR/rom.json |
Adds Korean conflict messages. |
frontend/src/locales/ja_JP/rom.json |
Adds Japanese conflict messages. |
frontend/src/locales/it_IT/rom.json |
Adds Italian conflict messages. |
frontend/src/locales/hu_HU/rom.json |
Adds Hungarian conflict messages. |
frontend/src/locales/fr_FR/rom.json |
Adds French conflict messages. |
frontend/src/locales/es_ES/rom.json |
Adds Spanish conflict messages. |
frontend/src/locales/en_US/rom.json |
Adds source English conflict messages. |
frontend/src/locales/en_GB/rom.json |
Adds English conflict messages. |
frontend/src/locales/de_DE/rom.json |
Adds German conflict messages. |
frontend/src/locales/cs_CZ/rom.json |
Adds Czech conflict messages. |
frontend/src/locales/bg_BG/rom.json |
Adds Bulgarian conflict messages. |
backend/tests/endpoints/test_sync.py |
Tests conflict emission, no-op silence, and fail-open behavior. |
backend/endpoints/sync.py |
Emits conflict notifications during negotiation. |
Review details
Suppressed comments (2)
frontend/src/v2/composables/useSyncConflictToast/index.ts:32
namemay be an empty string as well asnull; with??this returns"", and the caller'sname ? ... : genericbranch then hides the loaded ROM'sfs_name. Use a truthy fallback so cached games with blank metadata still get a useful name.
return rom ? (rom.name ?? rom.fs_name) : null;
frontend/src/v2/composables/useSyncConflictToast/index.ts:37
seenis keyed only byrom_id:file_name, but negotiation distinguishes saves by(rom_id, slot)and the conflict event omitsslot. Two conflicting slots with the same filename, or a later conflict after the original is resolved, are therefore suppressed for the rest of the page. Include a stable conflict/version identity in the event and use it here.
const key = `${payload.rom_id}:${payload.file_name}`;
if (seen.has(key)) return;
- Files reviewed: 23/23 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…name Negotiate awaited one socket emit per conflict in sequence, and each emit dials a fresh Redis manager, so a client whose launch reported many conflicts queued behind its own notification infrastructure with no upper bound on how long that took. Run the emits concurrently under a single bounded asyncio.wait_for, so the wait is constant rather than proportional to the conflict count. Each failure is still logged per file, and an unreachable Redis still leaves the negotiation intact. The conflict toast also fell back to the file name only when a ROM's name was null, not when it was blank, which rendered "Save conflict detected for ". Addresses the review on rommapp#4552. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Triaged all five findings. Two fixed in Fixed: Redis can delay negotiation
Valid, and the sharper half is the scaling rather than the raw latency. The emits ran sequentially, one awaited call per conflict, and The emits now go out concurrently under a single
Fixed: empty name bypasses the
|
`negotiate_sync` became `async def` so the new conflict emit could be awaited, which moved every synchronous handler call in its body (device lookup, save fetch, session writes, last_seen) onto the event loop. A device negotiating a large library blocked unrelated async requests and Socket.IO traffic for the duration. The route goes back to a sync `def`, so FastAPI keeps the negotiation in the threadpool, and only the emit crosses to a loop. The socket manager is built per call in write-only mode, which is what lets it run on a loop of its own; the folder watcher already bridges the same emitter that way. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
The negotiation path can block the event loop and create unbounded concurrent Redis managers.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
backend/endpoints/sync.py:124
- Changing this route to
async defmoves all of its synchronous SQLAlchemy handler calls onto the server event-loop thread. A negotiation performs several queries and commits before its firstawait, so a slow database or concurrent negotiations can block unrelated async requests and Socket.IO work. Keep the database-heavy negotiation on FastAPI's worker thread, or extract/offload the synchronous phase before awaiting notifications.
backend/endpoints/sync.py:356
- This launches one task per conflict, and each
emit_sync_conflictconstructs its ownAsyncRedisManager. Sincepayload.saveshas no length bound, a large conflict set can create an unbounded burst of Redis managers/connections even though the timeout limits duration. Reuse one manager and emit with bounded concurrency, or add a batch emitter that publishes all conflict payloads through one manager.
server_updated_at=save.updated_at,
server_content_hash=save.content_hash,
)
)
- Files reviewed: 23/23 changed files
- Comments generated: 0 new
- Review effort level: Balanced
A wide conflict set opened one Redis connection per conflict at once, so a client controlled how many the server dials in a single negotiation. Cap the fan-out with a semaphore alongside the existing deadline. The toast deduped on ROM and file alone, dropping a second device's conflict on the same save. Key it per device too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Conflict deduplication can suppress distinct push-pull conflicts that share a filename.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
frontend/src/v2/composables/useSyncConflictToast/index.ts:37
- This key is not unique for the existing push-pull producer:
backend/tasks/sync_push_pull_task.py:156emits every conflict withrom_id=0, while itsfile_nameis only a basename and can repeat across configured platform directories. Two distinct conflicts such assave.daton two platforms therefore produce the same key and the second warning is suppressed. Include a stable conflict identifier, such as platform plus save or slot identity, in the socket payload and key.
backend/endpoints/sync.py:158 - When the deadline expires,
TimeoutErrorhas an empty message, so this logsFailed to emit ... events:without explaining that notification delivery timed out. Handle the timeout explicitly and include the configured deadline; retaining the exception type also keeps the generic failure log informative.
- Files reviewed: 23/23 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Description
Explain the changes or enhancements you are proposing with this pull request.
Save conflicts were half-wired. The SSH push-pull path already resolves them server-side and calls
emit_sync_conflict, but the API negotiate path only reportedaction: "conflict"in its JSON response. On top of that, nothing infrontend/srcsubscribed to anysync:*event, so a conflict was invisible to the user either way.This closes both halves:
negotiate_syncemitssync:conflictonce per conflicting operation, after the session is updated sosession_idis valid. The route itself stays a syncdef, so its handler calls keep running in the threadpool; only the emit is async, reached throughasyncio.runbecause_get_socket_manager()builds a write-only manager per call rather than holding the app's.@protected_routebranches onis_async_callable, so the signature decides where the body runs.useSyncConflictToast, consumes the event and shows a warning toast naming the game. It resolves the name from a loaded cache (galleryRoms,currentRom,recentRoms) with no network request, and falls back to a generic string when the game is not on any loaded surface.The toast dedupes per page session and per device, because a conflict is re-reported on every negotiation until it is resolved; without that, the user would be told twice per play session, at launch and at exit. Keying on the device as well keeps a second device's conflict on the same save from being swallowed.
This is the first piece of save sync between RomM and the romm-desktop shell, which rides this REST negotiate protocol. The shell itself, states sync, and any conflict resolution flow are follow-ups.
flowchart LR shell[romm-desktop shell] -->|"POST /api/sync/negotiate"| negotiate["negotiate_sync<br/>(sync route, async emit)"] negotiate -->|"action: conflict<br/>asyncio.run"| emit["emit_sync_conflict"] emit -->|"sync:conflict, room user:{id}"| socket[Socket.IO] socket --> consumer["useSyncConflictToast<br/>(new)"] consumer -->|snackbarShow| host[NotificationHost] subgraph existing["already existed"] emit socket host endChecklist
Please check all that apply.
Unit tests cover the emit (payload, silence on a no_op, the fail-open contract when the emitter raises, the deadline that keeps a slow notification from stalling a launch, and the cap on concurrent emits), and the frontend test covers the named toast, the generic fallback, and the per-device dedupe.
Local checks:
pytest tests/endpoints/test_sync.py46 passed;npm run test1366 passed;npm run typecheckclean;npm run buildclean;check_i18n_locales.pyandcheck_i18n_sorted.pyclean;trunk fmt && trunk checkclean.Screenshots (if applicable)
The warning toast naming the game:
AI assistance: This change was written with Claude Code (Anthropic). I directed the design (transport, conflict ownership, toast-only surface) and reviewed every change; the code, tests, and translations were drafted by the agent.