[Input/Stylus] WPF WISP pen stack hangs indefinitely when stylus subsystem is accessed after disposal
Summary
When a WPF application shuts down and the dispatcher on the main thread has been deactivated, the WISP stylus subsystem can be triggered to create a new window (e.g., by a system event). This window creation path calls PenThreadWorker.WorkerGetTabletsInfo() on an already-disposed PenThreadWorker, causing the calling thread to hang indefinitely — the application process never exits cleanly.
A secondary issue causes PenThreadPool to return disposed PenThread objects as valid candidates, potentially prolonging or exacerbating the shutdown hang.
Observed behavior
- Application process does not exit after UI is closed.
- The hung thread is blocked in
WaitHandle.WaitOne() with no timeout inside PenThreadWorker.WorkerGetTabletsInfo().
- The application must be force-killed.
Repro notes
- Open a WPF application on a system with a WISP-compatible device (e.g., a display that reports display settings changes).
- Leave the application running for an extended period or trigger a display settings change event (e.g., reconnect a monitor/devbox session) while the application is shutting down.
- Close the application.
- Observe that the process does not terminate — it hangs indefinitely.
Note: exact repro timing depends on a race between dispatcher shutdown and a system event (e.g., WM_DISPLAYCHANGE) arriving during the shutdown window.
Impact
- Affected WPF applications never exit cleanly; they must be force-terminated.
- Users perceive the application as frozen after the UI disappears.
- Reproducible under display settings change events that arrive during shutdown.
Expected behavior
When PenThreadWorker has been disposed, calls to WorkerGetTabletsInfo() should return an empty result immediately without blocking.
PenThreadPool should not return disposed PenThread objects; disposed entries should be pruned from the pool.
Actual behavior
WorkerGetTabletsInfo() unconditionally enqueues a work item and then calls WaitOne() with no timeout. If the pen thread has already exited because PenThreadWorker was disposed, no one will ever call Set() on the done event and the caller blocks forever.
PenThreadPool.GetPenThreadForPenContextHelper checks only whether a WeakReference<PenThread> is alive, but does not check whether the target PenThread is disposed. A disposed-but-still-referenced thread (kept alive by its registered PenContext / _handles array) is incorrectly returned as a usable candidate.
Suspected root cause
Gap 1 — WorkerGetTabletsInfo missing disposed guard
Every other Worker* method in PenThreadWorker (WorkerAddPenContext, WorkerRemovePenContext, WorkerCreateContext, WorkerAcquireTabletLocks, …) starts with a disposed check and returns a safe value when disposed. WorkerGetTabletsInfo was the sole exception.
When called on a disposed worker it:
- Enqueues a
WorkerOperationGetTabletsInfo onto _workerOperation.
- Calls
RaiseResetEvent on the PIMC reset handle.
- Calls
getTablets.DoneEvent.WaitOne() — with no timeout.
The pen thread's ThreadProc exits when __disposed is true, so nobody calls DoneEvent.Set(). The caller waits forever.
Gap 2 — PenThreadPool returns disposed PenThread
PenThreadPool.GetPenThreadForPenContextHelper selects a candidate by checking only whether the WeakReference<PenThread> is still alive. It does not verify that the PenThread itself is not disposed. A disposed, but still strongly-referenced, PenThread is incorrectly returned as valid.
Proposed fix direction
- Add
internal bool IsDisposed => __disposed; to PenThreadWorker to expose disposal state.
- Add the matching
internal bool IsDisposed => _penThreadWorker.IsDisposed; surface to PenThread.
- Add a disposed guard at the top of
PenThreadWorker.WorkerGetTabletsInfo() that returns Array.Empty<TabletDeviceInfo>() immediately when disposed (consistent with all other Worker* methods).
- In
PenThreadPool.GetPenThreadForPenContextHelper, extend weak-reference cleanup to also remove entries whose target PenThread is disposed, and skip disposed candidates during selection.
- Make
PenThread._penThreadWorker readonly (it is always assigned in the constructor and never reassigned) and remove the now-redundant null check in DisposeHelper.
[Input/Stylus] WPF WISP pen stack hangs indefinitely when stylus subsystem is accessed after disposal
Summary
When a WPF application shuts down and the dispatcher on the main thread has been deactivated, the WISP stylus subsystem can be triggered to create a new window (e.g., by a system event). This window creation path calls
PenThreadWorker.WorkerGetTabletsInfo()on an already-disposedPenThreadWorker, causing the calling thread to hang indefinitely — the application process never exits cleanly.A secondary issue causes
PenThreadPoolto return disposedPenThreadobjects as valid candidates, potentially prolonging or exacerbating the shutdown hang.Observed behavior
WaitHandle.WaitOne()with no timeout insidePenThreadWorker.WorkerGetTabletsInfo().Repro notes
Impact
Expected behavior
When
PenThreadWorkerhas been disposed, calls toWorkerGetTabletsInfo()should return an empty result immediately without blocking.PenThreadPoolshould not return disposedPenThreadobjects; disposed entries should be pruned from the pool.Actual behavior
WorkerGetTabletsInfo()unconditionally enqueues a work item and then callsWaitOne()with no timeout. If the pen thread has already exited becausePenThreadWorkerwas disposed, no one will ever callSet()on the done event and the caller blocks forever.PenThreadPool.GetPenThreadForPenContextHelperchecks only whether aWeakReference<PenThread>is alive, but does not check whether the targetPenThreadis disposed. A disposed-but-still-referenced thread (kept alive by its registeredPenContext/_handlesarray) is incorrectly returned as a usable candidate.Suspected root cause
Gap 1 —
WorkerGetTabletsInfomissing disposed guardEvery other
Worker*method inPenThreadWorker(WorkerAddPenContext,WorkerRemovePenContext,WorkerCreateContext,WorkerAcquireTabletLocks, …) starts with a disposed check and returns a safe value when disposed.WorkerGetTabletsInfowas the sole exception.When called on a disposed worker it:
WorkerOperationGetTabletsInfoonto_workerOperation.RaiseResetEventon the PIMC reset handle.getTablets.DoneEvent.WaitOne()— with no timeout.The pen thread's
ThreadProcexits when__disposedistrue, so nobody callsDoneEvent.Set(). The caller waits forever.Gap 2 —
PenThreadPoolreturns disposedPenThreadPenThreadPool.GetPenThreadForPenContextHelperselects a candidate by checking only whether theWeakReference<PenThread>is still alive. It does not verify that thePenThreaditself is not disposed. A disposed, but still strongly-referenced,PenThreadis incorrectly returned as valid.Proposed fix direction
internal bool IsDisposed => __disposed;toPenThreadWorkerto expose disposal state.internal bool IsDisposed => _penThreadWorker.IsDisposed;surface toPenThread.PenThreadWorker.WorkerGetTabletsInfo()that returnsArray.Empty<TabletDeviceInfo>()immediately when disposed (consistent with all otherWorker*methods).PenThreadPool.GetPenThreadForPenContextHelper, extend weak-reference cleanup to also remove entries whose targetPenThreadis disposed, and skip disposed candidates during selection.PenThread._penThreadWorkerreadonly(it is always assigned in the constructor and never reassigned) and remove the now-redundant null check inDisposeHelper.