Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ members = [
'crates/hyperion-gui',
'crates/hyperion-hot-reload',
'crates/hyperion-hot-reload/demo/host',
'crates/hyperion-hot-reload/demo/index-probe-host',
'crates/hyperion-hot-reload/demo/index-probe-module',
'crates/hyperion-hot-reload/demo/module',
'crates/hyperion-inventory',
'crates/hyperion-item',
Expand Down Expand Up @@ -180,9 +182,15 @@ version = "1.1.9"
# stage to `flecs_components_get`, which asserts, so every system with a sparse
# term aborts under `set_threads > 1` — which is every system here. Repin on
# upstream once that lands.
#
# Also carrying the shared-dylib change: `crate-type = ["dylib", "rlib"]` plus a
# build script that re-exports flecs's C symbols. Hot reloading needs exactly one
# copy of this crate in the process, because it owns the process-global pool that
# hands out each component type's index into a world's component array. See
# `docs/hot-reload.md`.
features = ['flecs_manual_registration']
git = 'https://github.com/andrewgazelka/Flecs-Rust'
rev = '252944dedbc80741b7cca30dea67c5be95638950'
rev = 'f09dc5308d00c6a88c82b1195334b6ed2b2d2868'

[workspace.dependencies.geometry]
path = 'crates/geometry'
Expand Down
54 changes: 51 additions & 3 deletions crates/hyperion-hot-reload/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,64 @@ fn record_rustc() {
println!("cargo::rustc-env=HYPERION_HOT_RELOAD_RUSTC={fingerprint}");
}

/// The flecs C symbol patterns both platforms have to re-export, spelled without the
/// leading underscore Mach-O adds.
///
/// `flecs_ecs`'s own `build.rs` carries the same list, and that is not a duplicate to
/// consolidate: every dylib that ends up *containing* flecs's C has to export it, and
/// which dylib that is depends on how the consumer links. Under the shared-dylib recipe
/// flecs lives in `libflecs_ecs.so` and this list matches nothing here, harmlessly; built
/// without `-C prefer-dynamic`, this crate absorbs flecs itself and this list is the only
/// thing making it reachable.
const FLECS_EXPORTS: [&str; 4] = ["ecs_*", "flecs_*", "Ecs*", "FLECS_*"];

fn export_flecs_symbols() {
println!("cargo::rerun-if-changed=build.rs");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if matches!(target_os.as_str(), "macos" | "ios") {
println!("cargo::rustc-link-arg=-Wl,-all_load");
// ld64 unions -exported_symbol with the export list rustc generates.
for pattern in ["_ecs_*", "_flecs_*", "_Ecs*", "_FLECS_*"] {
println!("cargo::rustc-link-arg=-Wl,-exported_symbol,{pattern}");
for pattern in FLECS_EXPORTS {
println!("cargo::rustc-link-arg=-Wl,-exported_symbol,_{pattern}");
}
} else {
println!("cargo::rustc-link-arg=-Wl,--export-dynamic");
export_flecs_symbols_elf();
}
}

/// ELF needs a version script, because `--export-dynamic` cannot undo what rustc does.
///
/// rustc links a `dylib` with its own anonymous version script ending in `local: *`, which
/// demotes every symbol it did not generate. flecs's C symbols land in the object with
/// `DEFAULT` visibility and `LOCAL` binding, so they are present and unreachable, and
/// `--export-dynamic` and `--export-dynamic-symbol` are both powerless against a
/// version-script demotion. Measured on x86_64-linux: 9001 exported symbols, zero of them
/// `ecs_*`, `ecs_init` reading `FUNC LOCAL DEFAULT`.
///
/// A game module that cannot resolve `ecs_*` here links its own copy of `libflecs.a`
/// instead, which is two `ecs_os_api` globals in one process -- the failure `AbiToken`
/// exists to catch, arriving on a platform where the check itself could not run.
///
/// ld merges multiple version scripts and an explicit pattern beats a `*` wildcard, so a
/// second script naming these globs promotes exactly them and leaves rustc's own exports
/// alone. Same measurement after: 10717 exported, 666 of them `ecs_*`, `ecs_init` GLOBAL.
fn export_flecs_symbols_elf() {
let out_dir = std::env::var("OUT_DIR").expect("cargo always sets OUT_DIR");
let script = std::path::Path::new(&out_dir).join("flecs-exports.map");

let mut text = String::from("{\n global:\n");
for pattern in FLECS_EXPORTS {
text.push_str(" ");
text.push_str(pattern);
text.push_str(";\n");
}
// No `local:` clause. This script adds to rustc's export list rather than replacing
// it; a `local: *` here would hide every Rust symbol the host resolves through.
text.push_str("};\n");

std::fs::write(&script, text).expect("failed to write the flecs version script");
println!(
"cargo::rustc-link-arg=-Wl,--version-script={}",
script.display()
);
}
19 changes: 19 additions & 0 deletions crates/hyperion-hot-reload/demo/index-probe-host/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
edition.workspace = true
license.workspace = true
name = "hyperion-hot-reload-index-probe"
publish = false
repository.workspace = true
version.workspace = true

[[bin]]
name = "hot-reload-index-probe"
path = "src/main.rs"

[dependencies]
flecs_ecs.workspace = true
hyperion.workspace = true
libloading = "0.9.0"

[lints]
workspace = true
71 changes: 71 additions & 0 deletions crates/hyperion-hot-reload/demo/index-probe-host/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! Compares component-index allocation across the host/module dylib boundary.
//! See the module crate for why this is behavioural rather than an address comparison.
#![allow(
clippy::print_stdout,
reason = "this binary's whole purpose is reporting what it measured"
)]

