Skip to content

fix(reactor): defer IO request free while its libuv op is still in flight (#173)#175

Merged
EdmondDantes merged 1 commit into
mainfrom
fix-173-io-req-dispose-uaf
Jul 4, 2026
Merged

fix(reactor): defer IO request free while its libuv op is still in flight (#173)#175
EdmondDantes merged 1 commit into
mainfrom
fix-173-io-req-dispose-uaf

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

Fixes #173.

Root cause

A whole class of use-after-free where an IO request is disposed while libuv still holds a pointer to it — an awaiter abandons the request (stream_set_timeout()+fgets() where the timer wins, or a cancelled coroutine parked on write/flush/stat/sendfile) and calls req->dispose(req), but the libuv operation is still armed/in-flight.

The io/042 crash is one instance: an armed stream read whose request was freed but left io->active_req dangling with uv_read_start still active. The stale pointer survived until the uv_close callback drained on teardown, where the active_req backstop in libuv_io_event_dispose called dispose() through freed memory → 0xC0000005 after End. Intermittent because the freed Zend-MM block is usually still intact (silent double-free) and only crashes when it was reused between fclose() and shutdown.

Fix

  • Armed stream read / UDP recv: dispose now uv_read_stop/uv_udp_recv_stop and detaches io->active_req before freeing.
  • In-flight uv_write / uv_fs_* / sendfile (not synchronously cancellable): defer the free with a UV_IN_FLIGHT/DISPOSE_PENDING rendezvous, mirroring the one that already existed for UDP sendto. The completion callback finishes the deferred dispose and skips the NOTIFY; a queued-but-not-started threadpool op is uv_cancel'ed. io_file_stat_cb also skips writing the stat result into the vanished awaiter's buffer.
  • FILE io under a threadpool op: FILE handles have no uv_close rendezvous, so every fs submission (plus fs_open's pending io and both sendfile ios) now holds an event reference for the op's duration.
  • Latent: disposing a not-yet-closed stream/UDP io freed it right after scheduling uv_close; final teardown is now transferred to io_close_cb.

Verification

Built ZTS on Windows (VS17 x64). Full ext/async suite: 988 passed, 0 failed (1 pre-existing XFAIL-but-passes WARN, unrelated). Stress-ran io/042 60/60 clean, no access violation.

…ight (#173)

An awaiter that abandons its request — stream_set_timeout()+fgets() where the
timer wins, or a cancelled coroutine parked on a write/flush/stat/sendfile —
called req->dispose(req) while libuv still referenced the request, leaving
several distinct use-after-frees (seen as an intermittent access violation on
Windows process teardown in io/042):

- Armed stream read (the io/042 crash): dispose freed the request but left
  io->active_req pointing at it with uv_read_start still armed; the later
  uv_close callback ran dispose() through freed memory. Dispose now stops the
  reader and detaches io->active_req; the same detach was added to
  udp_req_dispose for an abandoned recvfrom.

- In-flight uv_write / uv_fs_read / uv_fs_write / uv_fs_fsync / uv_fs_fstat /
  sendfile: these cannot be cancelled synchronously, so dispose now defers the
  free with a UV_IN_FLIGHT/DISPOSE_PENDING rendezvous (mirroring the existing
  UDP-sendto one) — the completion callback finishes the deferred dispose and
  skips the NOTIFY; a queued-but-not-started threadpool op is uv_cancel'ed.
  io_file_stat_cb additionally skips writing the stat result into the vanished
  awaiter buffer.

- FILE io freed under a threadpool op: FILE-type handles have no uv_close
  rendezvous, so an owner dispose could free the async_io_t while a completion
  callback still reached through it. Every fs submission (and fs_open pending
  io, and both sendfile ios) now holds an event reference for the op duration.

- Latent: disposing a not-yet-closed stream/UDP io freed it after scheduling
  uv_close, leaving the pending close callback with a dangling handle; final
  teardown is now transferred to io_close_cb.
@EdmondDantes EdmondDantes force-pushed the fix-173-io-req-dispose-uaf branch from 32994bc to 972b057 Compare July 3, 2026 21:08
@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@EdmondDantes EdmondDantes merged commit 431c208 into main Jul 4, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows: access violation on process teardown in io/042 (proc_open pipes + concurrent coroutines), intermittent

1 participant