2 Commits
Author SHA1 Message Date
Knutwurst ec94f0578b Release 0.0.6 2026-06-25 06:35:01 +02:00
Knutwurst 88368e3df3 Plug response-OOM leak, cap RAM, free pool on shutdown
queue_buffer leaked the MUST_FREE payload (every queue_json_owned/
build_*_json/strdup(resp)) when MHD_create_response_from_buffer hit OOM.
The MUST_FREE contract hands ownership to MHD only on success — on the
NULL return path the caller still owns the buffer, so free it before
bailing. Critical under memory pressure where the first OOM turns into
a cascade.

patchdl_websrv_stop walked the verxml thread + workers but never freed
the remaining dl_job_t entries or g_debug_json. Today main never calls
the function, but the early-failure path inside patchdl_websrv_start
does, and any later graceful-shutdown work would hit the same leak.
Drain g_pool.jobs through free_job_locked under the pool lock; free
g_debug_json under g_mutex.

RAM caps:

- MHD: CONNECTION_LIMIT 64 -> 8 (single-user UI), and
  THREAD_STACK_SIZE = 512 KB. THREAD_PER_CONNECTION on libc's default
  pthread stack (multi-MB) was reserving hundreds of MB of VM per
  burst; the largest stack frame in any handler is the 8 KB sidecar
  buffer, so 512 KB is generous even with curl + openssl in the path.
- patchdl_buf_t caps: manifest 64 -> 16 MiB, version.xml 16 -> 4 MiB.
  Real PS5 manifests are 1-2 MiB; the old caps allowed 64 MiB per
  fetch with multiple fetches possible in flight.
- patchdl_install_status_json: ai_install_status_t (2 KB pad) moves
  from the MHD worker stack to a calloc/free pair so a polling browser
  doesn't keep committing pages on each /api/installstatus tick.
- seed_from_sidecar: 16 KB stack buf -> 8 KB. 4096-piece cap fits with
  the JSON wrapper inside 8 KB.

No functional changes; download/install/scan/tile paths unaffected.
2026-06-25 06:10:48 +02:00
5 changed files with 58 additions and 23 deletions

No files matched your search

