use anyhow::{Context, Result};
use std::path::Path;
#[cfg(target_os = "macos")]
use super::launch_at_login::macos_launch_at_login_startup;
#[cfg(target_os = "macos")]
use super::single_instance::{AcquireWrapperLockOutcome, acquire_wrapper_single_instance_lock};
#[cfg(any(target_os = "windows", target_os = "macos"))]
use super::single_instance::FIRST_RUN_OPENER_SPAWNED;
#[cfg(any(target_os = "windows", target_os = "macos"))]
use super::DASHBOARD_URL;
#[cfg(any(target_os = "windows", target_os = "macos"))]
use super::open_url_in_browser;
use super::{
SENTINEL_RESTART, SENTINEL_STOP, WRAPPER_EXIT_ALREADY_RUNNING, WRAPPER_EXIT_UPDATE_NEEDED,
WRAPPER_INITIAL_BACKOFF_SECS, WRAPPER_MAX_BACKOFF_SECS, WRAPPER_MAX_CONSECUTIVE_FAILURES,
WRAPPER_MAX_PORT_CONFLICT_KILLS,
};
#[cfg(any(target_os = "windows", target_os = "macos"))]
use super::{
dashboard_port_is_listening, first_run_marker_path, is_first_run_at, mark_first_run_complete_at,
};
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn spawn_first_run_dashboard_opener(log_dir: &Path) {
use std::time::{Duration, Instant};
let log_dir = log_dir.to_path_buf();
std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if dashboard_port_is_listening() {
open_url_in_browser(DASHBOARD_URL);
if let Some(marker) = first_run_marker_path() {
match mark_first_run_complete_at(&marker) {
Ok(()) => {
log_wrapper_event(&log_dir, "First-run onboarding: dashboard opened")
}
Err(e) => log_wrapper_event(
&log_dir,
&format!("Failed to write first-run marker: {e}"),
),
}
}
return;
}
std::thread::sleep(Duration::from_millis(500));
}
log_wrapper_event(
&log_dir,
"First-run dashboard open skipped: dashboard never became reachable",
);
});
}
pub(super) const WRAPPER_STUCK_NOTIFY_THRESHOLD: u32 = 3;
pub(super) const STUCK_STATUS_FILE_NAME: &str = "wrapper-stuck-status.json";
pub(super) const STUCK_STREAK_FILE_NAME: &str = "wrapper-stuck-streak.json";
pub(super) const STUCK_STREAK_TTL_SECS: u64 = 3600;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct FailureSignature {
pub(super) exit_code: i32,
pub(super) is_port_conflict: bool,
}
#[derive(Debug, Clone)]
pub(super) struct WrapperState {
pub(super) backoff_secs: u64,
pub(super) consecutive_failures: u32,
pub(super) port_conflict_kills: u32,
pub(super) last_failure_signature: Option<FailureSignature>,
pub(super) identical_failure_streak: u32,
pub(super) stuck_notified: bool,
pub(super) stuck_just_crossed: bool,
pub(super) signature_changed: bool,
}
impl WrapperState {
pub(super) fn new() -> Self {
Self {
backoff_secs: WRAPPER_INITIAL_BACKOFF_SECS,
consecutive_failures: 0,
port_conflict_kills: 0,
last_failure_signature: None,
identical_failure_streak: 0,
stuck_notified: false,
stuck_just_crossed: false,
signature_changed: false,
}
}
pub(super) fn record_failure_signature(&mut self, sig: FailureSignature) -> bool {
if self.last_failure_signature == Some(sig) {
self.identical_failure_streak = self.identical_failure_streak.saturating_add(1);
} else {
if self.last_failure_signature.is_some() {
self.signature_changed = true;
}
self.last_failure_signature = Some(sig);
self.identical_failure_streak = 1;
self.stuck_notified = false;
}
if self.identical_failure_streak >= WRAPPER_STUCK_NOTIFY_THRESHOLD && !self.stuck_notified {
self.stuck_notified = true;
return true;
}
false
}
pub(super) fn reset_failure_streak(&mut self) {
self.last_failure_signature = None;
self.identical_failure_streak = 0;
self.stuck_notified = false;
self.stuck_just_crossed = false;
self.signature_changed = false;
}
}
#[derive(Debug, PartialEq)]
pub(super) enum WrapperAction {
Update,
Exit,
KillAndRetry,
BackoffAndRelaunch { secs: u64 },
}
pub(super) fn next_wrapper_action(
state: &mut WrapperState,
exit_code: i32,
is_port_conflict: bool,
update_succeeded: Option<bool>, ) -> WrapperAction {
state.stuck_just_crossed = false;
state.signature_changed = false;
let sig = FailureSignature {
exit_code,
is_port_conflict,
};
match exit_code {
code if code == WRAPPER_EXIT_UPDATE_NEEDED => {
match update_succeeded {
Some(true) => {
state.consecutive_failures = 0;
state.port_conflict_kills = 0;
state.backoff_secs = WRAPPER_INITIAL_BACKOFF_SECS;
state.reset_failure_streak();
WrapperAction::Update
}
Some(false) => {
state.consecutive_failures += 1;
state.stuck_just_crossed = state.record_failure_signature(sig);
let secs = state.backoff_secs;
state.backoff_secs = (state.backoff_secs * 2).min(WRAPPER_MAX_BACKOFF_SECS);
WrapperAction::BackoffAndRelaunch { secs }
}
None => WrapperAction::Update, }
}
code if code == WRAPPER_EXIT_ALREADY_RUNNING => {
let sig = FailureSignature {
exit_code,
is_port_conflict: true,
};
state.stuck_just_crossed = state.record_failure_signature(sig);
WrapperAction::Exit
}
0 => {
state.reset_failure_streak();
WrapperAction::Exit
}
_ => {
state.stuck_just_crossed = state.record_failure_signature(sig);
if is_port_conflict {
state.port_conflict_kills += 1;
if state.port_conflict_kills <= WRAPPER_MAX_PORT_CONFLICT_KILLS {
state.backoff_secs = WRAPPER_INITIAL_BACKOFF_SECS;
return WrapperAction::KillAndRetry;
}
}
state.consecutive_failures += 1;
state.port_conflict_kills = 0;
let secs = state.backoff_secs;
state.backoff_secs = (state.backoff_secs * 2).min(WRAPPER_MAX_BACKOFF_SECS);
WrapperAction::BackoffAndRelaunch { secs }
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum StuckLoopAction {
Write,
Clear,
None,
}
pub(super) fn stuck_loop_action(
stuck_just_crossed: bool,
signature_changed: bool,
streak: u32,
) -> StuckLoopAction {
if stuck_just_crossed {
StuckLoopAction::Write
} else if streak == 0 || signature_changed {
StuckLoopAction::Clear
} else {
StuckLoopAction::None
}
}
fn jitter_secs(secs: u64) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
std::time::SystemTime::now().hash(&mut hasher);
let hash = hasher.finish();
let factor = 0.8 + (hash % 1000) as f64 / 2500.0;
(secs as f64 * factor) as u64
}
#[allow(dead_code)] pub(super) enum BackoffInterrupt {
Completed,
Quit,
Relaunch,
CheckUpdate,
}
pub(super) fn sleep_with_jitter_interruptible(
secs: u64,
#[cfg(any(target_os = "windows", target_os = "macos"))] action_rx: Option<
&std::sync::mpsc::Receiver<super::super::tray::TrayAction>,
>,
#[cfg(not(any(target_os = "windows", target_os = "macos")))] _action_rx: Option<
&std::sync::mpsc::Receiver<super::super::tray::TrayAction>,
>,
) -> BackoffInterrupt {
let jittered = jitter_secs(secs).max(1);
for _ in 0..jittered {
std::thread::sleep(std::time::Duration::from_secs(1));
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some(rx) = action_rx {
if let Ok(action) = rx.try_recv() {
match action {
super::super::tray::TrayAction::Quit => return BackoffInterrupt::Quit,
super::super::tray::TrayAction::ViewLogs => super::super::tray::open_log_file(),
super::super::tray::TrayAction::OpenDashboard => {
open_url_in_browser(DASHBOARD_URL);
}
super::super::tray::TrayAction::Start
| super::super::tray::TrayAction::Restart => {
return BackoffInterrupt::Relaunch;
}
super::super::tray::TrayAction::CheckUpdate => {
return BackoffInterrupt::CheckUpdate;
}
super::super::tray::TrayAction::Stop => {} }
}
}
}
BackoffInterrupt::Completed
}
pub(super) fn run_wrapper(version: &str) -> Result<()> {
#[cfg(target_os = "windows")]
unsafe {
winapi::um::wincon::FreeConsole();
}
use freenet::tracing::tracer::get_log_dir;
use std::sync::mpsc;
let log_dir = get_log_dir().unwrap_or_else(|| {
let fallback = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("freenet")
.join("logs");
eprintln!(
"Warning: could not determine log directory, using {}",
fallback.display()
);
fallback
});
std::fs::create_dir_all(&log_dir)
.with_context(|| format!("Failed to create log directory: {}", log_dir.display()))?;
#[cfg(target_os = "macos")]
let _wrapper_lock = match acquire_wrapper_single_instance_lock() {
AcquireWrapperLockOutcome::Acquired(guard) => Some(guard),
AcquireWrapperLockOutcome::AnotherWrapperRunning => {
log_wrapper_event(
&log_dir,
&format!(
"Wrapper pid={} exiting: another Freenet wrapper is already \
running (lockfile at ~/Library/Caches/Freenet/wrapper.lock is \
held). Expected if launchd fired RunAtLoad while an existing \
wrapper is alive, or if Freenet.app was double-launched. \
If Freenet's menu bar icon is not visible, another process may \
be holding the lock; try `lsof ~/Library/Caches/Freenet/wrapper.lock`.",
std::process::id()
),
);
return Ok(());
}
AcquireWrapperLockOutcome::UnavailableSoProceed => {
log_wrapper_event(
&log_dir,
"Wrapper single-instance lock unavailable; proceeding without guard. \
Dup-tray risk if RunAtLoad fires while another wrapper is alive.",
);
None
}
};
kill_stale_freenet_processes(&log_dir);
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
use super::super::tray::{TrayAction, WrapperStatus};
let (action_tx, action_rx) = mpsc::channel::<TrayAction>();
let (status_tx, status_rx) = mpsc::channel::<WrapperStatus>();
let (cleanup_tx, cleanup_rx) = mpsc::channel::<()>();
let version_owned = version.to_string();
let log_dir_clone = log_dir.clone();
#[cfg(target_os = "macos")]
macos_launch_at_login_startup(&log_dir);
let loop_handle = std::thread::spawn(move || {
let result = run_wrapper_loop(&log_dir_clone, Some((&action_rx, &status_tx)));
cleanup_tx.send(()).ok();
result
});
super::super::tray::run_tray_event_loop(action_tx, status_rx, cleanup_rx, &version_owned);
match loop_handle.join() {
Ok(result) => result,
Err(_) => anyhow::bail!("Wrapper loop thread panicked"),
}
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let _ = version;
run_wrapper_loop(
&log_dir,
None::<(
&mpsc::Receiver<super::super::tray::TrayAction>,
&mpsc::Sender<super::super::tray::WrapperStatus>,
)>,
)
}
}
pub(super) fn spawn_update_command(exe_path: &Path) -> std::io::Result<std::process::ExitStatus> {
let mut cmd = std::process::Command::new(exe_path);
cmd.args(["update", "--quiet"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd.status()
}
fn spawn_new_wrapper(exe_path: &Path, log_dir: &Path) -> bool {
let mut cmd = std::process::Command::new(exe_path);
cmd.args(["service", "run-wrapper"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
match cmd.spawn() {
Ok(_) => {
log_wrapper_event(log_dir, "New wrapper process spawned");
true
}
Err(e) => {
log_wrapper_event(
log_dir,
&format!("Failed to spawn new wrapper: {e}. Continuing with current wrapper."),
);
false
}
}
}
const NETWORK_READINESS_TIMEOUT_SECS: u64 = 60;
const NETWORK_READINESS_CHECK_INTERVAL_SECS: u64 = 2;
const NETWORK_PROBE_ADDR: &str = "freenet.org:443";
fn wait_for_network_ready(
log_dir: &Path,
#[cfg(any(target_os = "windows", target_os = "macos"))] action_rx: Option<
&std::sync::mpsc::Receiver<super::super::tray::TrayAction>,
>,
#[cfg(not(any(target_os = "windows", target_os = "macos")))] _action_rx: Option<
&std::sync::mpsc::Receiver<super::super::tray::TrayAction>,
>,
) -> bool {
wait_for_network_ready_inner(
log_dir,
NETWORK_PROBE_ADDR,
#[cfg(any(target_os = "windows", target_os = "macos"))]
action_rx,
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
_action_rx,
)
}
pub(super) fn wait_for_network_ready_inner(
log_dir: &Path,
probe_addr: &str,
#[cfg(any(target_os = "windows", target_os = "macos"))] action_rx: Option<
&std::sync::mpsc::Receiver<super::super::tray::TrayAction>,
>,
#[cfg(not(any(target_os = "windows", target_os = "macos")))] _action_rx: Option<
&std::sync::mpsc::Receiver<super::super::tray::TrayAction>,
>,
) -> bool {
use std::net::ToSocketAddrs;
if probe_addr.to_socket_addrs().is_ok() {
return true;
}
log_wrapper_event(
log_dir,
"Network not ready yet, waiting for connectivity...",
);
let max_checks = NETWORK_READINESS_TIMEOUT_SECS / NETWORK_READINESS_CHECK_INTERVAL_SECS;
for _ in 0..max_checks {
let jittered = jitter_secs(NETWORK_READINESS_CHECK_INTERVAL_SECS);
std::thread::sleep(std::time::Duration::from_secs(jittered.max(1)));
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some(rx) = action_rx {
if let Ok(super::super::tray::TrayAction::Quit) = rx.try_recv() {
return false;
}
}
if probe_addr.to_socket_addrs().is_ok() {
log_wrapper_event(log_dir, "Network is ready");
return true;
}
}
log_wrapper_event(
log_dir,
"Network readiness timeout — starting node anyway (it will retry internally)",
);
true
}
fn run_wrapper_loop(
log_dir: &Path,
#[cfg(any(target_os = "windows", target_os = "macos"))] tray: Option<(
&std::sync::mpsc::Receiver<super::super::tray::TrayAction>,
&std::sync::mpsc::Sender<super::super::tray::WrapperStatus>,
)>,
#[cfg(not(any(target_os = "windows", target_os = "macos")))] _tray: Option<(
&std::sync::mpsc::Receiver<super::super::tray::TrayAction>,
&std::sync::mpsc::Sender<super::super::tray::WrapperStatus>,
)>,
) -> Result<()> {
#[cfg(any(target_os = "windows", target_os = "macos"))]
use super::super::tray::WrapperStatus;
let exe_path = std::env::current_exe().context("Failed to get current executable")?;
let mut state = WrapperState::new();
#[cfg(any(target_os = "windows", target_os = "macos"))]
if !wait_for_network_ready(log_dir, tray.map(|(rx, _)| rx)) {
return Ok(()); }
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
wait_for_network_ready(log_dir, None);
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some(marker) = first_run_marker_path() {
if is_first_run_at(&marker)
&& !FIRST_RUN_OPENER_SPAWNED.swap(true, std::sync::atomic::Ordering::SeqCst)
{
spawn_first_run_dashboard_opener(log_dir);
}
}
loop {
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((_, status_tx)) = tray {
status_tx.send(WrapperStatus::Running).ok();
}
let stderr_path = log_dir.join("freenet.error.log.last");
let stderr_file = std::fs::File::create(&stderr_path).ok();
let mut cmd = std::process::Command::new(&exe_path);
cmd.arg("network");
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::null());
}
if let Some(stderr_file) = stderr_file {
cmd.stderr(stderr_file);
} else {
#[cfg(target_os = "windows")]
cmd.stderr(std::process::Stdio::null());
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
log_wrapper_event(log_dir, &format!("Failed to spawn freenet network: {e}"));
return Err(e).context("Failed to spawn freenet network");
}
};
let exit_code = loop {
match child.try_wait() {
Ok(Some(status)) => break status.code().unwrap_or(1),
Ok(None) => {} Err(e) => {
log_wrapper_event(log_dir, &format!("Error waiting for child: {e}"));
break 1;
}
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((action_rx, status_tx)) = tray {
if let Ok(action) = action_rx.try_recv() {
match action {
super::super::tray::TrayAction::Quit => {
drop(child.kill());
drop(child.wait());
return Ok(());
}
super::super::tray::TrayAction::Restart => {
log_wrapper_event(log_dir, "Restart requested via tray");
drop(child.kill());
drop(child.wait());
break SENTINEL_RESTART;
}
super::super::tray::TrayAction::Stop => {
log_wrapper_event(log_dir, "Stop requested via tray");
drop(child.kill());
drop(child.wait());
break SENTINEL_STOP;
}
super::super::tray::TrayAction::Start => {
}
super::super::tray::TrayAction::ViewLogs => {
super::super::tray::open_log_file()
}
super::super::tray::TrayAction::CheckUpdate => {
status_tx.send(WrapperStatus::Updating).ok();
let result = spawn_update_command(&exe_path);
match result {
Ok(s) if s.success() => {
log_wrapper_event(
log_dir,
"Update installed via tray, restarting wrapper...",
);
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
drop(child.kill());
drop(child.wait());
if spawn_new_wrapper(&exe_path, log_dir) {
return Ok(());
}
break SENTINEL_RESTART;
}
Ok(s)
if s.code()
== Some(
super::super::update::EXIT_CODE_BUNDLE_UPDATE_STAGED,
) =>
{
log_wrapper_event(
log_dir,
"Bundle update staged; exiting for updater to take over",
);
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
drop(child.kill());
drop(child.wait());
return Ok(());
}
Ok(_) => {
log_wrapper_event(log_dir, "No update available");
status_tx.send(WrapperStatus::UpToDate).ok();
}
Err(e) => {
log_wrapper_event(
log_dir,
&format!("Update check failed: {e}"),
);
status_tx.send(WrapperStatus::Running).ok();
}
}
}
super::super::tray::TrayAction::OpenDashboard => {
open_url_in_browser(DASHBOARD_URL);
}
}
}
}
std::thread::sleep(std::time::Duration::from_millis(250));
};
if exit_code == SENTINEL_RESTART {
continue;
}
if exit_code == SENTINEL_STOP {
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((action_rx, status_tx)) = tray {
status_tx.send(WrapperStatus::Stopped).ok();
loop {
if let Ok(action) = action_rx.try_recv() {
match action {
super::super::tray::TrayAction::Start
| super::super::tray::TrayAction::Restart => {
log_wrapper_event(log_dir, "Start requested via tray");
break; }
super::super::tray::TrayAction::Quit => {
return Ok(());
}
super::super::tray::TrayAction::ViewLogs => {
super::super::tray::open_log_file();
}
super::super::tray::TrayAction::CheckUpdate => {
status_tx.send(WrapperStatus::Updating).ok();
let result = spawn_update_command(&exe_path);
match result {
Ok(s) if s.success() => {
log_wrapper_event(
log_dir,
"Update installed while stopped, restarting wrapper...",
);
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
if spawn_new_wrapper(&exe_path, log_dir) {
return Ok(());
}
break;
}
Ok(s)
if s.code()
== Some(
super::super::update::EXIT_CODE_BUNDLE_UPDATE_STAGED,
) =>
{
log_wrapper_event(
log_dir,
"Bundle update staged while stopped; exiting for updater",
);
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
return Ok(());
}
Ok(_) => {
log_wrapper_event(log_dir, "No update available");
status_tx.send(WrapperStatus::UpToDate).ok();
}
Err(e) => {
log_wrapper_event(
log_dir,
&format!("Update check failed: {e}"),
);
}
}
}
super::super::tray::TrayAction::OpenDashboard
| super::super::tray::TrayAction::Stop => {}
}
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
continue;
}
let is_port_conflict = stderr_path
.exists()
.then(|| std::fs::read_to_string(&stderr_path).unwrap_or_default())
.map(|s| s.contains("already in use"))
.unwrap_or(false);
let update_succeeded = if exit_code == WRAPPER_EXIT_UPDATE_NEEDED {
log_wrapper_event(log_dir, "Update needed, running freenet update...");
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((_, status_tx)) = tray {
status_tx.send(WrapperStatus::Updating).ok();
}
let result = spawn_update_command(&exe_path);
let outcome = super::super::update::classify_update_subprocess(&result);
match super::super::update::update_counter_action(outcome) {
super::super::update::UpdateCounterAction::Clear => {
super::super::auto_update::clear_update_failures();
}
super::super::update::UpdateCounterAction::Record => {
super::super::auto_update::record_update_failure();
}
super::super::update::UpdateCounterAction::NoChange => {}
}
if outcome == super::super::update::UpdateSubprocessOutcome::BundleUpdateStaged {
log_wrapper_event(
log_dir,
"Bundle update staged during auto-update; exiting for updater",
);
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((_, status_tx)) = tray {
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
}
return Ok(());
}
let ok = outcome == super::super::update::UpdateSubprocessOutcome::BinaryReplaced;
if ok {
log_wrapper_event(log_dir, "Update successful, restarting...");
} else {
log_wrapper_event(log_dir, "Update failed");
}
Some(ok)
} else {
None
};
let action = next_wrapper_action(&mut state, exit_code, is_port_conflict, update_succeeded);
if exit_code == WRAPPER_EXIT_ALREADY_RUNNING {
handle_persistent_stuck_relaunch(log_dir, exit_code, true);
}
match stuck_loop_action(
state.stuck_just_crossed,
state.signature_changed,
state.identical_failure_streak,
) {
StuckLoopAction::Write => {
let status = StuckWrapperStatus::new(
exit_code,
is_port_conflict,
state.identical_failure_streak,
);
log_wrapper_event(log_dir, &status.log_line());
write_stuck_status_file(log_dir, &status);
notify_stuck_wrapper(&status);
}
StuckLoopAction::Clear => {
clear_stuck_status_file(log_dir);
if state.identical_failure_streak == 0 {
clear_stuck_streak_file(log_dir);
}
}
StuckLoopAction::None => {}
}
match action {
WrapperAction::Update => {
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((_, status_tx)) = tray {
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
std::thread::sleep(std::time::Duration::from_secs(2));
}
if spawn_new_wrapper(&exe_path, log_dir) {
return Ok(());
}
}
WrapperAction::Exit => {
let reason = if exit_code == WRAPPER_EXIT_ALREADY_RUNNING {
"Another instance is already running, exiting cleanly"
} else if exit_code == 0 {
"Normal shutdown"
} else {
"Exiting"
};
log_wrapper_event(log_dir, reason);
return Ok(());
}
WrapperAction::KillAndRetry => {
log_wrapper_event(
log_dir,
&format!(
"Port conflict detected (attempt {}/{WRAPPER_MAX_PORT_CONFLICT_KILLS}) — killing stale process and retrying...",
state.port_conflict_kills,
),
);
kill_stale_freenet_processes(log_dir);
std::thread::sleep(std::time::Duration::from_secs(2));
}
WrapperAction::BackoffAndRelaunch { secs } => {
if state.consecutive_failures >= WRAPPER_MAX_CONSECUTIVE_FAILURES {
log_wrapper_event(
log_dir,
&format!(
"Giving up after {} consecutive failures. \
Run 'freenet network' manually to diagnose.",
state.consecutive_failures,
),
);
return Ok(());
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((_, status_tx)) = tray {
status_tx.send(WrapperStatus::Stopped).ok();
}
log_wrapper_event(
log_dir,
&format!("Exited with code {exit_code}, restarting after {secs}s backoff..."),
);
loop {
#[cfg(any(target_os = "windows", target_os = "macos"))]
let interrupt = sleep_with_jitter_interruptible(secs, tray.map(|(rx, _)| rx));
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let interrupt = sleep_with_jitter_interruptible(secs, None);
match interrupt {
BackoffInterrupt::Quit => return Ok(()),
BackoffInterrupt::Relaunch => {
log_wrapper_event(log_dir, "Relaunch requested during backoff");
state.consecutive_failures = 0;
break; }
BackoffInterrupt::CheckUpdate => {
log_wrapper_event(log_dir, "Update check requested during backoff");
#[cfg(any(target_os = "windows", target_os = "macos"))]
if let Some((_, status_tx)) = tray {
status_tx.send(WrapperStatus::Updating).ok();
let result = spawn_update_command(&exe_path);
let outcome =
super::super::update::classify_update_subprocess(&result);
match super::super::update::update_counter_action(outcome) {
super::super::update::UpdateCounterAction::Clear => {
super::super::auto_update::clear_update_failures();
}
super::super::update::UpdateCounterAction::Record => {
super::super::auto_update::record_update_failure();
}
super::super::update::UpdateCounterAction::NoChange => {}
}
match outcome {
super::super::update::UpdateSubprocessOutcome::BinaryReplaced => {
log_wrapper_event(
log_dir,
"Update installed during backoff, restarting wrapper...",
);
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
if spawn_new_wrapper(&exe_path, log_dir) {
return Ok(());
}
state.consecutive_failures = 0;
break;
}
super::super::update::UpdateSubprocessOutcome::BundleUpdateStaged => {
log_wrapper_event(
log_dir,
"Bundle update staged during backoff; exiting for updater",
);
status_tx.send(WrapperStatus::UpdatedRestarting).ok();
return Ok(());
}
super::super::update::UpdateSubprocessOutcome::AlreadyUpToDate => {
log_wrapper_event(log_dir, "No update available");
status_tx.send(WrapperStatus::UpToDate).ok();
}
super::super::update::UpdateSubprocessOutcome::SpawnFailed => {
let msg = match &result {
Err(e) => {
format!("Update subprocess failed to spawn: {e}")
}
Ok(_) => {
"Update subprocess failed to spawn".to_string()
}
};
log_wrapper_event(log_dir, &msg);
status_tx.send(WrapperStatus::Stopped).ok();
}
super::super::update::UpdateSubprocessOutcome::OtherFailure => {
let msg = if let Ok(s) = &result {
format!(
"Update check failed with exit code {:?}",
s.code()
)
} else {
"Update check failed".to_string()
};
log_wrapper_event(log_dir, &msg);
status_tx.send(WrapperStatus::Stopped).ok();
}
}
}
continue;
}
BackoffInterrupt::Completed => break, }
}
}
}
}
}
const WRAPPER_LOG_RETENTION_DAYS: usize = 7;
pub(super) fn log_wrapper_event(log_dir: &Path, message: &str) {
use std::io::Write;
let now = chrono::Local::now();
let date_str = now.format("%Y-%m-%d");
let log_path = log_dir.join(format!("freenet-wrapper.{date_str}.log"));
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
let timestamp = now.format("%H:%M:%S");
drop(writeln!(file, "{timestamp}: {message}"));
}
eprintln!("{message}");
cleanup_old_wrapper_logs(log_dir);
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(super) struct StuckWrapperStatus {
pub(super) exit_code: i32,
pub(super) is_port_conflict: bool,
pub(super) failure_count: u32,
pub(super) last_error: String,
pub(super) recovery_hint: String,
}
impl StuckWrapperStatus {
pub(super) fn new(exit_code: i32, is_port_conflict: bool, failure_count: u32) -> Self {
let cause = if is_port_conflict {
format!("exit {exit_code} — the dashboard port is held by another process")
} else {
format!("exit {exit_code}")
};
let last_error =
format!("Node failed {failure_count} times in a row with the same cause: {cause}.");
let recovery_hint = if is_port_conflict {
"The background service looks stuck on an old process holding the port. \
Recover with: freenet service stop && freenet service start \
(or kill the stale 'freenet network' process holding the dashboard port)."
.to_string()
} else {
"The background service is repeatedly crashing on startup. \
Recover with: freenet service stop && freenet service start, \
then check the wrapper logs for the underlying error."
.to_string()
};
Self {
exit_code,
is_port_conflict,
failure_count,
last_error,
recovery_hint,
}
}
pub(super) fn log_line(&self) -> String {
format!(
"Wrapper appears stuck: {} {}",
self.last_error, self.recovery_hint
)
}
}
pub(super) fn write_stuck_status_file(log_dir: &Path, status: &StuckWrapperStatus) {
let path = log_dir.join(STUCK_STATUS_FILE_NAME);
match serde_json::to_string_pretty(status) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
eprintln!("Failed to write stuck-status file {}: {e}", path.display());
}
}
Err(e) => eprintln!("Failed to serialize stuck-status: {e}"),
}
}
pub(super) fn clear_stuck_status_file(log_dir: &Path) {
let path = log_dir.join(STUCK_STATUS_FILE_NAME);
if path.exists() {
drop(std::fs::remove_file(&path));
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(super) struct PersistentStuckStreak {
pub(super) exit_code: i32,
pub(super) is_port_conflict: bool,
pub(super) version: String,
pub(super) count: u32,
pub(super) updated_unix_secs: u64,
}
pub(super) fn persistent_streak_action(
prior: Option<&PersistentStuckStreak>,
exit_code: i32,
is_port_conflict: bool,
version: &str,
now_unix_secs: u64,
) -> (PersistentStuckStreak, bool) {
let continues = prior.is_some_and(|p| {
p.exit_code == exit_code
&& p.is_port_conflict == is_port_conflict
&& p.version == version
&& now_unix_secs.saturating_sub(p.updated_unix_secs) <= STUCK_STREAK_TTL_SECS
});
let count = if continues {
prior.map(|p| p.count).unwrap_or(0).saturating_add(1)
} else {
1
};
let just_crossed = count == WRAPPER_STUCK_NOTIFY_THRESHOLD;
let record = PersistentStuckStreak {
exit_code,
is_port_conflict,
version: version.to_string(),
count,
updated_unix_secs: now_unix_secs,
};
(record, just_crossed)
}
pub(super) fn read_stuck_streak_file(log_dir: &Path) -> Option<PersistentStuckStreak> {
let path = log_dir.join(STUCK_STREAK_FILE_NAME);
let json = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&json).ok()
}
pub(super) fn write_stuck_streak_file(log_dir: &Path, record: &PersistentStuckStreak) {
let path = log_dir.join(STUCK_STREAK_FILE_NAME);
match serde_json::to_string_pretty(record) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
eprintln!("Failed to write stuck-streak file {}: {e}", path.display());
}
}
Err(e) => eprintln!("Failed to serialize stuck-streak: {e}"),
}
}
pub(super) fn clear_stuck_streak_file(log_dir: &Path) {
let path = log_dir.join(STUCK_STREAK_FILE_NAME);
if path.exists() {
drop(std::fs::remove_file(&path));
}
}
fn now_unix_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn handle_persistent_stuck_relaunch(log_dir: &Path, exit_code: i32, is_port_conflict: bool) {
let prior = read_stuck_streak_file(log_dir);
let (record, just_crossed) = persistent_streak_action(
prior.as_ref(),
exit_code,
is_port_conflict,
env!("CARGO_PKG_VERSION"),
now_unix_secs(),
);
write_stuck_streak_file(log_dir, &record);
if just_crossed {
let status = StuckWrapperStatus::new(exit_code, is_port_conflict, record.count);
log_wrapper_event(log_dir, &status.log_line());
write_stuck_status_file(log_dir, &status);
notify_stuck_wrapper(&status);
}
}
fn notify_stuck_wrapper(status: &StuckWrapperStatus) {
#[cfg(target_os = "macos")]
{
let body = applescript_escape(&status.last_error);
let title = applescript_escape("Freenet service may be stuck");
let script = format!("display notification \"{body}\" with title \"{title}\"");
if let Err(e) = std::process::Command::new("osascript")
.arg("-e")
.arg(&script)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
eprintln!("Failed to post stuck-wrapper notification: {e}");
}
}
#[cfg(not(target_os = "macos"))]
{
let _ = status;
}
}
#[allow(dead_code)] pub(super) fn applescript_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn cleanup_old_wrapper_logs(log_dir: &Path) {
let entries = match std::fs::read_dir(log_dir) {
Ok(e) => e,
Err(_) => return,
};
let mut wrapper_logs: Vec<std::path::PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("freenet-wrapper.") && n.ends_with(".log"))
.unwrap_or(false)
})
.collect();
if wrapper_logs.len() <= WRAPPER_LOG_RETENTION_DAYS {
return;
}
wrapper_logs.sort();
let to_remove = wrapper_logs.len() - WRAPPER_LOG_RETENTION_DAYS;
for path in &wrapper_logs[..to_remove] {
drop(std::fs::remove_file(path));
}
}
pub(super) fn kill_stale_freenet_processes(log_dir: &Path) {
#[cfg(unix)]
{
let uid = std::process::Command::new("id")
.arg("-u")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
let uid = uid.trim();
let status = std::process::Command::new("pkill")
.args(["-f", "-u", uid, "freenet network"])
.status();
if let Ok(s) = status {
if s.success() {
log_wrapper_event(
log_dir,
"Killed stale freenet network process(es) on startup",
);
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
}
#[cfg(target_os = "windows")]
{
use super::windows::{FreenetServiceProcess, kill_freenet_processes_matching};
if kill_freenet_processes_matching(FreenetServiceProcess::Node) > 0 {
log_wrapper_event(
log_dir,
"Killed stale freenet network process(es) on startup",
);
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
}