From 56e02481a4f0e421009413f58493620cc3a51f87 Mon Sep 17 00:00:00 2001 From: Ulises Jeremias Date: Fri, 8 May 2026 15:50:39 -0300 Subject: [PATCH] Context improvements (#27110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(context): fix EmptyContext.done(), propagate_cancel, and OneContext bugs - EmptyContext.done(): return open (never-closed) channel instead of a closed channel. Background/TODO contexts are never canceled; selecting on their done channel should block forever, mirroring Go's nil channel semantics. The previous closed-channel approach caused propagate_cancel to immediately fire and OneContext goroutines to cancel on background contexts. - cancel.v propagate_cancel: add else branch to the non-blocking probe select so it does not block when the parent done channel is open. - onecontext.v: fix ctx2.err() bug (was ctx1.err()), add @[heap] for safe mutable reference passing to spawn, refactor spawn closures to pre-extract done channels (avoids stack-allocation issues), fix deadline() to use a found bool sentinel instead of unix()==0. - onecontext_test.v: re-enable file (remove vtest build: false) All context tests now pass (4 passed, 1 skipped for deadline_test.v). * feat(context): add with_cancel_cause, with_deadline_cause, with_timeout_cause, cause (Go 1.21 APIs) Implements the Go 1.21 context cause API for V: - CancelCauseFunc: fn(cause IError) — cancel with an optional cause - with_cancel_cause(parent): like with_cancel but returns CancelCauseFunc - with_deadline_cause(parent, d, cause): deadline ctx with custom cause - with_timeout_cause(parent, timeout, cause): convenience wrapper - cause(ctx): returns the recorded cause, or ctx.err() as fallback CauseContext wraps CancelContext and stores the cause under a value key so cause() can retrieve it by walking the context chain. First-caller wins: only the first non-none cause is stored. The cause_context_key lookup mirrors the cancel_context_key pattern already used in cancel.v. All 5 context tests pass (plus new cause_test.v: 7 cases). * docs(context): update README with cancellation cause API, fix v check-md errors - Add Cancellation with Causes section explaining with_cancel_cause, cause() - Add Context With Cancellation Cause example - Fix IError implementation (msg() + code() instead of str()) - Use context.cause(ctx) instead of ctx.cause() (free function not method) - Fix optional handling with !is none pattern - Expand onecontext/README.md with full usage examples, deadlines, cancellation * fix(context): address 3 Codex review comments on cause.v 1. deadline_cause: check ctx.cancel_ctx.err is none before recording, so the first cancellation (manual or from parent) always wins. 2. deadline(): store and return the deadline time in CauseContext, so deadline-aware contexts (onecontext.merge, etc.) work correctly. 3. cancel(): propagate parent's cause to children when canceled with the generic 'context canceled' error, fixing cause propagation through derived context trees. * style(context): v fmt cause.v * docs(context): add doc comments to CauseContext methods All pub methods on CauseContext now have proper doc comments to pass CI. --- vlib/context/README.md | 79 +++++++++ vlib/context/cancel.v | 1 + vlib/context/cause.v | 189 ++++++++++++++++++++++ vlib/context/cause_test.v | 112 +++++++++++++ vlib/context/context.v | 3 + vlib/context/empty.v | 18 ++- vlib/context/onecontext/onecontext.v | 28 ++-- vlib/context/onecontext/onecontext_test.v | 1 - 8 files changed, 412 insertions(+), 19 deletions(-) create mode 100644 vlib/context/cause.v create mode 100644 vlib/context/cause_test.v diff --git a/vlib/context/README.md b/vlib/context/README.md index 8f5e8f3e8..08ae2e764 100644 --- a/vlib/context/README.md +++ b/vlib/context/README.md @@ -21,6 +21,50 @@ Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx, just to make it more consistent. +## Cancellation with Causes + +When a context is canceled, you can optionally attach a **cause** — an error +describing why the cancellation happened. Use `with_cancel_cause` instead of +`with_cancel` to attach a cause: + +```v +import context + +fn main() { + mut bg := context.background() + mut ctx, cancel := context.with_cancel_cause(mut bg) + defer { cancel(none) } + + // Cancel with a specific error cause + cancel(error('request timed out')) +} +``` + +Retrieve the cause from any context using `cause(ctx)`: + +```v +import context + +fn main() { + mut bg := context.background() + mut ctx, cancel := context.with_cancel_cause(mut bg) + cancel(none) + c := context.cause(ctx) + if c !is none { + println('canceled: ${c}') + } +} +``` + +The cause is propagated through the context tree. If a parent context is +canceled with a cause, all derived contexts inherit the same cause. +If multiple ancestors are canceled, the first cause encountered is returned. + +### Convenience variants + +- `with_timeout_cause(ctx, dur, err)` — timeout with a custom cause +- `with_deadline_cause(ctx, deadline, err)` — deadline with a custom cause + ## Examples In this section you can see some usage examples for this module @@ -188,3 +232,38 @@ fn main() { assert not_found_value == dump(f(ctx, 'color')) } ``` + +### Context With Cancellation Cause + +```v +import context + +struct AppError { + msg string +} + +fn (e AppError) msg() string { + return e.msg +} + +fn (e AppError) code() int { + return 0 +} + +fn main() { + mut bg := context.background() + mut ctx, cancel := context.with_cancel_cause(mut bg) + defer { cancel(none) } + + // Simulate an error condition and cancel with a cause + cancel(AppError{ + msg: 'operation failed' + }) + + // Retrieve the cause + c := context.cause(ctx) + if c !is none { + eprintln('context canceled: ${c}') + } +} +``` \ No newline at end of file diff --git a/vlib/context/cancel.v b/vlib/context/cancel.v index 70588a246..21d33019f 100644 --- a/vlib/context/cancel.v +++ b/vlib/context/cancel.v @@ -133,6 +133,7 @@ fn propagate_cancel(mut parent Context, mut child Canceler) { child.cancel(false, parent.err()) return } + else {} } mut p := parent_cancel_context(mut parent) or { spawn fn (mut parent Context, mut child Canceler) { diff --git a/vlib/context/cause.v b/vlib/context/cause.v new file mode 100644 index 000000000..9c8d9d1c8 --- /dev/null +++ b/vlib/context/cause.v @@ -0,0 +1,189 @@ +// This module defines the Context type, which carries deadlines, cancellation signals, +// and other request-scoped values across API boundaries and between processes. +// Based on: https://github.com/golang/go/tree/master/src/context +// Last commit: https://github.com/golang/go/commit/52bf14e0e8bdcd73f1ddfb0c4a1d0200097d3ba2 +module context + +import rand +import time + +// cause_context_key is the value-context key used to store the cause in a CauseContext. +const cause_context_key = Key('context.CauseContext') + +// CancelCauseFunc is a cancel function that accepts an optional cause error. +// Calling it with a non-none cause records that cause; calling it with none +// is equivalent to calling a plain CancelFn. +pub type CancelCauseFunc = fn (cause IError) + +// A CauseContext wraps a CancelContext and records a cause error set via +// CancelCauseFunc. It implements the Context interface by delegating to the +// embedded CancelContext. +@[heap] +struct CauseContext { + id string + deadline time.Time +mut: + cancel_ctx CancelContext + cause IError = none +} + +// with_cancel_cause behaves like with_cancel but returns a CancelCauseFunc +// instead of a CancelFn. Calling the returned function with a non-none error +// records that error as the cause, retrievable via cause(ctx). Calling it with +// none is identical to a plain cancel. +// +// Example: +// mut bg := context.background() +// mut ctx, cancel := context.with_cancel_cause(mut bg) +// defer { cancel(none) } +// cancel(my_err) +// assert context.cause(ctx).str() == my_err.str() +pub fn with_cancel_cause(mut parent Context) (Context, CancelCauseFunc) { + id := rand.uuid_v4() + inner := new_cancel_context(parent) + mut ctx := &CauseContext{ + id: id + cancel_ctx: inner + } + propagate_cancel(mut parent, mut ctx) + cancel_fn := fn [mut ctx] (cause IError) { + if cause !is none { + ctx.cancel_ctx.mutex.lock() + if ctx.cause is none { + ctx.cause = cause + } + ctx.cancel_ctx.mutex.unlock() + } + ctx.cancel_ctx.cancel(true, canceled) + } + return Context(ctx), CancelCauseFunc(cancel_fn) +} + +// with_deadline_cause behaves like with_deadline but accepts a cause error that +// is recorded when the deadline fires (instead of the generic deadline_exceeded). +// If cancel is called before the deadline, that cancellation takes effect instead. +pub fn with_deadline_cause(mut parent Context, d time.Time, deadline_cause IError) (Context, CancelFn) { + if cur := parent.deadline() { + if cur < d { + return with_cancel(mut parent) + } + } + inner := new_cancel_context(parent) + id := rand.uuid_v4() + mut ctx := &CauseContext{ + id: id + cancel_ctx: inner + deadline: d + } + propagate_cancel(mut parent, mut ctx) + dur := d - time.now() + if dur.nanoseconds() <= 0 { + // deadline already passed + ctx.cause = deadline_cause + ctx.cancel_ctx.cancel(false, deadline_exceeded) + cancel_fn := fn [mut ctx] () { + ctx.cancel_ctx.cancel(true, canceled) + } + return Context(ctx), CancelFn(cancel_fn) + } + spawn fn (mut ctx CauseContext, dur time.Duration, deadline_cause IError) { + time.sleep(dur) + ctx.cancel_ctx.mutex.lock() + // Only record deadline cause if the context wasn't already canceled, + // so that the first cancellation's cause (manual or from parent) wins. + if ctx.cancel_ctx.err is none { + ctx.cause = deadline_cause + } + ctx.cancel_ctx.mutex.unlock() + ctx.cancel_ctx.cancel(true, deadline_exceeded) + }(mut ctx, dur, deadline_cause) + cancel_fn := fn [mut ctx] () { + ctx.cancel_ctx.cancel(true, canceled) + } + return Context(ctx), CancelFn(cancel_fn) +} + +// with_timeout_cause is a convenience wrapper around with_deadline_cause using +// a relative duration instead of an absolute time. +pub fn with_timeout_cause(mut parent Context, timeout time.Duration, deadline_cause IError) (Context, CancelFn) { + return with_deadline_cause(mut parent, time.now().add(timeout), deadline_cause) +} + +// cause returns the cause of the context's cancellation. +// If ctx was canceled via CancelCauseFunc with a non-none cause, that cause is +// returned. Otherwise, cause returns ctx.err() (i.e. canceled or +// deadline_exceeded). +pub fn cause(ctx Context) IError { + mut mctx := ctx + if val := mctx.value(cause_context_key) { + match val { + CauseContext { + val.cancel_ctx.mutex.lock() + c := val.cause + val.cancel_ctx.mutex.unlock() + if c !is none { + return c + } + } + else {} + } + } + return mctx.err() +} + +// --- CauseContext implements Context and Canceler --- + +// deadline returns the deadline for the context, or none if no deadline is set. +pub fn (ctx &CauseContext) deadline() ?time.Time { + if ctx.deadline != time.Time{} { + return ctx.deadline + } + return none +} + +// done returns a channel that is closed when the context is canceled. +pub fn (mut ctx CauseContext) done() chan int { + return ctx.cancel_ctx.done() +} + +// err returns the error that canceled the context, or none if not canceled. +pub fn (mut ctx CauseContext) err() IError { + return ctx.cancel_ctx.err() +} + +// value returns the cause context itself when the key matches cause_context_key, +// otherwise delegates to the embedded cancel context. +pub fn (ctx &CauseContext) value(key Key) ?Any { + if key == cause_context_key { + return ctx + } + return ctx.cancel_ctx.value(key) +} + +// str returns a string representation of the context (e.g. 'EmptyContext.with_cancel_cause'). +pub fn (ctx &CauseContext) str() string { + return context_name(ctx.cancel_ctx.context) + '.with_cancel_cause' +} + +// cancel cancels the context. If remove_from_parent is true, it also removes +// this context from its parent's children list. The err is recorded as the cause. +pub fn (mut ctx CauseContext) cancel(remove_from_parent bool, err IError) { + // If being canceled with the generic 'context canceled' error and we + // have a parent, propagate the parent's cause so children can retrieve it. + if err.str() == 'context canceled' { + match ctx.cancel_ctx.context { + EmptyContext {} + else { + parent_cause := cause(ctx.cancel_ctx.context) + if parent_cause !is none { + ctx.cause = parent_cause + } + } + } + } + ctx.cancel_ctx.cancel(false, err) + if remove_from_parent { + mut cc := &ctx.cancel_ctx.context + remove_child(mut cc, ctx) + } +} diff --git a/vlib/context/cause_test.v b/vlib/context/cause_test.v new file mode 100644 index 000000000..b20a3942e --- /dev/null +++ b/vlib/context/cause_test.v @@ -0,0 +1,112 @@ +// vtest retry: 3 +module context + +import time + +fn test_with_cancel_cause_no_cause() { + mut bg := background() + mut ctx, cancel := with_cancel_cause(mut bg) + defer { + cancel(none) + } + + assert ctx.err() is none + assert cause(ctx) is none + + cancel(none) + assert ctx.err().str() == 'context canceled' + // cause falls back to err() when no cause was set + assert cause(ctx).str() == 'context canceled' +} + +fn test_with_cancel_cause_with_cause() { + my_err := error('my custom cause') + + mut bg := background() + mut ctx, cancel := with_cancel_cause(mut bg) + defer { + cancel(none) + } + + assert ctx.err() is none + + cancel(my_err) + assert ctx.err().str() == 'context canceled' + assert cause(ctx).str() == 'my custom cause' +} + +fn test_with_cancel_cause_first_cause_wins() { + first := error('first cause') + second := error('second cause') + + mut bg := background() + mut ctx, cancel := with_cancel_cause(mut bg) + + cancel(first) + cancel(second) // should be ignored + cancel(none) // should be ignored + + assert cause(ctx).str() == 'first cause' +} + +fn test_with_cancel_cause_parent_cancel_propagates() { + mut bg := background() + mut parent, parent_cancel := with_cancel(mut bg) + mut ctx, _ := with_cancel_cause(mut parent) + + assert ctx.err() is none + + parent_cancel() + // give goroutine a moment to propagate + time.sleep(5 * time.millisecond) + + assert ctx.err().str() == 'context canceled' +} + +fn test_with_timeout_cause_fires_cause() { + my_cause := error('timed out for testing') + + mut bg := background() + mut ctx, cancel := with_timeout_cause(mut bg, 30 * time.millisecond, my_cause) + defer { + cancel() + } + + // not done yet + assert ctx.err() is none + + // wait for timeout + time.sleep(60 * time.millisecond) + + assert ctx.err().str() == 'context deadline exceeded' + assert cause(ctx).str() == 'timed out for testing' +} + +fn test_with_timeout_cause_cancel_before_deadline() { + my_cause := error('deadline cause') + + mut bg := background() + mut ctx, cancel := with_timeout_cause(mut bg, 500 * time.millisecond, my_cause) + + // cancel before deadline fires + cancel() + time.sleep(5 * time.millisecond) + + assert ctx.err().str() == 'context canceled' + // no cause was set via CancelCauseFunc; cause falls back to err() + assert cause(ctx).str() == 'context canceled' +} + +fn test_cause_on_plain_cancel_context() { + // cause() on a non-cause context should just return ctx.err() + mut bg := background() + mut ctx, cancel := with_cancel(mut bg) + defer { + cancel() + } + + assert cause(ctx) is none + + cancel() + assert cause(ctx).str() == 'context canceled' +} diff --git a/vlib/context/context.v b/vlib/context/context.v index af25bf31d..197b7049a 100644 --- a/vlib/context/context.v +++ b/vlib/context/context.v @@ -86,6 +86,9 @@ pub fn (ctx &Context) str() string { CancelContext { return ctx.str() } + CauseContext { + return ctx.str() + } TimerContext { return ctx.str() } diff --git a/vlib/context/empty.v b/vlib/context/empty.v index 87a762777..5497f2b3e 100644 --- a/vlib/context/empty.v +++ b/vlib/context/empty.v @@ -7,18 +7,24 @@ module context import time // An EmptyContext is never canceled, has no values. -pub struct EmptyContext {} +// The done_ch field is an open channel that is never closed, returned by done() +// on every call. This mirrors Go's context.Background().Done() == nil behavior: +// selecting on this channel blocks forever, meaning the context is never done. +pub struct EmptyContext { +mut: + done_ch chan int +} // deadline returns none, since an EmptyContext has no deadline. pub fn (ctx &EmptyContext) deadline() ?time.Time { return none } -// done returns a closed channel, since an EmptyContext can never be canceled. -pub fn (ctx &EmptyContext) done() chan int { - ch := chan int{} - ch.close() - return ch +// done returns an open channel that is never closed, since an EmptyContext can +// never be canceled. Selecting on the returned channel blocks forever. +// The same channel instance is returned on every call for a given EmptyContext. +pub fn (mut ctx EmptyContext) done() chan int { + return ctx.done_ch } // err returns none, since an EmptyContext is never canceled. diff --git a/vlib/context/onecontext/onecontext.v b/vlib/context/onecontext/onecontext.v index 3890c4e44..c70f47fcc 100644 --- a/vlib/context/onecontext/onecontext.v +++ b/vlib/context/onecontext/onecontext.v @@ -7,6 +7,7 @@ import time // canceled is the error returned when the cancel function is called on a merged context pub const canceled = error('canceled context') +@[heap] struct OneContext { mut: ctx context.Context @@ -38,20 +39,23 @@ pub fn merge(ctx context.Context, ctxs ...context.Context) (context.Context, con // or none if no context has a deadline set. pub fn (octx OneContext) deadline() ?time.Time { mut min := time.Time{} + mut found := false if deadline := octx.ctx.deadline() { min = deadline + found = true } for ctx in octx.ctxs { if deadline := ctx.deadline() { - if min.unix() == 0 || deadline < min { + if !found || deadline < min { min = deadline } + found = true } } - if min.unix() == 0 { + if !found { return none } @@ -124,10 +128,10 @@ pub fn (mut octx OneContext) cancel(err IError) { // run_two_contexts spawns a listener that cancels the merged context // when either of the two given contexts is done. pub fn (mut octx OneContext) run_two_contexts(mut ctx1 context.Context, mut ctx2 context.Context) { - spawn fn (mut octx OneContext, mut ctx1 context.Context, mut ctx2 context.Context) { - octx_cancel_done := octx.cancel_ctx.done() - c1done := ctx1.done() - c2done := ctx2.done() + octx_cancel_done := octx.cancel_ctx.done() + c1done := ctx1.done() + c2done := ctx2.done() + spawn fn (mut octx OneContext, octx_cancel_done chan int, c1done chan int, c2done chan int, mut ctx1 context.Context, mut ctx2 context.Context) { select { _ := <-octx_cancel_done { octx.cancel(canceled) @@ -136,18 +140,18 @@ pub fn (mut octx OneContext) run_two_contexts(mut ctx1 context.Context, mut ctx2 octx.cancel(ctx1.err()) } _ := <-c2done { - octx.cancel(ctx1.err()) + octx.cancel(ctx2.err()) } } - }(mut &octx, mut &ctx1, mut &ctx2) + }(mut &octx, octx_cancel_done, c1done, c2done, mut ctx1, mut ctx2) } // run_multiple_contexts spawns a listener that cancels the merged context // when the given context is done. pub fn (mut octx OneContext) run_multiple_contexts(mut ctx context.Context) { - spawn fn (mut octx OneContext, mut ctx context.Context) { - octx_cancel_done := octx.cancel_ctx.done() - cdone := ctx.done() + octx_cancel_done := octx.cancel_ctx.done() + cdone := ctx.done() + spawn fn (mut octx OneContext, octx_cancel_done chan int, cdone chan int, mut ctx context.Context) { select { _ := <-octx_cancel_done { octx.cancel(canceled) @@ -156,5 +160,5 @@ pub fn (mut octx OneContext) run_multiple_contexts(mut ctx context.Context) { octx.cancel(ctx.err()) } } - }(mut &octx, mut &ctx) + }(mut &octx, octx_cancel_done, cdone, mut ctx) } diff --git a/vlib/context/onecontext/onecontext_test.v b/vlib/context/onecontext/onecontext_test.v index 00ca5bd9c..f7912a1f4 100644 --- a/vlib/context/onecontext/onecontext_test.v +++ b/vlib/context/onecontext/onecontext_test.v @@ -1,4 +1,3 @@ -// vtest build: false // backtrace_symbols is missing module onecontext import context -- 2.39.5