The Bug I Was Certain I Had, and the Six I Actually Had

ComicFlow 3.4 was finished when a paying reader said a comic would not open. I was sure the cause was RAR5. It was six mundane things wearing the same blank page, because one catch block had been swallowing every error since the reader's first commit.

ComicFlow 3.4 is live. It was supposed to be live about a week ago.

By 5 September the release was done. Watch folders and Wi-Fi upload were built, the What’s New sheet was written, and the release notes were translated into every language the app supports. Build 1 was uploaded on 6 September and build 2 the day after. All that remained was to press the button.

On 12 September a one-star review arrived against 3.3, the version that was already live. It came from someone who had paid for the app. A comic would not open. That was the whole complaint, and it was accurate, and nothing in the app or in my analytics could say what had happened to them.

So I held the release. Build 3 went in on 13 September with an error-reporting layer the reader should have had from its first commit, and 3.4 shipped as a bigger update than the one I had planned. This post is about that build. The two features have their own write-up, for users, and this is not it.

What a blank book looks like from the inside

The symptom was a comic that opened to nothing. The reader appeared, drew its background, and showed no pages. No error, no message, no spinner that never stopped. Just an empty book you could close again.

Every layer of the app agreed this was fine. The library kept a row for the comic with a page count of zero. The reader view had a branch for “pages exist” and a branch for “an error was set” and, when neither was true, drew nothing. Analytics logged a successful reader_opened event, because that event fired the moment the view appeared, before any page had been asked for.

Underneath all of it were these lines in the CBR page provider:

do {
    archive = try Archive(path: url.path)
} catch {
    return
}
guard let allEntries = try? archive.entries() else { return }

Both paths return silently and leave the entry list empty. The rest of the app reads an empty entry list as zero pages, and zero pages is a valid state, so nothing downstream had any reason to complain.

Those lines are from the reader’s first commit on 22 January. They were unchanged until build 3. For as long as ComicFlow has had a reader, every distinct thing that could go wrong opening a RAR has produced the same empty book.

The hypothesis I was sure of

When I finally went through the telemetry with the right question, one thing stood out. Files with a bare .rar extension opened blank far more often than anything else in the library. Not a little more often. It was the worst format by a wide margin.

The explanation I reached for was RAR5. It is the current RAR format and most comic archives made in recent years use it. The story I told myself was that the bundled unrar was too old for it, and that an old unrar handed a RAR5 file would list zero entries rather than fail. I did not check either half of that. It was a specific, plausible, mechanistic story, and it fit the symptom exactly: the archive opens, the list is empty, the book is blank.

Then I did the thing I should have done first and built a real RAR5 archive of coloured test pages, and opened it.

Every page was there. I built a solid RAR5. Every page. RAR4, plain and solid. Every page. Unrar.swift 0.5.1 bundles unrar 7.13, which has read RAR5 for as long as RAR5 has existed.

The hypothesis survived exactly as long as it took to build a fixture and open it. It had felt like knowledge for considerably longer than that.

What was actually in the blank books

Once the RAR5 theory was gone I built every awkward kind of archive I could think of and pushed each one through the reader. Six of them came out blank.

Password-protected archives. The archive lists every entry, each page is flagged as encrypted, and extraction needs a password the app never asks for. The old code dropped encrypted entries from the page list as unsafe to hand to the unpacker, which was correct, and then reported the result as zero pages, which was not.

Archives with encrypted headers. A different failure with the same face. When the headers themselves are encrypted the archive will not even list its contents. The entries() call throws, the try? turns that into nil, and the guard returns.

Multi-volume sets. One part of several. The archive header says it is a volume, and it says whether it is the first one, and the old code never read either flag. It opened the file, found it could not go anywhere, and returned.

Nested archives. A .cbr that contains .cbr files, usually a whole series packed into one download. Every entry is real and none of them is an image, so the page filter removed all of them. Zero pages, technically true.

A web page saved as a comic. A download that returned an HTML error page, which the browser saved under the comic’s filename with the comic’s extension. The header sniffing I added in 3.3 correctly said this is not any archive it knows, fell back to the extension, which said RAR, and handed it to unrar, which refused. The refusal landed in the catch and disappeared.

Legacy code-page ZIP names. This is the one I like least, because the archive was perfectly good. A ZIP written by an older Windows tool stores its entry names in the machine’s local code page and does not set the flag that says “these are UTF-8”. My ZIP reader decoded every name as UTF-8:

let fileName = String(data: fileNameData, encoding: .utf8) ?? ""

A name like página_001.jpg is not valid UTF-8 in that encoding, so the decode returned nil, and the fallback made the name an empty string. An empty string has no extension. No extension means not an image. Every page in the archive was silently dropped, the archive reported zero pages, and anyone whose collection had been zipped that way got a shelf of blank books.