use flecs_ecs::core::ComponentId;

#[derive(flecs_ecs::macros::Component)]
struct HostMarkerA;
#[derive(flecs_ecs::macros::Component)]
struct HostMarkerB;
#[derive(flecs_ecs::macros::Component)]
struct HostMarkerC;

fn main() {
let path = std::env::args()
.nth(1)
.expect("usage: hot-reload-index-probe <module dylib>");

// Take several indices before the module is even loaded, so a shared pool has visibly
// advanced by the time the module asks for one.
let host_indices = [
<HostMarkerA as ComponentId>::index(),
<HostMarkerB as ComponentId>::index(),
<HostMarkerC as ComponentId>::index(),
<hyperion::simulation::Position as ComponentId>::index(),
];
let host_max = host_indices.iter().copied().max().expect("non-empty");

let lib = unsafe { libloading::Library::new(&path) }.expect("failed to dlopen the module");
let module_index = unsafe {
let f: libloading::Symbol<'_, unsafe extern "C" fn() -> u32> = lib
.get(b"probe_module_index")
.expect("no module index symbol");
f()
};
let module_position_index = unsafe {
let f: libloading::Symbol<'_, unsafe extern "C" fn() -> u32> = lib
.get(b"probe_position_index")
.expect("no position index symbol");
f()
};
// Leaked deliberately: `dlclose` would unmap text the process still holds pointers
// into, which is the segfault-at-exit documented in docs/hot-reload.md.
core::mem::forget(lib);

println!("host indices: {host_indices:?} (max {host_max})");
println!("module's own type index: {module_index}");
println!(
"hyperion::simulation::Position index: host {}, module {module_position_index}",
host_indices[3]
);

let shared_pool = module_index > host_max;
let shared_hyperion_index = host_indices[3] == module_position_index;
println!("SHARED_POOL={shared_pool}");
println!("SHARED_HYPERION_INDEX={shared_hyperion_index}");

assert!(
shared_pool,
"host and module allocate component indices from separate pools: the module got \
{module_index} after the host had already taken up to {host_max}.\nThis is the expected \
result on a default build, and the probe is the reason to know it. Passing needs the \
dylib recipe in docs/hot-reload.md: `hyperion` built as a dylib and everything compiled \
with `-C prefer-dynamic -C link-arg=-Wl,--undefined-version -C \
link-arg=-Wl,--allow-shlib-undefined`."
);
println!("PROBE_OK");
}
17 changes: 17 additions & 0 deletions crates/hyperion-hot-reload/demo/index-probe-module/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
edition.workspace = true
license.workspace = true
name = "hyperion-hot-reload-index-probe-module"
publish = false
repository.workspace = true
version.workspace = true

[lib]
crate-type = ["dylib"]

[dependencies]
flecs_ecs.workspace = true
hyperion.workspace = true

[lints]
workspace = true
50 changes: 50 additions & 0 deletions crates/hyperion-hot-reload/demo/index-probe-module/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//! Answers one question: do a host binary and a module dylib draw component indices from
//! one shared pool?
//!
//! `flecs_ecs`'s derive emits, per component type, a `static INDEX` initialised from a
//! process-global `INDEX_POOL`, and that index is a slot in the world's component array.
//! Two copies of `flecs_ecs` in one process means two pools, so the module writes into a
//! slot the host never filled. Everything the hot-reload gate does rests on this being one
//! pool, and nothing else it checks would notice if it were not.
//!
//! The test is behavioural rather than an address comparison, deliberately. Comparing
//! `ecs_init as usize` across the boundary reports a difference even when the copy is
//! shared, because an executable taking the address of a dynamically-linked function gets
//! its own PLT stub rather than the implementation. Measured exactly that trap: separate
//! copies and shared copies both printed mismatched addresses. Allocation order cannot be
//! faked -- if the pool is shared, an index taken here is strictly greater than every
//! index the host took first.
//!
//! The equality of any single index is not evidence either, and looked like evidence once.
//! Two separate pools both start at 1, so a type that happens to be the first registered on
//! each side reads `1` and `1`. That is what this probe reported when the module linked its
//! own static copy of everything, which is exactly the case it exists to detect.
//!
//! What makes the pool shared is `flecs_ecs` being built as a dylib, so every consumer
//! resolves one `libflecs_ecs.so`. It is not this crate's dependency list: dropping the
//! `hyperion-hot-reload` dependency entirely leaves the probe passing.

use flecs_ecs::core::ComponentId;

/// Declared here so its index can only ever have been allocated by this dylib.
#[derive(flecs_ecs::macros::Component)]
pub struct ModuleOnlyMarker;

/// A component type `hyperion` owns, to check the shared-pool result holds for a type
/// neither side declares locally.
///
/// # Safety
/// Called by the probe host through `dlsym`. Returns a plain integer.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn probe_position_index() -> u32 {
<hyperion::simulation::Position as ComponentId>::index()
}

/// An index allocated in this dylib, after the host has already taken several.
///
/// # Safety
/// Called by the probe host through `dlsym`. Returns a plain integer.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn probe_module_index() -> u32 {
<ModuleOnlyMarker as ComponentId>::index()
}
Loading
Loading