fix(observability): crash-safe file writes in logging layer (uncatchable NSException → hard crash)#1498
Open
r3dbars wants to merge 1 commit into
Open
fix(observability): crash-safe file writes in logging layer (uncatchable NSException → hard crash)#1498r3dbars wants to merge 1 commit into
r3dbars wants to merge 1 commit into
Conversation
…ble NSException → hard crash)
The diagnostic/logging layer used the legacy, NSException-throwing FileHandle
APIs (seekToEndOfFile() / write(_:) / readData(ofLength:)). These raise ObjC
NSExceptions on I/O failure (disk full, closed fd, file rotated out from under
the handle). Swift cannot catch ObjC exceptions, so they propagate to the
uncaught handler and hard-crash the app — from the very observability code meant
to watch stability.
Confirmed live crash in shipped build 1.1.48 (2026-07-05), fired from a
background telemetry flush (scheduleInfoFlush → flushBufferedInfoEvents):
EventFileWriter.write → NSFileHandle writeData:
→ _NSFileHandleRaiseOperationExceptionWhileReading
→ objc_exception_throw → app terminate
The post-1.1.48 refactor moved the call into LockedFileAppender.append but kept
the same crashing API, so it stayed live.
Fix: swap to the Swift error-returning variants (seekToEnd() /
write(contentsOf:) / read(upToCount:)) wrapped in do/catch. Invariant:
diagnostic logging must never crash the app. On error we swallow and
best-effort report to stderr via the existing redaction helpers. flock/NSLock
logic and approximateSize bookkeeping are unchanged; signatures stay
non-throwing so callers are untouched.
Six shipped runtime sites:
- Sources/Observability/LockedFileAppender.swift (the 1.1.48 crash path)
- Sources/TranscriptedCore/Logging/FileLogger.swift
- Sources/Observability/EventReporter.swift
- Sources/Observability/ReliabilityPacketRecorder.swift
- Sources/Observability/AppLogger.swift
- Sources/TranscriptedCore/Speaker/RetroactiveSpeakerUpdater.swift
Regression tests:
- LockedFileAppender append to a live read-only handle (write fails EBADF, same
shape as the 1.1.48 disk-write failure) returns instead of terminating.
- Source-contract guard: no shipped logging site calls the NSException-throwing
legacy APIs (comments stripped so the explanatory text doesn't false-trip).
- TranscriptSaver.extractTranscriptId reading a directory fd (EISDIR) returns
nil instead of crashing (runs under `swift test`).
Unblocks the 1.1.49 release-stability pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
The diagnostic/logging layer used the legacy, NSException-throwing
FileHandleAPIs —seekToEndOfFile(),write(_:),readData(ofLength:). These raise ObjCNSExceptions on I/O failure (disk full, closed fd, file rotated out from under the handle). Swift cannot catch ObjC exceptions, so they propagate to the uncaught handler and hard-crash the app. Ironic: the observability code meant to watch stability is the crash source.A real crash of this exact shape was captured on 2026-07-05 in shipped build 1.1.48, firing from a background telemetry flush (
scheduleInfoFlush → flushBufferedInfoEvents):The refactor since 1.1.48 moved the call into
LockedFileAppender.appendbut kept the same crashing API, so it stayed live.The fix
Swap the throwing legacy APIs for the Swift error-returning variants (macOS 10.15.4+; app min target ≥14), each wrapped in
do/catch:seekToEndOfFile()→try seekToEnd()write(data)→try write(contentsOf: data)readData(ofLength:)→try read(upToCount:)Invariant: diagnostic logging must never crash the app. On error we swallow and best-effort report to stderr via the existing redaction helpers (
ObservabilityTextRedactor.redact/LogPrivacySanitizer.sanitizeText).flock/NSLocklogic andapproximateSizebookkeeping are unchanged; signatures stay non-throwing so callers are untouched.Six shipped runtime sites
Sources/Observability/LockedFileAppender.swift— the 1.1.48 crash pathSources/TranscriptedCore/Logging/FileLogger.swiftSources/Observability/EventReporter.swiftSources/Observability/ReliabilityPacketRecorder.swiftSources/Observability/AppLogger.swiftSources/TranscriptedCore/Speaker/RetroactiveSpeakerUpdater.swiftOut of scope (per task):
Tools/**dev tools writing tostderr, third-party.build/checkouts/.Regression tests
LockedFileAppendercrash-safety (fast runner): append to a live read-only handle — the write failsEBADF, the same failure shape as the 1.1.48 disk-write — and the call returns instead of terminating the process.TranscriptSaver.extractTranscriptId(swift test): reading a directory fd (EISDIR) returnsnilinstead of crashing.Verification
bash build-deps.sh --force && bash build.sh— ✅ succeeds (app links, launch smoke + perf budget OK)swift test(RetroactiveSpeakerUpdater + FileLogger) — ✅ 47/47RepoCommandContractTeststripping on an untracked local.coord-worktrees/nested worktree (not in this PR, absent in a clean CI checkout), unrelated to this diff.Unblocks the 1.1.49 release-stability pass. Kept green — does not leave main red.
🤖 Generated with Claude Code