Thread dispatch

Use owner dispatch when work needs the platform/graphics or simulation thread. Retain a submission endpoint, submit owned inputs, then check the returned future from a later update. For changing drawable text, prefer the Engine text updater, which already manages preparation, upload and adoption. Use the primitives here for other operations that need a specific owner.

CE::Runtime::PlatformDispatcher accepts owned work(CE::Runtime::Context&) and runs it on the platform/graphics owner. CE::Runtime::SimulationDispatcher accepts owned work() and runs it on the simulation owner. Both return futures and expose a copyable submission() endpoint. Saved endpoints contain no borrowed service pointer and reject with CE::Exceptions::failed_operation before opening, after closing, and after service destruction.

These owner queues belong to CE::Runtime, which coordinates a game session and its thread-affine adapters. Generic CPU jobs use the independently owned CE::Workers pools and groups. The runtime owns queue opening, draining, and closing. Initialization can submit simulation work before a concurrent worker starts; that worker binds execution ownership before draining. Sequential mode binds the calling thread instead. A failed initialization may close the still-unbound simulation dispatcher on the platform because simulation ownership was never established.

Submit and adopt a result

Submission queues owned work; adoption belongs to a later update after readiness has been observed. Neither submission nor destroying a future runs the callback on the producer.

        sequenceDiagram
    accTitle: Owner dispatch and later result adoption
    accDescr: Simulation submits owned work and retains a future. Platform executes a detached queue batch, completes the future, and a later simulation update adopts the result without blocking.
    participant Simulation
    participant Queue as Platform queue
    participant Platform
    Simulation->>Queue: Submit owned callback
    Queue-->>Simulation: Future
    Queue->>Platform: Detached batch at owner boundary
    Platform->>Platform: Execute callback
    Platform-->>Simulation: Complete result future
    Simulation->>Simulation: Later update checks ready, then gets result
    

This fragment belongs in a game that already retains engine_, its runtime context. It makes one explicit live-window observation; ordinary layout should use tick.framebuffer_size instead. Include <cheryl/core/engine/engine-context.h>, <cheryl/core/display/window-interface.h>, <chrono>, <exception>, <future> and <optional>.

Store these members with the game:

std::optional<CE::Runtime::PlatformDispatcher::Submission> platform_requests_;
std::future<CE::Platform::FramebufferSize> pending_size_;
CE::Platform::FramebufferSize last_size_{};
std::exception_ptr size_failure_;
bool request_live_size_ = true;

During platform-owned init_platform(), save the endpoint:

platform_requests_.emplace(engine_.platform_dispatcher().submission());

During update(), submit once and adopt only a ready result:

if (request_live_size_ && !pending_size_.valid()) {
    request_live_size_ = false;
    try {
        pending_size_ = platform_requests_->submit([](CE::Runtime::Context& owner) {
            return owner.window().framebuffer_size();
        });
    } catch (...) {
        size_failure_ = std::current_exception(); // Rejected before acceptance.
    }
}
if (pending_size_.valid() &&
    pending_size_.wait_for(std::chrono::seconds{0}) == std::future_status::ready) {
    try {
        last_size_ = pending_size_.get();
        size_failure_ = nullptr;
    } catch (...) {
        size_failure_ = std::current_exception(); // Callback failure or cancellation.
    }
}

After adoption, last_size_ contains the platform window’s copied observation at callback execution. The callback captures no game pointer. A failure leaves the last successful size intact and is retained in size_failure_ for the game’s reporting policy; it does not trigger an automatic retry. Set request_live_size_ again only when another observation is wanted. get() consumes the future even when it throws.

Retain the game members through runtime shutdown and release them in deinit_platform(); shutdown settles or cancels accepted requests before that hook. Releasing a future by itself does not cancel submitted work. For a producer that outlives a service facade, retain its own endpoint copy and handle rejection after closure.

Queue and future contract

Each drain detaches one FIFO batch. New posts, including posts by callbacks in the batch, wait until a later drain. The owner never executes a submission inline, and recursive draining does not consume reentrant posts. Queue mutexes protect only state transfer; callbacks and cancelled capture destructors run without them. The enqueue mutex defines order between overlapping producers, not their start or completion times in other execution domains.

Submission owns callback captures. Borrowed captures must remain alive through completion or cancellation. Exceptions from accepted work reach its future; unexecuted work is cancelled by promise destruction (broken_promise). Do not wait on a future from the thread required to complete it. Wake callbacks own their scheduler state so an enqueue racing closure can safely finish its notification.

Callable arguments are decay-owned by submission. Copy endpoints for independent producers and retain them through their calls; they do not extend runtime/adapter lifetime. Reference captures and reference-valued results still borrow their targets. has_pending() observes queued tasks only, so false does not establish that a detached batch has finished or that acceptance has closed. Diagnostics are owned observations, not completion barriers. Use the returned future and runtime lifecycle for completion.

Event delivery adapters in CE::Events use these saved endpoints through a callable. CE::Events::EventBus remains independent of both dispatcher types; the adapters belong to composition code. See event-delivery.md.

Simulation boundary

runtime.simulation_dispatcher().submit(work) accepts work after runtime initialization opens the queue. Obtain submission() when a longer-lived adapter needs to save the endpoint. At every scheduled update, the runtime drains one simulation batch, checks stop, then transfers the entire available polling backlog immediately before game.update(). No input observations are removed merely because mailbox work ran. There is no total ordering between independent input and mailbox producers.

In concurrent mode submission notifies the shared scheduler but does not advance the scheduled update deadline. Delivery occurs at the next scheduled simulation boundary; posting must not create an early simulation tick. CE::Runtime::Simulation::TimingOptions configures that cadence independently of polling. In sequential mode the same boundary runs on the calling thread. Owner callbacks execute without the scheduler lock, permitting stop() and further platform/simulation submissions.

The runtime’s shutdown sequence coordinates queue cancellation, simulation joining, producer quiescence and accepted CPU/platform dependencies. Keep callback targets alive through that sequence; do not wait on work requiring the current owner. Saved endpoints reject after closure without extending adapter lifetime.