Commit Graph
182 Commits
Author SHA1 Message Date
Georges-Antoine AssiandClaude Opus 5.5 e232a52656 fix(docker): boot when the container runs as a non-default UID (#5189)
* fix(docker): boot when the container runs as a non-default UID

Unraid runs the container as 99:100, which can't write to the image's
/romm (owned by 1000, mode 755), only to the mounted subfolders. The
PSP pending dir at /romm/cache/retroarch_sync was created at import
time, so every gunicorn worker failed to boot.

- Buffer pending PSP save files under the user's assets folder
  (assets/users/<user>/retroarch/psp_pending/), beside the other
  RetroArch Cloud Sync files and outside the client-reachable blob
  categories.
- Move the zip and rom-converto download caches to /tmp/romm/cache,
  and point nginx's internal /cache/ alias there.
- Put gunicorn's control socket in /tmp/gunicorn instead of
  $HOME/.gunicorn, since HOME is / for that UID.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): honor ROMM_TMP_PATH for caches, explain an unwritable sync folder

- The download caches live under $ROMM_TMP_PATH/cache when it's set,
  so installs with a small Docker disk (Unraid's docker.img) can point
  them at a share. The entrypoint exports the same path for nginx's
  /cache/ alias.
- With ENABLE_SYNC_FOLDER_WATCHER on and /romm/sync not writable, /init
  warns and skips the watcher instead of dying on mkdir under errexit.
- The compose example lists the /romm/sync mount the sync features need.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): cover reading back and clearing buffered PSP files

Addresses Greptile's review on #5189.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): apply code-review findings to PSP pending buffer

Remove the emptied psp_pending folder after a merge, define the sync
folder path once in init, update the architecture doc's PSP and blob
locations, and drop a stale .gitignore entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(sync): apply simplify findings to PSP pending cleanup

Clear the buffered folder with remove_directory, let the startup check
own creating the sync folder, and reword the test fixture docstring.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): cap buffered PSP files and drop them on delete

Buffered files for an unresolved save folder now count against the
bundle limits, so a folder can't fill the assets share and then fail
every merge once it resolves. A DELETE also removes the buffered copy,
so a deleted file isn't merged back when the folder resolves.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): check the sync folder for push-pull sync too

ENABLE_SYNC_PUSH_PULL keeps SSH keys and device saves under /romm/sync
as well, so an unwritable folder now disables it with the watcher and
warns at boot instead of failing when the task runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): strip whitespace from env vars like the backend does

The backend strips every variable it reads, while the init script and
nginx templates used raw values, so a padded ROMM_TMP_PATH sent nginx's
/cache/ alias somewhere the backend never writes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* chore(config): drop the CACHE_BASE_PATH comment

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs(docker): say which backend config strips env whitespace

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* revert(docker): drop the entrypoint env whitespace trimming

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): apply pr-ready code-review findings

Gate push-pull on its SSH keys path rather than a writable sync folder,
accept the same truthy values as the backend, and clear only the merged
PSP pending files so another worker's buffered file survives.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor: apply pr-ready simplify findings

Share one truthy pattern across init's flag checks (the watcher gates
now accept the same values as the backend), disable gunicorn's unused
control socket instead of placing it, derive the converts redirect from
its own cache path, and factor the unknown PSP folder in tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs(docker): apply pr-ready humanizer pass to the sync mount note

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(fs): create handler base folders on first write, skip leftover temp files

