Host runtime specification
Since: 0.1.0
This document defines the portable runtime boundary shared by Swift, Kotlin, Go, Rust, C++, C#, and Unity implementations. C# is the reference implementation. Language-specific threading and reflection must stay outside the portable core.
Responsibilities
GameEventScriptProgram is the immutable parsed .gesb artifact. It contains
portable runtime segments and optional debug/source/build/opaque segments, but
no VM state, random generator, delegate, host binding, or queue. The normative
container is defined by Binary format. A program may be loaded into multiple hosts at
the same time. All public program segments copy their input and expose read-only views. Its
resource metadata contains only portable integers: per-handler requirements in
message-handler binds and their maxima in the program header model.
GameEventScriptHost is one autonomous serial execution unit. It owns the local
message queue, exact- and name-subscription indexes, private random stream,
runtime limits, observer, optional outbound sink, linked program instances, and
at most one reusable GesVmState. A host may contain only native handlers and
does not create a VM until the first program is loaded. Host construction accepts
a seed, entropy bytes, or an immutable sequence configuration, never a live
mutable generator; building two hosts from the same configuration creates two
independent streams.
GameEventScriptInstance is one host-specific link of one program. Load is
additive and returns an instance. Linking resolves extension and external-type
references against that host. The host assigns a stable, non-reused registration
ID. The instance retains only its host and ID for lifecycle operations; it does
not retain a detach closure. Detach is idempotent and removes the instance from
future dispatch snapshots. Before registering anything, loading rejects a cyclic
synchronous call graph or requirements above MaxRegisterValues and
MaxCallDepth. It then pre-warms the reusable register storage from the program
maximum.
GameEventScriptSubscription represents one native registration. Unsubscribe
uses the same host-owned registration-ID model, is idempotent, and affects future
snapshots only. Registration IDs are host-local lifecycle identities and are not
wire or program identities.
Portable native handlers implement IGameEventScriptNativeMessageHandler and
receive the message plus the host’s context through Handle. The Core stores the
handler object directly and contains no Action delegate adapter. C# callers may
use the Action<GameEventScriptMessage, GameEventScriptContext> convenience
overloads in CSharpBridge; other ports provide their own language-idiomatic
adapters without changing the Core contract.
An extension function may throw GameEventScriptExtensionFaultException to
deliberately report a defined failure, or throw any other exception
unanticipated by the runtime; Diagnostics owns the resulting
stable codes and the shared “current handler only, host stays reusable, never
nothing” contract shared with native handlers and the external-type boundary.
GameEventScriptContext is created once per host and passed to native handlers
and extensions. It exposes the host random stream, limits, Emit, and Publish.
Its random generator supports nested Push(), Push(Int64), and Pop() scopes.
It does not own a queue, VM, or scheduler. A context or its random generator must
not be retained for asynchronous use after a native or extension callback.
GameEventScriptVirtualMachine is a stateless executor. GesVmState contains
only the currently resumable execution: active linked program, instruction
pointer, registers, frames, stages, and active message. Random state and its
scope stack belong to the host-owned generator so extensions observe the same
active scoped stream as bytecode. The host
reuses this state serially for every script handler and fully resets it after
completion or failure. VM pooling across hosts is not part of this architecture.
All portable failures follow Diagnostics. Ordinary runtime handler failures
are reported to the observer, abort/reset only the failing handler, and leave the
remaining immutable dispatch snapshot runnable. A pump result that observed a
handler failure uses RuntimeError and carries its first diagnostic. Successful
VM stepping, resume, and dispatch do not allocate diagnostic objects.
Native callback atomicity means that the portable scheduler does not preempt a callback; it does not imply a transaction over application state. Initialization output staging and failure cleanup below cover host-managed registrations and outputs, not state already changed by native callbacks. Application-state ownership is defined in Language.
Compatibility of message behavior
Within a compatible release line, the enqueue-time recipient snapshot, dispatch priority and registration order, FIFO rules, initialization barriers, delayed message tie-breaking, and specified failure/detach handling remain part of the observable language contract. A compiler or runtime update must not independently change them. This preserves the specified ordering guarantees, not wall-clock execution speed or ordering between independent hosts.
For recompiled code, changed random results or opcode counts can affect subsequent messages and frame/limit outcomes under the exceptions in Determinism. Delayed execution additionally depends on clock observations and embedding pump calls. A speed improvement alone is not a promise of identical real-time interleaving with external inputs. Native callbacks remain subject to the embedding’s contract.
Loading and startup
Build() creates a host in its initial loading phase. IsReady is false, even
when IsIdle is true. Register native handlers and call Load for every initial
Program before Start. Receive returns false before a successful Start;
ExecuteFrame and RunToCompletion reject pumping a host that is not ready.
Load validates and links before committing registration. Validation, resource,
link, and initialization-queue capacity failures leave registrations, queued
messages, active execution and random state unchanged. An initialization snapshot
reserves one queue slot under MaxQueuedMessagesPerRun; insufficient capacity
raises link.initializationQueueFull. A Program without initialization needs no
slot. Loading is available to embedding code and native message handlers.
Reentrant loading during active script/extension execution or the initial Start
is rejected; paused execution between frames does not prohibit host-side loading.
Scripts and extension contexts have no loading API.
The initial lifecycle is summarized below. The readiness barrier completes initializations before ordinary message dispatch begins.
Diagram source (Mermaid)
flowchart TD
accTitle: Initial host loading and startup
accDescr: Build creates a loading host. Register handlers and load initial programs, then Start runs their initialization in load order. Only complete group success makes the host ready. Failure discards the group and leaves the host permanently not ready.
Build["Build: loading host"] --> Load["Register handlers and load programs"]
Load --> Start["Start: initialize in load order"]
Start --> Result{"All initializations succeed?"}
Result -->|Yes| Ready["Ready: receive and pump messages"]
Result -->|No| Failed["Group discarded; host remains not ready"]Initial group
Start() runs only initialization snapshots, in Load order. Handlers inside one
instance retain their declaration order. All initial registrations are already
available when any initialization emits, so its enqueue-time snapshot can include
another member of the group. No ordinary queued message is dispatched by Start.
Every initialization uses the ordinary runtime safety limits. Exhaustion fails
startup; the initial group never becomes partially ready. Successful Start marks
the host ready and returns StartResult.Ready, while emitted messages may remain
queued. StartResult also reports initialization opcode, completed-snapshot, emit,
and publish counters; these exclude subsequent ordinary dispatch. A runtime fault or exhausted safety limit returns RuntimeError or
RuntimeLimitReached, with a structured diagnostic. Failure removes the group’s
registrations and discards all its queued outputs. The host remains not ready and
cannot be started again. Repeating Start returns its original result without
executing code. Reentrant Start and pump calls are rejected.
Later instances
After Start, Load queues initialization at the normal FIFO position and returns
an Instance with absent StartResult. No extra priority is assigned to init.
The current logical message completes before the new initialization starts.
A later initialization may pause across ExecuteFrame calls. Its instance cannot
execute ordinary handlers until all its initialization handlers succeed. A later
Program without initialization has an immediate Ready result.
Success stores Ready on the instance and releases its buffered outputs. Failure stores RuntimeError or RuntimeLimitReached and its diagnostic, removes the instance’s registrations, discards its buffered outputs, and cancels every pending recipient entry belonging to that instance. Other recipients of the same logical message retain their order and still execute. Messages with no surviving recipient are removed without dispatch or undeliverable redistribution. This failure cancellation is stronger than ordinary Detach, which retains captured recipients. The host remains ready; the embedding decides whether to retry with a new Load. A new instance never inherits the previous instance’s captured deliveries.
The instance’s immutable completed StartResult remains readable after removal.
A startup safety limit is represented by runtime.initializationLimitReached;
the regular runtime-limit observer also identifies the limit and bound. Runtime
faults retain their original diagnostic code and context.
Initialization outputs
Init Emit captures recipients and reserves its FIFO position immediately.
Local delivery waits until that instance succeeds. Queue limits include staged messages and queued initialization
snapshots. Overflow of staged outputs exhausts the initialization. External
Receive calls between paused init frames enter the ordinary queue independently
and are not discarded as that init’s output. Observer emit notifications describe
attempts; their existence is not proof of committed delivery.
Init Publish also defers the external sink call: until the entire initial group
succeeds, or until the individual later init succeeds. Its immediate result has
OutboundDeferred when a sink exists, with OutboundAttempted/OutboundAccepted
false. AnyAccepted includes deferred acceptance. The observer receives the final
publish result on release; failed initializations never call the sink. Successful
local acceptance during init is provisional. Sink rejection/failure at release
retains ordinary publish semantics and does not undo successful initialization.
Direct external effects performed by arbitrary native callbacks are not rolled
back. Host readiness promises successful init, not completion of its emitted
message chains.
Random Ownership, Boundaries, and Limits
MaxRandomScopeDepth is the exact number of simultaneously active regular
random scopes permitted in one host. Zero permits no nested scope. On first use,
the generator allocates that many parent-state slots plus one reserved fault-gate slot;
the gate does not reduce the configured usable depth. Runtime boundary markers
are separate metadata and do not count as random scopes.
The host marks the random scope depth when every native or script handler starts. An extension call adds a nested marker. A script marker remains active while its VM execution is paused between frames, and may therefore be released on a different thread from the one that created it. Markers and scope state are ordinary host-owned heap state; they never use thread-local, async-local, or native call-stack storage.
The first push beyond MaxRandomScopeDepth copies the current valid stream into
the reserved gate slot, latches a MaxRandomScopeDepth runtime-limit fault, and
uses the current state only as disposable work until the active boundary returns.
Further pushes increment only a suppressed-depth counter and matching pops
decrement it; neither operation may reach the valid parent stack. Random draws
after the fault cannot alter the saved stream. Emit and Publish attempted
while the gate is active are observed as rejected, and Publish does not invoke
the outbound sink.
At boundary release the generator restores the gate state, unwinds every regular
scope above the marker, and clears the gate. Bytecode observes a rejected push
immediately and stops the current handler. An extension or native handler is an
atomic trusted callback and cannot be preempted portably; the VM or host stops
the handler as soon as that callback returns. No exception is part of this
contract. The observer receives one runtime-limit event and the pump returns
RuntimeLimitReached; the host remains reusable.
A pop may not cross the innermost runtime marker. Crossing it is a runtime error. A callback that returns with otherwise balanced execution but leaves scopes open is also a runtime error, after the marker first restores the parent stream. If a different runtime limit already aborted a script body before its generated pops, marker cleanup is expected recovery and adds no second imbalance diagnostic.
External Type Boundary
External types use two deliberately separate inputs. Compilation receives an
IGameEventScriptExternalTypeCatalog containing only declarative type, field,
constructor, parameter, and signature data. It does not contain executable
bindings. The resulting program stores only portable external-constructor import
bindings; it never stores the catalog or its definitions.
Host linking receives an IGameEventScriptExternalTypeRegistry that resolves a
constructor import to an IGameEventScriptExternalTypeConstructor. A resolved
constructor must report exactly the definition requested by the import. Runtime
values cross the portable boundary as IGameEventScriptExternalValue, exposing
their declarative definition and field values without exposing a platform host
object.
A constructor or a field accessor may likewise throw
GameEventScriptExtensionFaultException or an unanticipated exception; see
Diagnostics for the resulting codes and contract.
The C# bridge may implement both inputs with one convenience object. Its
reflection metadata, attributes, CLR instances, field delegates, conversion,
and constructor invocation remain entirely inside CSharpBridge. Portable JSON
conformance uses a manual catalog, constructor registry, and value
implementation and therefore does not depend on C# reflection.
Message Semantics
- Message arguments are ordered
GameEventScriptMessageArgumentname/value pairs. Their order is part ofSignatureId; neither Core construction nor conformance decoding derives it from a map’s iteration order. - Named argument labels must be unique after portable normalization. Repeated
_labels remain valid because they represent distinct positional arguments. - C# tuple construction is a
CSharpBridgeconvenience. Its dictionary adapter requires a knownGameEventScriptMessageSignatureand binds values in that signature’s declared order. Receive(message)captures current subscription snapshots and queues the message locally. It never invokes the outbound sink.Emit(message)does the same from a running handler.Publish(message)first attempts the same local enqueue and then calls the host’s single synchronousIGameEventScriptPublishSink, if configured.GameEventScriptPublishResultreportsLocalAccepted,OutboundAttempted,OutboundAccepted,OutboundDeferred, and derivedAnyAccepted. Sink rejection or exception never rolls back or corrupts local delivery. The observer sees the result.- Queue limits count logical messages, not handler invocations.
- Each queued message owns immutable exact/name subscription snapshots and a cursor. Every matching handler finishes in priority/registration order before the next logical message starts.
- A detach, unsubscribe, subscribe, or load during dispatch does not modify already captured snapshots. It applies when a later message is enqueued.
on initializationis a special per-instance handler, never normal external input. The initial Start barrier and subsequent FIFO initialization follow Loading and startup.
Portable State Machine
Since: 0.2.0 — Waiting and delayed-work transitions
The following pump states apply after successful Start. Initial loading and the
terminal startup-failure state are defined in Loading and startup.
Here Ready means queued work, distinct from the public IsReady startup flag.
Diagram source (Mermaid)
stateDiagram-v2
accTitle: Portable host pump states
accDescr: Runnable snapshots move the idle host to Ready, and pumping starts Dispatching. Native handlers are atomic. Script handlers can pause and resume across frames, then return to Dispatching on completion or failure. After a message completes, the queue determines Ready, Waiting or Idle. Safety-limit stops are described in the transition table.
direction TB
Idle --> Ready: Enqueue runnable work
Idle --> Waiting: Only future work
Ready --> Dispatching: Pump next snapshot
Dispatching --> Dispatching: Native handler
Dispatching --> ScriptRunning: Script handler
ScriptRunning --> Paused: Frame budget exhausted
Paused --> ScriptRunning: Resume in a later pump
ScriptRunning --> Dispatching: Handler completes or fails
Dispatching --> Ready: More runnable work
Dispatching --> Waiting: Only future work remains
Dispatching --> Idle: Queue empty
Waiting --> Ready: Pump with due or runnable workThe diagram is an overview; the transition rules include the following actions
and results. RuntimeError and RuntimeLimitReached are pump results, not
additional startup/readiness states.
| State / event | Action and next state |
|---|---|
| Idle; Receive, Emit, Publish, or Load with late initialization | Enqueue the captured snapshot. Runnable work leads to Ready; only future work leads to Waiting. |
| Ready; pump | Dequeue one logical message and enter Dispatching. |
| Dispatching; next native handler | Reset the per-handler safety budget and invoke atomically; remain Dispatching. |
| Dispatching; next script handler | Reset the per-handler safety budget; bind linked Program, entry and message to the host VM state; enter ScriptRunning. |
| Dispatching; no remaining handler | Complete the logical message. Continue in Ready when runnable work remains, Waiting when only timers remain, or Idle when the queue is empty. |
| Waiting; caller pumps | At or after NextMessageDelay, promote due messages behind runnable FIFO work and enter Ready. Remain Waiting if no work is runnable. Newly enqueued runnable work can be pumped before a future deadline. |
| ScriptRunning; frame budget remains | Execute opcodes synchronously; remain ScriptRunning or return to Dispatching. |
| ScriptRunning; frame budget exhausted | Enter Paused and retain the VM state. Resume that handler in a later pump. |
| ScriptRunning; handler completes | Fully reset VM state and return to Dispatching. |
| ScriptRunning; handler fails | Report the diagnostic, fully reset VM state and continue Dispatching; the pump result is RuntimeError. |
| ScriptRunning; handler safety limit reached | Reset VM state and stop the pump with RuntimeLimitReached. |
ExecuteFrame(opcodeBudget) is a caller-thread scheduler slice. Its opcode
budget is independent from safety limits and may pause only script bytecode;
native handlers remain atomic. RunToCompletion() pumps synchronously until the
host becomes idle, only future work remains, or a runtime limit stops the run. Core code starts no thread
and performs no synchronization. A host is serial but not thread-affine: only
one caller may access it at a time, while later frames may run on another thread
when the embedding environment supplies the required happens-before handoff.
The processed-message limit counts completed logical messages, after all of
their captured handlers have finished. After completing the configured number,
the pump reports RuntimeLimitReached and one corresponding observer event only
if another logical message remains queued. That next message stays queued and
is not started until a later pump call. If completing the last message drains
the queue exactly at the limit, the pump returns Completed without a
processed-message-limit observation, unless an independent error or limit
determines the result. These rules apply to both ExecuteFrame and
RunToCompletion, to native and script handlers, and to messages enqueued by
handlers during the pump.
Literal parsing limits
Each parse invocation has fixed portable bounds, applied only while executing
ParseLiteral: at most 1,048,576 input Unicode scalars (MaxLiteralInputScalars),
64 nested List/Map/Dice/Vector/Point containers (MaxLiteralDepth), and 65,536
total List items, Map entries, Dice rolls, and explicitly supplied Vector/Point
components across the whole input (MaxLiteralItems). A Dice, Vector, or Point
literal consumes one container level, including an empty form; its syntax does
not imply an additional List. Omitted spatial components do not consume items.
Duplicate Map keys
still count as entries. Scalar roots do not consume an item or nesting slot.
Exactly each bound is allowed; the next scalar, nested container, or item
exceeds it. Input length is checked before recognition. Nesting and item bounds
are checked incrementally before creating the next container or reading the
next item; trailing syntax errors do not undo a limit already encountered.
Exceeding a bound exhausts the current handler with the existing runtime-limit
mechanism and the corresponding stable limit name and numeric bound. It does
not return original Text as a recognition fallback or emit a partial value.
Previously completed effects remain observable and subsequent queued messages
can run normally. The counters are local to one parse invocation; programs
without parse maintain no parsing state or counters.
Iterator and Generated-Collection Limits
MaxLoopIterations counts successful iterator advances across all loops and
iterator-backed selectors in one handler. A present nothing item consumes one
iteration, just like any other value. Exhaustion and non-iterable sources consume
no successful-advance iteration. Compound intersection/difference additionally
charge this same counter before visiting each left candidate and before each
right search slot (including already-used slots). Map compound operations charge
each left candidate, each key-list validation item and each key search comparison. Failed searches therefore
cannot perform unlimited work inside one opcode. Exactly the configured number of advances is allowed; attempting
another successful advance stops the handler before its loop body or selector
expression executes. Nested and sequential loops share this counter. Frame
pauses retain it, and starting the next handler resets it.
MaxGeneratedCollectionItems bounds each collection incrementally materialized
by generated lists and iterator-backed selectors. The limit applies to retained
items, rather than the number of source items visited or a cumulative total
across separate collections:
- Compound iterator search masks and retained map results are individually bounded before growth. Cartesian pair Lists and entry Maps count their own retained components when materialized; direct register output creates no such collection and does not charge its size. Native Cartesian checks its outer product size using overflow-safe arithmetic before allocation.
- List projections, filters, and generated lists count every appended result,
including
nothing. - Map projections count distinct accepted keys. Replacing an existing key’s value
does not grow the map; skipped empty or
nothingkeys do not count. - Distinct-by projections count retained distinct keys, so discarded duplicates do not count again.
- Order-by projections count every buffered source item.
- Group-by projections limit both the number of groups in the result map and the number of items in each group’s list, independently.
Exactly the configured collection size is allowed. Before an insertion would exceed it, the runtime rejects that insertion without growing the collection and stops the handler without returning a partial projection result. Previously completed effects remain observable. This is an incremental materialization limit, not a byte-allocation budget or a bound on existing input collections.
For either limit, a nonpositive value disables that limit. An exceeded limit
produces one structured runtime-limit observation for the failing handler and
stops the pump with RuntimeLimitReached. Normal handler cleanup restores random
boundaries and clears VM state. Remaining handlers and queued messages stay
available for a later pump call.
C# Automatic Runner
Both native bridge runners are optional. They take ownership without implicitly starting a loading host. Register handlers, load Programs, and call the runner’s synchronous Start; successful Start schedules remaining ordinary work. A runner wrapping an already ready host schedules its pending work immediately. Both runners serialize callbacks on a shared background dispatcher and expose readiness, the last execution result and synchronized instance startup results.
CSharpBridge.GameEventScriptCSharpHostRunner serializes access
to one host with a C# lock and schedules at most one pump job for that host on a
shared dispatcher. Concurrent Receive, Load, subscribe, detach, and
unsubscribe calls go through the runner. Creating a runner immediately schedules
one pump when its host is ready and already has pending work, including queued
messages, late initialization, or a paused script handler. No later Receive or Load is
required to start that work. An idle host waits for later accepted work.
The initial scheduling uses the same serialization gate and outstanding-job guard as subsequent runner operations. The caller stops accessing the host directly when transferring ownership; pending callbacks may start before runner creation returns. Disposing the runner suppresses scheduled pumps that have not started, while preserving the host’s pending work.
Swift, Kotlin, Go, Rust, C++, and Unity provide their own actor, executor, event-loop, or main-thread policy around the same synchronous core contract.
Hot-Path Rules
- Script registrations store
GameEventScriptInstance + EntryAddress; no per-invocation delegate closure, runner object, or invocation interface exists. - The queue is a preallocated growing ring of logical message envelopes.
- Dispatch selection walks captured arrays without constructing handler lists.
- Handler registrations prepare their dispatch signature once and reuse it for VM entry, observer callbacks, and diagnostics.
- Execution and publish results are value types.
- Program loading prewarms VM register capacity. Later register growth is
geometric and bounded by
MaxRegisterValues. - Synchronous script call graphs are acyclic. A root handler has call-stack depth zero; every nested call adds one entry.
- After warmup, queue dispatch, handler selection, frame-result creation, and VM resume must not allocate. Message creation, emitted value payloads, extension behavior, and Conformance Markdown/YAML decoding are measured separately.
- Random boundary and scope storage is initialized on first use and retained by the host. Push, Pop, marker creation, overpush gating, and frame resume allocate nothing after that warmup.
Conformance Porting Contract
Every implementation runs the Conformance Markdown cases through this sequence:
source -> GameEventScriptProgram -> optional .gesb Write/Read -> Host.Load -> Host.StartYAML step input -> GameEventScriptMessage -> Host.ReceiveHost.ExecuteFrame or Host.RunToCompletion -> observed local/outbound messagesThe same Markdown cases define ordering, tags, initialization, multiple programs,
frame pause/resume, runtime limits, and Emit/Publish/Receive behavior for every
language in the monorepo. Message args are always encoded as an ordered YAML
sequence of mappings with name and value entries, including nested message
values and expected local/outbound messages.
Delayed dispatch
Since: 0.2.0
Each Host borrows a monotonic clock reporting nonnegative whole microseconds. The default clock measures actual elapsed time; an embedding can supply a clock for deterministic simulation or testing. Clock reads never pump messages.
A positive finite time quantity is converted to microseconds, rounded upward. Integer seconds are converted with checked integer arithmetic. Binary64 seconds are multiplied by 1,000,000 using binary64 arithmetic and rounded upward; the result must be smaller than 2^63. A negative input is invalid even if rounding would produce zero. The absolute deadline is clock time at acceptance plus the delay; overflow rejects the send. This is a minimum scheduling delay, not a real-time execution guarantee.
Delayed entries capture the same recipient snapshot as instant enqueue. Newly registered recipients do not receive older entries. Ordinary detach retains its captured-delivery behavior. When deadlines become due, entries are appended to the runnable FIFO in deadline order; equal deadlines retain enqueue order. Promotion occurs between complete logical messages, never in the middle of an active or paused handler. Future entries do not block runnable messages.
Publish delays both local delivery and the outbound sink invocation. A later sink rejection or failure cannot change the Boolean already returned to script. The existing sink diagnostic behavior remains applicable. Emit observations occur at acceptance; delayed publish observations occur when the publication is released.
Init computes deadlines when the expression executes but cannot release its outputs before its initial group or later-instance initialization succeeds. Already due entries become eligible after that barrier. Initial-group failure clears all outputs; later-instance failure clears its staged outputs and removes captured deliveries to the failed instance without canceling independent outbound publication or other recipients. Accepted sends outlive ordinary sender detach. Delayed entries count towards the existing logical-message queue limit; promotion does not reserve a second slot.
RunToCompletion and ExecuteFrame never wait or advance the clock. Waiting
means only future work remains, Completed means no work remains, and Paused
retains its frame-budget meaning. Errors and limits retain precedence.
PendingMessageCount includes delayed entries but excludes the active message;
IsIdle is false while delayed entries remain. NextMessageDelay reports remaining
whole microseconds, clamped to zero, or no value when there is no delayed entry.
Bridges and command-line adapters own timers and serialize all Host access.
Idle duration observation
Since: 0.3.0
IdleDurationMicroseconds (Swift idleDurationMicroseconds) is an optional
nonnegative Int64 count of whole microseconds in the current ready, work-free
phase, measured with the Host’s monotonic clock. It is absent before successful
Start and during pumping, active or paused handler execution, queued ordinary
messages or initialization, and delayed emits or publications. A deadline passing
alone does not finish work: the delayed entry must still be pumped.
The phase begins when successful Start or a pump returns with no active or pending work. Accepted queued work ends it immediately; a later work-free completion starts a new phase. Repeated observations, repeated Start, empty pumps, subscriptions, loads without initialization and rejected inputs do not restart an existing phase. A failed initial Start never establishes one. A runtime error on a ready host does not itself prohibit a new idle phase if the pump has actually left no active or pending work.
Reading the property never pumps, sleeps or schedules a message. External work
unknown to this Host does not affect the result. Embeddings choose their own
idle timeout and recovery message, such as a bot’s Idle() event. The existing
IsIdle queue-emptiness observation remains unchanged and does not imply readiness.
All raw Host access still requires a single serialized caller. Native automatic
runners expose the duration under their gate and return no value after disposal
or close; they do not introduce an idle timer or automatic recovery messages.