From fc6a1fd8f0cf889d55fab2aef50f184ed91aab0a Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Wed, 9 Sep 2026 20:08:37 +0200 Subject: [PATCH 1/4] Add --edw-rpc and Mix eval recovery after BEAM startup crashes. The host evaluates Elixir on a running node with erl_call. After three startup crashes the host runs Mix eval, then starts again. Co-authored-by: Cursor --- README.md | 2 + docs/packaging.md | 45 ++ docs/porting.md | 7 + docs/protocol.md | 4 + docs/specs/feature-beam-restart.md | 180 ++++++++ docs/specs/feature-edw-rpc.md | 165 +++++++ docs/specs/tests-beam-restart.yaml | 43 ++ docs/specs/tests-edw-rpc.yaml | 33 ++ docs/status/linux.md | 3 + docs/status/macos.md | 3 + docs/status/windows.md | 4 +- lib/desktop_webview/launcher.ex | 33 +- mix.exs | 4 +- native/linux/CMakeLists.txt | 1 + native/linux/src/beam_cli.cpp | 381 ++++++++++++++++ native/linux/src/beam_cli.hpp | 14 + native/linux/src/config.cpp | 17 + native/linux/src/config.hpp | 7 + native/linux/src/host_controller.cpp | 28 +- native/linux/src/host_controller.hpp | 3 +- native/linux/src/main.cpp | 3 + .../Sources/DesktopWebView/BeamCli.swift | 312 +++++++++++++ .../macos/Sources/DesktopWebView/Config.swift | 32 ++ .../DesktopWebView/HostController.swift | 22 +- .../macos/Sources/DesktopWebView/main.swift | 4 + native/windows/CMakeLists.txt | 1 + native/windows/src/beam_cli.cpp | 416 ++++++++++++++++++ native/windows/src/beam_cli.hpp | 11 + native/windows/src/config.cpp | 24 + native/windows/src/config.hpp | 7 + native/windows/src/host_controller.cpp | 26 +- native/windows/src/host_controller.hpp | 2 +- native/windows/src/main.cpp | 6 + test/e2e/restart_test.exs | 139 ++++++ test/e2e/rpc_test.exs | 75 ++++ test/support/beam_fixture.ex | 172 ++++++++ 36 files changed, 2196 insertions(+), 33 deletions(-) create mode 100644 docs/specs/feature-beam-restart.md create mode 100644 docs/specs/feature-edw-rpc.md create mode 100644 docs/specs/tests-beam-restart.yaml create mode 100644 docs/specs/tests-edw-rpc.yaml create mode 100644 native/linux/src/beam_cli.cpp create mode 100644 native/linux/src/beam_cli.hpp create mode 100644 native/macos/Sources/DesktopWebView/BeamCli.swift create mode 100644 native/windows/src/beam_cli.cpp create mode 100644 native/windows/src/beam_cli.hpp create mode 100644 test/e2e/restart_test.exs create mode 100644 test/e2e/rpc_test.exs create mode 100644 test/support/beam_fixture.ex diff --git a/README.md b/README.md index 18b881c..0831c2d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ See [docs/packaging.md](docs/packaging.md). - [Protocol](docs/protocol.md) — framing, methods, behavioral semantics, test RPC - [Porting](docs/porting.md) — checklist for Windows / Linux hosts - [Packaging](docs/packaging.md) — ini, argv, layouts, binaries +- [`--edw-rpc`](docs/specs/feature-edw-rpc.md) — one-shot Elixir via `erl_call` +- [BEAM restart / `--edw-recover`](docs/specs/feature-beam-restart.md) - [Desktop integration](docs/desktop-integration.md) - [AGENTS.md](AGENTS.md) — contributor / agent rules diff --git a/docs/packaging.md b/docs/packaging.md index 286bc9a..367ebd1 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -99,6 +99,10 @@ app_name = my_app args = start working_dir = beam enabled = true +# Optional overrides for --edw-rpc (else releases/COOKIE + vm.args) +# node = my_app@127.0.0.1 +# cookie = secret +# cookie_file = releases/COOKIE [network] host = 127.0.0.1 @@ -106,12 +110,21 @@ port = 0 [lifetime] mode = reconnect +restart_beam = true +restart_max_attempts = 0 +restart_backoff_ms = 500 +recovery_after = 3 +# recovery_script = recovery.exs [env] # Extra environment for the BEAM child # FOO = bar ``` +One-shot CLI (`--edw-rpc`, `--edw-recover`) does not listen, print +`listening`, or spawn `start`. See [feature-edw-rpc.md](specs/feature-edw-rpc.md) +and [feature-beam-restart.md](specs/feature-beam-restart.md). + ## CLI (`--edw-*`) All host options use the `edw` prefix. They are **stripped** before remaining @@ -127,6 +140,13 @@ argv is forwarded to the BEAM release. | `--edw-test-rpc` | Enable `test.*` JSON-RPC methods | | `--edw-beam-path=DIR` | Override beam release directory | | `--edw-beam-app=NAME` | Override release script name | +| `--edw-rpc ` | One-shot Elixir eval on the running node via `erl_call` | +| `--edw-recover` | One-shot Mix `eval` of `recovery_script` (no application start) | +| `--edw-recovery-script=PATH` | Recovery `.exs` path | +| `--edw-recovery-after=N` | Startup crashes before automatic recovery (default 3) | +| `--edw-restart-beam=true\|false` | Respawn BEAM after unexpected exit (default true) | +| `--edw-max-restart-attempts=N` | Cap consecutive unexpected exits (`0` = no cap) | +| `--edw-restart-backoff-ms=N` | Initial backoff; doubles, cap 5000 ms | Forwarded argv example: @@ -148,6 +168,31 @@ DesktopWebView --edw-port=0 -- --foo bar if lifetime is `reconnect` — the VM owns the host process. Reset session UI first. +### Host-driven BEAM restart + +Packaged mode (`restart_beam`, default true) respawns the release after an +unexpected child exit. Consecutive attempt counters reset only on a successful +`initialize`, not on spawn. + +Backoff after unexpected exit *n* (1-based): +`min(restart_backoff_ms * 2^min(n-1, 4), 5000)`. + +If `restart_max_attempts > 0` and consecutive unexpected exits reach that cap, +the host exits. `0` means no cap. + +A **startup crash** is a child exit before `initialize`. After +`recovery_after` (default 3) consecutive startup crashes, if `recovery_script` +is set, the host runs Mix release `eval`: + +```text +{beam}/bin/{app} eval "Code.eval_file(\"ABS_PATH\")" +``` + +OTP and Elixir load; the application does not start. Then the host respawns +`start`. `--edw-recover` runs that same `eval` without starting the UI. + +`--edw-rpc` and `--edw-recover` are mutually exclusive. + ## Binaries | Platform | Delivery | Artifact name | diff --git a/docs/porting.md b/docs/porting.md index 6565817..43e41f0 100644 --- a/docs/porting.md +++ b/docs/porting.md @@ -43,6 +43,11 @@ Do **not** copy macOS UI code into other platforms — share only the protocol. 9. **OS events** — reopen / open URL / open file where the OS supports them 10. **Packaged BEAM spawn** + **CI artifact** on tag draft releases 11. **Test RPC** behind `--edw-test-rpc`; run shared E2E +12. **`--edw-rpc`** — one-shot Elixir via erts `erl_call` (cookie/node from the + release). No UI. See [specs/feature-edw-rpc.md](specs/feature-edw-rpc.md). +13. **BEAM restart + `--edw-recover`** — shared backoff, reset counters on + `initialize`, Mix `eval` recovery script. See + [specs/feature-beam-restart.md](specs/feature-beam-restart.md). ## HTML file inputs and file-manager drag-and-drop @@ -115,6 +120,8 @@ Before flipping a status row to `done`, the corresponding E2E (or an added E2E) | Permissions + JS eval | `permission policy and simulate` | | HTML file input DOM contract | `HTML file input fixture exposes chooser semantics` | | Locale / OS string | `system locale and os_description` | +| `--edw-rpc` | `test/e2e/rpc_test.exs` | +| Restart / `--edw-recover` | `test/e2e/restart_test.exs` | Platform-specific asserts (e.g. `caps["platform"] == "macos"`) must be generalized when the second host lands — use `:os.type()` / host `initialize.platform`. diff --git a/docs/protocol.md b/docs/protocol.md index eae584f..f81bbb0 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -71,6 +71,10 @@ Notification (no `id`): client disconnects (and kills BEAM when the host exits in packaged mode). BEAM-first / `--edw-no-beam` (dev) always exits the host on client disconnect. +`--edw-rpc` and `--edw-recover` are process-shell commands, not JSON-RPC. +They do not listen. See [packaging.md](packaging.md) and +[specs/feature-edw-rpc.md](specs/feature-edw-rpc.md). + ## Behavioral semantics These rules are normative for every platform host. If macOS behavior and this diff --git a/docs/specs/feature-beam-restart.md b/docs/specs/feature-beam-restart.md new file mode 100644 index 0000000..a9e5185 --- /dev/null +++ b/docs/specs/feature-beam-restart.md @@ -0,0 +1,180 @@ +# BEAM restart and recovery Specification v0.1.0 + +> **Spec type:** Feature +> **Path:** `docs/specs/feature-beam-restart.md` + +## Overview + +The native host respawns a packaged BEAM child after an unexpected exit, with +shared backoff and attempt limits. After a run of startup crashes it may run a +configured Elixir recovery script through Mix release `eval` (OTP and Elixir +load; the application does not start). `--edw-recover` runs that same `eval` +path as a one-shot CLI. + +**Integration context:** Host-first packaged spawn in +`native/{macos,windows,linux}/` `HostController` plus process-shell CLI. +Replaces `heart` for desktop bundles. + +## Design Principles + +1. **One contract on every OS.** Same ini keys, flags, backoff formula, and + counters. +2. **Reset on `initialize` only.** Do not reset attempt counters on spawn. +3. **Recovery is Mix `eval`, not `start` or `rpc`.** The broken application + must not boot. +4. **`--edw-recover` is the same helper** as automatic recovery, for tests and + manual use. +5. **`--edw-no-beam` does not spawn, restart, or recover.** + +--- + +## Output Structure + +**Do generate:** host respawn + recovery, `--edw-recover`, packaging docs, +Elixir E2E. + +**Do not generate:** JSON-RPC methods, native unit-test frameworks. + +--- + +## Type Conventions + +| Spec type | Meaning | Examples | +|-----------|---------|----------| +| `milliseconds` | Integer delay | `500`, `5000` | +| `count` | Non-negative integer | `0` = no cap for max attempts | +| `recovery_script` | Path to `.exs` | `recovery.exs` | + +### Normalization + +- Relative `recovery_script` resolves like `beam.path`. +- CLI `--edw-recovery-script=` and ini `[lifetime] recovery_script` use the + existing ini-over-CLI merge for overlapping keys. `--edw-recover` is CLI only. +- `--edw-recover` honors `--edw-config`, `--edw-beam-path`, `--edw-beam-app`. + +--- + +## Error Handling + +| Condition | Result | +|-----------|--------| +| `--edw-rpc` and `--edw-recover` together | non-zero, no eval | +| `--edw-recover` and no script / missing file | non-zero, no `start` | +| Automatic recovery `eval` fails | log stderr, still respawn `start` | +| Restart cap reached | host process exits | + +--- + +## Restart policy + +Defaults: + +- `restart_beam` = true +- `restart_max_attempts` = 0 (no cap) +- `restart_backoff_ms` = 500 +- `recovery_after` = 3 +- `recovery_script` unset (automatic recovery off) + +Backoff after consecutive unexpected exit *n* (1-based): + +`min(restart_backoff_ms * 2^min(n-1, 4), 5000)` + +So 500, 1000, 2000, 4000, then 5000 ms. + +**Startup crash:** child exits and `initialize` has not succeeded for that +child. Capture this **before** session reset (reset clears `initialized`). + +**Runtime crash:** child exits after a successful `initialize`. + +**Clean exit:** `system.prepare_quit` window, or host-initiated quit. Do not +respawn. + +On successful `initialize`: set `restart_attempts = 0` and +`startup_failures = 0`. + +On unexpected exit, if `restart_beam`: + +1. If startup crash: `startup_failures += 1`. If `recovery_script` is set and + `recovery_after > 0` and `startup_failures >= recovery_after`, run recovery + `eval`, then set `startup_failures = 0`. +2. `restart_attempts += 1`. If `restart_max_attempts > 0` and + `restart_attempts >= restart_max_attempts`, exit the host (no further + spawn). +3. Else wait backoff and spawn `start` again. + +Do not run recovery on runtime crashes (`initialize` already reset +`startup_failures`). + +`recovery_after = 0` disables automatic recovery. `--edw-recover` still works. + +--- + +## Recovery `eval` + +Command (Unix): + +```text +{beam}/bin/{app} eval "Code.eval_file(\"ABS_PATH\")" +``` + +Windows: `{app}.bat eval ...` through `cmd.exe /c` as for `start`. + +Working directory: beam working_dir or beam dir. Extra `[env]` from ini. +Do not require `EDW_PORT`. + +This is Mix **eval**: OTP + Elixir, application **not** started. + +--- + +## API Surface (Behaviors) + +### `--edw-recover` → eval exit_code + +One-shot. No UI, no `listening`, no `start`. + +**Behavior:** + +| Condition | Output | +|-----------|--------| +| Script present | run recovery `eval`, forward stdio, exit with eval status | +| Script missing | non-zero | +| Combined with `--edw-rpc` | non-zero | + +Automatic crash-loop recovery MUST call this same helper. + +### Ini `[lifetime]` + +| Key | Default | Role | +|-----|---------|------| +| `restart_beam` | true | Enable respawn | +| `restart_max_attempts` | 0 | Cap consecutive unexpected exits | +| `restart_backoff_ms` | 500 | Initial backoff | +| `recovery_script` | unset | Path to `.exs` | +| `recovery_after` | 3 | Startup crashes before automatic eval | + +CLI: `--edw-restart-beam=`, `--edw-max-restart-attempts=`, +`--edw-restart-backoff-ms=`, `--edw-recovery-script=`, +`--edw-recovery-after=`, `--edw-recover`. + +--- + +## Testing + +Cases live in [tests-beam-restart.yaml](tests-beam-restart.yaml). Shared Elixir +E2E is the source of truth. + +## Generated Documentation + +Packaging lifetime section, porting checklist, status matrix rows. + +## Implementation Checklist + +- [ ] Counters reset only on `initialize` +- [ ] Windows parses the same restart CLI flags as macOS/Linux +- [ ] Recovery helper shared with `--edw-recover` +- [ ] E2E for recover CLI, crash loop, and max attempts +- [ ] Status `done` only when E2E is green + +## Version History + +- **v0.1.0** - Initial specification diff --git a/docs/specs/feature-edw-rpc.md b/docs/specs/feature-edw-rpc.md new file mode 100644 index 0000000..3f11975 --- /dev/null +++ b/docs/specs/feature-edw-rpc.md @@ -0,0 +1,165 @@ +# `--edw-rpc` Specification v0.1.0 + +> **Spec type:** Feature +> **Path:** `docs/specs/feature-edw-rpc.md` + +## Overview + +The native `DesktopWebView` binary exposes a one-shot `--edw-rpc ` CLI. +It evaluates an Elixir expression on a **running** packaged BEAM node through +erts `erl_call`, prints the inspected return value, and exits. + +**Integration context:** Host process shell in `native/{macos,windows,linux}/`. +Config discovery follows [docs/packaging.md](../packaging.md). This is not +JSON-RPC (`docs/protocol.md`). + +## Design Principles + +1. **One-shot, no UI.** `--edw-rpc` does not listen, print `listening`, spawn + `start`, or create a window. +2. **Elixir in, inspect out.** The public expression is Elixir. The host wraps + it for `erl_call`. Stdout is `Kernel.inspect/1` of the value plus a newline. +3. **Release files supply cookie and node.** Ini may override. The host does + not invent a cookie. +4. **Do not start BEAM.** If the node is down, exit non-zero. +5. **Same contract on every OS.** macOS, Windows, and Linux use the same flags, + discovery order, and exit codes. +6. **Mutually exclusive with `--edw-recover`.** + +--- + +## Output Structure + +**Do generate:** native CLI handling, packaging docs, Elixir E2E. + +**Do not generate:** JSON-RPC methods, native unit-test frameworks, a second +RPC protocol. + +--- + +## Type Conventions + +| Spec type | Meaning | Examples | +|-----------|---------|----------| +| `elixir_expr` | UTF-8 Elixir source | `1+1`, `node()` | +| `node_name` | Erlang node | `my_app@127.0.0.1`, short `my_app` | +| `cookie` | Distribution cookie string | contents of `releases/COOKIE` | +| `exit_code` | Process status | `0` success, non-zero failure | + +### Normalization + +- `--edw-rpc ` (next argv) and `--edw-rpc=` are the same. +- Relative `beam.path` resolves from the resources / executable directory as + in packaging.md. +- A node name with `@` from `-name` is a long name. `-sname` is a short name. + +--- + +## Error Handling + +| Platform host | Error style | +|---------------|-------------| +| Native CLI | Message on stderr, non-zero process exit | + +| Condition | Exit | Stderr | +|-----------|------|--------| +| `--edw-rpc` and `--edw-recover` together | non-zero | mutually exclusive | +| Missing expression | non-zero | usage | +| `erl_call` not found | non-zero | path search failed | +| Cookie or node not found | non-zero | discovery failed | +| Node down / `erl_call` fails / eval error | `erl_call` status | `erl_call` stderr | + +--- + +## Discovery + +Search order is the same on every OS. + +**Cookie** + +1. Ini `[beam] cookie` +2. Ini `[beam] cookie_file` (file contents, trim newline) +3. `{beam}/releases/COOKIE` +4. `-setcookie` in `vm.args` + +**Node** + +1. Ini `[beam] node` +2. `-name` or `-sname` in `{beam}/releases//vm.args` (`start_erl.data` or + first `releases/*/vm.args`) + +**`erl_call` binary** (`.exe` on Windows) + +1. `{beam}/erts-*/bin/erl_call` +2. `{beam}/lib/erl_interface-*/bin/erl_call` +3. `PATH` + +--- + +## API Surface (Behaviors) + +### `--edw-rpc ` → stdout + exit_code + +Evaluate `expr` on the running node. + +**Arguments:** + +- `expr`: Elixir source. Required. + +**Behavior:** + +| Condition | Output | +|-----------|--------| +| Success | `inspect(value)` and a newline on stdout and stderr, exit 0 | +| Node down | non-zero | +| Combined with `--edw-recover` | non-zero, no eval | + +**Eval method:** Base64-encode `expr`. Pipe Erlang to `erl_call -c ` +with `-name ` (long) or `-sname ` (short). Pass `-r` and +`-no_result_term`. Do **not** pass `-s` (that starts a node). The host writes +`Kernel.inspect/1` of the value to stdout (a temp file is allowed; `io:format` +does not reach a pipe). + +```erlang +Bin = base64:decode(<<"...">>), +{Val, _} = 'Elixir.Code':eval_string(Bin), +io:format("~ts~n", ['Elixir.Kernel':inspect(Val)]). +``` + +Do not `halt` the remote node. + +**Examples:** + +- `--edw-rpc '1+1'` → stdout `2` +- `--edw-rpc 'node()'` → the remote node name + +**Edge cases:** + +- Empty expression → error +- Quotes and newlines in `expr` → Base64 wrap, no shell interpolation of the + remote source + +--- + +## Testing + +Cases live in [tests-edw-rpc.yaml](tests-edw-rpc.yaml). Elixir E2E under +`test/e2e/` MUST implement them. Hosts MUST NOT add XCTest / gtest as the +source of truth. + +## Generated Documentation + +Packaging CLI table and ini `[beam] node` / `cookie` keys. Porting checklist +row for `--edw-rpc`. + +## Implementation Checklist + +- [ ] macOS / Windows / Linux one-shot CLI +- [ ] Discovery order implemented +- [ ] Mutual exclusion with `--edw-recover` +- [ ] E2E cases from tests-edw-rpc.yaml +- [ ] Status row `done` only when E2E is green + +## Version History + +- **v0.1.0** - Initial specification diff --git a/docs/specs/tests-beam-restart.yaml b/docs/specs/tests-beam-restart.yaml new file mode 100644 index 0000000..f19c7cb --- /dev/null +++ b/docs/specs/tests-beam-restart.yaml @@ -0,0 +1,43 @@ +# Input mapping: packaged host (no --edw-no-beam) or one-shot --edw-recover. +# Implementations MUST cover these in test/e2e/ (tag :e2e). + +edw_recover: + - name: "runs Mix eval without start or listening" + input: + flags: ["--edw-recover"] + recovery_script: writes_marker.exs + output: + exit: 0 + marker_written: true + start_invoked: false + listening: false + + - name: "fails when recovery_script is missing" + input: + flags: ["--edw-recover"] + recovery_script: null + output: + exit_nonzero: true + +beam_restart: + - name: "runs recovery eval after three startup crashes then start succeeds" + input: + recovery_after: 3 + recovery_script: writes_marker.exs + restart_backoff_ms: 50 + start_exits_until_marker: true + output: + start_failures: 3 + eval_count: 1 + then_start_stays_up: true + + - name: "stops after three crashes when max attempts is 3 and no recovery script" + input: + restart_max_attempts: 3 + recovery_script: null + restart_backoff_ms: 50 + start_always_exits: true + output: + start_failures: 3 + eval_count: 0 + host_exits: true diff --git a/docs/specs/tests-edw-rpc.yaml b/docs/specs/tests-edw-rpc.yaml new file mode 100644 index 0000000..ff48fd7 --- /dev/null +++ b/docs/specs/tests-edw-rpc.yaml @@ -0,0 +1,33 @@ +# Input mapping: each case is a host process invocation (no GUI). +# Implementations MUST cover these in test/e2e/ (tag :e2e). + +edw_rpc: + - name: "inspects 1+1 as 2" + input: + expr: "1+1" + node: running_test_node + output: + stdout_inspect: "2" + exit: 0 + + - name: "evaluates a module on the test node" + input: + expr: "DesktopWebview.Binary.available?()" + node: running_test_node + output: + stdout_inspect: "true" + exit: 0 + + - name: "fails when the node name is wrong" + input: + expr: "1+1" + node: "missing_edw_rpc@127.0.0.1" + output: + exit_nonzero: true + + - name: "rejects --edw-rpc together with --edw-recover" + input: + flags: ["--edw-rpc", "1+1", "--edw-recover"] + output: + exit_nonzero: true + no_listening: true diff --git a/docs/status/linux.md b/docs/status/linux.md index a94cf33..0c4c36b 100644 --- a/docs/status/linux.md +++ b/docs/status/linux.md @@ -34,5 +34,8 @@ Host: GTK 4 + WebKitGTK 6 (`native/linux/`). Binary delivery via GitHub Releases | Camera in webview | done | E2E via test RPC + fixture | | HTML `` and file-manager drag-and-drop | partial | WebKitGTK default chooser and drag handling; native picker and file-manager checks pending | | Test RPC channel | done | `--edw-test-rpc` | +| `--edw-rpc` (erl_call) | done | One-shot Elixir eval; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | +| Host-driven BEAM restart + backoff | done | Reset counters on `initialize`; shared E2E | +| Startup recovery script and `--edw-recover` | done | Mix `eval`; [feature-beam-restart.md](../specs/feature-beam-restart.md) | | Release artifact download | todo | | | CI build | done | ubuntu-latest + xvfb | diff --git a/docs/status/macos.md b/docs/status/macos.md index bdd0bd5..1ba25a4 100644 --- a/docs/status/macos.md +++ b/docs/status/macos.md @@ -37,5 +37,8 @@ manual-only with justification). | Dialog prompt | done | `NSAlert` + text field (manual) | | EventBridge Env/Window/Menu | done | Elixir unit coverage | | Test RPC channel | done | `--edw-test-rpc` | +| `--edw-rpc` (erl_call) | done | One-shot Elixir eval; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | +| Host-driven BEAM restart + backoff | done | Reset counters on `initialize`; E2E | +| Startup recovery script and `--edw-recover` | done | Mix `eval`; [feature-beam-restart.md](../specs/feature-beam-restart.md) | | Universal binary in priv | done | CI | | Ad-hoc codesign | done | | diff --git a/docs/status/windows.md b/docs/status/windows.md index 24121dc..db127ed 100644 --- a/docs/status/windows.md +++ b/docs/status/windows.md @@ -34,7 +34,9 @@ Release asset: `DesktopWebView-windows-x64.exe` (GitHub Releases; not Hex `priv/ | Camera in webview | done | Permission RPC + WebView2 kinds | | Native dialogs (`dialog.choose_file/dir`) | done | IFileOpenDialog + Win32 prompt | | HTML `` and Explorer drag-and-drop | partial | WebView2 built-in picker and drag handling; native picker and Explorer checks pending | -| Host-driven BEAM restart | done | `restart_beam` ini + process wait | +| Host-driven BEAM restart + backoff | done | Reset counters on `initialize`; shared E2E | +| `--edw-rpc` (erl_call) | done | One-shot Elixir eval; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | +| Startup recovery script and `--edw-recover` | done | Mix `eval`; [feature-beam-restart.md](../specs/feature-beam-restart.md) | | Test RPC channel | done | E2E | | Release artifact download | todo | Elixir fetch/cache still pending | | Ad-hoc / CI signing | todo | Authenticode via desktop_deployment later | diff --git a/lib/desktop_webview/launcher.ex b/lib/desktop_webview/launcher.ex index 931642e..c98a17c 100644 --- a/lib/desktop_webview/launcher.ex +++ b/lib/desktop_webview/launcher.ex @@ -14,6 +14,8 @@ defmodule DesktopWebview.Launcher do * `:port` — `--edw-port` (default 0) * `:lifetime` — `:reconnect` | `:coupled` * `:extra_args` — additional argv + * `:no_beam` — pass `--edw-no-beam` (default true) + * `:timeout` — wait for `listening` (ms) """ def start(opts \\ []) do binary = Keyword.get(opts, :binary) || DesktopWebview.Binary.path() @@ -22,7 +24,8 @@ defmodule DesktopWebview.Launcher do {:error, {:binary_missing, binary}} else args = - ["--edw-no-beam", "--edw-port=#{Keyword.get(opts, :port, 0)}"] ++ + no_beam_args(opts) ++ + ["--edw-port=#{Keyword.get(opts, :port, 0)}"] ++ test_rpc_args(opts) ++ lifetime_args(opts) ++ Keyword.get(opts, :extra_args, []) @@ -38,12 +41,26 @@ defmodule DesktopWebview.Launcher do ] ) + os_pid = + case Port.info(port, :os_pid) do + {:os_pid, pid} -> pid + _ -> nil + end + case await_listening(port, Keyword.get(opts, :timeout, 10_000)) do {:ok, listen_port} -> # Keep draining host stdout/stderr so WebKit logs cannot fill the pipe. drain_pid = spawn_link(fn -> drain_port(port) end) true = Port.connect(port, drain_pid) - {:ok, %{port: port, listen_port: listen_port, binary: binary, drain_pid: drain_pid}} + + {:ok, + %{ + port: port, + listen_port: listen_port, + binary: binary, + drain_pid: drain_pid, + os_pid: os_pid + }} {:error, reason} -> close_port(port) @@ -52,6 +69,14 @@ defmodule DesktopWebview.Launcher do end end + @doc """ + Run the host as a one-shot CLI (`--edw-rpc` / `--edw-recover`). Does not wait for `listening`. + """ + def oneshot(args, opts \\ []) when is_list(args) do + binary = Keyword.get(opts, :binary) || DesktopWebview.Binary.path() + System.cmd(binary, args, stderr_to_stdout: true) + end + def stop(%{port: port} = launcher) when is_port(port) do if pid = Map.get(launcher, :drain_pid) do Process.unlink(pid) @@ -77,6 +102,10 @@ defmodule DesktopWebview.Launcher do if Keyword.get(opts, :test_rpc, false), do: ["--edw-test-rpc"], else: [] end + defp no_beam_args(opts) do + if Keyword.get(opts, :no_beam, true), do: ["--edw-no-beam"], else: [] + end + defp lifetime_args(opts) do # BEAM-first launches use --edw-no-beam; default to coupled so stopping the # VM tears down the host (reconnect is for host-first packaged mode). diff --git a/mix.exs b/mix.exs index b4a62fd..ef94c2a 100644 --- a/mix.exs +++ b/mix.exs @@ -26,7 +26,9 @@ defmodule DesktopWebview.MixProject do "docs/desktop-integration.md", "docs/status/macos.md", "docs/status/windows.md", - "docs/status/linux.md" + "docs/status/linux.md", + "docs/specs/feature-edw-rpc.md", + "docs/specs/feature-beam-restart.md" ] ] ] diff --git a/native/linux/CMakeLists.txt b/native/linux/CMakeLists.txt index 448804a..0746a57 100644 --- a/native/linux/CMakeLists.txt +++ b/native/linux/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable(DesktopWebView src/rpc_server.cpp src/web_window.cpp src/host_controller.cpp + src/beam_cli.cpp ) target_include_directories(DesktopWebView PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) diff --git a/native/linux/src/beam_cli.cpp b/native/linux/src/beam_cli.cpp new file mode 100644 index 0000000..02073b4 --- /dev/null +++ b/native/linux/src/beam_cli.cpp @@ -0,0 +1,381 @@ +#include "beam_cli.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +extern char** environ; + +namespace beamcli { +namespace { + +std::string join_path(const std::string& a, const std::string& b) { + if (a.empty()) return b; + if (a.back() == '/') return a + b; + return a + "/" + b; +} + +bool is_absolute(const std::string& p) { return !p.empty() && p[0] == '/'; } + +bool file_exists(const std::string& path) { return g_file_test(path.c_str(), G_FILE_TEST_EXISTS); } + +std::string read_trimmed(const std::string& path) { + std::ifstream in(path); + if (!in) return {}; + std::ostringstream ss; + ss << in.rdbuf(); + std::string s = ss.str(); + while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ' || s.back() == '\t')) + s.pop_back(); + size_t i = 0; + while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) i++; + return s.substr(i); +} + +std::string resolved_beam_dir(const HostConfig& cfg) { + auto root = cfg.resources_root(); + if (!cfg.beam_path || cfg.beam_path->empty()) return join_path(root, "beam"); + return is_absolute(*cfg.beam_path) ? *cfg.beam_path : join_path(root, *cfg.beam_path); +} + +std::string resolved_working_dir(const HostConfig& cfg) { + auto beam = resolved_beam_dir(cfg); + if (!cfg.beam_working_dir || cfg.beam_working_dir->empty()) return beam; + return is_absolute(*cfg.beam_working_dir) ? *cfg.beam_working_dir + : join_path(cfg.resources_root(), *cfg.beam_working_dir); +} + +std::optional first_dir_prefix(const std::string& dir, const std::string& prefix) { + GDir* gdir = g_dir_open(dir.c_str(), 0, nullptr); + if (!gdir) return std::nullopt; + std::vector names; + const gchar* name; + while ((name = g_dir_read_name(gdir))) { + if (std::strncmp(name, prefix.c_str(), prefix.size()) == 0) names.emplace_back(name); + } + g_dir_close(gdir); + if (names.empty()) return std::nullopt; + std::sort(names.begin(), names.end()); + return names.front(); +} + +std::optional resolve_app_name(const HostConfig& cfg) { + if (cfg.beam_app && !cfg.beam_app->empty()) return *cfg.beam_app; + auto bin = join_path(resolved_beam_dir(cfg), "bin"); + GDir* gdir = g_dir_open(bin.c_str(), 0, nullptr); + if (!gdir) return std::nullopt; + std::optional found; + const gchar* name; + while ((name = g_dir_read_name(gdir))) { + if (name[0] == '.') continue; + std::string n = name; + if (n.size() >= 4 && (n.substr(n.size() - 4) == ".bat" || n.substr(n.size() - 4) == ".cmd")) continue; + found = n; + break; + } + g_dir_close(gdir); + return found; +} + +std::optional resolve_recovery_script(const HostConfig& cfg) { + if (!cfg.recovery_script || cfg.recovery_script->empty()) return std::nullopt; + std::string path = is_absolute(*cfg.recovery_script) + ? *cfg.recovery_script + : join_path(cfg.resources_root(), *cfg.recovery_script); + if (!g_file_test(path.c_str(), G_FILE_TEST_IS_REGULAR)) return std::nullopt; + return path; +} + +std::string eval_file_expr(const std::string& script_path) { + std::string posix = script_path; + for (char& c : posix) + if (c == '\\') c = '/'; + std::string escaped; + for (char c : posix) { + if (c == '\\' || c == '"') escaped.push_back('\\'); + escaped.push_back(c); + } + return "Code.eval_file(\"" + escaped + "\")"; +} + +std::string base64_encode(const std::string& in) { + gchar* enc = g_base64_encode(reinterpret_cast(in.data()), in.size()); + std::string out = enc ? enc : ""; + g_free(enc); + return out; +} + +struct NodeSpec { + std::string name; + bool short_name = false; +}; + +struct VmArgs { + std::optional node; + std::optional cookie; +}; + +std::optional vm_args_path(const std::string& beam_dir) { + auto releases = join_path(beam_dir, "releases"); + auto start_erl = read_trimmed(join_path(releases, "start_erl.data")); + if (!start_erl.empty()) { + std::istringstream ss(start_erl); + std::string erts, vsn; + if (ss >> erts >> vsn) { + auto p = join_path(join_path(releases, vsn), "vm.args"); + if (file_exists(p)) return p; + } + } + GDir* gdir = g_dir_open(releases.c_str(), 0, nullptr); + if (!gdir) return std::nullopt; + std::vector names; + const gchar* name; + while ((name = g_dir_read_name(gdir))) { + if (name[0] == '.') continue; + names.emplace_back(name); + } + g_dir_close(gdir); + std::sort(names.begin(), names.end()); + for (auto& n : names) { + auto p = join_path(join_path(releases, n), "vm.args"); + if (file_exists(p)) return p; + } + return std::nullopt; +} + +VmArgs parse_vm_args(const std::string& path) { + VmArgs out; + std::ifstream in(path); + if (!in) return out; + std::string line; + while (std::getline(in, line)) { + while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t')) + line.pop_back(); + size_t i = 0; + while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) i++; + if (i >= line.size() || line[i] == '#') continue; + std::istringstream ss(line.substr(i)); + std::string tok; + std::vector toks; + while (ss >> tok) toks.push_back(tok); + for (size_t t = 0; t + 1 < toks.size(); t++) { + if (toks[t] == "-sname") + out.node = NodeSpec{toks[t + 1], true}; + else if (toks[t] == "-name") + out.node = NodeSpec{toks[t + 1], false}; + else if (toks[t] == "-setcookie") + out.cookie = toks[t + 1]; + } + } + return out; +} + +std::optional find_erl_call(const std::string& beam_dir) { + if (auto erts = first_dir_prefix(beam_dir, "erts-")) { + auto p = join_path(join_path(beam_dir, *erts), "bin/erl_call"); + if (g_file_test(p.c_str(), G_FILE_TEST_IS_EXECUTABLE)) return p; + } + auto lib = join_path(beam_dir, "lib"); + if (auto ei = first_dir_prefix(lib, "erl_interface-")) { + auto p = join_path(join_path(lib, *ei), "bin/erl_call"); + if (g_file_test(p.c_str(), G_FILE_TEST_IS_EXECUTABLE)) return p; + } + gchar* found = g_find_program_in_path("erl_call"); + if (found) { + std::string p = found; + g_free(found); + return p; + } + return std::nullopt; +} + +std::optional find_cookie(const HostConfig& cfg, const std::string& beam_dir) { + if (cfg.beam_cookie && !cfg.beam_cookie->empty()) return *cfg.beam_cookie; + if (cfg.beam_cookie_file && !cfg.beam_cookie_file->empty()) { + std::string path = is_absolute(*cfg.beam_cookie_file) + ? *cfg.beam_cookie_file + : join_path(cfg.resources_root(), *cfg.beam_cookie_file); + auto t = read_trimmed(path); + if (!t.empty()) return t; + } + auto from_file = read_trimmed(join_path(join_path(beam_dir, "releases"), "COOKIE")); + if (!from_file.empty()) return from_file; + if (auto vm = vm_args_path(beam_dir)) { + auto parsed = parse_vm_args(*vm); + if (parsed.cookie) return parsed.cookie; + } + return std::nullopt; +} + +std::optional find_node(const HostConfig& cfg, const std::string& beam_dir) { + if (cfg.beam_node && !cfg.beam_node->empty()) { + bool short_name = cfg.beam_node->find('@') == std::string::npos; + return NodeSpec{*cfg.beam_node, short_name}; + } + if (auto vm = vm_args_path(beam_dir)) { + auto parsed = parse_vm_args(*vm); + if (parsed.node) return parsed.node; + } + return std::nullopt; +} + +int spawn_argv(const std::vector& argv, const std::string& wd, + const std::map& extra_env, const std::string* stdin_data) { + std::vector cargv; + for (auto& s : argv) cargv.push_back(const_cast(s.c_str())); + cargv.push_back(nullptr); + + std::vector env_store; + for (char** e = environ; e && *e; ++e) env_store.emplace_back(*e); + for (auto& [k, v] : extra_env) env_store.push_back(k + "=" + v); + std::vector envp; + for (auto& s : env_store) envp.push_back(s.data()); + envp.push_back(nullptr); + + GPid pid = 0; + gint stdin_fd = -1; + GError* err = nullptr; + GSpawnFlags flags = G_SPAWN_DO_NOT_REAP_CHILD; + if (!g_spawn_async_with_pipes(wd.c_str(), cargv.data(), envp.data(), flags, nullptr, nullptr, &pid, + stdin_data ? &stdin_fd : nullptr, nullptr, nullptr, &err)) { + fprintf(stderr, "edw: spawn failed: %s\n", err ? err->message : "unknown"); + if (err) g_error_free(err); + return 1; + } + if (stdin_data && stdin_fd >= 0) { + std::string payload = *stdin_data; + if (payload.empty() || payload.back() != '\n') payload.push_back('\n'); + const char* p = payload.data(); + size_t left = payload.size(); + while (left) { + ssize_t n = write(stdin_fd, p, left); + if (n <= 0) break; + p += n; + left -= static_cast(n); + } + close(stdin_fd); + } + int status = 0; + waitpid(pid, &status, 0); + g_spawn_close_pid(pid); + if (WIFEXITED(status)) return WEXITSTATUS(status); + return 1; +} + +} // namespace + +int run_recover(const HostConfig& cfg) { + auto script = resolve_recovery_script(cfg); + if (!script) { + fprintf(stderr, "edw: recovery_script is missing or not a file\n"); + return 1; + } + auto app = resolve_app_name(cfg); + if (!app) { + fprintf(stderr, "edw: no beam app_name and no bin script found in %s\n", + resolved_beam_dir(cfg).c_str()); + return 1; + } + auto bin = join_path(join_path(resolved_beam_dir(cfg), "bin"), *app); + if (!file_exists(bin)) { + fprintf(stderr, "edw: beam script not found: %s\n", bin.c_str()); + return 1; + } + std::vector argv{bin, "eval", eval_file_expr(*script)}; + return spawn_argv(argv, resolved_working_dir(cfg), cfg.extra_env, nullptr); +} + +int run_rpc(const HostConfig& cfg, const std::string& expr) { + auto beam_dir = resolved_beam_dir(cfg); + auto erl = find_erl_call(beam_dir); + if (!erl) { + fprintf(stderr, "edw: erl_call not found under %s or PATH\n", beam_dir.c_str()); + return 1; + } + auto cookie = find_cookie(cfg, beam_dir); + if (!cookie) { + fprintf(stderr, "edw: cookie not found (ini cookie/cookie_file, releases/COOKIE, or vm.args)\n"); + return 1; + } + auto node = find_node(cfg, beam_dir); + if (!node) { + fprintf(stderr, "edw: node not found (ini [beam] node or vm.args -name/-sname)\n"); + return 1; + } + auto b64 = base64_encode(expr); + char out_path[] = "/tmp/edw-rpc-out-XXXXXX"; + int out_fd = mkstemp(out_path); + if (out_fd < 0) { + fprintf(stderr, "edw: failed to create rpc output file\n"); + return 1; + } + close(out_fd); + std::string erlang = "Bin = base64:decode(<<\"" + b64 + + "\">>),\n{Val, _} = 'Elixir.Code':eval_string(Bin),\n" + "Inspect = 'Elixir.Kernel':inspect(Val),\n" + "ok = file:write_file(<<\"" + + std::string(out_path) + "\">>, Inspect).\n"; + std::vector argv{*erl, "-c", *cookie, "-r", "-no_result_term"}; + if (node->short_name) { + argv.push_back("-sname"); + } else { + argv.push_back("-name"); + } + argv.push_back(node->name); + argv.push_back("-e"); + int code = spawn_argv(argv, resolved_working_dir(cfg), cfg.extra_env, &erlang); + if (code == 0) { + std::ifstream in(out_path); + std::ostringstream ss; + ss << in.rdbuf(); + std::string text = ss.str(); + if (text.empty()) { + fprintf(stderr, "edw: erl_call succeeded but wrote no result file\n"); + unlink(out_path); + return 1; + } + if (text.back() != '\n') text.push_back('\n'); + fwrite(text.data(), 1, text.size(), stdout); + fflush(stdout); + fwrite(text.data(), 1, text.size(), stderr); + fflush(stderr); + } + unlink(out_path); + return code; +} + +bool maybe_run_exclusive(const HostConfig& cfg, int* exit_code) { + if (cfg.rpc_expr && cfg.recover) { + fprintf(stderr, "edw: --edw-rpc and --edw-recover are mutually exclusive\n"); + *exit_code = 1; + return true; + } + if (cfg.rpc_expr) { + if (cfg.rpc_expr->empty()) { + fprintf(stderr, "edw: --edw-rpc requires an Elixir expression\n"); + *exit_code = 1; + return true; + } + *exit_code = run_rpc(cfg, *cfg.rpc_expr); + return true; + } + if (cfg.recover) { + *exit_code = run_recover(cfg); + return true; + } + return false; +} + +} // namespace beamcli diff --git a/native/linux/src/beam_cli.hpp b/native/linux/src/beam_cli.hpp new file mode 100644 index 0000000..5a44451 --- /dev/null +++ b/native/linux/src/beam_cli.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include "config.hpp" + +namespace beamcli { + +// If --edw-rpc or --edw-recover is set, run it and return true with *exit_code. +// Returns false when the host should start the UI. +bool maybe_run_exclusive(const HostConfig& cfg, int* exit_code); + +int run_recover(const HostConfig& cfg); +int run_rpc(const HostConfig& cfg, const std::string& expr); + +} // namespace beamcli diff --git a/native/linux/src/config.cpp b/native/linux/src/config.cpp index d39da26..4d6a4db 100644 --- a/native/linux/src/config.cpp +++ b/native/linux/src/config.cpp @@ -122,6 +122,18 @@ HostConfig HostConfig::parse(int argc, char** argv) { 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 if (body == "recover") { + cfg.recover = true; + } else if (body == "rpc") { + if (i + 1 < argc) cfg.rpc_expr = argv[++i]; + else + cfg.rpc_expr = ""; + } else if (body.rfind("rpc=", 0) == 0) { + cfg.rpc_expr = body.substr(4); + } else if (body.rfind("recovery-script=", 0) == 0) { + cfg.recovery_script = body.substr(16); + } else if (body.rfind("recovery-after=", 0) == 0) { + cfg.recovery_after = std::stoi(body.substr(15)); } else { fprintf(stderr, "unknown --edw flag: %s\n", a.c_str()); } @@ -173,6 +185,8 @@ void HostConfig::apply_ini() { if (auto v = ini.get("lifetime", "restart_backoff_ms")) { restart_backoff_ms = static_cast(std::stoul(*v)); } + if (auto v = ini.get("lifetime", "recovery_script")) recovery_script = *v; + if (auto v = ini.get("lifetime", "recovery_after")) recovery_after = std::stoi(*v); if (auto v = ini.get("beam", "enabled")) { beam_enabled = !(*v == "false" || *v == "0"); } @@ -185,6 +199,9 @@ void HostConfig::apply_ini() { while (args >> tok) beam_args.push_back(tok); } if (auto v = ini.get("beam", "working_dir")) beam_working_dir = *v; + if (auto v = ini.get("beam", "node")) beam_node = *v; + if (auto v = ini.get("beam", "cookie")) beam_cookie = *v; + if (auto v = ini.get("beam", "cookie_file")) beam_cookie_file = *v; for (auto& [k, v] : ini.section("env")) { extra_env[k] = v; } diff --git a/native/linux/src/config.hpp b/native/linux/src/config.hpp index 207f127..387601a 100644 --- a/native/linux/src/config.hpp +++ b/native/linux/src/config.hpp @@ -26,6 +26,13 @@ struct HostConfig { bool restart_beam = true; int restart_max_attempts = 0; uint32_t restart_backoff_ms = 500; + std::optional rpc_expr; + bool recover = false; + std::optional recovery_script; + int recovery_after = 3; + std::optional beam_node; + std::optional beam_cookie; + std::optional beam_cookie_file; static HostConfig parse(int argc, char** argv); diff --git a/native/linux/src/host_controller.cpp b/native/linux/src/host_controller.cpp index 090011b..dc6875d 100644 --- a/native/linux/src/host_controller.cpp +++ b/native/linux/src/host_controller.cpp @@ -1,5 +1,6 @@ #include "host_controller.hpp" +#include "beam_cli.hpp" #include "json_util.hpp" #include @@ -216,8 +217,6 @@ void HostController::spawn_beam() { 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_, @@ -229,6 +228,7 @@ void HostController::spawn_beam() { } void HostController::beam_did_exit() { + bool was_initialized = initialized_; reset_session(); beam_pid_ = 0; if (restart_timer_id_ != 0) { @@ -242,24 +242,26 @@ void HostController::beam_did_exit() { expected_beam_exit_ = false; return; } - if (should_respawn_beam()) { - schedule_beam_respawn(); + if (!config_.restart_beam) return; + if (!was_initialized) { + startup_failures_ += 1; + if (config_.recovery_after > 0 && startup_failures_ >= config_.recovery_after && + config_.recovery_script) { + fprintf(stderr, "edw: startup crash limit reached; running recovery script\n"); + beamcli::run_recover(config_); + startup_failures_ = 0; + } } -} - -bool HostController::should_respawn_beam() { - if (!config_.restart_beam) return false; + beam_restart_attempts_ += 1; 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; + exit(1); } - return true; + schedule_beam_respawn(); } 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); @@ -693,6 +695,8 @@ JsonNode* HostController::dispatch(const std::string& method, JsonNode* params) if (method == "initialize") { reset_session(); initialized_ = true; + beam_restart_attempts_ = 0; + startup_failures_ = 0; JsonObject* caps = jsonutil::object_new(); json_object_set_boolean_member(caps, "window", TRUE); json_object_set_boolean_member(caps, "webview", TRUE); diff --git a/native/linux/src/host_controller.hpp b/native/linux/src/host_controller.hpp index 9aa94ea..605496b 100644 --- a/native/linux/src/host_controller.hpp +++ b/native/linux/src/host_controller.hpp @@ -50,8 +50,6 @@ class HostController { 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); @@ -99,5 +97,6 @@ class HostController { bool expected_beam_exit_ = false; bool quit_initiated_ = false; int beam_restart_attempts_ = 0; + int startup_failures_ = 0; guint restart_timer_id_ = 0; }; diff --git a/native/linux/src/main.cpp b/native/linux/src/main.cpp index e2a9e30..6a24851 100644 --- a/native/linux/src/main.cpp +++ b/native/linux/src/main.cpp @@ -1,5 +1,6 @@ #include "config.hpp" #include "host_controller.hpp" +#include "beam_cli.hpp" #include @@ -8,6 +9,8 @@ int main(int argc, char** argv) { auto config = HostConfig::parse(argc, argv); + int exclusive = 0; + if (beamcli::maybe_run_exclusive(config, &exclusive)) return exclusive; // Prefer software rendering when unset — WebKitGPU/DMA-BUF crashes are common on Xvfb. if (!g_getenv("WEBKIT_DISABLE_COMPOSITING_MODE")) diff --git a/native/macos/Sources/DesktopWebView/BeamCli.swift b/native/macos/Sources/DesktopWebView/BeamCli.swift new file mode 100644 index 0000000..51b8a93 --- /dev/null +++ b/native/macos/Sources/DesktopWebView/BeamCli.swift @@ -0,0 +1,312 @@ +import Darwin +import Foundation + +enum BeamCli { + struct NodeSpec { + var name: String + var short: Bool + } + + /// Runs `--edw-rpc` / `--edw-recover` when set. Returns an exit code, or nil to start the UI. + static func exclusiveExitCode(_ config: HostConfig) -> Int32? { + if config.rpcExpr != nil && config.recover { + fputs("edw: --edw-rpc and --edw-recover are mutually exclusive\n", stderr) + return 1 + } + if let expr = config.rpcExpr { + if expr.isEmpty { + fputs("edw: --edw-rpc requires an Elixir expression\n", stderr) + return 1 + } + return runRpc(config: config, expr: expr) + } + if config.recover { + return runRecover(config: config) + } + return nil + } + + static func resolvedBeamDir(_ config: HostConfig) -> String { + let root = config.resourcesRoot() + guard let path = config.beamPath, !path.isEmpty else { + return (root as NSString).appendingPathComponent("beam") + } + if (path as NSString).isAbsolutePath { return path } + return (root as NSString).appendingPathComponent(path) + } + + static func resolvedWorkingDir(_ config: HostConfig) -> String { + let beamDir = resolvedBeamDir(config) + guard let wd = config.beamWorkingDir, !wd.isEmpty else { return beamDir } + if (wd as NSString).isAbsolutePath { return wd } + return (config.resourcesRoot() as NSString).appendingPathComponent(wd) + } + + static func resolveAppName(_ config: HostConfig) -> String? { + if let name = config.beamApp, !name.isEmpty { return name } + let bin = (resolvedBeamDir(config) as NSString).appendingPathComponent("bin") + guard let files = try? FileManager.default.contentsOfDirectory(atPath: bin) else { return nil } + return files.sorted().first { !$0.hasPrefix(".") && !$0.hasSuffix(".bat") && !$0.hasSuffix(".cmd") } + } + + static func resolveRecoveryScript(_ config: HostConfig) -> String? { + guard let raw = config.recoveryScript, !raw.isEmpty else { return nil } + let path: String + if (raw as NSString).isAbsolutePath { + path = raw + } else { + path = (config.resourcesRoot() as NSString).appendingPathComponent(raw) + } + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), !isDir.boolValue else { + return nil + } + return path + } + + static func evalFileExpr(scriptPath: String) -> String { + let posix = scriptPath.replacingOccurrences(of: "\\", with: "/") + let escaped = posix + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "Code.eval_file(\"\(escaped)\")" + } + + @discardableResult + static func runRecover(config: HostConfig) -> Int32 { + guard let script = resolveRecoveryScript(config) else { + fputs("edw: recovery_script is missing or not a file\n", stderr) + return 1 + } + guard let app = resolveAppName(config) else { + fputs("edw: no beam app_name and no bin script found in \(resolvedBeamDir(config))\n", stderr) + return 1 + } + let beamDir = resolvedBeamDir(config) + var bin = (beamDir as NSString).appendingPathComponent("bin/\(app)") + if !FileManager.default.isExecutableFile(atPath: bin) && FileManager.default.fileExists(atPath: bin + ".bat") { + bin += ".bat" + } + guard FileManager.default.fileExists(atPath: bin) else { + fputs("edw: beam script not found: \(bin)\n", stderr) + return 1 + } + let proc = Process() + proc.executableURL = URL(fileURLWithPath: bin) + proc.arguments = ["eval", evalFileExpr(scriptPath: script)] + proc.currentDirectoryURL = URL(fileURLWithPath: resolvedWorkingDir(config)) + var env = ProcessInfo.processInfo.environment + for (k, v) in config.extraEnv { env[k] = v } + proc.environment = env + do { + try proc.run() + proc.waitUntilExit() + return proc.terminationStatus + } catch { + fputs("edw: failed to run recovery eval: \(error)\n", stderr) + return 1 + } + } + + static func runRpc(config: HostConfig, expr: String) -> Int32 { + let beamDir = resolvedBeamDir(config) + guard let erlCall = findErlCall(beamDir: beamDir) else { + fputs("edw: erl_call not found under \(beamDir) or PATH\n", stderr) + return 1 + } + guard let cookie = findCookie(config: config, beamDir: beamDir) else { + fputs("edw: cookie not found (ini cookie/cookie_file, releases/COOKIE, or vm.args)\n", stderr) + return 1 + } + guard let node = findNode(config: config, beamDir: beamDir) else { + fputs("edw: node not found (ini [beam] node or vm.args -name/-sname)\n", stderr) + return 1 + } + let b64 = Data(expr.utf8).base64EncodedString() + let outFile = FileManager.default.temporaryDirectory + .appendingPathComponent("edw-rpc-out-\(UUID().uuidString)") + let outPath = outFile.path.replacingOccurrences(of: "\\", with: "/") + let erlang = """ + Bin = base64:decode(<<"\(b64)">>), + {Val, _} = 'Elixir.Code':eval_string(Bin), + Inspect = 'Elixir.Kernel':inspect(Val), + ok = file:write_file(<<"\(outPath)">>, Inspect). + """ + var args = ["-c", cookie, "-r", "-no_result_term"] + if node.short { + args += ["-sname", node.name] + } else { + args += ["-name", node.name] + } + args.append("-e") + + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("edw-rpc-\(UUID().uuidString).erl") + var payload = erlang.trimmingCharacters(in: .whitespacesAndNewlines) + if !payload.hasSuffix(".") { payload += "." } + payload += "\n" + do { + try payload.write(to: tmp, atomically: true, encoding: .utf8) + } catch { + fputs("edw: failed to write erl_call input: \(error)\n", stderr) + return 1 + } + defer { + try? FileManager.default.removeItem(at: tmp) + try? FileManager.default.removeItem(at: outFile) + } + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: erlCall) + proc.arguments = args + proc.environment = ProcessInfo.processInfo.environment + do { + let readHandle = try FileHandle(forReadingFrom: tmp) + proc.standardInput = readHandle + proc.standardOutput = FileHandle(fileDescriptor: STDOUT_FILENO, closeOnDealloc: false) + proc.standardError = FileHandle(fileDescriptor: STDERR_FILENO, closeOnDealloc: false) + try proc.run() + proc.waitUntilExit() + try readHandle.close() + if proc.terminationStatus == 0, + let data = try? Data(contentsOf: outFile), + let text = String(data: data, encoding: .utf8) { + let line = text.hasSuffix("\n") ? text : text + "\n" + fputs(line, stdout) + fflush(stdout) + fputs(line, stderr) + fflush(stderr) + return 0 + } + if proc.terminationStatus == 0 { + fputs("edw: erl_call succeeded but wrote no result file\n", stderr) + return 1 + } + return proc.terminationStatus + } catch { + fputs("edw: failed to run erl_call: \(error)\n", stderr) + return 1 + } + } + + static func findErlCall(beamDir: String) -> String? { + let fm = FileManager.default + if let p = firstMatch(in: beamDir, directoryPrefix: "erts-", file: "bin/erl_call"), + fm.isExecutableFile(atPath: p) { + return p + } + let lib = (beamDir as NSString).appendingPathComponent("lib") + if let p = firstMatch(in: lib, directoryPrefix: "erl_interface-", file: "bin/erl_call"), + fm.isExecutableFile(atPath: p) { + return p + } + return which("erl_call") + } + + static func findCookie(config: HostConfig, beamDir: String) -> String? { + if let c = config.beamCookie, !c.isEmpty { return c } + if let file = config.beamCookieFile, !file.isEmpty { + let path = (file as NSString).isAbsolutePath + ? file + : (config.resourcesRoot() as NSString).appendingPathComponent(file) + if let text = readTrimmed(path) { return text } + } + if let text = readTrimmed((beamDir as NSString).appendingPathComponent("releases/COOKIE")) { + return text + } + if let vm = readVmArgs(beamDir: beamDir), let cookie = vm.cookie { + return cookie + } + return nil + } + + static func findNode(config: HostConfig, beamDir: String) -> NodeSpec? { + if let n = config.beamNode, !n.isEmpty { + let short = !n.contains("@") + return NodeSpec(name: n, short: short) + } + if let vm = readVmArgs(beamDir: beamDir), let node = vm.node { + return node + } + return nil + } + + private struct VmArgs { + var node: NodeSpec? + var cookie: String? + } + + private static func readVmArgs(beamDir: String) -> VmArgs? { + let releases = (beamDir as NSString).appendingPathComponent("releases") + var vmPath: String? + if let startErl = readTrimmed((releases as NSString).appendingPathComponent("start_erl.data")) { + let parts = startErl.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + if parts.count >= 2 { + vmPath = (releases as NSString).appendingPathComponent("\(parts[1])/vm.args") + } + } + if vmPath == nil { + if let vers = try? FileManager.default.contentsOfDirectory(atPath: releases) { + for v in vers.sorted() where !v.hasPrefix(".") { + let candidate = (releases as NSString).appendingPathComponent("\(v)/vm.args") + if FileManager.default.fileExists(atPath: candidate) { + vmPath = candidate + break + } + } + } + } + guard let vmPath, let text = try? String(contentsOfFile: vmPath, encoding: .utf8) else { return nil } + var out = VmArgs() + for raw in text.components(separatedBy: .newlines) { + let line = raw.trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix("#") { continue } + let toks = line.split(whereSeparator: { $0.isWhitespace }).map(String.init) + var i = 0 + while i < toks.count { + let t = toks[i] + if t == "-sname", i + 1 < toks.count { + out.node = NodeSpec(name: toks[i + 1], short: true) + i += 2 + continue + } + if t == "-name", i + 1 < toks.count { + out.node = NodeSpec(name: toks[i + 1], short: false) + i += 2 + continue + } + if t == "-setcookie", i + 1 < toks.count { + out.cookie = toks[i + 1] + i += 2 + continue + } + i += 1 + } + } + return out + } + + private static func firstMatch(in dir: String, directoryPrefix: String, file: String) -> String? { + guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir) else { return nil } + for name in names.sorted() where name.hasPrefix(directoryPrefix) { + let p = (dir as NSString).appendingPathComponent("\(name)/\(file)") + if FileManager.default.fileExists(atPath: p) { return p } + } + return nil + } + + private static func which(_ name: String) -> String? { + guard let path = ProcessInfo.processInfo.environment["PATH"] else { return nil } + for dir in path.split(separator: ":") { + let p = "\(dir)/\(name)" + if FileManager.default.isExecutableFile(atPath: p) { return p } + } + return nil + } + + private static func readTrimmed(_ path: String) -> String? { + guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/native/macos/Sources/DesktopWebView/Config.swift b/native/macos/Sources/DesktopWebView/Config.swift index 8f7fbcd..59ead08 100644 --- a/native/macos/Sources/DesktopWebView/Config.swift +++ b/native/macos/Sources/DesktopWebView/Config.swift @@ -23,6 +23,16 @@ struct HostConfig { /// Initial backoff between respawn attempts (ms). Doubles per attempt, /// capped at 5000 ms. var restartBackoffMs: UInt32 = 500 + /// One-shot Elixir expression for `--edw-rpc`. + var rpcExpr: String? = nil + /// One-shot Mix eval of `recoveryScript` (`--edw-recover`). + var recover: Bool = false + var recoveryScript: String? = nil + /// Consecutive startup crashes before automatic recovery. `0` disables auto recovery. + var recoveryAfter: Int = 3 + var beamNode: String? = nil + var beamCookie: String? = nil + var beamCookieFile: String? = nil enum Lifetime: String { case reconnect @@ -68,6 +78,21 @@ struct HostConfig { cfg.restartMaxAttempts = Int(body.dropFirst(21)) ?? 0 } else if body.hasPrefix("restart-backoff-ms=") { cfg.restartBackoffMs = UInt32(body.dropFirst(19)) ?? 500 + } else if body == "recover" { + cfg.recover = true + } else if body == "rpc" { + i += 1 + if i < argv.count { + cfg.rpcExpr = argv[i] + } else { + cfg.rpcExpr = "" + } + } else if body.hasPrefix("rpc=") { + cfg.rpcExpr = String(body.dropFirst(4)) + } else if body.hasPrefix("recovery-script=") { + cfg.recoveryScript = String(body.dropFirst(16)) + } else if body.hasPrefix("recovery-after=") { + cfg.recoveryAfter = Int(body.dropFirst(15)) ?? 3 } else { fputs("unknown --edw flag: \(a)\n", stderr) } @@ -99,6 +124,10 @@ struct HostConfig { if let v = ini["lifetime", "restart_backoff_ms"], let n = UInt32(v) { restartBackoffMs = n } + if let v = ini["lifetime", "recovery_script"] { recoveryScript = v } + if let v = ini["lifetime", "recovery_after"], let n = Int(v) { + recoveryAfter = n + } if let v = ini["beam", "enabled"] { beamEnabled = !(v == "false" || v == "0") } @@ -108,6 +137,9 @@ struct HostConfig { beamArgs = v.split(separator: " ").map(String.init) } if let v = ini["beam", "working_dir"] { beamWorkingDir = v } + if let v = ini["beam", "node"] { beamNode = v } + if let v = ini["beam", "cookie"] { beamCookie = v } + if let v = ini["beam", "cookie_file"] { beamCookieFile = v } for (k, v) in ini.section("env") { extraEnv[k] = v } diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 87e8e05..22b132d 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -32,8 +32,10 @@ final class HostController: NSObject, UNUserNotificationCenterDelegate { /// 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. + /// Number of consecutive unexpected BEAM exits since last `initialize`. private var beamRestartAttempts: Int = 0 + /// Consecutive child exits before `initialize` (startup crashes). + private var startupFailures: Int = 0 /// Pending restart timer; cancelled if the host quits before it fires. private var restartTimer: DispatchSourceTimer? = nil /// When true, `applicationShouldTerminate` cancels so last-window teardown @@ -217,6 +219,7 @@ final class HostController: NSObject, UNUserNotificationCenterDelegate { /// based on whether the exit looked intentional (`system.prepare_quit`) /// and whether we have a maximum-attempts budget left. private func beamDidExit() { + let wasInitialized = initialized resetSession() beamProcess = nil restartTimer?.cancel() @@ -233,12 +236,21 @@ final class HostController: NSObject, UNUserNotificationCenterDelegate { if !config.restartBeam { return } + if !wasInitialized { + startupFailures += 1 + if config.recoveryAfter > 0, + startupFailures >= config.recoveryAfter, + config.recoveryScript != nil { + fputs("edw: startup crash limit reached; running recovery script\n", stderr) + _ = BeamCli.runRecover(config: config) + startupFailures = 0 + } + } + beamRestartAttempts += 1 if config.restartMaxAttempts > 0, beamRestartAttempts >= config.restartMaxAttempts { fputs("edw: beam exited; restart limit reached, terminating host\n", stderr) - NSApp.terminate(nil) - return + exit(1) } - beamRestartAttempts += 1 let shift = min(beamRestartAttempts - 1, 4) let multiplier = UInt32(1 << shift) let backoff = min(config.restartBackoffMs * multiplier, 5_000) @@ -343,6 +355,8 @@ final class HostController: NSObject, UNUserNotificationCenterDelegate { case "initialize": resetSession() initialized = true + beamRestartAttempts = 0 + startupFailures = 0 return .object([ "protocol_version": .number(1), "platform": .string("macos"), diff --git a/native/macos/Sources/DesktopWebView/main.swift b/native/macos/Sources/DesktopWebView/main.swift index eb46544..8ec2694 100644 --- a/native/macos/Sources/DesktopWebView/main.swift +++ b/native/macos/Sources/DesktopWebView/main.swift @@ -40,6 +40,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } let config = HostConfig.parse(argv: CommandLine.arguments) +if let code = BeamCli.exclusiveExitCode(config) { + exit(code) +} + let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate diff --git a/native/windows/CMakeLists.txt b/native/windows/CMakeLists.txt index b066f1e..7e23f42 100644 --- a/native/windows/CMakeLists.txt +++ b/native/windows/CMakeLists.txt @@ -37,6 +37,7 @@ add_executable(DesktopWebView WIN32 src/rpc_server.cpp src/web_window.cpp src/host_controller.cpp + src/beam_cli.cpp ) target_include_directories(DesktopWebView PRIVATE diff --git a/native/windows/src/beam_cli.cpp b/native/windows/src/beam_cli.cpp new file mode 100644 index 0000000..04cd6d7 --- /dev/null +++ b/native/windows/src/beam_cli.cpp @@ -0,0 +1,416 @@ +#include "beam_cli.hpp" +#include "win_util.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace beamcli { +namespace { + +std::string read_trimmed(const std::string& path) { + std::ifstream in(path); + if (!in) return {}; + std::ostringstream ss; + ss << in.rdbuf(); + std::string s = ss.str(); + while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ' || s.back() == '\t')) + s.pop_back(); + size_t i = 0; + while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) i++; + return s.substr(i); +} + +std::string resolved_beam_dir(const HostConfig& cfg) { + auto root = cfg.resources_root(); + if (!cfg.beam_path || cfg.beam_path->empty()) return join_path(root, "beam"); + return is_absolute_path(*cfg.beam_path) ? *cfg.beam_path : join_path(root, *cfg.beam_path); +} + +std::string resolved_working_dir(const HostConfig& cfg) { + auto beam = resolved_beam_dir(cfg); + if (!cfg.beam_working_dir || cfg.beam_working_dir->empty()) return beam; + return is_absolute_path(*cfg.beam_working_dir) ? *cfg.beam_working_dir + : join_path(cfg.resources_root(), *cfg.beam_working_dir); +} + +std::vector list_dir(const std::string& dir) { + std::vector names; + WIN32_FIND_DATAA fd{}; + HANDLE h = FindFirstFileA((dir + "\\*").c_str(), &fd); + if (h == INVALID_HANDLE_VALUE) return names; + do { + if (fd.cFileName[0] == '.') continue; + names.emplace_back(fd.cFileName); + } while (FindNextFileA(h, &fd)); + FindClose(h); + std::sort(names.begin(), names.end()); + return names; +} + +std::optional first_dir_prefix(const std::string& dir, const std::string& prefix) { + for (auto& n : list_dir(dir)) { + if (n.rfind(prefix, 0) == 0) return n; + } + return std::nullopt; +} + +std::optional resolve_app_name(const HostConfig& cfg) { + if (cfg.beam_app && !cfg.beam_app->empty()) return *cfg.beam_app; + auto bin = join_path(resolved_beam_dir(cfg), "bin"); + std::string first_any; + std::string first_bat; + for (auto& name : list_dir(bin)) { + if (first_any.empty()) first_any = name; + auto lower = name; + for (auto& c : lower) c = static_cast(tolower(static_cast(c))); + if (first_bat.empty() && lower.size() >= 4 && + (lower.substr(lower.size() - 4) == ".bat" || lower.substr(lower.size() - 4) == ".cmd")) { + first_bat = name; + } + } + if (!first_bat.empty()) return first_bat; + if (!first_any.empty()) return first_any; + return std::nullopt; +} + +std::optional resolve_recovery_script(const HostConfig& cfg) { + if (!cfg.recovery_script || cfg.recovery_script->empty()) return std::nullopt; + std::string path = is_absolute_path(*cfg.recovery_script) + ? *cfg.recovery_script + : join_path(cfg.resources_root(), *cfg.recovery_script); + if (!file_exists(path)) return std::nullopt; + return path; +} + +std::string eval_file_expr(const std::string& script_path) { + std::string posix = script_path; + for (char& c : posix) + if (c == '\\') c = '/'; + std::string escaped; + for (char c : posix) { + if (c == '\\' || c == '"') escaped.push_back('\\'); + escaped.push_back(c); + } + return "Code.eval_file(\"" + escaped + "\")"; +} + +std::string base64_encode(const std::string& in) { + static const char tbl[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + int val = 0, valb = -6; + for (unsigned char c : in) { + val = (val << 8) + c; + valb += 8; + while (valb >= 0) { + out.push_back(tbl[(val >> valb) & 0x3F]); + valb -= 6; + } + } + if (valb > -6) out.push_back(tbl[((val << 8) >> (valb + 8)) & 0x3F]); + while (out.size() % 4) out.push_back('='); + return out; +} + +struct NodeSpec { + std::string name; + bool short_name = false; +}; + +struct VmArgs { + std::optional node; + std::optional cookie; +}; + +std::optional vm_args_path(const std::string& beam_dir) { + auto releases = join_path(beam_dir, "releases"); + auto start_erl = read_trimmed(join_path(releases, "start_erl.data")); + if (!start_erl.empty()) { + std::istringstream ss(start_erl); + std::string erts, vsn; + if (ss >> erts >> vsn) { + auto p = join_path(join_path(releases, vsn), "vm.args"); + if (file_exists(p)) return p; + } + } + for (auto& n : list_dir(releases)) { + auto p = join_path(join_path(releases, n), "vm.args"); + if (file_exists(p)) return p; + } + return std::nullopt; +} + +VmArgs parse_vm_args(const std::string& path) { + VmArgs out; + std::ifstream in(path); + if (!in) return out; + std::string line; + while (std::getline(in, line)) { + while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t')) + line.pop_back(); + size_t i = 0; + while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) i++; + if (i >= line.size() || line[i] == '#') continue; + std::istringstream ss(line.substr(i)); + std::string tok; + std::vector toks; + while (ss >> tok) toks.push_back(tok); + for (size_t t = 0; t + 1 < toks.size(); t++) { + if (toks[t] == "-sname") + out.node = NodeSpec{toks[t + 1], true}; + else if (toks[t] == "-name") + out.node = NodeSpec{toks[t + 1], false}; + else if (toks[t] == "-setcookie") + out.cookie = toks[t + 1]; + } + } + return out; +} + +std::optional find_erl_call(const std::string& beam_dir) { + if (auto erts = first_dir_prefix(beam_dir, "erts-")) { + auto p = join_path(join_path(join_path(beam_dir, *erts), "bin"), "erl_call.exe"); + if (file_exists(p)) return p; + p = join_path(join_path(join_path(beam_dir, *erts), "bin"), "erl_call"); + if (file_exists(p)) return p; + } + auto lib = join_path(beam_dir, "lib"); + if (auto ei = first_dir_prefix(lib, "erl_interface-")) { + auto p = join_path(join_path(join_path(lib, *ei), "bin"), "erl_call.exe"); + if (file_exists(p)) return p; + } + char buf[MAX_PATH]; + if (SearchPathA(nullptr, "erl_call.exe", nullptr, MAX_PATH, buf, nullptr)) return std::string(buf); + if (SearchPathA(nullptr, "erl_call", nullptr, MAX_PATH, buf, nullptr)) return std::string(buf); + return std::nullopt; +} + +std::optional find_cookie(const HostConfig& cfg, const std::string& beam_dir) { + if (cfg.beam_cookie && !cfg.beam_cookie->empty()) return *cfg.beam_cookie; + if (cfg.beam_cookie_file && !cfg.beam_cookie_file->empty()) { + std::string path = is_absolute_path(*cfg.beam_cookie_file) + ? *cfg.beam_cookie_file + : join_path(cfg.resources_root(), *cfg.beam_cookie_file); + auto t = read_trimmed(path); + if (!t.empty()) return t; + } + auto from_file = read_trimmed(join_path(join_path(beam_dir, "releases"), "COOKIE")); + if (!from_file.empty()) return from_file; + if (auto vm = vm_args_path(beam_dir)) { + auto parsed = parse_vm_args(*vm); + if (parsed.cookie) return parsed.cookie; + } + return std::nullopt; +} + +std::optional find_node(const HostConfig& cfg, const std::string& beam_dir) { + if (cfg.beam_node && !cfg.beam_node->empty()) { + bool short_name = cfg.beam_node->find('@') == std::string::npos; + return NodeSpec{*cfg.beam_node, short_name}; + } + if (auto vm = vm_args_path(beam_dir)) { + auto parsed = parse_vm_args(*vm); + if (parsed.node) return parsed.node; + } + return std::nullopt; +} + +std::wstring env_block(const std::map& extra) { + std::map env; + LPWCH strings = GetEnvironmentStringsW(); + if (strings) { + for (LPWCH p = strings; *p; p += wcslen(p) + 1) { + std::string entry = wide_to_utf8(p); + auto eq = entry.find('='); + if (eq != std::string::npos) env[entry.substr(0, eq)] = entry.substr(eq + 1); + } + FreeEnvironmentStringsW(strings); + } + for (auto& [k, v] : extra) env[k] = v; + std::wstring block; + for (auto& [k, v] : env) { + block += utf8_to_wide(k + "=" + v); + block.push_back(L'\0'); + } + block.push_back(L'\0'); + return block; +} + +int spawn_cmd(const std::string& cmdline, const std::string& wd, + const std::map& extra, const std::string* stdin_data) { + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + HANDLE stdin_r = nullptr, stdin_w = nullptr; + if (stdin_data) { + if (!CreatePipe(&stdin_r, &stdin_w, &sa, 0)) return 1; + SetHandleInformation(stdin_w, HANDLE_FLAG_INHERIT, 0); + } + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = SW_HIDE; + if (stdin_data) { + si.dwFlags |= STARTF_USESTDHANDLES; + si.hStdInput = stdin_r; + si.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE); + si.hStdError = GetStdHandle(STD_ERROR_HANDLE); + } + PROCESS_INFORMATION pi{}; + std::wstring wcmd = utf8_to_wide(cmdline); + std::vector mutable_cmd(wcmd.begin(), wcmd.end()); + mutable_cmd.push_back(L'\0'); + std::wstring wwd = utf8_to_wide(wd); + auto env = env_block(extra); + DWORD flags = CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW; + if (!CreateProcessW(nullptr, mutable_cmd.data(), nullptr, nullptr, TRUE, flags, env.data(), + wwd.empty() ? nullptr : wwd.c_str(), &si, &pi)) { + fprintf(stderr, "edw: spawn failed (%lu): %s\n", GetLastError(), cmdline.c_str()); + if (stdin_r) CloseHandle(stdin_r); + if (stdin_w) CloseHandle(stdin_w); + return 1; + } + if (stdin_r) CloseHandle(stdin_r); + if (stdin_data && stdin_w) { + DWORD written = 0; + WriteFile(stdin_w, stdin_data->data(), static_cast(stdin_data->size()), &written, nullptr); + CloseHandle(stdin_w); + } + WaitForSingleObject(pi.hProcess, INFINITE); + DWORD code = 1; + GetExitCodeProcess(pi.hProcess, &code); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + return static_cast(code); +} + +bool is_batch(const std::string& script) { + auto lower = script; + for (auto& c : lower) c = static_cast(tolower(static_cast(c))); + return lower.size() >= 4 && + (lower.substr(lower.size() - 4) == ".bat" || lower.substr(lower.size() - 4) == ".cmd"); +} + +std::string resolve_bin_script(const HostConfig& cfg) { + auto app = resolve_app_name(cfg); + if (!app) return {}; + auto script = join_path(join_path(resolved_beam_dir(cfg), "bin"), *app); + if (file_exists(script + ".bat")) return script + ".bat"; + if (file_exists(script + ".cmd")) return script + ".cmd"; + if (file_exists(script)) return script; + return {}; +} + +} // namespace + +int run_recover(const HostConfig& cfg) { + auto script_path = resolve_recovery_script(cfg); + if (!script_path) { + fprintf(stderr, "edw: recovery_script is missing or not a file\n"); + return 1; + } + auto bin = resolve_bin_script(cfg); + if (bin.empty()) { + fprintf(stderr, "edw: no beam app_name and no bin script found in %s\n", + resolved_beam_dir(cfg).c_str()); + return 1; + } + auto expr = eval_file_expr(*script_path); + std::ostringstream cmd; + if (is_batch(bin)) { + cmd << "cmd.exe /c \"" << bin << "\" eval \"" << expr << "\""; + } else { + cmd << '"' << bin << "\" eval \"" << expr << '"'; + } + return spawn_cmd(cmd.str(), resolved_working_dir(cfg), cfg.extra_env, nullptr); +} + +int run_rpc(const HostConfig& cfg, const std::string& expr) { + auto beam_dir = resolved_beam_dir(cfg); + auto erl = find_erl_call(beam_dir); + if (!erl) { + fprintf(stderr, "edw: erl_call not found under %s or PATH\n", beam_dir.c_str()); + return 1; + } + auto cookie = find_cookie(cfg, beam_dir); + if (!cookie) { + fprintf(stderr, "edw: cookie not found (ini cookie/cookie_file, releases/COOKIE, or vm.args)\n"); + return 1; + } + auto node = find_node(cfg, beam_dir); + if (!node) { + fprintf(stderr, "edw: node not found (ini [beam] node or vm.args -name/-sname)\n"); + return 1; + } + auto b64 = base64_encode(expr); + char tmp_dir[MAX_PATH]; + char out_path[MAX_PATH]; + if (!GetTempPathA(MAX_PATH, tmp_dir) || + !GetTempFileNameA(tmp_dir, "edw", 0, out_path)) { + fprintf(stderr, "edw: failed to create rpc output file\n"); + return 1; + } + std::string out_posix = out_path; + for (char& c : out_posix) + if (c == '\\') c = '/'; + std::string erlang = "Bin = base64:decode(<<\"" + b64 + + "\">>),\n{Val, _} = 'Elixir.Code':eval_string(Bin),\n" + "Inspect = 'Elixir.Kernel':inspect(Val),\n" + "ok = file:write_file(<<\"" + out_posix + "\">>, Inspect).\n"; + std::ostringstream cmd; + cmd << '"' << *erl << "\" -c \"" << *cookie << "\" -r -no_result_term "; + if (node->short_name) + cmd << "-sname "; + else + cmd << "-name "; + cmd << '"' << node->name << "\" -e"; + int code = spawn_cmd(cmd.str(), resolved_working_dir(cfg), cfg.extra_env, &erlang); + if (code == 0) { + std::ifstream in(out_path); + std::ostringstream ss; + ss << in.rdbuf(); + std::string text = ss.str(); + if (text.empty()) { + fprintf(stderr, "edw: erl_call succeeded but wrote no result file\n"); + DeleteFileA(out_path); + return 1; + } + if (text.back() != '\n') text.push_back('\n'); + fwrite(text.data(), 1, text.size(), stdout); + fflush(stdout); + fwrite(text.data(), 1, text.size(), stderr); + fflush(stderr); + } + DeleteFileA(out_path); + return code; +} + +bool maybe_run_exclusive(const HostConfig& cfg, int* exit_code) { + if (cfg.rpc_expr && cfg.recover) { + fprintf(stderr, "edw: --edw-rpc and --edw-recover are mutually exclusive\n"); + *exit_code = 1; + return true; + } + if (cfg.rpc_expr) { + if (cfg.rpc_expr->empty()) { + fprintf(stderr, "edw: --edw-rpc requires an Elixir expression\n"); + *exit_code = 1; + return true; + } + *exit_code = run_rpc(cfg, *cfg.rpc_expr); + return true; + } + if (cfg.recover) { + *exit_code = run_recover(cfg); + return true; + } + return false; +} + +} // namespace beamcli diff --git a/native/windows/src/beam_cli.hpp b/native/windows/src/beam_cli.hpp new file mode 100644 index 0000000..5151c03 --- /dev/null +++ b/native/windows/src/beam_cli.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include "config.hpp" + +namespace beamcli { + +bool maybe_run_exclusive(const HostConfig& cfg, int* exit_code); +int run_recover(const HostConfig& cfg); +int run_rpc(const HostConfig& cfg, const std::string& expr); + +} // namespace beamcli diff --git a/native/windows/src/config.cpp b/native/windows/src/config.cpp index 8bf3461..f42ccf3 100644 --- a/native/windows/src/config.cpp +++ b/native/windows/src/config.cpp @@ -99,6 +99,25 @@ 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 if (body == "recover") { + cfg.recover = true; + } else if (body == "rpc") { + if (i + 1 < argc) cfg.rpc_expr = argv[++i]; + else + cfg.rpc_expr = ""; + } else if (body.rfind("rpc=", 0) == 0) { + cfg.rpc_expr = body.substr(4); + } else if (body.rfind("recovery-script=", 0) == 0) { + cfg.recovery_script = body.substr(16); + } else if (body.rfind("recovery-after=", 0) == 0) { + cfg.recovery_after = std::stoi(body.substr(15)); } else { fprintf(stderr, "unknown --edw flag: %s\n", a.c_str()); } @@ -173,6 +192,11 @@ void HostConfig::apply_ini() { if (auto v = ini.get("lifetime", "restart_backoff_ms")) { restart_backoff_ms = static_cast(std::stoul(*v)); } + if (auto v = ini.get("lifetime", "recovery_script")) recovery_script = *v; + if (auto v = ini.get("lifetime", "recovery_after")) recovery_after = std::stoi(*v); + if (auto v = ini.get("beam", "node")) beam_node = *v; + if (auto v = ini.get("beam", "cookie")) beam_cookie = *v; + if (auto v = ini.get("beam", "cookie_file")) beam_cookie_file = *v; for (auto& [k, v] : ini.section("env")) { extra_env[k] = v; } diff --git a/native/windows/src/config.hpp b/native/windows/src/config.hpp index f3b0058..8c83a2e 100644 --- a/native/windows/src/config.hpp +++ b/native/windows/src/config.hpp @@ -24,6 +24,13 @@ struct HostConfig { bool restart_beam = true; int restart_max_attempts = 0; uint32_t restart_backoff_ms = 500; + std::optional rpc_expr; + bool recover = false; + std::optional recovery_script; + int recovery_after = 3; + std::optional beam_node; + std::optional beam_cookie; + std::optional beam_cookie_file; std::map extra_env; std::vector forwarded_argv; diff --git a/native/windows/src/host_controller.cpp b/native/windows/src/host_controller.cpp index 978a3da..43d1b36 100644 --- a/native/windows/src/host_controller.cpp +++ b/native/windows/src/host_controller.cpp @@ -1,4 +1,5 @@ #include "host_controller.hpp" +#include "beam_cli.hpp" #include "win_util.hpp" #include @@ -217,6 +218,7 @@ void HostController::watch_beam_process() { } void HostController::beam_did_exit() { + bool was_initialized = initialized_; clear_beam_watch(); if (beam_process_) { CloseHandle(beam_process_); @@ -232,24 +234,27 @@ void HostController::beam_did_exit() { expected_beam_exit_ = false; return; } - if (should_respawn_beam()) { - schedule_beam_respawn(); + if (!config_.restart_beam) return; + if (!was_initialized) { + startup_failures_ += 1; + if (config_.recovery_after > 0 && startup_failures_ >= config_.recovery_after && + config_.recovery_script) { + fprintf(stderr, "edw: startup crash limit reached; running recovery script\n"); + beamcli::run_recover(config_); + startup_failures_ = 0; + } } -} - -bool HostController::should_respawn_beam() { - if (!config_.restart_beam) return false; + beam_restart_attempts_ += 1; if (config_.restart_max_attempts > 0 && beam_restart_attempts_ >= config_.restart_max_attempts) { fprintf(stderr, "edw: beam exited; restart limit reached, terminating host\n"); PostQuitMessage(1); - return false; + return; } - return true; + schedule_beam_respawn(); } 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); @@ -398,7 +403,6 @@ void HostController::spawn_beam() { } CloseHandle(pi.hThread); beam_process_ = pi.hProcess; - beam_restart_attempts_ = 0; watch_beam_process(); } @@ -729,6 +733,8 @@ jsonutil::Json HostController::dispatch(const std::string& method, const jsonuti if (method == "initialize") { initialized_ = true; + beam_restart_attempts_ = 0; + startup_failures_ = 0; return jsonutil::Json{ {"protocol_version", 1}, {"platform", "windows"}, diff --git a/native/windows/src/host_controller.hpp b/native/windows/src/host_controller.hpp index f66355f..8389284 100644 --- a/native/windows/src/host_controller.hpp +++ b/native/windows/src/host_controller.hpp @@ -52,7 +52,6 @@ class HostController { void reset_session(); void spawn_beam(); void beam_did_exit(); - bool should_respawn_beam(); void schedule_beam_respawn(); void watch_beam_process(); void clear_beam_watch(); @@ -101,6 +100,7 @@ class HostController { bool expected_beam_exit_ = false; int id_counter_ = 0; int beam_restart_attempts_ = 0; + int startup_failures_ = 0; UINT next_menu_cmd_ = 1000; UINT_PTR respawn_timer_id_ = 0; std::map> windows_; diff --git a/native/windows/src/main.cpp b/native/windows/src/main.cpp index 86f213d..95b7ce1 100644 --- a/native/windows/src/main.cpp +++ b/native/windows/src/main.cpp @@ -2,6 +2,7 @@ #include "host_controller.hpp" #include "web_window.hpp" #include "win_util.hpp" +#include "beam_cli.hpp" #include #include @@ -35,6 +36,11 @@ int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) { for (auto& s : args) argv_ptrs.push_back(s.data()); auto config = HostConfig::parse(static_cast(argv_ptrs.size()), argv_ptrs.data()); + int exclusive = 0; + if (beamcli::maybe_run_exclusive(config, &exclusive)) { + CoUninitialize(); + return exclusive; + } WebWindow::register_class(); auto host = std::make_unique(std::move(config)); diff --git a/test/e2e/restart_test.exs b/test/e2e/restart_test.exs new file mode 100644 index 0000000..7e23d37 --- /dev/null +++ b/test/e2e/restart_test.exs @@ -0,0 +1,139 @@ +defmodule DesktopWebview.E2E.RestartTest do + use ExUnit.Case, async: false + + @moduletag :e2e + + alias DesktopWebview.{BeamFixture, Binary, Launcher} + + setup do + unless Binary.available?() do + flunk("DesktopWebView binary missing at #{Binary.path()}") + end + + :ok + end + + test "runs Mix eval without start or listening" do + root = BeamFixture.tmp_dir("edw-recover") + on_exit(fn -> File.rm_rf(root) end) + + fx = + BeamFixture.write_restart_fixture!(root, + lifetime_ini: "recovery_script = #{root}/recovery.exs" + ) + + {out, status} = + Launcher.oneshot([ + "--edw-recover", + "--edw-config=#{fx.ini}" + ]) + + assert status == 0, out + refute out =~ "listening " + assert File.exists?(Path.join(root, "recovered")) + assert BeamFixture.count_lines(Path.join(root, "starts.log")) == 0 + assert BeamFixture.count_lines(Path.join(root, "eval.log")) == 1 + end + + test "fails when recovery_script is missing" do + {_out, status} = Launcher.oneshot(["--edw-recover"]) + assert status != 0 + end + + test "runs recovery eval after three startup crashes then start succeeds" do + root = BeamFixture.tmp_dir("edw-restart") + + fx = + BeamFixture.write_restart_fixture!(root, + lifetime_ini: """ + recovery_after = 3 + recovery_script = #{root}/recovery.exs + """ + ) + + {:ok, launcher} = + Launcher.start( + no_beam: false, + test_rpc: false, + extra_args: ["--edw-config=#{fx.ini}"] + ) + + on_exit(fn -> + stop_host(launcher) + kill_fixture_children(root) + File.rm_rf(root) + end) + + assert BeamFixture.wait_until(fn -> + File.exists?(Path.join(root, "recovered")) and + BeamFixture.count_lines(Path.join(root, "starts.log")) >= 4 + end), + "expected recovery then a successful start; starts=#{inspect(File.read(Path.join(root, "starts.log")))} eval=#{inspect(File.read(Path.join(root, "eval.log")))}" + + assert BeamFixture.count_lines(Path.join(root, "eval.log")) == 1 + end + + test "stops after three crashes when max attempts is 3 and no recovery script" do + root = BeamFixture.tmp_dir("edw-max") + on_exit(fn -> File.rm_rf(root) end) + + fx = + BeamFixture.write_restart_fixture!(root, + always_fail: true, + lifetime_ini: """ + restart_max_attempts = 3 + """ + ) + + result = + Launcher.start( + no_beam: false, + test_rpc: false, + timeout: 8_000, + extra_args: ["--edw-config=#{fx.ini}"] + ) + + case result do + {:error, {:exit, status, _acc}} -> + assert status != 0 + + {:ok, launcher} -> + on_exit(fn -> stop_host(launcher) end) + assert BeamFixture.wait_until(fn -> not host_alive?(launcher) end, 8_000) + + other -> + flunk("unexpected launcher result: #{inspect(other)}") + end + + assert BeamFixture.count_lines(Path.join(root, "starts.log")) == 3 + refute File.exists?(Path.join(root, "eval.log")) + end + + defp host_alive?(%{port: port}) do + match?([_ | _], Port.info(port)) + end + + defp stop_host(%{os_pid: os_pid} = launcher) when is_integer(os_pid) do + Launcher.stop(launcher) + + case :os.type() do + {:win32, _} -> + System.cmd("taskkill", ["/F", "/PID", Integer.to_string(os_pid)], stderr_to_stdout: true) + + _ -> + System.cmd("kill", ["-9", Integer.to_string(os_pid)], stderr_to_stdout: true) + end + end + + defp stop_host(launcher), do: Launcher.stop(launcher) + + defp kill_fixture_children(root) do + case :os.type() do + {:win32, _} -> + :ok + + _ -> + System.cmd("pkill", ["-f", root], stderr_to_stdout: true) + end + end +end diff --git a/test/e2e/rpc_test.exs b/test/e2e/rpc_test.exs new file mode 100644 index 0000000..7f45527 --- /dev/null +++ b/test/e2e/rpc_test.exs @@ -0,0 +1,75 @@ +defmodule DesktopWebview.E2E.RpcTest do + use ExUnit.Case, async: false + + @moduletag :e2e + + alias DesktopWebview.{BeamFixture, Binary, Launcher} + + setup do + unless Binary.available?() do + flunk("DesktopWebView binary missing at #{Binary.path()}") + end + + cookie = :edw_e2e_cookie + node = BeamFixture.ensure_distributed!(cookie) + beam_dir = BeamFixture.tmp_dir("edw-rpc") + + on_exit(fn -> File.rm_rf(beam_dir) end) + + BeamFixture.write_rpc_release!(beam_dir, + node: node, + cookie: to_string(cookie) + ) + + %{beam_dir: beam_dir, node: node} + end + + test "inspects 1+1 as 2", %{beam_dir: beam_dir} do + {out, status} = + Launcher.oneshot([ + "--edw-rpc", + "1+1", + "--edw-beam-path=#{beam_dir}" + ]) + + assert status == 0, out + assert String.split(String.trim(out), "\n", trim: true) |> Enum.any?(&(&1 == "2")) + end + + test "evaluates a module on the test node", %{beam_dir: beam_dir} do + {out, status} = + Launcher.oneshot([ + "--edw-rpc", + "DesktopWebview.Binary.available?()", + "--edw-beam-path=#{beam_dir}" + ]) + + assert status == 0, out + assert String.trim(out) |> String.split("\n", trim: true) |> Enum.any?(&(&1 == "true")) + end + + test "fails when the node name is wrong", %{beam_dir: beam_dir} do + File.write!( + Path.join(beam_dir, "releases/0.1.0/vm.args"), + "-name missing_edw_rpc@127.0.0.1\n-setcookie edw_e2e_cookie\n" + ) + + {_out, status} = + Launcher.oneshot([ + "--edw-rpc", + "1+1", + "--edw-beam-path=#{beam_dir}" + ]) + + assert status != 0 + end + + test "rejects --edw-rpc together with --edw-recover" do + {out, status} = + Launcher.oneshot(["--edw-rpc", "1+1", "--edw-recover"]) + + assert status != 0 + refute out =~ "listening " + assert out =~ "mutually exclusive" + end +end diff --git a/test/support/beam_fixture.ex b/test/support/beam_fixture.ex new file mode 100644 index 0000000..7177722 --- /dev/null +++ b/test/support/beam_fixture.ex @@ -0,0 +1,172 @@ +defmodule DesktopWebview.BeamFixture do + @moduledoc false + + def tmp_dir(prefix) do + dir = + Path.join( + System.tmp_dir!(), + "#{prefix}-#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(dir) + dir + end + + def erl_call_path do + root = :code.root_dir() |> List.to_string() + + [ + Path.join(root, "erts-*/bin/erl_call"), + Path.join(root, "lib/erl_interface-*/bin/erl_call") + ] + |> Enum.flat_map(&Path.wildcard/1) + |> List.first() + end + + def erts_dir do + root = :code.root_dir() |> List.to_string() + Path.wildcard(Path.join(root, "erts-*")) |> List.first() + end + + def ensure_distributed!(cookie) when is_atom(cookie) do + unless Node.alive?() do + name = :"edw_e2e_#{System.unique_integer([:positive])}@127.0.0.1" + {:ok, _} = Node.start(name, :longnames) + end + + Node.set_cookie(cookie) + Node.self() + end + + def write_rpc_release!(beam_dir, opts) do + node = Keyword.fetch!(opts, :node) + cookie = Keyword.fetch!(opts, :cookie) + File.mkdir_p!(Path.join(beam_dir, "releases/0.1.0")) + File.mkdir_p!(Path.join(beam_dir, "bin")) + File.write!(Path.join(beam_dir, "releases/COOKIE"), "#{cookie}\n") + + File.write!( + Path.join(beam_dir, "releases/start_erl.data"), + "0.0 0.1.0\n" + ) + + File.write!( + Path.join(beam_dir, "releases/0.1.0/vm.args"), + "-name #{node}\n-setcookie #{cookie}\n" + ) + + beam_dir + end + + def write_restart_fixture!(root, opts \\ []) do + always_fail? = Keyword.get(opts, :always_fail, false) + bin = Path.join(root, "bin") + File.mkdir_p!(bin) + script = Path.join(bin, "edw_beam") + bat = Path.join(bin, "edw_beam.bat") + File.write!(script, unix_stub()) + File.chmod!(script, 0o755) + File.write!(bat, windows_stub()) + + recovery = Path.join(root, "recovery.exs") + + File.write!(recovery, """ + File.write!(Path.join(Path.dirname(__ENV__.file), "recovered"), "ok\\n") + """) + + if always_fail?, do: File.write!(Path.join(root, "always_fail"), "1\n") + + ini = Path.join(root, "DesktopWebView.ini") + + extra_lifetime = Keyword.get(opts, :lifetime_ini, "") + + File.write!(ini, """ + [beam] + path = #{root} + app_name = edw_beam + args = start + working_dir = #{root} + + [lifetime] + mode = reconnect + restart_beam = true + restart_backoff_ms = 50 + #{extra_lifetime} + """) + + %{root: root, ini: ini, recovery: recovery, script: script} + end + + def count_lines(path) do + case File.read(path) do + {:ok, body} -> body |> String.split("\n", trim: true) |> length() + {:error, _} -> 0 + end + end + + def wait_until(fun, timeout_ms \\ 15_000) do + deadline = System.monotonic_time(:millisecond) + timeout_ms + do_wait(fun, deadline) + end + + defp do_wait(fun, deadline) do + if fun.() do + true + else + if System.monotonic_time(:millisecond) > deadline do + false + else + Process.sleep(50) + do_wait(fun, deadline) + end + end + end + + defp unix_stub do + """ + #!/bin/sh + ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)" + CMD="${1:-}" + shift || true + case "$CMD" in + start) + echo start >> "$ROOT/starts.log" + if [ -f "$ROOT/always_fail" ]; then + exit 1 + fi + if [ -f "$ROOT/recovered" ]; then + while true; do sleep 3600; done + fi + exit 1 + ;; + eval) + echo "eval $*" >> "$ROOT/eval.log" + elixir -e "$*" + ;; + *) + echo "unknown command: $CMD" >&2 + exit 1 + ;; + esac + """ + end + + defp windows_stub do + """ + @echo off + set ROOT=%~dp0.. + if /I "%~1"=="eval" ( + echo eval %~2>> "%ROOT%\\eval.log" + elixir -e "%~2" + exit /b %ERRORLEVEL% + ) + echo start>> "%ROOT%\\starts.log" + if exist "%ROOT%\\always_fail" exit /b 1 + if exist "%ROOT%\\recovered" ( + ping -n 3600 127.0.0.1 >nul + exit /b 0 + ) + exit /b 1 + """ + end +end From 2f63ebb13512fd558a67615ad8cff023fa02dcab Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Thu, 10 Sep 2026 15:20:48 +0200 Subject: [PATCH 2/4] Fix CI epmd start and Windows recovery quoting. CI has no epmd, so Node.start failed. Windows Mix eval now uses cmd /s quoting and a hidden console. The host returns the WM_QUIT code. Co-authored-by: Cursor --- .../Sources/DesktopWebView/BeamCli.swift | 4 +- native/windows/src/beam_cli.cpp | 20 +++++----- native/windows/src/main.cpp | 4 +- test/e2e/rpc_test.exs | 19 +++++++--- test/support/beam_fixture.ex | 37 +++++++++++++++++-- 5 files changed, 62 insertions(+), 22 deletions(-) diff --git a/native/macos/Sources/DesktopWebView/BeamCli.swift b/native/macos/Sources/DesktopWebView/BeamCli.swift index 51b8a93..a87e88e 100644 --- a/native/macos/Sources/DesktopWebView/BeamCli.swift +++ b/native/macos/Sources/DesktopWebView/BeamCli.swift @@ -159,7 +159,9 @@ enum BeamCli { let proc = Process() proc.executableURL = URL(fileURLWithPath: erlCall) proc.arguments = args - proc.environment = ProcessInfo.processInfo.environment + var env = ProcessInfo.processInfo.environment + for (k, v) in config.extraEnv { env[k] = v } + proc.environment = env do { let readHandle = try FileHandle(forReadingFrom: tmp) proc.standardInput = readHandle diff --git a/native/windows/src/beam_cli.cpp b/native/windows/src/beam_cli.cpp index 04cd6d7..de8d6a0 100644 --- a/native/windows/src/beam_cli.cpp +++ b/native/windows/src/beam_cli.cpp @@ -92,12 +92,8 @@ std::string eval_file_expr(const std::string& script_path) { std::string posix = script_path; for (char& c : posix) if (c == '\\') c = '/'; - std::string escaped; - for (char c : posix) { - if (c == '\\' || c == '"') escaped.push_back('\\'); - escaped.push_back(c); - } - return "Code.eval_file(\"" + escaped + "\")"; + // ~s|...| avoids nested " so cmd.exe /s /c quoting stays intact. + return "Code.eval_file(~s|" + posix + "|)"; } std::string base64_encode(const std::string& in) { @@ -243,7 +239,8 @@ std::wstring env_block(const std::map& extra) { } int spawn_cmd(const std::string& cmdline, const std::string& wd, - const std::map& extra, const std::string* stdin_data) { + const std::map& extra, const std::string* stdin_data, + bool new_console) { SECURITY_ATTRIBUTES sa{}; sa.nLength = sizeof(sa); sa.bInheritHandle = TRUE; @@ -268,7 +265,8 @@ int spawn_cmd(const std::string& cmdline, const std::string& wd, mutable_cmd.push_back(L'\0'); std::wstring wwd = utf8_to_wide(wd); auto env = env_block(extra); - DWORD flags = CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW; + DWORD flags = CREATE_UNICODE_ENVIRONMENT; + flags |= new_console ? CREATE_NEW_CONSOLE : CREATE_NO_WINDOW; if (!CreateProcessW(nullptr, mutable_cmd.data(), nullptr, nullptr, TRUE, flags, env.data(), wwd.empty() ? nullptr : wwd.c_str(), &si, &pi)) { fprintf(stderr, "edw: spawn failed (%lu): %s\n", GetLastError(), cmdline.c_str()); @@ -324,11 +322,11 @@ int run_recover(const HostConfig& cfg) { auto expr = eval_file_expr(*script_path); std::ostringstream cmd; if (is_batch(bin)) { - cmd << "cmd.exe /c \"" << bin << "\" eval \"" << expr << "\""; + cmd << "cmd.exe /s /c \"" << '"' << bin << "\" eval \"" << expr << '"' << '"'; } else { cmd << '"' << bin << "\" eval \"" << expr << '"'; } - return spawn_cmd(cmd.str(), resolved_working_dir(cfg), cfg.extra_env, nullptr); + return spawn_cmd(cmd.str(), resolved_working_dir(cfg), cfg.extra_env, nullptr, true); } int run_rpc(const HostConfig& cfg, const std::string& expr) { @@ -370,7 +368,7 @@ int run_rpc(const HostConfig& cfg, const std::string& expr) { else cmd << "-name "; cmd << '"' << node->name << "\" -e"; - int code = spawn_cmd(cmd.str(), resolved_working_dir(cfg), cfg.extra_env, &erlang); + int code = spawn_cmd(cmd.str(), resolved_working_dir(cfg), cfg.extra_env, &erlang, false); if (code == 0) { std::ifstream in(out_path); std::ostringstream ss; diff --git a/native/windows/src/main.cpp b/native/windows/src/main.cpp index 95b7ce1..9f60016 100644 --- a/native/windows/src/main.cpp +++ b/native/windows/src/main.cpp @@ -50,12 +50,12 @@ int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) { return 1; } - MSG msg; + MSG msg{}; while (GetMessageW(&msg, nullptr, 0, 0) > 0) { TranslateMessage(&msg); DispatchMessageW(&msg); } CoUninitialize(); - return 0; + return msg.message == WM_QUIT ? static_cast(msg.wParam) : 0; } diff --git a/test/e2e/rpc_test.exs b/test/e2e/rpc_test.exs index 7f45527..b611a04 100644 --- a/test/e2e/rpc_test.exs +++ b/test/e2e/rpc_test.exs @@ -10,10 +10,13 @@ defmodule DesktopWebview.E2E.RpcTest do flunk("DesktopWebView binary missing at #{Binary.path()}") end + :ok + end + + defp rpc_beam_dir! do cookie = :edw_e2e_cookie node = BeamFixture.ensure_distributed!(cookie) beam_dir = BeamFixture.tmp_dir("edw-rpc") - on_exit(fn -> File.rm_rf(beam_dir) end) BeamFixture.write_rpc_release!(beam_dir, @@ -21,10 +24,12 @@ defmodule DesktopWebview.E2E.RpcTest do cookie: to_string(cookie) ) - %{beam_dir: beam_dir, node: node} + beam_dir end - test "inspects 1+1 as 2", %{beam_dir: beam_dir} do + test "inspects 1+1 as 2" do + beam_dir = rpc_beam_dir!() + {out, status} = Launcher.oneshot([ "--edw-rpc", @@ -36,7 +41,9 @@ defmodule DesktopWebview.E2E.RpcTest do assert String.split(String.trim(out), "\n", trim: true) |> Enum.any?(&(&1 == "2")) end - test "evaluates a module on the test node", %{beam_dir: beam_dir} do + test "evaluates a module on the test node" do + beam_dir = rpc_beam_dir!() + {out, status} = Launcher.oneshot([ "--edw-rpc", @@ -48,7 +55,9 @@ defmodule DesktopWebview.E2E.RpcTest do assert String.trim(out) |> String.split("\n", trim: true) |> Enum.any?(&(&1 == "true")) end - test "fails when the node name is wrong", %{beam_dir: beam_dir} do + test "fails when the node name is wrong" do + beam_dir = rpc_beam_dir!() + File.write!( Path.join(beam_dir, "releases/0.1.0/vm.args"), "-name missing_edw_rpc@127.0.0.1\n-setcookie edw_e2e_cookie\n" diff --git a/test/support/beam_fixture.ex b/test/support/beam_fixture.ex index 7177722..e4f0c72 100644 --- a/test/support/beam_fixture.ex +++ b/test/support/beam_fixture.ex @@ -30,14 +30,42 @@ defmodule DesktopWebview.BeamFixture do def ensure_distributed!(cookie) when is_atom(cookie) do unless Node.alive?() do + ensure_epmd!() name = :"edw_e2e_#{System.unique_integer([:positive])}@127.0.0.1" - {:ok, _} = Node.start(name, :longnames) + start_longnames!(name) end Node.set_cookie(cookie) Node.self() end + defp ensure_epmd! do + case :os.find_executable(~c"epmd") do + false -> + raise "epmd not found on PATH" + + path -> + System.cmd(List.to_string(path), ["-daemon"], stderr_to_stdout: true) + end + end + + defp start_longnames!(name, attempts \\ 20) do + case Node.start(name, :longnames) do + {:ok, _} -> + :ok + + {:error, {:already_started, _}} -> + :ok + + {:error, _reason} when attempts > 1 -> + Process.sleep(50) + start_longnames!(name, attempts - 1) + + {:error, reason} -> + raise "Node.start(#{inspect(name)}) failed: #{inspect(reason)}" + end + end + def write_rpc_release!(beam_dir, opts) do node = Keyword.fetch!(opts, :node) cookie = Keyword.fetch!(opts, :cookie) @@ -99,8 +127,11 @@ defmodule DesktopWebview.BeamFixture do def count_lines(path) do case File.read(path) do - {:ok, body} -> body |> String.split("\n", trim: true) |> length() - {:error, _} -> 0 + {:ok, body} -> + body |> String.replace("\r", "") |> String.split("\n", trim: true) |> length() + + {:error, _} -> + 0 end end From 2bf8ab806a5e930426ac57166c1f9b1a8eac0410 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Thu, 10 Sep 2026 15:29:05 +0200 Subject: [PATCH 3/4] Fix Windows epmd hang and Mix eval via a temp .cmd. Windows epmd -daemon does not return, so the host starts it without a wait. Recovery now writes a .cmd file and runs it with COMSPEC, so eval reaches the bat. Co-authored-by: Cursor --- native/windows/src/beam_cli.cpp | 37 +++++++++++++++++++++++++++------ test/support/beam_fixture.ex | 19 ++++++++++++++++- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/native/windows/src/beam_cli.cpp b/native/windows/src/beam_cli.cpp index de8d6a0..cd155df 100644 --- a/native/windows/src/beam_cli.cpp +++ b/native/windows/src/beam_cli.cpp @@ -305,6 +305,13 @@ std::string resolve_bin_script(const HostConfig& cfg) { return {}; } +std::string comspec_path() { + char buf[MAX_PATH]; + DWORD n = GetEnvironmentVariableA("COMSPEC", buf, MAX_PATH); + if (n == 0 || n >= MAX_PATH) return "cmd.exe"; + return std::string(buf, n); +} + } // namespace int run_recover(const HostConfig& cfg) { @@ -320,13 +327,31 @@ int run_recover(const HostConfig& cfg) { return 1; } auto expr = eval_file_expr(*script_path); - std::ostringstream cmd; - if (is_batch(bin)) { - cmd << "cmd.exe /s /c \"" << '"' << bin << "\" eval \"" << expr << '"' << '"'; - } else { - cmd << '"' << bin << "\" eval \"" << expr << '"'; + char tmp_dir[MAX_PATH]; + char tmp_file[MAX_PATH]; + if (!GetTempPathA(MAX_PATH, tmp_dir) || !GetTempFileNameA(tmp_dir, "edw", 0, tmp_file)) { + fprintf(stderr, "edw: failed to create recovery cmd file\n"); + return 1; } - return spawn_cmd(cmd.str(), resolved_working_dir(cfg), cfg.extra_env, nullptr, true); + std::string cmd_path = std::string(tmp_file) + ".cmd"; + DeleteFileA(tmp_file); + { + std::ofstream out(cmd_path, std::ios::binary); + if (!out) { + fprintf(stderr, "edw: failed to write recovery cmd file\n"); + return 1; + } + out << "@echo off\r\n"; + if (is_batch(bin)) { + out << "call \"" << bin << "\" eval \"" << expr << "\"\r\n"; + } else { + out << '"' << bin << "\" eval \"" << expr << "\"\r\n"; + } + } + std::string cmdline = "\"" + comspec_path() + "\" /c \"" + cmd_path + "\""; + int code = spawn_cmd(cmdline, resolved_working_dir(cfg), cfg.extra_env, nullptr, true); + DeleteFileA(cmd_path.c_str()); + return code; } int run_rpc(const HostConfig& cfg, const std::string& expr) { diff --git a/test/support/beam_fixture.ex b/test/support/beam_fixture.ex index e4f0c72..8afe5de 100644 --- a/test/support/beam_fixture.ex +++ b/test/support/beam_fixture.ex @@ -45,7 +45,24 @@ defmodule DesktopWebview.BeamFixture do raise "epmd not found on PATH" path -> - System.cmd(List.to_string(path), ["-daemon"], stderr_to_stdout: true) + start_epmd(List.to_string(path)) + end + end + + defp start_epmd(path) do + case :os.type() do + {:win32, _} -> + # Windows `epmd -daemon` stays attached; do not wait on System.cmd. + _port = + Port.open( + {:spawn_executable, String.to_charlist(path)}, + [:hide, args: [~c"-daemon"]] + ) + + Process.sleep(200) + + _ -> + System.cmd(path, ["-daemon"], stderr_to_stdout: true) end end From c0c543acf814ad69b15edfc0d4d88989823ec58b Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Thu, 10 Sep 2026 15:36:45 +0200 Subject: [PATCH 4/4] Run Windows Mix eval through a two-quote cmd.exe /c script. cmd.exe /c drops extra quoted eval args. A temp .cmd with no pipe chars matches packaged start. Co-authored-by: Cursor --- native/windows/src/beam_cli.cpp | 15 +++++---------- test/e2e/restart_test.exs | 6 ++++-- test/support/beam_fixture.ex | 1 + 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/native/windows/src/beam_cli.cpp b/native/windows/src/beam_cli.cpp index cd155df..ca0ed50 100644 --- a/native/windows/src/beam_cli.cpp +++ b/native/windows/src/beam_cli.cpp @@ -92,8 +92,8 @@ std::string eval_file_expr(const std::string& script_path) { std::string posix = script_path; for (char& c : posix) if (c == '\\') c = '/'; - // ~s|...| avoids nested " so cmd.exe /s /c quoting stays intact. - return "Code.eval_file(~s|" + posix + "|)"; + // ~s{...} avoids " and | so cmd.exe does not split or pipe the eval argument. + return "Code.eval_file(~s{" + posix + "})"; } std::string base64_encode(const std::string& in) { @@ -305,13 +305,6 @@ std::string resolve_bin_script(const HostConfig& cfg) { return {}; } -std::string comspec_path() { - char buf[MAX_PATH]; - DWORD n = GetEnvironmentVariableA("COMSPEC", buf, MAX_PATH); - if (n == 0 || n >= MAX_PATH) return "cmd.exe"; - return std::string(buf, n); -} - } // namespace int run_recover(const HostConfig& cfg) { @@ -348,7 +341,9 @@ int run_recover(const HostConfig& cfg) { out << '"' << bin << "\" eval \"" << expr << "\"\r\n"; } } - std::string cmdline = "\"" + comspec_path() + "\" /c \"" + cmd_path + "\""; + // Same shape as packaged start: cmd.exe /c "script" with exactly two quotes. + std::string cmdline = "cmd.exe /c \"" + cmd_path + "\""; + fprintf(stderr, "edw: recovery eval: %s\n", cmdline.c_str()); int code = spawn_cmd(cmdline, resolved_working_dir(cfg), cfg.extra_env, nullptr, true); DeleteFileA(cmd_path.c_str()); return code; diff --git a/test/e2e/restart_test.exs b/test/e2e/restart_test.exs index 7e23d37..22ff5bf 100644 --- a/test/e2e/restart_test.exs +++ b/test/e2e/restart_test.exs @@ -28,7 +28,9 @@ defmodule DesktopWebview.E2E.RestartTest do "--edw-config=#{fx.ini}" ]) - assert status == 0, out + assert status == 0, + "status=#{status} out=#{inspect(out)} calls=#{inspect(File.read(Path.join(root, "calls.log")))} eval=#{inspect(File.read(Path.join(root, "eval.log")))}" + refute out =~ "listening " assert File.exists?(Path.join(root, "recovered")) assert BeamFixture.count_lines(Path.join(root, "starts.log")) == 0 @@ -68,7 +70,7 @@ defmodule DesktopWebview.E2E.RestartTest do File.exists?(Path.join(root, "recovered")) and BeamFixture.count_lines(Path.join(root, "starts.log")) >= 4 end), - "expected recovery then a successful start; starts=#{inspect(File.read(Path.join(root, "starts.log")))} eval=#{inspect(File.read(Path.join(root, "eval.log")))}" + "expected recovery then a successful start; starts=#{inspect(File.read(Path.join(root, "starts.log")))} eval=#{inspect(File.read(Path.join(root, "eval.log")))} calls=#{inspect(File.read(Path.join(root, "calls.log")))}" assert BeamFixture.count_lines(Path.join(root, "eval.log")) == 1 end diff --git a/test/support/beam_fixture.ex b/test/support/beam_fixture.ex index 8afe5de..614788a 100644 --- a/test/support/beam_fixture.ex +++ b/test/support/beam_fixture.ex @@ -203,6 +203,7 @@ defmodule DesktopWebview.BeamFixture do """ @echo off set ROOT=%~dp0.. + echo cmd %*>> "%ROOT%\calls.log" if /I "%~1"=="eval" ( echo eval %~2>> "%ROOT%\\eval.log" elixir -e "%~2"