Skip to main content

incular_core/
hot_reload.rs

1//! Hot-patch call points for development builds.
2//!
3//! A retained closure keeps running the code it was compiled with, so a
4//! framework that type-erases an application callback once and calls it for
5//! the lifetime of a window would never observe a patch. Calling the callback
6//! through [`call`] or [`call_with`] instead resolves its newest body on every
7//! invocation. Without the `hot-reload` feature, and in builds without debug
8//! assertions, both functions are plain calls.
9//!
10//! A patch replaces code, never data: the callback's captured values keep the
11//! layout they were created with. Changing what a hot callback captures
12//! requires a restart.
13
14/// Calls `callback`, using its newest patched body when one is loaded.
15#[inline]
16pub fn call<F, R>(callback: &mut F) -> R
17where
18    F: FnMut() -> R,
19{
20    #[cfg(feature = "hot-reload")]
21    {
22        // A zero-sized function item keeps the lookup on the monomorphized
23        // entry point, which is emitted in the application crate whenever `F`
24        // is an application type and is therefore part of every patch.
25        fn entry<F, R>(callback: &mut F) -> R
26        where
27            F: FnMut() -> R,
28        {
29            callback()
30        }
31        subsecond::HotFn::current(entry::<F, R>).call((callback,))
32    }
33    #[cfg(not(feature = "hot-reload"))]
34    {
35        callback()
36    }
37}
38
39/// Calls `callback` with `argument`, using its newest patched body when one
40/// is loaded.
41#[inline]
42pub fn call_with<F, A, R>(callback: &mut F, argument: A) -> R
43where
44    F: FnMut(A) -> R,
45{
46    #[cfg(feature = "hot-reload")]
47    {
48        fn entry<F, A, R>(callback: &mut F, argument: A) -> R
49        where
50            F: FnMut(A) -> R,
51        {
52            callback(argument)
53        }
54        subsecond::HotFn::current(entry::<F, A, R>).call((callback, argument))
55    }
56    #[cfg(not(feature = "hot-reload"))]
57    {
58        callback(argument)
59    }
60}
61
62/// Address of the executable's entry symbol in this process. A build driver
63/// subtracts it from the address it recorded at link time to undo address
64/// space layout randomization.
65#[cfg(feature = "hot-reload")]
66#[must_use]
67pub fn aslr_reference() -> u64 {
68    subsecond::aslr_reference() as u64
69}
70
71/// A compiled patch produced by the build driver for one running process.
72#[cfg(feature = "hot-reload")]
73#[derive(Clone, Debug, serde::Deserialize)]
74#[serde(transparent)]
75pub struct HotPatch(subsecond::JumpTable);
76
77#[cfg(feature = "hot-reload")]
78impl HotPatch {
79    /// Number of functions this patch redirects.
80    #[must_use]
81    pub fn function_count(&self) -> usize {
82        self.0.map.len()
83    }
84
85    /// Loads the patch library and redirects later [`call`] and [`call_with`]
86    /// invocations to it. The library stays loaded for the life of the
87    /// process, so closures created by earlier code remain callable.
88    ///
89    /// # Safety
90    ///
91    /// The patch must have been built against the executable image of this
92    /// exact process. A patch for another build redirects calls to unrelated
93    /// addresses.
94    pub unsafe fn apply(self) -> Result<(), HotPatchError> {
95        #[cfg(all(windows, target_arch = "x86_64"))]
96        let table = {
97            let mut table = self.0;
98            table.lib = library_with_safe_trampolines(&table.lib)?;
99            table
100        };
101        #[cfg(not(all(windows, target_arch = "x86_64")))]
102        let table = self.0;
103        // SAFETY: the caller guarantees the jump table describes this process.
104        unsafe { subsecond::apply_patch(table) }.map_err(|error| HotPatchError(error.to_string()))
105    }
106}
107
108#[cfg(feature = "hot-reload")]
109const PE_MACHINE_X86_64: u16 = 0x8664;
110#[cfg(feature = "hot-reload")]
111const PE_SECTION_EXECUTE: u32 = 0x2000_0000;
112#[cfg(feature = "hot-reload")]
113const PE_SECTION_HEADER_SIZE: usize = 40;
114
115/// Rewrites the trampolines of a Windows x86-64 patch library so they no
116/// longer clobber `RAX`, returning how many were rewritten.
117///
118/// The build driver reaches functions of the original executable through
119/// `mov rax, <address>; jmp rax`. That is harmless for ordinary calls, but
120/// the stack probe `__chkstk`, which every function with more than a page of
121/// locals calls on entry, takes its size argument in `RAX`: through such a
122/// trampoline it probes a garbage amount of stack and the process dies with
123/// a stack overflow. `R11` is scratch at every call boundary and is no
124/// function's argument, so the trampolines are re-encoded as
125/// `mov r11, <address>; jmp r11` in the padding they already own.
126///
127/// Images for another machine, images that are not PE files and trampolines
128/// in any other shape are left untouched.
129#[cfg(feature = "hot-reload")]
130pub fn retarget_trampolines(image: &mut [u8]) -> usize {
131    let mut rewritten = 0;
132    for (start, end) in executable_sections(image) {
133        let mut offset = start;
134        // `48 B8 imm64` `FF E0` `pad`  ->  `49 BB imm64` `41 FF E3`
135        while offset + 13 <= end {
136            let code = &mut image[offset..offset + 13];
137            if code[..2] == [0x48, 0xB8]
138                && code[10..12] == [0xFF, 0xE0]
139                && matches!(code[12], 0x00 | 0xCC)
140            {
141                code[..2].copy_from_slice(&[0x49, 0xBB]);
142                code[10..].copy_from_slice(&[0x41, 0xFF, 0xE3]);
143                rewritten += 1;
144                offset += 13;
145            } else {
146                offset += 1;
147            }
148        }
149    }
150    rewritten
151}
152
153/// File ranges of the executable sections of an x86-64 PE image.
154#[cfg(feature = "hot-reload")]
155fn executable_sections(image: &[u8]) -> Vec<(usize, usize)> {
156    let u16_at = |offset: usize| {
157        let bytes = image.get(offset..offset.checked_add(2)?)?;
158        Some(u16::from_le_bytes([bytes[0], bytes[1]]))
159    };
160    let u32_at = |offset: usize| {
161        let bytes = image.get(offset..offset.checked_add(4)?)?;
162        Some(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
163    };
164    let sections = || {
165        let header = usize::try_from(u32_at(0x3C)?).ok()?;
166        if image.get(header..header.checked_add(4)?)? != b"PE\0\0" {
167            return None;
168        }
169        let coff = header + 4;
170        if u16_at(coff)? != PE_MACHINE_X86_64 {
171            return None;
172        }
173        let count = usize::from(u16_at(coff + 2)?);
174        let table = coff + 20 + usize::from(u16_at(coff + 16)?);
175        let mut ranges = Vec::new();
176        for index in 0..count {
177            let section = table + index * PE_SECTION_HEADER_SIZE;
178            let size = usize::try_from(u32_at(section + 16)?).ok()?;
179            let start = usize::try_from(u32_at(section + 20)?).ok()?;
180            if u32_at(section + 36)? & PE_SECTION_EXECUTE != 0 {
181                ranges.push((
182                    start.min(image.len()),
183                    start.saturating_add(size).min(image.len()),
184                ));
185            }
186        }
187        Some(ranges)
188    };
189    sections().unwrap_or_default()
190}
191
192/// Writes a copy of the patch library with
193/// [retargeted trampolines](retarget_trampolines) beside it and returns the
194/// path to load. A library that needs no rewriting is loaded as it is.
195#[cfg(all(feature = "hot-reload", windows, target_arch = "x86_64"))]
196fn library_with_safe_trampolines(
197    library: &std::path::Path,
198) -> Result<std::path::PathBuf, HotPatchError> {
199    const SUFFIX: &str = "-incular.dll";
200    let error = |error: std::io::Error| HotPatchError(format!("{}: {error}", library.display()));
201    let mut image = std::fs::read(library).map_err(error)?;
202    if retarget_trampolines(&mut image) == 0 {
203        return Ok(library.to_path_buf());
204    }
205    let stem = library.file_stem().unwrap_or_default().to_string_lossy();
206    let repaired = library.with_file_name(format!("{stem}{SUFFIX}"));
207    // Copies from earlier runs are no longer mapped; ones this process still
208    // has loaded refuse deletion and are skipped.
209    if let Some(entries) = library.parent().and_then(|parent| parent.read_dir().ok()) {
210        for entry in entries.flatten() {
211            if entry.file_name().to_string_lossy().ends_with(SUFFIX) {
212                let _ = std::fs::remove_file(entry.path());
213            }
214        }
215    }
216    std::fs::write(&repaired, image).map_err(error)?;
217    Ok(repaired)
218}
219
220/// The patch library could not be loaded into the process.
221#[cfg(feature = "hot-reload")]
222#[derive(Clone, Debug, PartialEq, Eq)]
223pub struct HotPatchError(String);
224
225#[cfg(feature = "hot-reload")]
226impl std::fmt::Display for HotPatchError {
227    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        write!(formatter, "hot patch was not applied: {}", self.0)
229    }
230}
231
232#[cfg(feature = "hot-reload")]
233impl std::error::Error for HotPatchError {}