Skip to main content

godspeed/
handover.rs

1// SPDX-License-Identifier: Apache-2.0
2//! A LIVE HANDOVER: a running service replaced by a new version without stopping - its state carried across, its
3//! name never unanswered.
4//!
5//! Erlang does this by loading new code into a running process. GodspeedOS will not (CLAUDE.md 2.5: the bytes that
6//! run are the bytes that were signed and checked), so it does the same thing with two isolated programs and an
7//! explicit handover:
8//!
9//! 1. The supervisor checks the new version from the disk and starts it BESIDE the running one, under the same name;
10//!    the name now answers to the new one.
11//! 2. It sends the running one an OFFER naming its successor. The running one finishes what is already in its queue,
12//!    sends its state to the successor, and ends itself.
13//! 3. The successor takes the state and carries on. What Erlang calls code change is yours: it reads the old version's state and
14//!    turns it into its own.
15//!
16//! A service opts in by declaring `handover = true` in its contract and doing both halves:
17//!
18//! ```ignore
19//! let mut h = gs::handover::Handover::new(&ctx);
20//! let mut buf = [0u8; gs::handover::STATE_MAX];
21//! let mut count = 0u64;
22//! if let Some(t) = h.take_over(&mut buf) {                // a successor: the running version's state
23//!     count = decode(t.state);
24//!     for m in t.early.iter().flatten() { handle(m); }
25//! }
26//! loop {
27//!     let msg = gs::ipc::recv(&ctx);
28//!     if let Some(offer) = h.offer(&msg) {                 // the supervisor's offer, checked
29//!         h.hand_over(offer, &encode(count), |m| handle(&m));   // does not return
30//!     }
31//!     handle(&msg);
32//! }
33//! ```
34//!
35//! **Why an offer is checked, and how.** The offer arrives on the service's public endpoint, which every client can
36//! send to, and the kernel does not say who sent a message. Anyone able to send an unchecked offer could make a service
37//! hand its state to them and end. So the supervisor gives every handover-capable start a KEY in its spawn mode
38//! (`SpawnRequest::probe_mode`, which only the supervisor can set, since only it calls `SpawnImage`), and a successor a
39//! key of its own. An offer must carry the running version's key and names the successor's; the state must carry the
40//! successor's. Each side refuses after [`WRONG_MAX`] wrong ones and stops listening for more, saying so - so a key
41//! cannot be guessed, at the cost that someone who keeps sending wrong offers can make a later upgrade fall back to a
42//! stop and start. That is loud, and no worse than an upgrade was before handovers.
43//!
44//! **What it does not do, said here rather than discovered:** capabilities do not travel - the successor holds what its
45//! own spawn request gave it, as a restart would. A request a client sent to the old version after it drained its queue
46//! and before it ended is lost with it, and that client's `gs::call` reports it (`OutcomeUnknown`), exactly as for a
47//! death. State is one message, [`STATE_MAX`] bytes; a service that needs more keeps the rest on disk.
48
49use crate::ipc::{self, Message};
50use crate::ServiceContext;
51
52/// Spawn-mode bit: this start carries a handover key (the low 30 bits).
53pub const KEYED: u32 = 1 << 30;
54/// Spawn-mode bit: this start is a SUCCESSOR, to wait for a running version's state.
55pub const SUCCESSOR: u32 = 1 << 31;
56/// The key's bits.
57pub const KEY_MASK: u32 = KEYED - 1;
58/// Supervisor -> running version: the offer, then the running version's key and the successor's (u32 LE each).
59pub const OFFER: &[u8; 8] = b"GSHOVER2";
60/// Running version -> successor: the successor's key (u32 LE), then the state.
61pub const STATE: &[u8; 8] = b"GSHOSTA2";
62/// The most state one handover carries.
63pub const STATE_MAX: usize = 4096 - 12;
64/// How long a successor waits for its state before it starts fresh, saying so.
65pub const TAKE_SECS: i64 = 10;
66/// How many ordinary messages a successor keeps that arrive before the state does.
67pub const EARLY_MAX: usize = 2;
68/// Wrong offers (or wrong states) taken before this side stops listening for any.
69pub const WRONG_MAX: u32 = 3;
70
71/// The spawn mode the supervisor gives a handover-capable start with `key`.
72pub const fn mode(key: u32, successor: bool) -> u32 {
73    KEYED | (key & KEY_MASK) | if successor { SUCCESSOR } else { 0 }
74}
75
76/// An offer that checked: the successor's key, which the state must carry.
77#[derive(Clone, Copy)]
78pub struct Offer { successor_key: u32 }
79
80/// What a successor was handed: the state, and any ordinary messages that arrived before it, in order.
81pub struct TakeOver<'b> {
82    pub state: &'b [u8],
83    pub early: [Option<Message>; EARLY_MAX],
84}
85
86/// One service's side of handovers: its key, and how many wrong offers it has seen.
87pub struct Handover<'c> {
88    ctx: &'c ServiceContext,
89    mode: u32,
90    wrong: u32,
91}
92
93impl<'c> Handover<'c> {
94    pub fn new(ctx: &'c ServiceContext) -> Handover<'c> {
95        Handover { ctx, mode: ctx.probe_mode(), wrong: 0 }
96    }
97
98    /// Was this program started to take over from a running version of itself?
99    pub fn is_successor(&self) -> bool { self.mode & (KEYED | SUCCESSOR) == KEYED | SUCCESSOR }
100
101    fn key(&self) -> u32 { self.mode & KEY_MASK }
102
103    /// Is `msg` the supervisor's offer for THIS version? `None` for an ordinary message, and for an offer that does not
104    /// carry this version's key, which is counted: after [`WRONG_MAX`] of them no offer is taken at all.
105    pub fn offer(&mut self, msg: &Message) -> Option<Offer> {
106        let p = msg.payload_bytes();
107        if p.len() != 16 || &p[..8] != OFFER { return None; }
108        if self.mode & KEYED == 0 || self.wrong >= WRONG_MAX {
109            return None;
110        }
111        let mine = u32::from_le_bytes([p[8], p[9], p[10], p[11]]);
112        let next = u32::from_le_bytes([p[12], p[13], p[14], p[15]]);
113        if mine != self.key() {
114            self.wrong += 1;
115            // The capability that came with it is not ours to keep.
116            if let Some(c) = ipc::take_sent_cap(self.ctx) { crate::cap::remove(self.ctx, c); }
117            self.ctx.log_fmt(format_args!("handover: an offer with the wrong key - refused ({} of {}){}", self.wrong, WRONG_MAX,
118                if self.wrong >= WRONG_MAX { "; no more offers are taken, so an upgrade restarts this instead" } else { "" }));
119            return None;
120        }
121        Some(Offer { successor_key: next & KEY_MASK })
122    }
123
124    /// Hand over and end: every message already queued goes to `drain` first (they were sent to this version, so this
125    /// version answers them), then the state goes to the successor with its key, then this program ends with status 0.
126    /// A successor that cannot be reached is said, and this version ends all the same: the successor holds the name.
127    pub fn hand_over(&self, offer: Offer, state: &[u8], mut drain: impl FnMut(Message)) -> ! {
128        let ctx = self.ctx;
129        let successor = ipc::take_sent_cap(ctx);
130        // Bounded by the queue: an endpoint holds sixteen, and nothing new comes here once the name is the successor's.
131        for _ in 0..64 {
132            match ipc::try_recv(ctx) {
133                Some(m) => drain(m),
134                None => break,
135            }
136        }
137        let mut out = [0u8; 4096];
138        let n = state.len().min(STATE_MAX);
139        out[..8].copy_from_slice(STATE);
140        out[8..12].copy_from_slice(&offer.successor_key.to_le_bytes());
141        out[12..12 + n].copy_from_slice(&state[..n]);
142        match successor {
143            Some(c) => {
144                if ipc::send_to(ctx, c, &Message::from_bytes(&out[..12 + n])).is_err() {
145                    ctx.log("handover: the successor could not be reached - its state is lost; it starts fresh");
146                }
147                crate::cap::remove(ctx, c);
148            }
149            None => ctx.log("handover: offered with no successor attached - nothing to hand to"),
150        }
151        if state.len() > STATE_MAX {
152            ctx.log_fmt(format_args!("handover: state cut to {} of {} bytes", STATE_MAX, state.len()));
153        }
154        crate::program::exit(ctx, 0)
155    }
156
157    /// Take over, if this is a successor: wait up to [`TAKE_SECS`] for the running version's state - carrying this
158    /// successor's key - keeping up to [`EARLY_MAX`] ordinary messages that arrive first. `None` for a program that is
159    /// not a successor, and for one whose state did not come in time, which is said: it starts fresh, as a restart
160    /// would. A state with the wrong key is refused, and after [`WRONG_MAX`] of them none is taken.
161    pub fn take_over<'b>(&mut self, buf: &'b mut [u8; STATE_MAX]) -> Option<TakeOver<'b>> {
162        if !self.is_successor() { return None; }
163        let ctx = self.ctx;
164        let mut early: [Option<Message>; EARLY_MAX] = core::array::from_fn(|_| None);
165        let mut ne = 0usize;
166        let start = crate::task::uptime_secs(ctx);
167        loop {
168            let left = TAKE_SECS - (crate::task::uptime_secs(ctx) - start);
169            if left <= 0 || self.wrong >= WRONG_MAX { break; }
170            let Some(m) = ipc::recv_within(ctx, left) else { break };
171            let p = m.payload_bytes();
172            if p.len() >= 12 && &p[..8] == STATE {
173                let key = u32::from_le_bytes([p[8], p[9], p[10], p[11]]);
174                if key != self.key() {
175                    self.wrong += 1;
176                    ctx.log_fmt(format_args!("handover: a state with the wrong key - refused ({} of {})", self.wrong, WRONG_MAX));
177                    continue;
178                }
179                let n = (p.len() - 12).min(buf.len());
180                buf[..n].copy_from_slice(&p[12..12 + n]);
181                return Some(TakeOver { state: &buf[..n], early });
182            }
183            if ne < EARLY_MAX {
184                early[ne] = Some(m);
185                ne += 1;
186            } else {
187                ctx.log("handover: a message came before the state with no room to keep it - dropped");
188            }
189        }
190        ctx.log("handover: no state came from the running version - starting fresh");
191        None
192    }
193}