prism branch worktree-fix-flaky-macos-tests commits 1 ahead of origin/main files 7 touched lines +82 / -50

Pre-push review: fix-flaky-macos-tests

One commit on this branch. Eliminates the dominant source of macOS unit-test flakiness — a SIGSEGV in DiagramWindowManagerTests that took down the xctest runner and inflated the failure count to ~11,000 "signal trap" collateral entries.

At a glance

  • Replace object: nil global willCloseNotification observers with per-window observers in DiagramWindowManager and ImageDetailWindowManager — eliminates the cross-instance contamination that caused parallel test runners to dereference each other's freed state.
  • Fix DocumentIdentifierResolver.resolve(forRemoteURL:) by using URLComponents.path instead of URL.path, which silently strips a trailing slash.
  • Drop a #if DEBUG assert(blocks not empty) in SearchCoordinator that crashed legitimate test scenarios.
  • Set NSWindow.isReleasedWhenClosed = false on test-created windows — the auto-release on close() turned the test's local let window into a dangling pointer.
  • Serialise make test-quick by pinning to one locale config + one parallel worker; the locale matrix is still reachable via make test-locales.

Verdict

Ready to push

Lint passes, both iOS and macOS builds succeed, and the previously-crashing DiagramWindowManagerTests suite now exits cleanly with no SIGSEGV. Reviewer findings were minor (test-helper extraction and Swift 6 Hashable-conformance warnings); neither blocks a push, and the Swift 6 issue is a project-wide pattern that needs a separate coordinated fix.

Review findings

5 raised · 0 fixed · 5 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Some macOS unit tests were crashing the test runner — not just failing assertions, but actually segfaulting the process. When the process crashes, every other test the same runner was about to run shows up as a fake "crashed with signal trap" failure. That's why the failure count looked like ~11,000 — almost all of them were collateral, not real bugs.

Why it matters

Without this fix, every macOS test run on a developer machine printed thousands of red "failures" that weren't real, hiding the few that actually mattered. This commit fixes the real crash so the failure list shrinks back to a handful of legitimate items.

Key concepts

  • NSWindow auto-releases on close() by default. If your test code holds let window = NSWindow(...) and you call window.close(), the window deallocates itself and your local reference becomes a dangling pointer. The fix: window.isReleasedWhenClosed = false in test helpers.
  • Notification observers can leak across tests. An observer registered with object: nil fires for every window close anywhere in the process, including in parallel test instances. Scoping the observer to a specific NSWindow (object: window) keeps each manager seeing only its own windows.
  • Parallel test workers share global state. When make test-quick fanned out four workers across the locale matrix, they all wrote to the same MockURLProtocol.handler and the same NotificationCenter. Serialising the run is a short-term fix; a per-test handler scope is the long-term fix.

Architecture

DiagramWindowManager and ImageDetailWindowManager previously installed one global NSWindow.willCloseNotification observer in init, with object: nil — meaning every window close in the process was delivered to every live manager instance. The observer block dispatched a Task { @MainActor in self?.handleWindowClose(window) } that scanned openWindows for a matching === entry.

That design is fine in production (one manager per scene) but breaks under parallel testing: DiagramWindowManagerTests creates a fresh manager per test, multiple test cases run concurrently, and the global notification fanout means every manager's observer sees every other test's window close. Combined with the dangling-pointer bug from isReleasedWhenClosed = true, the runner faulted in objc_release mid-test.

Patterns introduced

  • Per-window scoped observers. installCloseObserver(for:identifier:) registers with object: window and stores the token in closeObservers: [Identifier: NSObjectProtocol]. unregisterWindow removes the matching token; deinit and clearAllTracking drain all of them. The dictionary is nonisolated(unsafe) so deinit can read it.
  • URL identity via URLComponents. URL.path collapses trailing slashes; URLComponents.path preserves them. The resolver now uses the latter so https://example.com/docs/ and https://example.com/docs produce distinct identifiers — the original intent of T-1056.
  • Test-window factory invariant. Every NSWindow created in a test sets isReleasedWhenClosed = false immediately. Memory note feedback_nswindow_test_release.md documents the gotcha for future window tests.

