From 41a24c8d0ad2c5f04388cbda28f3e0a850bc05f4 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Sun, 26 Jul 2026 23:34:06 +0000 Subject: [PATCH] report a cancelled ctx read as the deadline, not end-of-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a deadline-limited read on a subprocess pipe spawns a worker to do the read and polls the context's cancel flag while waiting. under a single green worker the worker can finish the same instant the deadline fires: `task.is_done()` then wins the poll before the cancel is seen, so a read that came back empty only because it would have blocked was returned as a clean end-of-stream. a `read_exact` on top of it then failed with "unexpected eof" instead of deadline-exceeded, and `read_all` would stop short a chunk early. after awaiting the worker, the four process read primitives now re-check the context: if it is cancelled and nothing was read, that empty is the deadline, not eof. a read that returned real data before the deadline keeps it — only an empty-or-errored result is reclassified, so no data is dropped. the tcp read path already checked the cancel before reading and needed no change. found by verify-green-corpus: test_process_ctx diverged under one worker only (correct at the default worker count and os-thread). it now matches on all three, so its exclusion is removed and the whole corpus — 260 cases, both green worker modes — is byte-identical to os-thread again. --- Makefile | 9 +-------- std/io.pith | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 9c5707a9..122e1098 100644 --- a/Makefile +++ b/Makefile @@ -1019,14 +1019,7 @@ green-tests: green-smoke green-threadlocal green-pingpong green-producer-consume # a case that legitimately differs under green (nondeterministic scheduling # order that its expected output happens to encode) belongs in # GREEN_CORPUS_EXCLUDE with a reason, not in a loosened compare. -# test_process_ctx: under a single green worker (PITH_GREEN_WORKERS=1), a -# deadline-limited read_exact on a subprocess pipe reports the read failed but -# classifies the error as unexpected-eof rather than deadline-exceeded — its -# spawned read-worker returns empty on would-block instead of yielding until -# data or the deadline. correct at the default worker count and os-thread; a -# narrow single-worker green bug tracked separately. (see the read_exact ctx -# path in std/io.pith.) -GREEN_CORPUS_EXCLUDE := test_process_ctx +GREEN_CORPUS_EXCLUDE := GREEN_CORPUS_EXPECTED := $(filter-out $(addprefix tests/expected/,$(addsuffix .txt,$(GREEN_CORPUS_EXCLUDE))),$(REGRESSION_EXPECTED)) verify-green-corpus: build verify-green-corpus-only diff --git a/std/io.pith b/std/io.pith index 54cd46ee..e734e37e 100644 --- a/std/io.pith +++ b/std/io.pith @@ -1274,6 +1274,13 @@ fn process_stdout_read_ctx(reader: ProcessStdout, ctx: Context, max_bytes: Int) fail context_blocking_error(ctx) time.delay(1) read := await task + # the worker can finish the same instant the deadline fires: `is_done()` + # then wins the poll before the cancel is seen, so a read that came back + # empty only because it would have blocked is about to be mistaken for a + # clean end-of-stream. when the context is cancelled and nothing was read, + # it is the deadline, not eof. + if ctx.is_cancelled() and (read.is_err or read.ok.len() == 0): + fail context_blocking_error(ctx) if read.is_err: fail blocking_failed(read.err) return read.ok @@ -1298,6 +1305,13 @@ fn process_stderr_read_ctx(reader: ProcessStderr, ctx: Context, max_bytes: Int) fail context_blocking_error(ctx) time.delay(1) read := await task + # the worker can finish the same instant the deadline fires: `is_done()` + # then wins the poll before the cancel is seen, so a read that came back + # empty only because it would have blocked is about to be mistaken for a + # clean end-of-stream. when the context is cancelled and nothing was read, + # it is the deadline, not eof. + if ctx.is_cancelled() and (read.is_err or read.ok.len() == 0): + fail context_blocking_error(ctx) if read.is_err: fail blocking_failed(read.err) return read.ok @@ -1538,6 +1552,13 @@ fn process_stdout_read_bytes_ctx(reader: ProcessStdout, ctx: Context, max_bytes: fail context_blocking_error(ctx) time.delay(1) read := await task + # the worker can finish the same instant the deadline fires: `is_done()` + # then wins the poll before the cancel is seen, so a read that came back + # empty only because it would have blocked is about to be mistaken for a + # clean end-of-stream. when the context is cancelled and nothing was read, + # it is the deadline, not eof. + if ctx.is_cancelled() and (read.is_err or read.ok.len() == 0): + fail context_blocking_error(ctx) if read.is_err: fail blocking_failed(read.err) return read.ok @@ -1564,6 +1585,13 @@ fn process_stderr_read_bytes_ctx(reader: ProcessStderr, ctx: Context, max_bytes: fail context_blocking_error(ctx) time.delay(1) read := await task + # the worker can finish the same instant the deadline fires: `is_done()` + # then wins the poll before the cancel is seen, so a read that came back + # empty only because it would have blocked is about to be mistaken for a + # clean end-of-stream. when the context is cancelled and nothing was read, + # it is the deadline, not eof. + if ctx.is_cancelled() and (read.is_err or read.ok.len() == 0): + fail context_blocking_error(ctx) if read.is_err: fail blocking_failed(read.err) return read.ok