workspace hot-patching, dynamic tls fixups (#5291)

* Refactor cross-tls-crate-dylib configuration and update test output

- Commented out the crate-type configuration in Cargo.toml for cross-tls-crate-dylib.
- Modified the initial value of the thread-local variable FOO from 2.0 to 1.0 in the main.rs test file.
- Updated the output message in main.rs to be more descriptive.
- Added a new field `tls_fixups` to the JumpTable struct in subsecond-types, which will facilitate future runtime TLS sharing.

* ws hotpatch!

* remove old file

* undo core change

* svh

* Enhance rlib path resolution by incorporating -C extra-filename handling

* remove obj diffing

* small cleanups

* obj cache as files

* simplify

* simplify a bit

* dont bother round tripping cache

* extract to helper

* cleanup `build` function

* dont round-trip modified_crates

* make sure to wipe between full

* bit of a cleanup

* use explicit naming

* compact extra method

* fix temp cleanup

* fix warnign

* cleanup

* fmt, clippy
This commit is contained in:
Jonathan Kelley authored and GitHub committed 2026-02-10 22:37:16 -08:00
1 parent 9b0d380765
commit 33159f366a
12 files changed
+1216 -335

No files matched your search

Generated
+224 -224
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -414,6 +414,13 @@ incremental = true
[profile.dev.package.walrus] [profile.dev.package.walrus]
opt-level = 3 opt-level = 3
# ensure we have adversarial setup for tls
[profile.dev.package.cross-tls-crate]
opt-level = 2
[profile.dev.package.cross-tls-crate-dylib]
opt-level = 2
[profile.release-max-opt] [profile.release-max-opt]
inherits = "release" inherits = "release"
lto = true lto = true
+1
View File
@@ -29,6 +29,7 @@ subsecond-types = { workspace = true }
dioxus-cli-telemetry = { workspace = true } dioxus-cli-telemetry = { workspace = true }
dioxus-component-manifest = { workspace = true } dioxus-component-manifest = { workspace = true }
bytes = { workspace = true }
clap = { workspace = true, features = ["derive", "cargo"] } clap = { workspace = true, features = ["derive", "cargo"] }
convert_case = { workspace = true } convert_case = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
+79 -11
View File
@@ -1,12 +1,14 @@
use crate::{ use crate::{
serve::WebServer, verbosity_or_default, BuildArtifacts, BuildRequest, BuildStage, build::cache::ObjectCache, serve::WebServer, verbosity_or_default, BuildArtifacts,
BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result, RustcArgs, StructuredOutput, BuildRequest, BuildStage, BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result,
RustcArgs, StructuredOutput,
}; };
use anyhow::{bail, Context, Error}; use anyhow::{bail, Context, Error};
use dioxus_cli_opt::process_file_to; use dioxus_cli_opt::process_file_to;
use futures_util::{future::OptionFuture, pin_mut, FutureExt}; use futures_util::{future::OptionFuture, pin_mut, FutureExt};
use itertools::Itertools; use itertools::Itertools;
use std::{ use std::{
collections::HashSet,
env, env,
time::{Duration, Instant, SystemTime}, time::{Duration, Instant, SystemTime},
}; };
@@ -103,6 +105,13 @@ pub(crate) struct AppBuilder {
/// The debugger for the app - must be enabled with the `d` key /// The debugger for the app - must be enabled with the `d` key
pub(crate) pid: Option<u32>, pub(crate) pid: Option<u32>,
/// Cumulative set of workspace crates modified since the last fat build.
/// Each patch includes objects from ALL crates in this set.
pub modified_crates: HashSet<String>,
/// Cache of the latest `.rcgu.o` files for each modified workspace crate.
pub object_cache: ObjectCache,
} }
impl AppBuilder { impl AppBuilder {
@@ -159,6 +168,8 @@ impl AppBuilder {
artifacts: None, artifacts: None,
patch_cache: None, patch_cache: None,
pid: None, pid: None,
modified_crates: HashSet::new(),
object_cache: ObjectCache::new(&request.session_cache_dir()),
}) })
} }
@@ -319,7 +330,12 @@ impl AppBuilder {
update update
} }
pub(crate) fn patch_rebuild(&mut self, changed_files: Vec<PathBuf>, build_id: BuildId) { pub(crate) fn patch_rebuild(
&mut self,
changed_files: Vec<PathBuf>,
changed_crates: Vec<String>,
build_id: BuildId,
) {
// We need the rustc args from the original build to pass to the new build // We need the rustc args from the original build to pass to the new build
let Some(artifacts) = self.artifacts.as_ref().cloned() else { let Some(artifacts) = self.artifacts.as_ref().cloned() else {
tracing::warn!( tracing::warn!(
@@ -353,6 +369,38 @@ impl AppBuilder {
.context("Failed to get patch cache") .context("Failed to get patch cache")
.unwrap(); .unwrap();
// Pre-compute the cumulative modified_crates set. Every patch includes objects from
// ALL crates modified since the fat build. We compute the full cascade closure here
// (while we have &mut self) so it doesn't need to be round-tripped through BuildArtifacts.
//
// Note: compile_workspace_deps() independently computes which crates to compile for THIS
// patch (starting from changed_crates + cascade). That serves a different purpose — it only
// compiles what changed now, not everything ever modified. Both use workspace_dependents_of
// for the BFS, so they stay in sync automatically.
let tip_crate_name = self.build.main_target.replace('-', "_");
self.modified_crates.insert(tip_crate_name.clone());
// Add changed crates and their transitive workspace dependents (cascade).
let mut to_visit: Vec<String> = changed_crates.clone();
let mut visited = HashSet::new();
while let Some(c) = to_visit.pop() {
if !visited.insert(c.clone()) {
continue;
}
self.modified_crates.insert(c.clone());
for dep in self.build.workspace_dependents_of(&c) {
if dep != tip_crate_name && !visited.contains(&dep) {
to_visit.push(dep);
}
}
}
tracing::debug!(
"Patch rebuild: changed_crates={:?}, modified_crates={:?}",
changed_crates,
self.modified_crates,
);
// Abort all the ongoing builds, cleaning up any loose artifacts and waiting to cleanly exit // Abort all the ongoing builds, cleaning up any loose artifacts and waiting to cleanly exit
self.abort_all(BuildStage::Restarting); self.abort_all(BuildStage::Restarting);
self.build_task = tokio::spawn({ self.build_task = tokio::spawn({
@@ -362,9 +410,12 @@ impl AppBuilder {
tx: self.tx.clone(), tx: self.tx.clone(),
mode: BuildMode::Thin { mode: BuildMode::Thin {
changed_files, changed_files,
rustc_args: artifacts.direct_rustc, changed_crates,
modified_crates: self.modified_crates.clone(),
workspace_rustc_args: artifacts.workspace_rustc_args,
aslr_reference, aslr_reference,
cache, cache,
object_cache: self.object_cache.clone(),
}, },
}; };
async move { request.build(&ctx).await } async move { request.build(&ctx).await }
@@ -378,6 +429,10 @@ impl AppBuilder {
self.abort_all(BuildStage::Restarting); self.abort_all(BuildStage::Restarting);
self.artifacts.take(); self.artifacts.take();
self.patch_cache.take(); self.patch_cache.take();
// A full rebuild resets all accumulated hotpatch state — the fat binary is a clean baseline.
self.modified_crates.clear();
self.object_cache = ObjectCache::new(&self.build.session_cache_dir());
self.build_task = tokio::spawn({ self.build_task = tokio::spawn({
let request = self.build.clone(); let request = self.build.clone();
let ctx = BuildContext { let ctx = BuildContext {
@@ -545,14 +600,23 @@ impl AppBuilder {
)); ));
} }
// If there's any CARGO vars in the rustc_wrapper files, push those too // If there's any CARGO vars in the rustc_wrapper files, push those too.
if let Ok(res) = std::fs::read_to_string(self.build.rustc_wrapper_args_file()) { // Read from any per-crate args file in the directory (they all share the same CARGO_ envs).
if let Ok(res) = serde_json::from_str::<RustcArgs>(&res) { if let Ok(entries) = std::fs::read_dir(self.build.rustc_wrapper_args_dir()) {
for (key, value) in res.envs { for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(args) = serde_json::from_str::<RustcArgs>(&contents) {
for (key, value) in args.envs {
if key.starts_with("CARGO_") { if key.starts_with("CARGO_") {
envs.push((key, value)); envs.push((key, value));
} }
} }
break; // Only need one file for CARGO_ env vars
}
}
}
} }
} }
@@ -740,17 +804,21 @@ impl AppBuilder {
tracing::info!( tracing::info!(
"Hot-patching: {NOTE_STYLE}{}{NOTE_STYLE:#} took {GLOW_STYLE}{:?}ms{GLOW_STYLE:#}", "Hot-patching: {NOTE_STYLE}{}{NOTE_STYLE:#} took {GLOW_STYLE}{:?}ms{GLOW_STYLE:#}",
changed_file changed_file
.display() .strip_prefix(self.build.workspace_dir())
.to_string() .unwrap_or(changed_file)
.trim_start_matches(&self.build.crate_dir().display().to_string()), .display(),
SystemTime::now() SystemTime::now()
.duration_since(res.time_start) .duration_since(res.time_start)
.unwrap() .unwrap()
.as_millis() .as_millis()
); );
// Commit this patch
self.patches.push(jump_table.clone()); self.patches.push(jump_table.clone());
// Sync the updated object cache back from the build artifacts.
self.object_cache = res.object_cache.clone();
Ok(jump_table) Ok(jump_table)
} }
+119
View File
@@ -0,0 +1,119 @@
//! Object file cache for workspace hotpatching.
//!
//! Maintains the latest `.rcgu.o` files for each crate in the cumulative `modified_crates` set.
//! Used for accumulated relinking: combining objects from all modified crates into a single
//! patch dylib.
//!
//! Objects are stored on disk under `session_cache_dir/object_cache/{crate_name}/` so they
//! persist across patches without holding file contents in memory. The session cache dir
//! lives in `/tmp/` and is cleaned up by the OS.
//!
//! - **Dep crates:** objects are extracted from their rlib in `target/deps/`.
//! - **Tip crate:** objects are copied from linker arg paths since incremental compilation
//! overwrites them in place.
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
/// Cache of compiled object files on disk, keyed by crate name.
///
/// After each compilation, the cache is updated for the compiled crate by extracting
/// objects to `dir/{crate_name}/`. On relink, paths from all crates in `modified_crates`
/// are passed directly to the linker — no intermediate copy needed.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ObjectCache {
/// Root directory: `session_cache_dir/object_cache/`
dir: PathBuf,
/// crate_name -> object file paths on disk
objects: HashMap<String, Vec<PathBuf>>,
}
impl ObjectCache {
pub fn new(session_cache_dir: &Path) -> Self {
let dir = session_cache_dir.join("object_cache");
Self {
dir,
objects: HashMap::new(),
}
}
/// Extract `.rcgu.o` files from an rlib archive and write them to
/// `dir/{crate_name}/`. Replaces any previously cached objects for this crate.
pub fn cache_from_rlib(&mut self, crate_name: &str, rlib_path: &Path) -> anyhow::Result<()> {
let crate_dir = self.dir.join(crate_name);
// Clear previous objects for this crate
if crate_dir.exists() {
std::fs::remove_dir_all(&crate_dir)?;
}
std::fs::create_dir_all(&crate_dir)?;
let rlib_contents = std::fs::read(rlib_path)?;
let mut reader = ar::Archive::new(std::io::Cursor::new(rlib_contents));
let mut paths = Vec::new();
while let Some(Ok(mut entry)) = reader.next_entry() {
let name = std::str::from_utf8(entry.header().identifier())
.unwrap_or_default()
.to_string();
// Skip rmeta and empty entries
if name.ends_with(".rmeta") || entry.header().size() == 0 {
continue;
}
// Only keep object files
if !name.ends_with(".o") {
continue;
}
let mut data = Vec::with_capacity(entry.header().size() as usize);
entry.read_to_end(&mut data)?;
let obj_path = crate_dir.join(&name);
std::fs::write(&obj_path, &data)?;
paths.push(obj_path);
}
self.objects.insert(crate_name.to_string(), paths);
Ok(())
}
/// Cache tip crate objects by copying them from their filesystem paths.
///
/// Tip crate `.rcgu.o` files get overwritten on recompilation, so we copy
/// them into our cache directory for stable access.
pub fn cache_from_paths(
&mut self,
crate_name: &str,
object_paths: &[impl AsRef<Path>],
) -> anyhow::Result<()> {
let crate_dir = self.dir.join(crate_name);
if crate_dir.exists() {
std::fs::remove_dir_all(&crate_dir)?;
}
std::fs::create_dir_all(&crate_dir)?;
let mut paths = Vec::with_capacity(object_paths.len());
for path in object_paths {
let path = path.as_ref();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
let dest = crate_dir.join(name);
std::fs::copy(path, &dest)?;
paths.push(dest);
}
self.objects.insert(crate_name.to_string(), paths);
Ok(())
}
/// Get cached object file paths for a crate.
pub fn get(&self, crate_name: &str) -> Option<&Vec<PathBuf>> {
self.objects.get(crate_name)
}
}
+2
View File
@@ -10,6 +10,7 @@
mod assets; mod assets;
mod builder; mod builder;
mod cache;
mod context; mod context;
mod manifest; mod manifest;
mod patch; mod patch;
@@ -19,6 +20,7 @@ mod tools;
pub(crate) use assets::*; pub(crate) use assets::*;
pub(crate) use builder::*; pub(crate) use builder::*;
pub(crate) use cache::*;
pub(crate) use context::*; pub(crate) use context::*;
pub(crate) use manifest::*; pub(crate) use manifest::*;
pub(crate) use patch::*; pub(crate) use patch::*;
+100 -20
View File
@@ -4,7 +4,7 @@ use object::{
macho::{self}, macho::{self},
read::File, read::File,
write::{MachOBuildVersion, SectionId, StandardSection, Symbol, SymbolId, SymbolSection}, write::{MachOBuildVersion, SectionId, StandardSection, Symbol, SymbolId, SymbolSection},
Endianness, Object, ObjectSymbol, SymbolFlags, SymbolKind, SymbolScope, Endianness, Object, ObjectSection, ObjectSymbol, SymbolFlags, SymbolKind, SymbolScope,
}; };
use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; use rayon::prelude::{IntoParallelRefIterator, ParallelIterator};
use std::{ use std::{
@@ -76,6 +76,16 @@ pub struct HotpatchModuleCache {
// ... native stuff // ... native stuff
pub symbol_table: HashMap<String, CachedSymbol>, pub symbol_table: HashMap<String, CachedSymbol>,
/// Contents of the .tdata section from the original binary (TLS initialization image).
/// Used to provide correct init data for TLS symbol stubs instead of garbage addresses.
pub tls_init_data: Vec<u8>,
/// Map from `$tlv$init` symbol name to (offset_in_tdata, computed_size).
/// On macOS, Mach-O nlist doesn't carry symbol sizes, so we compute them from
/// adjacent symbol addresses in the `__thread_data` section. This lets us provide
/// correctly-sized TLS init data in stubs instead of defaulting to pointer_width.
pub tls_init_sizes: HashMap<String, (u64, u64)>,
} }
pub struct CachedSymbol { pub struct CachedSymbol {
@@ -277,10 +287,55 @@ impl HotpatchModuleCache {
)) ))
}) })
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
// Extract TLS initialization data and section metadata.
// This is used to correctly initialize TLS symbols in the stub
// instead of writing bogus absolute addresses into .tdata.
let tls_section = obj
.sections()
.find(|s| matches!(s.name(), Ok(".tdata" | "__thread_data")));
let tls_init_data = tls_section
.as_ref()
.and_then(|s| s.data().ok())
.unwrap_or(&[])
.to_vec();
// Build TLS init size map for macOS. Mach-O nlist doesn't carry symbol
// sizes, so we compute them from adjacent symbols in __thread_data.
// LLVM/rustc names init data symbols as `FOO$tlv$init` in __thread_data.
let tls_data_addr = tls_section.as_ref().map(|s| s.address()).unwrap_or(0);
let tls_data_size = tls_section.as_ref().map(|s| s.size()).unwrap_or(0);
let tls_section_index = tls_section.as_ref().map(|s| s.index());
let mut tls_init_syms: Vec<(u64, String)> = Vec::new();
for sym in obj.symbols() {
if let (Some(section_idx), Ok(sname)) = (sym.section_index(), sym.name()) {
if Some(section_idx) == tls_section_index {
let offset = sym.address().saturating_sub(tls_data_addr);
tls_init_syms.push((offset, sname.to_string()));
}
}
}
tls_init_syms.sort_by_key(|(addr, _)| *addr);
tls_init_syms.dedup_by_key(|(addr, _)| *addr);
let mut tls_init_sizes: HashMap<String, (u64, u64)> = HashMap::new();
for (i, (offset, sname)) in tls_init_syms.iter().enumerate() {
let size = if i + 1 < tls_init_syms.len() {
tls_init_syms[i + 1].0 - offset
} else {
tls_data_size.saturating_sub(*offset)
};
tls_init_sizes.insert(sname.clone(), (*offset, size));
}
HotpatchModuleCache { HotpatchModuleCache {
symbol_table, symbol_table,
path: original.to_path_buf(), path: original.to_path_buf(),
old_bytes, old_bytes,
tls_init_data,
tls_init_sizes,
..Default::default() ..Default::default()
} }
} }
@@ -1106,36 +1161,61 @@ pub fn create_undefined_symbol_stub(
PointerWidth::U64 => 8, PointerWidth::U64 => 8,
}; };
let size = if sym.size == 0 { // Resolve the TLS init data offset and size.
pointer_width //
// On ELF: sym.address IS the TLS offset and sym.size is the data size.
// On Mach-O: sym.address points to __thread_vars (TLV descriptor), NOT
// __thread_data. Mach-O nlist has no size field (always 0). We look up
// the corresponding $tlv$init symbol (LLVM convention) to get the real
// offset and size within __thread_data.
//
// Note: each patch gets its own TLS copy (not shared with the main exe).
// TLS variables reset to their initial value on patch.
// Use the full name (with Mach-O `_` prefix) since tls_init_sizes
// keys come from the same symbol table and include the prefix.
let init_key = format!("{}$tlv$init", name);
let (tls_offset, size) =
if let Some(&(offset, size)) = cache.tls_init_sizes.get(&init_key) {
// macOS: found the $tlv$init symbol with correct offset and size
(offset, size)
} else if sym.size > 0 {
// ELF: sym.address is the TLS offset, sym.size is the data size
(sym.address, sym.size)
} else if !cache.tls_init_sizes.is_empty() {
// macOS fallback: $tlv$init not found but map isn't empty (binary
// might be partially stripped). Use entire tdata as upper bound.
(0, cache.tls_init_data.len() as u64)
} else { } else {
sym.size // Last resort (ELF with size=0): use pointer width
(sym.address, pointer_width)
}; };
let align = size.min(pointer_width).next_power_of_two(); let align = size.min(pointer_width).next_power_of_two();
let mut init = vec![0u8; size as usize];
// write the contents of the symbol to the init vec let start = tls_offset as usize;
init.iter_mut() let end = start + size as usize;
.zip(match triple.endianness() { let init = if end <= cache.tls_init_data.len() {
Ok(target_lexicon::Endianness::Little) => abs_addr.to_le_bytes(), cache.tls_init_data[start..end].to_vec()
Ok(target_lexicon::Endianness::Big) => abs_addr.to_be_bytes(), } else {
_ => return Err(PatchError::UnsupportedPlatform(triple.to_string())), // Beyond .tdata bounds (.tbss) or Mach-O fallback: zero-init
}) vec![0u8; size as usize]
.for_each(|(b, v)| *b = v); };
let offset = obj.append_section_data(tls_section, &init, align); // Use add_symbol_data() so the object crate's Mach-O writer auto-creates
// __thread_vars TLV descriptors (via macho_add_thread_var). Without this,
obj.add_symbol(Symbol { // the symbol stays in __thread_data and the runtime misinterprets raw init
// bytes as a TLV descriptor — first 8 bytes become the thunk pointer.
let sym_id = obj.add_symbol(Symbol {
name: name.as_bytes()[name_offset..].to_vec(), name: name.as_bytes()[name_offset..].to_vec(),
value: offset, // offset inside .tdata value: 0,
size, size: 0,
scope: SymbolScope::Linkage, scope: SymbolScope::Linkage,
kind: SymbolKind::Tls, kind: SymbolKind::Tls,
weak: false, weak: false,
section: SymbolSection::Section(tls_section), section: SymbolSection::Undefined,
flags: SymbolFlags::None, // ignore for these stubs flags: SymbolFlags::None,
}); });
obj.add_symbol_data(sym_id, tls_section, &init, align);
} }
// We just assume all non-text symbols are data (globals, statics, etc) // We just assume all non-text symbols are data (globals, statics, etc)
+465 -53
View File
@@ -321,8 +321,8 @@
use crate::{ use crate::{
AndroidTools, AppManifest, BuildContext, BuildId, BundleFormat, DioxusConfig, Error, AndroidTools, AppManifest, BuildContext, BuildId, BundleFormat, DioxusConfig, Error,
LinkAction, LinkerFlavor, Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, LinkAction, LinkerFlavor, ObjectCache, Platform, Renderer, Result, RustcArgs, TargetArgs,
WasmBindgen, WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR, TraceSrc, WasmBindgen, WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR,
}; };
use anyhow::{bail, Context}; use anyhow::{bail, Context};
use cargo_metadata::diagnostic::Diagnostic; use cargo_metadata::diagnostic::Diagnostic;
@@ -339,7 +339,7 @@ use rayon::prelude::{IntoParallelRefIterator, ParallelIterator};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{borrow::Cow, ffi::OsString}; use std::{borrow::Cow, ffi::OsString};
use std::{ use std::{
collections::{BTreeMap, HashSet}, collections::{BTreeMap, HashMap, HashSet},
io::Write, io::Write,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Stdio, process::Stdio,
@@ -419,6 +419,7 @@ pub(crate) struct BuildRequest {
/// - Thin: A "thin" build that dynamically links against the dependencies produced by the "fat" build. /// - Thin: A "thin" build that dynamically links against the dependencies produced by the "fat" build.
/// This is generated by calling rustc *directly* and might be more fragile to construct, but /// This is generated by calling rustc *directly* and might be more fragile to construct, but
/// generates *much* faster than a regular base or fat build. /// generates *much* faster than a regular base or fat build.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum BuildMode { pub enum BuildMode {
/// A normal build generated using `cargo rustc` /// A normal build generated using `cargo rustc`
@@ -433,9 +434,26 @@ pub enum BuildMode {
/// A "thin" build generated with `rustc` directly and dx as a custom linker /// A "thin" build generated with `rustc` directly and dx as a custom linker
Thin { Thin {
rustc_args: RustcArgs, /// List of changed files causing this rebuild. Mostly used for diagnostics
changed_files: Vec<PathBuf>, changed_files: Vec<PathBuf>,
/// Which workspace crates had source file changes in this edit.
changed_crates: Vec<String>,
/// The ASLR slide of the running program, used to hardcode symbol jumps
aslr_reference: u64, aslr_reference: u64,
/// The captured RustcArgs for every crate in the workspace, collected by RUSTC_WORKSPACE_WRAPPER
/// This is used for replaying rustc invocations for workspace hotpatching
workspace_rustc_args: HashMap<String, RustcArgs>,
/// Cumulative set of all workspace crates modified since the fat build.
modified_crates: HashSet<String>,
/// Cache of compiled objects from previous thin builds, used by future re-linking
object_cache: ObjectCache,
/// Cache of initial binary parsing which speeds up stub creation
cache: Arc<HotpatchModuleCache>, cache: Arc<HotpatchModuleCache>,
}, },
} }
@@ -450,7 +468,7 @@ pub enum BuildMode {
pub struct BuildArtifacts { pub struct BuildArtifacts {
pub(crate) root_dir: PathBuf, pub(crate) root_dir: PathBuf,
pub(crate) exe: PathBuf, pub(crate) exe: PathBuf,
pub(crate) direct_rustc: RustcArgs, pub(crate) workspace_rustc_args: HashMap<String, RustcArgs>,
pub(crate) time_start: SystemTime, pub(crate) time_start: SystemTime,
pub(crate) time_end: SystemTime, pub(crate) time_end: SystemTime,
pub(crate) assets: AssetManifest, pub(crate) assets: AssetManifest,
@@ -458,6 +476,7 @@ pub struct BuildArtifacts {
pub(crate) patch_cache: Option<Arc<HotpatchModuleCache>>, pub(crate) patch_cache: Option<Arc<HotpatchModuleCache>>,
pub(crate) depinfo: RustcDepInfo, pub(crate) depinfo: RustcDepInfo,
pub(crate) build_id: BuildId, pub(crate) build_id: BuildId,
pub(crate) object_cache: ObjectCache,
} }
impl BuildRequest { impl BuildRequest {
@@ -1018,7 +1037,7 @@ impl BuildRequest {
// Create the session cache directory // Create the session cache directory
let cache_dir = self.session_cache_dir(); let cache_dir = self.session_cache_dir();
_ = std::fs::create_dir_all(&cache_dir); _ = std::fs::create_dir_all(&cache_dir);
_ = std::fs::File::create_new(self.rustc_wrapper_args_file()); _ = std::fs::create_dir_all(self.rustc_wrapper_args_dir());
_ = std::fs::File::create_new(self.link_err_file()); _ = std::fs::File::create_new(self.link_err_file());
_ = std::fs::File::create_new(self.link_args_file()); _ = std::fs::File::create_new(self.link_args_file());
_ = std::fs::File::create_new(self.windows_command_file()); _ = std::fs::File::create_new(self.windows_command_file());
@@ -1054,7 +1073,8 @@ impl BuildRequest {
// We need to make sure to not react to this though, so the filemap must cache it // We need to make sure to not react to this though, so the filemap must cache it
_ = self.bust_fingerprint(ctx); _ = self.bust_fingerprint(ctx);
// Run the cargo build to produce our artifacts // Run the cargo build to produce our artifacts.
// For thin builds this also pre-compiles workspace dep crates before the tip.
let mut artifacts = self.cargo_build(ctx).await?; let mut artifacts = self.cargo_build(ctx).await?;
// Write the build artifacts to the bundle on the disk // Write the build artifacts to the bundle on the disk
@@ -1062,10 +1082,10 @@ impl BuildRequest {
BuildMode::Thin { BuildMode::Thin {
aslr_reference, aslr_reference,
cache, cache,
rustc_args, modified_crates,
.. ..
} => { } => {
self.write_patch(ctx, *aslr_reference, &mut artifacts, cache, rustc_args) self.write_patch(ctx, *aslr_reference, &mut artifacts, cache, modified_crates)
.await?; .await?;
} }
@@ -1076,7 +1096,7 @@ impl BuildRequest {
self.write_executable(ctx, &artifacts.exe, &mut artifacts.assets) self.write_executable(ctx, &artifacts.exe, &mut artifacts.assets)
.await .await
.context("Failed to write executable")?; .context("Failed to write executable")?;
self.write_frameworks(ctx, &artifacts.direct_rustc) self.write_frameworks(ctx, &artifacts)
.await .await
.context("Failed to write frameworks")?; .context("Failed to write frameworks")?;
self.write_assets(ctx, &artifacts.assets) self.write_assets(ctx, &artifacts.assets)
@@ -1125,7 +1145,6 @@ impl BuildRequest {
Ok(artifacts) Ok(artifacts)
} }
/// Run the cargo build by assembling the build command and executing it. /// Run the cargo build by assembling the build command and executing it.
/// ///
/// This method needs to be very careful with processing output since errors being swallowed will /// This method needs to be very careful with processing output since errors being swallowed will
@@ -1133,6 +1152,10 @@ impl BuildRequest {
async fn cargo_build(&self, ctx: &BuildContext) -> Result<BuildArtifacts> { async fn cargo_build(&self, ctx: &BuildContext) -> Result<BuildArtifacts> {
let time_start = SystemTime::now(); let time_start = SystemTime::now();
// For thin builds, compile workspace dep crates before the tip.
// This updates dep rlibs on disk so cargo links the tip against fresh code.
let object_cache = self.compile_workspace_deps(ctx).await?;
// Extract the unit count of the crate graph so build_cargo has more accurate data // Extract the unit count of the crate graph so build_cargo has more accurate data
// "Thin" builds only build the final exe, so we only need to build one crate // "Thin" builds only build the final exe, so we only need to build one crate
let crate_count = match ctx.mode { let crate_count = match ctx.mode {
@@ -1246,13 +1269,32 @@ impl BuildRequest {
} }
} }
// Accumulate the rustc args from the wrapper, if they exist and can be parsed. // Load per-crate rustc args from the wrapper directory.
let mut direct_rustc = RustcArgs::default(); // Each workspace crate compiled through the wrapper has its own JSON file:
if let Ok(res) = std::fs::read_to_string(self.rustc_wrapper_args_file()) { // "{crate_name}.lib.json" (key: "{crate_name}.lib") for lib targets and
if let Ok(res) = serde_json::from_str(&res) { // "{crate_name}.bin.json" (key: "{crate_name}.bin") for bin targets.
direct_rustc = res; let mut workspace_rustc_args = HashMap::new();
let args_dir = self.rustc_wrapper_args_dir();
if let Ok(entries) = std::fs::read_dir(&args_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(args) = serde_json::from_str::<RustcArgs>(&contents) {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
workspace_rustc_args.insert(stem.to_string(), args);
} }
} }
}
}
}
}
tracing::trace!(
"Loaded workspace rustc args from {}: keys={:?}",
args_dir.display(),
workspace_rustc_args.keys().collect::<Vec<_>>(),
);
// If there's any warnings from the linker, we should print them out // If there's any warnings from the linker, we should print them out
if let Ok(linker_warnings) = std::fs::read_to_string(self.link_err_file()) { if let Ok(linker_warnings) = std::fs::read_to_string(self.link_err_file()) {
@@ -1265,12 +1307,16 @@ impl BuildRequest {
} }
} }
// Collect the linker args from the and update the rustc args // Collect the linker args and attach them to the tip crate's bin entry
direct_rustc.link_args = std::fs::read_to_string(self.link_args_file()) let tip_crate_name = self.tip_crate_name();
let tip_bin_key = format!("{tip_crate_name}.bin");
if let Some(tip_args) = workspace_rustc_args.get_mut(&tip_bin_key) {
tip_args.link_args = std::fs::read_to_string(self.link_args_file())
.context("Failed to read link args from file")? .context("Failed to read link args from file")?
.lines() .lines()
.map(|s| s.to_string()) .map(|s| s.to_string())
.collect::<Vec<_>>(); .collect();
}
let exe = output_location.context("Cargo build failed - no output location. Toggle tracing mode (press `t`) for more information.")?; let exe = output_location.context("Cargo build failed - no output location. Toggle tracing mode (press `t`) for more information.")?;
@@ -1278,7 +1324,14 @@ impl BuildRequest {
if matches!(ctx.mode, BuildMode::Fat) { if matches!(ctx.mode, BuildMode::Fat) {
ctx.status_starting_link(); ctx.status_starting_link();
let link_start = SystemTime::now(); let link_start = SystemTime::now();
self.run_fat_link(&exe, &direct_rustc).await?; self.run_fat_link(
&exe,
&workspace_rustc_args
.get(&tip_bin_key)
.cloned()
.unwrap_or_default(),
)
.await?;
tracing::debug!( tracing::debug!(
"Fat linking completed in {}us", "Fat linking completed in {}us",
SystemTime::now() SystemTime::now()
@@ -1302,7 +1355,7 @@ impl BuildRequest {
Ok(BuildArtifacts { Ok(BuildArtifacts {
time_end, time_end,
exe, exe,
direct_rustc, workspace_rustc_args,
time_start, time_start,
assets, assets,
mode, mode,
@@ -1310,9 +1363,122 @@ impl BuildRequest {
root_dir: self.root_dir(), root_dir: self.root_dir(),
patch_cache: None, patch_cache: None,
build_id: ctx.build_id, build_id: ctx.build_id,
object_cache,
}) })
} }
/// For thin builds, compile workspace dep crates BEFORE the tip crate.
///
/// This updates dep rlibs on disk so cargo links the tip against fresh code. Handles cascade
/// (recompiling workspace dependents for SVH consistency) and lib+bin tip targets.
///
/// Returns the updated `ObjectCache` — defaulting to empty for non-thin builds.
async fn compile_workspace_deps(&self, ctx: &BuildContext) -> Result<ObjectCache> {
let BuildMode::Thin {
workspace_rustc_args,
changed_crates,
object_cache,
..
} = &ctx.mode
else {
return Ok(ObjectCache::new(&self.session_cache_dir()));
};
let tip_name = self.tip_crate_name();
let mut object_cache = object_cache.clone();
// Compile workspace dep crates with cascade. Start with the explicitly changed dep
// crates (already in leaf-first order from handle_file_change). As we compile each,
// add the crate's workspace dependents so their rlibs have consistent SVH references.
let mut crates_to_compile: Vec<String> = changed_crates
.iter()
.filter(|c| *c != &tip_name)
.cloned()
.collect();
let mut compiled = HashSet::new();
let mut idx = 0;
while idx < crates_to_compile.len() {
let crate_name = crates_to_compile[idx].clone();
idx += 1;
if !compiled.insert(crate_name.clone()) || crate_name == tip_name {
continue;
}
let Some(rustc_args) = workspace_rustc_args.get(&format!("{crate_name}.lib")) else {
tracing::warn!("No captured rustc args for workspace crate {crate_name}, skipping");
continue;
};
tracing::debug!("Compiling workspace dep crate: {crate_name}");
self.compile_dep_crate(&crate_name, rustc_args)
.await
.with_context(|| format!("Failed to compile workspace dep crate '{crate_name}'"))?;
if let Some(rlib_path) = self.find_rlib_for_crate(&crate_name, rustc_args) {
if let Err(e) = object_cache.cache_from_rlib(&crate_name, &rlib_path) {
tracing::warn!("Failed to cache objects from rlib for {crate_name}: {e}");
}
}
for dependent in self.workspace_dependents_of(&crate_name) {
if dependent != tip_name && !compiled.contains(&dependent) {
tracing::debug!(
"Cascade: recompiling {dependent} (depends on recompiled {crate_name})"
);
crates_to_compile.push(dependent);
}
}
}
// If the tip crate has a lib target (src/lib.rs + src/main.rs), compile it
// before the bin target so the bin links against the fresh lib rlib.
let lib_key = format!("{tip_name}.lib");
if let Some(lib_args) = workspace_rustc_args.get(&lib_key) {
let rlib_pre = self.find_rlib_for_crate(&tip_name, lib_args);
let pre_modified = rlib_pre
.as_ref()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| m.modified().ok());
tracing::info!("Compiling tip lib target: {lib_key}");
if let Err(e) = self.compile_dep_crate(&tip_name, lib_args).await {
tracing::warn!("Failed to compile tip lib target: {e}");
} else if let Some(rlib_path) = self.find_rlib_for_crate(&tip_name, lib_args) {
let post_modified = std::fs::metadata(&rlib_path)
.ok()
.and_then(|m| m.modified().ok());
let rlib_changed = match (pre_modified, post_modified) {
(Some(pre), Some(post)) => post > pre,
_ => true,
};
tracing::info!(
"Found lib rlib at: {} (modified={})",
rlib_path.display(),
rlib_changed,
);
match object_cache.cache_from_rlib(&lib_key, &rlib_path) {
Ok(()) => {
let count = object_cache.get(&lib_key).map(|v| v.len()).unwrap_or(0);
tracing::info!("Cached {count} objects from tip lib rlib");
}
Err(e) => tracing::warn!("Failed to cache tip lib objects: {e}"),
}
} else {
tracing::warn!("Could not find rlib for tip lib target {tip_name}");
}
} else {
tracing::debug!(
"No lib target for tip crate (key '{lib_key}' not in workspace_rustc_args, keys={:?})",
workspace_rustc_args.keys().collect::<Vec<_>>()
);
}
Ok(object_cache)
}
/// Collect the assets from the final executable and modify the binary in place to point to the right /// Collect the assets from the final executable and modify the binary in place to point to the right
/// hashed asset location. /// hashed asset location.
async fn collect_assets(&self, exe: &Path, ctx: &BuildContext) -> Result<AssetManifest> { async fn collect_assets(&self, exe: &Path, ctx: &BuildContext) -> Result<AssetManifest> {
@@ -1421,9 +1587,20 @@ impl BuildRequest {
Ok(()) Ok(())
} }
async fn write_frameworks(&self, _ctx: &BuildContext, direct_rustc: &RustcArgs) -> Result<()> { async fn write_frameworks(
&self,
_ctx: &BuildContext,
artifacts: &BuildArtifacts,
) -> Result<()> {
let framework_dir = self.frameworks_folder(); let framework_dir = self.frameworks_folder();
// We use the rustc for the tip crate `main.rs` because that's where the linking happens
let direct_rustc = artifacts
.workspace_rustc_args
.get(&format!("{}.bin", self.tip_crate_name()))
.cloned()
.unwrap_or_default();
// We have some prebuilt stuff that needs to be copied into the framework dir // We have some prebuilt stuff that needs to be copied into the framework dir
let openssl_dir = AndroidTools::openssl_lib_dir(&self.triple); let openssl_dir = AndroidTools::openssl_lib_dir(&self.triple);
let openssl_dir_disp = openssl_dir.display().to_string(); let openssl_dir_disp = openssl_dir.display().to_string();
@@ -1633,17 +1810,66 @@ impl BuildRequest {
/// manually do any linking. /// manually do any linking.
/// ///
/// We also run some post processing steps here, like extracting out any new assets. /// We also run some post processing steps here, like extracting out any new assets.
///
/// `extra_objects` contains additional object file paths from compiled workspace dep crates
/// that should be included in the patch dylib. These are combined with the tip crate's
/// `.rcgu.o` files extracted from linker args, creating a self-contained patch.
async fn write_patch( async fn write_patch(
&self, &self,
ctx: &BuildContext, ctx: &BuildContext,
aslr_reference: u64, aslr_reference: u64,
artifacts: &mut BuildArtifacts, artifacts: &mut BuildArtifacts,
cache: &Arc<HotpatchModuleCache>, cache: &Arc<HotpatchModuleCache>,
rustc_args: &RustcArgs, modified_crates: &HashSet<String>,
) -> Result<()> { ) -> Result<()> {
ctx.status_hotpatching(); ctx.status_hotpatching();
let args = artifacts.direct_rustc.link_args.clone(); let tip_name = self.tip_crate_name();
// Cache tip crate objects from the FRESH linker args (from the just-completed
// thin build, not the stale ones from ctx.mode's fat build).
let tip_bin_key = format!("{tip_name}.bin");
let args = artifacts
.workspace_rustc_args
.get(&tip_bin_key)
.cloned()
.with_context(|| {
format!(
"Missing rustc args for tip bin target '{tip_bin_key}' \
(available keys: {:?})",
artifacts.workspace_rustc_args.keys().collect::<Vec<_>>()
)
})?;
// Collect objs from tip and re-cache them in the obj cache map
let tip_object_paths: Vec<PathBuf> = args
.link_args
.iter()
.filter(|arg| arg.ends_with(".rcgu.o"))
.map(PathBuf::from)
.collect();
if !tip_object_paths.is_empty() {
artifacts
.object_cache
.cache_from_paths(&tip_name, &tip_object_paths)
.context("Failed to cache objs during patch")?;
}
// Collect cached object paths from all modified dep crates.
// Objects are already on disk in the object cache directory.
// These must NOT be deleted after linking — they persist across patches.
let mut cached_objects: Vec<PathBuf> = Vec::new();
for dep_name in modified_crates.iter().filter(|c| *c != &tip_name) {
if let Some(paths) = artifacts.object_cache.get(dep_name) {
cached_objects.extend(paths.iter().cloned());
}
}
// If the tip has a lib target (lib+bin crate), include its cached objects too.
let lib_key = format!("{tip_name}.lib");
if let Some(paths) = artifacts.object_cache.get(&lib_key) {
cached_objects.extend(paths.iter().cloned());
}
// Extract out the incremental object files. // Extract out the incremental object files.
// //
@@ -1698,12 +1924,21 @@ impl BuildRequest {
// -Wl,-all_load // -Wl,-all_load
// ``` // ```
let mut dylibs = vec![]; let mut dylibs = vec![];
let mut object_files = args
// Tip objects from link_args are temps — safe to delete after linking.
let temp_objects: Vec<PathBuf> = args
.link_args
.iter() .iter()
.filter(|arg| arg.ends_with(".rcgu.o")) .filter(|arg| arg.ends_with(".rcgu.o"))
.sorted() .sorted()
.map(PathBuf::from) .map(PathBuf::from)
.collect::<Vec<_>>(); .collect();
// Merge both sets for the linker.
let mut object_files: Vec<PathBuf> =
Vec::with_capacity(cached_objects.len() + temp_objects.len());
object_files.append(&mut cached_objects);
object_files.extend(temp_objects.iter().cloned());
// On non-wasm platforms, we generate a special shim object file which converts symbols from // On non-wasm platforms, we generate a special shim object file which converts symbols from
// fat binary into direct addresses from the running process. // fat binary into direct addresses from the running process.
@@ -1734,7 +1969,7 @@ impl BuildRequest {
// Add the dylibs/sos to the linker args // Add the dylibs/sos to the linker args
// Make sure to use the one in the bundle, not the ones in the target dir or system. // Make sure to use the one in the bundle, not the ones in the target dir or system.
for arg in &rustc_args.link_args { for arg in &args.link_args {
if arg.ends_with(".dylib") || arg.ends_with(".so") { if arg.ends_with(".dylib") || arg.ends_with(".so") {
let path = PathBuf::from(arg); let path = PathBuf::from(arg);
dylibs.push(self.frameworks_folder().join(path.file_name().unwrap())); dylibs.push(self.frameworks_folder().join(path.file_name().unwrap()));
@@ -1755,7 +1990,7 @@ impl BuildRequest {
let mut out_args: Vec<OsString> = vec![]; let mut out_args: Vec<OsString> = vec![];
out_args.extend(object_files.iter().map(Into::into)); out_args.extend(object_files.iter().map(Into::into));
out_args.extend(dylibs.iter().map(Into::into)); out_args.extend(dylibs.iter().map(Into::into));
out_args.extend(self.thin_link_args(&args)?.iter().map(Into::into)); out_args.extend(self.thin_link_args(&args.link_args)?.iter().map(Into::into));
out_args.extend(out_arg.iter().map(Into::into)); out_args.extend(out_arg.iter().map(Into::into));
if cfg!(windows) { if cfg!(windows) {
@@ -1769,7 +2004,7 @@ impl BuildRequest {
} }
// Add more search paths for the linker // Add more search paths for the linker
let mut command_envs = rustc_args.envs.clone(); let mut command_envs = args.envs.clone();
// On linux, we need to set a more complete PATH for the linker to find its libraries // On linux, we need to set a more complete PATH for the linker to find its libraries
if cfg!(target_os = "linux") { if cfg!(target_os = "linux") {
@@ -1808,8 +2043,8 @@ impl BuildRequest {
// //
// Fortunately, this binary exists in two places - the deps dir and the target out dir. We // Fortunately, this binary exists in two places - the deps dir and the target out dir. We
// can just remove the one in the deps dir and the problem goes away. // can just remove the one in the deps dir and the problem goes away.
if let Some(idx) = args.iter().position(|arg| *arg == "-o") { if let Some(idx) = args.link_args.iter().position(|arg| *arg == "-o") {
_ = std::fs::remove_file(PathBuf::from(args[idx + 1].as_str())); _ = std::fs::remove_file(PathBuf::from(args.link_args[idx + 1].as_str()));
} }
// Now extract the assets from the fat binary // Now extract the assets from the fat binary
@@ -1821,9 +2056,9 @@ impl BuildRequest {
self.write_index_html(&artifacts.assets) self.write_index_html(&artifacts.assets)
.context("Failed to write index.html")?; .context("Failed to write index.html")?;
// Clean up the temps manually // Clean up temp object files (tip incremental objects + stub.o).
// todo: we might want to keep them around for debugging purposes // Cached dep objects in object_cache/ are NOT deleted — they persist across patches.
for file in object_files { for file in &temp_objects {
_ = std::fs::remove_file(file); _ = std::fs::remove_file(file);
} }
@@ -2509,7 +2744,14 @@ impl BuildRequest {
// ends up doing some recursive nonsense and dx is trying to link instead of compiling. // ends up doing some recursive nonsense and dx is trying to link instead of compiling.
// //
// todo: maybe rustc needs to be found on the FS instead of using the one in the path? // todo: maybe rustc needs to be found on the FS instead of using the one in the path?
BuildMode::Thin { rustc_args, .. } => { BuildMode::Thin {
workspace_rustc_args,
..
} => {
let rustc_args = workspace_rustc_args
.get(&format!("{}.bin", self.tip_crate_name()))
.context("Missing rustc args for tip crate")?;
let mut cmd = Command::new("rustc"); let mut cmd = Command::new("rustc");
cmd.current_dir(self.workspace_dir()); cmd.current_dir(self.workspace_dir());
cmd.env_clear(); cmd.env_clear();
@@ -2533,13 +2775,11 @@ impl BuildRequest {
Ok(cmd) Ok(cmd)
} }
// For Base and Fat builds, we use a regular cargo setup, but we might need to intercept // For Base and Fat builds, we use a regular cargo setup, but we intercept rustc for
// rustc itself in case we're hot-patching and need a reliable rustc environment to // workspace member crates to capture their args/envs for hot-patching.
// continuously recompile the top-level crate with.
// //
// In the future, when we support hot-patching *all* workspace crates, we will need to // We use RUSTC_WORKSPACE_WRAPPER which wraps only workspace member crates, letting us
// make use of the RUSTC_WORKSPACE_WRAPPER environment variable instead of RUSTC_WRAPPER // capture per-crate args without interfering with external dependency compilation.
// and then keep track of env and args on a per-crate basis.
// //
// We've also had a number of issues with incorrect canonicalization when passing paths // We've also had a number of issues with incorrect canonicalization when passing paths
// through envs on windows, hence the frequent use of dunce::canonicalize. // through envs on windows, hence the frequent use of dunce::canonicalize.
@@ -2566,15 +2806,18 @@ impl BuildRequest {
.envs(env.iter().map(|(k, v)| (k.as_ref(), v))); .envs(env.iter().map(|(k, v)| (k.as_ref(), v)));
if matches!(build_mode, BuildMode::Fat | BuildMode::Base { run: true }) { if matches!(build_mode, BuildMode::Fat | BuildMode::Base { run: true }) {
let args_dir = self.rustc_wrapper_args_dir();
std::fs::create_dir_all(&args_dir)
.context("Failed to create rustc wrapper args directory")?;
cmd.env( cmd.env(
DX_RUSTC_WRAPPER_ENV_VAR, DX_RUSTC_WRAPPER_ENV_VAR,
dunce::canonicalize(self.rustc_wrapper_args_file()) dunce::canonicalize(&args_dir)
.context("Failed to canonicalize rustc wrapper args file")? .context("Failed to canonicalize rustc wrapper args dir")?
.display() .display()
.to_string(), .to_string(),
); );
cmd.env( cmd.env(
"RUSTC_WRAPPER", "RUSTC_WORKSPACE_WRAPPER",
Workspace::path_to_dx()?.display().to_string(), Workspace::path_to_dx()?.display().to_string(),
); );
} }
@@ -3488,8 +3731,13 @@ impl BuildRequest {
self.session_cache_dir.join(self.bundle.to_string()) self.session_cache_dir.join(self.bundle.to_string())
} }
pub(crate) fn rustc_wrapper_args_file(&self) -> PathBuf { pub(crate) fn rustc_wrapper_args_dir(&self) -> PathBuf {
self.session_cache_dir().join("rustc_wrapper_args.txt") self.session_cache_dir().join("rustc_wrapper_args")
}
/// The crate name that rustc uses for the tip crate (hyphens replaced with underscores).
fn tip_crate_name(&self) -> String {
self.main_target.replace('-', "_")
} }
fn link_err_file(&self) -> PathBuf { fn link_err_file(&self) -> PathBuf {
@@ -4772,38 +5020,202 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
/// Blow away the fingerprint for this package, forcing rustc to recompile it. /// Blow away the fingerprint for this package, forcing rustc to recompile it.
/// ///
/// This prevents rustc from using the cached version of the binary, which can cause issues /// This prevents rustc from using the cached version of the binary, which can cause issues
/// Find workspace crates that directly depend on the given crate.
///
/// Returns underscore-normalized crate names of workspace members that have `crate_name`
/// as a dependency. Used for cascade detection — when a dep's public symbols change,
/// its dependents need recompilation too.
pub(crate) fn workspace_dependents_of(&self, crate_name: &str) -> Vec<String> {
let krates = &self.workspace.krates;
// Find the NodeId for the target crate
let target_nid = krates.workspace_members().find_map(|member| {
if let krates::Node::Krate { id, krate, .. } = member {
if krate.name.replace('-', "_") == crate_name {
return krates.nid_for_kid(id);
}
}
None
});
let Some(target_nid) = target_nid else {
return Vec::new();
};
// Use krates' direct_dependents to find reverse deps, filter to workspace members
let workspace_names: HashSet<String> = krates
.workspace_members()
.filter_map(|m| {
if let krates::Node::Krate { krate, .. } = m {
Some(krate.name.replace('-', "_"))
} else {
None
}
})
.collect();
krates
.direct_dependents(target_nid)
.into_iter()
.filter_map(|dep| {
let name = dep.krate.name.replace('-', "_");
if workspace_names.contains(&name) {
Some(name)
} else {
None
}
})
.collect()
}
/// Compile a workspace dependency crate directly with `rustc` using its captured args.
///
/// This produces an updated rlib at the same path cargo originally wrote to.
/// Used during thin builds to recompile changed workspace deps before the tip crate.
async fn compile_dep_crate(&self, crate_name: &str, rustc_args: &RustcArgs) -> Result<()> {
let mut cmd = Command::new("rustc");
cmd.current_dir(self.workspace_dir());
cmd.env_clear();
// Skip args[0] which is the rustc binary path captured by the wrapper
cmd.args(rustc_args.args[1..].iter());
// Restore the captured environment, filtering out wrapper env vars and
// stale cargo jobserver vars to prevent recursive invocation and warnings.
let filtered_env_keys = [
"RUSTC_WORKSPACE_WRAPPER",
"RUSTC_WRAPPER",
DX_RUSTC_WRAPPER_ENV_VAR,
"CARGO_MAKEFLAGS",
"MAKEFLAGS",
];
cmd.envs(
rustc_args
.envs
.iter()
.filter(|(k, _)| !filtered_env_keys.contains(&k.as_str()))
.cloned(),
);
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to compile workspace dep crate '{crate_name}':\n{stderr}");
}
Ok(())
}
/// Find the rlib path for a workspace crate from its captured rustc args.
///
/// Extracts `--out-dir` and `-C extra-filename` from the args to construct the exact
/// rlib filename. This is important because multiple rlibs for the same crate can coexist
/// in the deps directory (e.g., from different dx builds that produce different `-C metadata`),
/// and globbing would return an arbitrary one.
fn find_rlib_for_crate(&self, crate_name: &str, rustc_args: &RustcArgs) -> Option<PathBuf> {
// Extract --out-dir from the captured args
let out_dir = rustc_args
.args
.iter()
.zip(rustc_args.args.iter().skip(1))
.find(|(flag, _)| *flag == "--out-dir")
.map(|(_, dir)| PathBuf::from(dir))?;
// Extract -C extra-filename from captured args.
// Cargo passes this to rustc to disambiguate output filenames via metadata hash.
// Handle all forms: `-Cextra-filename=X`, `-C extra-filename=X`, and `-C` `extra-filename=X`.
let extra_filename = rustc_args.args.iter().enumerate().find_map(|(i, arg)| {
arg.strip_prefix("-Cextra-filename=")
.map(|s| s.to_string())
.or_else(|| {
// Handle `-C` followed by `extra-filename=X` as separate args
if arg == "-C" {
rustc_args.args.get(i + 1).and_then(|next| {
next.strip_prefix("extra-filename=").map(|s| s.to_string())
})
} else {
None
}
})
});
// If we have an exact extra-filename, construct the precise rlib path.
if let Some(extra) = &extra_filename {
let exact = out_dir.join(format!("lib{crate_name}{extra}.rlib"));
if exact.exists() {
return Some(exact);
}
}
// Fallback: glob for lib<crate_name>-<hash>.rlib in the output directory.
// This handles cases where -C extra-filename isn't in the captured args.
// Prefer the most recently modified rlib to avoid picking up stale artifacts.
let prefix = format!("lib{crate_name}-");
let entries = std::fs::read_dir(&out_dir).ok()?;
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
if name.starts_with(&prefix) && name.ends_with(".rlib") {
let mtime = entry.metadata().ok()?.modified().ok()?;
if best.as_ref().map_or(true, |(_, t)| mtime > *t) {
best = Some((entry.path(), mtime));
}
}
}
}
if let Some((path, _)) = best {
return Some(path);
}
None
}
/// with our hotpatching setup since it uses linker interception. /// with our hotpatching setup since it uses linker interception.
/// ///
/// This is sadly a hack. I think there might be other ways of busting the fingerprint (rustc wrapper?) /// This is sadly a hack. I think there might be other ways of busting the fingerprint (rustc wrapper?)
/// but that would require relying on cargo internals. /// but that would require relying on cargo internals.
/// ///
/// This might stop working if/when cargo stabilizes contents-based fingerprinting. /// This might stop working if/when cargo stabilizes contents-based fingerprinting.
///
/// `dx` compiles everything with `--target` which ends up with a structure like:
/// `target/<triple>/<profile>/.fingerprint/<package_name>-<hash>`
///
/// Normally you can't rely on this structure (ie with `cargo build`) but the explicit
/// target arg guarantees this will work.
fn bust_fingerprint(&self, ctx: &BuildContext) -> Result<()> { fn bust_fingerprint(&self, ctx: &BuildContext) -> Result<()> {
if matches!(ctx.mode, BuildMode::Fat) { if matches!(ctx.mode, BuildMode::Fat) {
// `dx` compiles everything with `--target` which ends up with a structure like:
// target/<triple>/<profile>/.fingerprint/<package_name>-<hash>
//
// normally you can't rely on this structure (ie with `cargo build`) but the explicit
// target arg guarantees this will work.
let fingerprint_dir = self let fingerprint_dir = self
.target_dir .target_dir
.join(self.triple.to_string()) .join(self.triple.to_string())
.join(&self.profile) .join(&self.profile)
.join(".fingerprint"); .join(".fingerprint");
// Bust fingerprints for ALL workspace member crates during Fat builds.
// This ensures cargo recompiles them through RUSTC_WORKSPACE_WRAPPER
// so we capture their rustc args for later thin builds.
let mut busted = HashSet::new();
for member in self.workspace.krates.workspace_members() {
if let krates::Node::Krate { krate, .. } = member {
busted.insert(krate.name.as_str());
}
}
// split at the last `-` used to separate the hash from the name // split at the last `-` used to separate the hash from the name
// This causes to more aggressively bust hashes for all combinations of features // This causes to more aggressively bust hashes for all combinations of features
// and fingerprints for this package since we're just ignoring the hash // and fingerprints for this package since we're just ignoring the hash
for entry in std::fs::read_dir(&fingerprint_dir)?.flatten() { if let Ok(entries) = std::fs::read_dir(&fingerprint_dir) {
for entry in entries.flatten() {
if let Some(fname) = entry.file_name().to_str() { if let Some(fname) = entry.file_name().to_str() {
if let Some((name, _)) = fname.rsplit_once('-') { if let Some((name, _)) = fname.rsplit_once('-') {
if name == self.package().name { if busted.contains(name) {
_ = std::fs::remove_dir_all(entry.path()); _ = std::fs::remove_dir_all(entry.path());
} }
} }
} }
} }
} }
}
Ok(()) Ok(())
} }
+15 -3
View File
@@ -209,12 +209,24 @@ impl CommandWithPlatformOverrides<BuildArgs> {
impl BuildArtifacts { impl BuildArtifacts {
pub(crate) fn into_structured_output(self) -> StructuredBuildArtifacts { pub(crate) fn into_structured_output(self) -> StructuredBuildArtifacts {
// Extract the tip crate's args for the structured output.
// The tip crate is identified by replacing hyphens with underscores in the target name,
// but since we don't have the BuildRequest here, we look for the entry with link_args
// (only the tip crate has link_args attached) or fall back to any entry.
let tip_args = self
.workspace_rustc_args
.values()
.find(|a| !a.link_args.is_empty())
.or_else(|| self.workspace_rustc_args.values().next())
.cloned()
.unwrap_or_default();
StructuredBuildArtifacts { StructuredBuildArtifacts {
path: self.root_dir, path: self.root_dir,
exe: self.exe, exe: self.exe,
rustc_args: self.direct_rustc.args, rustc_args: tip_args.args,
rustc_envs: self.direct_rustc.envs, rustc_envs: tip_args.envs,
link_args: self.direct_rustc.link_args, link_args: tip_args.link_args,
assets: self.assets.unique_assets().cloned().collect(), assets: self.assets.unique_assets().cloned().collect(),
} }
} }
+13 -3
View File
@@ -5,8 +5,8 @@ use crate::{
use anyhow::Context; use anyhow::Context;
use clap::Parser; use clap::Parser;
use dioxus_dx_wire_format::StructuredBuildArtifacts; use dioxus_dx_wire_format::StructuredBuildArtifacts;
use std::io::Read;
use std::sync::Arc; use std::sync::Arc;
use std::{collections::HashMap, io::Read};
const HELP_HEADING: &str = "Hotpatching a binary"; const HELP_HEADING: &str = "Hotpatching a binary";
@@ -70,15 +70,25 @@ impl HotpatchTip {
// consider a shared-mem approach or a binary serializer? something like arrow / parquet / bincode? // consider a shared-mem approach or a binary serializer? something like arrow / parquet / bincode?
let cache = Arc::new(HotpatchModuleCache::new(&exe, &request.triple)?); let cache = Arc::new(HotpatchModuleCache::new(&exe, &request.triple)?);
let mode = BuildMode::Thin { let tip_crate_name = request.main_target.replace('-', "_");
rustc_args: crate::RustcArgs { let mut workspace_rustc_args = HashMap::new();
workspace_rustc_args.insert(
format!("{tip_crate_name}.bin"),
crate::RustcArgs {
args: rustc_args, args: rustc_args,
envs: rustc_envs, envs: rustc_envs,
link_args, link_args,
}, },
);
let mode = BuildMode::Thin {
workspace_rustc_args,
changed_files: vec![], changed_files: vec![],
changed_crates: vec![],
modified_crates: std::collections::HashSet::new(),
aslr_reference: self.aslr_reference, aslr_reference: self.aslr_reference,
cache: cache.clone(), cache: cache.clone(),
object_cache: crate::ObjectCache::new(&request.session_cache_dir()),
}; };
let artifacts = AppBuilder::started(request, mode, build_id)? let artifacts = AppBuilder::started(request, mode, build_id)?
+37 -17
View File
@@ -5,9 +5,10 @@ use std::{
process::ExitCode, process::ExitCode,
}; };
/// The environment variable indicating where the args file is located. /// The environment variable indicating where the args directory is located.
/// ///
/// When `dx-rustc` runs, it writes its arguments to this file. /// When `dx-rustc` runs, it writes each workspace crate's arguments to a
/// separate file in this directory: `{dir}/{crate_name}.json`.
pub const DX_RUSTC_WRAPPER_ENV_VAR: &str = "DX_RUSTC"; pub const DX_RUSTC_WRAPPER_ENV_VAR: &str = "DX_RUSTC";
/// Is `dx` being used as a rustc wrapper? /// Is `dx` being used as a rustc wrapper?
@@ -64,7 +65,7 @@ fn has_linking_args() -> bool {
false false
} }
/// Run rustc directly, but output the result to a file. /// Run rustc directly, but output the result to a per-crate file in the args directory.
/// ///
/// <https://doc.rust-lang.org/cargo/reference/config.html#buildrustc> /// <https://doc.rust-lang.org/cargo/reference/config.html#buildrustc>
pub fn run_rustc() -> ExitCode { pub fn run_rustc() -> ExitCode {
@@ -75,11 +76,11 @@ pub fn run_rustc() -> ExitCode {
.run_link(); .run_link();
} }
let var_file: PathBuf = std::env::var(DX_RUSTC_WRAPPER_ENV_VAR) let args_dir: PathBuf = std::env::var(DX_RUSTC_WRAPPER_ENV_VAR)
.expect("DX_RUSTC env var must be set") .expect("DX_RUSTC env var must be set")
.into(); .into();
// Cargo invokes a wrapper like: `wrapper-name rustc [args...]` // Cargo invokes a workspace wrapper like: `wrapper-name rustc [args...]`
// We skip our own executable name (`wrapper-name`) to get the args passed to us. // We skip our own executable name (`wrapper-name`) to get the args passed to us.
let captured_args = args().skip(1).collect::<Vec<_>>(); let captured_args = args().skip(1).collect::<Vec<_>>();
@@ -89,32 +90,51 @@ pub fn run_rustc() -> ExitCode {
link_args: Default::default(), link_args: Default::default(),
}; };
// Another terrible hack to avoid caching non-sensical args when // Extract the crate name from the args to use as the filename.
// a build is completely fresh (rustc is invoked with --crate-name ___) // Skip non-sensical args when a build is completely fresh (rustc is invoked with --crate-name ___)
if rustc_args let crate_name = rustc_args
.args .args
.iter() .iter()
.skip_while(|arg| *arg != "--crate-name") .skip_while(|arg| *arg != "--crate-name")
.nth(1);
if let Some(crate_name) = crate_name {
if crate_name != "___" {
std::fs::create_dir_all(&args_dir)
.expect("Failed to create args directory for rustc wrapper");
let crate_type = rustc_args
.args
.iter()
.skip_while(|arg| *arg != "--crate-type")
.nth(1) .nth(1)
.is_some_and(|name| name != "___") .map(|s| s.as_str());
{
let parent_dir = var_file
.parent()
.expect("Args file path has no parent directory");
std::fs::create_dir_all(parent_dir)
.expect("Failed to create parent directory for args file");
let serialized_args = let serialized_args =
serde_json::to_string(&rustc_args).expect("Failed to serialize rustc args"); serde_json::to_string(&rustc_args).expect("Failed to serialize rustc args");
std::fs::write(&var_file, serialized_args).expect("Failed to write rustc args to file"); // Write args with an explicit target suffix: {crate_name}.lib.json or
// {crate_name}.bin.json. This avoids the ambiguity of a bare {crate_name}.json
// and ensures lib+bin crates don't overwrite each other.
let suffix = match crate_type {
Some("lib" | "rlib") => "lib",
Some("bin") => "bin",
_ => "bin", // proc-macro, cdylib, etc. — treat as bin
};
std::fs::write(
args_dir.join(format!("{crate_name}.{suffix}.json")),
&serialized_args,
)
.expect("Failed to write rustc args to file");
}
} }
// Run the actual rustc command. // Run the actual rustc command.
// We want all stdout/stderr to be inherited, so the user sees the compiler output. // We want all stdout/stderr to be inherited, so the user sees the compiler output.
let mut cmd = std::process::Command::new("rustc"); let mut cmd = std::process::Command::new("rustc");
// The first argument in `captured_args` is "rustc", which we need to skip // The first argument in `captured_args` is the rustc path, which we need to skip
// when passing arguments to the `rustc` command we are spawning. // when passing arguments to the `rustc` command we are spawning.
cmd.args(captured_args.iter().skip(1)); cmd.args(captured_args.iter().skip(1));
cmd.envs(rustc_args.envs); cmd.envs(rustc_args.envs);
+154 -4
View File
@@ -22,9 +22,9 @@ use notify::{
Config, EventKind, RecursiveMode, Watcher as NotifyWatcher, Config, EventKind, RecursiveMode, Watcher as NotifyWatcher,
}; };
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet, VecDeque},
net::{IpAddr, TcpListener}, net::{IpAddr, TcpListener},
path::PathBuf, path::{Path, PathBuf},
sync::Arc, sync::Arc,
time::Duration, time::Duration,
}; };
@@ -511,9 +511,13 @@ impl AppServer {
// A full rebuild is required when the user modifies static initializers which we haven't wired up yet. // A full rebuild is required when the user modifies static initializers which we haven't wired up yet.
if needs_full_rebuild && self.automatic_rebuilds { if needs_full_rebuild && self.automatic_rebuilds {
if self.use_hotpatch_engine { if self.use_hotpatch_engine {
self.client.patch_rebuild(files.to_vec(), BuildId::PRIMARY); let changed_crates = self.order_changed_crates(files);
self.client
.patch_rebuild(files.to_vec(), changed_crates.clone(), BuildId::PRIMARY);
if let Some(server) = self.server.as_mut() { if let Some(server) = self.server.as_mut() {
server.patch_rebuild(files.to_vec(), BuildId::SECONDARY); server.patch_rebuild(files.to_vec(), changed_crates, BuildId::SECONDARY);
} }
self.clear_hot_reload_changes(); self.clear_hot_reload_changes();
self.clear_cached_rsx(); self.clear_cached_rsx();
@@ -1163,6 +1167,152 @@ impl AppServer {
krates krates
} }
/// Compute the ordered compilation chain from a changed workspace crate to the tip crate.
///
/// Returns crate names (underscore-normalized) in compilation order: the changed crate first,
/// then each intermediate workspace crate that depends on it, ending with the tip crate.
///
/// Uses BFS from the tip crate through its workspace dependencies to find the path.
/// If the changed crate IS the tip crate, returns just `[tip]`.
pub(crate) fn workspace_dep_chain(&self, changed_crate: &str) -> Vec<String> {
let tip_name = self.client.build.main_target.replace('-', "_");
// If the changed crate is the tip, no chain needed
if changed_crate == tip_name {
return vec![tip_name];
}
// Build a map of workspace crate names to their krates NodeIds
let mut name_to_node: HashMap<String, NodeId> = HashMap::new();
for member in self.workspace.krates.workspace_members() {
if let krates::Node::Krate { id, krate, .. } = member {
let normalized = krate.name.replace('-', "_");
name_to_node.insert(normalized, self.workspace.krates.nid_for_kid(id).unwrap());
}
}
// BFS/DFS from tip through workspace deps to find path to changed crate.
// We walk the dependency edges (tip → its deps → their deps → ...) looking for changed_crate.
let Some(&tip_node) = name_to_node.get(&tip_name) else {
return vec![changed_crate.to_string()];
};
// parent[node] = the workspace crate that depends on it (closer to tip)
let mut parent: HashMap<NodeId, Option<NodeId>> = HashMap::new();
parent.insert(tip_node, None);
let mut queue = VecDeque::new();
queue.push_back(tip_node);
let mut target_node = None;
while let Some(current) = queue.pop_front() {
for (dep, _edge) in self.workspace.krates.get_deps(current) {
let (dep_name, dep_nid) = match dep {
krates::Node::Krate { id, krate, .. } => {
let normalized = krate.name.replace('-', "_");
let nid = self.workspace.krates.nid_for_kid(id).unwrap();
(normalized, nid)
}
_ => continue,
};
// Only traverse workspace members
if !name_to_node.contains_key(&dep_name) {
continue;
}
if parent.contains_key(&dep_nid) {
continue; // already visited
}
parent.insert(dep_nid, Some(current));
if dep_name == changed_crate {
target_node = Some(dep_nid);
break;
}
queue.push_back(dep_nid);
}
if target_node.is_some() {
break;
}
}
// Reconstruct the path from changed_crate → ... → tip
let Some(target) = target_node else {
// Changed crate not found in workspace dep graph — just compile it alone
return vec![changed_crate.to_string()];
};
let mut chain = vec![];
let mut node = target;
loop {
// Find the crate name for this node
let krate = &self.workspace.krates[node];
chain.push(krate.name.replace('-', "_"));
match parent.get(&node) {
Some(Some(parent_node)) => node = *parent_node,
_ => break,
}
}
chain
}
/// Order a set of changed workspace crates so that deeper dependencies compile first.
///
/// Uses `workspace_dep_chain` to determine the depth of each crate in the dependency graph,
/// then sorts so that leaves (deepest deps) compile before crates closer to the tip.
fn order_changed_crates(&self, files: &[PathBuf]) -> Vec<String> {
// Determine which workspace crates changed based on the file paths.
// Order them so deeper deps compile first (leaves before dependents).
let changed_set: HashSet<String> = files
.iter()
.filter_map(|f| self.file_to_workspace_crate(f))
.collect();
let mut crates_with_depth: Vec<_> = changed_set
.iter()
.map(|c| (c.clone(), self.workspace_dep_chain(c).len()))
.collect();
// Longer chain = deeper in dep tree = should compile first
crates_with_depth.sort_by(|a, b| b.1.cmp(&a.1));
crates_with_depth.into_iter().map(|(c, _)| c).collect()
}
/// Map a changed file path to the workspace crate it belongs to.
///
/// Returns the crate name in rustc convention (hyphens → underscores), matching the
/// `--crate-name` arg used by rustc and the keys in `workspace_rustc_args`.
///
/// Finds the workspace member whose crate directory is the longest prefix of the file path.
fn file_to_workspace_crate(&self, file: &Path) -> Option<String> {
let mut best_match: Option<(String, usize)> = None;
for member in self.workspace.krates.workspace_members() {
if let krates::Node::Krate { krate, .. } = member {
let Some(crate_dir) = krate.manifest_path.parent() else {
continue;
};
if let Ok(relative) = file.strip_prefix(crate_dir.as_std_path()) {
let depth = relative.components().count();
let is_better = best_match
.as_ref()
.map_or(true, |(_, best_depth)| depth < *best_depth);
if is_better {
best_match = Some((krate.name.replace('-', "_"), depth));
}
}
}
}
best_match.map(|(name, _)| name)
}
/// Check if this is a fullstack build. This means that there is an additional build with the `server` platform. /// Check if this is a fullstack build. This means that there is an additional build with the `server` platform.
pub(crate) fn is_fullstack(&self) -> bool { pub(crate) fn is_fullstack(&self) -> bool {
self.fullstack self.fullstack