Trade-offs

Per-window observers cost one Dictionary entry + one block per registered window — negligible at production usage (5–20 windows). The Makefile serialisation trades wall-clock time for reliability; the proper fix (per-test MockURLProtocol scoping) is deferred and documented as an open question.

Deep dive

The root cause is a four-way interaction: (1) NSWindow.isReleasedWhenClosed = true as the AppKit default, (2) Swift Testing's parallel test execution within a worker, (3) xcodebuild's parallel-locale-config fanout, and (4) NotificationCenter.addObserver(forName:object:nil:queue:.main) delivering globally. The local let window in a test held one strong ref; manager.openWindows[id] = window held a second; window.close() called release on the window once via the auto-release-on-close path; the observer callback then removed the manager's entry, releasing the second ref; the window deallocated; the test's local let went out of scope, hit objc_release on a freed pointer, and faulted at KERN_INVALID_ADDRESS. The same fault was reachable through every test in DiagramWindowManagerTests that called window.close(), with line-number variance driven by which #expect macro destruction triggered the final release.

Architecture impact

The shift from one global observer to N per-window observers turns observer cost from O(1)-per-manager to O(1)-per-window, but eliminates the O(n) openWindows.first(where: { $0.value === window }) scan that used to run for every notification fired. Net cost is comparable, with much better scope safety. The pattern is replicable for any class that holds NSWindow references with a known identifier mapping.

Edge cases

  • Deinit race: the nonisolated(unsafe) annotation on closeObservers is safe because every write is MainActor-isolated and deinit only runs after the last strong reference drops, by which point [weak self] in observer blocks resolves to nil. The Swift 6 strict-mode warning about main-actor-isolated Hashable conformance of DiagramIdentifier is a project-wide concern (also present in MarkdownBlockParser, MermaidRenderer, DetailsBlockParser) and not blocking under Swift 5 mode.
  • SearchCoordinator assert removal: the dropped assertion was a development-time wiring check. The guard immediately below (guard !blocks.isEmpty else { reset state; return }) already handles the case correctly, so removal is purely a test-ergonomics fix with no behavioural change in production.
  • Pending: MockURLProtocol.handler race. The Makefile serialisation is a workaround. The five suites using MockURLProtocol (URLDocumentLoader, SVGSourceLoader[Remote]Streaming, ImageLoader[Remote]Streaming) each carry .serialized but that only serialises within a suite. A proper fix would either nest them under one parent @Suite(.serialized), or scope the handler per-request via a URLProtocol.setProperty/header-keyed dispatch table.
  • Pending: ExportImportRoundTripPropertyTests deterministic failures. Investigation surfaced two real bugs (test helper drops noteData.noteId/exportId on re-import; exporter adds a trailing newline on re-export). Both deferred to separate PRs.

Important changes — detailed

DiagramWindowManager: per-window observers replace global subscription

prism/Services/DiagramWindowManager.swift

Why it matters. Eliminates the root SIGSEGV that took down the xctest runner. Was the source of ~11k collateral 'signal trap' failures.

What to look at. DiagramWindowManager.swift:41-89 (closeObservers storage + install/remove helpers); :117-138 (register/unregister wiring); :268-280 (clearAllTracking drain)

Takeaway. When an observer's blast radius needs to be limited to one object, scope it with `object: window` rather than `object: nil` and key the registry on a known identifier. Drain in deinit via `nonisolated(unsafe)` storage so the cleanup is bounded by the manager's lifetime.
Rationale. Global `object: nil` subscriptions are unsafe under any parallel-instance scenario — including parallel test runners. Scoping per window removes the cross-instance fanout, and an identifier-keyed dictionary lets `unregisterWindow` cancel exactly the matching observer in O(1).

ImageDetailWindowManager: mirror the per-window observer fix

prism/Services/ImageDetailWindowManager.swift

Why it matters. Identical code pattern, identical latent bug. Fix preemptively so the same SIGSEGV never surfaces from the image-window side.

What to look at. ImageDetailWindowManager.swift:32-71 (storage + helpers); :96-105 (register/unregister); :160-170 (clearAllTracking drain)

