Draft experimental hotpatching support 🎉

Thanks to `subsecond` by the Dioxus folks!
This commit is contained in:
Héctor Ramón Jiménez 2025-06-06 22:58:59 +02:00
parent 5ca5000cdc
commit 10bbe44c30
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
10 changed files with 212 additions and 35 deletions

85
Cargo.lock generated
View file

@ -519,6 +519,26 @@ dependencies = [
"serde",
]
[[package]]
name = "bincode"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
dependencies = [
"bincode_derive",
"serde",
"unty",
]
[[package]]
name = "bincode_derive"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
dependencies = [
"virtue",
]
[[package]]
name = "bit-set"
version = "0.8.0"
@ -702,6 +722,17 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cargo-hot-protocol"
version = "0.1.0"
source = "git+https://github.com/hecrj/cargo-hot.git?rev=e71ddcd1d37be79ddb6f22262600798319235ba5#e71ddcd1d37be79ddb6f22262600798319235ba5"
dependencies = [
"anyhow",
"bincode 2.0.1",
"log",
"subsecond",
]
[[package]]
name = "cast"
version = "0.3.0"
@ -2419,7 +2450,7 @@ dependencies = [
name = "iced_beacon"
version = "0.14.0-dev"
dependencies = [
"bincode",
"bincode 1.3.3",
"futures",
"iced_core",
"log",
@ -2451,6 +2482,7 @@ dependencies = [
name = "iced_debug"
version = "0.14.0-dev"
dependencies = [
"cargo-hot-protocol",
"iced_beacon",
"iced_core",
"iced_futures",
@ -3244,6 +3276,15 @@ version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "memfd"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2cffa4ad52c6f791f4f8b15f0c05f9824b2ced1160e88cc393d64fff9a8ac64"
dependencies = [
"rustix 0.38.44",
]
[[package]]
name = "memmap2"
version = "0.9.5"
@ -5406,6 +5447,34 @@ dependencies = [
"rayon",
]
[[package]]
name = "subsecond"
version = "0.7.0-alpha.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5b40acd555d02d9a0b5bf4080dbf2cd085d5e2eb2ae7851cb14b9bf5af15c"
dependencies = [
"js-sys",
"libc",
"libloading",
"memfd",
"memmap2",
"serde",
"subsecond-types",
"thiserror 2.0.12",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "subsecond-types"
version = "0.7.0-alpha.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bedadae58a56e137ac970c38c44bff38cee24400fef64c37d5a188a065b1ec1f"
dependencies = [
"serde",
]
[[package]]
name = "subtle"
version = "2.6.1"
@ -5483,7 +5552,7 @@ version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1"
dependencies = [
"bincode",
"bincode 1.3.3",
"bitflags 1.3.2",
"flate2",
"fnv",
@ -6169,6 +6238,12 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "unty"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "url"
version = "2.5.4"
@ -6288,6 +6363,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "virtue"
version = "0.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
[[package]]
name = "visible_bounds"
version = "0.1.0"

View file

@ -167,6 +167,7 @@ bincode = "1.3"
bitflags = "2.0"
bytemuck = { version = "1.0", features = ["derive"] }
bytes = "1.6"
cargo-hot = { package = "cargo-hot-protocol", git = "https://github.com/hecrj/cargo-hot.git", rev = "e71ddcd1d37be79ddb6f22262600798319235ba5" }
cosmic-text = "0.14"
dark-light = "2.0"
futures = { version = "0.3", default-features = false }

View file

@ -11,7 +11,7 @@ categories.workspace = true
keywords.workspace = true
[features]
enable = ["dep:iced_beacon"]
enable = ["dep:iced_beacon", "dep:cargo-hot"]
[dependencies]
iced_core.workspace = true
@ -21,3 +21,6 @@ log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
iced_beacon.workspace = true
iced_beacon.optional = true
cargo-hot.workspace = true
cargo-hot.optional = true

View file

@ -113,6 +113,14 @@ pub fn commands() -> Subscription<Command> {
internal::commands()
}
pub fn hot<O>(f: impl FnOnce() -> O) -> O {
internal::hot(f)
}
pub fn on_hotpatch(f: impl Fn() + Send + Sync + 'static) {
internal::on_hotpatch(f)
}
#[cfg(all(feature = "enable", not(target_arch = "wasm32")))]
mod internal {
use crate::core::theme;
@ -129,7 +137,7 @@ mod internal {
use beacon::span::present;
use std::sync::atomic::{self, AtomicBool, AtomicUsize};
use std::sync::{LazyLock, RwLock};
use std::sync::{Arc, LazyLock, RwLock};
pub fn init(metadata: Metadata) {
let name = metadata.name.split("::").next().unwrap_or(metadata.name);
@ -140,6 +148,8 @@ mod internal {
theme: metadata.theme,
can_time_travel: metadata.can_time_travel,
};
cargo_hot::connect();
}
pub fn quit() -> bool {
@ -271,6 +281,20 @@ mod internal {
Subscription::run(listen_for_commands)
}
pub fn hot<O>(f: impl FnOnce() -> O) -> O {
let mut f = Some(f);
// The `move` here is important. Hotpatching will not work
// otherwise.
cargo_hot::subsecond::call(move || {
f.take().expect("Hot function is stale")()
})
}
pub fn on_hotpatch(f: impl Fn() + Send + Sync + 'static) {
cargo_hot::subsecond::register_handler(Arc::new(f));
}
fn span(span: span::Stage) -> Span {
log(client::Event::SpanStarted(span.clone()));
@ -398,4 +422,10 @@ mod internal {
impl Span {
pub fn finish(self) {}
}
pub fn hot<O>(f: impl FnOnce() -> O) -> O {
f()
}
pub fn on_hotpatch(_f: impl Fn() + Send + Sync + 'static) {}
}

View file

@ -56,6 +56,9 @@ pub enum Action<T> {
/// Run a system action.
System(system::Action),
/// Recreate all user interfaces and redraw all windows.
Reload,
/// Exits the runtime.
///
/// This will normally close any application windows and
@ -79,6 +82,7 @@ impl<T> Action<T> {
Action::Clipboard(action) => Err(Action::Clipboard(action)),
Action::Window(action) => Err(Action::Window(action)),
Action::System(action) => Err(Action::System(action)),
Action::Reload => Err(Action::Reload),
Action::Exit => Err(Action::Exit),
}
}
@ -102,6 +106,7 @@ where
}
Action::Window(_) => write!(f, "Action::Window"),
Action::System(action) => write!(f, "Action::System({action:?})"),
Action::Reload => write!(f, "Action::Reload"),
Action::Exit => write!(f, "Action::Exit"),
}
}

View file

@ -38,6 +38,8 @@ use crate::{
Element, Executor, Font, Result, Settings, Size, Subscription, Task,
};
use iced_debug as debug;
use std::borrow::Cow;
pub mod timed;
@ -126,7 +128,7 @@ where
state: &mut Self::State,
message: Self::Message,
) -> Task<Self::Message> {
self.update.update(state, message)
debug::hot(|| self.update.update(state, message))
}
fn view<'a>(
@ -134,7 +136,7 @@ where
state: &'a Self::State,
_window: window::Id,
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
self.view.view(state)
debug::hot(|| self.view.view(state))
}
}
@ -327,7 +329,7 @@ impl<P: Program> Application<P> {
> {
Application {
raw: program::with_title(self.raw, move |state, _window| {
title.title(state)
debug::hot(|| title.title(state))
}),
settings: self.settings,
window: self.window,
@ -342,7 +344,9 @@ impl<P: Program> Application<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Application {
raw: program::with_subscription(self.raw, f),
raw: program::with_subscription(self.raw, move |state| {
debug::hot(|| f(state))
}),
settings: self.settings,
window: self.window,
}
@ -356,7 +360,9 @@ impl<P: Program> Application<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Application {
raw: program::with_theme(self.raw, move |state, _window| f(state)),
raw: program::with_theme(self.raw, move |state, _window| {
debug::hot(|| f(state))
}),
settings: self.settings,
window: self.window,
}
@ -370,7 +376,9 @@ impl<P: Program> Application<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Application {
raw: program::with_style(self.raw, f),
raw: program::with_style(self.raw, move |state, theme| {
debug::hot(|| f(state, theme))
}),
settings: self.settings,
window: self.window,
}
@ -385,7 +393,7 @@ impl<P: Program> Application<P> {
> {
Application {
raw: program::with_scale_factor(self.raw, move |state, _window| {
f(state)
debug::hot(|| f(state))
}),
settings: self.settings,
window: self.window,

View file

@ -6,6 +6,8 @@ use crate::time::Instant;
use crate::window;
use crate::{Element, Program, Settings, Subscription, Task};
use iced_debug as debug;
/// Creates an [`Application`] with an `update` function that also
/// takes the [`Instant`] of each `Message`.
///
@ -97,10 +99,12 @@ where
state: &mut Self::State,
(message, now): Self::Message,
) -> Task<Self::Message> {
self.update
.update(state, message, now)
.into()
.map(|message| (message, Instant::now()))
debug::hot(move || {
self.update
.update(state, message, now)
.into()
.map(|message| (message, Instant::now()))
})
}
fn view<'a>(
@ -108,16 +112,21 @@ where
state: &'a Self::State,
_window: window::Id,
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
self.view
.view(state)
.map(|message| (message, Instant::now()))
debug::hot(|| {
self.view
.view(state)
.map(|message| (message, Instant::now()))
})
}
fn subscription(
&self,
state: &Self::State,
) -> self::Subscription<Self::Message> {
(self.subscription)(state).map(|message| (message, Instant::now()))
debug::hot(|| {
(self.subscription)(state)
.map(|message| (message, Instant::now()))
})
}
}

View file

@ -6,6 +6,8 @@ use crate::theme;
use crate::window;
use crate::{Element, Executor, Font, Result, Settings, Subscription, Task};
use iced_debug as debug;
use std::borrow::Cow;
/// Creates an iced [`Daemon`] given its boot, update, and view logic.
@ -72,7 +74,7 @@ where
state: &mut Self::State,
message: Self::Message,
) -> Task<Self::Message> {
self.update.update(state, message)
debug::hot(|| self.update.update(state, message))
}
fn view<'a>(
@ -80,7 +82,7 @@ where
state: &'a Self::State,
window: window::Id,
) -> Element<'a, Self::Message, Self::Theme, Self::Renderer> {
self.view.view(state, window)
debug::hot(|| self.view.view(state, window))
}
}
@ -176,7 +178,7 @@ impl<P: Program> Daemon<P> {
> {
Daemon {
raw: program::with_title(self.raw, move |state, window| {
title.title(state, window)
debug::hot(|| title.title(state, window))
}),
settings: self.settings,
}
@ -190,7 +192,9 @@ impl<P: Program> Daemon<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Daemon {
raw: program::with_subscription(self.raw, f),
raw: program::with_subscription(self.raw, move |state| {
debug::hot(|| f(state))
}),
settings: self.settings,
}
}
@ -203,7 +207,9 @@ impl<P: Program> Daemon<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Daemon {
raw: program::with_theme(self.raw, f),
raw: program::with_theme(self.raw, move |state, window| {
debug::hot(|| f(state, window))
}),
settings: self.settings,
}
}
@ -216,7 +222,9 @@ impl<P: Program> Daemon<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Daemon {
raw: program::with_style(self.raw, f),
raw: program::with_style(self.raw, move |state, theme| {
debug::hot(|| f(state, theme))
}),
settings: self.settings,
}
}
@ -229,7 +237,9 @@ impl<P: Program> Daemon<P> {
impl Program<State = P::State, Message = P::Message, Theme = P::Theme>,
> {
Daemon {
raw: program::with_scale_factor(self.raw, f),
raw: program::with_scale_factor(self.raw, move |state, window| {
debug::hot(|| f(state, window))
}),
settings: self.settings,
}
}

View file

@ -85,6 +85,15 @@ where
let (proxy, worker) = Proxy::new(event_loop.create_proxy());
#[cfg(feature = "debug")]
{
let proxy = proxy.clone();
debug::on_hotpatch(move || {
proxy.send_action(Action::Reload);
});
}
let mut runtime = {
let executor =
P::Executor::new().map_err(Error::ExecutorCreationFailed)?;
@ -527,7 +536,7 @@ async fn run_instance<P>(
let create_compositor = {
let window = window.clone();
let mut proxy = proxy.clone();
let proxy = proxy.clone();
let default_fonts = default_fonts.clone();
async move {
@ -1075,9 +1084,9 @@ fn update<P: Program, E: Executor>(
runtime.track(recipes);
}
fn run_action<P, C>(
fn run_action<'a, P, C>(
action: Action<P::Message>,
program: &program::Instance<P>,
program: &'a program::Instance<P>,
compositor: &mut Option<C>,
events: &mut Vec<(window::Id, core::Event)>,
messages: &mut Vec<P::Message>,
@ -1085,7 +1094,7 @@ fn run_action<P, C>(
control_sender: &mut mpsc::UnboundedSender<Control>,
interfaces: &mut FxHashMap<
window::Id,
UserInterface<'_, P::Message, P::Theme, P::Renderer>,
UserInterface<'a, P::Message, P::Theme, P::Renderer>,
>,
window_manager: &mut WindowManager<P, C>,
ui_caches: &mut FxHashMap<window::Id, user_interface::Cache>,
@ -1437,6 +1446,29 @@ fn run_action<P, C>(
let _ = channel.send(Ok(()));
}
}
Action::Reload => {
for (id, window) in window_manager.iter_mut() {
let Some(ui) = interfaces.remove(&id) else {
continue;
};
let cache = ui.into_cache();
let size = window.size();
let _ = interfaces.insert(
id,
build_user_interface(
program,
cache,
&mut window.renderer,
size,
id,
),
);
window.raw.request_redraw();
}
}
Action::Exit => {
control_sender
.start_send(Control::Exit)

View file

@ -77,7 +77,7 @@ impl<T: 'static> Proxy<T> {
///
/// Note: This skips the backpressure mechanism with an unbounded
/// channel. Use sparingly!
pub fn send(&mut self, value: T)
pub fn send(&self, value: T)
where
T: std::fmt::Debug,
{
@ -88,13 +88,11 @@ impl<T: 'static> Proxy<T> {
///
/// Note: This skips the backpressure mechanism with an unbounded
/// channel. Use sparingly!
pub fn send_action(&mut self, action: Action<T>)
pub fn send_action(&self, action: Action<T>)
where
T: std::fmt::Debug,
{
self.raw
.send_event(action)
.expect("Send message to event loop");
let _ = self.raw.send_event(action);
}
/// Frees an amount of slots for additional messages to be queued in