diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs index c8a1e9a..1c1c90d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs @@ -172,6 +172,12 @@ public sealed class WorkerFrameWriter // caller still observes cancellation while the frame reaches the wire (documented // above). Rethrow from the wait rather than from the completion, so a claimed frame's // canceller is not held behind the very write it is abandoning. + // + // The wait ending without the lock IS cancellation in every reachable case — nothing + // disposes _writeLock — so the tombstone's TrySetCanceled is honest. A hypothetical + // faulted wait would take this same path and label the frame cancelled instead of + // faulted; that is internal state only, since the await below rethrows the fault itself + // to the caller. TombstoneIfUnclaimed(frame, cancellationToken); await lockWait.ConfigureAwait(false); } @@ -343,8 +349,23 @@ public sealed class WorkerFrameWriter /// Frame whose completion may fault without an awaiter. private static void ObserveAbandonedFault(PendingFrame frame) { - _ = frame.Completion.Task.ContinueWith( - task => _ = task.Exception, + ObserveFault(frame.Completion.Task); + } + + /// + /// Attaches the NEXT-04 fault-observing continuation to a task this writer starts and then + /// discards. Every such task must carry one: with no awaiter, a fault would otherwise reach + /// nobody and resurface as at finalization + /// — a detached, unattributable failure long after the code that caused it. The continuation + /// runs only on the faulted path and only touches , so it can + /// never fault itself; it runs inline because an already-faulted task would otherwise pay a + /// scheduling hop to do nothing. + /// + /// Discarded task whose fault would otherwise go unobserved. + private static void ObserveFault(Task task) + { + _ = task.ContinueWith( + faulted => _ = faulted.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); @@ -367,11 +388,14 @@ public sealed class WorkerFrameWriter /// Outstanding write-lock acquisition the caller has walked away from. private void DetachLockWait(Task lockWait) { - _ = lockWait.ContinueWith( + // The continuation task is discarded, so it takes the NEXT-04 fault observer: nothing awaits + // it, and a throw out of OnDetachedLockWaitSettled would otherwise be an unobserved-task + // exception raised at finalization rather than an attributable failure here. + ObserveFault(lockWait.ContinueWith( OnDetachedLockWaitSettled, CancellationToken.None, TaskContinuationOptions.None, - TaskScheduler.Default); + TaskScheduler.Default)); } /// @@ -394,7 +418,11 @@ public sealed class WorkerFrameWriter return; } - _ = DrainDetachedAsync(); + // Discarded, so it takes the NEXT-04 fault observer too. DrainDetachedAsync swallows the drain + // itself, but its release sits in a finally outside that catch: a Release that ever throws (a + // SemaphoreFullException from some future double-release regression, say) must fail somewhere + // attributable rather than at finalization. + ObserveFault(DrainDetachedAsync()); } ///