Not one of the six was RAR5. Two of them were not RAR at all.

One bit where there should have been a code

The thing I keep coming back to is not that the errors were lost. It is what losing them did to my reasoning.

A swallowed error does not just delete information. It merges unrelated failures into a single symptom. Six causes, six different fixes, six different things to tell the user, all collapsed into “zero pages”. And a single undifferentiated symptom is exactly the input that lets you build a confident theory, because there is nothing left in the data to contradict it. I was not being careless about RAR5. I was doing what anyone does with one bit of information, which is fill in the rest.

The system was giving me one bit. It should have been giving me a code.

Giving the failure a name

The fix in build 3 is structural rather than a stack of six patches. There is now one failure type, thrown by every path that can fail to open an archive:

nonisolated enum ArchiveOpenFailureReason: String, Sendable, CaseIterable {
    case unreadable = "unreadable"
    case notAnArchive = "not_an_archive"
    case webPage = "web_page"
    case corrupted = "corrupted"
    case passwordProtected = "password_protected"
    case multiVolume = "multi_volume"
    case nestedArchives = "nested_archives"
    case documentsOnly = "documents_only"
    case noImages = "no_images"
    case pagesTooLarge = "pages_too_large"
    case unsupported7z = "unsupported_7z"
    case unsupportedCompression = "unsupported_compression"
    // …
}

ArchiveOpenFailure carries one of those, the counts that make it concrete, an analytics-only detail code, and the localised sentence the user sees. It never carries a filename. The reader’s page providers throw it, the converter’s extractors wrap it, and the importer wraps it, so the same file produces the same reason in all three places.

The decision of pages-or-a-reason lives in one classifier that both the reader and the converter call. It walks the entry list once and, if no usable page came out, works down a list of explanations in order of how specific they are:

if !usable.isEmpty { return .success(usable) }

if imageCount > 0 {
    if encryptedImages > 0 { return .failure(ArchiveOpenFailure(.passwordProtected, )) }
    if unsupportedImages > 0 { return .failure(ArchiveOpenFailure(.unsupportedCompression, )) }
    return .failure(ArchiveOpenFailure(.pagesTooLarge, ))
}
if nestedArchives > 0 { return .failure(ArchiveOpenFailure(.nestedArchives, relevantCount: nestedArchives)) }
if documents > 0 { return .failure(ArchiveOpenFailure(.documentsOnly, relevantCount: documents)) }
return .failure(ArchiveOpenFailure(.noImages, entryCount: fileCount))

The order matters. An archive full of encrypted images is password-protected, not “no images”, even though from the page filter’s point of view both have zero usable pages. The user needs to hear the specific one.

Around that classifier sit two shared archive readers, one for RAR and one for ZIP, that open, classify and order pages for both the reader and the converter. Before 3.4 those two halves of the app each had their own idea of what was in a file. Now they cannot disagree, on which entries are pages, on what order they go in, or on why there are none. Page order is by full path now too, so a comic packed as chapter folders reads chapter one, then chapter two, instead of interleaving them by bare filename.

The ZIP reader tries UTF-8 first, then the ZIP specification’s default code page, then a lossy decode that cannot fail. The accented letter in página may come out wrong, because the default code page is not the one a Spanish Windows machine used, but the extension is ASCII and survives any eight-bit decoding, and the extension is all the page filter needs. A name that is slightly wrong is a comic that opens. A name that is empty is a blank book.

And the one rule that makes the rest hold: a provider handed to the reader has at least one page, or it was never handed over. The reader shows an error screen with the reason and a suggestion, the importer refuses the file and says why, and analytics records reader_open_failed with the reason code. The successful-open event now fires when the first page is on screen, not when the view appears.

The archive that took forever

The same investigation produced a small probe that reads the RAR main header straight from the file’s first bytes. It reports the format version, so a failure can be split by RAR4 and RAR5 in analytics instead of guessed at, and it reports whether an archive is solid. Solid archives turned out to have a problem of their own.

In a normal archive every file is compressed on its own. In a solid archive the compression runs continuously across file boundaries, so the data for any page depends on the state left behind by every page before it. Solid archives are smaller, which is why comic packers like them. Reaching any single entry means decompressing everything before it.

Unrar.swift’s extract reopens the archive and skips forward for every entry it is asked for. On a solid archive, “skipping” to a page costs a decode of every page before it. Extracting the whole book costs the sum of that, which is quadratic. I measured it on a real, full-length solid CBR and on a non-solid twin with identical pages.

On the solid archive the per-entry path took long enough to sit and watch, and the one pass was done before I could. The gap grew with the square of the page count, which is the shape you expect. On the non-solid twin the two paths were indistinguishable, and that second result is the one I care about: the one-pass version costs essentially nothing on archives that were never the problem.

