Await and Task Outcomes
await is Sec's normal consuming synchronization operation for an owning
Task[T] handle. It waits until the task reaches a terminal state, resolves
the lifecycle obligation owned by the handle, and produces the complete terminal outcome.
let worker := try spawn Calculate()
let outcome := await worker
task has type Task[T], then
await task always has the static type TaskOutcome[T]. Await
never collapses to T, never flattens an application Result, and
consumes the owning task handle exactly once.
Purpose
A task has two distinct layers that must not be conflated. First, the task function
itself has a declared result type T. Second, the execution of that task
may complete normally, be cancelled, panic at a recoverable task boundary, or fail at
the runtime/executor layer. TaskOutcome[T] preserves both layers.
Await also resolves lifecycle ownership. A Task[T] is an owning handle;
once it is passed into await, the source binding no longer owns or controls
that lifecycle. The await operation itself owns the lifecycle until the terminal
outcome has either been transferred to source code or cleaned up internally.
Core Rule
Task[T] --await--> TaskOutcome[T]
awaitrequires one available owningTask[T].- The result type is invariantly
TaskOutcome[T]. - The source task handle is consumed.
- The same owning handle cannot be awaited twice.
awaitdoes not create or detach a task.- Static proof of normal completion does not narrow the result type to
T.
The invariant result type is important for source readability, exhaustive matching, tooling and generic code. Code does not silently acquire a different type merely because an optimizer or analysis happens to know more about one producer.
Syntax
The canonical form is:
await expression
The operand expression must evaluate to an owning Task[T].
let worker := try spawn Calculate()
let outcome := await worker
A freshly created task may be awaited directly:
let outcome := await (try spawn Calculate())
Parentheses follow ordinary expression precedence. There is no special combined
spawn await syntax.
TaskOutcome[T]
Await resolves the canonical compiler-known task-outcome union. Sec 0.1 has exactly four source-visible terminal outcome variants:
type TaskOutcome[T] union {
// The task function returned normally with its declared result T.
Completed(T)
// Cooperative task cancellation committed terminally.
Cancelled
// A panic escaped the task boundary under the selected recoverable
// task-boundary panic policy.
Panicked(PanicInfo)
// The already-created task failed at the execution/runtime layer.
Failed(TaskError)
}
Scheduler states such as queued, runnable, running, blocked and suspended are not task outcomes. They describe execution state before terminal completion.
TaskError
TaskError describes failures that occur after a task already exists and
that belong to the execution/runtime layer rather than to the task function's own
application-level result.
enum TaskError error {
// The already-created task execution mechanism could not obtain
// memory required to continue or resume execution.
OutOfMemory
// A runtime/executor resource required after successful task
// creation was exhausted.
ResourceLimit
// The executor/runtime responsible for an already-created task
// became unavailable before the task reached another terminal outcome.
ExecutorUnavailable
// A target/runtime execution failure occurred after creation and
// is not represented by another TaskError variant.
NativeFailure
}
TaskError is not used for cooperative cancellation, panic, task creation
failure or an application-level Err(E) returned as part of T.
TaskSpawnError Boundary
Task creation failure happens before a Task[T] exists and therefore belongs
to TaskSpawnError. Await can never produce TaskSpawnError, because
its operand is already an existing task.
enum TaskSpawnError error {
// Memory required to create the task could not be obtained.
OutOfMemory
// A creation-time task/worker/thread/slot resource limit was exhausted.
ResourceLimit
// The configured executor could not accept/create the requested task.
ExecutorUnavailable
// A runtime task configuration valid as source input could not be
// instantiated under the selected execution configuration.
InvalidConfiguration
// A lower-level creation failure was not represented more specifically.
NativeFailure
}
let worker := try spawn Calculate() // TaskSpawnError belongs here
let outcome := await worker // TaskOutcome[int] belongs here
PanicInfo
Panicked(PanicInfo) uses the canonical panic metadata type. Its portable
Sec 0.1 source-visible shape is:
type PanicInfo struct {
// Stable registered panic-reason identity.
ID: PanicID,
// Canonical source file metadata.
File: string,
// Canonical source line according to source-location rules.
Line: uint,
// Canonical source column according to source-location rules.
Column: uint,
// Canonical declaring/executing function metadata.
Function: string,
}
A task-local panic can become Panicked only when the selected panic policy
can contain the panic at the task boundary. A hard-termination policy is not required
to fabricate a recoverable outcome.
Normal Completion
Completed(value) means that the task function returned normally. The value
has exactly type T. Await does not reinterpret the task function's result.
fn LoadImage() Result[Image, IOError] {
// ...
}
let worker := try spawn LoadImage()
let outcome := await worker
The type of outcome is:
TaskOutcome[Result[Image, IOError]]
If LoadImage() returns Err(IOError.InvalidValue), the task completed
normally. The outcome is therefore:
Completed(Err(IOError.InvalidValue))
It is not Failed(TaskError).
Task[void]
Awaiting Task[void] produces TaskOutcome[void]. It does not
collapse to plain void.
match await worker {
Completed => {
Continue()
}
Cancelled => {
HandleCancellation()
}
Panicked(info) => {
HandlePanic(info)
}
Failed(error) => {
HandleTaskFailure(error)
}
}
Exhaustive Outcome Handling
TaskOutcome[T] follows ordinary exhaustive union matching. Await does not
introduce a producer-refined exception that allows abnormal outcomes to disappear
from the type.
match await worker {
Completed(value) => {
Use(value)
}
Cancelled => {
HandleCancellation()
}
Panicked(info) => {
HandlePanic(info)
}
Failed(error) => {
HandleTaskFailure(error)
}
}
Whether an outcome may be explicitly discarded follows ordinary discard and must-use rules. Await itself never silently erases abnormal outcomes.
Handle Consumption
Entering await transfers lifecycle ownership from the source binding into the await operation. The binding becomes unavailable immediately on every runtime path that executes the await.
let worker := try spawn Calculate()
let outcome := await worker
worker.RequestCancel() // invalid: worker was consumed by await
The source binding does not become available again after successful completion, and it is not restored if cancellation of the waiting execution wins before await commit.
Await Operation Ownership
While await is pending, the await operation itself owns the consumed task lifecycle capability. This is semantic ownership even when a backend represents it using an internal task control block, scheduler token, continuation record or another runtime structure.
That ownership must survive suspension, migration, cancellation races and cleanup. It ends only when one of two things happens:
- the terminal outcome commits and transfers to source-level code; or
- caller-cancellation cleanup resolves the child to terminal state and destroys the outcome internally.
Await Commit Point
The await commit point is the semantic boundary at which the awaited task is terminal,
await has acquired its terminal TaskOutcome[T], and that outcome is ready
to transfer to the awaiting continuation.
The following are not by themselves await commit:
- a scheduler wakeup;
- observing that the task appears done;
- a cancellation request;
- the child merely entering a terminal transition.
Await commit is atomic at the semantic level with respect to cancellation of the waiting execution.
Completion Wins
If await commit happens before current-execution cancellation commits, the await
expression produces the acquired TaskOutcome[T]. Any active payload moves
or copies into source-level ownership according to its ordinary type semantics.
A later cancellation observation cannot roll back the completed await. If the caller subsequently unwinds because of cancellation, the already-produced outcome participates in ordinary deterministic cleanup.
Caller Cancellation Wins
await is a cancellation point for the current execution. If caller
cancellation commits before await commit, source code receives no
TaskOutcome[T] from that await.
The await operation then:
- retains the internally owned task lifecycle;
- requests cooperative cancellation of the awaited task;
- waits until the child reaches one terminal outcome;
- destroys or discards that outcome internally according to ordinary destruction rules;
- only then allows terminal cancellation of the waiting execution to finish.
No Implicit Detach
Caller cancellation must not turn an awaited task into detached background work. The runtime may not hand the child to an unstructured background manager merely to make the caller cancel faster.
Likewise, the consumed handle is not restored. A cancelling execution may already be unwinding, and the runtime must never manufacture a second owning lifecycle handle.
Because cancellation is cooperative, a child that does not observe its cancellation request may delay terminal cancellation of the waiter. This is intentional: preserving lifecycle and borrowed-state safety takes precedence over pretending that cancellation is instantaneous.
Completion/Cancellation Race
Await completion and caller cancellation form a first-commit race. Exactly one side wins semantically.
- If await commit wins, source code receives exactly one
TaskOutcome[T]. - If caller cancellation wins, source code receives no outcome from that await.
- The runtime must never produce both results.
- The runtime must never produce neither while losing the owned lifecycle.
Physical wakeup order, worker migration, interrupt timing and OS scheduling do not change which semantic state committed first.
Child Cancellation vs Caller Cancellation
Cancellation of the awaited task and cancellation of the waiting execution are distinct state machines.
- If the child reaches
Cancelledand await commits first, source code receivesTaskOutcome.Cancelled. - If caller cancellation commits first, source code receives no child outcome.
- The child's eventual terminal outcome is cleaned up internally by await.
The child's eventual terminal category may still be Completed,
Cancelled, Panicked or Failed. Requesting
cancellation does not predetermine the result.
RequestCancel Before Await
The owner may explicitly request child cancellation before awaiting it:
worker.RequestCancel()
let outcome := await worker
RequestCancel() does not consume the handle and does not guarantee that
the task has already become terminal. Await still resolves the actual terminal outcome.
Suspension and Physical Blocking
Await is potentially waiting, but the language does not mandate one physical implementation. A conforming backend may suspend a logical task, park or reschedule a worker, use an RTOS wait primitive, resume from an event loop, or physically block a native thread when the active target profile permits it.
In a task runtime, logical suspension is preferred where available and required by the selected execution model. Busy waiting is not permitted unless the target/profile explicitly defines bounded busy-wait behavior.
Resumption and Migration
A suspended task may resume on a different permitted physical worker or thread. Its logical task identity does not change.
- Task-local cancellation follows the logical task.
- Thread-local state follows the physical-thread rules.
- Thread-local state must not be silently treated as task-local across migration.
- Migration must not change the resulting
TaskOutcome[T].
Result Ownership Transfer
On successful await commit, ownership of the active terminal outcome moves into the
source-level TaskOutcome[T] value.
Completed(T)transfers a move-onlyTexactly once.- Copyable
Tfollows ordinary value semantics after materialization. Panicked(PanicInfo)carries the canonical panic metadata value.Failed(TaskError)carries the exact execution error.Cancelledhas no payload.
No separate completion payload remains extractable from the consumed task handle after await.
Nested Result
Await never performs an implicit try on the value returned by the task
function.
Task[Result[Image, IOError]]
|
await
v
TaskOutcome[Result[Image, IOError]]
A completed application error remains Completed(Err(error)). There is no
Sec 0.1 try await flattening construct, and future composition must preserve
the type layers unless explicitly standardized.
Await in Expressions
Await is an ordinary expression whose type is TaskOutcome[T]. It may appear
wherever that result type is valid.
let outcome := await worker
return await worker
Handle(await worker)
Ordinary Sec evaluation order still applies. Optimizations must not reorder observable side effects across an await boundary when doing so would change program behavior. Await commit is a synchronization and semantic side-effect boundary.
Forbidden Contexts
Inside defer
Sec 0.1 does not allow a user-authored await inside defer. Cleanup must not
introduce a new suspending lifecycle wait.
defer {
let outcome := await worker // invalid
}
Suggested diagnostic:
await is not allowed inside defer
During destruction
Sec 0.1 has no asynchronous destruction model. Destructors/finalization logic must not introduce source-level await suspension. The runtime lifecycle cleanup performed after cancelled await is a separate internal mechanism.
Compile-time and hidden static initialization
Await is not permitted during compile-time evaluation or hidden static initialization. Runtime task startup and waiting must remain visible in executable control flow.
Await vs Join
Await and join both synchronize with task completion, but their ownership behavior is intentionally different.
await task
waits for terminal task completion
consumes Task[T]
resolves lifecycle ownership
returns TaskOutcome[T]
join task
waits for terminal task completion
preserves the owning task handle
establishes terminal observation/synchronization
does not itself transfer TaskOutcome[T]
A handle preserved by a valid join may later be consumed by await when the task's
complete lifecycle/result capability remains available. The later await still returns
the full TaskOutcome[T] and consumes the handle.
Observers and Other Handles
Sec 0.1 await is task-specific and owning. It does not define a general structural Awaitable protocol.
- A non-owning
TaskObserver[T]cannot be awaited as an owning task. Thread[T]is not awaitable.- Process handles are not awaitable.
- User-defined types do not become awaitable by declaring
Await,PollorReadymethods. - No implicit conversion to
Task[T]occurs.
await requires Task[T]; got Thread[T]
await requires an owning Task[T]; got TaskObserver[T]
Guards and Borrows Across Await
MutexGuard
A live MutexGuard[T] may not cross an await suspension boundary. The
compiler rejects await while a guard remains live unless it can prove that the guard
has ended before suspension can occur.
mutex guard state remains active across await
Ordinary borrows
Await is not an automatic end for every borrow. A borrow may remain live across await only when analysis proves that all lifetime, aliasing, address-stability, migration and cancellation-cleanup requirements remain valid.
At minimum, the compiler must prove that:
- the owner remains alive;
- the referenced storage remains stable;
- no conflicting access can occur;
- suspension and resumption do not invalidate the reference;
- migration restrictions are satisfied;
- caller-cancellation cleanup cannot outlive the referenced owner.
If the child itself holds a valid borrow into caller-owned state, that borrow remains live until the child reaches terminal state. This is why pre-commit caller cancellation must resolve the child lifecycle instead of detaching it.
Move-Only Values Across Await
Ordinary owned move-only values may remain live across await when the continuation representation can preserve their ownership and destruction requirements. The compiler may spill them into task-frame storage as necessary.
Caller cancellation must still destroy every value owned by the waiting task exactly once. A value already transferred into the child is not simultaneously owned by the waiter.
Context, Timeout and Select
No Context operand
Sec 0.1 await has no Context parameter and does not read a hidden ambient
context. Context cancellation affects await only when application/runtime logic
explicitly converts it into the relevant execution/task cancellation behavior.
No await timeout parameter
Await does not have a timeout argument or Await(context) method. Timeout is
composed through select/after where the select rules support the
task branch.
Select
A select branch that commits task-outcome ownership uses the same result type and
ownership semantics as direct await: TaskOutcome[T]. Non-selected branches
must not consume the owning task handle.
Memory Synchronization
Successful await commit establishes task-completion synchronization. Memory effects published by the completed task according to the concurrency memory model become visible to the awaiting continuation through the canonical completion happens-before edge.
Merely observing a task status flag is not equivalent to await synchronization. A cancellation request is likewise not a general synchronization operation for unrelated application memory.
If caller cancellation wins, the internal cleanup wait must still establish enough synchronization to destroy the child's terminal outcome and owned resources safely.
Destruction and Cleanup
After successful await
Once await commits, the terminal outcome is owned by source-level code. Runtime task
resources owned solely by the consumed handle may be reclaimed when no observer or
runtime dependency still needs them. The runtime must not destroy a move-only
Completed(T) payload that has already transferred to the caller.
After caller-cancelled await
When caller cancellation wins before await commit, no source-level outcome is produced.
After the child becomes terminal, its outcome is owned by the internal cleanup path and
destroyed exactly once. A Completed(T) produced despite the cancellation
request must be destroyed normally rather than leaked.
Semantic Analysis
The compiler validates at least:
- the operand is exactly one available owning
Task[T]; - the task handle has not already been consumed;
- the result type is exactly
TaskOutcome[T]; - all task outcome payload types resolve correctly;
- move-only result ownership is represented exactly once;
- caller-cancellation cleanup preserves lifecycle ownership;
- no invalid mutex guard crosses suspension;
- borrows remain valid across suspension and cancellation cleanup;
- await is not inside
deferor compile-time/static initialization; - the selected target/profile supports task waiting;
- every control-flow edge resolves lifecycle ownership correctly.
Sema must not infer bare T as the await result and must not flatten
Task[Result[V, E]].
Flow-sensitive ownership analysis marks the source binding consumed on every path that executes await. Borrow analysis treats await as a potential suspension point and models caller-cancellation cleanup as potentially extending child-held borrows until child terminal cleanup completes.
Await also contributes a wait dependency to blocking/deadlock analysis. Cancellation of the waiter does not allow the analysis to assume that this dependency disappears immediately, because child lifecycle cleanup may continue waiting for terminal state.
Semantic IR and Lowering
Await remains an explicit semantic operation in Semantic IR. It must not be erased early into a normal function call or a raw synchronization primitive.
IR must preserve facts including:
- concrete
Task[T]andTaskOutcome[T]identities; - consumed lifecycle ownership;
- task/control identity;
- the await commit boundary;
- terminal outcome and active payload ownership;
- caller-cancellation registration and the first-commit race;
- child cancellation request on pre-commit caller cancellation;
- internal lifecycle retention and outcome-destruction path;
- suspension/resumption continuation;
- live borrows and guard restrictions;
- the task-completion synchronization edge;
- source provenance and target/profile requirements.
Lowering may choose different runtime mechanisms per target, but it must preserve
invariant TaskOutcome[T], exactly-once handle consumption, exactly-once
terminal outcome transfer or internal destruction, the completion/cancellation race,
no implicit detach, and all borrow/memory-synchronization guarantees.
Runtime Obligations
A conforming runtime must be able to register completion waiting and cancellation wakeup without losing task lifecycle ownership. Completion and caller cancellation must resolve atomically at the semantic level.
If caller cancellation wins before await commit, the runtime must:
- request cooperative cancellation of the child;
- continue owning and waiting for the child lifecycle;
- acquire the eventual terminal outcome;
- destroy that outcome internally;
- only then complete terminal cancellation of the waiter.
An already-terminal task may use an optimized fast path, but the ownership and commit semantics are identical.
LSP and Diagnostics
LSP hover on an await expression must show TaskOutcome[T] with the concrete
substituted T. It must never show bare T merely because normal
completion has been proven.
Navigation and completion should expose all four canonical outcome variants. Tooling
must not invent variants such as Success, Error or
Timeout.
Typical diagnostics include:
await requires Task[T]; got Thread[T]
await requires Task[T]; got Process
await requires an owning Task[T]; got TaskObserver[T]
use of consumed task worker
await is not allowed inside defer
mutex guard state remains active across await
borrow may become invalid while awaited task is still running
await is not available during compile-time evaluation
Diagnostics should point to both the consumed handle and the prior consumption site, and borrow diagnostics should identify the owner, borrow creation and await/cancellation cleanup lifetime.
Target and Profile Requirements
The immutable CompilationPlan determines how task waiting can be realized
on the selected target. Relevant capabilities may include task runtime availability,
suspension/resumption, worker parking or blocking policy, cancellation wakeups,
task-boundary panic containment, task runtime failure reporting, synchronization
primitives and no-allocation or bounded-runtime restrictions.
A target that cannot support tasks must reject task use through target validation. It
must not invent an await-specific runtime error or a target-specific variant of
TaskOutcome[T].
Specification Status
The canonical await rulebook is normative for Sec 0.1 and is revision 2.0. It defines the complete source semantics described on this page, including the exact outcome surface, task-handle consumption, await-commit semantics and caller-cancellation cleanup behavior.
Mutable implementation status is intentionally maintained separately in
governance/concurrency_await.yaml. This manual page does not infer or
duplicate that mutable ledger from the normative rulebook alone.
Best Practices
- Always treat
await Task[T]as producingTaskOutcome[T], never bareT. - Handle application errors inside
Completed(...)instead of confusing them withTaskError. - Remember that await consumes the owning task handle.
- Use
RequestCancel()when you want to request child cancellation before lifecycle resolution. - Do not expect waiter cancellation to detach the child or restore the consumed handle.
- Release
MutexGuardvalues before an await boundary. - Keep borrows across await only when their complete suspension and cancellation lifetimes are provably valid.
- Use join when you need completion synchronization while preserving the owning task handle.
- Use select/after for timeout composition instead of inventing an await timeout parameter.
- Rely on await for completion synchronization rather than polling a task status flag.
Summary
await Task[T]always has static typeTaskOutcome[T].TaskOutcome[T]has exactlyCompleted(T),Cancelled,Panicked(PanicInfo)andFailed(TaskError).TaskErrordescribes execution/runtime failure after successful creation;TaskSpawnErrorbelongs to creation.- An application-level
Err(E)returned by the task is still normal completion:Completed(Err(E)). Task[void]awaits toTaskOutcome[void], not plainvoid.- Await consumes the owning task handle exactly once.
- The await operation internally owns the lifecycle while waiting.
- Await commit and caller cancellation form a first-commit race.
- If caller cancellation wins, the child is cancellation-requested and fully resolved before waiter cancellation completes.
- Caller cancellation never implicitly detaches the child and never restores the consumed handle.
- Await may suspend and resume on another worker while preserving logical task identity.
- Await never implicitly flattens nested
Result. - Await is invalid inside
defer, asynchronous destruction and compile-time evaluation. - Join preserves the owning handle; await consumes it and transfers the full terminal outcome.
- Observers, threads, processes and arbitrary user values are not awaitable in Sec 0.1.
- Live mutex guards may not cross await suspension.
- Ordinary borrows may cross await only when lifetime, alias, migration and cancellation-cleanup requirements are proven.
- Successful await commit establishes task-completion synchronization.
- Semantic IR must preserve lifecycle ownership, commit state and cancellation-race semantics explicitly.