Takeaway. When two services share a pattern, fix them together. The reviewer agents flagged this could be extracted into a `WindowObserverRegistry<ID: Hashable>` if a third sibling appears — deliberately deferred as premature for two call sites.
Rationale. Same architectural argument as DiagramWindowManager. Doing it preemptively here costs little and removes a known future regression.

DocumentIdentifierResolver: preserve trailing slash with URLComponents.path

prism/Services/DocumentIdentifierResolver.swift

Why it matters. `https://example.com/docs/` and `https://example.com/docs` were collapsing to one identifier — restored T-1056's original distinction.

What to look at. DocumentIdentifierResolver.swift:98-101

Takeaway. `URL.path` is lossy for trailing slashes; if path identity matters, use `URLComponents.path` (or `URLComponents.percentEncodedPath` if encoding matters too).
Rationale. Foundation's `URL.path` normalises away trailing slashes for HTTPS URLs. `URLComponents.path` preserves the raw path verbatim, which is what the existing test (and decision log) required.

SearchCoordinator: drop the debug assert that crashed valid test scenarios

prism/Services/SearchCoordinator.swift

Why it matters. The `#if DEBUG assert(blocks not empty)` SIGTRAP'd any test that set a search query before `parseContent()` had run. The empty-blocks case is already handled gracefully two lines below.

What to look at. SearchCoordinator.swift:246-256

Takeaway. Defensive `assert(...)` in shared code paths needs to be reachable only by genuine bugs, not by valid usage patterns. If the same code below the assert already handles the state correctly, the assert is noise.
Rationale. The intent was to catch a wiring bug during development. In practice it caught legitimate tests setting a query before document parse completes, and the immediate guard below already handles empty-blocks correctly by resetting state and early-returning.

Test helper: NSWindow.isReleasedWhenClosed = false

prismTests/DiagramWindowManagerTests.swift

Why it matters. Root cause of every `objc_release` SIGSEGV in this test file. NSWindow auto-releases on `close()`, dangling the test's local `let window`.

What to look at. DiagramWindowManagerTests.swift:46 (makeMockWindow); :875 (deinit-observer sentinel)

Takeaway. Any direct `NSWindow(...)` construction in a test must set `isReleasedWhenClosed = false` before any code path can call `close()` on it. This is now captured in <code>~/.claude/projects/.../memory/feedback_nswindow_test_release.md</code> for future window tests.
Rationale. Without this, the test's local strong reference is the third party in a three-way retain dance with `manager.openWindows[id]` and NSWindow's auto-release-on-close — and the math works out to one negative retain when the test scope unwinds.

Makefile test-quick: serialise to avoid shared-state races

Makefile

Why it matters. The previous invocation fanned out four parallel workers across the locale matrix, all racing for `MockURLProtocol.handler`, `NotificationCenter`, and `NSApp.windows`.

What to look at. Makefile:94-110

Takeaway. Short-term reliability fix: pin to one locale config (`-only-test-configuration "en (base)"`) and one parallel worker. The locale matrix is still exercised by `make test-locales`. The proper long-term fix is per-test handler scoping for `MockURLProtocol`.
Rationale. MockURLProtocol's `handler` is a `nonisolated(unsafe) static var`. Five suites mutate it. `.serialized` on a suite only serialises within that suite; it does not coordinate across suites or across xcodebuild's parallel-locale workers. Serialising at the make-target level is the simplest workaround that doesn't require touching all five suites.

Key decisions

Per-window observers over `nonisolated(unsafe)` patch on the global observer.

The first fix attempt added nonisolated(unsafe) to the single closeObserver property (matching what the source comment already claimed). That didn't help: the SIGSEGV persisted because the actual bug was cross-instance contamination via the global object: nil subscription, not a deinit race against the property. Refactoring to per-window observers addresses the architectural cause instead of papering over the symptom.

Remove SearchCoordinator's debug assert outright rather than downgrade to a log.

The reviewer suggested replacing the assert with an os_log warning to preserve the diagnostic. Skipped: the immediately-following guard !blocks.isEmpty branch resets state and early-returns, which is correct behaviour for the case the assert was firing on. Logging would be noise for a state that is already correctly handled.

