I hit the exact same bug twice, in two unrelated parsers, months apart, in the app I'm rewriting my own infrastructure as. Same root cause, same fix, and I still didn't generalize it the first time. That failure to generalize is the more useful part of this story than the bug itself.
The bug
Swift's String is a collection of Character, and Character represents a grapheme cluster, the unit a human reader would call one visible character. That's the right abstraction for almost everything you do with text. It is the wrong abstraction for one specific thing: splitting on a line ending.
"\r\n", a carriage return followed by a line feed, is not two characters in Swift. It's one Character, one extended grapheme cluster, because Unicode defines CRLF as a single grapheme cluster boundary and Swift's Character follows Unicode's rule exactly. So content.split(separator: "\n"), where "\n" is a Character literal representing a lone line feed, does not match against the line-feed half of a "\r\n" cluster. It doesn't match at all. The call doesn't throw, doesn't warn, doesn't behave any differently for a file with a handful of CRLF lines than for one with none. It just silently returns fewer, longer lines than you expected, each one still carrying its embedded "\r\n" clusters as literal content instead of being split on them.
Where it hid the first time
I found this first while building the project's MIME parser, the piece that reads real email. Email is exactly where CRLF shows up constantly; RFC 5322 specifies CRLF as the canonical line ending, and plenty of real mail servers still send it that way even though plenty of others normalize to LF. A parser that only tests against LF-only fixtures will pass every test and then mishandle a meaningful fraction of real-world mail, because the bug is invisible until the input actually contains the line ending it breaks on.
The fix, once found, was small: stop splitting on the Character literal "\n" and split on Character.isNewline instead, a predicate that correctly recognizes CRLF, lone CR, lone LF, and the handful of Unicode line-separator characters as newline boundaries, each counted once, not as some fixed-width delimiter. I fixed it in the MIME parser, the tests went green, the fleet's email-sync code moved on. That should have been the end of it.
Where it hid the second time
Months later, building the project's iCalendar reader and writer for calendar sync, a test failed on the escaping logic for ICS TEXT values, the part of RFC 5545 that defines how literal newlines, commas, and semicolons get backslash-escaped inside a property value. The escape routine iterated the string's Characters looking for a lone "\n" to escape. A "\r\n" in the source text is one Character, never equal to the standalone "\n" literal being compared against, so it fell straight through the escaping logic unchanged and rode out into the serialized output as a literal, un-escaped control character.
It's the identical bug. Same language feature, same wrong assumption, an entirely different parser I'd written from scratch specifically because I already knew the first one existed. I didn't recognize it as the same bug until I was mid-debug and the shape of the failure, correct-looking code, silently wrong only on CRLF input, felt familiar enough to go check. It was the same one-liner, just in a different file with different surrounding logic to obscure it.
Why fixing it once didn't fix it twice
The honest answer is that I fixed the symptom in one file instead of the pattern everywhere it could recur. Character and "\r\n"-as-one-grapheme-cluster is not a fact specific to MIME parsing. It's a fact about the Swift String type, true of every piece of code in the project that ever splits or scans text on a newline boundary. Fixing the MIME parser's call site taught me the fact in the narrow context I found it in, and the narrow context is what I remembered, not the general rule.
What actually generalizes the fix is writing the rule down somewhere every future parser reads before it's written, not somewhere only the file that already broke reads. A comment on the fixed line documents that one line. A note in the project's shared gotchas file, the kind of running log I wrote about in a gotchas file for your AI, documents the rule for every line not written yet. I have that file. I hadn't put this in it after the first fix, because the first fix felt local: one function, one test, done. It wasn't done. It was one instance of a rule I hadn't extracted yet.
The actual lesson
A bug that recurs in a second, unrelated place isn't bad luck. It's a signal that the fix you shipped was scoped to the symptom instead of the cause. When you fix something like this, the useful question isn't "does this test pass now." It's "what is the general fact I just learned, and where does every future piece of code that could rediscover it get to read it first." The second time this bug showed up, I finally wrote the general version down: any code that splits or scans text on a newline boundary in this codebase uses Character.isNewline, never a literal "\n" comparison, full stop. It's in the gotchas file now. If a third parser gets written against CRLF-bearing input, and eventually one will, it starts from the rule instead of rediscovering it the expensive way, in a debugger, for the third time.