Skip to content

fix(observability): crash-safe file writes in logging layer (uncatchable NSException → hard crash)#1498

Open
r3dbars wants to merge 1 commit into
mainfrom
fix/observability-crash-safe-file-writes
Open

fix(observability): crash-safe file writes in logging layer (uncatchable NSException → hard crash)#1498
r3dbars wants to merge 1 commit into
mainfrom
fix/observability-crash-safe-file-writes

Conversation

@r3dbars

@r3dbars r3dbars commented Jul 8, 2026

Copy link
Copy Markdown
Owner

The bug

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. 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):

EventFileWriter.write → NSFileHandle writeData:
  → _NSFileHandleRaiseOperationExceptionWhileReading
  → objc_exception_throw → app terminate

The refactor since 1.1.48 moved the call into LockedFileAppender.append but 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/NSLock logic and approximateSize bookkeeping are unchanged; signatures stay non-throwing so callers are untouched.

Six shipped runtime sites

  1. Sources/Observability/LockedFileAppender.swiftthe 1.1.48 crash path
  2. Sources/TranscriptedCore/Logging/FileLogger.swift
  3. Sources/Observability/EventReporter.swift
  4. Sources/Observability/ReliabilityPacketRecorder.swift
  5. Sources/Observability/AppLogger.swift
  6. Sources/TranscriptedCore/Speaker/RetroactiveSpeakerUpdater.swift

Out of scope (per task): Tools/** dev tools writing to stderr, third-party .build/checkouts/.

Regression tests

  • LockedFileAppender crash-safety (fast runner): append to a live read-only handle — the write fails EBADF, the same failure shape as the 1.1.48 disk-write — and the call returns instead of terminating the process.
  • Source-contract guard (fast runner): asserts no shipped logging site calls the NSException-throwing legacy APIs (comments stripped so the explanatory text doesn't false-trip).
  • TranscriptSaver.extractTranscriptId (swift test): reading a directory fd (EISDIR) returns nil instead of crashing.

Verification

  • bash build-deps.sh --force && bash build.sh — ✅ succeeds (app links, launch smoke + perf budget OK)
  • Observability fast suite — ✅ 70/70
  • swift test (RetroactiveSpeakerUpdater + FileLogger) — ✅ 47/47
  • Full fast suite — 12780/12781; the 1 failure is RepoCommandContractTests tripping 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

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant