Hot patching systems with subsecond (#19309)

# Objective

- Enable hot patching systems with subsecond
- Fixes #19296 

## Solution

- First commit is the naive thin layer
- Second commit only check the jump table when the code is hot patched
instead of on every system execution
- Depends on https://github.com/DioxusLabs/dioxus/pull/4153 for a nicer
API, but could be done without
- Everything in second commit is feature gated, it has no impact when
the feature is not enabled

## Testing

- Check dependencies without the feature enabled: nothing dioxus in tree
- Run the new example: text and color can be changed

---------

Co-authored-by: Jan Hohenheim <jan@hohenheim.ch>
Co-authored-by: JMS55 <47158642+JMS55@users.noreply.github.com>
This commit is contained in:
François Mockers 2025-06-03 23:12:38 +02:00 committed by GitHub
parent 50aa40e980
commit 7a7bff8c17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 387 additions and 0 deletions

View File

@ -534,6 +534,9 @@ libm = ["bevy_internal/libm"]
# Enables use of browser APIs. Note this is currently only applicable on `wasm32` architectures.
web = ["bevy_internal/web"]
# Enable hotpatching of Bevy systems
hotpatching = ["bevy_internal/hotpatching"]
[dependencies]
bevy_internal = { path = "crates/bevy_internal", version = "0.16.0-dev", default-features = false }
tracing = { version = "0.1", default-features = false, optional = true }
@ -4411,3 +4414,15 @@ name = "Cooldown"
description = "Example for cooldown on button clicks"
category = "Usage"
wasm = true
[[example]]
name = "hotpatching_systems"
path = "examples/ecs/hotpatching_systems.rs"
doc-scrape-examples = true
required-features = ["hotpatching"]
[package.metadata.example.hotpatching_systems]
name = "Hotpatching Systems"
description = "Demonstrates how to hotpatch systems"
category = "ECS (Entity Component System)"
wasm = false

View File

@ -71,6 +71,12 @@ web = [
"dep:console_error_panic_hook",
]
hotpatching = [
"bevy_ecs/hotpatching",
"dep:dioxus-devtools",
"dep:crossbeam-channel",
]
[dependencies]
# bevy
bevy_derive = { path = "../bevy_derive", version = "0.16.0-dev" }
@ -87,6 +93,8 @@ variadics_please = "1.1"
tracing = { version = "0.1", default-features = false, optional = true }
log = { version = "0.4", default-features = false }
cfg-if = "1.0.0"
dioxus-devtools = { version = "0.7.0-alpha.1", optional = true }
crossbeam-channel = { version = "0.5.0", optional = true }
[target.'cfg(any(all(unix, not(target_os = "horizon")), windows))'.dependencies]
ctrlc = { version = "3.4.4", optional = true }

View File

@ -0,0 +1,42 @@
//! Utilities for hotpatching code.
extern crate alloc;
use alloc::sync::Arc;
use bevy_ecs::{event::EventWriter, HotPatched};
#[cfg(not(target_family = "wasm"))]
use dioxus_devtools::connect_subsecond;
use dioxus_devtools::subsecond;
pub use dioxus_devtools::subsecond::{call, HotFunction};
use crate::{Last, Plugin};
/// Plugin connecting to Dioxus CLI to enable hot patching.
#[derive(Default)]
pub struct HotPatchPlugin;
impl Plugin for HotPatchPlugin {
fn build(&self, app: &mut crate::App) {
let (sender, receiver) = crossbeam_channel::bounded::<HotPatched>(1);
// Connects to the dioxus CLI that will handle rebuilds
// This will open a connection to the dioxus CLI to receive updated jump tables
// Sends a `HotPatched` message through the channel when the jump table is updated
#[cfg(not(target_family = "wasm"))]
connect_subsecond();
subsecond::register_handler(Arc::new(move || {
sender.send(HotPatched).unwrap();
}));
// Adds a system that will read the channel for new `HotPatched`, and forward them as event to the ECS
app.add_event::<HotPatched>().add_systems(
Last,
move |mut events: EventWriter<HotPatched>| {
if receiver.try_recv().is_ok() {
events.write_default();
}
},
);
}
}

View File

@ -34,6 +34,9 @@ mod task_pool_plugin;
#[cfg(all(any(all(unix, not(target_os = "horizon")), windows), feature = "std"))]
mod terminal_ctrl_c_handler;
#[cfg(feature = "hotpatching")]
pub mod hotpatch;
pub use app::*;
pub use main_schedule::*;
pub use panic_handler::*;

View File

@ -83,6 +83,8 @@ critical-section = [
"bevy_reflect?/critical-section",
]
hotpatching = ["dep:subsecond"]
[dependencies]
bevy_ptr = { path = "../bevy_ptr", version = "0.16.0-dev" }
bevy_reflect = { path = "../bevy_reflect", version = "0.16.0-dev", features = [
@ -117,6 +119,7 @@ variadics_please = { version = "1.1", default-features = false }
tracing = { version = "0.1", default-features = false, optional = true }
log = { version = "0.4", default-features = false }
bumpalo = "3"
subsecond = { version = "0.7.0-alpha.1", optional = true }
concurrent-queue = { version = "2.5.0", default-features = false }
[target.'cfg(not(all(target_has_atomic = "8", target_has_atomic = "16", target_has_atomic = "32", target_has_atomic = "64", target_has_atomic = "ptr")))'.dependencies]

View File

@ -59,6 +59,9 @@ pub mod world;
pub use bevy_ptr as ptr;
#[cfg(feature = "hotpatching")]
use event::Event;
/// The ECS prelude.
///
/// This includes the most common types in this crate, re-exported for your convenience.
@ -123,6 +126,13 @@ pub mod __macro_exports {
pub use alloc::vec::Vec;
}
/// Event sent when a hotpatch happens.
///
/// Systems should refresh their inner pointers.
#[cfg(feature = "hotpatching")]
#[derive(Event, Default)]
pub struct HotPatched;
#[cfg(test)]
mod tests {
use crate::{

View File

@ -371,6 +371,11 @@ fn observer_system_runner<E: Event, B: Bundle, S: ObserverSystem<E, B>>(
// and is never exclusive
// - system is the same type erased system from above
unsafe {
// Always refresh hotpatch pointers
// There's no guarantee that the `HotPatched` event would still be there once the observer is triggered.
#[cfg(feature = "hotpatching")]
(*system).refresh_hotpatch();
match (*system).validate_param_unsafe(world) {
Ok(()) => {
if let Err(err) = (*system).run_unsafe(trigger, world) {

View File

@ -203,6 +203,10 @@ impl System for ApplyDeferred {
Ok(())
}
#[cfg(feature = "hotpatching")]
#[inline]
fn refresh_hotpatch(&mut self) {}
fn run(&mut self, _input: SystemIn<'_, Self>, _world: &mut World) -> Self::Out {
// This system does nothing on its own. The executor will apply deferred
// commands from other systems instead of running this system.

View File

@ -19,6 +19,8 @@ use crate::{
system::ScheduleSystem,
world::{unsafe_world_cell::UnsafeWorldCell, World},
};
#[cfg(feature = "hotpatching")]
use crate::{event::Events, HotPatched};
use super::__rust_begin_short_backtrace;
@ -443,6 +445,14 @@ impl ExecutorState {
return;
}
#[cfg(feature = "hotpatching")]
let should_update_hotpatch = !context
.environment
.world_cell
.get_resource::<Events<HotPatched>>()
.map(Events::is_empty)
.unwrap_or(true);
// can't borrow since loop mutably borrows `self`
let mut ready_systems = core::mem::take(&mut self.ready_systems_copy);
@ -460,6 +470,11 @@ impl ExecutorState {
// Therefore, no other reference to this system exists and there is no aliasing.
let system = unsafe { &mut *context.environment.systems[system_index].get() };
#[cfg(feature = "hotpatching")]
if should_update_hotpatch {
system.refresh_hotpatch();
}
if !self.can_run(system_index, conditions) {
// NOTE: exclusive systems with ambiguities are susceptible to
// being significantly displaced here (compared to single-threaded order)

View File

@ -16,6 +16,8 @@ use crate::{
},
world::World,
};
#[cfg(feature = "hotpatching")]
use crate::{event::Events, HotPatched};
use super::__rust_begin_short_backtrace;
@ -60,6 +62,12 @@ impl SystemExecutor for SimpleExecutor {
self.completed_systems |= skipped_systems;
}
#[cfg(feature = "hotpatching")]
let should_update_hotpatch = !world
.get_resource::<Events<HotPatched>>()
.map(Events::is_empty)
.unwrap_or(true);
for system_index in 0..schedule.systems.len() {
#[cfg(feature = "trace")]
let name = schedule.systems[system_index].name();
@ -120,6 +128,11 @@ impl SystemExecutor for SimpleExecutor {
#[cfg(feature = "trace")]
should_run_span.exit();
#[cfg(feature = "hotpatching")]
if should_update_hotpatch {
system.refresh_hotpatch();
}
// system has either been skipped or will run
self.completed_systems.insert(system_index);
@ -186,6 +199,12 @@ fn evaluate_and_fold_conditions(
world: &mut World,
error_handler: ErrorHandler,
) -> bool {
#[cfg(feature = "hotpatching")]
let should_update_hotpatch = !world
.get_resource::<Events<HotPatched>>()
.map(Events::is_empty)
.unwrap_or(true);
#[expect(
clippy::unnecessary_fold,
reason = "Short-circuiting here would prevent conditions from mutating their own state as needed."
@ -208,6 +227,10 @@ fn evaluate_and_fold_conditions(
return false;
}
}
#[cfg(feature = "hotpatching")]
if should_update_hotpatch {
condition.refresh_hotpatch();
}
__rust_begin_short_backtrace::readonly_run(&mut **condition, world)
})
.fold(true, |acc, res| acc && res)

View File

@ -12,6 +12,8 @@ use crate::{
schedule::{is_apply_deferred, BoxedCondition, ExecutorKind, SystemExecutor, SystemSchedule},
world::World,
};
#[cfg(feature = "hotpatching")]
use crate::{event::Events, HotPatched};
use super::__rust_begin_short_backtrace;
@ -60,6 +62,12 @@ impl SystemExecutor for SingleThreadedExecutor {
self.completed_systems |= skipped_systems;
}
#[cfg(feature = "hotpatching")]
let should_update_hotpatch = !world
.get_resource::<Events<HotPatched>>()
.map(Events::is_empty)
.unwrap_or(true);
for system_index in 0..schedule.systems.len() {
#[cfg(feature = "trace")]
let name = schedule.systems[system_index].name();
@ -121,6 +129,11 @@ impl SystemExecutor for SingleThreadedExecutor {
#[cfg(feature = "trace")]
should_run_span.exit();
#[cfg(feature = "hotpatching")]
if should_update_hotpatch {
system.refresh_hotpatch();
}
// system has either been skipped or will run
self.completed_systems.insert(system_index);
@ -204,6 +217,12 @@ fn evaluate_and_fold_conditions(
world: &mut World,
error_handler: ErrorHandler,
) -> bool {
#[cfg(feature = "hotpatching")]
let should_update_hotpatch = !world
.get_resource::<Events<HotPatched>>()
.map(Events::is_empty)
.unwrap_or(true);
#[expect(
clippy::unnecessary_fold,
reason = "Short-circuiting here would prevent conditions from mutating their own state as needed."
@ -226,6 +245,10 @@ fn evaluate_and_fold_conditions(
return false;
}
}
#[cfg(feature = "hotpatching")]
if should_update_hotpatch {
condition.refresh_hotpatch();
}
__rust_begin_short_backtrace::readonly_run(&mut **condition, world)
})
.fold(true, |acc, res| acc && res)

View File

@ -161,6 +161,12 @@ where
})
}
#[cfg(feature = "hotpatching")]
#[inline]
fn refresh_hotpatch(&mut self) {
self.system.refresh_hotpatch();
}
#[inline]
fn apply_deferred(&mut self, world: &mut crate::prelude::World) {
self.system.apply_deferred(world);

View File

@ -182,6 +182,13 @@ where
)
}
#[cfg(feature = "hotpatching")]
#[inline]
fn refresh_hotpatch(&mut self) {
self.a.refresh_hotpatch();
self.b.refresh_hotpatch();
}
#[inline]
fn apply_deferred(&mut self, world: &mut World) {
self.a.apply_deferred(world);
@ -392,6 +399,13 @@ where
self.b.run_unsafe(value, world)
}
#[cfg(feature = "hotpatching")]
#[inline]
fn refresh_hotpatch(&mut self) {
self.a.refresh_hotpatch();
self.b.refresh_hotpatch();
}
fn apply_deferred(&mut self, world: &mut World) {
self.a.apply_deferred(world);
self.b.apply_deferred(world);

View File

@ -26,6 +26,8 @@ where
F: ExclusiveSystemParamFunction<Marker>,
{
func: F,
#[cfg(feature = "hotpatching")]
current_ptr: subsecond::HotFnPtr,
param_state: Option<<F::Param as ExclusiveSystemParam>::State>,
system_meta: SystemMeta,
// NOTE: PhantomData<fn()-> T> gives this safe Send/Sync impls
@ -58,6 +60,11 @@ where
fn into_system(func: Self) -> Self::System {
ExclusiveFunctionSystem {
func,
#[cfg(feature = "hotpatching")]
current_ptr: subsecond::HotFn::current(
<F as ExclusiveSystemParamFunction<Marker>>::run,
)
.ptr_address(),
param_state: None,
system_meta: SystemMeta::new::<F>(),
marker: PhantomData,
@ -125,6 +132,20 @@ where
self.param_state.as_mut().expect(PARAM_MESSAGE),
&self.system_meta,
);
#[cfg(feature = "hotpatching")]
let out = {
let mut hot_fn =
subsecond::HotFn::current(<F as ExclusiveSystemParamFunction<Marker>>::run);
// SAFETY:
// - pointer used to call is from the current jump table
unsafe {
hot_fn
.try_call_with_ptr(self.current_ptr, (&mut self.func, world, input, params))
.expect("Error calling hotpatched system. Run a full rebuild")
}
};
#[cfg(not(feature = "hotpatching"))]
let out = self.func.run(world, input, params);
world.flush();
@ -134,6 +155,17 @@ where
})
}
#[cfg(feature = "hotpatching")]
#[inline]
fn refresh_hotpatch(&mut self) {
let new = subsecond::HotFn::current(<F as ExclusiveSystemParamFunction<Marker>>::run)
.ptr_address();
if new != self.current_ptr {
log::debug!("system {} hotpatched", self.name());
}
self.current_ptr = new;
}
#[inline]
fn apply_deferred(&mut self, _world: &mut World) {
// "pure" exclusive systems do not have any buffers to apply.

View File

@ -306,6 +306,9 @@ impl<Param: SystemParam> SystemState<Param> {
) -> FunctionSystem<Marker, F> {
FunctionSystem {
func,
#[cfg(feature = "hotpatching")]
current_ptr: subsecond::HotFn::current(<F as SystemParamFunction<Marker>>::run)
.ptr_address(),
state: Some(FunctionSystemState {
param: self.param_state,
world_id: self.world_id,
@ -519,6 +522,8 @@ where
F: SystemParamFunction<Marker>,
{
func: F,
#[cfg(feature = "hotpatching")]
current_ptr: subsecond::HotFnPtr,
state: Option<FunctionSystemState<F::Param>>,
system_meta: SystemMeta,
// NOTE: PhantomData<fn()-> T> gives this safe Send/Sync impls
@ -558,6 +563,9 @@ where
fn clone(&self) -> Self {
Self {
func: self.func.clone(),
#[cfg(feature = "hotpatching")]
current_ptr: subsecond::HotFn::current(<F as SystemParamFunction<Marker>>::run)
.ptr_address(),
state: None,
system_meta: SystemMeta::new::<F>(),
marker: PhantomData,
@ -578,6 +586,9 @@ where
fn into_system(func: Self) -> Self::System {
FunctionSystem {
func,
#[cfg(feature = "hotpatching")]
current_ptr: subsecond::HotFn::current(<F as SystemParamFunction<Marker>>::run)
.ptr_address(),
state: None,
system_meta: SystemMeta::new::<F>(),
marker: PhantomData,
@ -653,11 +664,35 @@ where
// will ensure that there are no data access conflicts.
let params =
unsafe { F::Param::get_param(&mut state.param, &self.system_meta, world, change_tick) };
#[cfg(feature = "hotpatching")]
let out = {
let mut hot_fn = subsecond::HotFn::current(<F as SystemParamFunction<Marker>>::run);
// SAFETY:
// - pointer used to call is from the current jump table
unsafe {
hot_fn
.try_call_with_ptr(self.current_ptr, (&mut self.func, input, params))
.expect("Error calling hotpatched system. Run a full rebuild")
}
};
#[cfg(not(feature = "hotpatching"))]
let out = self.func.run(input, params);
self.system_meta.last_run = change_tick;
out
}
#[cfg(feature = "hotpatching")]
#[inline]
fn refresh_hotpatch(&mut self) {
let new = subsecond::HotFn::current(<F as SystemParamFunction<Marker>>::run).ptr_address();
if new != self.current_ptr {
log::debug!("system {} hotpatched", self.name());
}
self.current_ptr = new;
}
#[inline]
fn apply_deferred(&mut self, world: &mut World) {
let param_state = &mut self.state.as_mut().expect(Self::ERROR_UNINITIALIZED).param;

View File

@ -151,6 +151,12 @@ where
Ok(())
}
#[cfg(feature = "hotpatching")]
#[inline]
fn refresh_hotpatch(&mut self) {
self.observer.refresh_hotpatch();
}
#[inline]
fn apply_deferred(&mut self, world: &mut World) {
self.observer.apply_deferred(world);

View File

@ -68,6 +68,12 @@ impl<S: System<In = ()>> System for InfallibleSystemWrapper<S> {
Ok(())
}
#[cfg(feature = "hotpatching")]
#[inline]
fn refresh_hotpatch(&mut self) {
self.0.refresh_hotpatch();
}
#[inline]
fn apply_deferred(&mut self, world: &mut World) {
self.0.apply_deferred(world);
@ -186,6 +192,12 @@ where
self.system.run_unsafe(&mut self.value, world)
}
#[cfg(feature = "hotpatching")]
#[inline]
fn refresh_hotpatch(&mut self) {
self.system.refresh_hotpatch();
}
fn apply_deferred(&mut self, world: &mut World) {
self.system.apply_deferred(world);
}
@ -293,6 +305,12 @@ where
self.system.run_unsafe(value, world)
}
#[cfg(feature = "hotpatching")]
#[inline]
fn refresh_hotpatch(&mut self) {
self.system.refresh_hotpatch();
}
fn apply_deferred(&mut self, world: &mut World) {
self.system.apply_deferred(world);
}

View File

@ -76,6 +76,10 @@ pub trait System: Send + Sync + 'static {
unsafe fn run_unsafe(&mut self, input: SystemIn<'_, Self>, world: UnsafeWorldCell)
-> Self::Out;
/// Refresh the inner pointer based on the latest hot patch jump table
#[cfg(feature = "hotpatching")]
fn refresh_hotpatch(&mut self);
/// Runs the system with the given input in the world.
///
/// For [read-only](ReadOnlySystem) systems, see [`run_readonly`], which can be called using `&World`.

View File

@ -344,6 +344,8 @@ web = [
"bevy_tasks/web",
]
hotpatching = ["bevy_app/hotpatching", "bevy_ecs/hotpatching"]
[dependencies]
# bevy (no_std)
bevy_app = { path = "../bevy_app", version = "0.16.0-dev", default-features = false, features = [

View File

@ -66,6 +66,8 @@ plugin_group! {
bevy_dev_tools:::DevToolsPlugin,
#[cfg(feature = "bevy_ci_testing")]
bevy_dev_tools::ci_testing:::CiTestingPlugin,
#[cfg(feature = "hotpatching")]
bevy_app::hotpatch:::HotPatchPlugin,
#[plugin_group]
#[cfg(feature = "bevy_picking")]
bevy_picking:::DefaultPickingPlugins,

View File

@ -85,6 +85,7 @@ The default feature set enables most of the expected features of a game engine,
|ghost_nodes|Experimental support for nodes that are ignored for UI layouting|
|gif|GIF image format support|
|glam_assert|Enable assertions to check the validity of parameters passed to glam|
|hotpatching|Enable hotpatching of Bevy systems|
|ico|ICO image format support|
|jpeg|JPEG image format support|
|libm|Uses the `libm` maths library instead of the one provided in `std` and `core`.|

View File

@ -319,6 +319,7 @@ Example | Description
[Fixed Timestep](../examples/ecs/fixed_timestep.rs) | Shows how to create systems that run every fixed timestep, rather than every tick
[Generic System](../examples/ecs/generic_system.rs) | Shows how to create systems that can be reused with different types
[Hierarchy](../examples/ecs/hierarchy.rs) | Creates a hierarchy of parents and children entities
[Hotpatching Systems](../examples/ecs/hotpatching_systems.rs) | Demonstrates how to hotpatch systems
[Immutable Components](../examples/ecs/immutable_components.rs) | Demonstrates the creation and utility of immutable components
[Iter Combinations](../examples/ecs/iter_combinations.rs) | Shows how to iterate over combinations of query results
[Nondeterministic System Order](../examples/ecs/nondeterministic_system_order.rs) | Systems run in parallel, but their order isn't always deterministic. Here's how to detect and fix this.

View File

@ -0,0 +1,94 @@
//! This example demonstrates how to hot patch systems.
//!
//! It needs to be run with the dioxus CLI:
//! ```sh
//! dx serve --hot-patch --example hotpatching_systems --features hotpatching
//! ```
//!
//! All systems are automatically hot patchable.
//!
//! You can change the text in the `update_text` system, or the color in the
//! `on_click` system, and those changes will be hotpatched into the running
//! application.
//!
//! It's also possible to make any function hot patchable by wrapping it with
//! `bevy::dev_tools::hotpatch::call`.
use std::time::Duration;
use bevy::{color::palettes, prelude::*};
fn main() {
let (sender, receiver) = crossbeam_channel::unbounded::<()>();
// This function is here to demonstrate how to make something hot patchable outside of a system
// It uses a thread for simplicity but could be an async task, an asset loader, ...
start_thread(receiver);
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(TaskSender(sender))
.add_systems(Startup, setup)
.add_systems(Update, update_text)
.run();
}
fn update_text(mut text: Single<&mut Text>) {
// Anything in the body of a system can be changed.
// Changes to this string should be immediately visible in the example.
text.0 = "before".to_string();
}
fn on_click(
_click: Trigger<Pointer<Click>>,
mut color: Single<&mut TextColor>,
task_sender: Res<TaskSender>,
) {
// Observers are also hot patchable.
// If you change this color and click on the text in the example, it will have the new color.
color.0 = palettes::tailwind::RED_600.into();
let _ = task_sender.0.send(());
}
#[derive(Resource)]
struct TaskSender(crossbeam_channel::Sender<()>);
fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
commands
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
..default()
},
children![(
Text::default(),
TextFont {
font_size: 100.0,
..default()
},
)],
))
.observe(on_click);
}
fn start_thread(receiver: crossbeam_channel::Receiver<()>) {
std::thread::spawn(move || {
while receiver.recv().is_ok() {
let start = bevy::platform::time::Instant::now();
// You can also make any part outside of a system hot patchable by wrapping it
// In this part, only the duration is hot patchable:
let duration = bevy::app::hotpatch::call(|| Duration::from_secs(2));
std::thread::sleep(duration);
info!("done after {:?}", start.elapsed());
}
});
}

View File

@ -0,0 +1,21 @@
---
title: Hot Patching Systems in a Running App
authors: ["@mockersf"]
pull_requests: [19309]
---
Bevy now supports hot patching systems through subsecond from the Dixous project.
Enabled with the feature `hotpatching`, every system can now be modified during execution, and the change directly visible in your game.
Run `BEVY_ASSET_ROOT="." dx serve --hot-patch --example hotpatching_systems --features hotpatching` to test it.
`dx` is the Dioxus CLI, to install it run `cargo install dioxus-cli@0.7.0-alpha.1`
TODO: use the fixed version that will match the version of subsecond dependency used in Bevy at release time
Known limitations:
- Only works on the binary crate (todo: plan to support it in Dioxus)
- Not supported in Wasm (todo: supported in Dioxus but not yet implemented in Bevy)
- No system signature change support (todo: add that in Bevy)
- May be sensitive to rust/linker configuration (todo: better support in Dioxus)