Skip to main content

ferroui_hot_patch/
supervisor.rs

1//! Admission is separate from patch loading: no devtools client applies unsolicited patches.
2use crate::{admit, Admission};
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    marker::PhantomData,
6    rc::Rc,
7};
8pub type SourceSnapshot = BTreeMap<String, String>;
9/// A snapshot includes every Rust source, manifest, lockfile, build script, and configuration input.
10/// Source bytes must come from the exact build that produced the running executable.
11pub struct PatchSupervisor {
12    baseline: SourceSnapshot,
13    registered: BTreeMap<String, BTreeSet<String>>,
14    view_builders: BTreeMap<(String, String), BTreeSet<String>>,
15    revision: u64,
16    accepted_table: Option<subsecond::JumpTable>,
17    restart_required: Option<String>,
18    identity: Rc<()>,
19    _ui_thread: PhantomData<Rc<()>>,
20}
21#[derive(Debug)]
22pub struct AdmittedPatch {
23    owner: Rc<()>,
24    baseline_revision: u64,
25    sources: SourceSnapshot,
26    pub functions: Vec<String>,
27    views: Vec<String>,
28}
29impl AdmittedPatch {
30    /// Only changed, explicitly registered builders contribute view identifiers.
31    /// Handler/command-only patches leave this empty.
32    pub fn views_to_reapply(&self) -> &[String] {
33        &self.views
34    }
35}
36impl PatchSupervisor {
37    pub fn new(baseline: SourceSnapshot, registered: BTreeMap<String, BTreeSet<String>>) -> Self {
38        Self {
39            baseline,
40            registered,
41            view_builders: BTreeMap::new(),
42            revision: 0,
43            accepted_table: None,
44            restart_required: None,
45            identity: Rc::new(()),
46            _ui_thread: PhantomData,
47        }
48    }
49    /// Validate explicit callback and builder registrations against the running
50    /// executable's source baseline. Registrations remain fixed for this process.
51    pub fn with_registrations(
52        baseline: SourceSnapshot,
53        registrations: impl IntoIterator<Item = crate::FunctionRegistration>,
54    ) -> Result<Self, String> {
55        let mut supervisor = Self::new(baseline, BTreeMap::new());
56        for registration in registrations {
57            let source = supervisor
58                .baseline
59                .get(&registration.source)
60                .ok_or_else(|| format!("Registered source is missing: {}", registration.source))?;
61            crate::admission::validate_registration(source, &registration.function)?;
62            if !supervisor
63                .registered
64                .entry(registration.source.clone())
65                .or_default()
66                .insert(registration.function.clone())
67            {
68                return Err(format!(
69                    "Duplicate function registration: {}::{}",
70                    registration.source, registration.function
71                ));
72            }
73            if let Some(views) = registration.views {
74                if views.is_empty() || views.iter().any(|view| view.trim().is_empty()) {
75                    return Err("A view builder requires nonempty view identifiers".into());
76                }
77                supervisor
78                    .view_builders
79                    .insert((registration.source, registration.function), views);
80            }
81        }
82        Ok(supervisor)
83    }
84    pub fn revision(&self) -> u64 {
85        self.revision
86    }
87    pub fn admit(&self, sources: SourceSnapshot) -> Result<AdmittedPatch, String> {
88        if let Some(reason) = &self.restart_required {
89            return Err(reason.clone());
90        }
91        if self.baseline.keys().ne(sources.keys()) {
92            return Err("Build input set changed; restart required".into());
93        }
94        let mut functions = Vec::new();
95        let mut views = BTreeSet::new();
96        for (path, before) in &self.baseline {
97            let after = &sources[path];
98            if before == after {
99                continue;
100            }
101            let registered = self
102                .registered
103                .get(path)
104                .ok_or_else(|| format!("Unpatchable build input changed: {path}"))?;
105            match admit(before, after, registered) {
106                Admission::Unchanged => (),
107                Admission::Patch { functions: changed } => {
108                    for name in &changed {
109                        if let Some(affected) =
110                            self.view_builders.get(&(path.clone(), name.clone()))
111                        {
112                            views.extend(affected.iter().cloned());
113                        }
114                    }
115                    functions.extend(changed.into_iter().map(|name| format!("{path}::{name}")))
116                }
117                Admission::RestartRequired(reason) => return Err(format!("{path}: {reason}")),
118            }
119        }
120        if functions.is_empty() {
121            return Err("No changed patchable functions".into());
122        }
123        Ok(AdmittedPatch {
124            owner: self.identity.clone(),
125            baseline_revision: self.revision,
126            sources,
127            functions,
128            views: views.into_iter().collect(),
129        })
130    }
131    /// Apply on the UI thread between callbacks, only after the patch build succeeded.
132    /// `current` is reread after the build; superseded builds cannot load their code.
133    ///
134    /// # Safety
135    /// The table must be produced by the matching Subsecond toolchain against this running
136    /// executable, exclusively from `patch`'s admitted source snapshot. Every non-source build
137    /// input and dependency must be unchanged. The caller must ensure no other thread is executing
138    /// patchable code. Source admission cannot validate machine addresses or compiler ABI.
139    /// This supervisor must be the exclusive dispatch-table owner; independent
140    /// Subsecond owners and registered Subsecond reload handlers are unsupported.
141    pub unsafe fn apply(
142        &mut self,
143        patch: AdmittedPatch,
144        current: &SourceSnapshot,
145        table: subsecond::JumpTable,
146    ) -> Result<u64, String> {
147        if !patch.views.is_empty() {
148            return Err("Registered views require an apply_with transaction".into());
149        }
150        // SAFETY: forwarded compiler, artifact and quiescence contract.
151        unsafe { self.apply_with(patch, current, table, || Ok(())) }
152    }
153
154    /// Install code provisionally and run a reversible view transaction with the
155    /// new function bodies. Publish the code revision only if that transaction
156    /// succeeds. On failure or panic, restore the previously accepted dispatch
157    /// table; an initial failed patch restores dispatch to the original executable.
158    /// The transaction must itself restore UI mutations before returning an error
159    /// or unwinding. This method cannot undo arbitrary application side effects.
160    /// It must reapply `patch.views_to_reapply()` through application adapters, or
161    /// fail; callback-only changes do not request any view reapplication.
162    ///
163    /// # Safety
164    /// All of `apply`'s guarantees apply. Retain every accepted patch artifact,
165    /// unmodified, until this supervisor and its process retire. No independent
166    /// Subsecond owner or registered Subsecond hot-reload handlers may mutate the
167    /// dispatch table or UI. The transaction must not publish work that executes
168    /// patchable code on another thread or after a rejected transaction.
169    pub unsafe fn apply_with(
170        &mut self,
171        patch: AdmittedPatch,
172        current: &SourceSnapshot,
173        table: subsecond::JumpTable,
174        transaction: impl FnOnce() -> Result<(), String>,
175    ) -> Result<u64, String> {
176        self.validate(&patch, current)?;
177        if crate::in_callback() {
178            return Err("Patch application must wait until the callback returns".into());
179        }
180        let accepted = table.clone();
181        let rollback = self.accepted_table.clone().unwrap_or_else(|| {
182            // The candidate's loader/ASLR descriptor is valid after successful
183            // installation. An empty map dispatches every function to the original
184            // executable, without unloading a library that may still own code.
185            let mut original = table.clone();
186            original.map.clear();
187            original
188        });
189        // SAFETY: the caller supplies the documented toolchain and quiescence guarantees.
190        unsafe { subsecond::apply_patch(table) }.map_err(|error| format!("{error:?}"))?;
191        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(transaction));
192        if !matches!(&outcome, Ok(Ok(()))) {
193            // SAFETY: this is the original, unrelocated descriptor of a retained
194            // accepted artifact (or the now-loaded candidate with no redirections).
195            if let Err(error) = unsafe { subsecond::apply_patch(rollback) } {
196                let reason = format!("Restart required: dispatch rollback failed: {error:?}");
197                self.restart_required = Some(reason.clone());
198                return Err(reason);
199            }
200            return match outcome {
201                Ok(Err(error)) => Err(error),
202                Err(payload) => std::panic::resume_unwind(payload),
203                Ok(Ok(())) => unreachable!(),
204            };
205        }
206        self.accepted_table = Some(accepted);
207        self.baseline = patch.sources;
208        self.revision += 1;
209        Ok(self.revision)
210    }
211    fn validate(&self, patch: &AdmittedPatch, current: &SourceSnapshot) -> Result<(), String> {
212        if let Some(reason) = &self.restart_required {
213            return Err(reason.clone());
214        }
215        if !Rc::ptr_eq(&patch.owner, &self.identity) {
216            return Err("Patch belongs to another supervisor".into());
217        }
218        if patch.baseline_revision != self.revision || &patch.sources != current {
219            return Err("Superseded patch".into());
220        }
221        Ok(())
222    }
223
224    /// A failed dispatch rollback makes further admission unsafe. The owning
225    /// development process must build and start a replacement before continuing.
226    pub fn restart_required(&self) -> Option<&str> {
227        self.restart_required.as_deref()
228    }
229}
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::FunctionRegistration;
234
235    #[test]
236    fn only_changed_builders_request_their_registered_views() {
237        let source = "fn click() -> i32 { 1 } fn build() -> i32 { 2 } fn detail() -> i32 { 3 }";
238        let baseline = BTreeMap::from([("app.rs".into(), source.into())]);
239        let supervisor = PatchSupervisor::with_registrations(
240            baseline,
241            [
242                FunctionRegistration::callback("app.rs", "click"),
243                FunctionRegistration::view_builder(
244                    "app.rs",
245                    "build",
246                    ["main".into(), "detail".into()],
247                ),
248                FunctionRegistration::view_builder("app.rs", "detail", ["detail".into()]),
249            ],
250        )
251        .unwrap();
252        let callback = BTreeMap::from([("app.rs".into(), source.replace("{ 1 }", "{ 4 }"))]);
253        assert!(supervisor
254            .admit(callback)
255            .unwrap()
256            .views_to_reapply()
257            .is_empty());
258        let builders = BTreeMap::from([(
259            "app.rs".into(),
260            source.replace("{ 2 }", "{ 5 }").replace("{ 3 }", "{ 6 }"),
261        )]);
262        let ticket = supervisor.admit(builders).unwrap();
263        assert_eq!(
264            ticket.views_to_reapply(),
265            &["detail".to_string(), "main".to_string()]
266        );
267        assert_eq!(ticket.functions, ["app.rs::build", "app.rs::detail"]);
268    }
269
270    #[test]
271    fn invalid_or_ambiguous_registrations_fail_before_admission() {
272        let baseline = BTreeMap::from([(
273            "app.rs".into(),
274            "fn click() {} async fn task() {} fn generic<T>() {} fn closure() { let f = || 1; }"
275                .into(),
276        )]);
277        for registrations in [
278            vec![FunctionRegistration::callback("absent.rs", "click")],
279            vec![FunctionRegistration::callback("app.rs", "absent")],
280            vec![FunctionRegistration::callback("app.rs", "task")],
281            vec![FunctionRegistration::callback("app.rs", "generic")],
282            vec![FunctionRegistration::callback("app.rs", "closure")],
283            vec![
284                FunctionRegistration::callback("app.rs", "click"),
285                FunctionRegistration::callback("app.rs", "click"),
286            ],
287            vec![FunctionRegistration::view_builder("app.rs", "click", [])],
288            vec![FunctionRegistration::view_builder(
289                "app.rs",
290                "click",
291                [" ".into()],
292            )],
293        ] {
294            assert!(PatchSupervisor::with_registrations(baseline.clone(), registrations).is_err());
295        }
296    }
297    #[test]
298    fn admission_tickets_are_bound_to_their_supervisor_and_revision() {
299        let before = BTreeMap::from([("main.rs".into(), "fn click() {}".into())]);
300        let registered = BTreeMap::from([("main.rs".into(), BTreeSet::from(["click".into()]))]);
301        let mut owner = PatchSupervisor::new(before.clone(), registered.clone());
302        let other = PatchSupervisor::new(before, registered);
303        let after = BTreeMap::from([("main.rs".into(), "fn click() { let x = 2; }".into())]);
304        let ticket = owner.admit(after.clone()).unwrap();
305        assert!(owner.validate(&ticket, &after).is_ok());
306        assert!(other
307            .validate(&ticket, &after)
308            .unwrap_err()
309            .contains("another supervisor"));
310        owner.revision += 1;
311        assert!(owner
312            .validate(&ticket, &after)
313            .unwrap_err()
314            .contains("Superseded"));
315    }
316    #[test]
317    fn rejects_changed_dependency_before_loading() {
318        let old = BTreeMap::from([
319            ("main.rs".into(), "fn click() {}".into()),
320            ("Cargo.toml".into(), "original".into()),
321        ]);
322        let supervisor = PatchSupervisor::new(
323            old.clone(),
324            BTreeMap::from([("main.rs".into(), BTreeSet::from(["click".into()]))]),
325        );
326        let mut new = old;
327        new.insert("main.rs".into(), "fn click() { let x = 2; }".into());
328        assert!(supervisor.admit(new.clone()).is_ok());
329        new.insert("Cargo.toml".into(), "changed".into());
330        assert!(supervisor.admit(new).unwrap_err().contains("Unpatchable"));
331    }
332}