Skip to content

Divider drags are sessions; imposed splits stop fighting foreign resizes - #179

Merged
azooz2003-bit merged 10 commits into
manaflow-ai:mainfrom
ejc3:divider-drag-sessions-v2
Jul 16, 2026
Merged

azooz2003-bit merged 10 commits into
manaflow-ai:mainfrom
ejc3:divider-drag-sessions-v2

Conversation

@ejc3

@ejc3 ejc3 commented Jul 13, 2026 •

Copy link
Copy Markdown

Status: a live fuzz marathon runs against a cmux build carrying this branch; the PR is open for review in parallel rather than held for it. Results land in a comment when the run finishes.

When a host imposes exact pane sizes (the setImposedFirstExtent API from #176) while users can also drag dividers, two pieces of divider behavior turned out to be built on guesses, and both guesses failed in a host under real use.

Drag detection guessed from event coincidence. The delegate decided "the user is dragging" by checking which event happened to be current inside a resize callback, and detected the release only when a resize callback happened to arrive while the mouseUp was still the current event. Drag a divider, hold it still, release: no resize coincides with the mouseUp, no drag end is ever reported, and a host that syncs the final position elsewhere (cmux sends it to a remote tmux) never hears about the drag. The reverse guess was worse: code treated "this split has an imposed extent" as proof it was not being dragged, so a host that re-imposed on its own schedule mid-drag silently discarded the user's gesture.

Dragging is now a session with a hard start and end taken from the mouse lifecycle itself: any mouseDown that reaches the split view begins it (content clicks never do — pane content swallows them), and the end fires when AppKit's own tracking loop returns at release — it cannot miss a drag-pause-release, and a session bracketing a non-drag click is a harmless no-op pair. The session deliberately does not re-derive the divider hit rect: AppKit unions our expanded rect with its own proposal, so any reconstruction would miss exactly the edge cases users grab, like a divider parked against a pane at its minimum size. BonsplitController.isDividerDragActive exposes the session so hosts can hold their own sizing while the user's hand is on the divider, splitTabBarDividerDragDidBegin/End notify the delegate, and noteDividerDragSession lets hosts with custom drag surfaces — and tests — bracket a session themselves. With overlapping sessions the delegate hears only the zero crossings: ending one session while another still owns the divider does not announce the drag over.

Imposed splits fought every resize they didn't initiate. When frames moved under an imposed split — a window resize, a parent divider landing — the resize callback re-asserted the stored extent synchronously, inside the very layout pass that had moved the frames. Each re-assert ran another layout pass, which moved more frames, recursively across every mounted split; a main-thread sample during the resulting beach-ball showed 100% of the time in that fight. The update path did the same whenever the divider sat off its last recorded outcome.

The rule now: an imposed split re-applies its extent only when the host imposes again (each call bumps an epoch), or when the divider moved while the split view itself did NOT change size — that is drift with nobody else coming to correct it, and one apply settles it, since applying cannot resize the split view. When the split view's size DID change, the old extent is not re-fought during the resize — but the split is not left parked either: each observed size change arms exactly one deferred apply that runs a turn after the resize's layout pass, because a host whose per-pane ideals are container-independent will re-impose the same number and only when its own inputs change, so nothing else would ever put a parked divider back. An apply never terminates off-target without a re-arm edge. setImposedFirstExtent now documents that contract. One addition to the budgeted retry: when a refused parent apply finally lands a turn later, it resizes everything nested inside after those splits already applied their extents — the retry now asks each nested imposed split to apply again against its settled container.

That rule has one subtle consequence: a container resize proportionally rescales dividers, so an imposed split drifts off its extent, and the host re-imposes afterward — but the fresh plan often computes the SAME extent, because per-pane ideals do not depend on the container. Deduplicating that call by value means nothing ever puts the divider back, and since settled inputs produce identical plans forever, the drift persists through every reconfirm; the visible symptom is a pane sitting one terminal row short of its assignment indefinitely. Every explicit call therefore re-arms one bounded apply, including a repeat of the stored extent; a repeat while the divider is already at target refreshes memos without a layout pass, so the re-arm cannot churn.

The same ownership rule covers every path that can move an imposed divider, enforced at the apply layer rather than per API: while any drag session owns a divider in the tree, an imposed apply — whether from setImposedFirstExtent (identical value or not), a drift renudge, a descendant renudge, a deferred apply, or retryImposedFirstExtent — refuses outright, consuming no retry budget and recording no bookkeeping, so nothing can move the divider under the pointer. The pending extent stays armed: when the session count returns to zero, each still-imposed split gets one deferred apply, and an interrupted retry chain resumes with its remaining budget. Drift renudges with no session active are untouched.

The drag-end geometry report is also guaranteed delivery: it is issued from the session counter's zero crossing with an explicit bypass of the external-update suppression window (that gate otherwise swallows a notification within ~50ms of a remote layout apply — a quick flick-drag released inside the window would end with dragDidEnd and no final geometry, breaking the documented order). All other callers keep the anti-echo gate, and host-bracketed sessions get the same delivery.

DEBUG logging follows the new shape: session begin/end, and each imposed apply with its target, outcome, and the container size it applied in.

Supersedes #178 — same change rebased onto current main after the imposed-extent follow-up work landed there.

Summary by CodeRabbit

  • New Features
    • Added split tab bar divider-drag lifecycle callbacks (begin/end).
    • Added controller APIs to expose divider-drag activity and manually bracket drag sessions.
    • Added support for an external sink for debug event logging.
  • Bug Fixes
    • Improved geometry-change delivery when divider dragging ends, including forced updates when needed.
    • Refined imposed divider extent re-application across resizes, including correct handling of nested imposed extents and retry/drag-session interruptions.
    • Updated imposed-extent “re-arming” behavior to avoid stale reassertions after resize layouts.
  • Tests
    • Added UI/layout sync tests covering divider-drag ordering and imposed extent behavior across resize and active-drag scenarios.

ejc3 added 3 commits July 13, 2026 08:54
Drag start and end come from the divider's mouse-tracking lifecycle
(mouseDown brackets AppKit's tracking loop), not from which event happens
to be current inside a resize callback — the old inference missed any
drag that paused before release. BonsplitController.isDividerDragActive
and the dragDidBegin/End delegate calls expose the session to hosts.

An imposed split re-applies its extent only on a fresh imposition, or
when the divider moved while the split view's size did not — putting a
drifted divider back where nothing else will. Re-asserting against a
resized container fought AppKit from inside its own layout pass,
recursively; a main-thread sample showed the whole thread in that fight.
When a refused parent apply lands on a later retry, nested imposed
splits re-apply against their settled containers.
…any divider mouseDown

With overlapping sessions (a host-bracketed custom drag alongside the
built-in tracking), ending one must not announce the drag over while the
other still owns the divider. And the session hit test cannot faithfully
reconstruct the effective divider rect AppKit tracks (our expansion
unioned with AppKit's own proposal), so any mouseDown that reaches the
split view — content clicks never do — brackets a session; a session
around a non-drag click is a harmless no-op pair. setImposedFirstExtent
now documents that a stored extent is not re-asserted across container
resizes: the host imposes fresh values for new sizes.
retryImposedFirstExtent re-asserts a stored extent on host recovery
triggers (rebuilds, tab re-shows, reconnects). Fired mid-drag it would
move the divider under the pointer, so it now returns false while any
drag session is active for the tree; the host's drag-end sync imposes
fresh values once the session closes.

Also reconciles the drag-session line with the imposed-first-extent
rework it now sits on: renudgeImposedDescendants uses the renamed
syncDividerNow hook, and reapplyDividerForThicknessChange records the
epoch/outcome/avail memo instead of the removed lastImposedTarget.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Divider drag sessions now propagate deterministically through AppKit views, coordinators, controllers, and delegates. Imposed extent synchronization accounts for active drags, resize state, retry epochs, nested splits, and forced geometry notifications. Debug logging can also route entries to an external sink.

Changes

Divider interaction and imposed synchronization

Layer / File(s) Summary
Divider drag lifecycle propagation
Sources/Bonsplit/Public/BonsplitDelegate.swift, Sources/Bonsplit/Internal/Controllers/SplitViewController.swift, Sources/Bonsplit/Internal/Views/SplitContainerView.swift, Sources/Bonsplit/Public/BonsplitController.swift
Divider tracking brackets drag sessions, updates internal state, exposes lifecycle APIs, and forwards delegate callbacks with final geometry delivery.
Imposed extent synchronization
Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Imposed synchronization now uses achieved outcomes, available size, epochs, drag state, nested descendant renudging, and retry resumption.
Imposed extent retry contract
Sources/Bonsplit/Public/BonsplitController.swift
Explicit unchanged extents re-arm bounded application, retries are blocked during active drags, and resize behavior is documented.
Divider synchronization validation
Tests/BonsplitTests/BonsplitTests.swift
Tests verify resize re-imposition, drag-time deferral, and geometry-before-drag-end event ordering.

Debug log routing

Layer / File(s) Summary
External sink and persistent file append
Sources/Bonsplit/Public/DebugEventLog.swift
DebugEventLog can send raw entries to an external sink, otherwise appends through a persistent handle, and avoids file dumps while the sink owns output.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant ThemedSplitView
  participant SplitContainerCoordinator
  participant SplitViewController
  participant BonsplitController
  participant BonsplitDelegate

  ThemedSplitView->>SplitContainerCoordinator: Begin divider drag session
  SplitContainerCoordinator->>SplitViewController: Record active session
  SplitViewController->>BonsplitController: Forward session state
  BonsplitController->>BonsplitDelegate: Notify drag began
  ThemedSplitView->>SplitContainerCoordinator: End divider drag session
  SplitContainerCoordinator->>SplitViewController: Record inactive session
  SplitViewController->>BonsplitController: Force final geometry notification
  BonsplitController->>BonsplitDelegate: Notify drag ended
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is specific and matches the main changes: divider-drag sessions and imposed split behavior during resizes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ejc3
ejc3 marked this pull request as ready for review July 13, 2026 16:29
@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes divider dragging an explicit session and changes how imposed pane extents react to layout changes. The main changes are:

  • Bracket divider drags with mouse lifecycle events.
  • Expose drag state and begin/end delegate callbacks.
  • Avoid reapplying stale imposed extents during container resizes.
  • Reconcile nested imposed splits after delayed parent retries.

Confidence Score: 4/5

The drag-end callback ordering needs to be fixed before merging.

  • splitTabBarDividerDragDidEnd runs before the final didChangeGeometry callback.
  • This contradicts the public delegate contract and can make hosts commit geometry too early.
  • The imposed-extent retry paths remain bounded and avoid synchronous layout recursion.

Sources/Bonsplit/Internal/Views/SplitContainerView.swift and Sources/Bonsplit/Public/BonsplitDelegate.swift

Important Files Changed

Filename Overview
Sources/Bonsplit/Internal/Controllers/SplitViewController.swift Adds tree-wide reference-counted divider drag sessions and zero-crossing notifications.
Sources/Bonsplit/Internal/Views/SplitContainerView.swift Adds mouse-lifecycle drag sessions and revised imposed-extent reconciliation, but sends drag-end before final geometry notification.
Sources/Bonsplit/Public/BonsplitController.swift Exposes drag state and custom session bracketing, and blocks imposed retries during active drags.
Sources/Bonsplit/Public/BonsplitDelegate.swift Adds optional drag begin/end callbacks whose documented geometry ordering is not met by the internal callback sequence.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant SplitView as ThemedSplitView
    participant Controller as SplitViewController
    participant Coordinator
    participant Host as BonsplitDelegate

    User->>SplitView: mouseDown
    SplitView->>Coordinator: sessionChanged(true)
    SplitView->>Controller: noteSession(true)
    Controller->>Host: dragDidBegin
    User->>SplitView: mouseUp
    SplitView->>Controller: noteSession(false)
    Controller->>Host: dragDidEnd
    SplitView->>Coordinator: sessionChanged(false)
    Coordinator->>Host: didChangeGeometry
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant SplitView as ThemedSplitView
    participant Controller as SplitViewController
    participant Coordinator
    participant Host as BonsplitDelegate

    User->>SplitView: mouseDown
    SplitView->>Coordinator: sessionChanged(true)
    SplitView->>Controller: noteSession(true)
    Controller->>Host: dragDidBegin
    User->>SplitView: mouseUp
    SplitView->>Controller: noteSession(false)
    Controller->>Host: dragDidEnd
    SplitView->>Coordinator: sessionChanged(false)
    Coordinator->>Host: didChangeGeometry
Loading

Reviews (1): Last reviewed commit: "Refuse imposed-extent retries while a dr..." | Re-trigger Greptile

Comment thread Sources/Bonsplit/Internal/Views/SplitContainerView.swift
ejc3 added 2 commits July 13, 2026 09:35
On session end the controller notified the delegate first, so drag-end
arrived before the coordinator's final didChangeGeometry — inverting the
documented contract that the settled geometry has already been reported
when drag-end runs. A host that buffers geometry callbacks and commits
at drag-end would commit pre-drag state. The coordinator now leads on
both edges of the session; hosts that suppress geometry callbacks while
the session is live lose nothing, since the model is final at release
and drag-end is the signal to read it.
…tent

A container resize proportionally rescales dividers, so an imposed split
can drift off its extent. The host re-imposes after the resize, but the
fresh plan often computes the same extent as before — per-pane ideals do
not depend on the container — and setImposedFirstExtent deduped that
call by value, bumping no epoch. syncPosition then saw an unchanged
epoch with a changed container and, per the one-writer rule, refused to
re-apply: the divider stayed wedged at the drifted position until some
input happened to change the number. Live signature: a settled pane one
row short of its tmux assignment, persisting through reconfirm.

Every explicit non-nil call now bumps the epoch, matching syncPosition's
documented 'bounded by explicit calls' contract. A repeat while the
divider is already at target is memo-only (no layout pass), so the
re-arm cannot churn.

Regression: testReimposingSameExtentAfterContainerResizeRetargetsExactly
imposes 120 in 400pt, shrinks to 300 (drift to 90), re-imposes the same
120 — red without the fix, green with it.
@ejc3

ejc3 commented Jul 13, 2026

Copy link
Copy Markdown
Author

Pushed a7c9a81: the live fuzz run caught an imposed split settling one terminal row short of its assignment and staying there. Root cause: a container resize drifts the divider proportionally, the host re-imposes, but the freshly planned extent is often identical (per-pane ideals do not depend on the container) and setImposedFirstExtent deduped the call by value — no epoch bump, no re-apply, wedged until some input changed the number. Every explicit non-nil call now re-arms one bounded apply; a repeat with the divider already at target is memo-only, so no churn. Regression test drives impose → proportional drift → identical re-impose (red without the fix). Suite 192/192.

ejc3 added 2 commits July 13, 2026 12:03
The public retryImposedFirstExtent already refused during a drag
session, but the internal apply paths did not: a mid-drag
setImposedFirstExtent (or a drift renudge, a descendant renudge, or a
deferred syncDividerNow apply) still ran its main-queue apply during
AppKit's tracking loop and yanked the divider out from under the
pointer, re-marking the split imposed against the drag's own clear.

Gate the apply at its funnel instead of per API. Every imposed apply
runs through the coordinator's syncPosition imposed branch or its
deferred retry, so those two points now consult the tree's live drag
session count and refuse outright: no divider write, no memo or epoch
bookkeeping, no retry budget consumed. The pending extent stays armed.

When the session count crosses back to zero, the internal controller
gives every still-imposed split one deferred apply. There is no epoch
bump, so a split already at its target only refreshes its memos, and
an interrupted retry chain resumes from the same sync with the budget
it had left. Drift renudges with no session active keep working as
before.
The guaranteed final notification funneled through
notifyGeometryChange, whose first line drops everything within ~50ms
of a fromExternal update. A quick flick released inside that window
lost the one notification the delegate contract promises has already
been delivered when dragDidEnd runs.

Deliver it from the session counter's zero crossing in the controller
instead, with an explicit force that bypasses only the suppression
window and only for this path; every other caller keeps the gate. The
zero crossing also covers host-bracketed sessions via
noteDividerDragSession, which never ran the coordinator's drag-end
path at all, and it still fires before splitTabBarDividerDragDidEnd,
so the ordering the contract promises is unchanged. The coordinator's
session-end callback now only clears its drag flag.
@ejc3

ejc3 commented Jul 13, 2026

Copy link
Copy Markdown
Author

Pushed two commits hardening the session contract. d0a6c15 moves the drag refusal to the apply layer: every imposed apply (setImposedFirstExtent including value-identical calls, drift renudges, descendant renudges, deferred applies, retryImposedFirstExtent) refuses while any drag session owns a divider — no divider write, no bookkeeping, no budget consumed — and the pending extent stays armed; the session's zero crossing gives each still-imposed split one deferred apply and resumes interrupted retry chains. 3273b80 guarantees the drag-end geometry report: it is delivered from the zero crossing with an explicit bypass of the external-update suppression window, which otherwise swallowed the final notification for a flick-drag released within ~50ms of a remote layout apply; all other callers keep the anti-echo gate. Both landed red-first (mid-session impose visibly yanked the divider 199.5→120 before the fix; the suppression test saw dragEnded with no geometry). Suite: 194 tests, 0 failures.

…ider

A window resize rescales an imposed split proportionally, moving the
divider off its extent. Since the re-apply condition became
`renudged || (moved && availUnchanged)`, that drift was left for the
host to fix: the theory was that a resized container always gets a
fresh imposition. But a host whose per-pane ideals are
container-independent computes the SAME extent for any container size
and only re-imposes when its own inputs change. If the epoch-bumped
applies from the last imposition all expired against not-yet-final
bounds (retry budget spent while a concurrent window resize was still
committing), the park was permanent — a deterministic fuzz repro held
the divider at the proportional position (plan 1199pt vs view 984pt)
for 50+ seconds until an unrelated input.

Give the parked divider one deferred apply of its own instead of
waiting for the host. Both places that observe the size change — the
didResize callback and syncPosition's imposed branch — record the new
available size and arm a single retry against it. Recording the size
immediately bounds this to one re-arm per size change, the apply runs
a runloop turn later so it never fights AppKit from inside the
resize's own layout pass, and applying cannot resize the split view,
so it cannot re-trigger itself. A mid-drag retry still refuses without
consuming its budget; the session-end renudge resumes the chain. The
contract, now stated in the doc comments: an apply never terminates
off-target without re-arming on the next size change.

The new regression test imposes an extent, resizes the window with no
further imposition and no drag session, and asserts the first pane
returns to the extent; it fails without the fix (divider stays at
192pt instead of 120pt). The existing same-extent re-imposition test
asserted the drift persisted until the host's call — updated to the
new contract.
@ejc3

ejc3 commented Jul 13, 2026

Copy link
Copy Markdown
Author

Pushed 6568da9, root-caused from a deterministic fuzz repro. When the window resized under an unchanged imposed extent, the divider was left at AppKit's proportional position on the theory the host would impose a fresh value for the new size — but a host that computes per-pane ideals independently of the container re-imposes the same number, and only when its own inputs change. If the retry budget from the last imposition had expired against not-yet-final bounds (SwiftUI still committing a concurrent window resize), nothing ever moved the divider again: plan 1199pt, view 984pt, for 50+ seconds until an unrelated input. The fix gives a parked divider one deferred apply of its own when the container's size changes — both observation points record the new available size (one re-arm per size change) and arm a single retry that runs a turn after the resize's layout pass. Mid-drag it still refuses and resumes at session end. New regression test resizes the window with no fresh imposition and asserts the pane returns to its extent (red without the fix); one prior test had pinned the parked behavior as correct and now asserts the self-heal. Suite 195/195.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Tests/BonsplitTests/BonsplitTests.swift`:
- Around line 3535-3539: Update the affected Bonsplit tests around the drift and
re-application scenarios to first assert that AppKit produces an observed
off-target divider state, then separately verify the explicit re-arm or deferred
apply restores the imposed extent. For the identical re-imposition case, move
verification before the self-healing loop or use the existing internal
epoch/apply-count hook so the test proves a new application was accepted rather
than merely observing the loop’s final correction.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ff5da075-b6d1-43ab-898a-42cb9363d6c2

📥 Commits

Reviewing files that changed from the base of the PR and between 3273b80 and 6568da9.

📒 Files selected for processing (3)
  • Sources/Bonsplit/Internal/Views/SplitContainerView.swift
  • Sources/Bonsplit/Public/BonsplitController.swift
  • Tests/BonsplitTests/BonsplitTests.swift
🚧 Files skipped from review as they are similar to previous changes (2)
  • Sources/Bonsplit/Public/BonsplitController.swift
  • Sources/Bonsplit/Internal/Views/SplitContainerView.swift

Comment thread Tests/BonsplitTests/BonsplitTests.swift Outdated
ejc3 added 2 commits July 13, 2026 16:16
Both container-resize tests only checked the final divider width, so
they would pass even if AppKit never moved the divider off the imposed
extent. Capture the divider synchronously after setContentSize, before
any runloop turn — the heal is deferred a turn, so the proportional
drift (192pt for the 400 to 640 grow, under 116pt for the shrink) is
observable there — and only then pump and assert the recovery.

The same-extent re-imposition test also lost its teeth: the size-change
re-arm already restores the extent before the re-impose call runs, so
the final assertion could no longer tell whether the call was accepted
or deduped by value. Isolate it by perturbing the divider at constant
container size with a direct AppKit setPosition (the imposition stays
stored and no avail changes, so the size-change re-arm cannot fire),
pumping with no impose call and asserting the divider stays put —
nothing heals it autonomously — then re-imposing the identical extent
and asserting the divider returns. The recovery is attributable to the
explicit call alone.
dlog appended to its log file by opening a FileHandle per line and doing
seekToEnd()+write. When another writer in the same process appends to the
same file, the two race: lines interleave, clobber each other, and land
out of timestamp order.

A host app can now install an external sink that receives every line, so
all appends can go through one serialized writer. Without a sink, appends
keep a single O_APPEND handle open instead of reopening per line, and
dump() steps aside entirely when a sink owns the file.
@ejc3

ejc3 commented Jul 14, 2026

Copy link
Copy Markdown
Author

Pushed 92093ae: hosts can take over debug log appends via setExternalSink. Motivation: bonsplit's dlog and the host's own debug logger were two independent per-line seek-and-write writers on one file, which interleaved fragments and reordered lines under load — during a live capture this dropped a session-begin line and sent an investigation down a false path. With a sink installed, all lines flow through the host's single sequenced writer; standalone bonsplit keeps one persistent O_APPEND handle instead of reopening per line.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
Sources/Bonsplit/Public/DebugEventLog.swift (2)

101-104: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Operational advice: write does not throw on file rotation.

The comment indicates an expectation that file rotation will cause write to throw. In POSIX file systems, rotating a file by moving or unlinking it (e.g., standard mv/rm) does not invalidate an existing open file descriptor. write will continue succeeding (writing to the unlinked inode) and will not throw an error, meaning appendHandle won't be automatically closed and reopened.

If you ever need to support standard external log rotation, you will typically need a file-coordination mechanism, a signal handler (e.g., SIGHUP), or to use copytruncate log rotation. The current catch block correctly and successfully recovers from actual IO errors (e.g., disk full, unmounted volumes) but won't detect standard log rotation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Bonsplit/Public/DebugEventLog.swift` around lines 101 - 104, Update
the comment in the write error handler near appendHandle to remove the incorrect
claim that file rotation causes write to throw; describe this block as recovery
from actual I/O errors while preserving the existing appendHandle reset
behavior.

29-34: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Close appendHandle when installing an external sink.

When an external sink takes over, the built-in file append is bypassed. Consider closing and releasing the appendHandle immediately to avoid holding onto an unused open file descriptor.

♻️ Proposed refactor
     public static func setExternalSink(_ sink: (`@Sendable` (String) -> Void)?) {
         let log = shared
         log.queue.async {
             log.externalSink = sink
+            if sink != nil {
+                try? log.appendHandle?.close()
+                log.appendHandle = nil
+            }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Bonsplit/Public/DebugEventLog.swift` around lines 29 - 34, Update
setExternalSink to close and release the existing appendHandle when installing a
non-nil external sink, before assigning externalSink. Keep the handle available
when removing the external sink so built-in file logging can continue as
intended.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@Sources/Bonsplit/Public/DebugEventLog.swift`:
- Around line 101-104: Update the comment in the write error handler near
appendHandle to remove the incorrect claim that file rotation causes write to
throw; describe this block as recovery from actual I/O errors while preserving
the existing appendHandle reset behavior.
- Around line 29-34: Update setExternalSink to close and release the existing
appendHandle when installing a non-nil external sink, before assigning
externalSink. Keep the handle available when removing the external sink so
built-in file logging can continue as intended.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b030c13e-34be-4ede-9ae3-d4cc83f47ad6

📥 Commits

Reviewing files that changed from the base of the PR and between 6568da9 and 92093ae.

📒 Files selected for processing (2)
  • Sources/Bonsplit/Public/DebugEventLog.swift
  • Tests/BonsplitTests/BonsplitTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Tests/BonsplitTests/BonsplitTests.swift

@azooz2003-bit
azooz2003-bit merged commit 1bf2578 into manaflow-ai:main Jul 16, 2026
4 checks passed
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.

2 participants