Phocus 4.1.1 Bug Investigations - Technical Details
Technical investigation of four Phocus 4.1.1 bugs: stack traces, plist monitoring, log analysis, and root cause identification.
Table of Contents
Inside Four Phocus 4.1.1 Bugs: The Technical Investigation
This is the companion post to "Phocus 4.1.1: Four Things That Should Be on the Fix List," which covers the same four bugs in practical, photographer-friendly terms. This post is the engineering version. It covers the investigation methods, evidence, and root cause analysis behind each finding.
If you're a developer, a technically curious photographer, or someone at Hasselblad reading this, this is the detail behind the conclusions.
A note on support: This post represents my personal exploration and testing, not official technical support or guidance from Hasselblad. If you need assistance with your Hasselblad equipment, please contact Hasselblad directly: customersupport@hasselblad.com for global support, support.us@hasselblad.com for the Americas, or visit hasselblad.com/support for regional options.
All testing was performed on Phocus 4.1.1 running on Apple Silicon (M4 Pro), macOS 26 Tahoe. Tested on both a Mac Mini Pro (48 GB) and MacBook Pro (48 GB).
Bug 1: Import Adjustment Dropdown Ignored
The claim
Selecting an HNCS preset (Nature, Portrait, etc.) in the Import dialog's Adjustment dropdown has no effect. Images always import with the default rendering baseline.
The evidence
Visual confirmation: I imported a single .3FR with Nature selected. Post-import, the Adjustments toolbar showed "3FR" (raw baseline). Manually switching to Nature after import produced a visible histogram shift. Switching to Standard produced a histogram identical to the post-import state. The rendering after import matched Standard, not Nature, regardless of what was selected in the Import dialog.
Log analysis: Phocus writes to ~/Library/Application Support/Phocus/Logs/Phocus.log. During the import, the log recorded:
14:30:04.914 Profile .dfR: not found
14:30:04.915 Profile .dfC: not found
14:30:04.915 Profile .dfG: not found
14:30:04.919 [Pyramid] Adding new pyramid task for /Users/.../2025-11-11_01_00706.3FR<Dust_No><Scene_403_273913372>...
14:30:04.919 [Pyramid] Now processing ...
14:30:05.142 ProcessLowResEntry: ... got buffer success
14:30:05.461 ProcessLowResEntry: ... Laplacian success
14:30:05.461 ProcessLowResEntry: ... complete with pyramid 1
The log records pyramid generation (thumbnail building), dust profile lookups, and scene parameters. There is no entry for the Adjustment selection. No "Applying Nature preset" or equivalent. The import code path does not appear to read the dropdown value.
The <Scene_403_273913372> tag in the pyramid task is notable. The 403 is the HNCS scene identifier (Standard). If Nature had been applied, this value would differ. It matches the Standard baseline, confirming the preset was not applied at import time.
Probable cause
The Import dialog's Adjustment dropdown is wired to the UI but not connected to the import code path. The dropdown state is never read when the import executes.
Bug 2: Synchronous File I/O on Main Thread Causes UI Hang
The claim
Phocus performs synchronous filesystem operations on the main thread inside FSEvents callbacks, causing unbounded UI freezes when file I/O is slow.
The evidence
macOS captured a stackshot during a hang that lasted approximately 10 minutes before I force-quit the application. The stackshot sampled 26 frames at 100ms intervals. The main thread was blocked on the same call in every single sample:
Main thread (com.apple.main-thread), all 26 samples:
NSApplication run
→ CFRunLoop
→ __CFRunLoopDoSource1
→ __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__
→ __CFMachPortPerform
→ FSEventsClientProcessMessageCallback
→ FSEventsD2F_server
→ _Xcallback_rpc
→ implementation_callback_rpc
→ [Phocus + 1384972]
→ [Phocus + 1413808]
→ [Phocus + 1411532]
→ [Phocus + 1410228]
→ [Phocus + 1410068]
→ NSFileManager.attributesOfItem(atPath:)
→ String.withFileSystemRepresentation
→ _FileManagerImpl.attributesOfItem(atPath:)
→ listxattr ← BLOCKED
The chain is:
- FSEvents delivers a filesystem change notification to Phocus via a Mach port callback on the main run loop
- Phocus's internal handler (four frames of Phocus code without symbols) processes the notification
- The handler calls
NSFileManager.attributesOfItem(atPath:)synchronously attributesOfItemcallslistxattrto enumerate extended attributeslistxattrblocks in the kernel, waiting for the filesystem to respond- The main thread cannot return to the run loop until
listxattrcompletes
Key metrics from the hang report:
- Process: Phocus 4.1.1, PID 64327, arm64
- Memory footprint: 9005.65 MB (not memory-related, 48 GB available)
- Active threads: 60 (only the main thread was blocked)
- Time since fork: 908 seconds (normal session, ~15 minutes)
- Duration unresponsive before sampling: 8 seconds
- Sampling duration: 2.6 seconds (26 samples × 100ms)
- Actual hang duration: ~10 minutes (required force-quit)
- Fan speed: 0 rpm (not thermally throttled)
The anti-pattern
FSEvents notifications arrive on whatever thread the run loop is servicing. In Phocus's case, this is the main thread. The correct pattern on macOS is:
- Receive the FSEvents notification
- Dispatch any file I/O to a background
DispatchQueue - Post results back to the main thread only for UI updates
Apple's documentation explicitly recommends this. NSFileManager.attributesOfItem(atPath:) can block for an unbounded duration depending on the underlying filesystem. On local APFS/SSD, latency is sub-millisecond. On any slower medium (external USB, network mount, or a filesystem under kernel contention), the main thread blocks for the full duration of the I/O.
The fix
Move the attributesOfItem(atPath:) call (and any other file I/O in the FSEvents handler) to a background dispatch queue. The existing Phocus code at offsets +1384972 through +1410068 should dispatch to DispatchQueue.global() or a dedicated serial queue before performing file attribute reads.
Bug 3: Tool Visibility Preferences Written from Stale Memory
The claim
Phocus maintains two separate in-memory representations of tool visibility state. The quit-time preferences save reads from a stale, initialization-time copy rather than the live copy that tracks user changes.
The investigation
I couldn't reproduce this on demand, so I built automated monitoring. A LaunchAgent watches ~/Library/Preferences/dk.hasselblad.phocus.plist via FSEvents and captures a timestamped snapshot every time the file is modified. I deployed this on two machines and let it run continuously.
Tool visibility is stored in the plist inside the LastLayout key, which is a base64-encoded XML blob. Within that blob, the ToolsForAdjust array contains integer identifiers for each visible tool.
The overnight capture
On the MacBook Pro, I launched Phocus at 20:28, customized tools during the session, and closed the laptop lid at 21:21. Nine hours later (06:06), I opened the lid and immediately pressed Cmd+Q.
The plist monitor captured two writes within 12 seconds of termination. The second write (06:07:08) was the reversion: the ToolsForAdjust array no longer matched my customizations. Tools I had hidden reappeared. Tools I had made visible were written back as hidden.
I correlated the exact sequence using log show (macOS unified log):
06:06:20 Lid open
06:06:22 Authentication
06:06:55 Cmd+Q keystroke delivered to Phocus
06:06:56 Termination
06:07:08 Plist reversion write
Phocus had been idle in memory for 8 hours 46 minutes.
The controlled experiment
I then ran 6 sequential plist snapshots to isolate the mechanism:
| Snap | Action | ToolsForAdjust count | Result |
|---|---|---|---|
| 1 | Phocus running (reverted state from morning) | 15 | Baseline |
| 2 | Cmd+Q, zero tool changes made | 19 | Array changed despite no user interaction |
| 3 | Relaunched | 19 | Unchanged from snap 2 |
| 4 | Removed 4 tools via UI, Phocus still running | 19 | Nothing written to disk |
| 5 | Cmd+Q | 15 | Removals correctly reflected |
| 6 | Relaunched | 15 | Changes persisted |
Snap 1 → 2 is the critical result. The user made zero changes to tool visibility. Yet Cmd+Q wrote a ToolsForAdjust array with 19 items (was 15 on disk), with different tools in different visibility states. The quit-time save wrote data that matched neither the on-disk state nor the user's current UI state.
Snap 3 → 4 proves that unchecking a tool in the Adjust panel writes nothing to disk. Not to the LastLayout blob, not to the top-level tool_rem_* keys, not to Layouts.xml, not to cfprefsd, not to any file in ~/Library/Application Support/Phocus/. The change exists only in process memory.
The tool_rem_* keys (e.g., tool_rem_3, tool_rem_7, tool_rem_10) are byte-for-byte identical across all 6 snapshots. The tool visibility UI does not write to these keys. They appear to be remnants of an older storage mechanism.
Root cause (behavioral conclusion)
Phocus maintains two separate representations of tool visibility in memory:
- A mutable copy that tracks the user's live changes (what the UI displays)
- An immutable copy that is frozen at initialization time
The quit-time layout save reads from the immutable copy. This explains:
- Short-cycle quits work: Both copies are still similar because insufficient time has passed for divergence
- Long idle fails: The immutable copy represents initialization state, which increasingly differs from user configuration
- Zero-interaction writes produce different data (snap 1→2): The immutable copy may differ from what was on disk because Phocus constructs it independently at launch
- It's not a "settings not saving" problem: The save mechanism works correctly. It reads from the wrong source.
Additional anti-pattern: write-on-quit
Beyond the dual-copy issue, Phocus only writes preferences at application termination. The standard macOS pattern is NSUserDefaults, which persists changes within seconds of a set() call via the cfprefsd daemon. Writing only on quit means any crash, force-quit, or unexpected termination loses all preference changes made during the session. Combined with Bug #2 (UI hangs requiring force-quit), this creates a compounding failure mode.
The fix
Two changes needed:
- The quit-time layout save must read from the mutable copy (the one tracking live user changes), not the initialization-time copy
- Preference writes should happen when changes are made (via
NSUserDefaultsor equivalent), not only at application termination
Where to look
The code path that writes ToolsForAdjust into the LastLayout blob at applicationWillTerminate:. The function that gathers tool visibility state before serialization is reading from the wrong object.
Bug 4: "Use Last Saved Adjustments" Architecture
The claim
The "Use Last Saved Adjustments" button operates from a transient in-memory buffer that is destroyed on commit, not from the .phos sidecar. The button name is the opposite of its behavior.
The architecture
Phocus's adjustment lifecycle has three states:
- Uncommitted: Adjustments exist only in memory. The .phos sidecar has not been updated.
- Committed: Adjustments have been written to the .phos sidecar (by Save Changes or auto-save).
- Buffered: A copy of the last uncommitted adjustments persists in a transient buffer after commit.
The "Use Last Saved Adjustments" button reads from state 3, the transient buffer. This buffer is populated when a commit event fires (auto-save on navigation, or clicking Save Changes). It contains the adjustments that were just committed.
The buffer is destroyed by the next commit event. This means:
- Edit Image A → navigate to B (auto-save fires on A, buffer populated) → click "Use Last Saved Adjustments" on B → A's adjustments apply. Works.
- Continue to Image C without saving B → click "Use Last Saved Adjustments" on C → A's adjustments apply. Works (buffer survives because B hasn't been committed).
- Click Save Changes on B → buffer destroyed → navigate to C → button grayed out. Gone permanently.
- Return to Image A (whose adjustments are fully recorded in its .phos sidecar) → button still grayed out. Cannot re-populate from sidecar.
The naming problem
The button is labeled "Use Last Saved Adjustments" but:
- It does NOT read from saved (committed) data in the .phos sidecar
- It ONLY works with the transient buffer created during the save operation
- The act of saving (the next save, not the one that created the buffer) is precisely what destroys the buffer
A more accurate name would be "Paste Last Adjustments" or "Apply Buffered Adjustments."
Comparison with other RAW processors
Every other major RAW processor implements adjustment transfer as a read from persisted state:
- Capture One: Select source image → Copy Adjustments → select target → Apply Adjustments. Works at any time, regardless of save state.
- Lightroom: Select source → Copy Settings → select target → Paste Settings. The source image's develop settings are always available.
- DxO PhotoLab: Select source → Copy Correction Settings → paste to targets. Reads from the database.
These implementations read from the committed/persisted adjustment data, not from a transient buffer. The adjustment transfer is decoupled from the save lifecycle.
The fix
"Use Last Saved Adjustments" should read from the .phos sidecar of the previously active image, not from a transient in-memory buffer. If an image has committed adjustments (which it always does after the first auto-save), the button should be able to read and apply them regardless of when the commit occurred.
Workaround
Use Modify instead. Select the source image plus all target images in the browser, click Modify, and check the tool groups you want to transfer. Modify reads from the source image's current state (committed or not) and applies it to all selected targets. The limitation is that you must know all target images upfront.
The Tech Behind the Frame Newsletter
Join the newsletter to receive the latest updates in your inbox.