Skip to content

fix: honor ctx cancellation in terminal operations - #18

Merged
tr1v3r merged 1 commit into
masterfrom
fix/terminal-ctx-cancel
Sep 14, 2026
Merged

tr1v3r merged 1 commit into
masterfrom
fix/terminal-ctx-cancel

Conversation

@tr1v3r

@tr1v3r tr1v3r commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Problem

The published cancellation contract was silently broken on bare pipelines (no intermediate ops between WithContext and the terminal):

  • export.go:28-31 (WithContext): "a cancelled context makes intermediate operations stop pulling and terminals return promptly."
  • doc.go:76-78: infinite sources hang "without a cancellable context — bound them with Limit or WithContext" (i.e. WithContext is the documented remedy)
  • README.md:276-285 ("Infinite streams hang non-short-circuiting terminals"): "Bound them with Limit or WithContext" → `stream.Repeat(1).WithContext(ctx).Take() // cancellable: ok*

Hang repro (pre-fix):

ctx, cancel := context.WithCancel(context.Background())
cancel()
stream.From(finite, -1).WithContext(ctx).ToSlice() // never checks ctx → 3 elems leak through
go func() { stream.Repeat(1).WithContext(hctx).ToSlice() }() // infinite source → HANGS FOREVER even after hctx cancel

Affected terminals/paths: ToSlice, Collect, Count (-1 path), Last, First, Seq, and every materialize-based stage — Sort, ReverseSort, Reverse, Pick (end<0), Execute.

Root cause

Only ForEach/AllMatch/AnyMatch/NonMatch/Reduce*/Take checked s.cancelled(). materialize() and the remaining terminals pulled upstream unconditionally, so a cancelled context never stopped a bare pipeline (and Repeat+terminal hung forever).

Fix

  • materialize(ctx, seq) now consults ctx at every element boundary: empty when cancelled up front, the elements pulled so far when cancelled mid-stream — the same partial-result semantics the Reduce family already had. All six call sites pass the streamer's ctx (Sort / ReverseSort / Reverse / Pick materialize path / Execute / ToSlice).
  • First (cancelled → zero value), Last (cancelled → partial), Count iteration path (cancelled → count so far; the O(1) sizeHint fast path consumes nothing and deliberately does not consult ctx — documented) gain per-element checks.
  • Seq() returns a wrapped sequence checking cancelled() per element, so native for range loops over bare pipelines honor WithContext.
  • Pick's counting loop gains the check every other intermediate op already had (aligns with Skip/Limit).
  • Docs (export.go WithContext/ToSlice/Last/Count/Seq entries, doc.go, README.md) now state the cooperative element-boundary cancellation semantics precisely (incl. the "later operations only" boundary).

Test evidence

New cancel_bare_test.go:

suite assertion
pre-cancelled, 12 terminals all return empty/zero promptly (ToSlice/Collect/Last/Count/First/Sort/ReverseSort/Reverse/Pick both paths/Execute/Seq range)
infinite source + mid-run cancel 10 sub-cases return within 5s watchdogs
exact README repro Repeat(1).WithContext(ctx).ToSlice() returns after cancel
uncancelled control 7 results unchanged
go test ./... -race -count=1   → ok stream 5.951s / ok tests 1.963s
go vet ./...                   → clean
golangci-lint run              → 0 issues

The WithContext contract (export.go: "a cancelled context makes intermediate
operations stop pulling and terminals return promptly"; doc.go and README
promise WithContext as the remedy for infinite sources) silently failed on
bare pipelines: ToSlice/Collect/Count(-1 path)/Last/First/Seq and the
materialize-based stages (Sort/ReverseSort/Reverse/Pick end<0/Execute)
never consulted ctx, so Repeat(1).WithContext(cancelled).ToSlice() hung
forever.

Root cause: only ForEach/AllMatch/AnyMatch/NonMatch/Reduce*/Take checked
s.cancelled(); materialize() and the remaining terminals pulled upstream
unconditionally.

Fix: materialize(ctx, seq) consults ctx at every element boundary — empty
when cancelled up front, partial results when cancelled mid-stream (same
semantics as the Reduce family) — and all six call sites pass the
streamer's ctx. First/Last/Count(iteration path)/Seq gain per-element
checks; Seq returns a wrapped sequence so native range loops over bare
pipelines honor WithContext; Pick's counting loop gains the check that
every other intermediate op already had. Docs updated to state the
cooperative, element-boundary cancellation semantics precisely.

Tests: cancel_bare_test.go — 12 terminals on a pre-cancelled bare pipeline
return empty/zero; an infinite source unblocks every terminal after
mid-run cancellation (5s watchdogs); the README repro
Repeat(1).WithContext(ctx).ToSlice() returns after cancel; uncancelled
control results unchanged. go test ./... -race -count=1 green; vet clean;
golangci-lint 0 issues.
@tr1v3r
tr1v3r force-pushed the fix/terminal-ctx-cancel branch from 6ce4d34 to caff3b9 Compare September 14, 2026 02:35
@tr1v3r
tr1v3r merged commit 800105d into master Sep 14, 2026
2 checks passed
@tr1v3r
tr1v3r deleted the fix/terminal-ctx-cancel branch September 14, 2026 02:49
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