Ein Download riss ab, galt als fertig — und setzte nie fort

Dein Bericht: bei rund 3 GB abgebrochen, beim Fortsetzen wieder bei null,
und später „plötzlich bei 60 Prozent fertig". Beide Hälften standen so im
Code.

**Fertig war sie nicht, sie war halb.**

    if expected > 0, Double(written) < Double(expected) * 0.5 { verwerfen }

Alles über der Hälfte galt als vollständig. Diese Regel war nie für diesen
Fall gedacht, sondern für die **hochgerechnete** Größe der Transcoder-Stufen
(Bitrate × Dauer) — dort ist Abweichung normal. Beim Original nennt der
Server die Dateigröße in den Metadaten, und da ist jedes fehlende Byte ein
Abriss.

Das unterscheidet jetzt `expectedIsExact`, gesetzt beim Planen und
mitgeführt am Eintrag. Dazu merkt sich der Koordinator, für welche Titel der
Server eine `Content-Length` geschickt hat — auch das ist eine Zusage.

**„Fortsetzen" war eine Zusage, die der Code nicht halten konnte.**
`NSURLSessionDownloadTaskResumeData` kam im ganzen Projekt nicht vor; den
Wiederaufnahme-Datensatz gab es nur beim Pausieren von Hand. Nach einem
Netzfehler war er weg, und der Knopf daneben hieß trotzdem „Fortsetzen".
Jetzt wird er aus dem Fehler gerettet, und wo es ihn nicht gibt, heißt der
Knopf „Erneut versuchen".

**Und es merkte niemand, wenn das WLAN zurückkam.** `RootView` reicht das
jetzt weiter — nur für Einträge mit Wiederaufnahme-Datensatz, damit ein 404
sich nicht im Kreis versucht.

**Rate und Restzeit** stehen in der Zeile: „12 MB / 20 MB · 3,4 MB/s ·
gleich fertig". `DownloadRate` ist ein reiner Wertetyp mit Uhr von außen —
geglättet, weil rohe Differenzen um ein Vielfaches springen, und ohne Zahl,
solange nichts gemessen ist. Neun Tests.

**Zwei Dinge, die erst die Messung gezeigt hat** — und die beide nicht in
der App liegen:

Erstens: reißt eine Übertragung ab, **die eine `Content-Length` angekündigt
hatte**, wiederholt die Hintergrundsitzung von iOS sie selbst — stumm, ohne
den Delegaten zu fragen, und **jedes Mal ab Byte null**. Gemessen: sieben
Anläufe, jeder die vollen 12 MB. Das ist genau dein „fing wieder bei null
an", und dagegen hilft kein Wiederaufnahme-Datensatz.

Zweitens: ein Fortsetzen braucht eine Prüfmarke vom Server. Ohne `ETag`
oder `Last-Modified` kann iOS sein `If-Range` nicht stellen und lädt alles
noch einmal — gemessen, 20 MB zweimal. Mit Marke kam die zweite Anfrage als
`Range: bytes=5636096-` und holte nur den Rest.

Die Attrappe kann das jetzt alles nachstellen: eine beliebig große Datei,
die auf Wunsch mittendrin abreißt, mit oder ohne angekündigte Länge, und
gedrosselt. Ohne sie ist nichts davon beweisbar — und genau daran ist der
Fehler vorbeigekommen.