+20 -11
View File
@@ -475,7 +475,10 @@ patchdl_install_status_json(char *out, size_t out_sz) {
char tid[32];
char method[32];
int start_rc;
ai_install_status_t st;
/* ai_install_status_t carries a 2 KB safety pad; live on the heap so
a frequently-polled /api/installstatus doesn't keep committing pages
on every MHD worker's stack. */
ai_install_status_t *st = NULL;
char status[17], src_type[9];
int rc;
int progress = 0;
@@ -514,20 +517,25 @@ patchdl_install_status_json(char *out, size_t out_sz) {
return -1;
}
st = calloc(1, sizeof(*st));
if (!st) {
snprintf(out, out_sz, "{\"error\":\"oom\"}");
return -1;
}
/* sceAppInstUtilGetInstallStatus(char *content_id_out, status_t *status):
first arg is an OUTPUT buffer that receives the current install's content_id.
Do NOT pass `cid` there — it would be overwritten. The visible id fits in
0x30 bytes but Sony's NUL-pad length is unknown; use a padded buffer. */
{
char ai_cid_out[AI_CONTENTID_OUT_SIZE] = {0};
memset(&st, 0, sizeof(st));
rc = ai_get_status(ai_cid_out, &st);
rc = ai_get_status(ai_cid_out, st);
(void)ai_cid_out; /* returned content_id for future use */
}
copy_bounded(status, sizeof(status), st.status, sizeof(st.status));
copy_bounded(src_type, sizeof(src_type), st.src_type, sizeof(st.src_type));
if (st.total_size > 0)
progress = (int)((st.downloaded_size * 100) / st.total_size);
copy_bounded(status, sizeof(status), st->status, sizeof(st->status));
copy_bounded(src_type, sizeof(src_type), st->src_type, sizeof(st->src_type));
if (st->total_size > 0)
progress = (int)((st->downloaded_size * 100) / st->total_size);
if (progress < 0) progress = 0;
if (progress > 100) progress = 100;
terminal = (!strcmp(status, "playable") ||
@@ -542,10 +550,11 @@ patchdl_install_status_json(char *out, size_t out_sz) {
"\"promote_progress\":%u,\"error_code\":%d}",
terminal ? "true" : "false", cid, tid, method, start_rc, rc,
status, src_type, progress,
(unsigned long long)st.downloaded_size,
(unsigned long long)st.total_size,
(unsigned)st.promote_progress,
(int)st.error_info.error_code);
(unsigned long long)st->downloaded_size,
(unsigned long long)st->total_size,
(unsigned)st->promote_progress,
(int)st->error_info.error_code);
free(st);
return rc;
}
+5 -3
View File
@@ -31,9 +31,11 @@
#define PATCHDL_MAX_TOTAL_BYTES (200ULL * 1024 * 1024 * 1024) /* 200 GiB */
/* In-RAM buffer caps for full HTTP body fetches. version.xml is a few KB,
manifest JSON is a few MB at most — fail-closed beyond that. */
#define PATCHDL_BUF_MAX_VERXML (16 * 1024 * 1024)
#define PATCHDL_BUF_MAX_MANIFEST (64 * 1024 * 1024)
manifest JSON is a few MB at most — fail-closed beyond that. The
manifest cap is sized for ~4096 pieces × ~3 KB JSON each with plenty of
headroom; real PS5 manifests are 1-2 MB. */
#define PATCHDL_BUF_MAX_VERXML (4 * 1024 * 1024)
#define PATCHDL_BUF_MAX_MANIFEST (16 * 1024 * 1024)
#ifdef PATCHDL_HAVE_CURL
/* Replacement for fopen("wb"/"r+b") that refuses to follow a symlink at the
+1 -1
View File
@@ -1,3 +1,3 @@
#pragma once
#define PATCHDL_VERSION "0.0.5"
#define PATCHDL_VERSION "0.0.6"
+3 -3
View File
@@ -183,10 +183,10 @@ patchdl_verxml_query(const char *url, uint32_t fw_bin, patchdl_verinfo_t *out) {
if (!url || !out) return -1;
memset(out, 0, sizeof(*out));
/* version.xml is a few KB in practice; cap to 16 MiB so a misbehaving CDN
can't slurp unbounded RAM into the buffer. */
/* version.xml is a few KB in practice; cap so a misbehaving CDN can't
slurp unbounded RAM into the buffer. */
memset(&buf, 0, sizeof(buf));
buf.max = 16 * 1024 * 1024;
buf.max = 4 * 1024 * 1024;
if (patchdl_http_get(url, &buf)) return -1;
if (!buf.data || !buf.size) { free(buf.data); return -1; }
+29 -5
View File
@@ -283,7 +283,14 @@ queue_buffer(struct MHD_Connection *conn, unsigned int status,
enum MHD_Result ret;
resp = MHD_create_response_from_buffer(size, (void *)data, mm);
if (!resp) return MHD_NO;
if (!resp) {
/* MUST_FREE hands ownership to MHD on success; on failure we still
own it and have to free it ourselves or the caller's strdup'd
JSON leaks. PERSISTENT/MUST_COPY buffers are caller-owned and
we leave them alone. */
if (mm == MHD_RESPMEM_MUST_FREE) free((void *)data);
return MHD_NO;
}
MHD_add_response_header(resp, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, "*");
MHD_add_response_header(resp, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store");
@@ -1129,7 +1136,11 @@ write_job_state(const dl_job_t *job) {
ps[] states and done_bytes. Called with the pool lock held. */
static void
seed_from_sidecar(dl_job_t *job) {
char path[320], buf[16384], murl[768];
/* 8 KB sidecar buffer: 4096-piece cap × 2 hex chars / 8 bits = 1024 hex
chars for the bitmap, plus the JSON wrapper and the up-to-768-byte
manifest_url — fits with room. Halved from 16 KB to lighten the
per-admit stack frame on the pool worker. */
char path[320], buf[8192], murl[768];
FILE *f;
size_t n;
int nbytes = (job->mf.count + 7) / 8;
@@ -2341,11 +2352,16 @@ patchdl_websrv_start(unsigned short port) {
MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_THREAD_PER_CONNECTION,
port, NULL, NULL, &on_request, NULL,
MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
/* DoS guards: bound concurrent sockets + per-IP to keep a misbehaving
LAN client from exhausting pthreads on the PS5. */
MHD_OPTION_CONNECTION_LIMIT, (unsigned int)64,
/* DoS guards + RAM caps: the PS5 process budget is a few hundred MB
and the default pthread stack on this libc is multiple MB. We're
a single-user UI — 8 concurrent connections is plenty for the
SSE poll + a couple of AJAX, and 256 KB per worker is more than
enough for our handlers (largest stack use is a 16 KB sidecar
buffer in seed_from_sidecar). */
MHD_OPTION_CONNECTION_LIMIT, (unsigned int)8,
MHD_OPTION_PER_IP_CONNECTION_LIMIT, (unsigned int)8,
MHD_OPTION_CONNECTION_TIMEOUT, (unsigned int)30,
MHD_OPTION_THREAD_STACK_SIZE, (size_t)(512 * 1024),
MHD_OPTION_END);
if (!web_daemon) {
@@ -2398,11 +2414,19 @@ patchdl_websrv_stop(void) {
for (int s = 0; s < g_pool.n_workers; s++)
pthread_join(g_pool.workers[s], NULL);
g_pool.n_workers = 0;
/* Free every job still in the pool list. Workers have joined so nobody
else touches the list. free_job_locked unlinks + frees mf.pieces[i].url,
mf.pieces, ps, bitmap, and closes the fd. */
pthread_mutex_lock(&g_pool.mtx);
while (g_pool.jobs) free_job_locked(g_pool.jobs);
pthread_mutex_unlock(&g_pool.mtx);
patchdl_net_global_cleanup();
pthread_mutex_lock(&g_mutex);
patchdl_scan_free(g_titles, g_title_count);
g_titles = NULL;
g_title_count = 0;
free(g_debug_json);
g_debug_json = NULL;
pthread_mutex_unlock(&g_mutex);
}