Photo by Possessed Photography / Unsplash

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.

Konrad Michels
Konrad Michels

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.


If you'd like to support this documentation project: ☕ Buy me a coffee

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:

  1. FSEvents delivers a filesystem change notification to Phocus via a Mach port callback on the main run loop
  2. Phocus's internal handler (four frames of Phocus code without symbols) processes the notification
  3. The handler calls NSFileManager.attributesOfItem(atPath:) synchronously
  4. attributesOfItem calls listxattr to enumerate extended attributes
  5. listxattr blocks in the kernel, waiting for the filesystem to respond
  6. The main thread cannot return to the run loop until listxattr completes

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:

  1. Receive the FSEvents notification
  2. Dispatch any file I/O to a background DispatchQueue
  3. 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:

  1. A mutable copy that tracks the user's live changes (what the UI displays)
  2. 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:

  1. The quit-time layout save must read from the mutable copy (the one tracking live user changes), not the initialization-time copy
  2. Preference writes should happen when changes are made (via NSUserDefaults or 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:

  1. Uncommitted: Adjustments exist only in memory. The .phos sidecar has not been updated.
  2. Committed: Adjustments have been written to the .phos sidecar (by Save Changes or auto-save).
  3. 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.


Bug ReportHasselbladphocus

Comments