Initial project import

This commit is contained in:
Agila Forge Importer committed 2026-07-13 15:20:32 +00:00
commit 23f2d29e32
11957 files changed
+1754577

No files matched your search

+581
View File
@@ -0,0 +1,581 @@
# x.async
`x.async` is a small structured-concurrency layer for V programs.
It composes the primitives that already exist in V: `spawn`,
`sync.WaitGroup`, channels, `context`, and `time`.
The module does not add a scheduler, does not change the language, and does not
implement `async/await`. It must not become a hidden runtime, event loop,
external dependency, or competing concurrency model. Its purpose is narrower:
make common concurrent control-flow easier to write, stop, test, and review.
Safety is the primary design constraint. Public APIs should be defensive, shared
state must be synchronized explicitly, errors must not be lost silently,
channels must not leave workers blocked without a consumer, and behavior must
remain robust in `-prod`.
The current API intentionally centers on five building blocks, plus small
context and function-type helpers:
- `Group`: run related jobs, wait for them, return the first error, and cancel
sibling jobs cooperatively.
- `Task[T]`: run one value-returning job and wait for its result once.
- `Pool`: run accepted jobs with a fixed concurrency limit and bounded backlog.
- periodic jobs: run a blocking `every()` loop or a detached
`PeriodicHandle` without overlapping iterations.
- `with_timeout` / `with_timeout_context`: run one job with a bounded deadline.
Ticker objects and server-specific helpers are not part of this API.
`PeriodicHandle` is only an explicit `stop()` / `wait()` lifecycle handle for
one detached periodic loop.
## Why
Raw `spawn` plus `sync.WaitGroup` is explicit and fast, but real applications
quickly need the same extra wiring around it:
- one parent cancellation signal shared by all jobs;
- one place to wait for all accepted jobs;
- predictable first-error propagation;
- value-returning tasks without hand-written result channels;
- bounded worker pools for request bursts or CPU-heavy server work;
- periodic cleanup loops that stop through context cancellation;
- fail-fast sibling cancellation;
- timeouts that do not require hand-written result channels in every caller.
`x.async` keeps those mechanics small and visible. A job is still a normal V
function. Cancellation is still exposed as a normal `context.Context`. Waiting
for groups is still backed by `sync.WaitGroup`; value and timeout handoff uses
small bounded channels. Pools use fixed worker slots plus a bounded accepted-job
backlog.
Internally, `x.async` owns the derived cancellation context used by its jobs.
That internal context does not schedule work; it only keeps cancellation and
timeout propagation deterministic for this module.
## Quick Start
```v
import context
import time
import x.async
fn main() {
parent := context.background()
mut group := async.new_group(parent)
group.go(fn (mut ctx context.Context) ! {
// A long-running job should observe ctx.done().
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
50 * time.millisecond {
return
}
}
})!
group.go(fn (mut ctx context.Context) ! {
_ = ctx
return error('stop the group')
})!
group.wait() or { eprintln('group failed: ${err.msg()}') }
}
```
## API
### Context helpers
`async.background()` returns `context.background()`.
`async.with_cancel()` returns a cancellable context derived from background:
```v ignore
ctx, cancel := async.with_cancel()
defer {
cancel()
}
```
These helpers are convenience wrappers. Code that already has a
`context.Context` should pass it directly to `new_group()` or
`with_timeout_context()`.
### JobFn
```v ignore
pub type JobFn = fn (mut context.Context) !
pub type TaskFn[T] = fn (mut context.Context) !T
```
Every `x.async` job receives a context. This is a deliberate choice:
cancellation is part of the function signature instead of a hidden global side
channel.
## Cooperative Cancellation
Cancellation in `x.async` is cooperative. The module closes the shared context's
`done()` channel, but it does not interrupt, kill, or preempt a running thread.
A job that can run for a long time should check the context:
```v ignore
fn worker(mut ctx context.Context) ! {
done := ctx.done()
for {
select {
_ := <-done {
return ctx.err()
}
10 * time.millisecond {
// Do a small unit of work.
}
}
}
}
```
If a job ignores `ctx.done()`, `Group.wait()` will still wait for it to return.
`Pool.close()`, `every()`, and `PeriodicHandle.wait()` also wait for running
non-cooperative jobs to return. `with_timeout()` returns when the timeout
expires, but the ignored job may keep running until it finishes naturally.
## Group
`Group` coordinates related jobs.
```v ignore
parent := context.background()
mut group := async.new_group(parent)
group.go(fn (mut ctx context.Context) ! {
serve_http(mut ctx)!
})!
group.go(fn (mut ctx context.Context) ! {
cleanup_loop(mut ctx)!
})!
group.wait()!
```
Guarantees:
- `go()` accepts a job only before `wait()` starts.
- `wait()` blocks until every accepted job has finished.
- `wait()` is safe when no job was accepted.
- the first job error is stored once and returned by `wait()`;
- the first job error cancels the shared context so cooperative sibling jobs can
stop early;
- calling `go()` after `wait()` starts returns an error instead of racing
`sync.WaitGroup.add()` against `sync.WaitGroup.wait()`.
`wait()` is a one-shot operation. Calling it a second time returns an error. This
keeps the lifecycle simple: create a group, submit jobs, wait once, then discard
the group.
Use `new_group_with_config()` only when callers need a bounded snapshot of job
errors in addition to the first error returned by `wait()`:
```v ignore
mut group := async.new_group_with_config(parent, collect_errors: true, max_errors: 8)!
```
Error collection is opt-in. `new_group()` does not collect errors, and
`errors()` returns an empty array for that default mode. When collection is
enabled, `max_errors` must be positive; collection stores at most `max_errors`
observed job errors and `errors()` returns a copy of that bounded snapshot.
`wait()` still returns only the first observed error and never returns an
aggregate. Concurrent error order is not guaranteed.
## Task
`Task[T]` represents one concurrent computation that returns either a value or
an error.
```v ignore
mut task := async.run[string](fn (mut ctx context.Context) !string {
_ = ctx
return load_config()!
})!
config := task.wait()!
```
Use `run_with_context()` when the task should follow an existing parent context:
```v ignore
parent := context.background()
mut task := async.run_with_context[int](parent, fn (mut ctx context.Context) !int {
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
50 * time.millisecond {
return 42
}
}
})!
value := task.wait()!
```
Guarantees:
- `run()` and `run_with_context()` reject a nil task function.
- `wait()` blocks until the task publishes its single result.
- `wait()` is one-shot; calling it a second time returns an error.
- the result channel is bounded with capacity 1, so a finished task can publish
its value or error before the owner calls `wait()`.
- parent cancellation is cooperative; the task function must observe
`ctx.done()` and return.
`Task[T]` does not expose a kill operation. A task that ignores cancellation may
continue until its function returns naturally.
## Pool
`Pool` limits how many jobs run concurrently and how many pending jobs can wait
in memory.
```v ignore
mut pool := async.new_pool(workers: 4, queue_size: 128)!
defer {
pool.close() or {}
}
pool.try_submit(fn (mut ctx context.Context) ! {
process_message(mut ctx)!
})!
```
Use `new_pool_with_context()` when the pool should follow an existing parent
context:
```v ignore
parent := context.background()
mut pool := async.new_pool_with_context(parent, workers: 2, queue_size: 32)!
```
Backpressure is explicit. `try_submit()` never waits for backlog space:
- if the pool is open and the backlog has capacity, the job is accepted;
- if the backlog is full, it returns `async: pool queue is full`;
- if the pool is closed or already waiting, it returns `async: pool is closed`;
- if the job function is nil, it returns `async: job function is nil`.
Use `submit_with_context()` when the caller should wait for backlog space, but
only while a parent context remains active:
```v ignore
parent_ctx, cancel := async.with_cancel()
defer {
cancel()
}
pool.submit_with_context(parent_ctx, fn (mut ctx context.Context) ! {
process_message(mut ctx)!
})!
```
Use `submit_with_timeout()` when admission should wait only up to a fixed
duration:
```v ignore
pool.submit_with_timeout(250 * time.millisecond, fn (mut ctx context.Context) ! {
process_message(mut ctx)!
})!
```
The context or timeout bounds admission only. Once the job is accepted, it
receives the pool's context, the same as `try_submit()`. If the parent context is
canceled before acceptance, `submit_with_context()` returns that parent context
error. If the timeout expires before acceptance, `submit_with_timeout()` returns
`async: timeout`. If `wait()` or `close()` starts while a caller is waiting for
admission, the submit call returns `async: pool is closed`.
Lifecycle:
- `workers` and `queue_size` must be positive and are fixed at creation.
- at most `workers` accepted jobs execute user code at the same time.
- at most `workers + queue_size` jobs can be accepted and unfinished at once.
- `close()` stops accepting new jobs, waits for accepted jobs, and returns the
first job error if any.
- `wait()` has the same behavior as `close()`; both are one-shot.
- a job error is stored once and returned by `close()` / `wait()`.
- `Pool` is not fail-fast: a job error does not kill running jobs, does not
cancel already accepted sibling jobs, and does not discard accepted backlog.
- parent cancellation is cooperative; jobs must observe `ctx.done()` and return.
`Pool` does not kill running jobs. A non-cooperative job can delay `close()` until
it returns naturally. `close()` and `wait()` drain accepted jobs before returning
the first stored error. User code never runs while the pool lifecycle mutex is
held.
## Periodic Jobs
`every()` runs a job repeatedly until its context is canceled or the job returns
an error. It is blocking and does not start background work:
```v ignore
ctx, cancel := async.with_cancel()
defer {
cancel()
}
async.every(ctx, 5 * time.second, fn (mut ctx context.Context) ! {
cleanup_stale_clients(mut ctx)!
})!
```
Guarantees:
- `interval` must be positive; zero or negative intervals return
`async: interval must be positive`.
- `every()` is blocking and does not start a hidden background loop.
- the first iteration runs after one interval.
- iterations never overlap; a slow iteration delays the next one.
- a job error stops the loop and is returned unchanged.
- context cancellation stops the loop and returns the context error.
Use `start_every()` when the periodic loop should run in the background with an
explicit lifecycle handle:
```v ignore
ctx, cancel := async.with_cancel()
defer {
cancel()
}
mut handle := async.start_every(ctx, 5 * time.second, fn (mut ctx context.Context) ! {
cleanup_stale_clients(mut ctx)!
})!
defer {
handle.stop()
handle.wait() or {}
}
```
`start_every()` has the same interval, nil-job, first-tick, no-overlap, and job
error rules as `every()`, but returns immediately after starting one detached
loop. If the parent context is already canceled, it returns the parent context
error and does not start a worker.
`PeriodicHandle` lifecycle:
- `stop()` is idempotent and requests cooperative shutdown of the detached loop;
- `stop()` is non-blocking and does not kill a running job;
- `wait()` blocks until the detached loop exits;
- `wait()` is one-shot; a second call returns
`async: periodic wait was already called`;
- a normal `stop()` makes `wait()` return successfully;
- a job error is returned unchanged by `wait()`, even if its message matches
`context canceled`;
- parent cancellation is returned by `wait()` as the parent context error.
The detached loop publishes its final result through a bounded channel with
capacity 1, so it can exit even if the owner has not called `wait()` yet.
A normal stop is recognized only when the loop itself observes the handle-owned
context cancellation outside a user job error. If parent cancellation is already
observable when `stop()` is called, parent cancellation wins and `wait()` returns
the parent context error.
If the owner never calls `stop()` or the parent context is never canceled, the
detached loop can keep running. If the owner calls `stop()` but never calls
`wait()`, a running non-cooperative job may still continue until it returns
naturally.
Periodic helpers are not schedulers and do not expose ticker objects. They run
one serial loop; a slow iteration delays the next one.
## Timeout
`with_timeout()` runs one job with a background context and a timeout:
```v ignore
async.with_timeout(2 * time.second, fn (mut ctx context.Context) ! {
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
100 * time.millisecond {
return
}
}
})!
```
`with_timeout_context()` derives the timeout from an existing parent context:
```v ignore
parent := context.background()
async.with_timeout_context(parent, 250 * time.millisecond, fn (mut ctx context.Context) ! {
fetch_or_compute(mut ctx)!
})!
```
Error behavior:
- if the job finishes before the effective timeout deadline, the job error is
returned unchanged;
- if the timeout expires first, the public error is `async: timeout`;
- if the parent context is canceled first, including when the parent's own
deadline expires first, the parent context error is returned;
- a job that observes the local `x.async` timeout by returning
`context deadline exceeded` is normalized to `async: timeout`.
Timeout ownership matters at the deadline boundary. If the job result is received
first but the job finished at or after an `x.async`-owned timeout deadline,
`with_timeout_context()` still returns `async: timeout`. That normalization is
not applied to a shorter parent-owned deadline; parent cancellation keeps the
parent context error path.
The result channel used internally is buffered so the spawned job can finish and
publish its result even if the caller has already returned on timeout.
## Safety And Security Notes
`x.async` is about control-flow safety, not sandboxing.
- It does not recover panics from spawned jobs.
- It does not kill work that ignores cancellation.
- It does not validate user input, paths, network data, or files.
- `Task[T].wait()` is one-shot so result ownership is unambiguous.
- `Pool.try_submit()` is non-blocking and returns a stable error when the
accepted-job backlog is full instead of hiding backpressure.
- `Pool.close()` drains accepted jobs before returning and reports the first
job error.
- `every()` is blocking and serial, so periodic iterations cannot overlap.
- `PeriodicHandle.stop()` is cooperative, and `PeriodicHandle.wait()` is
one-shot so detached periodic lifecycle ownership is explicit.
- It uses `sync.Mutex` to protect mutable lifecycle/result state in `Group`,
`Task[T]`, `Pool`, and `PeriodicHandle`.
- It keeps `sync.WaitGroup.add()` and `sync.WaitGroup.wait()` separated by a
lifecycle mutex for `Group` and `Pool` where accepted work can race with
shutdown.
When jobs process untrusted input, the application must still apply the normal
validation and resource limits for that domain.
## Limits
This milestone does not include:
- unbounded blocking pool submission without a context or timeout;
- ticker objects;
- multi-consumer futures or promise chaining;
- panic recovery;
- scheduler changes;
- `goroutines`, `coroutines`, `x.atomics`, or `sync.stdatomic`.
The current module deliberately stays small. Its job is to structure V's
existing `spawn`, `sync`, channels, `context`, and `time` primitives, not to
replace them with a new runtime.
Possible future additions, if accepted by the project maintainers and backed by
tests, could still fit the current philosophy:
- more examples and integration tests for other V modules that already use
concurrency;
- careful rewrites of existing V modules that need concurrency, if maintainers
decide `x.async` makes their lifecycle, cancellation, or backpressure simpler
and safer without breaking compatibility;
- more benchmarks and stress tests that remain bounded and reproducible.
Other ideas would require a separate design because they move beyond this
module's current scope:
- a scheduler or event loop;
- async/await syntax or compiler changes;
- green threads or coroutine/goroutine integration;
- preemptive cancellation or killing running jobs;
- panic recovery across spawned work;
- low-level atomic or lock-free rewrites of the public API.
Those larger features may be useful someday, but they should not be hidden
inside `x.async`. They need separate review so this module can remain minimal,
safe, and easy to reason about.
## Examples
Small runnable examples live in `vlib/x/async/examples/`:
- `basic_group.v`: first error propagation and cooperative sibling cancellation.
- `basic_task.v`: run one value-returning task and consume it with `wait()`.
- `worker_pool.v`: fixed concurrency with explicit `try_submit()` backpressure.
- `bounded_pool_submit.v`: context/timeout-bounded `Pool` admission.
- `periodic.v`: a blocking `every()` loop stopped by context cancellation.
- `timeout.v`: run one cooperative job with a bounded timeout.
- `net_http/`: synthetic `net.http` request/response work through `Pool`.
- `net_websocket/`: in-memory `websocket.Message` processing through `Group`;
this is not an end-to-end websocket server test.
- `mcp/`: in-memory MCP request/response dispatch through `Task[T]`.
- `veb/`: in-memory `veb.Context` response lifecycle through `Task[T]`.
Each example focuses on one API and uses only local, in-memory work.
## Tests
The targeted test suite lives next to the module:
```sh
v test vlib/x/async
v -prod test vlib/x/async
```
For automated validation, prefer the guarded script:
```sh
sh vlib/x/async/tools/validate.sh
```
It runs formatting verification, dev tests, and `-prod` tests serially with a
fresh `VTMP` and `VCACHE` for that run. Do not run two V validation runners
against the same checkout/cache unless each runner has isolated `VTMP`,
`VCACHE`, and output paths. That isolation protects the validation harness from
V build artefact collisions; it is separate from the runtime guarantees of
`x.async`.
The tests cover successful groups, first-error propagation, empty waits,
rejected submissions after `wait()`, optional bounded group error collection,
one-shot task waits, task values and errors, pool worker limits, pool queue
backpressure, pool close/wait behavior, bounded pool submission with context
cancellation and timeout, periodic execution, detached periodic handle stop/wait
lifecycle, periodic cancellation, non-overlapping periodic iterations,
cooperative cancellation, timeout errors, parent cancellation, nil job rejection,
parent deadline preservation, invalid intervals, zero/already-expired timeouts,
stress cases, and jobs that ignore cancellation. Internal tests also verify that
the derived `AsyncContext` closes `done()` and propagates parent
cancellation.
Module-oriented integration tests live in `vlib/x/async/tests/`. They are
synthetic and local: no external service, fixed port, path dependency, or fragile
server shutdown is required. They currently cover `net.http`, `net.websocket`,
`mcp`, and `veb` boundaries.
## Benchmarks
Small local benchmarks live in `vlib/x/async/benchmarks/`:
```sh
sh vlib/x/async/benchmarks/run_async_benchmark.sh
```
The script uses the local `./v`, runs serially, and isolates `VTMP`, `VCACHE`,
and the benchmark executable output. It measures short default runs for
`Group`, `Task[T]`, `Pool`, bounded pool admission, `with_timeout()`, and
`every()`.
The default sizes are intentionally modest. They can be changed with
`XASYNC_BENCH_GROUP_ROUNDS`, `XASYNC_BENCH_GROUP_JOBS`,
`XASYNC_BENCH_TASK_ROUNDS`, `XASYNC_BENCH_POOL_JOBS`,
`XASYNC_BENCH_POOL_WORKERS`, `XASYNC_BENCH_POOL_BOUNDED_ROUNDS`,
`XASYNC_BENCH_POOL_BOUNDED_TIMEOUT_MS`, `XASYNC_BENCH_TIMEOUT_ROUNDS`,
`XASYNC_BENCH_EVERY_ITERATIONS`, and `XASYNC_BENCH_EVERY_INTERVAL_MS`.
Benchmark output is local diagnostic data, not a portable performance claim.
+35
View File
@@ -0,0 +1,35 @@
module async
import context
// JobFn is the function signature run by Group and timeout helpers.
//
// The passed context is canceled when the parent context is canceled, when a
// group task fails, or when a timeout expires. Cancellation is cooperative:
// jobs should observe `ctx.done()` and return.
pub type JobFn = fn (mut context.Context) !
// TaskFn is the function signature run by Task.
//
// It mirrors JobFn but returns a value. Like all x.async work, the function
// receives a context and must observe `ctx.done()` for cooperative cancellation.
pub type TaskFn[T] = fn (mut context.Context) !T
// background returns a root context for async helpers.
//
// It is intentionally just a thin wrapper around `context.background()` so code
// can start with `async.background()` and later pass the same context to the
// standard `context` APIs without conversion.
pub fn background() context.Context {
return context.background()
}
// with_cancel returns a cancellable context derived from background.
//
// Call the returned cancel function when the surrounding operation is done, even
// when all jobs completed successfully. That mirrors `context.with_cancel()` and
// releases parent/child cancellation references promptly.
pub fn with_cancel() (context.Context, context.CancelFn) {
ctx, cancel := new_cancel_context(context.background())
return context.Context(ctx), cancel
}
+46
View File
@@ -0,0 +1,46 @@
# x.async benchmarks
This folder contains cautious local benchmarks for the public `x.async` API.
They are observation tools, not correctness tests and not portable performance
claims.
## Available benchmark
- `async_benchmark.v`: measures short default runs for `Group`, `Task[T]`,
`Pool`, bounded pool admission, `with_timeout()`, and `every()`.
- `run_async_benchmark.sh`: builds and runs the benchmark with the local `./v`,
isolated `VTMP`/`VCACHE`, and a temporary output binary.
## Run
From the repository root:
```sh
sh vlib/x/async/benchmarks/run_async_benchmark.sh
```
The script is serialized by design. Do not run multiple V benchmark or test
runners against the same checkout/cache without isolating `VTMP`, `VCACHE`, and
output paths.
## Parameters
The defaults are intentionally small. Tune them locally with:
- `XASYNC_BENCH_GROUP_ROUNDS`
- `XASYNC_BENCH_GROUP_JOBS`
- `XASYNC_BENCH_TASK_ROUNDS`
- `XASYNC_BENCH_POOL_JOBS`
- `XASYNC_BENCH_POOL_WORKERS`
- `XASYNC_BENCH_POOL_BOUNDED_ROUNDS`
- `XASYNC_BENCH_POOL_BOUNDED_TIMEOUT_MS`
- `XASYNC_BENCH_TIMEOUT_ROUNDS`
- `XASYNC_BENCH_EVERY_ITERATIONS`
- `XASYNC_BENCH_EVERY_INTERVAL_MS`
The bounded pool admission case exercises `submit_with_timeout()` while the
pool is full and `submit_with_context()` after capacity opens. The timeout only
bounds admission; the benchmark does not make performance assertions.
Do not commit machine-specific benchmark results as permanent truth. Tests
remain the authority for functional and concurrency-safety validation.
@@ -0,0 +1,270 @@
import benchmark
import context
import os
import time
import x.async as xasync
const default_group_rounds = 8
const default_group_jobs = 16
const default_task_rounds = 32
const default_pool_jobs = 64
const default_pool_workers = 4
const default_pool_bounded_rounds = 8
const default_pool_bounded_timeout_ms = 2
const default_timeout_rounds = 32
const default_every_iterations = 5
const default_every_interval_ms = 1
fn main() {
run_benchmarks() or {
eprintln('x.async benchmark failed: ${err.msg()}')
exit(1)
}
}
fn run_benchmarks() ! {
group_rounds := env_int('XASYNC_BENCH_GROUP_ROUNDS', default_group_rounds, 1, 200)
group_jobs := env_int('XASYNC_BENCH_GROUP_JOBS', default_group_jobs, 1, 512)
task_rounds := env_int('XASYNC_BENCH_TASK_ROUNDS', default_task_rounds, 1, 500)
pool_jobs := env_int('XASYNC_BENCH_POOL_JOBS', default_pool_jobs, 1, 1000)
pool_workers := env_int('XASYNC_BENCH_POOL_WORKERS', default_pool_workers, 1, 64)
pool_bounded_rounds := env_int('XASYNC_BENCH_POOL_BOUNDED_ROUNDS', default_pool_bounded_rounds,
1, 100)
pool_bounded_timeout_ms := env_int('XASYNC_BENCH_POOL_BOUNDED_TIMEOUT_MS',
default_pool_bounded_timeout_ms, 1, 100)
timeout_rounds := env_int('XASYNC_BENCH_TIMEOUT_ROUNDS', default_timeout_rounds, 1, 500)
every_iterations := env_int('XASYNC_BENCH_EVERY_ITERATIONS', default_every_iterations, 1, 100)
every_interval_ms :=
env_int('XASYNC_BENCH_EVERY_INTERVAL_MS', default_every_interval_ms, 1, 100)
println('x.async cautious benchmark')
println('Override sizes with XASYNC_BENCH_* environment variables.')
mut checksum := 0
mut b := benchmark.start()
checksum += bench_group(group_rounds, group_jobs)!
b.measure('Group: rounds=${group_rounds}, jobs_per_round=${group_jobs}, checksum=${checksum}')
checksum += bench_task(task_rounds)!
b.measure('Task: rounds=${task_rounds}, checksum=${checksum}')
checksum += bench_pool(pool_jobs, pool_workers)!
b.measure('Pool: jobs=${pool_jobs}, workers=${pool_workers}, checksum=${checksum}')
checksum += bench_pool_bounded_admission(pool_bounded_rounds, pool_bounded_timeout_ms)!
b.measure('Pool bounded admission: rounds=${pool_bounded_rounds}, timeout_ms=${pool_bounded_timeout_ms}, checksum=${checksum}')
checksum += bench_timeout(timeout_rounds)!
b.measure('with_timeout: rounds=${timeout_rounds}, checksum=${checksum}')
checksum += bench_every(every_iterations, every_interval_ms)!
b.measure('every: iterations=${every_iterations}, interval_ms=${every_interval_ms}, checksum=${checksum}')
}
fn env_int(name string, default_value int, min_value int, max_value int) int {
raw := os.getenv(name)
if raw == '' {
return default_value
}
mut value := raw.int()
if value < min_value {
value = min_value
}
if value > max_value {
value = max_value
}
return value
}
fn bench_group(rounds int, jobs_per_round int) !int {
mut total := 0
for _ in 0 .. rounds {
done := chan int{cap: jobs_per_round}
mut group := xasync.new_group(context.background())
for _ in 0 .. jobs_per_round {
group.go(fn [done] (mut ctx context.Context) ! {
_ = ctx
done <- 1
})!
}
group.wait()!
for _ in 0 .. jobs_per_round {
total += <-done
}
}
return total
}
fn bench_task(rounds int) !int {
mut total := 0
for i in 0 .. rounds {
mut task := xasync.run[int](fn [i] (mut ctx context.Context) !int {
_ = ctx
return i + 1
})!
total += task.wait()!
}
return total
}
fn bench_pool(jobs int, workers int) !int {
done := chan int{cap: jobs}
mut pool := xasync.new_pool(workers: workers, queue_size: jobs)!
for _ in 0 .. jobs {
pool.try_submit(fn [done] (mut ctx context.Context) ! {
_ = ctx
done <- 1
})!
}
pool.close()!
mut total := 0
for _ in 0 .. jobs {
total += <-done
}
return total
}
fn bench_pool_bounded_admission(rounds int, timeout_ms int) !int {
mut total := 0
admission_timeout := time.Duration(timeout_ms) * time.millisecond
for _ in 0 .. rounds {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
started := chan bool{cap: 2}
release := chan bool{cap: 3}
finished := chan bool{cap: 2}
attempting := chan bool{cap: 1}
accepted := chan bool{cap: 1}
ran := chan int{cap: 1}
blocking_job := fn [started, release, finished] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
finished <- true
}
pool.try_submit(blocking_job)!
wait_for_benchmark_bool(started, 'bounded admission pool job did not start')!
pool.try_submit(blocking_job)!
mut timed_out := false
pool.submit_with_timeout(admission_timeout, fn (mut ctx context.Context) ! {
_ = ctx
}) or {
if err.msg() != 'async: timeout' {
return err
}
timed_out = true
}
if !timed_out {
release <- true
release <- true
pool.close()!
return error('submit_with_timeout accepted while the pool was full')
}
submit_thread := spawn fn [mut pool, attempting, accepted, ran] () {
attempting <- true
pool.submit_with_context(context.background(), fn [ran] (mut ctx context.Context) ! {
_ = ctx
ran <- 1
}) or {
accepted <- false
return
}
accepted <- true
}()
wait_for_benchmark_bool(attempting, 'bounded admission submitter did not start')!
release <- true
wait_for_benchmark_bool(finished, 'bounded admission first job did not finish')!
wait_for_benchmark_bool(accepted,
'submit_with_context did not accept after capacity opened')!
release <- true
total += read_benchmark_int(ran, 'bounded admission accepted job did not run')!
pool.close()!
submit_thread.wait()
}
return total
}
fn bench_timeout(rounds int) !int {
mut total := 0
for _ in 0 .. rounds {
xasync.with_timeout(1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
})!
total++
}
return total
}
fn bench_every(iterations int, interval_ms int) !int {
ctx, cancel := xasync.with_cancel()
ticks := chan int{cap: iterations + 4}
result := chan string{cap: 1}
interval := time.Duration(interval_ms) * time.millisecond
worker := spawn fn [ctx, ticks, result, interval] () {
xasync.every(ctx, interval, fn [ticks] (mut ctx context.Context) ! {
_ = ctx
ticks <- 1
}) or {
result <- err.msg()
return
}
result <- 'ok'
}()
mut total := 0
for _ in 0 .. iterations {
select {
value := <-ticks {
total += value
}
1 * time.second {
cancel()
worker.wait()
return error('every benchmark did not receive a tick')
}
}
}
cancel()
select {
msg := <-result {
if msg != 'context canceled' {
worker.wait()
return error('unexpected every benchmark result: ${msg}')
}
}
1 * time.second {
worker.wait()
return error('every benchmark did not stop after cancellation')
}
}
worker.wait()
return total
}
fn wait_for_benchmark_bool(signal chan bool, message string) ! {
select {
ok := <-signal {
if !ok {
return error(message)
}
}
1 * time.second {
return error(message)
}
}
}
fn read_benchmark_int(signal chan int, message string) !int {
select {
value := <-signal {
return value
}
1 * time.second {
return error(message)
}
}
return 0
}
@@ -0,0 +1,32 @@
#!/usr/bin/env sh
set -eu
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
repo_root=$(CDPATH= cd "$script_dir/../../../.." && pwd)
cd "$repo_root"
if [ ! -x ./v ]; then
echo "x.async benchmarks must be run from a V checkout with local ./v" >&2
exit 1
fi
tmp_root=$(mktemp -d "${TMPDIR:-/tmp}/xasync-benchmark.XXXXXX")
cleanup() {
rm -rf "$tmp_root"
}
trap cleanup EXIT INT TERM
vtmp="$tmp_root/vtmp"
vcache="$tmp_root/vcache"
out="$tmp_root/out/async_benchmark"
mkdir -p "$vtmp" "$vcache" "$(dirname "$out")"
echo "Running x.async benchmark with isolated VTMP and VCACHE."
echo "Tune sizes with XASYNC_BENCH_* environment variables."
echo "See vlib/x/async/benchmarks/README.md for the full list."
if command -v timeout >/dev/null 2>&1; then
timeout 180s env VTMP="$vtmp" VCACHE="$vcache" ./v -prod -o "$out" run vlib/x/async/benchmarks/async_benchmark.v
else
env VTMP="$vtmp" VCACHE="$vcache" ./v -prod -o "$out" run vlib/x/async/benchmarks/async_benchmark.v
fi
+180
View File
@@ -0,0 +1,180 @@
module async
import context
import sync
import time
enum CancelSource {
unknown
parent
timeout
cancel
}
// AsyncContext is the small `context.Context` implementation owned by x.async.
//
// The public API still accepts and returns the standard `context.Context`
// interface. Internally, x.async needs a derived context whose cancellation is
// simple, idempotent, and non-blocking for Group and timeout lifecycles.
//
// This is not a scheduler or runtime. It is only the cancellation signal shared
// by jobs launched through this module.
@[heap]
struct AsyncContext {
mut:
parent context.Context
done chan int
mutex &sync.Mutex = sync.new_mutex()
err IError = none
cancel_src CancelSource
has_deadline bool
deadline_at time.Time
deadline_src CancelSource
}
fn new_cancel_context(parent context.Context) (&AsyncContext, context.CancelFn) {
mut ctx := &AsyncContext{
parent: parent
done: chan int{cap: 1}
mutex: sync.new_mutex()
}
if !ctx.propagate_existing_parent_error() {
spawn watch_parent_context(mut ctx)
}
cancel_fn := fn [mut ctx] () {
ctx.cancel_with(error(context_canceled), .cancel)
}
return ctx, context.CancelFn(cancel_fn)
}
fn new_timeout_context(parent context.Context, timeout time.Duration) (&AsyncContext, context.CancelFn) {
mut deadline_at := time.now().add(timeout)
mut deadline_src := CancelSource.timeout
if parent_deadline := parent.deadline() {
if parent_deadline < deadline_at {
deadline_at = parent_deadline
deadline_src = .parent
}
}
mut ctx := &AsyncContext{
parent: parent
done: chan int{cap: 1}
mutex: sync.new_mutex()
has_deadline: true
deadline_at: deadline_at
deadline_src: deadline_src
}
if !ctx.propagate_existing_parent_error() {
spawn watch_parent_context(mut ctx)
}
if deadline_src == .timeout {
effective_timeout := deadline_at - time.now()
if effective_timeout.nanoseconds() <= 0 {
ctx.cancel_with(error(context_deadline_exceeded), .timeout)
} else {
spawn watch_timeout_context(mut ctx, effective_timeout)
}
}
cancel_fn := fn [mut ctx] () {
ctx.cancel_with(error(context_canceled), .cancel)
}
return ctx, context.CancelFn(cancel_fn)
}
// deadline returns x.async's own deadline when present, otherwise the parent's
// deadline. This preserves deadline metadata for callers that inspect it.
pub fn (ctx &AsyncContext) deadline() ?time.Time {
if ctx.has_deadline {
return ctx.deadline_at
}
return ctx.parent.deadline()
}
// value delegates to the parent context. x.async does not add request values.
pub fn (ctx &AsyncContext) value(key context.Key) ?context.Any {
return ctx.parent.value(key)
}
// done returns the cancellation channel shared by jobs using this context.
pub fn (mut ctx AsyncContext) done() chan int {
ctx.mutex.lock()
done := ctx.done
ctx.mutex.unlock()
return done
}
// err returns the local cancellation reason, or the parent's reason if the
// parent is already canceled before propagation reaches this context.
pub fn (mut ctx AsyncContext) err() IError {
ctx.mutex.lock()
err := ctx.err
ctx.mutex.unlock()
if err !is none {
return err
}
return ctx.parent.err()
}
fn (ctx &AsyncContext) was_canceled_by_timeout() bool {
ctx.mutex.lock()
cancel_src := ctx.cancel_src
ctx.mutex.unlock()
return cancel_src == .timeout
}
fn (mut ctx AsyncContext) cancel_with(err IError, cancel_src CancelSource) {
if err is none {
return
}
ctx.mutex.lock()
if ctx.err !is none {
ctx.mutex.unlock()
return
}
ctx.err = err
ctx.cancel_src = cancel_src
if !ctx.done.closed {
ctx.done <- 0
ctx.done.close()
}
ctx.mutex.unlock()
}
fn (mut ctx AsyncContext) propagate_existing_parent_error() bool {
mut parent := ctx.parent
err := parent.err()
if err !is none {
ctx.cancel_with(err, .parent)
return true
}
return false
}
fn watch_parent_context(mut ctx AsyncContext) {
mut parent := ctx.parent
parent_done := parent.done()
local_done := ctx.done()
select {
_ := <-local_done {
return
}
_ := <-parent_done {
err := parent.err()
if err !is none {
ctx.cancel_with(err, .parent)
}
}
}
}
fn watch_timeout_context(mut ctx AsyncContext, timeout time.Duration) {
local_done := ctx.done()
select {
_ := <-local_done {
return
}
timeout {
ctx.cancel_with(error(context_deadline_exceeded), .timeout)
}
}
}
@@ -0,0 +1,80 @@
module async
import context
import time
fn test_async_context_cancel_closes_done_and_sets_error() {
mut ctx, cancel := new_cancel_context(context.background())
cancel()
done := ctx.done()
select {
_ := <-done {
assert ctx.err().msg() == context_canceled
}
1 * time.second {
assert false, 'cancel did not close AsyncContext.done()'
}
}
cancel()
assert ctx.err().msg() == context_canceled
}
fn test_async_context_timeout_closes_done_and_sets_deadline_error() {
mut ctx, cancel := new_timeout_context(context.background(), 20 * time.millisecond)
defer {
cancel()
}
done := ctx.done()
select {
_ := <-done {
assert ctx.err().msg() == context_deadline_exceeded
}
1 * time.second {
assert false, 'timeout did not close AsyncContext.done()'
}
}
}
fn test_timeout_result_detects_only_owned_deadline_miss() {
mut ctx, cancel := new_timeout_context(context.background(), 1 * time.second)
defer {
cancel()
}
assert !TimeoutResult{
finished_at: ctx.deadline_at.add(-1 * time.nanosecond)
}.finished_after_owned_timeout(ctx)
assert TimeoutResult{
finished_at: ctx.deadline_at.add(1 * time.nanosecond)
}.finished_after_owned_timeout(ctx)
mut background := context.background()
parent, parent_cancel := context.with_timeout(mut background, 1 * time.second)
defer {
parent_cancel()
}
mut child, child_cancel := new_timeout_context(parent, 2 * time.second)
defer {
child_cancel()
}
assert !TimeoutResult{
finished_at: child.deadline_at.add(1 * time.nanosecond)
}.finished_after_owned_timeout(child)
}
fn test_async_context_parent_cancel_propagates_to_child() {
parent, parent_cancel := new_cancel_context(context.background())
mut child, child_cancel := new_cancel_context(context.Context(parent))
defer {
child_cancel()
}
parent_cancel()
done := child.done()
select {
_ := <-done {
assert child.err().msg() == context_canceled
}
1 * time.second {
assert false, 'parent cancellation did not reach child AsyncContext'
}
}
}
+24
View File
@@ -0,0 +1,24 @@
module async
// Error strings are centralized because they form the observable contract for
// callers and tests. Keep them short, stable, and explicit about whether the
// failure came from x.async or from the parent context.
const err_group_go_after_wait = 'async: group does not accept new tasks after wait starts'
const err_group_max_errors_invalid = 'async: group max_errors must be positive'
const err_group_wait_called = 'async: group wait was already called'
const err_interval_invalid = 'async: interval must be positive'
const err_nil_job = 'async: job function is nil'
const err_periodic_wait_called = 'async: periodic wait was already called'
const err_pool_closed = 'async: pool is closed'
const err_pool_queue_full = 'async: pool queue is full'
const err_pool_queue_size_invalid = 'async: pool queue size must be positive'
const err_pool_wait_called = 'async: pool wait was already called'
const err_pool_workers_invalid = 'async: pool worker count must be positive'
const err_task_wait_called = 'async: task wait was already called'
const err_timeout = 'async: timeout'
const context_canceled = 'context canceled'
// The current context module exposes deadline expiry as an IError with this
// message. x.async normalizes that specific deadline error to `async: timeout`.
const context_deadline_exceeded = 'context deadline exceeded'
+54
View File
@@ -0,0 +1,54 @@
# x.async examples
This folder contains short public examples for the current `x.async` API.
Each file focuses on one idea and uses only local, in-memory work.
Examples are documentation-first programs. They show how the public API is
expected to be used in small readable scenarios. They are executed by the
validation script to avoid bit rot, but the regression guarantees live in the
`tests/` folder.
## Rules
- No external service is required.
- No fixed local path is used.
- No network listener is opened.
- Examples avoid `panic()` as a control-flow pattern.
- Cancellation remains cooperative; examples show jobs checking `ctx.done()`
when they may run for more than a tiny unit of work.
## Examples
- `basic_group.v`: first error propagation and cooperative sibling
cancellation.
- `basic_task.v`: one value-returning task consumed with `wait()`.
- `worker_pool.v`: fixed concurrency and explicit `try_submit()` backpressure.
- `bounded_pool_submit.v`: wait for pool admission with a timeout or context
while jobs keep using the pool context.
- `periodic.v`: a blocking `every()` loop stopped by context cancellation.
- `timeout.v`: one cooperative job bounded by `with_timeout()`.
- `net_http/`: synthetic `net.http` request/response processing with `Pool`.
- `net_websocket/`: in-memory `websocket.Message` processing. It is not an
end-to-end websocket server example.
- `mcp/`: in-memory MCP request/response dispatch with `Task[T]`.
- `veb/`: synthetic `veb.Context` response lifecycle with `Task[T]`.
## Run
From the repository root:
```sh
./v run vlib/x/async/examples/basic_group.v
./v run vlib/x/async/examples/basic_task.v
./v run vlib/x/async/examples/worker_pool.v
./v run vlib/x/async/examples/bounded_pool_submit.v
./v run vlib/x/async/examples/periodic.v
./v run vlib/x/async/examples/timeout.v
./v run vlib/x/async/examples/net_http/request_batch.v
./v run vlib/x/async/examples/net_websocket/message_pipeline.v
./v run vlib/x/async/examples/mcp/tool_dispatch.v
./v run vlib/x/async/examples/veb/app_lifecycle.v
```
Use `tools/validate.sh` for the guarded module validation path. It isolates V
temporary/cache directories and keeps validation runners serialized.
@@ -0,0 +1,30 @@
import context
import time
import x.async as xasync
fn main() {
mut group := xasync.new_group(context.background())
group.go(fn (mut ctx context.Context) ! {
_ = ctx
time.sleep(20 * time.millisecond)
return error('stop group')
})!
group.go(fn (mut ctx context.Context) ! {
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
1 * time.second {
return error('worker did not observe cancellation')
}
}
})!
group.wait() or {
println('group failed: ${err.msg()}')
return
}
}
@@ -0,0 +1,12 @@
import context
import x.async as xasync
fn main() {
mut task := xasync.run[int](fn (mut ctx context.Context) !int {
_ = ctx
return 42
})!
value := task.wait()!
println('task result: ${value}')
}
@@ -0,0 +1,77 @@
import context
import time
import x.async as xasync
fn main() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
started := chan bool{cap: 2}
release := chan bool{cap: 2}
accepted := chan bool{cap: 1}
ran := chan bool{cap: 1}
context_ran := chan bool{cap: 1}
blocking_job := fn [started, release] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
}
pool.try_submit(blocking_job)!
if !wait_bool(started) {
eprintln('first pool job did not start')
exit(1)
}
pool.try_submit(blocking_job)!
submitter := spawn fn [mut pool, accepted, ran] () {
pool.submit_with_timeout(1 * time.second, fn [ran] (mut ctx context.Context) ! {
_ = ctx
ran <- true
}) or {
eprintln('bounded submit failed: ${err.msg()}')
accepted <- false
return
}
accepted <- true
}()
release <- true
if !wait_bool(accepted) {
eprintln('bounded submit was not accepted after capacity opened')
exit(1)
}
release <- true
if !wait_bool(ran) {
eprintln('bounded submit job did not run')
exit(1)
}
parent_ctx, cancel := xasync.with_cancel()
defer {
cancel()
}
pool.submit_with_context(parent_ctx, fn [context_ran] (mut ctx context.Context) ! {
_ = ctx
context_ran <- true
})!
if !wait_bool(context_ran) {
eprintln('context-bounded submit job did not run')
exit(1)
}
pool.close()!
submitter.wait()
println('timeout and context bounded submits completed')
}
fn wait_bool(ch chan bool) bool {
select {
ok := <-ch {
return ok
}
1 * time.second {
return false
}
}
return false
}
@@ -0,0 +1,18 @@
# mcp integration example
This example uses MCP JSON-RPC values in memory and runs the dispatch step in an
`x.async.Task`. It does not start stdio, HTTP, a process transport, or any
external service.
The goal is to show the integration boundary:
- build an MCP request value;
- decode and validate it inside a task;
- return an MCP response value;
- consume the result with `Task.wait()`.
Run from the repository root:
```sh
./v run vlib/x/async/examples/mcp/tool_dispatch.v
```
@@ -0,0 +1,37 @@
import context
import mcp
import x.async as xasync
struct EchoArgs {
pub:
text string
}
fn dispatch_tool(raw string) !mcp.Response {
req := mcp.decode_request(raw)!
if req.method != 'tools/call' {
return error('unsupported MCP method')
}
args := req.decode_params[EchoArgs]()!
result := mcp.tool_text_result('echo: ${args.text}')
return mcp.new_response(1, result, mcp.ResponseError{})
}
fn main() {
request := mcp.new_request(1, 'tools/call', EchoArgs{
text: 'hello'
})
mut task := xasync.run[mcp.Response](fn [request] (mut ctx context.Context) !mcp.Response {
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
else {}
}
return dispatch_tool(request.encode())!
})!
response := task.wait()!
println(response.encode())
}
@@ -0,0 +1,21 @@
# net.http integration example
This example shows how `x.async` can coordinate local `net.http` request and
response handling without opening a network listener.
It is intentionally synthetic: requests are constructed in memory, processed by
a small handler, and drained through a `Pool`. This keeps the example stable
while still showing the lifecycle pattern used by HTTP-facing code:
- construct or receive a `http.Request`;
- submit bounded work to an `x.async.Pool`;
- publish a `http.Response` result;
- close the pool and report the first error if one occurred.
Run from the repository root:
```sh
./v run vlib/x/async/examples/net_http/request_batch.v
```
No external service, fixed port, file path, or real HTTP server is used.
@@ -0,0 +1,47 @@
import context
import net.http
import x.async as xasync
fn synthetic_http_handler(req http.Request) !http.Response {
path := if req.url.contains('?') { req.url.all_before('?') } else { req.url }
match path {
'/health' {
return http.new_response(status: .ok, body: 'healthy')
}
'/ready' {
return http.new_response(status: .ok, body: 'ready')
}
else {
return http.new_response(status: .not_found, body: 'missing')
}
}
}
fn main() {
requests := [
http.new_request(.get, '/health', ''),
http.new_request(.get, '/ready', ''),
http.new_request(.get, '/missing', ''),
]
responses := chan string{cap: requests.len}
mut pool := xasync.new_pool(workers: 2, queue_size: requests.len)!
for req in requests {
pool.try_submit(fn [req, responses] (mut ctx context.Context) ! {
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
else {}
}
resp := synthetic_http_handler(req)!
responses <- '${req.url} -> ${resp.status_code} ${resp.body}'
})!
}
pool.close()!
for _ in 0 .. requests.len {
println(<-responses)
}
}
@@ -0,0 +1,19 @@
# net.websocket integration example
This example stays in memory around `websocket.Message` processing. It does not
open a websocket server, does not connect a client, and must not be read as an
end-to-end websocket server validation.
That boundary is deliberate. In this V snapshot, `websocket.Server.close()` is
not a complete, stable server shutdown mechanism suitable for fragile
validation. The public example therefore demonstrates the safe part that
`x.async` can own here: coordinating message/callback-style work and propagating
errors without inventing a websocket runtime.
Run from the repository root:
```sh
./v run vlib/x/async/examples/net_websocket/message_pipeline.v
```
No external service, fixed port, or websocket server lifecycle is used.
@@ -0,0 +1,49 @@
import context
import net.websocket
import x.async as xasync
fn describe_message(msg websocket.Message) !string {
return match msg.opcode {
.text_frame {
'text:${msg.payload.bytestr()}'
}
.ping {
'ping'
}
else {
error('unsupported websocket message opcode')
}
}
}
fn main() {
messages := [
websocket.Message{
opcode: .text_frame
payload: 'hello'.bytes()
},
websocket.Message{
opcode: .ping
},
]
processed := chan string{cap: messages.len}
mut group := xasync.new_group(context.background())
for msg in messages {
group.go(fn [msg, processed] (mut ctx context.Context) ! {
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
else {}
}
processed <- describe_message(msg)!
})!
}
group.wait()!
for _ in 0 .. messages.len {
println(<-processed)
}
}
+49
View File
@@ -0,0 +1,49 @@
import context
import time
import x.async as xasync
fn main() {
ctx, cancel := xasync.with_cancel()
ticks := chan int{cap: 4}
done := chan string{cap: 1}
worker := spawn fn [ctx, ticks, done] () {
xasync.every(ctx, 10 * time.millisecond, fn [ticks] (mut ctx context.Context) ! {
_ = ctx
ticks <- 1
}) or {
done <- err.msg()
return
}
done <- 'completed'
}()
if !wait_tick(ticks) || !wait_tick(ticks) {
eprintln('periodic job did not tick')
exit(1)
}
cancel()
select {
msg := <-done {
println('periodic stopped: ${msg}')
}
1 * time.second {
eprintln('periodic job did not stop')
exit(1)
}
}
worker.wait()
}
fn wait_tick(ticks chan int) bool {
select {
_ := <-ticks {
return true
}
1 * time.second {
return false
}
}
return false
}
+23
View File
@@ -0,0 +1,23 @@
import context
import time
import x.async as xasync
fn main() {
xasync.with_timeout(20 * time.millisecond, fn (mut ctx context.Context) ! {
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
1 * time.second {
return error('work unexpectedly finished')
}
}
}) or {
println('timeout result: ${err.msg()}')
return
}
eprintln('expected timeout error')
exit(1)
}
@@ -0,0 +1,14 @@
# veb integration example
This example is a compile-time, in-memory `veb.Context` lifecycle sample. It
does not start a `veb` server and does not bind a port.
The example is intentionally small because robust server lifecycle validation
belongs to `veb` itself. Here, `x.async` only coordinates a synthetic handler
step and returns the response body through `Task.wait()`.
Run from the repository root:
```sh
./v run vlib/x/async/examples/veb/app_lifecycle.v
```
@@ -0,0 +1,27 @@
import context
import veb
import x.async as xasync
fn render_health_response() !string {
mut web_ctx := veb.Context{}
_ := web_ctx.text('veb ok')
if web_ctx.res.status_code != 200 {
return error('unexpected veb status')
}
return web_ctx.res.body
}
fn main() {
mut task := xasync.run[string](fn (mut ctx context.Context) !string {
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
else {}
}
return render_health_response()!
})!
println(task.wait()!)
}
@@ -0,0 +1,45 @@
import context
import time
import x.async as xasync
fn main() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
started := chan int{cap: 2}
release := chan bool{cap: 2}
job := fn [started, release] (mut ctx context.Context) ! {
_ = ctx
started <- 1
_ := <-release
}
pool.try_submit(job)!
if !wait_started(started) {
eprintln('pool job did not start')
exit(1)
}
pool.try_submit(job)!
pool.try_submit(job) or { println('backpressure: ${err.msg()}') }
release <- true
if !wait_started(started) {
eprintln('pool job did not start')
exit(1)
}
release <- true
pool.close()!
println('pool drained')
}
fn wait_started(started chan int) bool {
select {
_ := <-started {
return true
}
1 * time.second {
return false
}
}
return false
}
+155
View File
@@ -0,0 +1,155 @@
module async
import context
import sync
// GroupConfig enables optional Group behavior while preserving new_group() defaults.
@[params]
pub struct GroupConfig {
pub:
collect_errors bool
max_errors int
}
// Group coordinates a set of related concurrent jobs.
//
// Jobs share a derived context. The first job error is returned by wait() and
// cancels the shared context so sibling jobs can stop cooperatively.
@[heap]
pub struct Group {
mut:
ctx context.Context
cancel context.CancelFn = unsafe { nil }
wg &sync.WaitGroup = sync.new_waitgroup()
// Protects both WaitGroup lifecycle state and first_err. WaitGroup.add()
// must not race with WaitGroup.wait(), so go() and wait() share this lock.
mutex &sync.Mutex = sync.new_mutex()
// Stored once. Later job errors never replace the first failure observed by
// the group, even when several jobs fail concurrently after cancellation.
first_err IError = none
// Optional bounded collection of observed job errors. It does not change
// wait(), which still returns only first_err.
collect_errors bool
max_errors int
collected_errors []IError
// Set before wait() calls wg.wait(). Once true, no further jobs are accepted.
waiting bool
}
// new_group creates a Group with a shared cancellable context derived from parent.
//
// The parent is accepted by value to keep the public call site simple. The
// derived context is owned by the group and canceled on first job error or when
// wait() completes.
pub fn new_group(parent context.Context) &Group {
return new_group_internal(parent, GroupConfig{})
}
// new_group_with_config creates a Group with opt-in bounded error collection.
pub fn new_group_with_config(parent context.Context, config GroupConfig) !&Group {
if config.collect_errors && config.max_errors <= 0 {
return error(err_group_max_errors_invalid)
}
return new_group_internal(parent, config)
}
fn new_group_internal(parent context.Context, config GroupConfig) &Group {
ctx, cancel := new_cancel_context(parent)
return &Group{
ctx: context.Context(ctx)
cancel: cancel
wg: sync.new_waitgroup()
mutex: sync.new_mutex()
collect_errors: config.collect_errors
max_errors: config.max_errors
}
}
// go starts f in a new concurrent task.
//
// Calling go after wait has started returns an error. The task should not
// panic; panics in spawned work are not recovered by x.async.
pub fn (mut g Group) go(f JobFn) ! {
if f == unsafe { nil } {
return error(err_nil_job)
}
g.mutex.lock()
if g.waiting {
g.mutex.unlock()
return error(err_group_go_after_wait)
}
// add() happens while holding the same mutex that guards wait(), preventing
// callers from triggering sync.WaitGroup's add-while-waiting misuse panic.
g.wg.add(1)
g.mutex.unlock()
spawn run_group_job(mut g, f)
}
// wait blocks until all accepted group jobs finish.
//
// It returns the first job error, if any. wait may be called once; after it
// starts, the group no longer accepts new jobs.
pub fn (mut g Group) wait() ! {
g.mutex.lock()
if g.waiting {
g.mutex.unlock()
return error(err_group_wait_called)
}
g.waiting = true
g.mutex.unlock()
g.wg.wait()
// Always cancel after all jobs finish to release the derived context and to
// make the lifecycle symmetric with context.with_cancel().
g.cancel()
err := g.get_first_error()
if err !is none {
return err
}
}
// errors returns a snapshot of collected job errors.
//
// Collection is opt-in through new_group_with_config(). The order of concurrently
// observed errors is not guaranteed.
pub fn (mut g Group) errors() []IError {
g.mutex.lock()
errs := g.collected_errors.clone()
g.mutex.unlock()
return errs
}
fn run_group_job(mut g Group, f JobFn) {
defer {
g.wg.done()
}
// Each job gets its own local mutable interface value. The underlying
// context is shared and synchronized by the context module.
mut job_ctx := g.ctx
f(mut job_ctx) or { g.set_first_error(err) }
}
fn (mut g Group) set_first_error(err IError) {
mut should_cancel := false
g.mutex.lock()
if g.first_err is none {
g.first_err = err
should_cancel = true
}
if g.collect_errors && g.collected_errors.len < g.max_errors {
g.collected_errors << err
}
g.mutex.unlock()
if should_cancel {
// Cancel outside the group mutex. context cancellation can notify child
// contexts, so keeping our own lock out of that path avoids lock nesting.
g.cancel()
}
}
fn (mut g Group) get_first_error() IError {
g.mutex.lock()
err := g.first_err
g.mutex.unlock()
return err
}
+338
View File
@@ -0,0 +1,338 @@
import context
import time
import x.async as xasync
fn test_with_cancel_sets_error_and_closes_done() {
mut ctx, cancel := xasync.with_cancel()
cancel()
done := ctx.done()
select {
_ := <-done {
assert ctx.err().msg() == 'context canceled'
}
1 * time.second {
assert false, 'cancel did not close the context done channel'
}
}
}
fn test_group_success() {
parent := context.background()
mut group := xasync.new_group(parent)
done := chan int{cap: 2}
group.go(fn [done] (mut ctx context.Context) ! {
_ = ctx
done <- 1
})!
group.go(fn [done] (mut ctx context.Context) ! {
_ = ctx
done <- 2
})!
group.wait()!
assert (<-done) + (<-done) == 3
}
fn test_group_error_returns_first_error() {
parent := context.background()
mut group := xasync.new_group(parent)
group.go(fn (mut ctx context.Context) ! {
_ = ctx
return error('group failed')
})!
group.wait() or {
assert err.msg() == 'group failed'
return
}
assert false
}
fn test_group_default_does_not_collect_errors() {
mut group := xasync.new_group(context.background())
group.go(fn (mut ctx context.Context) ! {
_ = ctx
return error('group failed')
})!
group.wait() or {
assert err.msg() == 'group failed'
assert group.errors().len == 0
return
}
assert false
}
fn test_group_rejects_invalid_error_collection_config() {
xasync.new_group_with_config(context.background(), collect_errors: true, max_errors: 0) or {
assert err.msg() == 'async: group max_errors must be positive'
return
}
assert false
}
fn test_group_wait_without_tasks() {
parent := context.background()
mut group := xasync.new_group(parent)
group.wait()!
}
fn test_group_refuses_go_after_wait() {
parent := context.background()
mut group := xasync.new_group(parent)
group.wait()!
group.go(fn (mut ctx context.Context) ! {
_ = ctx
}) or {
assert err.msg() == 'async: group does not accept new tasks after wait starts'
return
}
assert false
}
fn test_group_refuses_second_wait() {
parent := context.background()
mut group := xasync.new_group(parent)
group.wait()!
group.wait() or {
assert err.msg() == 'async: group wait was already called'
return
}
assert false
}
fn test_group_refuses_nil_job() {
parent := context.background()
mut group := xasync.new_group(parent)
nil_job := unsafe { xasync.JobFn(nil) }
group.go(nil_job) or {
assert err.msg() == 'async: job function is nil'
return
}
assert false
}
fn test_group_cancels_siblings_cooperatively() {
parent := context.background()
mut group := xasync.new_group(parent)
cancelled := chan bool{cap: 1}
group.go(fn (mut ctx context.Context) ! {
_ = ctx
return error('stop siblings')
})!
group.go(fn [cancelled] (mut ctx context.Context) ! {
done := ctx.done()
select {
_ := <-done {
cancelled <- true
}
1 * time.second {
cancelled <- false
}
}
})!
group.wait() or { assert err.msg() == 'stop siblings' }
assert <-cancelled
}
fn test_group_first_error_remains_stable_with_concurrent_secondary_errors() {
parent := context.background()
mut group := xasync.new_group(parent)
secondary_ready := chan bool{cap: 8}
for _ in 0 .. 8 {
group.go(fn [secondary_ready] (mut ctx context.Context) ! {
secondary_ready <- true
done := ctx.done()
select {
_ := <-done {
return error('secondary error after cancellation')
}
1 * time.second {
return error('secondary error timeout')
}
}
})!
}
for _ in 0 .. 8 {
assert <-secondary_ready
}
group.go(fn (mut ctx context.Context) ! {
_ = ctx
return error('primary error')
})!
group.wait() or {
assert err.msg() == 'primary error'
return
}
assert false
}
fn test_group_collects_errors_without_changing_wait_error() {
mut group := xasync.new_group_with_config(context.background(),
collect_errors: true
max_errors: 4
)!
secondary_ready := chan bool{cap: 3}
for i in 0 .. 3 {
group.go(fn [secondary_ready, i] (mut ctx context.Context) ! {
secondary_ready <- true
done := ctx.done()
select {
_ := <-done {
return error('secondary collected error ${i}')
}
1 * time.second {
return error('secondary timeout ${i}')
}
}
})!
}
for _ in 0 .. 3 {
assert <-secondary_ready
}
group.go(fn (mut ctx context.Context) ! {
_ = ctx
return error('primary collected error')
})!
group.wait() or {
assert err.msg() == 'primary collected error'
errs := group.errors()
assert errs.len == 4
assert error_messages_contain(errs, 'primary collected error')
return
}
assert false
}
fn test_group_error_collection_is_bounded_and_includes_first_error() {
mut group := xasync.new_group_with_config(context.background(),
collect_errors: true
max_errors: 2
)!
secondary_ready := chan bool{cap: 4}
for i in 0 .. 4 {
group.go(fn [secondary_ready, i] (mut ctx context.Context) ! {
secondary_ready <- true
done := ctx.done()
select {
_ := <-done {
return error('bounded secondary error ${i}')
}
1 * time.second {
return error('bounded secondary timeout ${i}')
}
}
})!
}
for _ in 0 .. 4 {
assert <-secondary_ready
}
group.go(fn (mut ctx context.Context) ! {
_ = ctx
return error('bounded primary error')
})!
group.wait() or {
assert err.msg() == 'bounded primary error'
errs := group.errors()
assert errs.len == 2
assert error_messages_contain(errs, 'bounded primary error')
return
}
assert false
}
fn test_group_errors_returns_snapshot_copy() {
mut group := xasync.new_group_with_config(context.background(),
collect_errors: true
max_errors: 1
)!
group.go(fn (mut ctx context.Context) ! {
_ = ctx
return error('snapshot source error')
})!
group.wait() or {
assert err.msg() == 'snapshot source error'
mut snapshot := group.errors()
assert snapshot.len == 1
snapshot[0] = error('mutated snapshot error')
second_snapshot := group.errors()
assert second_snapshot.len == 1
assert second_snapshot[0].msg() == 'snapshot source error'
return
}
assert false
}
fn test_group_many_short_jobs_return_first_error() {
jobs := 64
mut group := xasync.new_group(context.background())
done := chan int{cap: jobs}
for i in 0 .. jobs {
group.go(fn [done, i] (mut ctx context.Context) ! {
_ = ctx
done <- i
})!
}
group.go(fn (mut ctx context.Context) ! {
_ = ctx
return error('group stress failure')
})!
group.wait() or {
assert err.msg() == 'group stress failure'
mut seen := []bool{len: jobs}
for _ in 0 .. jobs {
select {
i := <-done {
assert i >= 0
assert i < jobs
assert !seen[i]
seen[i] = true
}
1 * time.second {
assert false, 'group short job did not finish'
}
}
}
for was_seen in seen {
assert was_seen
}
return
}
assert false
}
fn test_group_parent_cancellation_is_observed_by_cooperative_job() {
parent_ctx, cancel := xasync.with_cancel()
mut group := xasync.new_group(parent_ctx)
observed := chan string{cap: 1}
group.go(fn [observed] (mut ctx context.Context) ! {
done := ctx.done()
select {
_ := <-done {
err := ctx.err()
observed <- err.msg()
return err
}
1 * time.second {
observed <- 'not canceled'
return error('parent cancellation was not observed')
}
}
})!
cancel()
group.wait() or { assert err.msg() == 'context canceled' }
select {
msg := <-observed {
assert msg == 'context canceled'
}
2 * time.second {
assert false, 'cooperative group job did not observe parent cancellation'
}
}
}
fn error_messages_contain(errs []IError, msg string) bool {
for err in errs {
if err.msg() == msg {
return true
}
}
return false
}
+249
View File
@@ -0,0 +1,249 @@
module async
import context
import sync
import time
enum PeriodicResultKind {
completed
job_error
context_error
}
struct PeriodicResult {
kind PeriodicResultKind
err IError = none
}
// PeriodicHandle owns one detached periodic loop started by start_every().
//
// The handle exposes only explicit lifecycle operations: stop() requests
// cooperative shutdown, and wait() consumes the loop result once.
@[heap]
pub struct PeriodicHandle {
mut:
parent context.Context
ctx context.Context
cancel context.CancelFn = unsafe { nil }
result_ch chan PeriodicResult
mutex &sync.Mutex = sync.new_mutex()
stopped bool
stop_owns_cancel bool
waited bool
}
// every runs f repeatedly with interval spacing until ctx is canceled or f fails.
//
// This helper is intentionally blocking: it does not start a hidden scheduler or
// background loop. The first iteration runs after one interval. Iterations never
// overlap; a slow job delays the next interval. Cancellation is cooperative, so
// a running job must observe `ctx.done()` if it needs to stop before returning.
pub fn every(parent context.Context, interval time.Duration, f JobFn) ! {
if interval.nanoseconds() <= 0 {
return error(err_interval_invalid)
}
if f == unsafe { nil } {
return error(err_nil_job)
}
mut ctx := parent
initial_err := ctx.err()
if initial_err !is none {
return initial_err
}
done_ch := ctx.done()
mut watch_done := true
// context.background().done() is closed in V but err() remains none. Treat
// that as a non-cancelable context instead of returning immediately.
select {
_ := <-done_ch {
err := ctx.err()
if err !is none {
return err
}
watch_done = false
}
else {}
}
for {
if watch_done {
select {
_ := <-done_ch {
err := ctx.err()
if err !is none {
return err
}
watch_done = false
}
interval {
run_periodic_iteration(mut ctx, f)!
}
}
} else {
time.sleep(interval)
run_periodic_iteration(mut ctx, f)!
}
}
}
// start_every starts f in one detached periodic loop and returns its lifecycle handle.
//
// The first iteration runs after one interval. Iterations never overlap; a slow
// job delays the next interval. The returned handle must be stopped and waited
// on by its owner, otherwise the detached loop can keep running.
pub fn start_every(parent context.Context, interval time.Duration, f JobFn) !&PeriodicHandle {
if interval.nanoseconds() <= 0 {
return error(err_interval_invalid)
}
if f == unsafe { nil } {
return error(err_nil_job)
}
mut parent_ctx := parent
initial_err := parent_ctx.err()
if initial_err !is none {
return initial_err
}
ctx, cancel := new_cancel_context(parent)
mut handle := &PeriodicHandle{
parent: parent
ctx: context.Context(ctx)
cancel: cancel
result_ch: chan PeriodicResult{cap: 1}
mutex: sync.new_mutex()
}
spawn run_periodic_handle(mut handle, interval, f)
return handle
}
// stop requests cooperative shutdown of the detached periodic loop.
//
// stop is idempotent and non-blocking. A running job still has to return
// naturally; wait() is responsible for observing the final result.
pub fn (mut h PeriodicHandle) stop() {
mut parent := h.parent
parent_err := parent.err()
h.mutex.lock()
if h.stopped {
h.mutex.unlock()
return
}
h.stopped = true
h.stop_owns_cancel = parent_err is none
should_cancel := h.stop_owns_cancel
cancel := h.cancel
h.mutex.unlock()
if should_cancel {
cancel()
}
}
// wait blocks until the detached periodic loop exits.
//
// wait is one-shot. It returns job errors and parent cancellation errors. A
// normal stop() request is reported as successful completion.
pub fn (mut h PeriodicHandle) wait() ! {
h.mutex.lock()
if h.waited {
h.mutex.unlock()
return error(err_periodic_wait_called)
}
h.waited = true
h.mutex.unlock()
result := <-h.result_ch
if result.err !is none {
if result.kind == .context_error && h.is_stop_result() {
return
}
return result.err
}
}
fn run_periodic_iteration(mut ctx context.Context, f JobFn) ! {
f(mut ctx)!
err := ctx.err()
if err !is none {
return err
}
}
fn run_periodic_handle(mut h PeriodicHandle, interval time.Duration, f JobFn) {
mut ctx := h.ctx
result := run_detached_periodic_loop(mut ctx, interval, f)
h.cancel()
h.result_ch <- result
}
fn run_detached_periodic_loop(mut ctx context.Context, interval time.Duration, f JobFn) PeriodicResult {
done_ch := ctx.done()
mut watch_done := true
select {
_ := <-done_ch {
err := ctx.err()
if err !is none {
return PeriodicResult{
kind: .context_error
err: err
}
}
watch_done = false
}
else {}
}
for {
if watch_done {
select {
_ := <-done_ch {
err := ctx.err()
if err !is none {
return PeriodicResult{
kind: .context_error
err: err
}
}
watch_done = false
}
interval {
result := run_detached_periodic_iteration(mut ctx, f)
if result.err !is none {
return result
}
}
}
} else {
time.sleep(interval)
result := run_detached_periodic_iteration(mut ctx, f)
if result.err !is none {
return result
}
}
}
return PeriodicResult{}
}
fn run_detached_periodic_iteration(mut ctx context.Context, f JobFn) PeriodicResult {
f(mut ctx) or { return PeriodicResult{
kind: .job_error
err: err
} }
err := ctx.err()
if err !is none {
return PeriodicResult{
kind: .context_error
err: err
}
}
return PeriodicResult{}
}
fn (mut h PeriodicHandle) is_stop_result() bool {
h.mutex.lock()
stop_owns_cancel := h.stop_owns_cancel
h.mutex.unlock()
return stop_owns_cancel
}
+422
View File
@@ -0,0 +1,422 @@
import context
import time
import x.async as xasync
fn test_every_runs_at_least_one_iteration() {
parent_ctx, cancel := xasync.with_cancel()
ran := chan bool{cap: 1}
result := chan string{cap: 1}
worker := spawn fn [parent_ctx, ran, result] () {
xasync.every(parent_ctx, 5 * time.millisecond, fn [ran] (mut ctx context.Context) ! {
_ = ctx
ran <- true
}) or {
result <- err.msg()
return
}
result <- 'ok'
}()
select {
did_run := <-ran {
assert did_run
}
1 * time.second {
assert false, 'periodic job did not run'
}
}
cancel()
select {
msg := <-result {
assert msg == 'context canceled'
}
1 * time.second {
assert false, 'every did not stop after cancellation'
}
}
worker.wait()
}
fn test_every_stops_on_cancellation() {
parent_ctx, cancel := xasync.with_cancel()
entered := chan bool{cap: 1}
result := chan string{cap: 1}
worker := spawn fn [parent_ctx, entered, result] () {
entered <- true
xasync.every(parent_ctx, 1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
}) or {
result <- err.msg()
return
}
result <- 'ok'
}()
select {
did_enter := <-entered {
assert did_enter
}
1 * time.second {
assert false, 'every worker did not start'
}
}
cancel()
select {
msg := <-result {
assert msg == 'context canceled'
}
1 * time.second {
assert false, 'every did not stop on context cancellation'
}
}
worker.wait()
}
fn test_every_returns_immediately_when_parent_is_already_canceled() {
parent_ctx, cancel := xasync.with_cancel()
cancel()
xasync.every(parent_ctx, 1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
}) or {
assert err.msg() == 'context canceled'
return
}
assert false
}
fn test_every_returns_iteration_error() {
parent_ctx, cancel := xasync.with_cancel()
result := chan string{cap: 1}
worker := spawn fn [parent_ctx, result] () {
xasync.every(parent_ctx, 5 * time.millisecond, fn (mut ctx context.Context) ! {
_ = ctx
return error('periodic failed')
}) or {
result <- err.msg()
return
}
result <- 'ok'
}()
select {
msg := <-result {
assert msg == 'periodic failed'
}
1 * time.second {
assert false, 'every did not return the periodic job error'
}
}
cancel()
worker.wait()
}
fn test_every_rejects_zero_interval() {
xasync.every(context.background(), 0 * time.millisecond, fn (mut ctx context.Context) ! {
_ = ctx
}) or {
assert err.msg() == 'async: interval must be positive'
return
}
assert false
}
fn test_every_rejects_negative_interval() {
xasync.every(context.background(), -1 * time.millisecond, fn (mut ctx context.Context) ! {
_ = ctx
}) or {
assert err.msg() == 'async: interval must be positive'
return
}
assert false
}
fn test_every_rejects_nil_job() {
nil_job := unsafe { xasync.JobFn(nil) }
xasync.every(context.background(), 1 * time.second, nil_job) or {
assert err.msg() == 'async: job function is nil'
return
}
assert false
}
fn test_every_does_not_overlap_iterations() {
parent_ctx, cancel := xasync.with_cancel()
active := chan bool{cap: 1}
active <- true
entered := chan bool{cap: 2}
release := chan bool{cap: 2}
overlap := chan bool{cap: 1}
result := chan string{cap: 1}
worker := spawn fn [parent_ctx, active, entered, release, overlap, result] () {
xasync.every(parent_ctx, 5 * time.millisecond, fn [active, entered, release, overlap] (mut ctx context.Context) ! {
select {
_ := <-active {}
else {
overlap <- true
return error('periodic overlap')
}
}
entered <- true
done := ctx.done()
select {
_ := <-release {}
_ := <-done {
active <- true
return ctx.err()
}
}
active <- true
}) or {
result <- err.msg()
return
}
result <- 'ok'
}()
wait_for_periodic_entry(entered)
select {
_ := <-entered {
assert false, 'periodic iterations overlapped while first job was still running'
}
50 * time.millisecond {}
}
select {
did_overlap := <-overlap {
assert !did_overlap
}
else {}
}
release <- true
wait_for_periodic_entry(entered)
cancel()
release <- true
select {
msg := <-result {
assert msg == 'context canceled'
}
1 * time.second {
assert false, 'every did not stop after non-overlap test cancellation'
}
}
worker.wait()
}
fn test_start_every_rejects_invalid_interval() {
xasync.start_every(context.background(), 0 * time.millisecond, fn (mut ctx context.Context) ! {
_ = ctx
}) or {
assert err.msg() == 'async: interval must be positive'
return
}
assert false
}
fn test_start_every_rejects_negative_interval() {
xasync.start_every(context.background(), -1 * time.millisecond, fn (mut ctx context.Context) ! {
_ = ctx
}) or {
assert err.msg() == 'async: interval must be positive'
return
}
assert false
}
fn test_start_every_rejects_nil_job() {
nil_job := unsafe { xasync.JobFn(nil) }
xasync.start_every(context.background(), 1 * time.second, nil_job) or {
assert err.msg() == 'async: job function is nil'
return
}
assert false
}
fn test_start_every_returns_immediately_when_parent_is_already_canceled() {
parent_ctx, cancel := xasync.with_cancel()
cancel()
xasync.start_every(parent_ctx, 1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
}) or {
assert err.msg() == 'context canceled'
return
}
assert false
}
fn test_periodic_handle_stop_before_first_iteration() {
ran := chan bool{cap: 1}
mut handle := xasync.start_every(context.background(), 1 * time.second, fn [ran] (mut ctx context.Context) ! {
_ = ctx
ran <- true
})!
handle.stop()
handle.wait()!
assert_no_periodic_signal(ran, 'periodic handle ran before first interval after stop')
}
fn test_periodic_handle_stop_between_ticks() {
ticks := chan bool{cap: 2}
mut handle := xasync.start_every(context.background(), 5 * time.millisecond, fn [ticks] (mut ctx context.Context) ! {
_ = ctx
ticks <- true
})!
wait_for_periodic_entry(ticks)
handle.stop()
handle.wait()!
assert_no_periodic_signal(ticks, 'periodic handle ticked again after stop and wait')
}
fn test_periodic_handle_stop_during_long_job_waits_for_job_to_return() {
entered := chan bool{cap: 1}
release := chan bool{cap: 1}
waited := chan bool{cap: 1}
mut handle := xasync.start_every(context.background(), 5 * time.millisecond, fn [entered, release] (mut ctx context.Context) ! {
_ = ctx
entered <- true
_ := <-release
})!
wait_for_periodic_entry(entered)
handle.stop()
wait_thread := spawn fn [mut handle, waited] () {
handle.wait() or {
waited <- false
return
}
waited <- true
}()
assert_no_periodic_signal(waited, 'periodic handle wait returned before long job completed')
release <- true
wait_for_periodic_entry(waited)
wait_thread.wait()
}
fn test_periodic_handle_wait_returns_job_error() {
mut handle := xasync.start_every(context.background(), 5 * time.millisecond, fn (mut ctx context.Context) ! {
_ = ctx
return error('periodic handle failed')
})!
handle.wait() or {
assert err.msg() == 'periodic handle failed'
return
}
assert false
}
fn test_periodic_handle_wait_returns_job_context_canceled_error_after_stop() {
entered := chan bool{cap: 1}
release := chan bool{cap: 1}
mut handle := xasync.start_every(context.background(), 5 * time.millisecond, fn [entered, release] (mut ctx context.Context) ! {
_ = ctx
entered <- true
_ := <-release
return error('context canceled')
})!
wait_for_periodic_entry(entered)
handle.stop()
release <- true
handle.wait() or {
assert err.msg() == 'context canceled'
return
}
assert false, 'job-returned context canceled error was treated as normal stop'
}
fn test_periodic_handle_wait_returns_parent_cancellation() {
parent_ctx, cancel := xasync.with_cancel()
mut handle := xasync.start_every(parent_ctx, 1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
})!
cancel()
handle.wait() or {
assert err.msg() == 'context canceled'
return
}
assert false
}
fn test_periodic_handle_stop_after_parent_cancel_keeps_parent_error() {
parent_ctx, cancel := xasync.with_cancel()
mut handle := xasync.start_every(parent_ctx, 1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
})!
cancel()
handle.stop()
handle.wait() or {
assert err.msg() == 'context canceled'
return
}
assert false
}
fn test_periodic_handle_does_not_overlap_iterations() {
active := chan bool{cap: 1}
active <- true
entered := chan bool{cap: 2}
release := chan bool{cap: 2}
overlap := chan bool{cap: 1}
mut handle := xasync.start_every(context.background(), 5 * time.millisecond, fn [active, entered, release, overlap] (mut ctx context.Context) ! {
select {
_ := <-active {}
else {
overlap <- true
return error('periodic handle overlap')
}
}
entered <- true
_ = ctx
_ := <-release
active <- true
})!
wait_for_periodic_entry(entered)
assert_no_periodic_signal(entered,
'periodic handle iterations overlapped while first job was still running')
select {
did_overlap := <-overlap {
assert !did_overlap
}
else {}
}
release <- true
wait_for_periodic_entry(entered)
handle.stop()
release <- true
handle.wait()!
assert_no_periodic_signal(entered, 'periodic handle started another iteration after stop')
}
fn test_periodic_handle_double_stop_and_wait() {
mut handle := xasync.start_every(context.background(), 1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
})!
handle.stop()
handle.stop()
handle.wait()!
handle.wait() or {
assert err.msg() == 'async: periodic wait was already called'
return
}
assert false
}
fn wait_for_periodic_entry(entered chan bool) {
select {
did_enter := <-entered {
assert did_enter
}
1 * time.second {
assert false, 'periodic job did not enter'
}
}
}
fn assert_no_periodic_signal(signal chan bool, message string) {
select {
_ := <-signal {
assert false, message
}
50 * time.millisecond {}
}
}
+262
View File
@@ -0,0 +1,262 @@
module async
import context
import sync
import time
// PoolConfig configures a fixed-size concurrency pool.
//
// Both values must be positive. `workers` is the maximum number of jobs that
// can execute concurrently; `queue_size` is the bounded backlog accepted by
// try_submit() while all worker slots are busy.
@[params]
pub struct PoolConfig {
pub:
workers int
queue_size int
}
// Pool limits concurrent JobFn execution with fixed worker slots and bounded backlog.
//
// The pool owns one derived context shared by all jobs. Closing the pool stops
// new submissions, waits for every accepted job, and returns the first job error
// if any.
@[heap]
pub struct Pool {
mut:
ctx context.Context
cancel context.CancelFn = unsafe { nil }
tokens chan bool
submit_ready chan int
wg &sync.WaitGroup = sync.new_waitgroup()
max_jobs int
// Protects lifecycle flags, accepted job count, WaitGroup.add(), and
// first_err. This lock is never held while a user JobFn runs.
mutex &sync.Mutex = sync.new_mutex()
first_err IError = none
closed bool
waited bool
accepted int
}
// new_pool creates a Pool with a background parent context.
pub fn new_pool(config PoolConfig) !&Pool {
return new_pool_with_context(context.background(), config)
}
// new_pool_with_context creates a Pool with a context derived from parent.
//
// The worker limit and queue size are fixed for the pool lifetime. Parent
// cancellation is cooperative: jobs must observe `ctx.done()` and return.
pub fn new_pool_with_context(parent context.Context, config PoolConfig) !&Pool {
if config.workers <= 0 {
return error(err_pool_workers_invalid)
}
if config.queue_size <= 0 {
return error(err_pool_queue_size_invalid)
}
ctx, cancel := new_cancel_context(parent)
mut pool := &Pool{
ctx: context.Context(ctx)
cancel: cancel
tokens: chan bool{cap: config.workers}
submit_ready: chan int{cap: 1}
wg: sync.new_waitgroup()
max_jobs: config.workers + config.queue_size
mutex: sync.new_mutex()
}
for _ in 0 .. config.workers {
pool.tokens <- true
}
return pool
}
// try_submit accepts f if the pool is open and its bounded backlog has capacity.
//
// It never blocks for queue space. A full backlog returns `async: pool queue is
// full`, making backpressure explicit for callers.
pub fn (mut p Pool) try_submit(f JobFn) ! {
if f == unsafe { nil } {
return error(err_nil_job)
}
p.mutex.lock()
if p.closed {
p.mutex.unlock()
return error(err_pool_closed)
}
if p.accepted >= p.max_jobs {
p.mutex.unlock()
return error(err_pool_queue_full)
}
p.accept_job_locked()
p.mutex.unlock()
// The JobFn is passed directly to the spawned wrapper instead of being
// stored in a channel. That keeps closure ownership with V's normal spawn
// path while the token channel below still enforces the fixed worker limit.
spawn run_pool_job(mut p, f)
}
// submit_with_context waits until f can be accepted, parent is canceled, or the pool closes.
//
// The parent context bounds admission only. Once f is accepted, the job runs with
// the pool's own context, matching try_submit() and preserving pool lifecycle ownership.
pub fn (mut p Pool) submit_with_context(parent context.Context, f JobFn) ! {
if f == unsafe { nil } {
return error(err_nil_job)
}
mut submit_ctx := parent
initial_err := submit_ctx.err()
if initial_err !is none {
return initial_err
}
done := submit_ctx.done()
mut watch_done := true
select {
_ := <-done {
err := submit_ctx.err()
if err !is none {
return err
}
watch_done = false
}
else {}
}
for {
p.mutex.lock()
if p.closed {
p.mutex.unlock()
return error(err_pool_closed)
}
if watch_done {
err := submit_ctx.err()
if err !is none {
p.mutex.unlock()
return err
}
}
if p.accepted < p.max_jobs {
p.accept_job_locked()
p.mutex.unlock()
spawn run_pool_job(mut p, f)
return
}
submit_ready := p.submit_ready
p.mutex.unlock()
if !watch_done {
_ := <-submit_ready
continue
}
select {
_ := <-submit_ready {
continue
}
_ := <-done {
err := submit_ctx.err()
if err !is none {
return err
}
watch_done = false
continue
}
}
}
}
// submit_with_timeout waits up to timeout for f to be accepted by the pool.
//
// The timeout bounds admission only. Accepted jobs still receive the pool context.
pub fn (mut p Pool) submit_with_timeout(timeout time.Duration, f JobFn) ! {
timeout_ctx, cancel := new_timeout_context(context.background(), timeout)
mut submit_ctx := context.Context(timeout_ctx)
defer {
cancel()
}
p.submit_with_context(submit_ctx, f) or {
if err.msg() == context_deadline_exceeded && timeout_ctx.was_canceled_by_timeout() {
return error(err_timeout)
}
return err
}
}
// wait closes the pool to new submissions, drains accepted jobs, and waits for completion.
//
// wait is one-shot. It returns the first job error observed by any accepted job.
pub fn (mut p Pool) wait() ! {
p.mutex.lock()
if p.waited {
p.mutex.unlock()
return error(err_pool_wait_called)
}
p.waited = true
p.closed = true
p.wake_submitters_locked()
p.mutex.unlock()
p.wg.wait()
p.cancel()
err := p.get_first_error()
if err !is none {
return err
}
}
// close is an explicit lifecycle alias for wait().
//
// It rejects later submissions and waits for all accepted jobs before returning.
pub fn (mut p Pool) close() ! {
p.wait()!
}
fn run_pool_job(mut p Pool, f JobFn) {
defer {
p.finish_accepted_job()
p.wg.done()
}
// The token channel is a bounded semaphore. At most `workers` accepted jobs
// can pass this point and run user code concurrently.
_ := <-p.tokens
defer {
p.tokens <- true
}
mut job_ctx := p.ctx
f(mut job_ctx) or { p.set_first_error(err) }
}
fn (mut p Pool) finish_accepted_job() {
p.mutex.lock()
p.accepted--
p.wake_submitters_locked()
p.mutex.unlock()
}
fn (mut p Pool) accept_job_locked() {
p.accepted++
// add() is protected by the same mutex as wait(), so callers cannot race an
// accepted job against pool shutdown.
p.wg.add(1)
}
fn (mut p Pool) wake_submitters_locked() {
if !p.submit_ready.closed {
p.submit_ready.close()
}
if !p.closed {
p.submit_ready = chan int{cap: 1}
}
}
fn (mut p Pool) set_first_error(err IError) {
p.mutex.lock()
if p.first_err is none {
p.first_err = err
}
p.mutex.unlock()
}
fn (mut p Pool) get_first_error() IError {
p.mutex.lock()
err := p.first_err
p.mutex.unlock()
return err
}
+732
View File
@@ -0,0 +1,732 @@
import context
import time
import x.async as xasync
fn test_pool_rejects_invalid_config() {
xasync.new_pool(workers: 0, queue_size: 1) or {
assert err.msg() == 'async: pool worker count must be positive'
return
}
assert false
}
fn test_pool_rejects_invalid_queue_size() {
xasync.new_pool(workers: 1, queue_size: 0) or {
assert err.msg() == 'async: pool queue size must be positive'
return
}
assert false
}
fn test_pool_respects_worker_count() {
mut pool := xasync.new_pool(workers: 2, queue_size: 4)!
started := chan bool{cap: 4}
release := chan bool{cap: 4}
for _ in 0 .. 4 {
pool.try_submit(fn [started, release] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
})!
}
wait_for_bool_signal(started, 'first pool job did not start')
wait_for_bool_signal(started, 'second pool job did not start')
select {
_ := <-started {
assert false, 'pool started more jobs than worker count before release'
}
100 * time.millisecond {}
}
for _ in 0 .. 4 {
release <- true
}
pool.close()!
}
fn test_pool_queue_full_returns_backpressure_error() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
started := chan bool{cap: 1}
release := chan bool{cap: 2}
blocking_job := fn [started, release] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
}
pool.try_submit(blocking_job)!
wait_for_bool_signal(started, 'blocking pool job did not start')
pool.try_submit(blocking_job)!
pool.try_submit(blocking_job) or {
assert err.msg() == 'async: pool queue is full'
release <- true
release <- true
pool.close()!
return
}
assert false
}
fn test_pool_submit_with_context_waits_until_capacity_is_available() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
started := chan bool{cap: 2}
release := chan bool{cap: 2}
attempting := chan bool{cap: 1}
accepted := chan bool{cap: 1}
ran := chan bool{cap: 1}
blocking_job := fn [started, release] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
}
pool.try_submit(blocking_job)!
wait_for_bool_signal(started, 'first blocking pool job did not start')
pool.try_submit(blocking_job)!
submit_thread := spawn fn [mut pool, attempting, accepted, ran] () {
attempting <- true
pool.submit_with_context(context.background(), fn [ran] (mut ctx context.Context) ! {
_ = ctx
ran <- true
}) or {
accepted <- false
return
}
accepted <- true
}()
wait_for_bool_signal(attempting, 'bounded submit thread did not start')
select {
_ := <-accepted {
assert false, 'bounded submit returned while pool was full'
}
50 * time.millisecond {}
}
release <- true
wait_for_bool_signal(accepted, 'bounded submit did not accept after capacity opened')
release <- true
wait_for_bool_signal(ran, 'accepted bounded submit job did not run')
pool.close()!
submit_thread.wait()
}
fn test_pool_submit_with_timeout_returns_timeout_without_leaking_acceptance() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
started := chan bool{cap: 2}
release := chan bool{cap: 2}
finished := chan bool{cap: 2}
blocking_job := fn [started, release, finished] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
finished <- true
}
pool.try_submit(blocking_job)!
wait_for_bool_signal(started, 'first blocking pool job did not start')
pool.try_submit(blocking_job)!
pool.submit_with_timeout(20 * time.millisecond, fn (mut ctx context.Context) ! {
_ = ctx
}) or {
assert err.msg() == 'async: timeout'
release <- true
wait_for_bool_signal(finished, 'first blocking pool job did not finish')
pool.submit_with_timeout(1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
})!
release <- true
pool.close()!
return
}
assert false
}
fn test_pool_submit_with_context_parent_cancel_does_not_accept_job() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
started := chan bool{cap: 1}
release := chan bool{cap: 2}
attempting := chan bool{cap: 1}
would_run := chan bool{cap: 1}
result := chan string{cap: 1}
parent_ctx, cancel := xasync.with_cancel()
blocking_job := fn [started, release] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
}
pool.try_submit(blocking_job)!
wait_for_bool_signal(started, 'blocking pool job did not start')
pool.try_submit(blocking_job)!
submit_thread := spawn fn [mut pool, parent_ctx, attempting, would_run, result] () {
attempting <- true
pool.submit_with_context(parent_ctx, fn [would_run] (mut ctx context.Context) ! {
_ = ctx
would_run <- true
}) or {
result <- err.msg()
return
}
result <- 'accepted'
}()
wait_for_bool_signal(attempting, 'bounded submit thread did not start')
select {
msg := <-result {
assert false, 'bounded submit returned before parent cancellation: ${msg}'
}
50 * time.millisecond {}
}
cancel()
select {
msg := <-result {
assert msg == 'context canceled'
}
1 * time.second {
assert false, 'bounded submit did not return after parent cancellation'
}
}
release <- true
release <- true
pool.close()!
assert_no_bool_signal(would_run, 'job was accepted after parent cancellation')
submit_thread.wait()
}
fn test_pool_close_wakes_waiting_submitter_without_accepting_job() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
started := chan bool{cap: 1}
release := chan bool{cap: 2}
attempting := chan bool{cap: 1}
would_run := chan bool{cap: 1}
result := chan string{cap: 1}
closed := chan bool{cap: 1}
blocking_job := fn [started, release] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
}
pool.try_submit(blocking_job)!
wait_for_bool_signal(started, 'blocking pool job did not start')
pool.try_submit(blocking_job)!
submit_thread := spawn fn [mut pool, attempting, would_run, result] () {
attempting <- true
pool.submit_with_context(context.background(), fn [would_run] (mut ctx context.Context) ! {
_ = ctx
would_run <- true
}) or {
result <- err.msg()
return
}
result <- 'accepted'
}()
wait_for_bool_signal(attempting, 'bounded submit thread did not start')
select {
msg := <-result {
assert false, 'bounded submit returned before close: ${msg}'
}
50 * time.millisecond {}
}
close_thread := spawn fn [mut pool, closed] () {
pool.close() or {
closed <- false
return
}
closed <- true
}()
select {
msg := <-result {
assert msg == 'async: pool is closed'
}
1 * time.second {
assert false, 'bounded submit did not return after pool close started'
}
}
release <- true
release <- true
wait_for_bool_signal(closed, 'pool close did not finish after accepted jobs were released')
assert_no_bool_signal(would_run, 'job was accepted after pool close started')
submit_thread.wait()
close_thread.wait()
}
fn test_pool_bounded_submit_rejects_nil_job() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
nil_job := unsafe { xasync.JobFn(nil) }
pool.submit_with_context(context.background(), nil_job) or {
assert err.msg() == 'async: job function is nil'
pool.submit_with_timeout(20 * time.millisecond, nil_job) or {
assert err.msg() == 'async: job function is nil'
pool.close()!
return
}
assert false
}
assert false
}
fn test_pool_submit_after_close_is_refused() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
pool.close()!
pool.try_submit(fn (mut ctx context.Context) ! {
_ = ctx
}) or {
assert err.msg() == 'async: pool is closed'
return
}
assert false
}
fn test_pool_wait_is_one_shot() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
pool.wait()!
pool.wait() or {
assert err.msg() == 'async: pool wait was already called'
return
}
assert false
}
fn test_pool_refuses_nil_job() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
nil_job := unsafe { xasync.JobFn(nil) }
pool.try_submit(nil_job) or {
assert err.msg() == 'async: job function is nil'
pool.close()!
return
}
assert false
}
fn test_pool_close_waits_for_accepted_jobs() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
release := chan bool{cap: 1}
closed := chan bool{cap: 1}
pool.try_submit(fn [release] (mut ctx context.Context) ! {
_ = ctx
_ := <-release
})!
close_thread := spawn fn [mut pool, closed] () {
pool.close() or {
closed <- false
return
}
closed <- true
}()
select {
_ := <-closed {
assert false, 'pool close returned before accepted job completed'
}
100 * time.millisecond {}
}
release <- true
select {
ok := <-closed {
assert ok
}
1 * time.second {
assert false, 'pool close did not return after accepted job completed'
}
}
close_thread.wait()
}
fn test_pool_first_error_is_propagated() {
mut pool := xasync.new_pool(workers: 1, queue_size: 2)!
started := chan bool{cap: 1}
release := chan bool{cap: 1}
pool.try_submit(fn [started, release] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
return error('first pool failure')
})!
wait_for_bool_signal(started, 'first pool failure job did not start')
pool.try_submit(fn (mut ctx context.Context) ! {
_ = ctx
return error('second pool failure')
})!
release <- true
pool.close() or {
assert err.msg() == 'first pool failure'
return
}
assert false
}
fn test_pool_error_does_not_drop_accepted_jobs() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
accepted_job_ran := chan bool{cap: 1}
pool.try_submit(fn (mut ctx context.Context) ! {
_ = ctx
return error('first pool failure')
})!
pool.try_submit(fn [accepted_job_ran] (mut ctx context.Context) ! {
_ = ctx
accepted_job_ran <- true
})!
pool.close() or {
assert err.msg() == 'first pool failure'
select {
did_run := <-accepted_job_ran {
assert did_run
}
1 * time.second {
assert false, 'accepted pool job did not run after earlier error'
}
}
return
}
assert false
}
fn test_pool_concurrent_errors_return_one_error_and_drain_accepted_jobs() {
error_jobs := 4
ok_jobs := 8
mut pool := xasync.new_pool(workers: error_jobs, queue_size: ok_jobs)!
started := chan bool{cap: error_jobs}
release := chan bool{cap: error_jobs}
completed_ok := chan bool{cap: ok_jobs}
for i in 0 .. error_jobs {
pool.try_submit(fn [started, release, i] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
return error('pool concurrent failure ${i}')
})!
}
for _ in 0 .. ok_jobs {
pool.try_submit(fn [completed_ok] (mut ctx context.Context) ! {
_ = ctx
completed_ok <- true
})!
}
for _ in 0 .. error_jobs {
wait_for_bool_signal(started, 'pool error job did not start')
}
for _ in 0 .. error_jobs {
release <- true
}
pool.close() or {
assert err.msg().starts_with('pool concurrent failure ')
for _ in 0 .. ok_jobs {
wait_for_bool_signal(completed_ok, 'accepted ok pool job did not complete')
}
return
}
assert false
}
fn test_pool_close_drains_many_accepted_jobs_while_finishing() {
jobs := 12
workers := 3
mut pool := xasync.new_pool(workers: workers, queue_size: jobs - workers)!
started := chan bool{cap: jobs}
release := chan bool{cap: jobs}
finished := chan bool{cap: jobs}
closed := chan bool{cap: 1}
for _ in 0 .. jobs {
pool.try_submit(fn [started, release, finished] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
finished <- true
})!
}
for _ in 0 .. workers {
wait_for_bool_signal(started, 'initial pool job did not start')
}
close_thread := spawn fn [mut pool, closed] () {
pool.close() or {
closed <- false
return
}
closed <- true
}()
wait_until_pool_rejects_as_closed(mut pool)
assert_no_bool_signal(closed, 'pool close returned while accepted jobs were still blocked')
for _ in 0 .. jobs - 1 {
release <- true
}
for _ in 0 .. jobs - 1 {
wait_for_bool_signal(finished, 'accepted pool job did not finish')
}
assert_no_bool_signal(closed, 'pool close returned before the last accepted job finished')
release <- true
wait_for_bool_signal(finished, 'last accepted pool job did not finish')
wait_for_bool_signal(closed, 'pool close did not drain accepted jobs')
close_thread.wait()
}
fn test_pool_parent_cancellation_is_observed_by_cooperative_job() {
parent_ctx, cancel := xasync.with_cancel()
mut pool := xasync.new_pool_with_context(parent_ctx, workers: 1, queue_size: 1)!
started := chan bool{cap: 1}
pool.try_submit(fn [started] (mut ctx context.Context) ! {
started <- true
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
1 * time.second {
return error('pool job did not observe parent cancellation')
}
}
return error('unreachable')
})!
wait_for_bool_signal(started, 'pool job did not start before parent cancellation')
cancel()
pool.close() or {
assert err.msg() == 'context canceled'
return
}
assert false
}
fn test_pool_non_cooperative_job_finishes_naturally_after_parent_cancel() {
parent_ctx, cancel := xasync.with_cancel()
mut pool := xasync.new_pool_with_context(parent_ctx, workers: 1, queue_size: 1)!
finished := chan bool{cap: 1}
pool.try_submit(fn [finished] (mut ctx context.Context) ! {
_ = ctx
time.sleep(20 * time.millisecond)
finished <- true
})!
cancel()
pool.close()!
assert <-finished
}
fn test_pool_short_stress_many_jobs() {
jobs := 100
mut pool := xasync.new_pool(workers: 4, queue_size: jobs)!
done := chan int{cap: jobs}
for i in 0 .. jobs {
pool.try_submit(fn [done, i] (mut ctx context.Context) ! {
_ = ctx
done <- i
})!
}
pool.close()!
mut seen := []bool{len: jobs}
for _ in 0 .. jobs {
i := <-done
assert i >= 0
assert i < jobs
assert !seen[i]
seen[i] = true
}
for was_seen in seen {
assert was_seen
}
}
fn test_pool_bounded_submit_stress_waiting_submitters_are_released() {
workers := 2
queue_size := 2
initial_jobs := workers + queue_size
submitters := 8
mut pool := xasync.new_pool(workers: workers, queue_size: queue_size)!
started := chan bool{cap: initial_jobs}
release := chan bool{cap: initial_jobs}
attempting := chan bool{cap: submitters}
accepted := chan int{cap: submitters}
ran := chan int{cap: submitters}
blocking_job := fn [started, release] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
}
for _ in 0 .. initial_jobs {
pool.try_submit(blocking_job)!
}
for _ in 0 .. workers {
wait_for_bool_signal(started, 'initial pool worker did not start')
}
mut submit_threads := []thread{}
for i in 0 .. submitters {
submit_threads << spawn fn [mut pool, attempting, accepted, ran, i] () {
attempting <- true
pool.submit_with_context(context.background(), fn [ran, i] (mut ctx context.Context) ! {
_ = ctx
ran <- i
}) or {
failed_index := -1 - i
accepted <- failed_index
return
}
accepted <- i
}()
}
for _ in 0 .. submitters {
wait_for_bool_signal(attempting, 'bounded submitter did not start')
}
for _ in 0 .. initial_jobs {
release <- true
}
mut seen_accepted := []bool{len: submitters}
for _ in 0 .. submitters {
i := wait_for_int_signal(accepted, 'bounded submitter was not accepted')
assert i >= 0
assert i < submitters
assert !seen_accepted[i]
seen_accepted[i] = true
}
mut seen_ran := []bool{len: submitters}
for _ in 0 .. submitters {
i := wait_for_int_signal(ran, 'accepted bounded submitter job did not run')
assert i >= 0
assert i < submitters
assert !seen_ran[i]
seen_ran[i] = true
}
pool.close()!
for t in submit_threads {
t.wait()
}
}
fn test_pool_bounded_submit_stress_waiting_submitters_rejected_on_close() {
workers := 2
queue_size := 2
initial_jobs := workers + queue_size
submitters := 8
mut pool := xasync.new_pool(workers: workers, queue_size: queue_size)!
started := chan bool{cap: initial_jobs}
release := chan bool{cap: initial_jobs}
attempting := chan bool{cap: submitters}
result := chan string{cap: submitters}
ran := chan bool{cap: submitters}
closed := chan bool{cap: 1}
blocking_job := fn [started, release] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
}
for _ in 0 .. initial_jobs {
pool.try_submit(blocking_job)!
}
for _ in 0 .. workers {
wait_for_bool_signal(started, 'initial pool worker did not start')
}
mut submit_threads := []thread{}
for i in 0 .. submitters {
submit_threads << spawn fn [mut pool, attempting, result, ran, i] () {
attempting <- true
pool.submit_with_context(context.background(), fn [ran] (mut ctx context.Context) ! {
_ = ctx
ran <- true
}) or {
result <- err.msg()
return
}
result <- 'accepted ${i}'
}()
}
for _ in 0 .. submitters {
wait_for_bool_signal(attempting, 'bounded submitter did not start')
}
close_thread := spawn fn [mut pool, closed] () {
pool.close() or {
closed <- false
return
}
closed <- true
}()
for _ in 0 .. submitters {
msg := wait_for_string_signal(result, 'bounded submitter did not return after close')
assert msg == 'async: pool is closed'
}
assert_no_bool_signal(ran, 'bounded submitter job ran after close started')
for _ in 0 .. initial_jobs {
release <- true
}
wait_for_bool_signal(closed, 'pool close did not finish after releasing accepted jobs')
for t in submit_threads {
t.wait()
}
close_thread.wait()
}
fn wait_for_bool_signal(signal chan bool, message string) {
select {
ok := <-signal {
assert ok
}
1 * time.second {
assert false, message
}
}
}
fn wait_for_int_signal(signal chan int, message string) int {
select {
value := <-signal {
return value
}
1 * time.second {
assert false, message
}
}
return 0
}
fn wait_for_string_signal(signal chan string, message string) string {
select {
value := <-signal {
return value
}
1 * time.second {
assert false, message
}
}
return ''
}
fn assert_no_bool_signal(signal chan bool, message string) {
select {
_ := <-signal {
assert false, message
}
else {}
}
}
fn wait_until_pool_rejects_as_closed(mut pool xasync.Pool) {
probe := fn (mut ctx context.Context) ! {
_ = ctx
}
for _ in 0 .. 100 {
pool.try_submit(probe) or {
if err.msg() == 'async: pool is closed' {
return
}
assert err.msg() == 'async: pool queue is full'
time.sleep(1 * time.millisecond)
continue
}
assert false, 'pool accepted probe while backlog should be full'
}
assert false, 'pool close did not start'
}
+86
View File
@@ -0,0 +1,86 @@
module async
import context
import sync
struct TaskResult[T] {
value T
err IError = none
}
// Task represents one concurrent computation that produces either a value or an error.
//
// A Task is intentionally small: it starts one `spawn`, stores one result in a
// bounded channel, and lets the owner consume that result with wait(). It does
// not recover panics and it does not kill work that ignores cancellation.
@[heap]
pub struct Task[T] {
mut:
ctx context.Context
cancel context.CancelFn = unsafe { nil }
result_ch chan TaskResult[T]
// Guards the one-shot wait contract. The result channel carries the actual
// value/error, so no additional shared result state is needed.
mutex &sync.Mutex = sync.new_mutex()
waited bool
}
// run starts f with a background context and returns a Task for its result.
//
// The returned Task owns a derived context that is canceled when f finishes.
pub fn run[T](f TaskFn[T]) !&Task[T] {
return run_with_context[T](context.background(), f)
}
// run_with_context starts f with a context derived from parent.
//
// Cancellation is cooperative. If parent is canceled, f must observe ctx.done()
// and return. The result channel is buffered so f can publish its single result
// even if the owner has not called wait() yet.
pub fn run_with_context[T](parent context.Context, f TaskFn[T]) !&Task[T] {
if f == unsafe { nil } {
return error(err_nil_job)
}
ctx, cancel := new_cancel_context(parent)
mut task := &Task[T]{
ctx: context.Context(ctx)
cancel: cancel
result_ch: chan TaskResult[T]{cap: 1}
mutex: sync.new_mutex()
}
spawn fn [T](task &Task[T], f TaskFn[T]) {
mut job_ctx := task.ctx
value := f(mut job_ctx) or {
task.result_ch <- TaskResult[T]{
err: err
}
task.cancel()
return
}
task.result_ch <- TaskResult[T]{
value: value
}
task.cancel()
}(task, f)
return task
}
// wait blocks until the task publishes its result, then returns the value or error.
//
// wait is one-shot. A second call returns a stable error instead of blocking on
// an already-consumed result channel.
pub fn (mut task Task[T]) wait() !T {
task.mutex.lock()
if task.waited {
task.mutex.unlock()
return error(err_task_wait_called)
}
task.waited = true
task.mutex.unlock()
result := <-task.result_ch
if result.err !is none {
return result.err
}
return result.value
}
+184
View File
@@ -0,0 +1,184 @@
import context
import time
import x.async as xasync
fn test_task_returns_value() {
mut task := xasync.run[int](fn (mut ctx context.Context) !int {
_ = ctx
return 42
})!
assert task.wait()! == 42
}
fn test_task_returns_error() {
mut task := xasync.run[int](fn (mut ctx context.Context) !int {
_ = ctx
return error('task failed')
})!
task.wait() or {
assert err.msg() == 'task failed'
return
}
assert false
}
fn test_task_wait_is_one_shot() {
mut task := xasync.run[string](fn (mut ctx context.Context) !string {
_ = ctx
return 'ok'
})!
assert task.wait()! == 'ok'
task.wait() or {
assert err.msg() == 'async: task wait was already called'
return
}
assert false
}
fn test_task_concurrent_wait_is_one_shot_without_deadlock() {
release := chan bool{cap: 1}
results := chan string{cap: 2}
mut task := xasync.run[int](fn [release] (mut ctx context.Context) !int {
_ = ctx
_ := <-release
return 7
})!
first_waiter := spawn fn [mut task, results] () {
value := task.wait() or {
results <- 'error:${err.msg()}'
return
}
results <- 'value:${value}'
}()
second_waiter := spawn fn [mut task, results] () {
value := task.wait() or {
results <- 'error:${err.msg()}'
return
}
results <- 'value:${value}'
}()
release <- true
mut saw_value := false
mut saw_second_wait_error := false
for _ in 0 .. 2 {
select {
result := <-results {
match result {
'value:7' {
saw_value = true
}
'error:async: task wait was already called' {
saw_second_wait_error = true
}
else {
assert false, 'unexpected concurrent wait result: ${result}'
}
}
}
1 * time.second {
assert false, 'concurrent task waiters did not both finish'
}
}
}
first_waiter.wait()
second_waiter.wait()
assert saw_value
assert saw_second_wait_error
}
fn test_task_parent_cancellation_is_observed() {
parent_ctx, cancel := xasync.with_cancel()
mut task := xasync.run_with_context[int](parent_ctx, fn (mut ctx context.Context) !int {
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
1 * time.second {
return error('task did not observe parent cancellation')
}
}
return error('unreachable')
})!
cancel()
task.wait() or {
assert err.msg() == 'context canceled'
return
}
assert false
}
fn test_task_parent_already_canceled_is_observed() {
parent_ctx, cancel := xasync.with_cancel()
cancel()
mut task := xasync.run_with_context[int](parent_ctx, fn (mut ctx context.Context) !int {
done := ctx.done()
select {
_ := <-done {
return ctx.err()
}
1 * time.second {
return error('task did not observe already canceled parent')
}
}
return error('unreachable')
})!
task.wait() or {
assert err.msg() == 'context canceled'
return
}
assert false
}
fn test_task_observes_context_done() {
parent_ctx, cancel := xasync.with_cancel()
observed := chan bool{cap: 1}
mut task := xasync.run_with_context[int](parent_ctx, fn [observed] (mut ctx context.Context) !int {
done := ctx.done()
select {
_ := <-done {
observed <- true
return ctx.err()
}
1 * time.second {
observed <- false
return error('task did not observe ctx.done()')
}
}
return error('unreachable')
})!
cancel()
task.wait() or { assert err.msg() == 'context canceled' }
select {
did_observe := <-observed {
assert did_observe
}
1 * time.second {
assert false, 'task did not report cancellation observation'
}
}
}
fn test_task_ignores_cancellation_but_result_publication_is_buffered() {
parent_ctx, cancel := xasync.with_cancel()
mut task := xasync.run_with_context[int](parent_ctx, fn (mut ctx context.Context) !int {
_ = ctx
time.sleep(20 * time.millisecond)
return 9
})!
cancel()
time.sleep(60 * time.millisecond)
assert task.wait()! == 9
}
fn test_task_refuses_nil_function() {
nil_task_fn := unsafe { xasync.TaskFn[int](nil) }
xasync.run[int](nil_task_fn) or {
assert err.msg() == 'async: job function is nil'
return
}
assert false
}
+28
View File
@@ -0,0 +1,28 @@
# x.async integration tests
This folder contains module-oriented integration tests for `x.async`.
Tests are validation-first programs. Unlike the examples, they are written to
assert edge cases, error propagation, cancellation behavior, backpressure, and
module integration boundaries. They should fail when a regression is introduced.
The tests are deliberately local and synthetic. They verify that `x.async`
composes cleanly with selected V modules without modifying those modules and
without depending on public network services, fixed ports, filesystem paths, or
fragile server shutdown behavior.
Current subfolders:
- `net_http/`: in-memory `net.http` request/response work through `Pool`.
- `net_websocket/`: in-memory `websocket.Message` processing and callback-style
error propagation through `Task` and `Group`.
- `mcp/`: in-memory MCP JSON-RPC request/response dispatch through `Task`.
- `veb/`: in-memory `veb.Context` response lifecycle through `with_timeout()`.
Run through the guarded validation path:
```sh
sh vlib/x/async/tools/validate.sh
```
The validation script uses isolated `VTMP` and `VCACHE` and runs serially.
+11
View File
@@ -0,0 +1,11 @@
# mcp integration tests
These tests keep MCP traffic in memory. They do not start stdio, HTTP, a child
process, or any external MCP service.
The coverage target is the `x.async` boundary around MCP-shaped work:
- build an MCP request;
- decode and validate it inside a `Task`;
- produce an MCP response;
- decode the result after `Task.wait()`.
@@ -0,0 +1,28 @@
import context
import mcp
import x.async as xasync
struct TestEchoArgs {
pub:
text string
}
fn test_mcp_task_dispatches_in_memory_request() {
request := mcp.new_request(1, 'tools/call', TestEchoArgs{
text: 'hello'
})
mut task := xasync.run[mcp.Response](fn [request] (mut ctx context.Context) !mcp.Response {
_ = ctx
req := mcp.decode_request(request.encode())!
if req.method != 'tools/call' {
return error('unexpected MCP method')
}
args := req.decode_params[TestEchoArgs]()!
result := mcp.tool_text_result(args.text)
return mcp.new_response(1, result, mcp.ResponseError{})
})!
response := task.wait()!
result := response.decode_result[mcp.ToolResult]()!
assert result.content.contains('hello')
}
@@ -0,0 +1,11 @@
# net.http integration tests
These tests use `net.http` request and response values in memory. They do not
open a listener, bind a port, or call an external service.
The coverage target is the `x.async` boundary around HTTP-shaped work:
- bounded pool submission;
- explicit queue-full backpressure;
- draining accepted request handlers through `Pool.close()`;
- preserving response data produced by accepted jobs.
@@ -0,0 +1,73 @@
import context
import net.http
import time
import x.async as xasync
fn test_net_http_pool_handles_synthetic_requests_with_backpressure() {
mut pool := xasync.new_pool(workers: 1, queue_size: 1)!
started := chan bool{cap: 1}
release := chan bool{cap: 2}
responses := chan int{cap: 2}
first_req := http.new_request(.get, '/first', '')
second_req := http.new_request(.get, '/second', '')
third_req := http.new_request(.get, '/third', '')
pool.try_submit(fn [first_req, started, release, responses] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
resp := synthetic_http_response(first_req)!
responses <- resp.status_code
})!
wait_for_http_signal(started, 'first synthetic HTTP job did not start')
pool.try_submit(fn [second_req, release, responses] (mut ctx context.Context) ! {
_ = ctx
_ := <-release
resp := synthetic_http_response(second_req)!
responses <- resp.status_code
})!
pool.try_submit(fn [third_req] (mut ctx context.Context) ! {
_ = ctx
_ = third_req
}) or {
assert err.msg() == 'async: pool queue is full'
release <- true
release <- true
pool.close()!
assert read_http_status(responses) == 200
assert read_http_status(responses) == 200
return
}
assert false
}
fn synthetic_http_response(req http.Request) !http.Response {
if req.url == '' {
return error('empty synthetic HTTP URL')
}
return http.new_response(status: .ok, body: req.url)
}
fn wait_for_http_signal(signal chan bool, message string) {
select {
ok := <-signal {
assert ok
}
1 * time.second {
assert false, message
}
}
}
fn read_http_status(responses chan int) int {
select {
status := <-responses {
return status
}
1 * time.second {
assert false, 'synthetic HTTP response was not produced'
}
}
return 0
}
@@ -0,0 +1,11 @@
# net.websocket integration tests
These tests are intentionally in-memory and synthetic. They exercise
`websocket.Message` processing and callback-shaped control flow with `x.async`,
but they do not start a websocket server or claim end-to-end websocket server
coverage.
That limitation is important: in this V snapshot, `websocket.Server.close()` is
not a complete, stable shutdown primitive for a fragile validation test. The
tests therefore avoid binding ports and focus on the part `x.async` can safely
compose: message work, cancellation, and error propagation around callbacks.
@@ -0,0 +1,44 @@
import context
import net.websocket
import x.async as xasync
fn test_net_websocket_task_processes_message_in_memory() {
msg := websocket.Message{
opcode: .text_frame
payload: 'hello'.bytes()
}
mut task := xasync.run[string](fn [msg] (mut ctx context.Context) !string {
_ = ctx
return websocket_message_text(msg)!
})!
assert task.wait()! == 'hello'
}
fn test_net_websocket_callback_error_propagates_through_group() {
mut group := xasync.new_group(context.background())
observed := chan string{cap: 1}
group.go(fn [observed] (mut ctx context.Context) ! {
_ = ctx
msg := websocket.Message{
opcode: .close
}
text := websocket_message_text(msg) or {
observed <- err.msg()
return err
}
observed <- text
})!
group.wait() or {
assert err.msg() == 'unsupported websocket opcode'
assert <-observed == 'unsupported websocket opcode'
return
}
assert false
}
fn websocket_message_text(msg websocket.Message) !string {
if msg.opcode != .text_frame {
return error('unsupported websocket opcode')
}
return msg.payload.bytestr()
}
+8
View File
@@ -0,0 +1,8 @@
# veb integration tests
These tests use `veb.Context` in memory. They do not start a `veb` server, bind
a port, or depend on HTTP client/server timing.
The coverage target is narrow: `x.async` can bound and propagate errors for a
synthetic veb handler step without becoming part of `veb`'s runtime or server
lifecycle.
@@ -0,0 +1,18 @@
import context
import time
import veb
import x.async as xasync
fn test_veb_context_response_inside_timeout() {
xasync.with_timeout(1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
mut web_ctx := veb.Context{}
_ := web_ctx.text('hello from veb')
if web_ctx.res.status_code != 200 {
return error('unexpected veb status')
}
if web_ctx.res.body != 'hello from veb' {
return error('unexpected veb body')
}
})!
}
+103
View File
@@ -0,0 +1,103 @@
module async
import context
import time
struct TimeoutResult {
err IError = none
finished_at time.Time
}
// with_timeout runs f with a background context and returns an error if timeout expires first.
pub fn with_timeout(timeout time.Duration, f JobFn) ! {
with_timeout_context(context.background(), timeout, f)!
}
// with_timeout_context runs f with a context derived from parent and bounded by timeout.
//
// If timeout expires before f returns, this returns `async: timeout` and
// cancels the derived context. The job must observe the context to stop early;
// x.async does not kill spawned work.
pub fn with_timeout_context(parent context.Context, timeout time.Duration, f JobFn) ! {
if f == unsafe { nil } {
return error(err_nil_job)
}
async_ctx, cancel := new_timeout_context(parent, timeout)
mut ctx := context.Context(async_ctx)
defer {
cancel()
}
initial_err := ctx.err()
if initial_err !is none {
if initial_err.msg() == context_deadline_exceeded && async_ctx.was_canceled_by_timeout() {
return error(err_timeout)
}
return initial_err
}
// The channel is buffered so a non-cooperative job can still publish its
// result later without blocking after the caller has returned on timeout.
result_ch := chan TimeoutResult{cap: 1}
spawn run_timeout_job(ctx, f, result_ch)
done_ch := ctx.done()
select {
result := <-result_ch {
handle_timeout_result(mut ctx, async_ctx, result)!
return
}
_ := <-done_ch {
handle_timeout_done(mut ctx, async_ctx)!
return
}
}
}
fn handle_timeout_result(mut ctx context.Context, async_ctx &AsyncContext, result TimeoutResult) ! {
ctx_err := ctx.err()
if result.finished_after_owned_timeout(async_ctx) {
if ctx_err !is none && !async_ctx.was_canceled_by_timeout() {
return ctx_err
}
return error(err_timeout)
}
if result.err !is none {
if ctx_err !is none && ctx_err.msg() == context_deadline_exceeded
&& result.err.msg() == context_deadline_exceeded {
if async_ctx.was_canceled_by_timeout() {
return error(err_timeout)
}
}
return result.err
}
}
fn handle_timeout_done(mut ctx context.Context, async_ctx &AsyncContext) ! {
err := ctx.err()
if err !is none {
if err.msg() == context_deadline_exceeded && async_ctx.was_canceled_by_timeout() {
return error(err_timeout)
}
return err
}
return error(err_timeout)
}
fn (result TimeoutResult) finished_after_owned_timeout(async_ctx &AsyncContext) bool {
return async_ctx.deadline_src == .timeout && (async_ctx.deadline_at < result.finished_at
|| async_ctx.deadline_at == result.finished_at)
}
fn run_timeout_job(ctx context.Context, f JobFn, result_ch chan TimeoutResult) {
// Spawned functions cannot receive mutable non-reference arguments, so the
// worker creates the mutable context interface value locally.
mut job_ctx := ctx
f(mut job_ctx) or {
result_ch <- TimeoutResult{
err: err
finished_at: time.now()
}
return
}
result_ch <- TimeoutResult{
finished_at: time.now()
}
}
+295
View File
@@ -0,0 +1,295 @@
import context
import sync
import time
import x.async as xasync
@[heap]
struct ControlledDeadlineParent {
deadline_at time.Time
mut:
done_ch chan int
mutex &sync.Mutex = sync.new_mutex()
err_value IError = none
}
fn new_controlled_deadline_parent(deadline_at time.Time) &ControlledDeadlineParent {
return &ControlledDeadlineParent{
deadline_at: deadline_at
done_ch: chan int{}
mutex: sync.new_mutex()
}
}
fn (ctx &ControlledDeadlineParent) deadline() ?time.Time {
return ctx.deadline_at
}
fn (ctx &ControlledDeadlineParent) value(key context.Key) ?context.Any {
_ = key
return none
}
fn (mut ctx ControlledDeadlineParent) done() chan int {
ctx.mutex.lock()
done := ctx.done_ch
ctx.mutex.unlock()
return done
}
fn (mut ctx ControlledDeadlineParent) err() IError {
ctx.mutex.lock()
err := ctx.err_value
ctx.mutex.unlock()
return err
}
fn (mut ctx ControlledDeadlineParent) close_with_error(err IError) {
ctx.mutex.lock()
ctx.err_value = err
if !ctx.done_ch.closed {
ctx.done_ch.close()
}
ctx.mutex.unlock()
}
fn context_error_or_closed_without_error(mut ctx context.Context) ! {
err := ctx.err()
if err !is none {
return err
}
return error('context closed without error')
}
fn test_with_timeout_returns_timeout_error() {
xasync.with_timeout(5 * time.millisecond, fn (mut ctx context.Context) ! {
_ = ctx
time.sleep(50 * time.millisecond)
}) or {
assert err.msg() == 'async: timeout'
return
}
assert false
}
fn test_with_timeout_returns_without_waiting_for_ignored_cancellation() {
stopwatch := time.new_stopwatch()
xasync.with_timeout(10 * time.millisecond, fn (mut ctx context.Context) ! {
_ = ctx
time.sleep(250 * time.millisecond)
}) or {
assert err.msg() == 'async: timeout'
assert stopwatch.elapsed() < 150 * time.millisecond
return
}
assert false
}
fn test_with_timeout_zero_duration_expires_immediately() {
xasync.with_timeout(0 * time.millisecond, fn (mut ctx context.Context) ! {
done := ctx.done()
select {
_ := <-done {
return context_error_or_closed_without_error(mut ctx)
}
1 * time.second {
return
}
}
}) or {
assert err.msg() == 'async: timeout'
return
}
assert false
}
fn test_with_timeout_negative_duration_is_already_expired() {
xasync.with_timeout(-1 * time.millisecond, fn (mut ctx context.Context) ! {
done := ctx.done()
select {
_ := <-done {
return context_error_or_closed_without_error(mut ctx)
}
1 * time.second {
return
}
}
}) or {
assert err.msg() == 'async: timeout'
return
}
assert false
}
fn test_with_timeout_success_before_timeout() {
seen := chan int{cap: 1}
xasync.with_timeout(1 * time.second, fn [seen] (mut ctx context.Context) ! {
_ = ctx
seen <- 7
})!
assert <-seen == 7
}
fn test_with_timeout_returns_job_error() {
xasync.with_timeout(1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
return error('job failed')
}) or {
assert err.msg() == 'job failed'
return
}
assert false
}
fn test_with_timeout_preserves_job_error_matching_context_message_before_timeout() {
xasync.with_timeout(1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
return error('context deadline exceeded')
}) or {
assert err.msg() == 'context deadline exceeded'
return
}
assert false
}
fn test_with_timeout_refuses_nil_job() {
nil_job := unsafe { xasync.JobFn(nil) }
xasync.with_timeout(1 * time.second, nil_job) or {
assert err.msg() == 'async: job function is nil'
return
}
assert false
}
fn test_with_timeout_returns_timeout_for_cooperative_job() {
mut timed_out := false
xasync.with_timeout(50 * time.millisecond, fn (mut ctx context.Context) ! {
done := ctx.done()
select {
_ := <-done {
return context_error_or_closed_without_error(mut ctx)
}
1 * time.second {
return error('cooperative timeout job was not canceled')
}
}
}) or {
assert err.msg() == 'async: timeout'
timed_out = true
}
assert timed_out
}
fn test_with_timeout_context_uses_parent_context() {
mut parent_ctx, cancel := xasync.with_cancel()
cancel()
xasync.with_timeout_context(parent_ctx, 1 * time.second, fn (mut ctx context.Context) ! {
done := ctx.done()
select {
_ := <-done {
return context_error_or_closed_without_error(mut ctx)
}
1 * time.second {}
}
}) or {
assert err.msg() == 'context canceled'
return
}
assert false
}
fn test_with_timeout_context_preserves_already_expired_parent_deadline() {
mut background := context.background()
parent_ctx, parent_cancel := context.with_timeout(mut background, -1 * time.millisecond)
defer {
parent_cancel()
}
xasync.with_timeout_context(parent_ctx, 1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
return error('job should not run with an already expired parent')
}) or {
assert err.msg() == 'context deadline exceeded'
return
}
assert false
}
fn test_with_timeout_context_preserves_parent_deadline_that_expires_first() {
mut background := context.background()
parent_ctx, parent_cancel := context.with_timeout(mut background, 10 * time.millisecond)
defer {
parent_cancel()
}
xasync.with_timeout_context(parent_ctx, 1 * time.second, fn (mut ctx context.Context) ! {
_ = ctx
time.sleep(100 * time.millisecond)
}) or {
assert err.msg() == 'context deadline exceeded'
return
}
assert false
}
fn test_with_timeout_context_waits_for_controlled_parent_done_and_returns_exact_parent_error() {
mut parent_ctx := new_controlled_deadline_parent(time.now().add(-1 * time.second))
started := chan bool{cap: 1}
release := chan bool{cap: 1}
result := chan string{cap: 1}
caller := spawn fn [mut parent_ctx, started, release, result] () {
xasync.with_timeout_context(context.Context(parent_ctx), 1 * time.second, fn [started, release] (mut ctx context.Context) ! {
_ = ctx
started <- true
_ := <-release
}) or {
result <- err.msg()
return
}
result <- 'ok'
}()
select {
did_start := <-started {
assert did_start
}
1 * time.second {
assert false, 'with_timeout_context returned before starting the job for a pending parent'
}
}
select {
msg := <-result {
assert false, 'with_timeout_context returned before parent done closed: ${msg}'
}
else {}
}
parent_ctx.close_with_error(error('controlled parent deadline'))
select {
msg := <-result {
assert msg == 'controlled parent deadline'
}
1 * time.second {
assert false, 'with_timeout_context did not return after controlled parent closed'
}
}
release <- true
caller.wait()
}
fn test_with_timeout_context_uses_own_timeout_before_parent_deadline() {
mut background := context.background()
parent_ctx, parent_cancel := context.with_timeout(mut background, 1 * time.second)
defer {
parent_cancel()
}
xasync.with_timeout_context(parent_ctx, 10 * time.millisecond, fn (mut ctx context.Context) ! {
_ = ctx
time.sleep(100 * time.millisecond)
}) or {
assert err.msg() == 'async: timeout'
return
}
assert false
}
+29
View File
@@ -0,0 +1,29 @@
# x.async tools
This folder contains local helper scripts for safe validation of `x.async`.
Scripts must use repository-relative paths and must not depend on local machine
paths, secrets, or external services.
## `validate.sh`
Runs the guarded validation path:
- `./v fmt -verify` for module, tests, examples, and benchmarks V files.
- each public example under `vlib/x/async/examples/`, run serially.
- `./v test vlib/x/async`.
- `./v -prod test vlib/x/async`.
The script creates a fresh temporary root, sets isolated `VTMP` and `VCACHE`,
uses the repository-local `./v`, and runs commands serially. This avoids the
known class of V runner artefact/cache collisions that can happen when multiple
external runners share the same checkout/cache.
From the repository root:
```sh
sh vlib/x/async/tools/validate.sh
```
If a crash appears through this serialized and isolated path, treat it as a
blocking runtime/test signal. Do not classify it as tooling noise without a new
investigation.
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env sh
set -eu
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
repo_root=$(CDPATH= cd "$script_dir/../../../.." && pwd)
cd "$repo_root"
if [ ! -x ./v ]; then
echo "x.async validation must be run from a V checkout with local ./v" >&2
exit 1
fi
tmp_root=$(mktemp -d "${TMPDIR:-/tmp}/xasync-validate.XXXXXX")
cleanup() {
rm -rf "$tmp_root"
}
trap cleanup EXIT INT TERM
vtmp="$tmp_root/vtmp"
vcache="$tmp_root/vcache"
mkdir -p "$vtmp" "$vcache"
run_v() {
seconds=$1
shift
echo "+ ./v $*"
if command -v timeout >/dev/null 2>&1; then
timeout "$seconds" env VTMP="$vtmp" VCACHE="$vcache" ./v "$@"
else
env VTMP="$vtmp" VCACHE="$vcache" ./v "$@"
fi
}
# Keep the official x.async validation serial. Running two V runners against the
# same checkout/cache can make build artefacts collide before x.async code runs.
v_files=$(find vlib/x/async -type f -name '*.v' | sort)
example_files=$(find vlib/x/async/examples -type f -name '*.v' | sort)
run_v 60 fmt -verify $v_files
for example in $example_files; do
run_v 60 run "$example"
done
run_v 120 test vlib/x/async
run_v 180 -prod test vlib/x/async