freenet node: the update exit is watched from a thread for as long as the check takes, not for two seconds inside the start call

The supervisor that applies the node's update-wanted exit (code 42)
watched a fresh child for two seconds and then let go. The exit is the
result of a network check the node makes at startup, and it takes as long
as the network takes: on a fresh install today a pinned 0.2.132 exited
several seconds after launch, after the supervisor had already returned,
and the layer read "node absent" with only node.log saying why — the
exact failure the supervisor was written against, back again.

`start_node_inner` now puts the child in `freenet_node` itself and leaves
`watch_for_update` polling it from its own thread for 90 s, under the
lock rather than owning the child — the child belongs to the state, and
the Stop button takes it. An update is applied and the peer restarted
while the lock is held, so nothing reads the dead child as "stopped" and
starts a second peer meanwhile; any other exit is logged and the slot
emptied; an emptied slot ends the watch. The first boot is the one launch
the watcher cannot cover (its child never reaches the state), so that loop
handles code 42 itself, once, with its clock restarted.

Seen live on this machine: the other-exit branch caught a spawn that died
on a busy port (code 43) within half a second and cleared the slot; a peer
killed by hand and started again from the app was connected two seconds
later. The exit-42 branch itself is not reproducible here any more — the
binary is on the latest release — and stands on the same code path.
This commit is contained in:
KeelrLeek committed 2026-09-06 08:17:55 -05:00
1 parent f67a0a30b4
commit 594a33a302
1 file changed
+66 -29
+66 -29
View File
@@ -140,9 +140,12 @@ const TELEMETRY_OFF: &str = "telemetry-enabled = false";
const FIRST_BOOT_WAIT: Duration = Duration::from_secs(20);
/// What the node returns when it has spotted a newer release and wants one.
const EXIT_UPDATE_NEEDED: i32 = 42;
/// How long to watch a fresh child for that exit. The check runs at startup,
/// before any network work, so this only has to outlast process spawn.
const UPDATE_EXIT_WAIT: Duration = Duration::from_secs(2);
/// How long to keep watching a fresh peer for that exit. The check is a
/// network call the node makes at startup, and it takes as long as the
/// network takes: on 2026-09-06 a fresh 0.2.132 took more than the two
/// seconds this used to wait, exited after the supervisor had already gone,
/// and the layer read "node absent" with only node.log saying why.
const UPDATE_EXIT_WATCH: Duration = Duration::from_secs(90);
struct NodeDirs {
base: PathBuf,
@@ -236,7 +239,7 @@ fn spawn_node(bin: &Path, dirs: &NodeDirs, log: &Path) -> Result<Child, String>
.map_err(|e| format!("start the peer: {e}"))
}
/// Spawn the peer and be its supervisor.
/// The peer's supervisor.
///
/// A node that spots a newer release exits with code 42 and waits for someone
/// to apply it; Freenet ships that someone as a system service. We cannot use
@@ -250,25 +253,45 @@ fn spawn_node(bin: &Path, dirs: &NodeDirs, log: &Path) -> Result<Child, String>
/// permanently absent with only a line in node.log to say why. One update, one
/// restart, no recursion — if the fresh binary asks again we let it be, rather
/// than loop.
fn spawn_supervised(bin: &Path, dirs: &NodeDirs, log: &Path) -> Result<Child, String> {
let mut child = spawn_node(bin, dirs, log)?;
let deadline = Instant::now() + UPDATE_EXIT_WAIT;
///
/// Watches the peer that `start_node_inner` just put in `freenet_node`, from
/// its own thread, polling under the lock rather than owning the child — the
/// child belongs to the state, and the Stop button takes it. An update is
/// applied and the peer restarted while the lock is held, so nothing reads
/// the dead child as "stopped" and starts a second peer in the meantime. Any
/// other exit is logged and the slot emptied; an emptied slot ends the watch.
fn watch_for_update(app: tauri::AppHandle, bin: PathBuf, dirs: NodeDirs, log: PathBuf) {
std::thread::spawn(move || {
let deadline = Instant::now() + UPDATE_EXIT_WATCH;
while Instant::now() < deadline {
match child.try_wait() {
Ok(Some(status)) if status.code() == Some(EXIT_UPDATE_NEEDED) => {
std::thread::sleep(Duration::from_millis(500));
let state = app.state::<AppState>();
let mut guard = state.freenet_node.lock().unwrap();
match guard.as_mut().map(|c| c.try_wait()) {
Some(Ok(None)) => continue,
Some(Ok(Some(status))) if status.code() == Some(EXIT_UPDATE_NEEDED) => {
log::warn!("freenet node: exit 42 — a newer release is out; applying it");
apply_update(bin)?;
let child = spawn_node(bin, dirs, log)?;
match apply_update(&bin).and_then(|()| spawn_node(&bin, &dirs, &log)) {
Ok(child) => {
log::info!("freenet node: updated and restarted (pid {})", child.id());
return Ok(child);
*guard = Some(child);
}
// Any other early exit is the caller's problem to report, and a
// live child is the normal case.
Ok(Some(_)) | Err(_) => break,
Ok(None) => std::thread::sleep(Duration::from_millis(100)),
Err(e) => {
log::warn!("freenet node: could not apply the update: {e}");
*guard = None;
}
}
Ok(child)
return;
}
Some(Ok(Some(status))) => {
log::warn!("freenet node: the peer exited ({status}) — see {}", log.display());
*guard = None;
return;
}
Some(Err(_)) | None => return,
}
}
});
}
/// Run the node's own updater. Blocking — this whole path is already on a
@@ -321,10 +344,11 @@ pub fn freenet_node_info(
}
/// Start the peer under the app's folder (the first-boot recipe when its
/// config does not exist yet). Returns the running child.
async fn start_node_inner(app: &tauri::AppHandle) -> Result<Child, String> {
/// config does not exist yet), put it in `freenet_node`, and leave the
/// supervisor watching it.
async fn start_node_inner(app: &tauri::AppHandle) -> Result<(), String> {
let handle = app.clone();
tauri::async_runtime::spawn_blocking(move || -> Result<Child, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let bin = find_binary(&handle)
.ok_or("no Freenet peer found on this machine — install it (cargo install freenet) and try again")?;
let dirs = node_dirs(&handle)?;
@@ -333,10 +357,22 @@ async fn start_node_inner(app: &tauri::AppHandle) -> Result<Child, String> {
let config = dirs.config.join("config.toml");
if !config.is_file() {
let first_log = dirs.base.join("first-boot.log");
let mut first = spawn_supervised(&bin, &dirs, &first_log)?;
let deadline = Instant::now() + FIRST_BOOT_WAIT;
let mut first = spawn_node(&bin, &dirs, &first_log)?;
let mut deadline = Instant::now() + FIRST_BOOT_WAIT;
let mut updated = false;
while !config.is_file() && Instant::now() < deadline {
if let Ok(Some(status)) = first.try_wait() {
// The first boot is the one launch the watcher cannot
// cover — its child is never in the state — so the update
// exit is handled here, once, with the clock restarted.
if status.code() == Some(EXIT_UPDATE_NEEDED) && !updated {
log::warn!("freenet node: exit 42 on first boot — a newer release is out; applying it");
apply_update(&bin)?;
updated = true;
first = spawn_node(&bin, &dirs, &first_log)?;
deadline = Instant::now() + FIRST_BOOT_WAIT;
continue;
}
return Err(format!(
"the peer exited during its first boot ({status}) — see {}",
first_log.display()
@@ -352,9 +388,12 @@ async fn start_node_inner(app: &tauri::AppHandle) -> Result<Child, String> {
log::info!("freenet node: first boot wrote {}", config.display());
}
pin_telemetry_off(&config)?;
let child = spawn_supervised(&bin, &dirs, &dirs.base.join("node.log"))?;
let log = dirs.base.join("node.log");
let child = spawn_node(&bin, &dirs, &log)?;
log::info!("freenet node started (pid {}) from {}", child.id(), bin.display());
Ok(child)
*handle.state::<AppState>().freenet_node.lock().unwrap() = Some(child);
watch_for_update(handle.clone(), bin, dirs, log);
Ok(())
})
.await
.map_err(|e| e.to_string())?
@@ -381,9 +420,8 @@ pub async fn ensure_node(app: tauri::AppHandle) {
if !matches!(state.freenet.detect().await, freenet_adapter::AdapterStatus::NodeAbsent) {
return;
}
match start_node_inner(&app).await {
Ok(child) => *state.freenet_node.lock().unwrap() = Some(child),
Err(e) => log::warn!("freenet node: could not start the peer: {e}"),
if let Err(e) = start_node_inner(&app).await {
log::warn!("freenet node: could not start the peer: {e}");
}
}
@@ -395,8 +433,7 @@ pub async fn freenet_node_start(
if node_info(&app, &state)?.started_by_app {
return node_info(&app, &state);
}
let child = start_node_inner(&app).await?;
*state.freenet_node.lock().unwrap() = Some(child);
start_node_inner(&app).await?;
node_info(&app, &state)
}