Skip to content

feat(firmware): let missing firmware be found, cleaned up, and kept out of the player - #4077

Open
Spinnich wants to merge 4 commits into
rommapp:masterfrom
Spinnich:fix/missing-firmware-cleanup
Open

feat(firmware): let missing firmware be found, cleaned up, and kept out of the player#4077
Spinnich wants to merge 4 commits into
rommapp:masterfrom
Spinnich:fix/missing-firmware-cleanup

Conversation

@Spinnich

@Spinnich Spinnich commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #4075

A scan flags firmware whose file has vanished with missing_from_fs, but nothing acted on that flag. The result was three separate problems:

  • Stale firmware could only be found by opening each platform's Firmware tab one at a time, and there was no bulk removal — unlike ROMs, which have had a "Cleanup missing ROMs" task for a long time.
  • The player offered missing firmware as a BIOS choice and, when a platform had exactly one entry, auto-selected it (the behaviour added in Auto-select firmware when only one option is available #3150). Games then failed to boot with nothing on screen pointing at the BIOS as the cause.
  • firmware_count on PlatformSchema counted rows, so a platform whose only BIOS file had been deleted still advertised firmware it could not serve.

This PR makes the flag actionable end to end:

  1. GET /api/firmware gains a ?missing= filter (true / false / omitted for everything), so callers can select on the flag instead of taking the whole set and filtering client-side.
  2. New cleanup_missing_firmware manual task, modelled on cleanup_missing_roms and registered next to it. Optionally scoped to one platform_id. It only deletes database rows — the files are already gone, so nothing is removed from disk.
  3. New "Missing firmware" tab under Settings → Library Management, listing every flagged entry library-wide with a platform multi-select and a "Clean up all" action wired to the task.
  4. The v2 player no longer offers missing firmware. It fetches with missing: false, and BIOS selection moved into a pure util so the "sole entry auto-select" rule can only ever fire on an entry that actually exists.
  5. firmware_count counts only firmware present on disk, so the platform stat matches what is usable.

Files changed

Backend

File Change
backend/handler/database/firmware_handler.py list_firmware() gains a missing: bool | None filter; stacks with the existing platform_id and hidden-platform filters
backend/endpoints/firmware.py ?missing= query param on GET /api/firmware, forwarded to the handler
backend/endpoints/responses/firmware.py FirmwareSchema gains platform_id so a library-wide list can group by platform without a round trip per row
backend/endpoints/responses/platform.py firmware_count excludes entries flagged missing_from_fs
backend/tasks/manual/cleanup_missing_firmware.py New. CleanupMissingFirmwareTask + stats dataclass, mirroring the ROM cleanup
backend/endpoints/tasks.py Registers cleanup_missing_firmware in manual_tasks

Frontend

File Change
frontend/src/v2/components/Settings/MissingFirmwareSection.vue New. The Missing firmware tab
frontend/src/v2/views/Settings/LibraryManagement.vue Adds the missing-firmware tab to the Tab union, nav, and body
frontend/src/v2/utils/playerFirmware.ts New. resolveInitialFirmware() — storage → core config → sole option, all restricted to firmware that exists
frontend/src/v2/views/Player/EmulatorJS.vue Fetches firmware with missing: false and delegates initial selection to the util
frontend/src/v2/components/Gallery/FirmwareTab.vue The locally patched firmware_count now matches what the server derives
frontend/src/services/api/firmware.ts getFirmware() forwards an optional missing param
frontend/src/__generated__/models/FirmwareSchema.ts Regenerated (platform_id)
frontend/src/locales/*/settings.json 6 new keys, translated across all 18 locales

Testing notes

Automated:

  • Backend: 2723 passed, 2 skipped. New coverage — 5 handler tests for the missing filter (including that it stacks with platform_id and with hidden platforms), 6 endpoint tests for GET /api/firmware, 5 task tests (deletes only flagged rows, scopes to one platform, counts delete failures, no-op stats), plus firmware_count on the platform endpoint and a registry test asserting the task is actually reachable by name.
  • Frontend: 630 passed / 49 files. 8 unit tests for resolveInitialFirmware (the key one: a sole entry whose file is gone resolves to null rather than being auto-selected) and 7 component tests for the new tab.
  • npm run typecheck, trunk fmt && trunk check, check_i18n_locales.py, check_i18n_sorted.py all clean.

Manual, against a dev library with firmware rows flagged missing:

  • Settings → Library Management → Missing firmware lists the flagged entries with platform, path and size; the platform select only offers platforms that actually have something to clean up.
  • "Clean up all" with no platform selected runs unscoped; with exactly one platform selected it forwards platform_id; the list refreshes and empties.
  • A platform whose only BIOS was deleted from disk: the player's BIOS dropdown is now empty and nothing is preselected, and the platform's firmware stat no longer counts it.
  • Verified in both light and dark themes.

Things worth a reviewer's attention

  1. firmware_count is a behaviour change to an existing field. Platforms holding stale firmware rows will report a lower count than before. That is what the issue asks for, but it is user-visible.
  2. FirmwareSchema.platform_id is an additive API field — the regenerated types are included in this PR.
  3. The cleanup confirmation has no type-to-confirm gate, unlike the ROM cleanup's requireTyped: "DELETE". Per the v2 patterns guide, type-to-confirm is required when an action affects the filesystem; this one only drops rows pointing at files that are already gone, whereas the ROM cleanup additionally removes resource directories from disk. Happy to add the gate if you'd rather the two cleanups look identical.
  4. v1's player still offers missing firmware. Its fetch lives in frontend/src/views/Player/EmulatorJS/Base.vue, a frozen v1 path that check-v1-frozen blocks, so it is untouched here. It would need the allow-v1-changes label as a follow-up if you want it fixed before v1 is retired.
  5. The task does not filter hidden platforms, matching cleanup_missing_roms; both are gated on Scope.TASKS_RUN.
  6. The tab loads the whole missing set unpaginated and filters by platform in memory. Firmware tables are dozens of rows, so this stays well inside a single request, and it lets the multi-select work against an API that only accepts one platform_id. There is no index on missing_from_fs — deliberately, at this table size.

Checklist

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

AI assistance disclosure

This PR was written primarily by Claude Code (Opus 5), under my direction and review: issue analysis, tests, implementation, and translations. I reviewed the diff, and this PR description was also AI-generated and edited by me.

Screenshots (if applicable)

image image

Spinnich and others added 2 commits August 2, 2026 23:52
A scan flags firmware whose file vanished, but nothing could act on the
flag: stale BIOS entries could only be found by opening each platform's
Firmware tab one at a time, the player still offered them (auto-selecting
the sole entry, so the game failed to boot with nothing pointing at the
BIOS), and platform firmware counts included them.

Adds a `missing` filter to the firmware list endpoint, a
`cleanup_missing_firmware` manual task alongside the ROM one, and a
Missing firmware tab in Library management that lists every flagged entry
library-wide with a bulk cleanup. The player now asks for present
firmware only, and firmware selection moved to a `playerFirmware` util
that never picks a missing entry. `firmware_count` counts only firmware
present on disk; the rows themselves still ship so the Firmware tab can
strike them through.

`FirmwareSchema` gains `platform_id` so the library-wide list can group
by platform.

Fixes rommapp#4075

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fold `selectableFirmware` back into `resolveInitialFirmware`. It had no
call site of its own, so the export existed only for its tests, and the
filtering it did is already covered through `resolveInitialFirmware`.

Clear the post-cleanup refetch timer on unmount so leaving the tab within
the delay can't surface a fetch error over an unrelated page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR makes missing firmware queryable and cleanable, excludes unavailable firmware from platform counts and the v2 player, and adds a library-wide management tab.

  • Adds backend filtering, response data, and a manual database cleanup task.
  • Adds the missing-firmware management UI and API wiring.
  • Restricts player BIOS selection and platform firmware counts to usable files.

Confidence Score: 4/5

The cleanup refresh race should be fixed before merging because a queued job can leave the new management tab showing stale firmware indefinitely after reporting success.

The backend returns as soon as cleanup is queued, while the new component refreshes exactly once after 1.5 seconds and has no task-completion-driven reconciliation, so normal worker delays produce persistently incorrect UI state.

Files Needing Attention: frontend/src/v2/components/Settings/MissingFirmwareSection.vue

Important Files Changed

Filename Overview
backend/handler/database/firmware_handler.py Adds a composable missing-state filter to firmware queries without an identified defect.
backend/tasks/manual/cleanup_missing_firmware.py Adds a registered manual task that deletes only firmware rows already flagged missing, optionally scoped to one platform.
backend/endpoints/firmware.py Exposes the missing-state filter through the authenticated firmware endpoint.
backend/endpoints/responses/platform.py Changes firmware_count to represent only firmware currently available on disk.
frontend/src/v2/components/Settings/MissingFirmwareSection.vue Adds the missing-firmware list and cleanup action, but its fixed-delay refresh can race the asynchronous cleanup job and leave stale rows displayed.
frontend/src/v2/utils/playerFirmware.ts Centralizes initial BIOS resolution while excluding missing entries and preserving the existing selection precedence.
frontend/src/v2/views/Player/EmulatorJS.vue Fetches only present firmware and delegates initial BIOS selection to the tested utility.
frontend/src/services/api/firmware.ts Forwards the optional missing filter through the firmware API wrapper.

Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
frontend/src/v2/components/Settings/MissingFirmwareSection.vue:129-134
**Fixed-delay cleanup reconciliation**

If the low-priority worker takes more than 1.5 seconds to start or finish this job, `runTask` returns after enqueueing and the sole refresh runs before deletion completes, leaving stale firmware rows displayed indefinitely after the success notification.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "refactor(firmware): tighten the player f..." | Re-trigger Greptile

Comment on lines +129 to +134
await taskApi.runTask("cleanup_missing_firmware", body);
snackbar.success(t("settings.cleanup-firmware-queued"));
// Give the queued task a moment to land before reflecting the result.
refetchTimer = setTimeout(() => {
void fetchMissingFirmware();
}, 1500);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Fixed-delay cleanup reconciliation

If the low-priority worker takes more than 1.5 seconds to start or finish this job, runTask returns after enqueueing and the sole refresh runs before deletion completes, leaving stale firmware rows displayed indefinitely after the success notification.

Knowledge Base Used: Tasks and Scheduler

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/v2/components/Settings/MissingFirmwareSection.vue
Line: 129-134

Comment:
**Fixed-delay cleanup reconciliation**

If the low-priority worker takes more than 1.5 seconds to start or finish this job, `runTask` returns after enqueueing and the sole refresh runs before deletion completes, leaving stale firmware rows displayed indefinitely after the success notification.

**Knowledge Base Used:** [Tasks and Scheduler](https://app.greptile.com/romm/-/custom-context/knowledge-base/rommapp/romm/-/docs/tasks-and-scheduler.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Both cleanup tabs refreshed on a fixed 1.5s delay after enqueueing their
task. The run endpoint returns as soon as the job is queued, so whenever
the low-priority worker took longer than that, the refresh ran against
pre-cleanup data and the tab kept showing rows that were already gone,
behind a success toast.

Add useTaskCompletion, which polls the job's own status until it reaches
a terminal state, and refresh off that instead. Backs off from 400ms to
5s between polls, gives up after 5 minutes, and treats a job that can no
longer be fetched as done since its result has aged out of Redis. It
cancels on scope dispose so leaving the tab mid-wait can't refresh or
raise an error over another page.

Applies to the missing ROMs cleanup too, which has the same race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Spinnich

Spinnich commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Valid catch @greptileai, fixed in 872afb2.

The refresh was on a fixed 1.5s delay, but POST /tasks/run/{name} returns as
soon as the job is enqueued, so any worker slower than that left the tab showing
rows that were already deleted, behind a success toast.

Replaced the delay with a new useTaskCompletion composable that polls the job's
own status via GET /api/tasks/{task_id} until it reaches a terminal state
(finished / failed / stopped / canceled), and refreshes off that:

  • backs off 400ms → 5s between polls, caps the total wait at 5 minutes, and
    refreshes anyway on timeout
  • treats an unfetchable job as done, since a 404 means it ran and aged out of
    Redis past TASK_RESULT_TTL
  • cancels on scope dispose, so leaving the tab mid-wait can't refresh or raise an
    error over an unrelated page

Polling rather than sockets is deliberate: there's no task-completion socket
event, and TasksSection already polls task status on an interval, so this is
the same mechanism scoped to a single job.

Worth noting the missing ROMs cleanup in MissingGamesSection had the
identical race on master — I'd modelled this tab on it — so the fix is applied
there too.

Added 7 unit tests for the composable and 2 component tests (refreshes once the
task reports finished; leaves the list alone while it's still started).
Frontend suite 641 passed / 50 files, typecheck and trunk clean.

@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Clean fix. The useTaskCompletion composable is well-structured — the generation counter correctly handles the supersession case (a second awaitTask call while one is in flight resolves the first with false), and the settle/finish split keeps the cancel-vs-poll-completes race from double-resolving the promise.

A few minor observations worth noting:

  1. Error handling breadth — catching all errors from getTaskById and treating them as "done" is pragmatic for the 404/TTL case, but it silently swallows transient network errors (timeouts, 5xx) the same way. In practice the timeout floor (POLL_TIMEOUT_MS) covers this — the caller will eventually refresh — but it means a repeated network blip during a long cleanup could trigger an early refresh against mid-run data. Low-risk at this table size, just worth being aware of.

  2. The void poll() on first call is immediate, which is good — no artificial delay on a job that's already finished when the first poll lands. The "already finished" test confirms this works correctly.

  3. The fix applied to MissingGamesSection is the right call — the same race was definitely present there since this PR modelled the firmware section on it.

The 7 composable tests cover the meaningful branches well, including the supersession and scope-dispose cases that are easy to miss. Looks good.

Every failed status lookup counted as completion, so a timeout or a 5xx
mid-cleanup refreshed the tab against a job that was still running. Only
a 404 says the job is gone; anything else says nothing about it, so keep
polling and let the existing deadline bound the wait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Spinnich

Spinnich commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Good catch on point 1 @greptileai — fixed in c594222.

It also surfaced a mismatch between the comment and the code: the comment said a
job past its result TTL 404s, but the catch treated every failure as
completion. So a timeout or a 5xx mid-cleanup would resolve true and refresh
against a job still running — the same stale-list symptom the composable exists
to prevent, just behind a rarer trigger.

Now narrowed to a genuine 404 via axios.isAxiosError(err) && err.response?.status === 404,
matching how the rest of the codebase discriminates errors. Anything else falls
through to the normal backoff, and POLL_TIMEOUT_MS still bounds the wait, so a
persistently failing lookup degrades to "refresh anyway" rather than hanging.

Two tests pin it: a 503 followed by finished must produce two lookups (the
previous code stopped after one, so it fails against 872afb2), and a
persistently failing lookup must still settle at the deadline.

Points 2 and 3 need no change — the immediate first poll is already asserted by
the "resolves without waiting when the job already finished" test, and the
MissingGamesSection fix landed in 872afb2.

One correction to the read on the generation counter: it's right now, but only
because the first cut of that mechanism had a bug — cancel() cleared the
pending timer, which orphaned the promise so a cancelled wait never resolved.
The scope-dispose and supersession tests caught it before it was pushed.

Frontend suite 643 passed / 50 files, typecheck and trunk clean.

@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The fix is exactly right. The narrowed catch correctly separates the two cases:

  • 404 → the job result TTL expired, meaning the job ran and is gone → finish(true), trigger the refresh
  • anything else (5xx, timeout, network error) → we don't know whether the job finished → fall through to the next setTimeout, keep polling, let POLL_TIMEOUT_MS serve as the backstop

The two pinning tests are well-chosen:

  • "keeps polling through a transient failure" — asserts exactly 2 calls, so a 503 can't short-circuit to completion the way the old broad catch would have
  • "stops retrying a persistently failing lookup at the deadline" — confirms the deadline still forces resolution even if the endpoint never recovers, so the caller doesn't hang

And the settle + generation counter explanation makes sense — without settle?.(false) in cancel(), a cancel() that fires while a setTimeout callback is pending would clear the timer but leave the Promise unresolved until the next getTaskById eventually returned. The scope-dispose and supersession tests validating that were the right thing to pin.

LGTM on c594222.

@Spinnich
Spinnich requested a review from gantoine August 3, 2026 00:57
@gantoine gantoine changed the title fix(firmware): let missing firmware be found, cleaned up, and kept out of the player feat(firmware): let missing firmware be found, cleaned up, and kept out of the player Aug 4, 2026
@gantoine gantoine added the on-hold Pending further research or blocked by another issue label Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

on-hold Pending further research or blocked by another issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Firmware/BIOS entries whose files are gone can't be cleaned up, and are still offered to the emulator

2 participants