FSHandler no longer mkdirs its base path when constructed, so a module-level
handler rooted at an unmounted or unwritable folder can't take down boot;
every write path already creates its parent folders. Blob listings skip
.romm_tmp_ files a crashed write left behind, so they are never merged into
a PSP bundle or advertised in the sync manifest. The prefix now lives in one
TEMP_FILE_PREFIX constant.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 16:48:50 -04:00
Georges-Antoine AssiandClaude Opus 5.5 22e9911977 Print the configured ROMM_PORT in the startup log (#5023)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 08:58:03 -04:00
Sam Dornan 6215ab341e Disable redis-py maintenance notifications probe (#5015) 2026-10-02 07:58:20 -04:00
Georges-Antoine AssiandClaude Opus 5.5 17765df6b2 fix(tasks): keep failure reports and queue choice on the task spec
report_task_failure can run in the worker parent when a work horse is
killed, so it now enqueues notify_task_failure on the default queue
instead of loading the notification stack there.

enqueue_task defaults to the queue its spec names. The streaming worker
runs as PreloadingWorker, which imports the enabled scheduled tasks of
its queues, so the reaper's per-minute horse inherits its stack. Spec
tests now read config in a fresh interpreter instead of comparing a
value with itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 11:29:52 -04:00
Georges-Antoine AssiandClaude Opus 5.5 e1052665c1 feat(streaming): run the session reaper on a streaming queue with a worker of its own
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 09:46:35 -04:00
zurdiandClaude Opus 5.5 5cccceea60 fix(audit): close the gaps left open by the pr-ready review
- A failed login keeps the attempted username only when it names an
  account, so a password typed into the username box is never stored.
- Gunicorn trusts X-Forwarded-For only from loopback and private ranges
  (FORWARDED_ALLOW_IPS, documented in env.template), so a client can no
  longer choose the address the audit log and rate limits see.
- A player's fetch (purpose=play) is recorded as rom.player_load instead
  of being skipped, so the marker can't hide a download.
- Play-session ingest treats a rom hidden from the caller as unknown, so
  its session and rom.play event don't name it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 08:08:53 +02:00
zurdiandClaude Opus 5.5 5cd71a244c simplify: pin the RQ worker PID write with a plain substring check
The test checks the one line the watchdog relies on, next to the keepalive
pair instead of between it, and the comment states RQ's late write once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 21:17:21 +02:00
zurdiandClaude Opus 5.5 ce36d3f0fb review: guard the RQ worker PID file and comment it where it is written
Adds a test that start_rq_worker records $! instead of passing --pid, and
moves the comment next to the line it explains.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 21:14:34 +02:00
zurdiandClaude Opus 5.5 87ab23fd38 fix(docker): record the RQ workers' PID as soon as they start
The watchdog restarts any process whose PID file is missing, and RQ's --pid
writes that file only after importing the worker class. Since RomMWorker
imports tasks.tasks (and with it config, SQLAlchemy and the auth and socket
handlers), reaching that point takes seconds on a cold, bytecode-less image,
so on a slower host the watchdog started a fresh pair of workers every 5 s
and each one lived on as a duplicate. The init script now writes $! like it
does for the cron scheduler and the watchers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 21:12:09 +02:00
Sam DornanandClaude Opus 5 b91a1af13e perf(nginx): drop open_file_cache and guard the keepalive precondition
open_file_cache keyed descriptors by path, so a cover replaced via
os.replace() kept serving the old inode until the validity window expired,
and the ?ts= buster does not change the filesystem key. The comment beside
it claimed the opposite. The benefit was never measured either: the cache
saves an open() and an fstat() per request, which is microseconds on a
local file, and covers are served immutable so repeat hits are rare.
Removing it is simpler than scoping it away from the one tree it was
supposed to help.

The keepalive test asserted gunicorn outlives nginx's idle timeout without
checking that nginx pools upstream connections at all, which it only does
by default from 1.29.7, so a downgrade would have left the test green and
the pooling silently gone. It now checks the pinned version too.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HDXxq3vAxJVGhm1pQuK8V7
2026-09-12 01:44:25 +00:00
Sam DornanandClaude Opus 5 5d58170bb0 docs(nginx): correct why the gunicorn keep-alive has to be raised
The comment and test docstring said a short keep-alive makes nginx reuse a
connection gunicorn is closing and return 502. Reproducing the race says
otherwise: across 320 requests timed against the close boundary, GET and
POST alike, nothing failed and nginx logged nothing. It notices the closed
idle connection and opens a fresh one.

What the short value does cost is the connection pool. Measured with a
counting upstream behind nginx, eight requests three seconds apart opened
eight upstream connections at a 2s idle close and one at 65s, so below
nginx's idle timeout the keepalive it now enables by default never pays off.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HDXxq3vAxJVGhm1pQuK8V7
2026-09-12 01:08:18 +00:00
Sam DornanandClaude Opus 5 da6ea1dd16 fix(docker): bump nginx to 1.29.8 and raise gunicorn keep-alive
nginx 1.29.7 is a security release (CVE-2026-27654, CVE-2026-27784,
CVE-2026-32647, CVE-2026-27651, CVE-2026-28753, CVE-2026-28755) and also
enables upstream keepalive by default, defaulting proxy_http_version to
1.1. That removes a TCP connect and teardown per /api request, which the
config never opted into.

It also makes the gunicorn keep-alive too short: nginx now holds idle
upstream connections for up to 60s while gunicorn closed them after 2,
so nginx could send a request on a connection gunicorn was closing and
return 502. Raise the default past nginx's idle timeout.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HDXxq3vAxJVGhm1pQuK8V7
2026-09-12 00:10:32 +00:00
SpinnichandClaude Opus 5 424d1ebc1c fix(server): default to 4 API workers instead of 1
A single worker was the reason a lone user loading a gallery measured
6.474 s worst-case TTFB against 1.939 s at 4 workers. The multi-worker
path is already supported: the log forwarder takes a Redis lock so
exactly one worker relays.

Only installs that never set WEB_SERVER_CONCURRENCY move; any explicit
value is preserved.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 16:56:48 +00:00
Georges-Antoine AssiandClaude Opus 5 dc7d14f31d refactor(scan): let RQ discard the scan dispatch, and say the guard once
The dispatch job was hidden from the task list by a meta flag the endpoint
had to look for on every job in every queue and registry. RQ already has
the rule the flag was implementing: `result_ttl=0` deletes a job the moment
it succeeds, while a failed one still lands in the failed registry under
its own TTL. Registering the scan entry with it drops the marker, the
predicate, its four filter sites and the two Redis reads each one cost.

The concurrency guard is now one call. `get_blocking_library_scans()` says
which scans a second one has to wait for, so the socket handler no longer
assembles that rule out of two helpers in two shapes, and `ALL_QUEUES` sits
with the queues it lists rather than in the API layer.

Also merges the finished and failed registry sweeps, which were the same
loop, trims comments that explained the design rather than the code, and
drops two socket tests that duplicated their neighbours.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 12:29:58 -04:00
Georges-Antoine AssiandClaude Opus 5 ffc8dffc20 Merge upstream master into claude/rq-scan-queue-split
Master landed #4274 with the review changes that came out of it, and those
moved the code this branch was built on. Scan job bookkeeping now lives in
`handler/scan_jobs.py`, every enqueue goes through `enqueue_task`, cron
entries carry their own name and result TTL, and the watcher builds its two
rescans from one helper.

Resolved by putting this branch's changes where that code now lives:

- The scan queue, the scoped-scan check and the second scheduled registry
  moved into `handler/scan_jobs.py`, keeping master's single-round-trip
  reads (`get_job_ids` plus one `fetch_many`, and `get_jobs_to_schedule`
  for the stale sweep) across both registries.
- `enqueue_scheduled_scan` sits next to `enqueue_task` in the registry and
  is built from it, rather than hand-rolling the same enqueue in
  `tasks/tasks.py`. Its tests moved with it.
- Cron keeps master's per-entry name and result TTL, and adds the scan
  dispatch on top: `TASK_TIMEOUT` for the dispatch, the task's own timeout
  (now `SCAN_TIMEOUT`) for the scan it enqueues.
- The watcher's `schedule_rescan` helper enqueues on the scan queue with
  the abandoned-job callback.
- `PeriodicTask.func` is gone from master, so the scheduled rescan declares
  only its timeout. `tasks.registry.enqueue_scheduled_scan` joins the func
  paths an RQ worker has to be able to import.
- The job stubs shared by the discovery tests grew the scan queue and a
  scoped-scan job; the duplicated stale-sweep tests are master's.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 12:16:44 -04:00
Georges-Antoine AssiandClaude Opus 5 787bbba2b4 refactor(tasks): give every enqueue one home
Scheduling moved onto RQ, which left the task catalog with two identity
schemes: the registry key a job payload now carries, and the dotted func
path each PeriodicTask still declared. Nothing read the latter, so it is
gone, along with the test that guarded paths no payload names.

Four sites built the same enqueue by hand and had already drifted: the
startup backfills passed the global timeout and no result_ttl, so an
auto-enqueued conversion fell out of the task list after RQ's default
500s while the same task triggered by an admin lived for TASK_RESULT_TTL.
They now go through enqueue_task, which derives timeout, ttl and meta
from the task itself.

Scan discovery paid a Redis round trip per queued job to re-read a status
the fetch already carried, and the stale sweep read every delayed job to
find the few past due. Both now ask Redis once.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 21:03:30 -04:00
Sam DornanandClaude Opus 5 3593d2398a perf(scan): give scans their own queue and worker
A library scan runs for hours on the same worker as everything else, so
every cleanup, conversion and metadata refresh waited behind it. Scans go
on a queue of their own, consumed by a second worker, and the queues that
serve the rest of the app keep moving while a scan runs.

One worker on one queue also makes "one scan at a time" structural rather
than something the socket handler has to enforce, and it leaves another
worker running to notice when the scan worker dies.

Which is what the second half of this does. A worker killed mid-scan
never reaches the handler that reports the scan ended, so clients kept
showing a scan that no longer existed. RQ hands an abandoned job to its
failure callback when a surviving worker reaps it, so scans carry one
that emits `scan:done_ko`. Only for AbandonedJobError: every other
failure is already reported by the scan as it unwinds, and emitting here
too would report it twice. The scheduled rescan is not covered, because
rq cron cannot attach callbacks.

Scan discovery still reads the other queues, so a scan enqueued before
this change is still found and still runs.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-02 15:02:16 -05:00
Sam DornanandClaude Opus 5 4e3ca0611f fix(docker): read REDIS_SSL as the boolean the app reads it as
The shell built the connection URL with `redis${REDIS_SSL:+s}`, which
only asks whether the variable is set. `env.template` ships
`REDIS_SSL=false`, so following the documented value pointed the worker
at `rediss://` while the app parsed the same variable through
`safe_str_to_bool` and connected in plaintext. Adding the cron process
put a second process behind it.

Parse the value in both entrypoints, matching the truthy set the app
accepts.

Also clear the old scheduler's lock and instance keys even when its
registry held no jobs: a scheduler that never queued anything still
registered itself, and only the orphan-job work needs the registry.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-02 15:02:04 -05:00
Sam DornanandClaude Opus 5 4e59217275 refactor(tasks): schedule with RQ instead of rq-scheduler
rq-scheduler has one release since 2023, 109 open issues, and RomM ran a
fork of it for Redis username and SSL options that upstream never took.
Everything it was here for now ships with RQ.

Its two failure modes were the reason for the workarounds in 9e9f59c04.
It reads a job's function name before taking the job out of its registry,
so one unreadable job stalls it for good; RQ's scheduler removes every id
it looked at whether or not the job could be read. And cancelling a
delayed job needed both `Scheduler.cancel` and `Job.cancel`, because the
first left the status alone and the second left the registry entry, which
the scheduler then queued anyway; `Job.cancel` on its own now clears the
scheduled registry.

Delayed jobs, which is how the watcher defers a rescan, go onto the queue
with `Queue.enqueue_in` and are released by the worker running with
`--with-scheduler`. Periodic tasks are declared in `tasks/cron_config.py`
and loaded by an `rq cron` process, replacing `rqscheduler`. A task is
registered only when it is enabled and has a cron string, so the
schedule-then-unschedule dance around the env toggles is gone, and with
it `PeriodicTask.init/schedule/unschedule`.

Jobs are enqueued by task name through `run_task_by_name`, resolved
against a catalog in `tasks/registry.py`. Nothing pickles a task instance
into Redis any more, which is what made a payload unreadable across
upgrades in the first place. The catalog also gives the tasks endpoint
one home for what a task is, rather than a list of its own.

Scan discovery keys off the type a job carries rather than the scheduled
rescan's function name, since every task now shares one entry point. That
also drops SCAN_LIBRARY_TASK_FUNC, which only existed to name a function
across an import cycle.

The watcher's dedupe worked on `job.args[0]` while scans are enqueued with
keyword arguments, so it never matched and every filesystem change
scheduled another scan. It reads `platform_ids` from the payload now, and
deliberately ignores a scan already running: that one may have walked past
the folder that just changed.

Startup clears what the old scheduler left in Redis, once.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-02 15:02:04 -05:00
Sam DornanandClaude Opus 5 9e92e325f7 chore: drop review-flagged comments
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 08:11:09 -05:00
Sam DornanandClaude Opus 5 09b3c92690 fix(docker): ship valkey-cli and log watchdog restarts
The image bundles valkey but not its CLI, which Alpine packages
separately, so the internal cache cannot be inspected from inside a
running container. The watchdog also restarted dead processes silently,
hiding both the crash and whatever state died with it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 16:51:41 -05:00
Georges-Antoine AssiandClaude Opus 5 76efbe72fd fix(docker): keep the Redis password out of the RQ worker command line
The worker received its connection string through `rq worker --url`, which
put the password in argv for the life of the process, readable via
/proc/<pid>/cmdline, ps, and any monitoring or diagnostic tooling that
collects command lines (CWE-214).

RQ's `--url` option already reads from RQ_REDIS_URL, so passing it through
the environment instead is behavior-preserving. This matches how the RQ
scheduler alongside it already takes its credentials.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-03 21:24:31 -04:00
Georges-Antoine AssiandClaude Opus 5 ae74f8de53 chore(docker): trim the scheduler comment
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 11:04:36 -04:00
Ryan McClellandandClaude Opus 5 0ae753e4fe fix(docker): always start the RQ scheduler
The scheduler only started when one of four ENABLE_SCHEDULED_* flags was
true, but nothing else respects that condition. startup.py registers the
netplay, upload-tmp and zip-cache cleanups unconditionally, three more
flags gate periodic tasks that are absent from the list, and the
filesystem watcher defers its rescans through the scheduler as well.

Any of those leaves jobs sitting in the scheduler registry with no
process to run them. On a default install the three cleanups never fire
at all. A watcher running with none of the four listed tasks enabled
leaves a delayed scan_platforms entry that nothing will execute, and the
concurrent scan guard reads it as a scan already queued, so every manual
scan is refused with "A scan is already in progress". Restarting does not
help, because the entry is persisted state rather than a running process.

Start the scheduler unconditionally, matching entrypoint.sh, which never
gated it. A stuck entry then clears on its own, since the scheduler
enqueues past-due jobs as soon as it comes up.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-31 12:13:37 -07:00
Georges-Antoine Assiandgreptile-apps[bot] 7f0636aed5 Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-28 21:26:15 -04:00
Georges-Antoine AssiandClaude Opus 5 48259b3a9e fix(docker): snapshot the internal valkey hourly instead of every few minutes
The keyspace holds the Switch TitleDB and LaunchBox caches, so dump.rdb runs
to hundreds of MB (424MB on a dev instance). Valkey's stock save policy
rewrites all of it every ~5 minutes once 100 keys change, which idle RQ
bookkeeping alone triggers, for gigabytes of disk writes per hour.

Fixes #3980

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 18:41:49 -04:00
Ryan McClellandandClaude Opus 5 90d1c72256 feat(tasks): allow orphaned resource cleanup to run on a schedule
Orphaned covers and screenshots left behind by deleted ROMs accumulate
indefinitely, because the only way to reclaim them is to trigger the
cleanup task by hand. On a long-lived instance the resources directory
grows without bound, which is a problem when it shares a filesystem with
the database.

Move the task to `tasks/scheduled/` and turn it into a `PeriodicTask`
with an opt-in cron schedule, defaulting to 5:00 AM so it runs after the
nightly scan and metadata tasks have settled.

`enabled` stays `True` rather than tracking the new env var, because the
run-task endpoint requires both `enabled` and `manual_run`. Gating it
would have silently removed the ability to run the cleanup by hand,
which is how it works today. The cron string is what is opt-in, and
`init()` unschedules when it is unset.

Add the new flag to the init script's scheduler condition, so enabling
only this schedule still starts `rqscheduler`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-27 13:53:30 -07:00
Georges-Antoine Assi f1b49fa9c2 cleanup 2026-07-27 08:22:25 -04:00
Georges-Antoine AssiandClaude Opus 5 98651ecbf8 Fix ROMM_AUTH_SECRET_KEY auto-generation under nounset
The init script runs with `set -o nounset`, so testing `${ROMM_AUTH_SECRET_KEY}`
aborted with "unbound variable" before reaching the generation branch, leaving
the container in a restart loop. This only triggered when the variable was
omitted entirely; shipping it with an empty value took the working path.

Use `${ROMM_AUTH_SECRET_KEY-}` so the unset case is testable, and warn instead
of info since a generated key changes on every restart.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-27 08:07:50 -04:00
Georges-Antoine AssiandClaude Opus 4.8 168fb2f009 fix(docker): sync nginx upstream port with DEV_PORT
The nginx upstream was hardcoded to 127.0.0.1:5000, while gunicorn and the
readiness check bind/probe ${DEV_PORT}. With a non-default DEV_PORT the
readiness check passes but every proxied request returns 502.

nginx.conf is not run through envsubst, so move the wsgi_server upstream into
the templated default.conf.template and export DEV_PORT in the entrypoint so
the upstream port tracks the gunicorn bind.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-18 19:05:40 -04:00
Georges-Antoine Assi 0ca1eb5529 fix(docker): proxy nginx to gunicorn over TCP instead of unix socket
nginx workers are dropped to the `romm` user while gunicorn creates
/tmp/gunicorn.sock as root. This only worked because gunicorn's default
umask leaves the socket world-writable; on some hosts (e.g. OMV) the
worker cannot connect() and every /api request 502s with
"connect() to unix:/tmp/gunicorn.sock failed (13: Permission denied)".

Proxy over loopback TCP (127.0.0.1:5000), which gunicorn already binds,
so there is no socket file owner/mode to mismatch. Readiness now polls
the TCP port instead of waiting for the socket file.

Fixes #3809

Generated-By: PostHog Code
Task-Id: 3cb72cda-eb77-4bc0-97ab-cde365a031c9
2026-07-18 14:44:08 -04:00
Georges-Antoine Assi 23d724f447 fix(backend): keep default RQ maintenance interval for prompt scan recovery
The registry-cleanup log noise is already suppressed by the RomMWorker log
filter, so the hourly maintenance interval was unnecessary and delayed pruning
of orphaned STARTED jobs and stale workers. The watcher's Worker.all() scan
dedupe relies on that pruning, so restore the default (~10 min) cadence.

Generated-By: PostHog Code
Task-Id: 78c8b2f8-2b03-443f-aa4c-8867c1259f2a
2026-07-15 20:02:49 -04:00
Georges-Antoine Assi 315576b0de feat(backend): reduce RQ worker registry sweep verbosity and frequency
Generated-By: PostHog Code
2026-07-12 21:39:09 -04:00
Georges-Antoine AssiandClaude Opus 4.8 619e03ab32 fix: stop serving private user assets via unauthenticated nginx static route
The default Docker image symlinked /romm/assets into the nginx static web
root (/assets/romm/assets), where it was served by an unauthenticated
`location /assets { try_files ... }` block. /romm/assets holds private user
data (save files, save states, screenshots, avatars) that is meant to be
accessible only through the authenticated /api/raw/assets/{path} route
(Scope.ASSETS_READ). The static symlink bypassed that protection, letting any
unauthenticated caller read another user's files given a (guessable) path.
Avatar URLs leaked the hex user ID through the same static route, making path
construction straightforward.

Fix:
- Drop the /romm/assets symlink from the Docker image build and both
  entrypoint scripts; only /romm/resources (public cover art, screenshots,
  manuals) remains statically served.
- Point the frontend avatar URLs at the authenticated /api/raw/assets/ route
  instead of /assets/romm/assets/. Browser <img> loads authenticate via the
  existing session cookie.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-05 17:34:30 -04:00
Georges-Antoine AssiandClaude Opus 4.7 354207d683 Drop redundant out_arr reset in otel_prefix
All callers declare a fresh `local -a wrap=()` before invoking, so the
in-function reset is unnecessary.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-24 15:17:42 -04:00
Georges-Antoine AssiandClaude Opus 4.7 a4f1f516bc Simplify OTEL wrapper helper and fix shell quoting
Collapse `otel_prefix` and `otel_prefix_str` into a single nameref-based
helper. Watchfiles call sites embed the array as a shell-quoted prefix
via `${wrap[*]@Q}`, which also fixes a quoting bug where an
`OTEL_SERVICE_NAME_PREFIX` containing a single quote would produce an
invalid command string and break the watcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-24 14:57:52 -04:00
Claude f434fde772 Refactor OTEL wrapping into shared helpers
Collapse the duplicated OTEL_SDK_DISABLED / opentelemetry-instrument
branches in run_startup, start_bin_gunicorn, start_bin_watcher, and
start_bin_sync_watcher into two small helpers:

- otel_prefix: emits the wrapper as NUL-delimited argv tokens (for
  direct process invocation).
- otel_prefix_str: emits the wrapper as a shell-string prefix (for
  embedding inside `watchfiles --target-type command`).

Each call site becomes a single command instead of a 2- or 3-way
branch with a fully duplicated command body. As a side effect, the
watcher functions now also gain the `command -v opentelemetry-instrument`
fallback that the gunicorn/startup paths added.
2026-05-24 18:21:40 +00:00
copilot-swe-agent[bot]andgantoine 84a0dd9ef2 Conditionally disable opentelemetry wrapper during init startup
Co-authored-by: gantoine <[email protected]>
2026-05-24 14:14:32 +00:00
Georges-Antoine Assi ba25477a2d changes from bot review 2026-05-17 19:07:57 -04:00
Georges-Antoine Assi 887bdad0ef mark local 2026-05-17 17:52:47 -04:00
Georges-Antoine Assi 3557850701 refactor 2026-05-17 17:52:14 -04:00
Georges-Antoine Assi 51f0522193 undo 2026-05-17 17:39:47 -04:00
Georges-Antoine AssiandClaude Opus 4.7 ea22b06502 fix(sync): export SYNC_BASE_PATH so the Python child inherits it
The shell fallback was assigned locally but never exported, so
sync_watcher.py and the Python config layer never saw the resolved
value. They happened to land on the same /var/lib/romm/sync default by
coincidence; export it so the shell and Python defaults stay linked
through a single source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-17 17:22:54 -04:00
Georges-Antoine AssiandClaude Opus 4.7 e5c9d0faf7 fix(sync): create SYNC_BASE_PATH at startup before watching
The prod Dockerfile creates /var/lib/romm/sync at build time, but if a
user overrides SYNC_BASE_PATH to a path that doesn't exist (or runs the
dev entrypoint, which never created the default), watchfiles fails to
start because its target directory is missing. Have both entrypoints
mkdir -p the resolved path before handing it to watchfiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-17 14:12:16 -04:00
Georges-Antoine AssiandClaude Opus 4.7 10d3eb2079 feat(sync): default SYNC_BASE_PATH to /var/lib/romm/sync
Move the sync staging folder out of ROMM_BASE_PATH so it lives on a
dedicated writable mount. This lets the container run with a read-only
root filesystem without losing in-flight save uploads, and keeps
app-owned state separate from the user-curated library volume.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-17 09:28:39 -04:00
Georges-Antoine AssiandClaude Opus 4.7 3f52ab616e fix(docker): generate gunicorn logging config to writable /tmp path
The init script ran `sed -i` against /etc/gunicorn/logging.conf, which
fails both on read-only root filesystems and when the container runs
as a non-root UID (since /etc/gunicorn is not chmod'd writable). Copy
the config to /tmp/gunicorn/logging.conf at startup and edit/use that
copy instead, leaving the image file untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-16 18:14:59 -04:00
Georges-Antoine Assi faed12e407 remove log-level from rqscheduler 2026-05-08 19:30:43 -04:00
Georges-Antoine AssiandClaude Opus 4.6 485b6af0dd fix: wire LOGLEVEL into gunicorn logger level and RQ worker/scheduler
The gunicorn logging config hard-coded level=INFO, ignoring LOGLEVEL.
Patch it at startup via sed. Also pass --logging_level to rq worker
and rqscheduler so their framework logging respects LOGLEVEL.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 10:09:03 -04:00
Georges-Antoine Assi af2d1f3471 changes from bot review 2026-04-11 09:58:32 -04:00
copilot-swe-agent[bot]andgantoine bc9961cfe5 fix: remove dynamic gunicorn logging config generator from init script
Agent-Logs-Url: https://github.com/rommapp/romm/sessions/bdfc3221-ba18-445d-9cf7-f4b495e82577

Co-authored-by: gantoine <[email protected]>
2026-04-11 02:48:15 +00:00