Skip to content

Commit df1f25b

Browse files
fix(045): remove broad catch{} swallows in tear-off pipeline (#420)
PR #418 (Spec 045 §2.6 VS-style immediate tab tear-off) landed with 14 broad catch{} blocks across the tear-off code paths. Most were dead-defensive: the ReactorWindow public mutators (Close, Hide, Activate, SetOpacity, SetNoActivate, SetIgnorePointerInput) are all no-op on _disposed and internally narrow the teardown-COMException set, so an outer catch swallowed nothing. Changes per issue #420 inventory: DockTabTearOff.cs (1 site) • PositionFloating tracker-tick catch removed. Audited Stop-before-Close ordering: every path that closes the floating window calls Stop() first (detaches Tick handler, clears s_active), so AppWindow.Move always targets a live AppWindow. DockHostNativeComponent.cs (4 sites → 0) • BeginImmediateTearOff: DockFloatingWindow.Open catch removed. Open failure is a bug we want to surface; layout mutation is deferred until after Open returns. • FinalizeImmediateDrop: two floatingWindow.Close catches removed. ReactorWindow.Close is idempotent; called once per branch. • FinalizeImmediateDrop drop-outside: strip-drag-styles catch removed. All four mutators are disposal-safe. DockFloatingWindow.cs (9 sites → 0, plus 1 latent bug fix) • BeginFloatingTearOff source-XamlRoot read: catch removed. • BeginFloatingTearOff Open call: catch removed. • Single-tab path: ownWindow.AppWindow.Hide() → ownWindow.Hide(). The bare AppWindow.Hide() bypassed ReactorWindow.Hide's _disposed guard and teardown-COMException catch; the wrapper has both. • Multi-tab path SetIgnorePointerInput(true) — latent bug fix. The call requires WS_EX_LAYERED. A regular floating window opens with Opacity=1.0 (not layered), so SetIgnorePointerInput(true) was throwing InvalidOperationException every multi-tab tear-off, silently swallowed — the source window was never actually marked click-through despite the comment claiming it was. Now nudge SetOpacity(0.9999) first to install WS_EX_LAYERED (alpha rounds to 255 → visually identical). • RestoreSourcePointerInput catch removed; also restores SetOpacity(1.0) to undo the layering nudge. • Two capturedDragged.Close catches (enqueued + sync fallback) removed. • Fallback xr ??= capturedDragged.NativeWindow?.Content?.XamlRoot catch removed (window was just opened synchronously). • RestoreWindowFromDrag four-setter catch removed (all disposal-safe). Three explicitly out-of-scope catches in DockTabTearOff.cs are preserved per the issue: • L191 TabView.CapturePointer (best-effort, can fail on stale pointer) • L256 TabView.ReleasePointerCaptures (no captures → no-op) • L502 active.CancelDrop in Tracker.ForceCancel (must not crash next drag) Verified: dotnet test Reactor.Tests on ARM64 — 9038 passed, 0 failed. The selftest fixtures (T07/T08 host tear-off, T11-T14 floating tear-off) check Spec.Opacity >= 0.99 on restore; the 0.9999 layering nudge plus the explicit SetOpacity(1.0) restore satisfy those assertions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 005ecae commit df1f25b

3 files changed

Lines changed: 122 additions & 86 deletions

File tree

src/Reactor/Docking/Native/DockFloatingWindow.cs

Lines changed: 80 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -542,9 +542,11 @@ void RemoveLocal(DockableContent pane)
542542
// Capture the source XamlRoot BEFORE RemoveLocal might close
543543
// ownWindow (single-tab case → its only pane is the one we're
544544
// tearing off, panes goes to empty, ownWindow.Close()).
545-
// Accessing it through a closed window's NativeWindow would NRE.
546-
XamlRoot? sourceXamlRoot = null;
547-
try { sourceXamlRoot = ownWindow.NativeWindow?.Content?.XamlRoot; } catch { }
545+
// ownWindow was just retrieved from holder[0] inside this
546+
// synchronous handler, so its Window/Content/XamlRoot are
547+
// alive; the `?.` chain handles the not-yet-mounted edge
548+
// case where Content is null.
549+
var sourceXamlRoot = ownWindow.NativeWindow?.Content?.XamlRoot;
548550

549551
// Whether this tear-off will leave ownWindow with no remaining
550552
// panes (→ RemoveLocal closes it). Drives the Z-order strategy
@@ -564,18 +566,19 @@ void RemoveLocal(DockableContent pane)
564566
var initialTopLeft = (
565567
(double)(req.CursorScreenPhys.X - req.PressOffsetPhys.X) / req.SourceScale,
566568
(double)(req.CursorScreenPhys.Y - req.PressOffsetPhys.Y) / req.SourceScale);
567-
ReactorWindow draggedWindow;
568-
try
569-
{
570-
draggedWindow = DockFloatingWindow.Open(
571-
pane,
572-
manager: manager,
573-
opacity: 0.5,
574-
noActivate: true,
575-
ignorePointerInput: true,
576-
initialPosition: initialTopLeft);
577-
}
578-
catch { return null; }
569+
// DockFloatingWindow.Open is a normal-code-flow call: its
570+
// failure modes (WinUI window-creation refusal, pre-condition
571+
// violations) are bugs we want to surface, not swallow. The
572+
// layout mutation (RemoveLocal) and session.Begin happen
573+
// AFTER Open returns, so a throw here leaves source state
574+
// intact.
575+
var draggedWindow = DockFloatingWindow.Open(
576+
pane,
577+
manager: manager,
578+
opacity: 0.5,
579+
noActivate: true,
580+
ignorePointerInput: true,
581+
initialPosition: initialTopLeft);
579582
// CRITICAL: stop ownWindow from intercepting pointer events
580583
// before the preview opens. ownWindow has foreground focus
581584
// (user just clicked a tab) and sits at the top of Z-order;
@@ -594,13 +597,39 @@ void RemoveLocal(DockableContent pane)
594597
// drag events; restored in confirm/cancel below.
595598
if (willClose)
596599
{
597-
try { ownWindow.AppWindow.Hide(); }
598-
catch { /* window may already be tearing down */ }
600+
// ReactorWindow.Hide() wraps _appWindow.Hide() with the
601+
// _disposed guard + teardown-reentry COMException catch
602+
// (the bare ownWindow.AppWindow.Hide() bypass below
603+
// didn't have either). The previous outer catch was
604+
// hiding any throw path; using the wrapper makes the
605+
// behavior explicit and the catch redundant.
606+
ownWindow.Hide();
599607
}
600608
else
601609
{
602-
try { ownWindow.SetIgnorePointerInput(true); }
603-
catch { /* window may already be tearing down */ }
610+
// Multi-tab path: source window stays visible with its
611+
// remaining tabs. We mark it click-through so the host
612+
// overlays see the drag's pointer events.
613+
//
614+
// SetIgnorePointerInput requires WS_EX_LAYERED on the
615+
// underlying HWND (OS only honors transparent on layered
616+
// windows). A regular floating window opens with full
617+
// opacity → not layered → SetIgnorePointerInput(true)
618+
// would throw InvalidOperationException. Nudge opacity
619+
// to 0.9999 first to install WS_EX_LAYERED — the alpha
620+
// byte rounds to 255 so the user sees no visual change.
621+
// RestoreSourcePointerInput (below) reverses both.
622+
//
623+
// The previous code wrapped this whole block in a bare
624+
// catch and so the SetIgnorePointerInput call was being
625+
// silently swallowed by the InvalidOperationException
626+
// every multi-tab tear-off — the source window was NEVER
627+
// actually marked click-through. Pointer-pass-through
628+
// here is required behavior per §2.6, so the fix is to
629+
// make the call succeed, not to keep swallowing it.
630+
if (ownWindow.Spec.Opacity >= 1.0)
631+
ownWindow.SetOpacity(0.9999);
632+
ownWindow.SetIgnorePointerInput(true);
604633
}
605634
RemoveLocal(pane);
606635

@@ -617,24 +646,30 @@ void RestoreSourcePointerInput()
617646
{
618647
// Multi-tab case only — undo the SetIgnorePointerInput
619648
// toggle so the user can interact with the source window's
620-
// remaining tabs after the drag ends. No-op when the
621-
// source closed.
649+
// remaining tabs after the drag ends, and restore full
650+
// opacity (paired with the 0.9999 nudge above that
651+
// installed WS_EX_LAYERED). Both calls are no-op on a
652+
// disposed window, so the genuine external race (user
653+
// closed the source window mid-drag) is safe — no catch
654+
// needed.
622655
if (capturedSource is null) return;
623-
try { capturedSource.SetIgnorePointerInput(false); }
624-
catch { /* window may have been closed already */ }
656+
capturedSource.SetIgnorePointerInput(false);
657+
capturedSource.SetOpacity(1.0);
625658
}
626659
Action confirm = () =>
627660
{
628661
var target = DockTabTearOff.TryConfirmHoveredTargetFor(manager);
629662
if (target is not null)
630663
{
631664
RestoreSourcePointerInput();
665+
// ReactorWindow.Close is idempotent (no-op after
666+
// _disposed) and internally narrows the teardown
667+
// COMException set, so calling it once per branch —
668+
// enqueued OR sync fallback, never both — is safe
669+
// without an outer catch.
632670
var dq = global::Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
633-
bool ok = dq is not null && dq.TryEnqueue(() =>
634-
{
635-
try { capturedDragged.Close(); } catch { }
636-
});
637-
if (!ok) { try { capturedDragged.Close(); } catch { } }
671+
bool ok = dq is not null && dq.TryEnqueue(capturedDragged.Close);
672+
if (!ok) capturedDragged.Close();
638673
return;
639674
}
640675
// No target hit — drop-outside semantics: strip drag styles,
@@ -652,9 +687,10 @@ void RestoreSourcePointerInput()
652687

653688
// If we couldn't capture the source XamlRoot (rare race), fall
654689
// back to the dragged window's once it's mounted. Worst case
655-
// RasterizationScale is 1.0 default.
656-
var xr = sourceXamlRoot;
657-
try { xr ??= capturedDragged.NativeWindow?.Content?.XamlRoot; } catch { }
690+
// RasterizationScale is 1.0 default. capturedDragged was
691+
// opened synchronously above, so its NativeWindow chain is
692+
// live; the `?.` chain handles the not-yet-mounted Content.
693+
var xr = sourceXamlRoot ?? capturedDragged.NativeWindow?.Content?.XamlRoot;
658694

659695
return new DockTabTearOff.TearOffActive
660696
{
@@ -669,14 +705,19 @@ void RestoreSourcePointerInput()
669705

670706
static void RestoreWindowFromDrag(ReactorWindow w)
671707
{
672-
try
673-
{
674-
w.SetIgnorePointerInput(false);
675-
w.SetOpacity(1.0);
676-
w.SetNoActivate(false);
677-
w.Activate();
678-
}
679-
catch { /* window may have been closed already */ }
708+
// All four ReactorWindow mutators are no-op on _disposed and
709+
// internally narrow the teardown COMException set, so a
710+
// genuine external close mid-drag (e.g. user closed the
711+
// dragged preview through some other path) is safe without
712+
// an outer catch. The drop-outside contract says this window
713+
// stays open at the cursor's release position; if it's
714+
// actually closed by the time we get here, that's a bug we
715+
// want to surface rather than silently strip styles on a
716+
// dead handle.
717+
w.SetIgnorePointerInput(false);
718+
w.SetOpacity(1.0);
719+
w.SetNoActivate(false);
720+
w.Activate();
680721
}
681722

682723
var chrome = DockFloatingWindow.BuildChrome(

src/Reactor/Docking/Native/DockHostNativeComponent.cs

Lines changed: 26 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -475,23 +475,19 @@ void LogOp(
475475
(double)(req.CursorScreenPhys.X - req.PressOffsetPhys.X) / req.SourceScale,
476476
(double)(req.CursorScreenPhys.Y - req.PressOffsetPhys.Y) / req.SourceScale);
477477

478-
ReactorWindow floatingWindow;
479-
try
480-
{
481-
floatingWindow = DockFloatingWindow.Open(
482-
pane,
483-
manager: manager,
484-
opacity: 0.5,
485-
noActivate: true,
486-
ignorePointerInput: true,
487-
initialPosition: initialTopLeft);
488-
}
489-
catch
490-
{
491-
// Failure → no mutation should be visible. We didn't
492-
// StoreOverride yet so the layout is still intact.
493-
return null;
494-
}
478+
// DockFloatingWindow.Open is a normal-code-flow call: its
479+
// failure modes are bugs (WinUI window-creation refusal,
480+
// pre-condition violations) that we want to surface, not
481+
// swallow. The layout mutation (StoreOverride) is deferred
482+
// until after Open returns so a throw here leaves
483+
// effectiveLayout intact.
484+
var floatingWindow = DockFloatingWindow.Open(
485+
pane,
486+
manager: manager,
487+
opacity: 0.5,
488+
noActivate: true,
489+
ignorePointerInput: true,
490+
initialPosition: initialTopLeft);
495491

496492
StoreOverride(afterRemove);
497493
manager.OnContentFloated?.Invoke(new DockContentFloatedEventArgs { Content = pane });
@@ -576,30 +572,25 @@ void FinalizeImmediateDrop(ReactorWindow floatingWindow, DockableContent pane, b
576572
// breaks WinUI's dispatcher (see the spike doc's
577573
// "render-time cleanup" gotcha). One queued tick is
578574
// enough to let the host's pending re-render flush.
575+
// ReactorWindow.Close is idempotent (no-op after _disposed)
576+
// and internally swallows the COMException teardown-reentry
577+
// set, so calling it once per branch — enqueued OR sync
578+
// fallback, never both — is safe without an outer catch.
579579
var dq = global::Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
580-
bool enqueued = dq is not null && dq.TryEnqueue(() =>
581-
{
582-
try { floatingWindow.Close(); }
583-
catch { /* window may already be tearing down */ }
584-
});
585-
if (!enqueued)
586-
{
587-
try { floatingWindow.Close(); } catch { }
588-
}
580+
bool enqueued = dq is not null && dq.TryEnqueue(floatingWindow.Close);
581+
if (!enqueued) floatingWindow.Close();
589582
return;
590583
}
591584

592585
// No target hit (drop-outside / cancel). Strip the
593586
// drag-only window styles so the floating window becomes a
594-
// real, interactive floating pane.
595-
try
596-
{
597-
floatingWindow.SetIgnorePointerInput(false);
598-
floatingWindow.SetOpacity(1.0);
599-
floatingWindow.SetNoActivate(false);
600-
floatingWindow.Activate();
601-
}
602-
catch { }
587+
// real, interactive floating pane. All four ReactorWindow
588+
// mutators are no-op on _disposed and internally narrow the
589+
// teardown COMException set, so no outer catch is needed.
590+
floatingWindow.SetIgnorePointerInput(false);
591+
floatingWindow.SetOpacity(1.0);
592+
floatingWindow.SetNoActivate(false);
593+
floatingWindow.Activate();
603594

604595
session?.End();
605596
setDragActive(false);

src/Reactor/Docking/Native/DockTabTearOff.cs

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -547,18 +547,22 @@ private static void PositionFloating()
547547
var cursorPhys = DockTabTearOff.TryGetCursorPhys();
548548
var x = cursorPhys.X - active.OffsetPhys.X;
549549
var y = cursorPhys.Y - active.OffsetPhys.Y;
550-
try
551-
{
552-
// Bypass ReactorWindow.SetPosition's DIP→physical roundtrip
553-
// (which uses the window's _dpi — unreliable for a fresh
554-
// NoActivate window). Drive AppWindow.Move with absolute
555-
// screen-physical coords directly. Same coordinate system
556-
// as GetCursorPos returns, so the cursor and the window
557-
// top-left stay locked together exactly.
558-
active.FloatingWindow.AppWindow.Move(
559-
new global::Windows.Graphics.PointInt32(x, y));
560-
}
561-
catch { /* window may have been closed externally */ }
550+
// Bypass ReactorWindow.SetPosition's DIP→physical roundtrip
551+
// (which uses the window's _dpi — unreliable for a fresh
552+
// NoActivate window). Drive AppWindow.Move with absolute
553+
// screen-physical coords directly. Same coordinate system
554+
// as GetCursorPos returns, so the cursor and the window
555+
// top-left stay locked together exactly.
556+
//
557+
// Ordering guarantee: every path that closes the floating window
558+
// (FinalizeInternal, ForceCancel, host-unmount UseEffect cleanup)
559+
// calls Stop() FIRST, which detaches the Tick handler and clears
560+
// s_active. PositionFloating is only invoked from OnTick (timer
561+
// disabled after Stop) and synchronously from Start (window is
562+
// fresh). No path leaves a live tracker pointing at a closed
563+
// window, so AppWindow.Move always targets a live AppWindow.
564+
active.FloatingWindow.AppWindow.Move(
565+
new global::Windows.Graphics.PointInt32(x, y));
562566
}
563567

564568
private static void FinalizeInternal(bool commit)

0 commit comments

Comments
 (0)