The one pass drops below Unrar.swift to the bundled unrar C API. Open the archive once in extract mode, walk the headers in archive order, test the entries we want into a callback that collects the bytes, skip the ones we do not:

let handle = RAROpenArchiveEx(&flags)           // OpenMode = RAR_OM_EXTRACT, once
RARSetCallback(handle, callback, sinkPointer)  // bytes arrive here; unrar writes nothing itself

while RARReadHeaderEx(handle, &header) == ERAR_SUCCESS {
    let name = String(cString: &header.FileName.0)
    guard let index = indexByName[name] else {
        RARProcessFile(handle, RAR_SKIP, nil, nil)    // not a page we want
        continue
    }
    sink.reset()
    RARProcessFile(handle, RAR_TEST, nil, nil)        // decode into the sink
    try sink.data.write(to: directory.appendingPathComponent(String(format: "%05d_%@", index, name)))
}

The interesting decision is not the loop. It is where the loop runs.

The converter uses it for every RAR, because a conversion extracts every page anyway and there is nothing to lose. The reader is more careful. It uses the one pass only for archives the header probe says are solid, and only once someone asks for a page past the first few. The opening pages still come out the direct way, one at a time.

The reason is the import. When a comic is added to the library the app needs its cover and nothing else. Page one of a solid archive costs one decode on the direct path, which is cheap. Running a full pass over a long book to produce a thumbnail would make the common case slower in order to fix a rare one. So the reader pays the setup cost only when a person is demonstrably reading deep into a solid book, spills the pages to a temporary directory once, serves everything after that from disk, and deletes the directory when the book is closed.

Not selling to someone you just failed

The review that held the release open came from someone who had paid. Whatever else went wrong for them, the app had asked for money from a person it was, at that moment, failing. Pro has nothing to do with whether a file opens, but from the outside that sequence is indistinguishable from being cheated, and the review said so.

So build 3 also has a rule about when not to sell. For a day after the app fails a user, whether that is a conversion error, an import it could not add, or a comic that would not open, the promotional Pro surfaces go quiet. No What’s New upsell, no post-conversion card, no onboarding link.

static func presentation(for surface: PaywallSurface, lastTroubleDate: Date?) -> PaywallPresentation {
    guard hasRecentTrouble(lastTroubleDate: lastTroubleDate) else { return .show }
    switch surface {
    case .promotional:   return .suppress
    case .userInitiated: return .showWithTroubleNotice
    }
}

The second case is the tension, and I want to be honest that it is one. A paywall the user opens themselves, by tapping a Pro feature or the Pro row in Settings, is not suppressed. Hiding the purchase from someone who is actively trying to buy is its own failure. Instead it shows, with a short note on top that says Pro adds features and does not change which files open, and a link to help. Pro stays discoverable. It just stops being offered as a fix.

There is a counter on the suppressed impressions, so I will know what this costs. A rule like this should be accountable, not just virtuous.

The corpus

None of this would have been found by the unit tests I had, which fed synthetic headers to the sniffer and passed throughout. So the fixtures are real archives now, generated by a script that runs the real tools: RAR4 and RAR5, plain and solid, a long solid one, encrypted files, encrypted headers, a volume set, a recovery record, comics nested in a .rar, a PDF inside a .rar, chapter folders, an HTML page saved as .cbr, a 7z, a ZIP with legacy code-page names, an encrypted ZIP, a ZIP64 ZIP, and a bzip2 ZIP.

Every fixture is pushed through both the reader and the converter, and the tests assert not just that each one opens or fails with the right reason, but that the two halves of the app agree. The rule going forward is simple: when a new flavour of archive fails in the wild, it becomes a fixture before it becomes a fix.

What I would tell myself in January

A swallowed error does not hide one failure. It merges all of them. Six causes became one symptom, and one symptom is exactly enough to build a wrong theory on.

Confidence that comes from a single bit is not knowledge. I had a specific, mechanistic, plausible explanation and it was wrong, because nothing in the system could have told me otherwise. The way out was not to think harder. It was to build a real RAR5 file and look.

A success event that fires before anything is on screen is not measuring success. Since April my analytics had counted blank books as opened books, and I read the number as reassurance.

Test the hypothesis before the fix. One real fixture would have saved me from designing around a library that was never broken.

A finished release is not a reason to ship. The features were done. Shipping them on top of a reader that could still open a blank book would have put a new What’s New sheet in front of the next person it failed.

7z is still not supported, and the app now says so instead of showing an empty book. I still do not know which of the six that reader hit. Neither did the app, which was the whole problem, and the next person will be told.