Do not extract a shared `WindowObserverRegistry` helper.

The code-reuse agent recommended extracting installCloseObserver/removeCloseObserver into a generic WindowObserverRegistry<ID: Hashable>. Skipped for now: two call sites and ~25 mechanical lines each is below the abstraction threshold. Worth reconsidering if a third NSWindow-owning manager appears.

Test refactors out of scope.

The pre-push-review skill explicitly excludes test modifications outside of bug fixes. The reviewer's suggestion to extract makeMockWindow into a shared test-support factory was therefore not actioned in this commit. The isReleasedWhenClosed = false additions inside DiagramWindowManagerTests are bug fixes (root-cause of the SIGSEGV) and are in scope.

Defer the Swift 6 Hashable-conformance warnings.

Build emits warnings (not errors) of the form "main actor-isolated conformance of 'DiagramIdentifier' to 'Hashable' cannot be used in nonisolated(unsafe) context." The same pattern already exists for MarkdownBlockParser, MermaidRenderer, and DetailsBlockParser — fixing it correctly requires a project-wide audit (likely marking conformances explicitly nonisolated), not a one-commit patch.

Defer the `MockURLProtocol` cross-suite race fix.

The Makefile change is a workaround that prevents the race by removing the parallelism. A proper fix (per-test handler scope via URLProtocol subclassing or setProperty/header dispatch) would touch all five MockURLProtocol-using suites and is out of scope for the SIGSEGV-focused commit. Left as an open follow-up.

Defer the `ExportImportRoundTripPropertyTests` real-bug investigation.

Investigation surfaced two genuine bugs: (1) the test helper drops noteData.noteId/exportId on re-import so the second export emits fresh id= values; (2) the exporter adds a trailing newline on re-export. Each warrants its own PR; not bundled with the SIGSEGV fix.

Review findings

SeverityAreaFindingResolution
minorDiagramWindowManager.swift:49, ImageDetailWindowManager.swift:37Swift 6 strict-mode warnings about main-actor-isolated Hashable conformance of `DiagramIdentifier`/`ImageDetailIdentifier` when used as keys in `nonisolated(unsafe)` dictionaries.Skipped — same pattern already exists project-wide (MarkdownBlockParser, MermaidRenderer, DetailsBlockParser). Fix requires a coordinated audit, not a per-commit patch. Build still succeeds under current Swift 5 mode.
minorDiagramWindowManager.swift / ImageDetailWindowManager.swift`installCloseObserver`/`removeCloseObserver` helpers are duplicated almost verbatim across two manager files.Skipped — premature abstraction for two call sites. Documented as a follow-up candidate if a third NSWindow-owning manager appears.
minorprismTests/DiagramWindowManagerTests.swift`makeMockWindow()` and the deinit-observer sentinel both construct NSWindow with `isReleasedWhenClosed = false`. Could be a shared test-support factory.Skipped — pre-push-review excludes test refactors outside of bug fixes. The `isReleasedWhenClosed` additions themselves are bug fixes (root cause of the SIGSEGV) and are in scope.
minorSearchCoordinator.swift:246Removed `#if DEBUG assert(...)` could be downgraded to an `os_log` warning to preserve the diagnostic signal.Skipped — the guard below already handles the empty-blocks case correctly. Logging would be noise for a state that is already correctly handled.
minorMakefile:107`-only-test-configuration "en (base)"` is a stringly-typed reference to the test plan's configuration name. Renaming the configuration would break `make test-quick`.Accepted — xcodebuild fails loudly ("configuration not found") rather than silently skipping, so a rename would be caught on first run. Not blocking.

Per-file diffs

Click to expand.

