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}