Im Simulator gegen die Attrappe: 12 von 20 MB → „Fehlgeschlagen:
Übertragung unvollständig", nicht Haken. Vollständig → Haken. iOS und tvOS
bauen, 489 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 21:05:34 +02:00
co-authored by Claude Opus 5
parent c37087fd34
commit e86c0a6e95
11 changed files with 5549 additions and 4903 deletions
@@ -55,6 +55,22 @@ private func temporaryRoot() -> URL {
@Suite("Download-Warteschlange")
struct DownloadCoordinatorTests {
@Test("Ob die Größe genau ist, kommt am Eintrag an")
func carriesExactSizeFlag() throws {
let coordinator = try makeCoordinator(root: temporaryRoot())
var genau = plan("a", bytes: 20_000_000)
genau.expectedIsExact = true
let eins = try coordinator.enqueue(genau)
#expect(eins.expectedIsExact)
// Die Voreinstellung ist „geschätzt" — die umgerechneten Stufen
// dürfen abweichen, und wer das Feld vergisst, bekommt die mildere
// Regel statt verworfener Downloads.
let zwei = try coordinator.enqueue(plan("b", bytes: 20_000_000))
#expect(!zwei.expectedIsExact)
}
@Test("Ein Titel wird angenommen und steht in der Warteschlange")
func acceptsFirstDownload() throws {
let coordinator = try makeCoordinator(root: temporaryRoot())
@@ -0,0 +1,118 @@
import Testing
@testable import FlixyDownloads
@Suite("Downloadrate und Restzeit")
struct DownloadRateTests {
/// Ein fester Anfangszeitpunkt, von dem aus gerechnet wird.
///
/// Die Uhr kommt von außen herein — deshalb braucht hier nichts zu warten,
/// und eine Übertragung über eine Viertelstunde ist in Mikrosekunden
/// nachgestellt.
private let start = ContinuousClock.now
@Test("Vor dem ersten Abstand gibt es keine Zahl")
func noRateFromASinglePoint() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
// Null wäre eine Auskunft („es geht nichts durch"), und die stimmt
// nicht — es ist nur noch nichts gemessen.
#expect(rate.bytesPerSecond == nil)
}
@Test("Gleichmäßiger Zufluss ergibt eine ruhige Zahl")
func steadyFlowIsSteady() {
var rate = DownloadRate()
for sekunde in 0...5 {
rate.record(bytes: Int64(sekunde) * 1_000_000, at: start.advanced(by: .seconds(sekunde)))
}
// Auf das Byte genau: die Glättung zieht einen Messwert nur dann in
// eine Richtung, wenn er vom bisherigen abweicht.
#expect(rate.bytesPerSecond == 1_000_000)
}
@Test("Ein Ausreißer verschiebt die Zahl, aber reißt sie nicht mit")
func outlierIsDamped() {
var rate = DownloadRate()
for sekunde in 0...5 {
rate.record(bytes: Int64(sekunde) * 1_000_000, at: start.advanced(by: .seconds(sekunde)))
}
// Eine einzelne Sekunde mit dem Zehnfachen.
rate.record(bytes: 15_000_000, at: start.advanced(by: .seconds(6)))
let gemessen = try! #require(rate.bytesPerSecond)
// Roh wären es zehn Millionen. Geglättet liegt die Zahl dazwischen —
// sie folgt, statt zu springen.
#expect(gemessen > 1_000_000)
#expect(gemessen < 10_000_000)
}
@Test("Messpunkte dichter als eine halbe Sekunde zählen nicht")
func samplesTooCloseAreIgnored() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
rate.record(bytes: 50_000, at: start.advanced(by: .milliseconds(100)))
rate.record(bytes: 100_000, at: start.advanced(by: .milliseconds(200)))
#expect(rate.bytesPerSecond == nil)
// Und der Bezugspunkt ist dabei nicht weitergerückt: gemessen wird
// über die volle Sekunde seit dem ersten Punkt, nicht über die letzten
// achthundert Millisekunden.
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
#expect(rate.bytesPerSecond == 1_000_000)
}
@Test("Eine lange Stille verfälscht die Rate nicht")
func longGapResetsTheMeasurement() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
#expect(rate.bytesPerSecond == 1_000_000)
// Eine halbe Minute ohne Rückmeldung heißt nicht „langsam", sondern
// „stand still": App angehalten, Gerät gesperrt, Netz weg. Daraus eine
// Rate zu rechnen ergäbe dreißig Kilobyte je Sekunde und eine Restzeit
// von Stunden.
rate.record(bytes: 1_030_000, at: start.advanced(by: .seconds(31)))
#expect(rate.bytesPerSecond == nil)
}
@Test("Fängt die Übertragung von vorn an, gilt keine alte Zahl")
func restartFromZeroDiscardsTheRate() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
#expect(rate.bytesPerSecond == 1_000_000)
// Genau das passiert, wenn ein gescheiterter Download ohne
// Wiederaufnahme-Datensatz neu beginnt.
rate.record(bytes: 0, at: start.advanced(by: .seconds(2)))
#expect(rate.bytesPerSecond == nil)
}
@Test("Die Restzeit folgt aus Rate und Rest")
func remainingFollowsFromRate() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
let rest = try! #require(rate.remaining(received: 1_000_000, total: 11_000_000))
#expect(rest == .seconds(10))
}
@Test("Ohne Rate keine Restzeit")
func noRemainingWithoutRate() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
// Eine Restzeit ohne Messung wäre geraten — und geraten sieht man ihr
// nicht an.
#expect(rate.remaining(received: 0, total: 1_000_000) == nil)
}
@Test("Ist alles da, gibt es nichts mehr zu schätzen")
func noRemainingWhenComplete() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
#expect(rate.remaining(received: 1_000_000, total: 1_000_000) == nil)
}
}
@@ -163,36 +163,78 @@ struct DownloadPlausibilityTests {
// „fertig". Angekommen war eine HTML-Fehlerseite, und weil die
// empfangene Größe auch als erwartete notiert wurde, stand die
// Anzeige auf hundert Prozent.
#expect(DownloadCoordinator.implausibility(written: 2_048, expected: 873_000_000) != nil)
#expect(DownloadCoordinator.implausibility(
written: 2_048, expected: 873_000_000, announced: true
) != nil)
}
@Test("Auch ohne angekündigte Größe fällt eine Fehlerseite auf")
func tinyFileWithoutExpectation() {
// Beim umgerechneten Download kennt der Server die Endgröße vorab
// nicht — die Untergrenze muss deshalb allein tragen.
#expect(DownloadCoordinator.implausibility(written: 900, expected: 0) != nil)
#expect(DownloadCoordinator.implausibility(
written: 900, expected: 0, announced: false
) != nil)
}
@Test("Eine abgebrochene Übertragung fällt auf")
func truncatedTransferIsRejected() {
#expect(DownloadCoordinator.implausibility(written: 200_000_000, expected: 873_000_000) != nil)
#expect(DownloadCoordinator.implausibility(
written: 200_000_000, expected: 873_000_000, announced: false
) != nil)
}
@Test("Bei sechzig Prozent einer angekündigten Größe ist die Datei kaputt")
func truncatedAtSixtyPercentIsRejected() {
// **Der Fehler, der diese Unterscheidung nötig gemacht hat.** Hier
// galt dieselbe Hälfte-Regel wie für geschätzte Größen, und eine bei
// sechzig Prozent abgerissene Datei kam durch: Haken dran, und beim
// Abspielen brach sie mittendrin ab. Gemeldet wurde das als
// „plötzlich war er fertig".
#expect(DownloadCoordinator.implausibility(
written: 1_800_000_000, expected: 3_000_000_000, announced: true
) != nil)
}
@Test("Dieselben sechzig Prozent einer Schätzung gehen durch")
func truncatedAtSixtyPercentOfEstimatePasses() {
// Gleiche Zahlen, andere Herkunft: hochgerechnet aus Bitrate und
// Dauer. Dort ist eine Abweichung nach unten normal, und streng zu
// prüfen hieße, gültige Downloads zu verwerfen.
#expect(DownloadCoordinator.implausibility(
written: 1_800_000_000, expected: 3_000_000_000, announced: false
) == nil)
}
@Test("Eine vollständige Datei geht durch")
func completeFilePasses() {
#expect(DownloadCoordinator.implausibility(written: 873_000_000, expected: 873_000_000) == nil)
#expect(DownloadCoordinator.implausibility(
written: 873_000_000, expected: 873_000_000, announced: true
) == nil)
}
@Test("Kleinere Abweichungen sind erlaubt")
@Test("Kleinere Abweichungen sind erlaubt, solange die Größe geschätzt ist")
func smallDeviationPasses() {
// Der Transcoder arbeitet mit variabler Bitrate und trifft die
// geschätzte Größe nie genau. Zu streng zu prüfen hieße, gültige
// Downloads zu verwerfen.
#expect(DownloadCoordinator.implausibility(written: 800_000_000, expected: 873_000_000) == nil)
// geschätzte Größe nie genau.
#expect(DownloadCoordinator.implausibility(
written: 800_000_000, expected: 873_000_000, announced: false
) == nil)
}
@Test("Dieselbe Abweichung bei angekündigter Größe ist ein Abriss")
func smallDeviationOfAnnouncedSizeIsRejected() {
// Hat der Server die Größe genannt, gibt es keine erklärbare
// Abweichung: was fehlt, fehlt.
#expect(DownloadCoordinator.implausibility(
written: 800_000_000, expected: 873_000_000, announced: true
) != nil)
}
@Test("Ohne Erwartung geht eine normale Datei durch")
func noExpectationPasses() {
#expect(DownloadCoordinator.implausibility(written: 500_000_000, expected: 0) == nil)
#expect(DownloadCoordinator.implausibility(
written: 500_000_000, expected: 0, announced: false
) == nil)
}
}