1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
#[cfg(feature = "dev")]
pub struct HotApp {
// inner must be declared first so it drops before _lib (Rust drops fields in declaration order)
inner: Box<dyn crate::app::App>,
_lib: libloading::Library,
}
/// The host's handle on a tree the dylib mounted and owns: an opaque pointer plus the shims to drive it. The
/// function pointers are copied out of the library once (plain `fn` pointers, not borrowed `Symbol`s) so this
/// handle carries no lifetime; it is valid for as long as the library stays mapped, which the runner guarantees
/// by dropping the tree before it replaces the app.
#[cfg(feature = "dev")]
struct HotTreeHandle {
ptr: *mut crate::tree::HotTree,
on_event: unsafe extern "Rust" fn(*mut crate::tree::HotTree, &platform_core::Event) -> bool,
paint: unsafe extern "Rust" fn(*mut crate::tree::HotTree) -> Vec<renderer_core::DrawCommand>,
is_dirty: unsafe extern "Rust" fn(*mut crate::tree::HotTree) -> bool,
generation: unsafe extern "Rust" fn(*mut crate::tree::HotTree) -> u64,
walk: unsafe extern "Rust" fn(*mut crate::tree::HotTree) -> Vec<ui_tree::SegmentNodeInfo>,
release: unsafe extern "Rust" fn(*mut crate::tree::HotTree),
/// Absent in a dylib built before the input registries were fed on this side; the tree still runs, it
/// just leaves `key_pressed` answering for longer than a frame.
end_frame: Option<unsafe extern "Rust" fn(*mut crate::tree::HotTree)>,
}
#[cfg(feature = "dev")]
impl HotTreeHandle {
/// Resolves every shim up front and mounts the tree inside the dylib. `None` when any symbol is missing (a
/// dylib built before app-side mounting existed), so the caller can fall back to mounting on the host side.
fn mount(lib: &libloading::Library, app: &dyn crate::app::App) -> Option<Self> {
unsafe {
let mount: libloading::Symbol<
unsafe extern "Rust" fn(&dyn crate::app::App) -> *mut crate::tree::HotTree,
> = lib.get(b"_rsx_hot_tree_mount\0").ok()?;
let handle = Self {
ptr: mount(app),
on_event: *lib.get(b"_rsx_hot_tree_on_event\0").ok()?,
paint: *lib.get(b"_rsx_hot_tree_paint\0").ok()?,
is_dirty: *lib.get(b"_rsx_hot_tree_dirty\0").ok()?,
generation: *lib.get(b"_rsx_hot_tree_generation\0").ok()?,
walk: *lib.get(b"_rsx_hot_tree_walk\0").ok()?,
release: *lib.get(b"_rsx_hot_tree_release\0").ok()?,
end_frame: lib
.get(b"_rsx_hot_tree_end_frame\0")
.ok()
.map(|symbol| *symbol),
};
Some(handle)
}
}
}
#[cfg(feature = "dev")]
impl crate::tree::UiTree for HotTreeHandle {
fn on_event(&mut self, event: &platform_core::Event) -> ui_core::EventResult {
if unsafe { (self.on_event)(self.ptr, event) } {
ui_core::EventResult::Handled
} else {
ui_core::EventResult::Ignored
}
}
fn frame(&self) -> crate::tree::Frame<'_> {
crate::tree::Frame::Owned(unsafe { (self.paint)(self.ptr) })
}
fn end_frame(&self) {
if let Some(end) = self.end_frame {
unsafe { end(self.ptr) }
}
}
fn is_dirty(&self) -> bool {
unsafe { (self.is_dirty)(self.ptr) }
}
fn generation(&self) -> u64 {
unsafe { (self.generation)(self.ptr) }
}
fn walk(&self, out: &mut Vec<ui_tree::SegmentNodeInfo>) {
out.extend(unsafe { (self.walk)(self.ptr) });
}
}
#[cfg(feature = "dev")]
impl Drop for HotTreeHandle {
fn drop(&mut self) {
unsafe { (self.release)(self.ptr) };
}
}
#[cfg(feature = "dev")]
impl crate::app::App for HotApp {
fn root(&self) -> Box<dyn ui_core::Component> {
self.inner.root()
}
// Mount inside the dylib, where the app's signals live: a tree mounted out here would register its segment
// effects in the host's reactive runtime and never subscribe to anything the app writes (see `crate::tree`).
// A dylib built before these shims existed falls back to the old host-side mount, which still runs — driven
// by the force-tick workaround — rather than failing to start.
fn mount(&mut self) -> Box<dyn crate::tree::UiTree> {
match HotTreeHandle::mount(&self._lib, self.inner.as_ref()) {
Some(handle) => Box::new(handle),
None => {
tracing::warn!(
"dylib exports no app-side tree mount; falling back to host-side mounting (rebuild it)"
);
Box::new(crate::tree::LocalTree::new(self.inner.root()))
}
}
}
fn clear_color(&self) -> Option<renderer_core::Color> {
self.inner.clear_color()
}
fn on_frame(&mut self, ctx: &mut crate::app_context::AppCtx) {
self.inner.on_frame(ctx)
}
fn hot_snapshot(&self) -> Option<String> {
// Missing symbol (dylib built before hot state existed) degrades to no preservation.
let snapshot: libloading::Symbol<unsafe extern "Rust" fn() -> String> =
unsafe { self._lib.get(b"_rsx_hot_snapshot\0") }.ok()?;
Some(unsafe { snapshot() })
}
fn hot_restore(&self, blob: &str) {
if let Ok(restore) = unsafe {
self._lib
.get::<unsafe extern "Rust" fn(&str)>(b"_rsx_hot_restore\0")
} {
unsafe { restore(blob) }
}
}
// Resolved per call rather than cached: this is a dev-only path (never compiled into release builds), the symbol lookup is a cheap hashmap hit, and per-call resolution avoids storing a `Symbol` borrowed from `_lib` inside the same struct. Missing symbol (dylib built before hot motion existed) degrades to a no-op: the host's own motion-core copy is a separate, empty registry, so ticking it would accomplish nothing useful.
fn motion_tick(&self, now: std::time::Instant) {
if let Ok(tick) = unsafe {
self._lib
.get::<unsafe extern "Rust" fn(std::time::Instant)>(b"_rsx_hot_motion_tick\0")
} {
unsafe { tick(now) }
}
}
fn motion_has_active(&self) -> bool {
let Ok(active) = (unsafe {
self._lib
.get::<unsafe extern "Rust" fn() -> bool>(b"_rsx_hot_motion_active\0")
}) else {
return false;
};
unsafe { active() }
}
fn motion_has_continuous(&self) -> bool {
let Ok(continuous) = (unsafe {
self._lib
.get::<unsafe extern "Rust" fn() -> bool>(b"_rsx_hot_motion_continuous\0")
}) else {
return false;
};
unsafe { continuous() }
}
// Batch the dylib's own reactive runtime (separate from the host's) across event dispatch. Missing symbol (dylib built before this existed) degrades to a no-op: without it the app runs as before, just without the mid-dispatch flush protection.
fn begin_event_batch(&self) {
if let Ok(begin) = unsafe {
self._lib
.get::<unsafe extern "Rust" fn()>(b"_rsx_hot_begin_batch\0")
} {
unsafe { begin() }
}
}
fn end_event_batch(&self) {
if let Ok(end) = unsafe {
self._lib
.get::<unsafe extern "Rust" fn()>(b"_rsx_hot_end_batch\0")
} {
unsafe { end() }
}
}
// Relayout the dylib's own layout runtime (separate from the host's) so a reactive list change is laid
// out before the frame composes. Missing symbol (dylib built before this existed) degrades to a no-op.
fn relayout(&self) {
if let Ok(relayout) = unsafe {
self._lib
.get::<unsafe extern "Rust" fn()>(b"_rsx_hot_relayout\0")
} {
unsafe { relayout() }
}
}
// Consult the dylib's own overlay registry (separate thread-local from the host's): `overlay` widgets
// register in the dylib where the view is built, so a modal's priority routing / background blocking
// must be driven across this boundary. Missing symbol (dylib built before this existed) degrades to
// `false` — the event falls through to the tree walk, as before this feature.
fn dispatch_overlays(&self, event: &platform_core::Event) -> bool {
let Ok(dispatch) = (unsafe {
self._lib
.get::<unsafe extern "Rust" fn(&platform_core::Event) -> bool>(
b"_rsx_hot_dispatch_overlays\0",
)
}) else {
return false;
};
unsafe { dispatch(event) }
}
// Drain window commands from the dylib's own thread-local queue (separate from the host's): a title bar's
// `on_press` pushes into the dylib's platform-core copy, so the host must drain it across this boundary to
// apply drag/minimize/maximize/close. Missing symbol (dylib built before this existed) degrades to an
// empty vec — window controls are simply inert until the dylib is rebuilt.
fn drain_window_commands(&self) -> Vec<platform_core::WindowCommand> {
let Ok(drain) = (unsafe {
self._lib
.get::<unsafe extern "Rust" fn() -> Vec<platform_core::WindowCommand>>(
b"_rsx_hot_drain_window_commands\0",
)
}) else {
return Vec::new();
};
unsafe { drain() }
}
// Write the OS light/dark preference into the dylib's own theme runtime (separate from the host's), where
// the `follow_system` effect lives. Missing symbol (dylib built before this existed) degrades to a no-op.
fn set_system_dark(&self, dark: bool) {
if let Ok(set) = unsafe {
self._lib
.get::<unsafe extern "Rust" fn(bool)>(b"_rsx_hot_set_system_dark\0")
} {
unsafe { set(dark) }
}
}
// Run the completions of tasks spawned inside the dylib: `spawn_task` registered their callbacks in the
// dylib's own reactive-core thread-local, so the host must drain it across this boundary — its own copy is
// empty. Missing symbol (dylib built before this existed) degrades to a no-op.
fn drain_tasks(&self) {
if let Ok(drain) = unsafe {
self._lib
.get::<unsafe extern "Rust" fn()>(b"_rsx_hot_drain_tasks\0")
} {
unsafe { drain() }
}
}
// Install the loop wake in the dylib's own reactive-core copy, so a worker finishing inside it can run a
// frame. Missing symbol degrades to a no-op: results then wait for the next input event to be drained.
fn install_task_waker(&self, waker: crate::app_context::RedrawWaker) {
if let Ok(install) = unsafe {
self._lib
.get::<unsafe extern "Rust" fn(crate::app_context::RedrawWaker)>(
b"_rsx_hot_install_task_waker\0",
)
} {
unsafe { install(waker) }
}
}
}
#[cfg(feature = "dev")]
pub fn load_hot_app(path: &std::path::Path) -> Result<HotApp, Box<dyn std::error::Error>> {
// Copy to a unique path before dlopen. On Linux, dlopen caches loaded libraries by (device, inode). If the linker writes the new .so in-place (same inode), dlopen returns the already-loaded old handle instead of the fresh build. Copying creates a new inode, guaranteeing a fresh load. Unlinking after dlopen is safe: the kernel keeps the inode alive via the mapping until the Library is dropped.
let unique = path.with_file_name(format!(
".hot-{}.so",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::copy(path, &unique)?;
// RUNTIME and THEME use trivially-destructible TLS types (no Drop impl), so no TLS destructors are registered in the dylib. dlclose without RTLD_NODELETE is safe.
let lib_result = crate::dylib::open(&unique);
let _ = std::fs::remove_file(&unique);
let lib = lib_result?;
let create: libloading::Symbol<unsafe extern "Rust" fn() -> Box<dyn crate::app::App>> =
unsafe { lib.get(b"_rsx_hot_create_app\0") }?;
let inner = unsafe { create() };
Ok(HotApp { inner, _lib: lib })
}
#[cfg(feature = "dev")]
pub enum HotEvent {
Reload(std::path::PathBuf),
BuildError(String),
}
/// Connects to the cargo-telar TCP loopback channel (it binds the port and passes it via
/// `TELAR_HOT_PORT`) and forwards line-delimited hot events. TCP instead of a unix socket so the
/// same code path works on non-Unix hosts.
#[cfg(feature = "dev")]
pub fn listen_hot_reload(port: u16) -> std::sync::mpsc::Receiver<HotEvent> {
use std::io::BufRead;
use std::net::TcpStream;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("telar-hot-reload".to_string())
.spawn(move || {
// cargo-telar binds before spawning us, but retry briefly in case it is mid-rebuild.
let mut stream = None;
for _ in 0..20 {
match TcpStream::connect(("127.0.0.1", port)) {
Ok(s) => {
stream = Some(s);
break;
}
Err(_) => std::thread::sleep(std::time::Duration::from_millis(250)),
}
}
let Some(stream) = stream else {
tracing::error!("hot reload channel connect failed (port {port})");
return;
};
let reader = std::io::BufReader::new(stream);
for line in reader.lines() {
let Ok(line) = line else { break };
let line = line.trim();
if line.is_empty() {
continue;
}
let event = if let Some(path_str) = line.strip_prefix("hot:") {
HotEvent::Reload(std::path::PathBuf::from(path_str))
} else if let Some(msg) = line.strip_prefix("err:") {
HotEvent::BuildError(unescape_lines(msg))
} else {
// Legacy: bare path (no prefix) — treat as reload
HotEvent::Reload(std::path::PathBuf::from(line))
};
if tx.send(event).is_err() {
break;
}
}
})
.ok();
rx
}
/// Undoes the escaping `cargo-telar` applies so a multi-line build error survives a protocol of one event
/// per line. A trailing lone backslash cannot occur (the sender doubles them) and is passed through rather
/// than dropped, so a malformed message is still shown.
fn unescape_lines(message: &str) -> String {
let mut out = String::with_capacity(message.len());
let mut chars = message.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The build error shown in the window is a rustc-shaped code frame, and a frame is mostly `|` — which
/// is exactly what the old protocol substituted for a newline. Round-tripping one proves the frame
/// survives instead of being cut apart at every gutter.
#[test]
fn a_code_frame_survives_the_hot_reload_channel() {
let frame =
"error: mismatched types\n --> src/home.rsx:4\n |\n4 | text \"{n}\" size:no\n |\n";
let escaped = frame
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "");
assert!(
!escaped.contains('\n'),
"the wire carries one line per event"
);
assert_eq!(unescape_lines(&escaped), frame);
}
/// A backslash in the message (a Windows path, an escaped quote rustc quoted back) is not a line break.
#[test]
fn a_literal_backslash_is_not_read_as_an_escape() {
let message = "cannot find `C:\\src\\home.rsx`";
let escaped = message.replace('\\', "\\\\").replace('\n', "\\n");
assert_eq!(unescape_lines(&escaped), message);
}
}