Summary
Async continuations posted to a disposed NetMQPoller crash the entire host process. This is the root cause of Sentry BLUEYE-APP-FH (561 events / 93 users) and its native counterpart BLUEYE-APP-9G (SIGABRT via xamarin_unhandled_exception_handler — same users, same timestamps).
Mechanism
NetMQPoller.Run() installs NetMQSynchronizationContext as SynchronizationContext.Current on the poller thread.
- Any code that
awaits while running on the poller thread (socket/timer event handlers and everything they call synchronously) captures this context for its continuations. In BlueyeApp this happens routinely: telemetry handlers fan out through IMessenger to app code.
- The app disposes the poller on drone disconnect (
BaseZeroMqSubscriber.ClearPoller).
- A pending continuation then fires: the await machinery calls
NetMQSynchronizationContext.Post, which does task.Start(m_poller) → NetMQPoller.QueueTask → CheckDisposed() throws ObjectDisposedException, wrapped in TaskSchedulerException.
- Exceptions thrown from
SynchronizationContext.Post in the await path are rethrown on a thread-pool thread (AsyncMethodBuilderCore.ThrowAsync — visible in the Sentry stack). Application code cannot catch them; on iOS the Mono runtime aborts the process.
System.Threading.Tasks.TaskSchedulerException
---> System.ObjectDisposedException: NetMQPoller
at NetMQPoller.CheckDisposed()
at NetMQPoller.QueueTask(Task task)
at Task.Start(TaskScheduler)
at NetMQSynchronizationContext.Post(SendOrPostCallback d, object state)
at SynchronizationContextAwaitTaskContinuation.PostAction(object state)
at <>c.<ThrowAsync>b__124_1(object state)
A second, related defect: tasks queued between Stop() and Dispose() are silently discarded when Dispose() disposes m_tasksQueue. Their awaiting continuations then hang forever. The Stop()-then-Dispose() sequence is exactly what BaseZeroMqSubscriber.ClearPoller runs on every disconnect.
Why the library must own this
The sync context is installed implicitly by the poller; the application never sees the Post call sites (await machinery, async void exception propagation). Throwing there is unhandleable. The contract options are: run the callback somewhere, or crash/hang — so the library has to pick "run it somewhere".
Fix (branch fix/poller-disposed-synccontext)
NetMQSynchronizationContext.Post: if the poller is disposed (checked up front, plus catching the dispose race on task.Start), fall back to ThreadPool.QueueUserWorkItem. The faulted task's exception is observed so it cannot surface as UnobservedTaskException.
NetMQSynchronizationContext.Send: same detection; executes the callback inline on the calling thread.
NetMQPoller.Dispose: drains m_tasksQueue (by enumerating the backing queue — TryDequeue relies on pair-socket signalling that is dead once the poller thread has exited) and executes remaining tasks before disposing the queue, so nothing queued between Stop() and Dispose() is lost. TryExecuteTask is a no-op for tasks already executed, so double execution is not possible.
Trade-off to be aware of: a fallback continuation runs on a thread-pool thread instead of the poller thread. Code that would have touched sockets in that continuation loses the poller's thread affinity — but the poller (and typically its sockets) is already disposed at that point, so the alternative was a process crash, not a working continuation.
Covered by three new tests in NetMQPollerTest (SynchronizationContextPostAfterDisposeRunsCallbackOnThreadPool, SynchronizationContextSendAfterDisposeRunsCallbackInline, TaskStartedAfterStopRunsWhenPollerIsDisposed); the full Poller test category passes.
Upstreaming
Closest upstream report is zeromq#697 (2017) — same component, same ThrowAsync mechanism, acknowledged by the maintainer, closed by the stale bot without a fix. The disposed-poller variant is unreported. Plan: after the next BlueyeApp release confirms BLUEYE-APP-FH/9G stop occurring and no regressions show up, file this upstream with the patch as a PR.
Summary
Async continuations posted to a disposed
NetMQPollercrash the entire host process. This is the root cause of Sentry BLUEYE-APP-FH (561 events / 93 users) and its native counterpart BLUEYE-APP-9G (SIGABRT viaxamarin_unhandled_exception_handler— same users, same timestamps).Mechanism
NetMQPoller.Run()installsNetMQSynchronizationContextasSynchronizationContext.Currenton the poller thread.awaits while running on the poller thread (socket/timer event handlers and everything they call synchronously) captures this context for its continuations. In BlueyeApp this happens routinely: telemetry handlers fan out throughIMessengerto app code.BaseZeroMqSubscriber.ClearPoller).NetMQSynchronizationContext.Post, which doestask.Start(m_poller)→NetMQPoller.QueueTask→CheckDisposed()throwsObjectDisposedException, wrapped inTaskSchedulerException.SynchronizationContext.Postin the await path are rethrown on a thread-pool thread (AsyncMethodBuilderCore.ThrowAsync— visible in the Sentry stack). Application code cannot catch them; on iOS the Mono runtime aborts the process.A second, related defect: tasks queued between
Stop()andDispose()are silently discarded whenDispose()disposesm_tasksQueue. Their awaiting continuations then hang forever. TheStop()-then-Dispose()sequence is exactly whatBaseZeroMqSubscriber.ClearPollerruns on every disconnect.Why the library must own this
The sync context is installed implicitly by the poller; the application never sees the
Postcall sites (await machinery,async voidexception propagation). Throwing there is unhandleable. The contract options are: run the callback somewhere, or crash/hang — so the library has to pick "run it somewhere".Fix (branch
fix/poller-disposed-synccontext)NetMQSynchronizationContext.Post: if the poller is disposed (checked up front, plus catching the dispose race ontask.Start), fall back toThreadPool.QueueUserWorkItem. The faulted task's exception is observed so it cannot surface asUnobservedTaskException.NetMQSynchronizationContext.Send: same detection; executes the callback inline on the calling thread.NetMQPoller.Dispose: drainsm_tasksQueue(by enumerating the backing queue —TryDequeuerelies on pair-socket signalling that is dead once the poller thread has exited) and executes remaining tasks before disposing the queue, so nothing queued betweenStop()andDispose()is lost.TryExecuteTaskis a no-op for tasks already executed, so double execution is not possible.Trade-off to be aware of: a fallback continuation runs on a thread-pool thread instead of the poller thread. Code that would have touched sockets in that continuation loses the poller's thread affinity — but the poller (and typically its sockets) is already disposed at that point, so the alternative was a process crash, not a working continuation.
Covered by three new tests in
NetMQPollerTest(SynchronizationContextPostAfterDisposeRunsCallbackOnThreadPool,SynchronizationContextSendAfterDisposeRunsCallbackInline,TaskStartedAfterStopRunsWhenPollerIsDisposed); the full Poller test category passes.Upstreaming
Closest upstream report is zeromq#697 (2017) — same component, same
ThrowAsyncmechanism, acknowledged by the maintainer, closed by the stale bot without a fix. The disposed-poller variant is unreported. Plan: after the next BlueyeApp release confirms BLUEYE-APP-FH/9G stop occurring and no regressions show up, file this upstream with the patch as a PR.