From e4a125e1c6d480d54b99450f3d04d66855110baf Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Tue, 11 Aug 2026 21:04:27 +0200 Subject: [PATCH] fix: ein Upload durfte den ganzen Dienst umbringen -- und tat es BEIM UEBERNEHMEN VON 3670 DATEIEN STARB DER DIENST. Nicht einmal, sondern in Schleife: acht Neustarts, nach aussen 502 vom Proxy. TypeError [ERR_INVALID_STATE]: ReadableStream is already closed at ReadableByteStreamController.close (node:internal/webstreams/...) DIE URSACHE WAR MEINE EIGENE AENDERUNG von vor einer Stunde. Um PowerShells Formular-Rumpf zu vertragen, hatte ich `c.req.parseBody()` durch `c.req.raw.formData()` ersetzt -- und damit Hono den Rumpf weggenommen, den es selbst verwaltet. Beim Aufraeumen der Antwort schliesst dann jemand einen Strom, der schon zu ist. Die Ausnahme faellt in einem Microtask an, also AUSSERHALB jedes try/catch, und Node beendet den Prozess. Beim Test mit 57 Dateien fiel das nicht auf. Bei 3670 schon. Jetzt geht der Normalfall wieder ueber Hono; der eigene Weg gilt nur noch fuer die Antwort mit Anfuehrungszeichen an der Grenzmarkierung, fuer die er gedacht war. UND EIN NETZ DARUNTER: process.on('uncaughtException') faengt, was ausserhalb jedes try/catch anfaellt, und laesst den Dienst weiterlaufen. Die Abwaegung steht im Code: das ist NICHT in jedem Fall richtig -- der Prozess kann danach kaputt sein. Hier ueberwiegt das Weiterlaufen, weil dieser Dienst keinen Zustand im Speicher haelt (alles in SQLite und auf der Platte) und ein Dienst, der wegen EINER Anfrage fuer alle weg ist, der schlechtere Tausch waere. Laut wird es trotzdem. DER SCHADEN WAR REPARIERBAR, und zwar mit dem Werkzeug von heute Nachmittag: 206 Dateien waren durch, 2 lagen auf der Platte OHNE Datensatz (geschrieben, dann starb der Prozess vor dem Eintrag), dazu eine verwaiste Vorschau. Der Verwaisten-Finder hat sie gefunden, "aufnehmen" hat sie eingetragen -- keine Datei verloren. Und die Fehlermeldung im Uebernahmeskript verschwieg die Ursache: sie meldete "Conversion from JSON failed ... Unexpected character <", obwohl die Wahrheit ein 502 war. Jetzt wird der HTTP-Code mitgelesen, bei 502 sauber abgebrochen und gesagt, dass ein neuer Lauf einfach weitermacht. Co-Authored-By: Claude Opus 5 --- server/src/index.ts | 23 ++++++++++++++++++++++ server/src/routes/dash.ts | 36 ++++++++++++++++++++++++----------- tools/uebernehmen.ps1 | 40 ++++++++++++++++++++++++++++++--------- 3 files changed, 79 insertions(+), 20 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 010921f..ded5c62 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -60,6 +60,29 @@ app.onError((err, c) => { return c.text(`Serverfehler: ${text}`, 500) }) +// Ein Fehler AUSSERHALB jedes try/catch darf den Dienst nicht umbringen. +// +// Genau das ist beim Uebernehmen von 3670 Dateien passiert: undici warf +// "ReadableStream is already closed" aus einem Microtask -- also an einer +// Stelle, an der kein try/catch der Welt steht. Node beendet den Prozess +// daraufhin, Docker startet ihn neu, die naechste Anfrage kippt ihn wieder: +// eine Neustartschleife, und nach aussen 502. +// +// DIE ABWAEGUNG, ausgesprochen: einen unerwarteten Fehler zu ueberleben ist +// nicht in jedem Fall richtig -- der Prozess KANN danach in einem kaputten +// Zustand sein. Hier ueberwiegt trotzdem das Weiterlaufen: dieser Dienst haelt +// keinen Zustand im Speicher, der verderben koennte (alles steht in SQLite und +// auf der Platte), und ein Dienst, der wegen EINER Anfrage fuer alle weg ist, +// ist der schlechtere Tausch. +// +// Laut wird es trotzdem: die Spur steht vollstaendig in der Konsole. +process.on('uncaughtException', (err) => { + console.error('[unerwartet] uncaughtException — der Dienst laeuft weiter:', err) +}) +process.on('unhandledRejection', (grund) => { + console.error('[unerwartet] unhandledRejection — der Dienst laeuft weiter:', grund) +}) + // -------------------------------------------------------- Dateien ausliefern /** Liefert eine Datei aus, mit ETag und Bereichsanfragen. diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index a82b939..b81d83e 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -591,19 +591,41 @@ dashRoutes.post('/upload', async (c) => { // eigenen Multipart-Parser, und der waere fuer diesen Dienst // unverhaeltnismaessig. Werkzeuge auf dieser Maschine nehmen deshalb // `curl.exe -F` (siehe tools/uebernehmen.ps1); curl setzt beides korrekt. - let form: FormData + let dateien: File[] + let ordnerRoh = '' + let ueberschreiben = true + try { const typ = c.req.header('content-type') ?? '' if (/boundary="/.test(typ)) { const gerade = typ.replace(/boundary="([^"]+)"/, 'boundary=$1') const rumpf = await c.req.arrayBuffer() - form = await new Request('http://formular.invalid/', { + const form = await new Request('http://formular.invalid/', { method: 'POST', headers: { 'content-type': gerade }, body: rumpf, }).formData() + dateien = form.getAll('file').filter((f) => typeof f !== 'string') as unknown as File[] + ordnerRoh = ((form.get('folder') as string | null) ?? '').trim() + ueberschreiben = form.get('overwrite') !== 'false' } else { - form = await c.req.raw.formData() + // DER NORMALFALL GEHT UEBER HONO, und das ist kein Geschmack: + // c.req.raw.formData() direkt aufzurufen nimmt Hono den Rumpf weg, + // den es selbst verwaltet. Beim Aufraeumen der Antwort schliesst + // dann jemand einen Strom, der schon zu ist -- "ReadableStream is + // already closed", eine Ausnahme AUSSERHALB jedes try/catch, und + // der Prozess ist weg. + // + // Genau das ist beim Uebernehmen von 3670 Dateien passiert: der + // Dienst lief in eine Neustartschleife, und NPM meldete 502. Beim + // Test mit 57 Dateien war es nicht aufgefallen. + const roh = await c.req.parseBody({ all: true }) + const feld = roh['file'] + dateien = (Array.isArray(feld) ? feld : [feld]).filter( + (f) => f && typeof f !== 'string', + ) as unknown as File[] + ordnerRoh = typeof roh['folder'] === 'string' ? roh['folder'].trim() : '' + ueberschreiben = roh['overwrite'] !== 'false' } } catch (err) { // Ein kaputter Rumpf ergab bisher einen 500, aufgefangen nur von der @@ -617,14 +639,7 @@ dashRoutes.post('/upload', async (c) => { ) } - // getAll und nicht get: sonst kaeme von fuenf Dateien nur die letzte an. - // Ohne DOM-Typen kennt TypeScript hier nur FormDataEntryValue; die - // Einengung auf File muss deshalb ausgesprochen werden. Der Test bleibt - // derselbe -- was kein Text ist, ist eine Datei. - const dateien = form.getAll('file').filter((f) => typeof f !== 'string') as unknown as File[] if (dateien.length === 0) return c.json({ error: 'Keine Datei dabei' }, 400) - - const ordnerRoh = (form.get('folder') as string | null)?.trim() ?? '' let ordner = '' if (ordnerRoh) { try { @@ -637,7 +652,6 @@ dashRoutes.post('/upload', async (c) => { } } - const ueberschreiben = form.get('overwrite') !== 'false' const ergebnisse: { name: string ok: boolean diff --git a/tools/uebernehmen.ps1 b/tools/uebernehmen.ps1 index c08ea0f..1ed17e4 100644 --- a/tools/uebernehmen.ps1 +++ b/tools/uebernehmen.ps1 @@ -181,16 +181,38 @@ foreach ($g in $gruppen) { ) foreach ($d in $dateien) { $argumente += @('-F', "file=@$($d.FullName)") } - try { - $res = (& curl.exe @argumente) | ConvertFrom-Json - foreach ($r in $res.ergebnisse) { - if ($r.ok) { $hoch++; Gut "$($r.path)" } - elseif ($r.error -like '*belegt*') { $schon++ } - else { $fehler++; Schlecht "$($r.name): $($r.error)" } - } - } catch { + # Den HTTP-Code MIT abfragen. Vorher stand hier nur ConvertFrom-Json, + # und als der Dienst wegbrach, meldete das Skript + # "Conversion from JSON failed ... Unexpected character <" -- also + # einen Parser-Fehler, obwohl die Wahrheit ein 502 vom Proxy war. Eine + # Fehlermeldung, die die Ursache verschweigt, kostet genau die Zeit, + # die sie sparen soll. + $argumente += @('-w', "`n%{http_code}") + $antwort = (& curl.exe @argumente) -join "`n" + $code = ($antwort -split "`n")[-1] + $rumpf = ($antwort -split "`n")[0..(($antwort -split "`n").Count - 2)] -join "`n" + + if ($code -ne '200') { $fehler += $dateien.Count - Schlecht "Stapel fehlgeschlagen: $($_.Exception.Message)" + Schlecht "Stapel fehlgeschlagen: HTTP $code — $($rumpf.Substring(0, [Math]::Min(120, $rumpf.Length)))" + if ($code -eq '502') { + Schlecht 'Der Dienst antwortet nicht. Laeuft der Container? (docker ps)' + Schlecht 'Der Lauf laesst sich danach einfach wiederholen — was da ist, wird uebersprungen.' + break + } + } else { + try { + $res = $rumpf | ConvertFrom-Json + foreach ($r in $res.ergebnisse) { + if ($r.ok) { $hoch++ } + elseif ($r.error -like '*belegt*') { $schon++ } + else { $fehler++; Schlecht "$($r.name): $($r.error)" } + } + Gut "$hoch uebernommen, $schon schon da, $fehler Fehler" + } catch { + $fehler += $dateien.Count + Schlecht "Antwort unlesbar: $($rumpf.Substring(0, [Math]::Min(120, $rumpf.Length)))" + } } $dateien | Remove-Item -Force -ErrorAction SilentlyContinue