Skip to main content

unlit3dx/
host.rs

1//! Host helpers: the sub-world that shares the host's thread.
2
3use async_channel::{Receiver, bounded};
4use unlit_ecs::{Bundle, DynamicBundle, World};
5
6use crate::channel::publish_collected;
7use crate::executor::LocalTasks;
8use crate::handle::WorldHandle;
9use crate::job::{Job, JobSender};
10use crate::thread::ThreadStatus;
11use crate::thread::panic_message;
12use crate::world::WorldName;
13use unlit_ecs::bundle;
14
15/// The sub-world's command inbox, a component of the nested world itself.
16///
17/// [`drain_sub_world`] empties it. It is an implementation detail of the
18/// sub-world: callers reach the world through the [`WorldHandle`] the
19/// spawn returns, never through this component.
20struct SubWorldInbox(Receiver<Job>);
21
22/// The sub-world's status, published the way a threaded world publishes its
23/// own, so a host learns that its sub-world ended.
24///
25/// It is a component of the nested world, so dropping that world - the host
26/// despawning the entity — is what ends the sub-world: the state it leaves
27/// behind reads `Stopped`, and a reader that arrives afterwards still sees
28/// it.
29struct SubWorldStatus(ThreadStatus);
30
31impl Drop for SubWorldStatus {
32    fn drop(&mut self) {
33        self.0.finish();
34    }
35}
36
37/// The sub-world's own state: the inbox its jobs arrive in and the status it
38/// publishes.
39///
40/// The two are spawned together on one entity of the nested world's root scope,
41/// and [`drain_sub_world`] reads them as one: a world with an inbox but no
42/// status could take jobs it never reports on, and one with a status but no
43/// inbox could never take a job at all.
44struct SubWorld {
45    inbox: Receiver<Job>,
46    status: ThreadStatus,
47}
48
49impl Bundle for SubWorld {
50    fn into_bundle(self) -> DynamicBundle {
51        bundle![SubWorldInbox(self.inbox), SubWorldStatus(self.status)]
52    }
53}
54
55/// The host entity a nested world hangs on: the world itself, under the name a
56/// bridge lists it by.
57struct HostWorld {
58    world: World,
59    name: &'static str,
60}
61
62impl Bundle for HostWorld {
63    fn name(&self) -> &'static str {
64        self.name
65    }
66
67    fn into_bundle(self) -> DynamicBundle {
68        bundle![self.world, WorldName(self.name)]
69    }
70}
71
72/// Spawn a sub-world that shares the host's thread, nested as a [`World`]
73/// component of the returned entity, with a command inbox of the given
74/// capacity.
75///
76/// `name` labels the world for a bridge that lists the host's worlds; it
77/// lands on the host entity as a [`WorldName`].
78///
79/// Sub-worlds are for work that must stay on the host thread — rendering
80/// into a window's swap chain, say — but whose state should not mix with
81/// the host's. Queued jobs run when the host calls [`drain_sub_world`], and
82/// the world itself is reached with a [`World`] query on the entity the
83/// returned handle names.
84///
85/// # Panics
86///
87/// Panics when `command_capacity` is zero: a queue that can never hold
88/// a job would silently block every sender.
89#[must_use]
90pub fn spawn_sub_world(
91    host: &mut World,
92    name: &'static str,
93    command_capacity: usize,
94) -> WorldHandle {
95    let (queue, inbox) = bounded(command_capacity);
96    let mut nested = World::new();
97    // The sub-world owns its async tasks, so a job that mounts something
98    // asynchronous hands it a task instead of an executor.
99    LocalTasks::install(&mut nested);
100    let status = ThreadStatus::detached();
101    nested.root_scope_mut().spawn(SubWorld {
102        inbox,
103        status: status.clone(),
104    });
105    let sender = JobSender::new(queue);
106    let entity = host.root_scope_mut().spawn(HostWorld {
107        world: nested,
108        name,
109    });
110    WorldHandle::with_status(entity, sender, status)
111}
112
113/// Run every job queued for the sub-world so far, in queue order.
114///
115/// Call this from the host thread before driving the world's frame: jobs
116/// arrive at any time and must land between frames, on the host's thread.
117///
118/// A job that panics ends the sub-world: the drain stops where the panic
119/// was, the world's state reports the panic, and the queue closes so a
120/// later send is refused instead of queued for a world that will never run
121/// it. A world that has ended is left alone, collectors included: a
122/// collector is arbitrary code, and running one past a panic would hand the
123/// panic to the host.
124///
125/// Once the jobs have run the drain publishes what the world's subscriptions
126/// collected, so a sub-world publishes on the host's thread the way a
127/// threaded world publishes on its own. A world its owner has stopped
128/// publishes its last state and then ends.
129pub fn drain_sub_world(nested: &mut World) {
130    // The jobs take the world while they run, so the inbox borrow must end
131    // before the first one starts. Cloning the receiver is cheap and lets the
132    // drain run without collecting the jobs first.
133    let (receiver, status) = {
134        let mut sub_world_rows = nested
135            .root_scope()
136            .query::<(&SubWorldInbox, &SubWorldStatus)>();
137
138        let Some((sub_world_inbox, sub_world_status)) = sub_world_rows.iter().next() else {
139            return;
140        };
141        (sub_world_inbox.0.clone(), sub_world_status.0.clone())
142    };
143    if status.is_finished() {
144        return;
145    }
146    if status.stop_requested() {
147        // A stop is not a panic, so the world collects one last time: the
148        // state it had reached is the last its readers ever get.
149        publish_collected(nested);
150        status.finish();
151        receiver.close();
152        return;
153    }
154    while let Ok(job) = receiver.try_recv() {
155        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job(nested)));
156        if let Err(payload) = result {
157            let message = panic_message(payload.as_ref());
158            log::error!("a sub-world job panicked: {message}");
159            status.record_panic(message);
160            // The world is over. Close the queue so a sender learns it from
161            // the send, as a threaded world's departed receiver already
162            // taught it.
163            receiver.close();
164            return;
165        }
166    }
167    publish_collected(nested);
168}