dotnet / dotnet/wpf

Input/Stylus: WPF WISP pen stack hangs indefinitely when stylus subsystem is accessed after disposal

Open
#11,486 0 comments 1 reaction 0 assignees View on GitHub
:construction: work in progress Bug Investigate
Dominant language
C#
Stars
7.7k
Forks
1.3k
Avg merge
1d 11h
Merged PRs (30d)
61

Description

# [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
1. Open a WPF application on a system with a WISP-compatible device (e.g., a display that reports display settings changes).
2. 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.
3. Close the application.
4. 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` 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:
1. Enqueues a `WorkerOperationGetTabletsInfo` onto `_workerOperation`.
2. Calls `RaiseResetEvent` on the PIMC reset handle.
3. 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` 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()` 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`.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.