Desktop Client Upload Reliability#
MinerU Desktop's cloud-parsing upload flow contains two compounding defects that can leave tasks permanently stuck in uploading with no visible error. The primary trigger is a Content-Type mismatch on the OSS presigned PUT; the secondary amplifier is a polling reconciler that actively erases the resulting failure state.
Upload Flow Overview#
When a user submits a file for cloud parsing, the Desktop client:
- Calls the MinerU API to create a task and receive a presigned OSS PUT URL (along with the
Content-Typethe server signed). - Issues an HTTP PUT directly to OSS with the file bytes and the
Content-Typeheader. - Begins a 5-second polling loop that reads task status from the server and writes it to the local
taskDatatable.
OSS V1 presigned URLs include Content-Type in their HMAC signature string (PUT\n\n<Content-Type>\n<Expires>\n<CanonicalizedResource>), so the value sent in the PUT must exactly match what the server signed — any deviation produces 403 SignatureDoesNotMatch.
Root Cause 1 — Missing Content-Type Fallback on Main Upload Path#
The main upload path in app.asar reads the Content-Type from the presigned URL response using optional chaining:
type: c?.headers?.[p]?.["Content-Type"]
When that header is absent, this evaluates to undefined. The PUT is then constructed as:
headers: { "Content-Type": r } // r = undefined
Depending on which HTTP client Electron uses for this code path:
fetch/undici: serialisesundefinedto the literal string"undefined"on the wire — OSS rejects it with403 SignatureDoesNotMatch.node http.request: throwsTypeError: Invalid value "undefined" for header "Content-Type"synchronously — the PUT is never sent at all.
A sibling upload path in the same app includes a safe fallback (|| "application/pdf"), but the main path does not.
The || "application/pdf" fallback is not a correct fix in general: the Content-Type must echo what the server actually signed. For a task of type = img, the server signs image/jpeg or image/png; sending application/pdf would still 403. The only safe behavior is to treat a missing Content-Type from the issue-URL response as a hard task failure, persist err_code, and stop.
All stuck tasks observed in the wild also have an empty type field in the local taskData table, while the 209 historical successful tasks carry type = pdf or type = img. Whatever regression stopped populating type (beginning around 2026-09-29 per the reporter) is the upstream trigger for the 403s.
Root Cause 2 — Polling Reconciler Overwrites Local Failure State#
After the PUT fails, the client's error handler only calls console.error(...). The failure is:
- Not persisted to
main.log - Not written to
err_msgorerr_codeintaskData - Possibly visible momentarily as a local
failedstate
Within 5 seconds, the polling loop syncs from the server, which still reports uploading (since the file never arrived). The local state is overwritten back to uploading, and the cycle repeats indefinitely. The reporter observed more than 400 polling attempts without any terminal state.
The effective state machine is:
uploading
→ OSS PUT → 403 SignatureDoesNotMatch
→ local state = failed
→ 5-second poll fires
→ server state = uploading
→ local state = uploading ← loops forever
The polling loop has no maximum retry count and no overall timeout. A correct reconciler should never downgrade a locally terminal state, and should surface the last known error rather than resetting it.
Related Issue — OSS Signing Behavior Inversion#
Issue #5393 describes the inverse signature behavior: some presigned URLs are signed for an empty Content-Type, causing clients that auto-set application/octet-stream or similar to receive 403. This suggests the server-side presigning is inconsistent across task types or environments — reinforcing that the client must echo the signed value verbatim rather than applying any fixed fallback.
Debugging Checklist#
| Signal | Meaning |
|---|---|
taskData.type is empty on stuck tasks | Upstream type-population regression is the trigger |
Content-Type: undefined in network capture | Main path reached, JS undefined sent on wire |
| No PUT request in network capture at all | Synchronous throw before request was sent |
err_msg / err_code empty, can_retry = 0 | Error handler swallowed exception; task is zombied |
retry_time = 0 despite repeated polls | Polling is reading server state, not counting upload attempts |
Manual PUT to the same presigned URL with Content-Type: application/pdf will return 200 OK, but will not advance the server-side task — task completion appears to rely on an OSS upload callback (x-oss-callback) that a plain PUT does not trigger.
Fix Direction#
- Content-Type: Echo the value returned in the presigned URL response, verbatim. If absent, fail the task hard, persist
err_code, and do not retry. - Error persistence: Write upload errors to
main.logand toerr_msg/err_codeintaskData, not only toconsole.error. - Polling guard: The reconciler must not overwrite a locally terminal state (
failed) with a server-reported transitional state (uploading). Add an attempt cap with an error surfacing path. - Upstream: Investigate why
typestopped being populated around 2026-09-29 — that is what stopped the server from signing with a correct Content-Type in the first place.