protonscr

server: esync leaks one eventfd per thread on every CreateThread, even with correct app code (missing close of thread->esync_apc_fd)

protonclosed
ValveSoftware/Proton#10085 · opened 2026-08-22 by aydar-kamaltdinov · updated 2026-08-22 · 1 comments · github
1 matching comments, n / p to jump
Aaydar-kamaltdinov 2026-08-22 github

Compatibility Report

  • Name of the game with compatibility issues: Thief (2014)
  • Steam AppID of the game: 239160

System Information

  • GPU: N/A — root cause confirmed directly in source code, independent of GPU/rendering (see below)
  • Video driver version: N/A
  • Kernel version: N/A (originally observed on Android/ARM64, not desktop Linux — see note below)
  • Link to full system information report as Gist: N/A — this is not a desktop/Steam Deck report, see note below
  • Proton version: proton_10.0 branch, commit b8fdff8e1f855b5276ec4ddca0f31b2792554322

I confirm:

  • [x] that I haven't found an existing compatibility report for this game for this specific issue.
  • [ ] that I have checked whether there are updates for my system available.

Note on scope: this was originally found on Android (a Proton-derived ARM64EC build used by several "run Windows games on Android" apps), not on desktop Linux/Steam Deck — so the usual GPU/driver/kernel/PROTON_LOG fields don't apply in the normal sense. However, the root cause is a wineserver-side file descriptor leak, entirely independent of GPU/rendering/platform, and I've directly confirmed the same buggy code is present in this repository's own proton_10.0 branch (not just a downstream fork), so it should reproduce identically on desktop Proton/Steam Deck under the same conditions (see Reproduction below) — same root cause, same fix.

Symptoms

The game crashes deterministically with EXCEPTION_ACCESS_VIOLATION after a fixed number of simulation ticks (~14400, i.e. after a fixed amount of active gameplay time, not real time — confirmed independent of framerate). Root cause: WINEESYNC=1 leaks exactly one real file descriptor (thread->esync_apc_fd) per CreateThread(), for the lifetime of wineserver — deterministically, on every thread, even with fully correct application code (CreateThread + WaitForSingleObject + CloseHandle). A game/engine that spawns one short-lived worker thread per simulation tick (common — job systems, audio callbacks, etc.) will hit RLIMIT_NOFILE after a fixed, predictable number of ticks. Invisible on real Windows (near-unlimited handle table), fatal here.

Root cause detail

server/thread.c, create_thread() allocates two eventfds per thread object:

thread->esync_fd     = esync_create_fd( 0, 0 );
thread->esync_apc_fd = esync_create_fd( 0, 0 );

but destroy_thread() only closes one of them:

if (do_esync())
    close( thread->esync_fd );
if (thread->fsync_idx)
{
    fsync_free_shm_idx( thread->fsync_idx );
    fsync_free_shm_idx( thread->fsync_apc_idx );
}

esync_apc_fd is referenced elsewhere only via esync_wake_fd() / esync_clear() (APC signaling) — never close()d anywhere in the tree. Note the fsync path right below correctly frees both fsync_idx and fsync_apc_idx — this looks like a straightforward oversight specific to the esync side only.

Fix

     if (do_esync())
-        close( thread->esync_fd );
+    {
+        close( thread->esync_fd );
+        close( thread->esync_apc_fd );
+    }
     if (thread->fsync_idx)

Empirical verification

Built wineserver from this repo's proton_10.0 branch for aarch64-linux-android (NDK r29) to match the exact protocol version (856) used by the real
device build. Ran a 200-iteration CreateThread loop, measuring wineserver's live eventfd count via /proc/<pid>/fd:

Scenario: 200x CreateThread WITHOUT CloseHandle
Before fix: 852 (baseline ~452 + 200x2)
After fix: 853 (unchanged -- object never destroyed, expected)

Scenario: 200x CreateThread WITH CloseHandle (correct app code)
Before fix: 652 (baseline ~452 + 200x1 -- leaking despite fully correct code)
After fix: 453 (= baseline, ZERO leak)

Reproduction

  1. Any program that calls CreateThread() in a loop with WINEESYNC=1 set will leak one fd per call, regardless of whether the returned handle is closed.
  2. For Thief specifically: start any level, stand still or play normally — crash occurs after a fixed ~14400 simulation ticks (~4 min of active gameplay at 60 fps, ~8 min at 30 fps — scales with fps, not wall-clock time, confirming it's tick-driven).
  3. Workaround confirmed: WINEESYNC=0 eliminates the crash entirely (no crash in 23+ minutes vs. deterministic crash within minutes with esync on) — at a real performance cost, which is the actual motivation for wanting this fixed rather than worked around.

Also filed upstream against WineHQ (original home of the eventfd_synchronization patchset, since removed from wine-staging master in favor of ntsync): https://bugs.winehq.org/show_bug.cgi?id=60215


Note: this report was drafted/formatted with AI assistance for the technical writeup, but all testing, reproduction, log capture, root-causing, and the empirical before/after fix verification (attaching to the process's fd table, building and cross-compiling Wine from source, confirming protocol-version-exact compatibility with a real device build) were performed and manually confirmed by me on physical hardware.

Kkisak-valve maintainer 2026-08-22 github

Hello @aydar-kamaltdinov, we're using one issue report per unofficially supported game title, and usually I'd transfer this issue report to #279, however, at no point did Proton 10 support ARM based builds or any running on Android. Esync based thread synchronization is also being phased out in favor of ntsync.

Closing as invalid.

Proton versions

Launch options

Upstream links