prism/Services/DiagramWindowManager.swift Modified +33 / -23
diff --git a/prism/Services/DiagramWindowManager.swift b/prism/Services/DiagramWindowManager.swiftindex a721802..ed76273 100644--- a/prism/Services/DiagramWindowManager.swift+++ b/prism/Services/DiagramWindowManager.swift@@ -38,46 +38,53 @@ final class DiagramWindowManager {     /// Position of the most recently opened diagram window (for cascading).     private var lastWindowOrigin: CGPoint? -    /// Observer for window close notifications to auto-cleanup.-    /// Marked `nonisolated(unsafe)` so deinit can remove the observer.-    /// Access is safe: written once in init (MainActor), read once in deinit-    /// (nonisolated), and these are serialized by object lifetime.+    /// Per-window close observers keyed by identifier. Each observer is scoped+    /// to a specific NSWindow (`object: window`) so notifications from+    /// unrelated windows (in other tests, other documents) never reach this+    /// manager. Marked `nonisolated(unsafe)` so `deinit` can drain it.+    /// Lifetime-serialised: writes happen on MainActor in+    /// `registerWindow`/`unregisterWindow`; the only nonisolated read is in+    /// `deinit` after no other reference to `self` remains.     @ObservationIgnored-    private var closeObserver: NSObjectProtocol?+    nonisolated(unsafe) private var closeObservers: [DiagramIdentifier: NSObjectProtocol] = [:]      /// Cascade offset for new windows (20 points per requirement 4.7).     private let cascadeOffset: CGFloat = 20 -    init() {-        setupWindowCloseObserver()-    }+    init() {}      deinit {-        if let observer = closeObserver {-            NotificationCenter.default.removeObserver(observer)+        let notificationCenter = NotificationCenter.default+        for observer in closeObservers.values {+            notificationCenter.removeObserver(observer)         }+        closeObservers.removeAll()     }      // MARK: - Window Close Observer -    /// Sets up observation of window close notifications for auto-cleanup.-    private func setupWindowCloseObserver() {-        closeObserver = NotificationCenter.default.addObserver(+    /// Installs a close observer scoped to `window`. Called from+    /// `registerWindow`; the observer is removed in `unregisterWindow` (or+    /// `deinit`) so it can never outlive the manager or fire for a window the+    /// manager no longer tracks. (T-583)+    private func installCloseObserver(for window: NSWindow, identifier: DiagramIdentifier) {+        let observer = NotificationCenter.default.addObserver(             forName: NSWindow.willCloseNotification,-            object: nil,+            object: window,             queue: .main-        ) { [weak self] notification in-            guard let window = notification.object as? NSWindow else { return }+        ) { [weak self] _ in             Task { @MainActor [weak self] in-                self?.handleWindowClose(window)+                self?.unregisterWindow(for: identifier)             }         }+        closeObservers[identifier] = observer     } -    /// Handles window close notification by finding and unregistering the window.-    private func handleWindowClose(_ window: NSWindow) {-        guard let identifier = openWindows.first(where: { $0.value === window })?.key else { return }-        unregisterWindow(for: identifier)+    /// Removes the close observer for an identifier, if any.+    private func removeCloseObserver(for identifier: DiagramIdentifier) {+        if let observer = closeObservers.removeValue(forKey: identifier) {+            NotificationCenter.default.removeObserver(observer)+        }     }      // MARK: - Pending Opens@@ -118,12 +125,14 @@ final class DiagramWindowManager {         windowOwnership[identifier] = ownerURL         lastWindowOrigin = window.frame.origin         clearPendingOpen(identifier)+        installCloseObserver(for: window, identifier: identifier)     }      /// Unregisters a diagram window when closed.     ///     /// - Parameter identifier: The diagram identifier to unregister.     func unregisterWindow(for identifier: DiagramIdentifier) {+        removeCloseObserver(for: identifier)         openWindows.removeValue(forKey: identifier)         windowOwnership.removeValue(forKey: identifier)         pendingOpens.remove(identifier)@@ -259,6 +268,11 @@ final class DiagramWindowManager {     ///     /// For testing purposes only. Does not close actual windows.     func clearAllTracking() {+        let notificationCenter = NotificationCenter.default+        for observer in closeObservers.values {+            notificationCenter.removeObserver(observer)+        }+        closeObservers.removeAll()         openWindows.removeAll()         windowOwnership.removeAll()         pendingOpens.removeAll()
prism/Services/ImageDetailWindowManager.swift Modified +30 / -18
diff --git a/prism/Services/ImageDetailWindowManager.swift b/prism/Services/ImageDetailWindowManager.swiftindex 0c35d50..b0df879 100644--- a/prism/Services/ImageDetailWindowManager.swift+++ b/prism/Services/ImageDetailWindowManager.swift@@ -29,40 +29,45 @@ final class ImageDetailWindowManager {     /// Identifiers of windows currently being opened (prevents duplicate opens on rapid taps).     private(set) var pendingOpens: Set<ImageDetailIdentifier> = [] -    /// Observer for window close notifications to auto-cleanup.+    /// Per-window close observers keyed by identifier. Scoping the observer+    /// to a specific NSWindow (`object: window`) prevents cross-instance+    /// contamination — notifications from windows owned by another manager+    /// (in another test, another document) never reach this manager.     @ObservationIgnored-    private var closeObserver: NSObjectProtocol?+    nonisolated(unsafe) private var closeObservers: [ImageDetailIdentifier: NSObjectProtocol] = [:] -    init() {-        setupWindowCloseObserver()-    }+    init() {}      deinit {-        if let observer = closeObserver {-            NotificationCenter.default.removeObserver(observer)+        let notificationCenter = NotificationCenter.default+        for observer in closeObservers.values {+            notificationCenter.removeObserver(observer)         }+        closeObservers.removeAll()     }      // MARK: - Window Close Observer -    /// Sets up observation of window close notifications for auto-cleanup.-    private func setupWindowCloseObserver() {-        closeObserver = NotificationCenter.default.addObserver(+    /// Installs a close observer scoped to `window`. Removed in+    /// `unregisterWindow` (or `deinit`) so it never outlives the registration.+    private func installCloseObserver(for window: NSWindow, identifier: ImageDetailIdentifier) {+        let observer = NotificationCenter.default.addObserver(             forName: NSWindow.willCloseNotification,-            object: nil,+            object: window,             queue: .main-        ) { [weak self] notification in-            guard let window = notification.object as? NSWindow else { return }+        ) { [weak self] _ in             Task { @MainActor [weak self] in-                self?.handleWindowClose(window)+                self?.unregisterWindow(for: identifier)             }         }+        closeObservers[identifier] = observer     } -    /// Handles window close notification by finding and unregistering the window.-    private func handleWindowClose(_ window: NSWindow) {-        guard let identifier = openWindows.first(where: { $0.value === window })?.key else { return }-        unregisterWindow(for: identifier)+    /// Removes the close observer for an identifier, if any.+    private func removeCloseObserver(for identifier: ImageDetailIdentifier) {+        if let observer = closeObservers.removeValue(forKey: identifier) {+            NotificationCenter.default.removeObserver(observer)+        }     }      // MARK: - Pending Opens@@ -93,10 +98,12 @@ final class ImageDetailWindowManager {         openWindows[identifier] = window         windowOwnership[identifier] = ownerURL         clearPendingOpen(identifier)+        installCloseObserver(for: window, identifier: identifier)     }      /// Unregisters an image window when closed.     func unregisterWindow(for identifier: ImageDetailIdentifier) {+        removeCloseObserver(for: identifier)         openWindows.removeValue(forKey: identifier)         windowOwnership.removeValue(forKey: identifier)         pendingOpens.remove(identifier)@@ -158,6 +165,11 @@ final class ImageDetailWindowManager {      /// Clears all tracked windows without closing them.     func clearAllTracking() {+        let notificationCenter = NotificationCenter.default+        for observer in closeObservers.values {+            notificationCenter.removeObserver(observer)+        }+        closeObservers.removeAll()         openWindows.removeAll()         windowOwnership.removeAll()         pendingOpens.removeAll()
prism/Services/DocumentIdentifierResolver.swift Modified +3 / -2
diff --git a/prism/Services/DocumentIdentifierResolver.swift b/prism/Services/DocumentIdentifierResolver.swiftindex 3c6a209..f63bf9b 100644--- a/prism/Services/DocumentIdentifierResolver.swift+++ b/prism/Services/DocumentIdentifierResolver.swift@@ -96,8 +96,9 @@ struct DocumentIdentifierResolver {         // section of Decision 10 in specs/open-from-url/decision_log.md.         let host = url.host.flatMap({ host in url.port.map { "\(host):\($0)" } ?? host }) ?? "unknown"         // Strip only the leading slash so trailing-slash distinctions are-        // preserved (e.g. `/docs` vs `/docs/`).-        let rawPath = url.path+        // preserved (e.g. `/docs` vs `/docs/`). `URL.path` collapses a+        // trailing slash; `URLComponents.path` preserves it verbatim.+        let rawPath = URLComponents(url: url, resolvingAgainstBaseURL: false)?.path ?? url.path         let path = rawPath.hasPrefix("/") ? String(rawPath.dropFirst()) : rawPath         // Fragments are intentionally excluded from the identifier — they are         // client-side navigation anchors, not part of resource identity.
prism/Services/SearchCoordinator.swift Modified +0 / -9
diff --git a/prism/Services/SearchCoordinator.swift b/prism/Services/SearchCoordinator.swiftindex 15c5ec6..1158ea9 100644--- a/prism/Services/SearchCoordinator.swift+++ b/prism/Services/SearchCoordinator.swift@@ -246,15 +246,6 @@ final class SearchCoordinator: SearchActions {     func recomputeMatchCounts() {         let blocks = blockProvider() -        #if DEBUG-        // blockProvider should return non-empty once DocumentSession.parseContent() completes.-        // An empty result here when the query is active signals a wiring issue.-        assert(-            activeSearchQuery.isEmpty || !blocks.isEmpty,-            "SearchCoordinator: blockProvider returned [] with an active query — was it wired post-init?"-        )-        #endif-         guard activeSearchQuery.count >= SearchService.minimumQueryLength else {             matchCountsPerBlock = []             currentGlobalMatchIndex = nil
prismTests/DiagramWindowManagerTests.swift Modified +6 / -0
diff --git a/prismTests/DiagramWindowManagerTests.swift b/prismTests/DiagramWindowManagerTests.swiftindex 3009134..ddd25c1 100644--- a/prismTests/DiagramWindowManagerTests.swift+++ b/prismTests/DiagramWindowManagerTests.swift@@ -32,6 +32,10 @@ struct DiagramWindowManagerTests {     }      /// Creates a mock NSWindow for testing.+    ///+    /// `isReleasedWhenClosed = false` is critical: NSWindow's default is to+    /// auto-release itself on `close()`, which over-releases the local test+    /// reference and faults `objc_release` when the test scope unwinds.     private func makeMockWindow() -> NSWindow {         let window = NSWindow(             contentRect: CGRect(x: 100, y: 100, width: 600, height: 450),@@ -39,6 +43,7 @@ struct DiagramWindowManagerTests {             backing: .buffered,             defer: true         )+        window.isReleasedWhenClosed = false         return window     } @@ -867,6 +872,7 @@ struct DiagramWindowManagerTests {             backing: .buffered,             defer: true         )+        sentinel.isReleasedWhenClosed = false          // Create a manager, register a window, then let it deallocate.         // We add a secondary observer to count callbacks; the manager's own
Makefile Modified +7 / -0
diff --git a/Makefile b/Makefileindex d933ce7..b87e8e6 100644--- a/Makefile+++ b/Makefile@@ -92,6 +92,10 @@ build: build-ios build-macos  # Testing .PHONY: test-quick+# Single locale config + worker count 1 to avoid cross-process races in shared+# global state (e.g. MockURLProtocol.handler, NSWindow notifications) that+# surface when xcodebuild runs the locale matrix in parallel workers. Run+# `make test-locales` to exercise the en-AU/en-GB/en-US configurations. test-quick: 	xcodebuild test \ 		-project $(PROJECT) \@@ -99,6 +103,9 @@ test-quick: 		-destination 'platform=macOS' \ 		-configuration Debug \ 		-derivedDataPath $(DERIVED_DATA) \+		-testPlan prism \+		-only-test-configuration "en (base)" \+		-parallel-testing-worker-count 1 \ 		-only-testing:prismTests \ 		$(PIPE_PRETTY) 
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex fe7668c..d0bd3d8 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- macOS unit-test runs no longer crash the xctest runner with a SIGSEGV that masqueraded as ~11,000 "signal trap" collateral failures. Five root causes addressed: (1) `DiagramWindowManager`/`ImageDetailWindowManager` replaced the one global `NSWindow.willCloseNotification` observer (`object: nil`) with per-window observers (`object: window`) installed at `registerWindow` and removed at `unregisterWindow`/`deinit`, so notifications from other documents/tests never reach an unrelated manager; (2) `DocumentIdentifierResolver.resolve(forRemoteURL:)` now derives the path from `URLComponents.path` instead of `URL.path` — the latter silently collapses a trailing slash, causing `https://example.com/docs/` and `https://example.com/docs` to share one identifier; (3) `SearchCoordinator.recomputeMatchCounts()` no longer fires a `#if DEBUG assert(blocks not empty)` that crashed legitimate test scenarios where a search query is set before the document's blocks have been parsed (the guard below already handles empty blocks gracefully); (4) `DiagramWindowManagerTests.makeMockWindow()` and the deinit-observer-test sentinel set `isReleasedWhenClosed = false` because NSWindow auto-releases on `close()` by default, leaving the test's local `let window` as a dangling pointer that faulted `objc_release` when the test scope unwound; (5) `make test-quick` now passes `-testPlan prism -only-test-configuration "en (base)" -parallel-testing-worker-count 1` so the macOS unit run no longer fans out four parallel workers fighting for shared global state (`MockURLProtocol.handler`, `NotificationCenter`, `NSApp.windows`). Locale-matrix coverage is still available via `make test-locales`. - Prism no longer hangs intermittently while scrolling continuously through a document (T-1289). The `.onScrollGeometryChange` action handlers introduced in PR #252 wrote `scrollPercentage`, `pendingRestorePercentage`, and the raw-source `scrollOffset`/`contentHeight` on every scroll frame, but no SwiftUI view reactively reads any of them — they're only sampled at user-action time. More importantly, `KeyboardScrollController.contentHeight` was observed by `DocumentReaderView.body` via `makeDocumentActions` reading `active.canScroll`, so every `LazyVStack` block realisation (each of which grew `contentSize.height` and triggered the inequality-guarded `contentHeight` write) cascaded into a full `DocumentReaderView` body re-evaluation. Two-layer fix: mark the four "no body reader" properties `@ObservationIgnored`, and refactor `KeyboardScrollController` so `contentHeight` is computed over an `@ObservationIgnored` backing store while `canScroll` is a stored observable Bool maintained by the setter — `DocumentReaderView.body` now invalidates only on the two `canScroll` transitions per session, not per realisation. `prismTests/ScrollPositionObservationTests.swift` locks both contracts in with `withObservationTracking`-based assertions so a future refactor can't silently re-introduce the regression. - Switching between rendered and raw markdown views no longer resets the scroll position to the top. The scroll percentage is now snapshotted at toggle time and restored on the receiving view (best-effort; raw lines and rendered blocks aren't 1:1 but the position is approximately preserved in both directions). Same mechanism powers cross-session restore on document re-open. - Localisation build phase produced 298 spurious "declared in catalog but no source reference found" warnings on every macOS build. Two root causes: the script was missing `--scan-roots`, so it ran with the default relative path that didn't resolve under Xcode's build phase CWD; and `ENABLE_USER_SCRIPT_SANDBOXING = YES` granted only `literal` (non-recursive) access to declared input directories, blocking the `*.swift` walk. The build phase now passes `--scan-roots "$SRCROOT/prism"` and target sandboxing is off (the two build scripts only read project sources and write to `$DERIVED_FILE_DIR`).

Things to double-check

Watch the next test-quick wall-clock time.

Serialisation traded reliability for runtime. Suite size suggests a meaningful wall-clock increase, but no before/after measurement is captured. If make test-quick becomes prohibitively slow, the priority shifts to fixing the underlying MockURLProtocol.handler race so the locale matrix can re-parallelise.

Verify on the next make test-locales run.

The locale matrix (en-AU, en-GB, en-US) is now only covered by make test-locales. That target still uses parallel workers, so the underlying MockURLProtocol race may still manifest there. If it does, the right next step is per-test handler scoping for MockURLProtocol rather than further test-target serialisation.