Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions native/linux/src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint32_t>(std::stoul(body.substr(19)));
} else {
fprintf(stderr, "unknown --edw flag: %s\n", a.c_str());
}
Expand Down Expand Up @@ -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<uint32_t>(std::stoul(*v));
}
if (auto v = ini.get("beam", "enabled")) {
beam_enabled = !(*v == "false" || *v == "0");
}
Expand Down
4 changes: 4 additions & 0 deletions native/linux/src/config.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ struct HostConfig {
bool beam_enabled = true;
std::map<std::string, std::string> extra_env;
std::vector<std::string> 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);

Expand Down
72 changes: 72 additions & 0 deletions native/linux/src/host_controller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <fstream>
#include <sys/utsname.h>
#include <unistd.h>
#include <algorithm>

namespace {

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<HostController*>(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<uint32_t>(1) << shift;
uint32_t backoff = std::min(config_.restart_backoff_ms * multiplier, 5000u);

restart_timer_id_ = g_timeout_add(
static_cast<guint>(backoff),
+[](gpointer user_data) -> gboolean {
auto* self = static_cast<HostController*>(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,
Expand Down Expand Up @@ -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"};
Expand Down
11 changes: 11 additions & 0 deletions native/linux/src/host_controller.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -88,4 +94,9 @@ class HostController {
std::map<std::string, std::map<std::string, std::string>> 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;
};
24 changes: 24 additions & 0 deletions native/macos/Sources/DesktopWebView/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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")
}
Expand Down
59 changes: 58 additions & 1 deletion native/macos/Sources/DesktopWebView/HostController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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") }
Expand Down Expand Up @@ -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") }
Expand Down
Loading