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>
103 lines
4.1 KiB
Swift
103 lines
4.1 KiB
Swift
import Foundation
|
|
|
|
/// Wie schnell ein Download gerade läuft — und wie lange er noch braucht.
|
|
///
|
|
/// **Ein Wertetyp mit Uhr von außen.** Die Rückmeldungen der Übertragung
|
|
/// kommen mehrmals je Sekunde; was daraus eine Zahl macht, ist reine Rechnung
|
|
/// und gehört nicht in den Koordinator. Die Zeitpunkte kommen als Argument
|
|
/// herein, damit sich eine Übertragung ohne echte Uhr nachstellen lässt — eine
|
|
/// Rate, die man nur im Betrieb beurteilen kann, ist keine prüfbare Zahl.
|
|
///
|
|
/// **Warum geglättet.** Zwischen zwei Rückmeldungen schwankt die rohe
|
|
/// Differenz um ein Vielfaches: ein Puffer läuft voll, der Funk hat ein Loch,
|
|
/// das System hält die Hintergrundsitzung kurz an. Ungeglättet stünde dort
|
|
/// eine Zahl, die zehnmal je Sekunde springt — und eine springende Zahl ist
|
|
/// keine Auskunft, sondern Unruhe.
|
|
public struct DownloadRate: Sendable, Equatable {
|
|
/// Wie stark ein neuer Messwert gegenüber dem bisherigen zählt.
|
|
///
|
|
/// Ein Drittel: schnell genug, dass ein echter Einbruch binnen weniger
|
|
/// Sekunden ankommt, träge genug, dass einzelne Ausreißer untergehen.
|
|
private static let weight = 1.0 / 3.0
|
|
|
|
/// Kürzere Abstände werden nicht gemessen.
|
|
///
|
|
/// Über fünfzig Millisekunden gerechnet entscheidet der Zufall, wann das
|
|
/// System den Puffer leert — nicht die Leitung.
|
|
static let minimumInterval: Duration = .milliseconds(500)
|
|
|
|
/// Ab dieser Lücke beginnt die Messung von vorn.
|
|
///
|
|
/// So lange ohne eine einzige Rückmeldung heißt nicht „langsam", sondern
|
|
/// „stand still": die App war angehalten, das Gerät gesperrt, das Netz
|
|
/// weg. Diese Pause in die Rate einzurechnen ergäbe eine Zahl nahe null
|
|
/// und eine Restzeit von Stunden.
|
|
static let maximumInterval: Duration = .seconds(10)
|
|
|
|
/// Ein Messpunkt: wie viel war wann da.
|
|
///
|
|
/// Als Typ und nicht als Tupel — ein Tupel kann `Equatable` nicht erben,
|
|
/// und ohne das wäre der ganze Wertetyp nicht vergleichbar.
|
|
private struct Anchor: Equatable {
|
|
var bytes: Int64
|
|
var at: ContinuousClock.Instant
|
|
}
|
|
|
|
/// Der letzte Messpunkt, gegen den gerechnet wird.
|
|
private var anchor: Anchor?
|
|
private var smoothed: Double?
|
|
|
|
public init() {}
|
|
|
|
/// Bytes je Sekunde — `nil`, solange nichts gemessen wurde.
|
|
///
|
|
/// **Nicht null.** Null wäre eine Auskunft („es geht nichts durch"), und
|
|
/// die stimmt in der ersten halben Sekunde nie.
|
|
public var bytesPerSecond: Double? { smoothed }
|
|
|
|
/// Nimmt den Stand der Übertragung entgegen.
|
|
public mutating func record(bytes: Int64, at now: ContinuousClock.Instant) {
|
|
guard let anchor else {
|
|
self.anchor = Anchor(bytes: bytes, at: now)
|
|
return
|
|
}
|
|
|
|
let elapsed = now - anchor.at
|
|
guard elapsed >= Self.minimumInterval else { return }
|
|
defer { self.anchor = Anchor(bytes: bytes, at: now) }
|
|
|
|
// Rückwärts heißt: eine neue Aufgabe fängt von vorn an. Dann ist jede
|
|
// bisherige Messung ungültig — und eine negative Rate wäre Unsinn.
|
|
guard bytes >= anchor.bytes, elapsed <= Self.maximumInterval else {
|
|
smoothed = nil
|
|
return
|
|
}
|
|
|
|
let seconds = elapsed.inSeconds
|
|
guard seconds > 0 else { return }
|
|
|
|
let gemessen = Double(bytes - anchor.bytes) / seconds
|
|
smoothed = smoothed.map { $0 + (gemessen - $0) * Self.weight } ?? gemessen
|
|
}
|
|
|
|
/// Wie lange es bei dieser Rate noch dauert.
|
|
///
|
|
/// `nil`, solange keine Rate feststeht oder die Gesamtgröße unbekannt ist.
|
|
/// Eine Restzeit ohne Rate wäre geraten, und geraten sieht man ihr nicht
|
|
/// an.
|
|
public func remaining(received: Int64, total: Int64) -> Duration? {
|
|
guard let smoothed, smoothed > 0, total > received else { return nil }
|
|
return .seconds(Double(total - received) / smoothed)
|
|
}
|
|
}
|
|
|
|
extension Duration {
|
|
/// Die Dauer als Sekunden.
|
|
///
|
|
/// `components` liefert ganze Sekunden und Attosekunden getrennt; für eine
|
|
/// Division braucht es beides in einer Zahl.
|
|
var inSeconds: Double {
|
|
Double(components.seconds) + Double(components.attoseconds) / 1e18
|
|
}
|
|
}
|