diff --git a/native/linux/src/config.cpp b/native/linux/src/config.cpp index 665867e..d39da26 100644 --- a/native/linux/src/config.cpp +++ b/native/linux/src/config.cpp @@ -115,6 +115,13 @@ HostConfig HostConfig::parse(int argc, char** argv) { cfg.beam_path = body.substr(10); } else if (body.rfind("beam-app=", 0) == 0) { cfg.beam_app = body.substr(9); + } else if (body.rfind("restart-beam=", 0) == 0) { + auto v = body.substr(13); + cfg.restart_beam = !(v == "false" || v == "0"); + } else if (body.rfind("max-restart-attempts=", 0) == 0) { + cfg.restart_max_attempts = std::stoi(body.substr(21)); + } else if (body.rfind("restart-backoff-ms=", 0) == 0) { + cfg.restart_backoff_ms = static_cast(std::stoul(body.substr(19))); } else { fprintf(stderr, "unknown --edw flag: %s\n", a.c_str()); } @@ -157,6 +164,15 @@ void HostConfig::apply_ini() { if (auto v = ini.get("lifetime", "mode")) { lifetime = (*v == "coupled") ? Lifetime::Coupled : Lifetime::Reconnect; } + if (auto v = ini.get("lifetime", "restart_beam")) { + restart_beam = !(*v == "false" || *v == "0"); + } + if (auto v = ini.get("lifetime", "restart_max_attempts")) { + restart_max_attempts = std::stoi(*v); + } + if (auto v = ini.get("lifetime", "restart_backoff_ms")) { + restart_backoff_ms = static_cast(std::stoul(*v)); + } if (auto v = ini.get("beam", "enabled")) { beam_enabled = !(*v == "false" || *v == "0"); } diff --git a/native/linux/src/config.hpp b/native/linux/src/config.hpp index 52101ef..207f127 100644 --- a/native/linux/src/config.hpp +++ b/native/linux/src/config.hpp @@ -22,6 +22,10 @@ struct HostConfig { bool beam_enabled = true; std::map extra_env; std::vector forwarded_argv; + // Host-driven BEAM restart policy (replaces heart on host-first bundles). + bool restart_beam = true; + int restart_max_attempts = 0; + uint32_t restart_backoff_ms = 500; static HostConfig parse(int argc, char** argv); diff --git a/native/linux/src/host_controller.cpp b/native/linux/src/host_controller.cpp index f5b51c4..dd9c426 100644 --- a/native/linux/src/host_controller.cpp +++ b/native/linux/src/host_controller.cpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace { @@ -45,6 +46,10 @@ JsonObject* params_obj(JsonNode* params) { HostController::HostController(HostConfig config) : config_(std::move(config)) {} HostController::~HostController() { + if (restart_timer_id_ != 0) { + g_source_remove(restart_timer_id_); + restart_timer_id_ = 0; + } if (beam_pid_ > 0) { kill(beam_pid_, SIGTERM); beam_pid_ = 0; @@ -115,6 +120,7 @@ bool HostController::start() { void HostController::client_disconnected() { // BEAM-first/dev (`--edw-no-beam`): exit with the Elixir client. if (config_.lifetime == Lifetime::Coupled || config_.no_beam) { + quit_initiated_ = true; if (beam_pid_ > 0) { kill(beam_pid_, SIGTERM); beam_pid_ = 0; @@ -183,7 +189,66 @@ void HostController::spawn_beam() { fprintf(stderr, "edw: failed to spawn beam: %s\n", err ? err->message : "unknown"); if (err) g_error_free(err); beam_pid_ = 0; + return; } + // Reset counter when we successfully spawn a fresh BEAM. + beam_restart_attempts_ = 0; + // Watch the child; when BEAM exits, decide whether to respawn it (mirrors + // the Swift HostController.terminationHandler path). + g_child_watch_add(beam_pid_, + +[](GPid pid, gint /*status*/, gpointer user_data) -> void { + auto* self = static_cast(user_data); + self->beam_did_exit(); + }, + this); +} + +void HostController::beam_did_exit() { + beam_pid_ = 0; + if (restart_timer_id_ != 0) { + g_source_remove(restart_timer_id_); + restart_timer_id_ = 0; + } + if (quit_initiated_) { + return; + } + if (expected_beam_exit_) { + expected_beam_exit_ = false; + return; + } + if (should_respawn_beam()) { + schedule_beam_respawn(); + } +} + +bool HostController::should_respawn_beam() { + if (!config_.restart_beam) return false; + if (config_.restart_max_attempts > 0 && + beam_restart_attempts_ >= config_.restart_max_attempts) { + fprintf(stderr, "edw: beam exited; restart limit reached, terminating host\n"); + g_main_loop_quit(nullptr); + return false; + } + return true; +} + +void HostController::schedule_beam_respawn() { + beam_restart_attempts_ += 1; + int shift = std::min(beam_restart_attempts_ - 1, 4); + uint32_t multiplier = static_cast(1) << shift; + uint32_t backoff = std::min(config_.restart_backoff_ms * multiplier, 5000u); + + restart_timer_id_ = g_timeout_add( + static_cast(backoff), + +[](gpointer user_data) -> gboolean { + auto* self = static_cast(user_data); + self->restart_timer_id_ = 0; + if (self->config_.beam_enabled && !self->config_.no_beam) { + self->spawn_beam(); + } + return G_SOURCE_REMOVE; + }, + this); } void HostController::handle_request(JsonNode* id, const std::string& method, JsonNode* params, @@ -797,6 +862,13 @@ JsonNode* HostController::dispatch(const std::string& method, JsonNode* params) std::string desc = std::string("Linux ") + u.release; return jsonutil::string_node(desc); } + if (method == "system.prepare_quit") { + // Elixir signals a clean shutdown. Mark so the next BEAM exit is not + // treated as a crash and does not trigger host-driven respawn. + quit_initiated_ = true; + expected_beam_exit_ = true; + return jsonutil::bool_node(true); + } if (method == "system.set_permission_policy") { auto origin = jsonutil::object_get_string(p, "origin"); if (!origin) throw HostError{-32602, "origin"}; diff --git a/native/linux/src/host_controller.hpp b/native/linux/src/host_controller.hpp index 17a09cb..1fee5f3 100644 --- a/native/linux/src/host_controller.hpp +++ b/native/linux/src/host_controller.hpp @@ -47,6 +47,12 @@ class HostController { private: void client_disconnected(); void spawn_beam(); + // Called from a glib child-watch source whenever BEAM exits. + void beam_did_exit(); + // Decide whether to respawn BEAM (mirrors the Swift logic). + bool should_respawn_beam(); + // Schedule a delayed respawn via glib main-loop timer. + void schedule_beam_respawn(); std::string next_id(const std::string& prefix); void handle_request(JsonNode* id, const std::string& method, JsonNode* params, @@ -88,4 +94,9 @@ class HostController { std::map> permission_policy_; GPid beam_pid_ = 0; GtkApplication* app_ = nullptr; + // Respawn bookkeeping (mirror of Swift HostController). + bool expected_beam_exit_ = false; + bool quit_initiated_ = false; + int beam_restart_attempts_ = 0; + guint restart_timer_id_ = 0; }; diff --git a/native/macos/Sources/DesktopWebView/Config.swift b/native/macos/Sources/DesktopWebView/Config.swift index e090760..8f7fbcd 100644 --- a/native/macos/Sources/DesktopWebView/Config.swift +++ b/native/macos/Sources/DesktopWebView/Config.swift @@ -14,6 +14,15 @@ struct HostConfig { var beamEnabled: Bool = true var extraEnv: [String: String] = [:] var forwardedArgv: [String] = [] + /// When true, the host re-spawns BEAM after it exits unexpectedly. + /// Driven by the host-first macOS bundle: replaces the `heart` watchdog. + var restartBeam: Bool = true + /// Maximum number of consecutive BEAM respawns before the host gives up. + /// `0` means retry indefinitely. + var restartMaxAttempts: Int = 0 + /// Initial backoff between respawn attempts (ms). Doubles per attempt, + /// capped at 5000 ms. + var restartBackoffMs: UInt32 = 500 enum Lifetime: String { case reconnect @@ -53,6 +62,12 @@ struct HostConfig { cfg.beamPath = String(body.dropFirst(10)) } else if body.hasPrefix("beam-app=") { cfg.beamApp = String(body.dropFirst(9)) + } else if body.hasPrefix("restart-beam=") { + cfg.restartBeam = (body.dropFirst(13) != "false" && body.dropFirst(13) != "0") + } else if body.hasPrefix("max-restart-attempts=") { + cfg.restartMaxAttempts = Int(body.dropFirst(21)) ?? 0 + } else if body.hasPrefix("restart-backoff-ms=") { + cfg.restartBackoffMs = UInt32(body.dropFirst(19)) ?? 500 } else { fputs("unknown --edw flag: \(a)\n", stderr) } @@ -75,6 +90,15 @@ struct HostConfig { if let v = ini["network", "host"] { host = v } if let v = ini["network", "port"], let p = UInt16(v) { port = p } if let v = ini["lifetime", "mode"], let l = Lifetime(rawValue: v) { lifetime = l } + if let v = ini["lifetime", "restart_beam"] { + restartBeam = !(v == "false" || v == "0") + } + if let v = ini["lifetime", "restart_max_attempts"], let n = Int(v) { + restartMaxAttempts = n + } + if let v = ini["lifetime", "restart_backoff_ms"], let n = UInt32(v) { + restartBackoffMs = n + } if let v = ini["beam", "enabled"] { beamEnabled = !(v == "false" || v == "0") } diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 5914f08..1321741 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -27,6 +27,13 @@ final class HostController: NSObject { private(set) var quitRequested = false /// When true, `applicationShouldTerminate` may finish tearing down the host. private(set) var readyToTerminate = false + /// Set by `system.prepare_quit` so a BEAM exit during this window is + /// treated as a clean shutdown (no host-driven respawn). + private var expectedBeamExitUntil: Date? = nil + /// Number of times the host has respawned BEAM in this process's lifetime. + private var beamRestartAttempts: Int = 0 + /// Pending restart timer; cancelled if the host quits before it fires. + private var restartTimer: DispatchSourceTimer? = nil init(config: HostConfig) { self.config = config @@ -104,6 +111,10 @@ final class HostController: NSObject { func finishQuit() { readyToTerminate = true + restartTimer?.cancel() + restartTimer = nil + // Force the host-kills-BEAM path into the "do not respawn" branch. + expectedBeamExitUntil = Date().addingTimeInterval(3.0) beamProcess?.terminate() NSApp.reply(toApplicationShouldTerminate: true) NSApp.terminate(nil) @@ -136,6 +147,9 @@ final class HostController: NSObject { ($0 as NSString).isAbsolutePath ? $0 : (root as NSString).appendingPathComponent($0) } ?? beamDir proc.currentDirectoryURL = URL(fileURLWithPath: wd) + proc.terminationHandler = { [weak self] _ in + DispatchQueue.main.async { self?.beamDidExit() } + } do { try proc.run() beamProcess = proc @@ -144,6 +158,47 @@ final class HostController: NSObject { } } + /// Handle BEAM exit while the host is still alive. Decide whether to respawn + /// based on whether the exit looked intentional (`system.prepare_quit`) + /// and whether we have a maximum-attempts budget left. + private func beamDidExit() { + beamProcess = nil + restartTimer?.cancel() + restartTimer = nil + if quitRequested { + // Host initiated the quit; do not respawn. + return + } + if let until = expectedBeamExitUntil, until > Date() { + // Elixir sent `system.prepare_quit` and exited within the window. + // Treat as a clean shutdown. + return + } + if !config.restartBeam { + return + } + if config.restartMaxAttempts > 0, beamRestartAttempts >= config.restartMaxAttempts { + fputs("edw: beam exited; restart limit reached, terminating host\n", stderr) + NSApp.terminate(nil) + return + } + beamRestartAttempts += 1 + let shift = min(beamRestartAttempts - 1, 4) + let multiplier = UInt32(1 << shift) + let backoff = min(config.restartBackoffMs * multiplier, 5_000) + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + .milliseconds(Int(backoff))) + timer.setEventHandler { [weak self] in + guard let self else { return } + self.restartTimer = nil + if self.config.beamEnabled && !self.config.noBeam { + self.spawnBeam() + } + } + restartTimer = timer + timer.resume() + } + private func firstBin(in dir: String) -> String? { guard let files = try? FileManager.default.contentsOfDirectory(atPath: dir) else { return nil } return files.sorted().first { !$0.hasPrefix(".") && !$0.hasSuffix(".bat") } @@ -385,8 +440,10 @@ final class HostController: NSObject { let v = ProcessInfo.processInfo.operatingSystemVersionString return .string("macOS \(v)") case "system.prepare_quit": - // Elixir is about to halt; mark so TCP disconnect finishes host teardown. + // Elixir is about to halt; mark so the upcoming BEAM exit is treated + // as a clean shutdown (no host-driven respawn). quitRequested = true + expectedBeamExitUntil = Date().addingTimeInterval(3.0) return .bool(true) case "system.set_permission_policy": guard let origin = params?["origin"]?.stringValue else { throw HostError(-32602, "origin") }