Skip to content

Give a Windows console read a buffer the handle owns - #301

Merged
EdmondDantes merged 1 commit into
mainfrom
286-windows-stream-read-cancel
Sep 12, 2026
Merged

EdmondDantes merged 1 commit into
mainfrom
286-windows-stream-read-cancel

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

Closes the second half of #286: a Windows console read handed libuv the caller's buffer.

uv_read_stop() is not an ownership barrier for a tty in line mode. uv__tty_queue_read_line() calls the allocator on the loop thread and hands the address to uv_tty_line_read_thread, started with QueueUserWorkItem, which nothing joins. Cancellation is not a stop but an injected keystroke: uv__cancel_read_console() writes a VK_RETURN record into the console input, so ReadConsoleW returns successfully and the worker writes the characters into that buffer — which by then is the readbuf a cancelled coroutine has already freed through php_stream_free(). libuv drops the result itself, because UV_HANDLE_CANCELLATION_PENDING suppresses read_cb; that is also why the rendezvous the issue proposed cannot work — there is no completion to wait for.

The change

A tty handle carries one 8 KB buffer (async_io_t.tty_read_buf), allocated at the first read through the reactor's own allocator and never resized; the completion copies out of it into the request, bounded by what that request asked for. libuv releases the handle only through its close callback, which runs once reqs_pending has drained — after the worker has written. A handle disposed with the reactor already down keeps its buffer, leaked on purpose, the trade libuv_io_close already makes for the descriptor (#282).

The size is a bound, not a guess: uv_tty_line_read_thread clamps its read to MAX_INPUT_BUFFER_LENGTH = 8192 whatever the allocator offers, so a console can never answer with more.

Pipes, TCP sockets and a tty in raw mode are untouched, and pay no copy: all three call the allocator on the loop thread once the bytes have arrived (uv__pipe_read_data, uv__process_tcp_read_req, uv__tty_queue_read_raw), so the address never outlives the callback. The multishot path, where the owner supplies base.alloc_cb, is untouched as well.

Evidence

Reproduced, not inferred — but not by a test, because a CI runner has no console. The procedure is in docs/286-windows-console-read-cancel.md: build libuv with /fsanitize=address, add a Sleep(200) in uv_tty_line_read_thread between ReadConsoleW and uv_utf16_to_wtf8 to widen the race, then cancel a parked fread(STDIN, 4096) and fclose the stream in a real console window.

Without the change:

ERROR: AddressSanitizer: heap-use-after-free
WRITE of size 1 thread T-1
    #0 uv_utf16_to_wtf8 src\idna.c:504
    #1 uv_tty_line_read_thread src\win\tty.c:576
freed by thread T0 here:
    #3 php_stream_free main\streams\streams.c:473
    #4 zif_fclose ext\standard\file.c:779

With it, the same run is clean, and so are eight rounds without the injected delay.

Worth knowing beyond this issue: the prebuilt libuv.lib the Windows build links is not compiled with the sanitizer, so a write from a libuv worker thread into freed memory has never been visible to ASAN. Every earlier "clean under Windows ASAN" for this defect — including 3500 rounds — was measuring nothing.

ext/async/tests/io on an ASAN ZTS build against the stock libuv 1.49.3: 98 passed, 4 skipped, 0 failed.

@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

uv_read_stop() is not an ownership barrier for a tty: uv__tty_queue_read_line()
hands the allocator's address to a ReadConsoleW worker started with
QueueUserWorkItem, and the cancellation is an injected VK_RETURN, so the worker
returns successfully and writes into memory the cancelled coroutine has already
freed. A pipe and a TCP socket allocate in the loop thread once the bytes are
there and are not exposed.

A tty handle now carries one 8 KB buffer, allocated at the first read and never
resized, and the completion copies out of it into the request. libuv releases
the handle only through its close callback, which runs once the pending
requests have drained.
@EdmondDantes
EdmondDantes force-pushed the 286-windows-stream-read-cancel branch from 71f021a to 75ec748 Compare September 12, 2026 10:59
@EdmondDantes
EdmondDantes merged commit 876d0f9 into main Sep 12, 2026
9 checks passed
@EdmondDantes
EdmondDantes deleted the 286-windows-stream-read-cancel branch September 12, 2026 11:25
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.

1 participant