From fc6a1fd8f0cf889d55fab2aef50f184ed91aab0a Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Wed, 9 Sep 2026 20:08:37 +0200 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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" From 171465a2218e1c589652b51648d63565bbf9076b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 14:34:59 +0000 Subject: [PATCH 05/10] Document native single-instance and control-socket rpc.eval. Add the single-instance spec and rewrite --edw-rpc off erl_call. The control socket stays separate from the Elixir EDW TCP client. Co-authored-by: Dominic Letz --- AGENTS.md | 1 + README.md | 3 +- docs/packaging.md | 28 +++-- docs/porting.md | 10 +- docs/protocol.md | 26 ++++- docs/specs/feature-edw-rpc.md | 123 ++++++++------------ docs/specs/feature-single-instance.md | 160 ++++++++++++++++++++++++++ docs/specs/tests-edw-rpc.yaml | 11 +- docs/specs/tests-single-instance.yaml | 27 +++++ docs/status/linux.md | 3 +- docs/status/macos.md | 3 +- docs/status/windows.md | 3 +- mix.exs | 1 + 13 files changed, 303 insertions(+), 96 deletions(-) create mode 100644 docs/specs/feature-single-instance.md create mode 100644 docs/specs/tests-single-instance.yaml diff --git a/AGENTS.md b/AGENTS.md index 4092f3b..44bc2ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ config :desktop, :menu_adapter, DesktopWebview.Menu.Adapter 5. **No native unit-test frameworks** (no XCTest, etc.) as the source of truth. Extend the shared Elixir E2E suite instead. Test-only RPC (`test.*`) is allowed when gated by `--edw-test-rpc`. 6. **Status matrices are authoritative.** Mark a feature `done` on a platform only when Elixir E2E covers it. 7. **Per-platform native code stays isolated.** Do not share Swift/C++/GTK UI code across `native/*` until a deliberate shared core exists. +8. **Single-instance and `--edw-rpc` go through the control socket.** Do not replace the Elixir TCP client. Second launch uses `instance.activate`; `--edw-rpc` uses `instance.eval` → host→client `rpc.eval`. Do not use `erl_call` / epmd for these paths. ## Protocol ownership diff --git a/README.md b/README.md index 0831c2d..0409165 100644 --- a/README.md +++ b/README.md @@ -58,7 +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` +- [`--edw-rpc`](docs/specs/feature-edw-rpc.md) — one-shot Elixir via control socket + `rpc.eval` +- [Single-instance](docs/specs/feature-single-instance.md) — host-owned lock and second-launch activate - [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 367ebd1..0bdd783 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -99,17 +99,15 @@ 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 port = 0 [lifetime] mode = reconnect +# multi (default) | single — packaged apps set single +# instances = single +# instance_id = ddrive restart_beam = true restart_max_attempts = 0 restart_backoff_ms = 500 @@ -122,8 +120,10 @@ recovery_after = 3 ``` 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). +`listening`, or spawn `start`. `--edw-rpc` is a control-socket client of a +running single-instance host. See [feature-edw-rpc.md](specs/feature-edw-rpc.md), +[feature-single-instance.md](specs/feature-single-instance.md), and +[feature-beam-restart.md](specs/feature-beam-restart.md). ## CLI (`--edw-*`) @@ -140,7 +140,9 @@ 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-instances=multi\|single` | Instance mode (default `multi`) | +| `--edw-instance-id=NAME` | Control-socket lock name (default: host exe basename) | +| `--edw-rpc ` | One-shot Elixir eval via control socket `instance.eval` | | `--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) | @@ -193,6 +195,16 @@ OTP and Elixir load; the application does not start. Then the host respawns `--edw-rpc` and `--edw-recover` are mutually exclusive. +### Single-instance + +Default `instances = multi` so `--edw-no-beam` E2E can run more than one host. +Packaged apps set `instances = single`. The first host binds the control +socket. A second launch sends `instance.activate` (not a second EDW TCP +client) and exits 0. See [feature-single-instance.md](specs/feature-single-instance.md). + +When the host spawns BEAM, it sets `RELEASE_DISTRIBUTION=none` if that +environment key is unset. + ## Binaries | Platform | Delivery | Artifact name | diff --git a/docs/porting.md b/docs/porting.md index 43e41f0..f33c973 100644 --- a/docs/porting.md +++ b/docs/porting.md @@ -43,11 +43,16 @@ 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). +12. **`--edw-rpc`** — one-shot Elixir via the control socket (`instance.eval`) + and host→client `rpc.eval`. No UI. No `erl_call`. 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). +14. **Single-instance** — `instances` / `instance_id`, control socket, + `instance.activate`, `RELEASE_DISTRIBUTION=none` when unset. Do not + replace the Elixir TCP client. See + [specs/feature-single-instance.md](specs/feature-single-instance.md). ## HTML file inputs and file-manager drag-and-drop @@ -121,6 +126,7 @@ Before flipping a status row to `done`, the corresponding E2E (or an added E2E) | 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` | +| Single-instance activate | `test/e2e/instance_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 f81bbb0..48c939f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -71,9 +71,12 @@ 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). +`--edw-rpc` and `--edw-recover` are process-shell commands. They do not +listen and they do not connect to this EDW TCP socket. `--edw-rpc` uses the +**control socket** (`instance.eval`) of a single-instance host; the host then +sends `rpc.eval` on this session. `--edw-recover` stays Mix `eval`. See +[packaging.md](packaging.md), [specs/feature-edw-rpc.md](specs/feature-edw-rpc.md), +and [specs/feature-single-instance.md](specs/feature-single-instance.md). ## Behavioral semantics @@ -230,6 +233,9 @@ MAY replace the previous one; document if you support multiple clients. Replace MUST reset session UI (see `initialize` and reconnect) and MUST NOT treat the replaced socket as a host-quit signal. +Second app launch and `--edw-rpc` MUST NOT connect to this socket. They use the +control socket in [feature-single-instance.md](specs/feature-single-instance.md). + ## Production methods ### `initialize` @@ -355,6 +361,20 @@ Events: `event.notification.click`, `event.notification.dismiss`, quitting. Packaged mode also terminates any BEAM child it spawned. - Elixir `EventBridge` maps `event.system.quit` → `Desktop.Window.quit/0`. +### `rpc.eval` (host → client) + +Used by `--edw-rpc` after the control socket `instance.eval` request. The +Elixir client handles it in `DesktopWebview.Transport` (`Code.eval_string/1` + +`Kernel.inspect/1`). It does not require EventBridge. + +```json +{"jsonrpc":"2.0","id":N,"method":"rpc.eval","params":{"expr":"1+1"}} +``` + +Success result: `{ "inspect": "2" }`. Eval error: JSON-RPC error `-32000`. +No initialized Elixir client: the control-socket `instance.eval` fails +non-zero (this method is not sent). + ### Permissions (hybrid) Host → client **request**: diff --git a/docs/specs/feature-edw-rpc.md b/docs/specs/feature-edw-rpc.md index 3f11975..f14e3a7 100644 --- a/docs/specs/feature-edw-rpc.md +++ b/docs/specs/feature-edw-rpc.md @@ -1,4 +1,4 @@ -# `--edw-rpc` Specification v0.1.0 +# `--edw-rpc` Specification v0.2.0 > **Spec type:** Feature > **Path:** `docs/specs/feature-edw-rpc.md` @@ -6,34 +6,41 @@ ## 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. +It connects to the **control socket** of a running single-instance host, asks +that host to evaluate an Elixir expression on the existing EDW session +(`rpc.eval`), prints the inspected return value, and exits. + +This path does **not** use `erl_call`, a distribution cookie, or `epmd`. **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`). +Config discovery follows [docs/packaging.md](../packaging.md). The control +socket is specified in [feature-single-instance.md](feature-single-instance.md). +The host→client method is specified in [docs/protocol.md](../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`.** + `start`, create a window, or bind the control socket as a server. +2. **Elixir in, inspect out.** The public expression is Elixir. Stdout is + `Kernel.inspect/1` of the value plus a newline. +3. **Ask the running host.** The CLI is a control-socket client + (`instance.eval`). The host forwards `rpc.eval` to the initialized Elixir + client. +4. **Single-instance only.** If `instances = multi`, or no host holds the + lock, 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`.** `--edw-recover` stays Mix + `eval` and does not use the control socket. --- ## Output Structure -**Do generate:** native CLI handling, packaging docs, Elixir E2E. +**Do generate:** native CLI client, packaging docs, Elixir E2E. -**Do not generate:** JSON-RPC methods, native unit-test frameworks, a second -RPC protocol. +**Do not generate:** `erl_call` cookie/node discovery, a second Elixir TCP +client, native unit-test frameworks. --- @@ -41,17 +48,15 @@ RPC protocol. | 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` | +| `elixir_expr` | UTF-8 Elixir source | `1+1`, `DesktopWebview.Binary.available?()` | +| `instance_id` | Control-socket lock name | `edw-rpc-42` | | `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. +- The client uses the same ini-over-CLI merge as other overlapping keys + (`--edw-config`, `--edw-instances`, `--edw-instance-id`). --- @@ -65,34 +70,10 @@ RPC protocol. |-----------|------|--------| | `--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` +| `instances = multi` | non-zero | no running single-instance host | +| No host / connect failed | non-zero | no running single-instance host | +| No initialized Elixir client | non-zero | control `instance.eval` error | +| Eval error | non-zero | JSON-RPC `-32000` message | --- @@ -100,7 +81,7 @@ Search order is the same on every OS. ### `--edw-rpc ` → stdout + exit_code -Evaluate `expr` on the running node. +Evaluate `expr` on the Elixir client of the running single-instance host. **Arguments:** @@ -110,34 +91,28 @@ Evaluate `expr` on the running node. | Condition | Output | |-----------|--------| -| Success | `inspect(value)` and a newline on stdout and stderr, exit 0 | -| Node down | non-zero | +| Success | `inspect(value)` and a newline on stdout (and stderr), exit 0 | +| No host / multi / not initialized | 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)]). -``` +**Eval method:** Connect to the control socket. Send JSON-RPC +`instance.eval` `{expr}`. The host sends EDW request `rpc.eval` to the +Elixir client. `DesktopWebview.Transport` runs `Code.eval_string/1` and +returns `{inspect}`. -Do not `halt` the remote node. +Do not `halt` the remote VM. **Examples:** - `--edw-rpc '1+1'` → stdout `2` -- `--edw-rpc 'node()'` → the remote node name +- `--edw-rpc 'DesktopWebview.Binary.available?()'` → `true` when that + module is loaded in the connected client **Edge cases:** - Empty expression → error -- Quotes and newlines in `expr` → Base64 wrap, no shell interpolation of the - remote source +- Quotes and newlines in `expr` → JSON string, no shell interpolation of + the remote source --- @@ -149,17 +124,17 @@ source of truth. ## Generated Documentation -Packaging CLI table and ini `[beam] node` / `cookie` keys. Porting checklist -row for `--edw-rpc`. +Packaging CLI table. Porting checklist row for `--edw-rpc`. Protocol +`rpc.eval`. ## Implementation Checklist -- [ ] macOS / Windows / Linux one-shot CLI -- [ ] Discovery order implemented +- [ ] macOS / Windows / Linux one-shot CLI via `instance.eval` - [ ] 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 +- **v0.2.0** - Control socket + `rpc.eval`; drop `erl_call` +- **v0.1.0** - Initial specification (`erl_call`) diff --git a/docs/specs/feature-single-instance.md b/docs/specs/feature-single-instance.md new file mode 100644 index 0000000..7ebcf29 --- /dev/null +++ b/docs/specs/feature-single-instance.md @@ -0,0 +1,160 @@ +# Native single-instance Specification v0.1.0 + +> **Spec type:** Feature +> **Path:** `docs/specs/feature-single-instance.md` + +## Overview + +When `instances = single`, the native host owns process uniqueness. The first +host binds a per-user control socket for its process lifetime. A second launch +does **not** connect to the Elixir EDW TCP socket. It sends `instance.activate` +on the control socket, the running host raises existing windows and forwards +open-url / open-file / reopen to BEAM, and the second process exits 0. + +**Integration context:** Host process shell in `native/{macos,windows,linux}/`. +The EDW TCP session stays one Elixir client ([protocol.md](../protocol.md) +“Single client”). `--edw-rpc` is a client of this same control socket +([feature-edw-rpc.md](feature-edw-rpc.md)). + +## Design Principles + +1. **Host owns the lock.** Apps do not need `epmd` or a named BEAM node to + detect a second launch. +2. **Do not replace the Elixir TCP client.** Second launch and `--edw-rpc` + use the control socket only. +3. **Default is multi.** Current E2E and `--edw-no-beam` keep working. + Packaged apps set `instances = single` in their ini. +4. **Same contract on every OS.** Same ini keys, flags, activate argv rules, + and exit codes. +5. **One-shot exclusive modes do not bind as server.** `--edw-rpc` is a + control-socket client. `--edw-recover` stays Mix `eval` and does not use + the socket. A normal UI host, including `--edw-no-beam`, binds when + `instances = single`. + +--- + +## Output Structure + +**Do generate:** control-socket server/client per platform, ini/CLI, Elixir E2E. + +**Do not generate:** a second Elixir TCP client, shared Swift/C++/GTK UI, +native unit-test frameworks, changes to `--edw-recover`. + +--- + +## Type Conventions + +| Spec type | Meaning | Examples | +|-----------|---------|----------| +| `instances` | `multi` (default) or `single` | `single` | +| `instance_id` | Lock name; default is the host exe basename | `ddrive`, `DesktopWebView` | +| `argv` | Forwarded argv after `--edw-*` strip | `["ddrive://invite/x"]` | +| `exit_code` | Process status | `0` success, non-zero failure | + +### Normalization + +- CLI `--edw-instances=` and `--edw-instance-id=` use the same ini-over-CLI + merge as other overlapping keys. +- Relative paths in activate argv resolve from the current working directory + of the second process. +- `instance_id` is sanitized for the socket / pipe name (keep `[A-Za-z0-9._-]`). + +--- + +## Error Handling + +| Condition | Result | +|-----------|--------| +| `instances = multi` | No lock. A second host starts normally. | +| Bind fails and `instance.activate` succeeds | Second process exits 0 | +| Bind fails and activate cannot reach a host | Second process exits non-zero | +| `--edw-rpc` when `instances = multi` | Non-zero (no running single-instance host) | +| `--edw-rpc` when no host holds the lock | Non-zero | + +--- + +## Ini and CLI + +```ini +[lifetime] +# multi (default) | single +instances = single +# lock name; default is the host exe basename +# instance_id = ddrive +``` + +| Flag | Meaning | +|------|---------| +| `--edw-instances=multi\|single` | Instance mode | +| `--edw-instance-id=NAME` | Lock name | + +On BEAM spawn, the host sets `RELEASE_DISTRIBUTION=none` when that env key is +unset. Apps can still set a name if they want distribution. + +--- + +## Control socket + +When `instances = single`, the first host binds a per-user local endpoint and +holds it for the process lifetime: + +- macOS/Linux: Unix socket `$TMPDIR/edw-{uid}-{instance_id}.sock` + (`TMPDIR` falls back to `/tmp`) +- Windows: named mutex `Local\edw-{instance_id}` plus named pipe + `\\.\pipe\edw-{uid}-{instance_id}` + +Framing: same **4-byte big-endian length + JSON-RPC 2.0** as EDW. + +| Method | Params | Result | +|--------|--------|--------| +| `instance.activate` | `{argv: [string]}` | `true` | +| `instance.eval` | `{expr: string}` | `{inspect: string}` | + +`instance.eval` forwards host→client `rpc.eval` on the existing EDW session +([protocol.md](../protocol.md)). No initialized Elixir client → JSON-RPC +error; the `--edw-rpc` process exits non-zero. + +A stale Unix socket file (nothing listens) is removed and the first host +binds again. + +--- + +## Activate argv + +The second process strips `--edw-*` first (same as BEAM forward). Then, for +each remaining argument (or once with an empty list): + +| Argv | Host action | +|------|-------------| +| empty | `event.system.reopen` and raise existing native windows | +| `scheme:` (including `file:` and `ddrive:`) | `event.system.open_url` | +| else if the path exists | `event.system.open_file` | +| else | `event.system.open_url` with the raw string | + +A Windows drive path (`C:\...`) is not a URL scheme. Raise / show existing +native windows (same role as `Desktop.Window.show` in `do_focus`). + +--- + +## Testing + +Cases live in [tests-single-instance.yaml](tests-single-instance.yaml). Elixir +E2E under `test/e2e/` MUST implement them. Hosts MUST NOT add XCTest / gtest +as the source of truth. + +## Generated Documentation + +Packaging `[lifetime] instances` / `instance_id`, CLI flags, porting checklist +row, AGENTS.md hard rule, status rows. + +## Implementation Checklist + +- [ ] macOS / Windows / Linux control socket +- [ ] Activate argv classification + raise windows +- [ ] `RELEASE_DISTRIBUTION=none` when unset +- [ ] E2E cases from tests-single-instance.yaml +- [ ] Status row `done` only when E2E is green + +## Version History + +- **v0.1.0** - Initial specification diff --git a/docs/specs/tests-edw-rpc.yaml b/docs/specs/tests-edw-rpc.yaml index ff48fd7..0f289e6 100644 --- a/docs/specs/tests-edw-rpc.yaml +++ b/docs/specs/tests-edw-rpc.yaml @@ -1,27 +1,28 @@ # Input mapping: each case is a host process invocation (no GUI). # Implementations MUST cover these in test/e2e/ (tag :e2e). +# Start a single-instance host, connect Transport, then oneshot --edw-rpc. edw_rpc: - name: "inspects 1+1 as 2" input: expr: "1+1" - node: running_test_node + host: running_single_instance output: stdout_inspect: "2" exit: 0 - - name: "evaluates a module on the test node" + - name: "evaluates a module on the connected client" input: expr: "DesktopWebview.Binary.available?()" - node: running_test_node + host: running_single_instance output: stdout_inspect: "true" exit: 0 - - name: "fails when the node name is wrong" + - name: "fails when no single-instance host is running" input: expr: "1+1" - node: "missing_edw_rpc@127.0.0.1" + host: none output: exit_nonzero: true diff --git a/docs/specs/tests-single-instance.yaml b/docs/specs/tests-single-instance.yaml new file mode 100644 index 0000000..1fee080 --- /dev/null +++ b/docs/specs/tests-single-instance.yaml @@ -0,0 +1,27 @@ +# Input mapping: each case is a host process invocation. +# Implementations MUST cover these in test/e2e/ (tag :e2e). +# Use a unique instance_id per test so leftover hosts do not collide. + +single_instance: + - name: "second host with URL exits 0 and first gets open_url" + input: + instances: single + second_argv: ["ddrive://invite/x"] + output: + second_exit: 0 + first_event: event.system.open_url + url: "ddrive://invite/x" + + - name: "empty forwarded argv emits reopen" + input: + instances: single + second_argv: [] + output: + second_exit: 0 + first_event: event.system.reopen + + - name: "instances=multi keeps both hosts up" + input: + instances: multi + output: + both_listening: true diff --git a/docs/status/linux.md b/docs/status/linux.md index 0c4c36b..4fa55da 100644 --- a/docs/status/linux.md +++ b/docs/status/linux.md @@ -34,7 +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) | +| `--edw-rpc` (control socket) | todo | One-shot `instance.eval` → `rpc.eval`; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | +| Single-instance lock + activate | todo | [feature-single-instance.md](../specs/feature-single-instance.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 | | diff --git a/docs/status/macos.md b/docs/status/macos.md index 1ba25a4..f5e6a48 100644 --- a/docs/status/macos.md +++ b/docs/status/macos.md @@ -37,7 +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) | +| `--edw-rpc` (control socket) | todo | One-shot `instance.eval` → `rpc.eval`; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | +| Single-instance lock + activate | todo | [feature-single-instance.md](../specs/feature-single-instance.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 | diff --git a/docs/status/windows.md b/docs/status/windows.md index db127ed..4a903d0 100644 --- a/docs/status/windows.md +++ b/docs/status/windows.md @@ -35,7 +35,8 @@ Release asset: `DesktopWebView-windows-x64.exe` (GitHub Releases; not Hex `priv/ | 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 + 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) | +| `--edw-rpc` (control socket) | todo | One-shot `instance.eval` → `rpc.eval`; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | +| Single-instance lock + activate | todo | [feature-single-instance.md](../specs/feature-single-instance.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 | diff --git a/mix.exs b/mix.exs index ef94c2a..70ceb1e 100644 --- a/mix.exs +++ b/mix.exs @@ -28,6 +28,7 @@ defmodule DesktopWebview.MixProject do "docs/status/windows.md", "docs/status/linux.md", "docs/specs/feature-edw-rpc.md", + "docs/specs/feature-single-instance.md", "docs/specs/feature-beam-restart.md" ] ] From 9d250f5767abd3a477d67a4cac02433ba241bf2e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 14:35:01 +0000 Subject: [PATCH 06/10] Implement control-socket single-instance and rpc.eval. Bind a per-user lock when instances=single. Second launch sends instance.activate. --edw-rpc is a control-socket client that forwards rpc.eval to the connected Elixir Transport. Drop erl_call. Co-authored-by: Dominic Letz --- lib/desktop_webview/transport.ex | 31 ++ native/linux/CMakeLists.txt | 4 +- native/linux/src/beam_cli.cpp | 221 +-------- native/linux/src/config.cpp | 26 ++ native/linux/src/config.hpp | 5 + native/linux/src/host_controller.cpp | 79 ++++ native/linux/src/host_controller.hpp | 3 + native/linux/src/instance_lock.cpp | 437 ++++++++++++++++++ native/linux/src/instance_lock.hpp | 43 ++ native/linux/src/main.cpp | 21 +- native/linux/src/rpc_server.cpp | 5 + native/linux/src/rpc_server.hpp | 1 + .../Sources/DesktopWebView/BeamCli.swift | 215 +-------- .../macos/Sources/DesktopWebView/Config.swift | 28 ++ .../DesktopWebView/HostController.swift | 55 +++ .../Sources/DesktopWebView/InstanceLock.swift | 333 +++++++++++++ .../Sources/DesktopWebView/RPCServer.swift | 6 + .../macos/Sources/DesktopWebView/main.swift | 16 + native/windows/CMakeLists.txt | 1 + native/windows/src/beam_cli.cpp | 67 +-- native/windows/src/config.cpp | 26 ++ native/windows/src/config.hpp | 5 + native/windows/src/host_controller.cpp | 77 +++ native/windows/src/host_controller.hpp | 6 + native/windows/src/instance_lock.cpp | 317 +++++++++++++ native/windows/src/instance_lock.hpp | 47 ++ native/windows/src/main.cpp | 23 +- native/windows/src/rpc_server.cpp | 4 + native/windows/src/rpc_server.hpp | 1 + test/e2e/instance_test.exs | 108 +++++ test/e2e/rpc_test.exs | 72 +-- test/support/beam_fixture.ex | 17 + 32 files changed, 1804 insertions(+), 496 deletions(-) create mode 100644 native/linux/src/instance_lock.cpp create mode 100644 native/linux/src/instance_lock.hpp create mode 100644 native/macos/Sources/DesktopWebView/InstanceLock.swift create mode 100644 native/windows/src/instance_lock.cpp create mode 100644 native/windows/src/instance_lock.hpp create mode 100644 test/e2e/instance_test.exs diff --git a/lib/desktop_webview/transport.ex b/lib/desktop_webview/transport.ex index 0e6820e..2fbee63 100644 --- a/lib/desktop_webview/transport.ex +++ b/lib/desktop_webview/transport.ex @@ -151,6 +151,37 @@ defmodule DesktopWebview.Transport do state end + defp handle_message(%{"method" => "rpc.eval", "id" => id, "params" => params}, state) do + expr = params["expr"] + + cond do + not is_binary(expr) or expr == "" -> + :ok = send_json(state.socket, Codec.error_response(id, -32602, "expr required")) + + true -> + try do + {val, _} = Code.eval_string(expr) + :ok = send_json(state.socket, Codec.response(id, %{"inspect" => inspect(val)})) + rescue + e -> + :ok = + send_json( + state.socket, + Codec.error_response(id, -32000, Exception.message(e)) + ) + catch + kind, reason -> + :ok = + send_json( + state.socket, + Codec.error_response(id, -32000, Exception.format(kind, reason, [])) + ) + end + end + + state + end + defp handle_message(%{"method" => method, "params" => params} = msg, state) when not is_map_key(msg, "id") do broadcast(state, {:edw_event, method, params}) diff --git a/native/linux/CMakeLists.txt b/native/linux/CMakeLists.txt index 0746a57..0a3e473 100644 --- a/native/linux/CMakeLists.txt +++ b/native/linux/CMakeLists.txt @@ -21,9 +21,11 @@ add_executable(DesktopWebView src/web_window.cpp src/host_controller.cpp src/beam_cli.cpp + src/instance_lock.cpp ) +find_package(Threads REQUIRED) target_include_directories(DesktopWebView PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) -target_link_libraries(DesktopWebView PRIVATE PkgConfig::DEPS) +target_link_libraries(DesktopWebView PRIVATE PkgConfig::DEPS Threads::Threads) install(TARGETS DesktopWebView RUNTIME DESTINATION bin) diff --git a/native/linux/src/beam_cli.cpp b/native/linux/src/beam_cli.cpp index 02073b4..afca8ab 100644 --- a/native/linux/src/beam_cli.cpp +++ b/native/linux/src/beam_cli.cpp @@ -1,11 +1,10 @@ #include "beam_cli.hpp" +#include "instance_lock.hpp" + #include -#include #include -#include -#include #include #include #include @@ -31,19 +30,6 @@ 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"); @@ -57,20 +43,6 @@ std::string resolved_working_dir(const HostConfig& cfg) { : 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"); @@ -81,7 +53,8 @@ std::optional resolve_app_name(const HostConfig& cfg) { 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; + if (n.size() >= 4 && (n.substr(n.size() - 4) == ".bat" || n.substr(n.size() - 4) == ".cmd")) + continue; found = n; break; } @@ -110,127 +83,6 @@ std::string eval_file_expr(const std::string& script_path) { 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; @@ -298,62 +150,19 @@ int run_recover(const HostConfig& cfg) { } 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"); + if (cfg.instances != Instances::Single) { + fprintf(stderr, "edw: --edw-rpc requires a running single-instance host\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; + std::string inspect; + int code = InstanceLock::client_eval(cfg.resolved_instance_id(), expr, &inspect); + if (code != 0) return code; + if (inspect.empty() || inspect.back() != '\n') inspect.push_back('\n'); + fwrite(inspect.data(), 1, inspect.size(), stdout); + fflush(stdout); + fwrite(inspect.data(), 1, inspect.size(), stderr); + fflush(stderr); + return 0; } bool maybe_run_exclusive(const HostConfig& cfg, int* exit_code) { diff --git a/native/linux/src/config.cpp b/native/linux/src/config.cpp index 4d6a4db..2190300 100644 --- a/native/linux/src/config.cpp +++ b/native/linux/src/config.cpp @@ -134,6 +134,11 @@ HostConfig HostConfig::parse(int argc, char** argv) { cfg.recovery_script = body.substr(16); } else if (body.rfind("recovery-after=", 0) == 0) { cfg.recovery_after = std::stoi(body.substr(15)); + } else if (body.rfind("instances=", 0) == 0) { + auto v = body.substr(10); + cfg.instances = (v == "single") ? Instances::Single : Instances::Multi; + } else if (body.rfind("instance-id=", 0) == 0) { + cfg.instance_id = body.substr(12); } else { fprintf(stderr, "unknown --edw flag: %s\n", a.c_str()); } @@ -163,6 +168,23 @@ std::optional HostConfig::resolve_ini_path() const { return std::nullopt; } +std::string HostConfig::exe_basename() const { + char buf[4096]; + ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (n > 0) { + buf[n] = '\0'; + std::string p = buf; + auto pos = p.find_last_of('/'); + return pos == std::string::npos ? p : p.substr(pos + 1); + } + return "DesktopWebView"; +} + +std::string HostConfig::resolved_instance_id() const { + if (instance_id && !instance_id->empty()) return *instance_id; + return exe_basename(); +} + void HostConfig::apply_ini() { auto path = resolve_ini_path(); if (!path) return; @@ -187,6 +209,10 @@ void HostConfig::apply_ini() { } 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("lifetime", "instances")) { + instances = (*v == "single") ? Instances::Single : Instances::Multi; + } + if (auto v = ini.get("lifetime", "instance_id")) instance_id = *v; if (auto v = ini.get("beam", "enabled")) { beam_enabled = !(*v == "false" || *v == "0"); } diff --git a/native/linux/src/config.hpp b/native/linux/src/config.hpp index 387601a..ebb254c 100644 --- a/native/linux/src/config.hpp +++ b/native/linux/src/config.hpp @@ -7,6 +7,7 @@ #include enum class Lifetime { Reconnect, Coupled }; +enum class Instances { Multi, Single }; struct HostConfig { bool no_beam = false; @@ -33,11 +34,15 @@ struct HostConfig { std::optional beam_node; std::optional beam_cookie; std::optional beam_cookie_file; + Instances instances = Instances::Multi; + std::optional instance_id; static HostConfig parse(int argc, char** argv); std::string resources_root() const; std::optional resolve_ini_path() const; + std::string resolved_instance_id() const; + std::string exe_basename() const; private: void apply_ini(); diff --git a/native/linux/src/host_controller.cpp b/native/linux/src/host_controller.cpp index dc6875d..15fc753 100644 --- a/native/linux/src/host_controller.cpp +++ b/native/linux/src/host_controller.cpp @@ -205,6 +205,14 @@ void HostController::spawn_beam() { env_store.push_back("EDW_PORT=" + std::to_string(server_.port())); env_store.push_back("EDW_HOST=" + config_.host); for (auto& [k, v] : config_.extra_env) env_store.push_back(k + "=" + v); + bool has_rel_dist = false; + for (auto& e : env_store) { + if (e.rfind("RELEASE_DISTRIBUTION=", 0) == 0) { + has_rel_dist = true; + break; + } + } + if (!has_rel_dist) env_store.push_back("RELEASE_DISTRIBUTION=none"); std::vector envp; for (auto& s : env_store) envp.push_back(s.data()); envp.push_back(nullptr); @@ -279,6 +287,77 @@ void HostController::schedule_beam_respawn() { this); } +namespace { + +bool looks_like_scheme(const std::string& s) { + auto colon = s.find(':'); + if (colon == std::string::npos || colon == 0) return false; + if (colon == 1 && s.size() >= 3 && (s[2] == '\\' || s[2] == '/')) return false; + for (size_t i = 0; i < colon; i++) { + unsigned char c = static_cast(s[i]); + bool ok = (i == 0) ? std::isalpha(c) : (std::isalnum(c) || c == '+' || c == '.' || c == '-'); + if (!ok) return false; + } + return true; +} + +void notify_object(RpcServer& server, const std::string& method, JsonObject* o) { + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + server.notify(method, n); +} + +} // namespace + +void HostController::activate_from_argv(const std::vector& argv) { + if (argv.empty()) { + JsonObject* o = jsonutil::object_new(); + notify_object(server_, "event.system.reopen", o); + } else { + for (const auto& a : argv) { + if (looks_like_scheme(a)) { + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "url", a.c_str()); + notify_object(server_, "event.system.open_url", o); + } else if (g_file_test(a.c_str(), G_FILE_TEST_EXISTS)) { + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "path", a.c_str()); + notify_object(server_, "event.system.open_file", o); + } else { + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "url", a.c_str()); + notify_object(server_, "event.system.open_url", o); + } + } + } + for (auto& [_, w] : windows_) { + w->show(); + w->raise(); + } +} + +void HostController::eval_rpc(const std::string& expr, + std::function done) { + if (!initialized_ || !server_.has_client()) { + done(false, "no initialized Elixir client"); + return; + } + JsonObject* o = jsonutil::object_new(); + json_object_set_string_member(o, "expr", expr.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + server_.request("rpc.eval", n, [done](JsonNode* result) { + JsonObject* obj = jsonutil::as_object(result); + if (auto inspect = jsonutil::object_get_string(obj, "inspect")) { + done(true, *inspect); + return; + } + done(false, "rpc.eval failed"); + }); +} + void HostController::handle_request(JsonNode* id, const std::string& method, JsonNode* params, RpcServer::ReplyFn reply) { auto free_params = [&]() { diff --git a/native/linux/src/host_controller.hpp b/native/linux/src/host_controller.hpp index 605496b..0a13990 100644 --- a/native/linux/src/host_controller.hpp +++ b/native/linux/src/host_controller.hpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -43,6 +44,8 @@ class HostController { bool start(); RpcServer& server() { return server_; } + void activate_from_argv(const std::vector& argv); + void eval_rpc(const std::string& expr, std::function done); private: void client_disconnected(); diff --git a/native/linux/src/instance_lock.cpp b/native/linux/src/instance_lock.cpp new file mode 100644 index 0000000..cefc576 --- /dev/null +++ b/native/linux/src/instance_lock.cpp @@ -0,0 +1,437 @@ +#include "instance_lock.hpp" + +#include "json_util.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +constexpr int kTimeoutMs = 15000; + +std::string tmp_dir() { + const char* tmp = std::getenv("TMPDIR"); + if (tmp && tmp[0]) return tmp; + return "/tmp"; +} + +bool write_all(int fd, const void* data, size_t n) { + const char* p = static_cast(data); + size_t left = n; + while (left) { + ssize_t w = write(fd, p, left); + if (w < 0) { + if (errno == EINTR) continue; + return false; + } + if (w == 0) return false; + p += w; + left -= static_cast(w); + } + return true; +} + +bool read_all_timeout(int fd, void* data, size_t n, int timeout_ms) { + char* p = static_cast(data); + size_t left = n; + while (left) { + pollfd pfd{fd, POLLIN, 0}; + int pr = poll(&pfd, 1, timeout_ms); + if (pr <= 0) return false; + ssize_t r = read(fd, p, left); + if (r < 0) { + if (errno == EINTR) continue; + return false; + } + if (r == 0) return false; + p += r; + left -= static_cast(r); + } + return true; +} + +bool write_frame(int fd, const std::string& json) { + uint32_t len = htonl(static_cast(json.size())); + return write_all(fd, &len, 4) && write_all(fd, json.data(), json.size()); +} + +bool read_frame(int fd, std::string* out, int timeout_ms) { + uint32_t nlen = 0; + if (!read_all_timeout(fd, &nlen, 4, timeout_ms)) return false; + uint32_t len = ntohl(nlen); + if (len == 0 || len > 8 * 1024 * 1024) return false; + out->assign(len, '\0'); + return read_all_timeout(fd, out->data(), len, timeout_ms); +} + +bool can_connect(const std::string& path) { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return false; + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", path.c_str()); + int rc = connect(fd, reinterpret_cast(&addr), sizeof(addr)); + close(fd); + return rc == 0; +} + +int connect_path(const std::string& path) { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -1; + timeval tv{}; + tv.tv_sec = kTimeoutMs / 1000; + tv.tv_usec = (kTimeoutMs % 1000) * 1000; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", path.c_str()); + if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { + close(fd); + return -1; + } + return fd; +} + +JsonNode* make_request(const std::string& method, JsonNode* params) { + JsonNode* id = jsonutil::int_node(1); + JsonNode* req = jsonutil::rpc_request(id, method, params); + json_node_free(id); + return req; +} + +int rpc_call(const std::string& instance_id, const std::string& method, JsonNode* params, + JsonNode** out_result, std::string* err_out) { + int fd = connect_path(InstanceLock::socket_path(instance_id)); + if (fd < 0) { + if (params) json_node_free(params); + if (err_out) *err_out = "no running single-instance host"; + return 1; + } + JsonNode* req = make_request(method, params); + std::string json = jsonutil::stringify(req); + json_node_free(req); + if (!write_frame(fd, json)) { + close(fd); + if (err_out) *err_out = "control socket write failed"; + return 1; + } + std::string payload; + if (!read_frame(fd, &payload, kTimeoutMs)) { + close(fd); + if (err_out) *err_out = "control socket read failed"; + return 1; + } + close(fd); + JsonNode* root = jsonutil::parse(payload); + if (!root) { + if (err_out) *err_out = "control socket parse failed"; + return 1; + } + JsonObject* obj = jsonutil::as_object(root); + if (obj && json_object_has_member(obj, "error")) { + JsonObject* err = jsonutil::as_object(json_object_get_member(obj, "error")); + std::string msg = "instance request failed"; + if (auto m = jsonutil::object_get_string(err, "message")) msg = *m; + json_node_free(root); + if (err_out) *err_out = msg; + return 1; + } + if (out_result && obj && json_object_has_member(obj, "result")) { + *out_result = json_node_copy(json_object_get_member(obj, "result")); + } + json_node_free(root); + return 0; +} + +void run_on_main_sync(const std::function& fn) { + if (g_main_context_is_owner(g_main_context_default())) { + fn(); + return; + } + struct Wait { + std::function fn; + std::mutex mu; + std::condition_variable cv; + bool done = false; + }; + auto w = std::make_shared(); + w->fn = fn; + auto* raw = new std::shared_ptr(w); + g_idle_add( + +[](gpointer data) -> gboolean { + auto* pw = static_cast*>(data); + auto w = *pw; + delete pw; + w->fn(); + { + std::lock_guard lock(w->mu); + w->done = true; + } + w->cv.notify_one(); + return G_SOURCE_REMOVE; + }, + raw); + std::unique_lock lock(w->mu); + w->cv.wait_for(lock, std::chrono::milliseconds(kTimeoutMs), [&] { return w->done; }); +} + +} // namespace + +InstanceLock::~InstanceLock() { + running_ = false; + if (listen_fd_ >= 0) { + shutdown(listen_fd_, SHUT_RDWR); + close(listen_fd_); + listen_fd_ = -1; + } + if (thread_.joinable()) thread_.join(); + if (!path_.empty()) unlink(path_.c_str()); +} + +std::string InstanceLock::sanitize_id(const std::string& raw) { + std::string out; + out.reserve(raw.size()); + for (unsigned char c : raw) { + if (std::isalnum(c) || c == '.' || c == '_' || c == '-') + out.push_back(static_cast(c)); + else + out.push_back('_'); + } + if (out.size() > 64) out.resize(64); + return out.empty() ? "DesktopWebView" : out; +} + +std::string InstanceLock::socket_path(const std::string& instance_id) { + return tmp_dir() + "/edw-" + std::to_string(static_cast(getuid())) + "-" + + sanitize_id(instance_id) + ".sock"; +} + +bool InstanceLock::try_serve(const std::string& instance_id) { + path_ = socket_path(instance_id); + if (can_connect(path_)) return false; + unlink(path_.c_str()); + + listen_fd_ = socket(AF_UNIX, SOCK_STREAM, 0); + if (listen_fd_ < 0) return false; + + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", path_.c_str()); + if (bind(listen_fd_, reinterpret_cast(&addr), sizeof(addr)) != 0) { + close(listen_fd_); + listen_fd_ = -1; + return false; + } + if (listen(listen_fd_, 8) != 0) { + close(listen_fd_); + listen_fd_ = -1; + unlink(path_.c_str()); + return false; + } + return true; +} + +void InstanceLock::set_handlers(ActivateFn activate, EvalFn eval) { + activate_ = std::move(activate); + eval_ = std::move(eval); +} + +void InstanceLock::start() { + if (listen_fd_ < 0 || running_) return; + running_ = true; + thread_ = std::thread([this] { accept_loop(); }); +} + +void InstanceLock::accept_loop() { + while (running_) { + int fd = accept(listen_fd_, nullptr, nullptr); + if (fd < 0) { + if (!running_) break; + if (errno == EINTR) continue; + break; + } + handle_client(fd); + close(fd); + } +} + +void InstanceLock::handle_client(int fd) { + std::string payload; + if (!read_frame(fd, &payload, kTimeoutMs)) return; + JsonNode* root = jsonutil::parse(payload); + if (!root) return; + JsonObject* obj = jsonutil::as_object(root); + if (!obj) { + json_node_free(root); + return; + } + auto method = jsonutil::object_get_string(obj, "method").value_or(""); + JsonNode* id = json_object_has_member(obj, "id") + ? json_node_copy(json_object_get_member(obj, "id")) + : jsonutil::int_node(1); + JsonObject* params = jsonutil::as_object(json_object_get_member(obj, "params")); + + if (method == "instance.activate") { + std::vector argv; + if (params) { + JsonNode* arrn = json_object_get_member(params, "argv"); + JsonArray* arr = jsonutil::as_array(arrn); + if (arr) { + guint n = json_array_get_length(arr); + for (guint i = 0; i < n; i++) { + if (auto s = jsonutil::as_string(json_array_get_element(arr, i))) argv.push_back(*s); + } + } + } + json_node_free(root); + if (activate_) { + run_on_main_sync([this, argv] { activate_(argv); }); + } + JsonNode* resp = jsonutil::rpc_ok(id, jsonutil::bool_node(true)); + write_frame(fd, jsonutil::stringify(resp)); + json_node_free(resp); + json_node_free(id); + return; + } + + if (method == "instance.eval") { + std::string expr; + if (auto e = jsonutil::object_get_string(params, "expr")) expr = *e; + json_node_free(root); + if (expr.empty()) { + JsonNode* resp = jsonutil::rpc_error(id, -32602, "expr required"); + write_frame(fd, jsonutil::stringify(resp)); + json_node_free(resp); + json_node_free(id); + return; + } + if (!eval_) { + JsonNode* resp = jsonutil::rpc_error(id, -32000, "no initialized Elixir client"); + write_frame(fd, jsonutil::stringify(resp)); + json_node_free(resp); + json_node_free(id); + return; + } + + struct EvalWait { + bool ok = false; + std::string text; + std::mutex mu; + std::condition_variable cv; + bool done = false; + }; + auto wait = std::make_shared(); + + run_on_main_sync([this, expr, wait] { + eval_(expr, [wait](bool ok, std::string text) { + { + std::lock_guard lock(wait->mu); + wait->ok = ok; + wait->text = std::move(text); + wait->done = true; + } + wait->cv.notify_one(); + }); + }); + + { + std::unique_lock lock(wait->mu); + if (!wait->cv.wait_for(lock, std::chrono::milliseconds(kTimeoutMs), + [&] { return wait->done; })) { + JsonNode* resp = jsonutil::rpc_error(id, -32000, "rpc.eval timed out"); + write_frame(fd, jsonutil::stringify(resp)); + json_node_free(resp); + json_node_free(id); + return; + } + } + + if (!wait->ok) { + JsonNode* resp = + jsonutil::rpc_error(id, -32000, wait->text.empty() ? "rpc.eval failed" : wait->text); + write_frame(fd, jsonutil::stringify(resp)); + json_node_free(resp); + json_node_free(id); + return; + } + JsonObject* ro = jsonutil::object_new(); + json_object_set_string_member(ro, "inspect", wait->text.c_str()); + JsonNode* result = json_node_alloc(); + json_node_init_object(result, ro); + json_object_unref(ro); + JsonNode* resp = jsonutil::rpc_ok(id, result); + write_frame(fd, jsonutil::stringify(resp)); + json_node_free(resp); + json_node_free(id); + return; + } + + json_node_free(root); + JsonNode* resp = jsonutil::rpc_error(id, -32601, "Method not found"); + write_frame(fd, jsonutil::stringify(resp)); + json_node_free(resp); + json_node_free(id); +} + +int InstanceLock::client_activate(const std::string& instance_id, + const std::vector& argv) { + JsonArray* arr = json_array_new(); + for (auto& a : argv) json_array_add_string_element(arr, a.c_str()); + JsonObject* params = jsonutil::object_new(); + json_object_set_array_member(params, "argv", arr); + JsonNode* pn = json_node_alloc(); + json_node_init_object(pn, params); + json_object_unref(params); + std::string err; + int code = rpc_call(instance_id, "instance.activate", pn, nullptr, &err); + if (code != 0) { + fprintf(stderr, "edw: instance.activate failed: %s\n", err.c_str()); + } + return code; +} + +int InstanceLock::client_eval(const std::string& instance_id, const std::string& expr, + std::string* inspect_out) { + JsonObject* params = jsonutil::object_new(); + json_object_set_string_member(params, "expr", expr.c_str()); + JsonNode* pn = json_node_alloc(); + json_node_init_object(pn, params); + json_object_unref(params); + JsonNode* result = nullptr; + std::string err; + int code = rpc_call(instance_id, "instance.eval", pn, &result, &err); + if (code != 0) { + fprintf(stderr, "edw: instance.eval failed: %s\n", err.c_str()); + if (result) json_node_free(result); + return code; + } + JsonObject* obj = jsonutil::as_object(result); + auto inspect = jsonutil::object_get_string(obj, "inspect"); + if (!inspect) { + fprintf(stderr, "edw: instance.eval missing inspect\n"); + if (result) json_node_free(result); + return 1; + } + if (inspect_out) *inspect_out = *inspect; + if (result) json_node_free(result); + return 0; +} diff --git a/native/linux/src/instance_lock.hpp b/native/linux/src/instance_lock.hpp new file mode 100644 index 0000000..9cfe8a4 --- /dev/null +++ b/native/linux/src/instance_lock.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include +#include + +class InstanceLock { + public: + using ActivateFn = std::function& argv)>; + using EvalDone = std::function; + using EvalFn = std::function; + + InstanceLock() = default; + ~InstanceLock(); + + InstanceLock(const InstanceLock&) = delete; + InstanceLock& operator=(const InstanceLock&) = delete; + + bool try_serve(const std::string& instance_id); + void set_handlers(ActivateFn activate, EvalFn eval); + void start(); + + static int client_activate(const std::string& instance_id, + const std::vector& argv); + static int client_eval(const std::string& instance_id, const std::string& expr, + std::string* inspect_out); + + static std::string socket_path(const std::string& instance_id); + static std::string sanitize_id(const std::string& raw); + + private: + void accept_loop(); + void handle_client(int fd); + + int listen_fd_ = -1; + std::string path_; + std::atomic running_{false}; + std::thread thread_; + ActivateFn activate_; + EvalFn eval_; +}; diff --git a/native/linux/src/main.cpp b/native/linux/src/main.cpp index 6a24851..64ec8d0 100644 --- a/native/linux/src/main.cpp +++ b/native/linux/src/main.cpp @@ -1,17 +1,28 @@ +#include "beam_cli.hpp" #include "config.hpp" #include "host_controller.hpp" -#include "beam_cli.hpp" +#include "instance_lock.hpp" #include #include #include +#include +#include int main(int argc, char** argv) { auto config = HostConfig::parse(argc, argv); int exclusive = 0; if (beamcli::maybe_run_exclusive(config, &exclusive)) return exclusive; + std::unique_ptr lock; + if (config.instances == Instances::Single) { + lock = std::make_unique(); + if (!lock->try_serve(config.resolved_instance_id())) { + return InstanceLock::client_activate(config.resolved_instance_id(), config.forwarded_argv); + } + } + // Prefer software rendering when unset — WebKitGPU/DMA-BUF crashes are common on Xvfb. if (!g_getenv("WEBKIT_DISABLE_COMPOSITING_MODE")) g_setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", FALSE); @@ -24,6 +35,14 @@ int main(int argc, char** argv) { gtk_init(); auto host = std::make_unique(std::move(config)); + if (lock) { + HostController* h = host.get(); + lock->set_handlers([h](const std::vector& argv) { h->activate_from_argv(argv); }, + [h](const std::string& expr, InstanceLock::EvalDone done) { + h->eval_rpc(expr, std::move(done)); + }); + lock->start(); + } if (!host->start()) { fprintf(stderr, "failed to start host\n"); return 1; diff --git a/native/linux/src/rpc_server.cpp b/native/linux/src/rpc_server.cpp index b07ebda..047008c 100644 --- a/native/linux/src/rpc_server.cpp +++ b/native/linux/src/rpc_server.cpp @@ -220,6 +220,11 @@ void RpcServer::notify(const std::string& method, JsonNode* params) { } void RpcServer::request(const std::string& method, JsonNode* params, PendingCallback cb) { + if (!connection_) { + if (params) json_node_free(params); + cb(nullptr); + return; + } int id_num = next_outbound_id_++; JsonNode* id = jsonutil::int_node(id_num); pending_[jsonutil::id_key(id)] = std::move(cb); diff --git a/native/linux/src/rpc_server.hpp b/native/linux/src/rpc_server.hpp index bca622f..8446852 100644 --- a/native/linux/src/rpc_server.hpp +++ b/native/linux/src/rpc_server.hpp @@ -32,6 +32,7 @@ class RpcServer { void notify(const std::string& method, JsonNode* params); // takes ownership of params void request(const std::string& method, JsonNode* params, PendingCallback cb); // takes params void close_connection(); + bool has_client() const { return connection_ != nullptr; } private: void accept(GSocketConnection* conn); diff --git a/native/macos/Sources/DesktopWebView/BeamCli.swift b/native/macos/Sources/DesktopWebView/BeamCli.swift index a87e88e..31c12d2 100644 --- a/native/macos/Sources/DesktopWebView/BeamCli.swift +++ b/native/macos/Sources/DesktopWebView/BeamCli.swift @@ -2,11 +2,6 @@ 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 { @@ -109,206 +104,18 @@ enum BeamCli { } 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) + guard config.instances == .single else { + fputs("edw: --edw-rpc requires a running single-instance host\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 - 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 - 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 + var inspect = "" + let code = InstanceLock.clientEval(config.resolvedInstanceId(), expr: expr, inspect: &inspect) + if code != 0 { return code } + let line = inspect.hasSuffix("\n") ? inspect : inspect + "\n" + fputs(line, stdout) + fflush(stdout) + fputs(line, stderr) + fflush(stderr) + return 0 } } diff --git a/native/macos/Sources/DesktopWebView/Config.swift b/native/macos/Sources/DesktopWebView/Config.swift index 59ead08..774e4d9 100644 --- a/native/macos/Sources/DesktopWebView/Config.swift +++ b/native/macos/Sources/DesktopWebView/Config.swift @@ -33,12 +33,19 @@ struct HostConfig { var beamNode: String? = nil var beamCookie: String? = nil var beamCookieFile: String? = nil + var instances: Instances = .multi + var instanceId: String? = nil enum Lifetime: String { case reconnect case coupled } + enum Instances: String { + case multi + case single + } + static func parse(argv: [String]) -> HostConfig { var cfg = HostConfig() var forwarded: [String] = [] @@ -93,6 +100,11 @@ struct HostConfig { cfg.recoveryScript = String(body.dropFirst(16)) } else if body.hasPrefix("recovery-after=") { cfg.recoveryAfter = Int(body.dropFirst(15)) ?? 3 + } else if body.hasPrefix("instances=") { + let v = String(body.dropFirst(10)) + cfg.instances = Instances(rawValue: v) ?? .multi + } else if body.hasPrefix("instance-id=") { + cfg.instanceId = String(body.dropFirst(12)) } else { fputs("unknown --edw flag: \(a)\n", stderr) } @@ -128,6 +140,10 @@ struct HostConfig { if let v = ini["lifetime", "recovery_after"], let n = Int(v) { recoveryAfter = n } + if let v = ini["lifetime", "instances"], let i = Instances(rawValue: v) { + instances = i + } + if let v = ini["lifetime", "instance_id"] { instanceId = v } if let v = ini["beam", "enabled"] { beamEnabled = !(v == "false" || v == "0") } @@ -163,6 +179,18 @@ struct HostConfig { if let res = Bundle.main.resourcePath { return res } return URL(fileURLWithPath: CommandLine.arguments[0]).deletingLastPathComponent().path } + + func exeBasename() -> String { + if let url = Bundle.main.executableURL { + return url.lastPathComponent + } + return URL(fileURLWithPath: CommandLine.arguments[0]).lastPathComponent + } + + func resolvedInstanceId() -> String { + if let id = instanceId, !id.isEmpty { return id } + return exeBasename() + } } struct Ini { diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 22b132d..8fb6f00 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -107,6 +107,58 @@ final class HostController: NSObject, UNUserNotificationCenterDelegate { server.notify(method: "event.system.open_file", params: .object(["path": .string(path)])) } + func activateFromArgv(_ argv: [String]) { + if argv.isEmpty { + notifyReopen() + } else { + for a in argv { + if Self.looksLikeScheme(a) { + notifyOpenURL(a) + } else if FileManager.default.fileExists(atPath: a) { + notifyOpenFile(a) + } else { + notifyOpenURL(a) + } + } + } + for w in windows.values { + raiseWindow(w) + } + } + + func evalRpc(_ expr: String, done: @escaping (Bool, String) -> Void) { + guard initialized, server.hasClient else { + done(false, "no initialized Elixir client") + return + } + server.request(method: "rpc.eval", params: .object(["expr": .string(expr)])) { result in + if let inspect = result?["inspect"]?.stringValue { + done(true, inspect) + } else { + done(false, "rpc.eval failed") + } + } + } + + private static func looksLikeScheme(_ s: String) -> Bool { + guard let colon = s.firstIndex(of: ":") else { return false } + let scheme = s[s.startIndex..= 3 { + let third = s[s.index(s.startIndex, offsetBy: 2)] + if third == "\\" || third == "/" { return false } + } + for (i, ch) in scheme.enumerated() { + if i == 0 { + if !ch.isLetter { return false } + } else if !(ch.isLetter || ch.isNumber || ch == "+" || ch == "." || ch == "-") { + return false + } + } + return true + } + private func clientDisconnected() { resetSession() // BEAM-first/dev (`--edw-no-beam`): the Elixir node owns the host. When it @@ -199,6 +251,9 @@ final class HostController: NSObject, UNUserNotificationCenterDelegate { env["EDW_PORT"] = "\(server.port)" env["EDW_HOST"] = config.host for (k, v) in config.extraEnv { env[k] = v } + if env["RELEASE_DISTRIBUTION"] == nil { + env["RELEASE_DISTRIBUTION"] = "none" + } proc.environment = env let wd = config.beamWorkingDir.map { ($0 as NSString).isAbsolutePath ? $0 : (root as NSString).appendingPathComponent($0) diff --git a/native/macos/Sources/DesktopWebView/InstanceLock.swift b/native/macos/Sources/DesktopWebView/InstanceLock.swift new file mode 100644 index 0000000..ba1600c --- /dev/null +++ b/native/macos/Sources/DesktopWebView/InstanceLock.swift @@ -0,0 +1,333 @@ +import Darwin +import Foundation + +final class InstanceLock { + typealias ActivateFn = ([String]) -> Void + typealias EvalFn = (String, @escaping (Bool, String) -> Void) -> Void + + private var listenFD: Int32 = -1 + private var path: String = "" + private var running = false + private var thread: Thread? + private var activate: ActivateFn? + private var eval: EvalFn? + + deinit { + stop() + } + + static func sanitizeId(_ raw: String) -> String { + var out = "" + for ch in raw.unicodeScalars { + if CharacterSet.alphanumerics.contains(ch) || ch == "." || ch == "_" || ch == "-" { + out.append(Character(ch)) + } else { + out.append("_") + } + } + if out.count > 64 { out = String(out.prefix(64)) } + return out.isEmpty ? "DesktopWebView" : out + } + + static func socketPath(_ instanceId: String) -> String { + let tmp = ProcessInfo.processInfo.environment["TMPDIR"] ?? "/tmp" + let root = tmp.hasSuffix("/") ? String(tmp.dropLast()) : tmp + return "\(root)/edw-\(getuid())-\(sanitizeId(instanceId)).sock" + } + + func tryServe(_ instanceId: String) -> Bool { + path = InstanceLock.socketPath(instanceId) + if InstanceLock.canConnect(path) { return false } + unlink(path) + + listenFD = socket(AF_UNIX, SOCK_STREAM, 0) + if listenFD < 0 { return false } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + path.withCString { cstr in + withUnsafeMutableBytes(of: &addr.sun_path) { buf in + guard let base = buf.baseAddress else { return } + strncpy(base.assumingMemoryBound(to: CChar.self), cstr, buf.count - 1) + } + } + let len = socklen_t(MemoryLayout.size) + let bindRC = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(listenFD, $0, len) } + } + if bindRC != 0 { + close(listenFD) + listenFD = -1 + return false + } + if listen(listenFD, 8) != 0 { + close(listenFD) + listenFD = -1 + unlink(path) + return false + } + return true + } + + func setHandlers(activate: @escaping ActivateFn, eval: @escaping EvalFn) { + self.activate = activate + self.eval = eval + } + + func start() { + guard listenFD >= 0, !running else { return } + running = true + let thread = Thread { [weak self] in self?.acceptLoop() } + thread.name = "edw.instance" + self.thread = thread + thread.start() + } + + func stop() { + running = false + if listenFD >= 0 { + shutdown(listenFD, SHUT_RDWR) + close(listenFD) + listenFD = -1 + } + if !path.isEmpty { unlink(path) } + } + + static func clientActivate(_ instanceId: String, argv: [String]) -> Int32 { + var err = "" + let code = rpcCall(instanceId, method: "instance.activate", params: ["argv": argv], result: { _ in }, err: &err) + if code != 0 { + fputs("edw: instance.activate failed: \(err)\n", stderr) + } + return code + } + + static func clientEval(_ instanceId: String, expr: String, inspect: inout String) -> Int32 { + var err = "" + var out = "" + let code = rpcCall(instanceId, method: "instance.eval", params: ["expr": expr], result: { obj in + if let s = obj["inspect"] as? String { out = s } + }, err: &err) + if code != 0 { + fputs("edw: instance.eval failed: \(err)\n", stderr) + return code + } + if out.isEmpty { + fputs("edw: instance.eval missing inspect\n", stderr) + return 1 + } + inspect = out + return 0 + } + + private func acceptLoop() { + while running { + let fd = accept(listenFD, nil, nil) + if fd < 0 { + if !running { break } + if errno == EINTR { continue } + break + } + handleClient(fd) + close(fd) + } + } + + private func handleClient(_ fd: Int32) { + guard let payload = InstanceLock.readFrame(fd) else { return } + guard let obj = try? JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any] else { return } + let method = obj["method"] as? String ?? "" + let id = obj["id"] ?? 1 + let params = obj["params"] as? [String: Any] ?? [:] + + if method == "instance.activate" { + let argv = params["argv"] as? [String] ?? [] + runOnMainSync { self.activate?(argv) } + _ = InstanceLock.writeFrame(fd, InstanceLock.encodeOK(id: id, result: true)) + return + } + if method == "instance.eval" { + let expr = params["expr"] as? String ?? "" + if expr.isEmpty { + _ = InstanceLock.writeFrame(fd, InstanceLock.encodeError(id: id, code: -32602, message: "expr required")) + return + } + guard let eval else { + _ = InstanceLock.writeFrame(fd, InstanceLock.encodeError(id: id, code: -32000, message: "no initialized Elixir client")) + return + } + let sem = DispatchSemaphore(value: 0) + var ok = false + var text = "" + runOnMainSync { + eval(expr) { o, t in + ok = o + text = t + sem.signal() + } + } + if sem.wait(timeout: .now() + 15) == .timedOut { + _ = InstanceLock.writeFrame(fd, InstanceLock.encodeError(id: id, code: -32000, message: "rpc.eval timed out")) + return + } + if !ok { + _ = InstanceLock.writeFrame(fd, InstanceLock.encodeError(id: id, code: -32000, message: text.isEmpty ? "rpc.eval failed" : text)) + return + } + _ = InstanceLock.writeFrame(fd, InstanceLock.encodeOK(id: id, result: ["inspect": text])) + return + } + _ = InstanceLock.writeFrame(fd, InstanceLock.encodeError(id: id, code: -32601, message: "Method not found")) + } + + private func runOnMainSync(_ body: @escaping () -> Void) { + if Thread.isMainThread { + body() + return + } + DispatchQueue.main.sync(execute: body) + } + + private static func canConnect(_ path: String) -> Bool { + let fd = connectPath(path) + if fd >= 0 { + close(fd) + return true + } + return false + } + + private static func connectPath(_ path: String) -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + if fd < 0 { return -1 } + var tv = timeval(tv_sec: 15, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + path.withCString { cstr in + withUnsafeMutableBytes(of: &addr.sun_path) { buf in + guard let base = buf.baseAddress else { return } + strncpy(base.assumingMemoryBound(to: CChar.self), cstr, buf.count - 1) + } + } + let len = socklen_t(MemoryLayout.size) + let rc = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, len) } + } + if rc != 0 { + close(fd) + return -1 + } + return fd + } + + private static func rpcCall(_ instanceId: String, method: String, params: [String: Any], + result: ([String: Any]) -> Void, err: inout String) -> Int32 { + let fd = connectPath(socketPath(instanceId)) + if fd < 0 { + err = "no running single-instance host" + return 1 + } + let body: [String: Any] = ["jsonrpc": "2.0", "id": 1, "method": method, "params": params] + guard let data = try? JSONSerialization.data(withJSONObject: body), + writeFrame(fd, String(data: data, encoding: .utf8) ?? "") else { + close(fd) + err = "control socket write failed" + return 1 + } + guard let payload = readFrame(fd) else { + close(fd) + err = "control socket read failed" + return 1 + } + close(fd) + guard let obj = try? JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any] else { + err = "control socket parse failed" + return 1 + } + if let error = obj["error"] as? [String: Any] { + err = (error["message"] as? String) ?? "instance request failed" + return 1 + } + if let res = obj["result"] as? [String: Any] { + result(res) + } else if obj["result"] != nil { + result([:]) + } + return 0 + } + + private static func writeFrame(_ fd: Int32, _ json: String) -> Bool { + var len = UInt32(json.utf8.count).bigEndian + let hdr = withUnsafeBytes(of: &len) { Data($0) } + guard writeAll(fd, hdr), writeAll(fd, Data(json.utf8)) else { return false } + return true + } + + private static func readFrame(_ fd: Int32) -> String? { + var hdr = [UInt8](repeating: 0, count: 4) + guard readAll(fd, &hdr) else { return nil } + let len = hdr.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian } + if len == 0 || len > 8 * 1024 * 1024 { return nil } + var buf = [UInt8](repeating: 0, count: Int(len)) + guard readAll(fd, &buf) else { return nil } + return String(bytes: buf, encoding: .utf8) + } + + private static func writeAll(_ fd: Int32, _ data: Data) -> Bool { + data.withUnsafeBytes { raw in + var p = raw.bindMemory(to: UInt8.self).baseAddress! + var left = data.count + while left > 0 { + let n = write(fd, p, left) + if n < 0 { + if errno == EINTR { continue } + return false + } + if n == 0 { return false } + p += n + left -= n + } + return true + } + } + + private static func readAll(_ fd: Int32, _ buf: inout [UInt8]) -> Bool { + var offset = 0 + while offset < buf.count { + let n = buf.withUnsafeMutableBytes { raw in + read(fd, raw.baseAddress!.advanced(by: offset), buf.count - offset) + } + if n < 0 { + if errno == EINTR { continue } + return false + } + if n == 0 { return false } + offset += n + } + return true + } + + private static func encodeOK(id: Any, result: Any) -> String { + let body: [String: Any] = ["jsonrpc": "2.0", "id": id, "result": result] + if let data = try? JSONSerialization.data(withJSONObject: body), + let s = String(data: data, encoding: .utf8) { + return s + } + return #"{"jsonrpc":"2.0","id":1,"result":true}"# + } + + private static func encodeError(id: Any, code: Int, message: String) -> String { + let body: [String: Any] = [ + "jsonrpc": "2.0", + "id": id, + "error": ["code": code, "message": message] + ] + if let data = try? JSONSerialization.data(withJSONObject: body), + let s = String(data: data, encoding: .utf8) { + return s + } + return #"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"error"}}"# + } +} diff --git a/native/macos/Sources/DesktopWebView/RPCServer.swift b/native/macos/Sources/DesktopWebView/RPCServer.swift index d044423..2ddee3f 100644 --- a/native/macos/Sources/DesktopWebView/RPCServer.swift +++ b/native/macos/Sources/DesktopWebView/RPCServer.swift @@ -17,6 +17,8 @@ final class RPCServer { private(set) var port: UInt16 = 0 + var hasClient: Bool { connection != nil } + func start(host: String, port: UInt16) throws { let params = NWParameters.tcp params.allowLocalEndpointReuse = true @@ -127,6 +129,10 @@ final class RPCServer { } func request(method: String, params: JSONValue?, completion: @escaping (JSONValue?) -> Void) { + guard connection != nil else { + completion(nil) + return + } let idNum = nextOutboundId nextOutboundId += 1 let id = JSONValue.number(Double(idNum)) diff --git a/native/macos/Sources/DesktopWebView/main.swift b/native/macos/Sources/DesktopWebView/main.swift index 8ec2694..675f279 100644 --- a/native/macos/Sources/DesktopWebView/main.swift +++ b/native/macos/Sources/DesktopWebView/main.swift @@ -44,12 +44,28 @@ if let code = BeamCli.exclusiveExitCode(config) { exit(code) } +var lock: InstanceLock? +if config.instances == .single { + let candidate = InstanceLock() + if !candidate.tryServe(config.resolvedInstanceId()) { + exit(InstanceLock.clientActivate(config.resolvedInstanceId(), argv: config.forwardedArgv)) + } + lock = candidate +} + let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate let host = HostController(config: config) delegate.host = host +if let lock { + lock.setHandlers( + activate: { host.activateFromArgv($0) }, + eval: { host.evalRpc($0, done: $1) } + ) + lock.start() +} do { try host.start() diff --git a/native/windows/CMakeLists.txt b/native/windows/CMakeLists.txt index 7e23f42..9b3a277 100644 --- a/native/windows/CMakeLists.txt +++ b/native/windows/CMakeLists.txt @@ -38,6 +38,7 @@ add_executable(DesktopWebView WIN32 src/web_window.cpp src/host_controller.cpp src/beam_cli.cpp + src/instance_lock.cpp ) target_include_directories(DesktopWebView PRIVATE diff --git a/native/windows/src/beam_cli.cpp b/native/windows/src/beam_cli.cpp index ca0ed50..3d04d7e 100644 --- a/native/windows/src/beam_cli.cpp +++ b/native/windows/src/beam_cli.cpp @@ -1,4 +1,5 @@ #include "beam_cli.hpp" +#include "instance_lock.hpp" #include "win_util.hpp" #include @@ -350,63 +351,19 @@ int run_recover(const HostConfig& cfg) { } 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()); + if (cfg.instances != Instances::Single) { + fprintf(stderr, "edw: --edw-rpc requires a running single-instance host\n"); 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, false); - 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; + std::string inspect; + int code = InstanceLock::client_eval(cfg.resolved_instance_id(), expr, &inspect); + if (code != 0) return code; + if (inspect.empty() || inspect.back() != '\n') inspect.push_back('\n'); + fwrite(inspect.data(), 1, inspect.size(), stdout); + fflush(stdout); + fwrite(inspect.data(), 1, inspect.size(), stderr); + fflush(stderr); + return 0; } bool maybe_run_exclusive(const HostConfig& cfg, int* exit_code) { diff --git a/native/windows/src/config.cpp b/native/windows/src/config.cpp index f42ccf3..3ab1ef3 100644 --- a/native/windows/src/config.cpp +++ b/native/windows/src/config.cpp @@ -118,6 +118,11 @@ HostConfig HostConfig::parse(int argc, char** argv) { cfg.recovery_script = body.substr(16); } else if (body.rfind("recovery-after=", 0) == 0) { cfg.recovery_after = std::stoi(body.substr(15)); + } else if (body.rfind("instances=", 0) == 0) { + auto v = body.substr(10); + cfg.instances = (v == "single") ? Instances::Single : Instances::Multi; + } else if (body.rfind("instance-id=", 0) == 0) { + cfg.instance_id = body.substr(12); } else { fprintf(stderr, "unknown --edw flag: %s\n", a.c_str()); } @@ -158,6 +163,23 @@ std::optional HostConfig::resolve_ini_path() const { return std::nullopt; } +std::string HostConfig::exe_basename() const { + char buf[MAX_PATH]; + DWORD n = GetModuleFileNameA(nullptr, buf, MAX_PATH); + if (n == 0 || n >= MAX_PATH) return "DesktopWebView"; + std::string p = buf; + auto slash = p.find_last_of("/\\"); + std::string base = (slash == std::string::npos) ? p : p.substr(slash + 1); + auto dot = base.find_last_of('.'); + if (dot != std::string::npos) base = base.substr(0, dot); + return base.empty() ? "DesktopWebView" : base; +} + +std::string HostConfig::resolved_instance_id() const { + if (instance_id && !instance_id->empty()) return *instance_id; + return exe_basename(); +} + void HostConfig::apply_ini() { auto path = resolve_ini_path(); if (!path) return; @@ -194,6 +216,10 @@ void HostConfig::apply_ini() { } 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("lifetime", "instances")) { + instances = (*v == "single") ? Instances::Single : Instances::Multi; + } + if (auto v = ini.get("lifetime", "instance_id")) instance_id = *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; diff --git a/native/windows/src/config.hpp b/native/windows/src/config.hpp index 8c83a2e..de0ffe9 100644 --- a/native/windows/src/config.hpp +++ b/native/windows/src/config.hpp @@ -7,6 +7,7 @@ #include enum class Lifetime { Reconnect, Coupled }; +enum class Instances { Multi, Single }; struct HostConfig { bool no_beam = false; @@ -33,11 +34,15 @@ struct HostConfig { std::optional beam_cookie_file; std::map extra_env; std::vector forwarded_argv; + Instances instances = Instances::Multi; + std::optional instance_id; static HostConfig parse(int argc, char** argv); std::string resources_root() const; std::optional resolve_ini_path() const; + std::string resolved_instance_id() const; + std::string exe_basename() const; private: void apply_ini(); diff --git a/native/windows/src/host_controller.cpp b/native/windows/src/host_controller.cpp index 43d1b36..2b517fb 100644 --- a/native/windows/src/host_controller.cpp +++ b/native/windows/src/host_controller.cpp @@ -24,6 +24,23 @@ struct RequestMsg { RpcServer::ReplyFn reply; }; +struct EvalMsg { + std::string expr; + std::function done; +}; + +bool looks_like_scheme(const std::string& s) { + auto colon = s.find(':'); + if (colon == std::string::npos || colon == 0) return false; + if (colon == 1 && s.size() >= 3 && (s[2] == '\\' || s[2] == '/')) return false; + for (size_t i = 0; i < colon; i++) { + unsigned char c = static_cast(s[i]); + bool ok = (i == 0) ? std::isalpha(c) : (std::isalnum(c) || c == '+' || c == '.' || c == '-'); + if (!ok) return false; + } + return true; +} + } // namespace HostController::HostController(HostConfig config) : config_(std::move(config)) {} @@ -111,6 +128,18 @@ LRESULT HostController::on_host_message(HWND hwnd, UINT msg, WPARAM wParam, LPAR delete m; return 0; } + if (msg == WM_EDW_INSTANCE_ACTIVATE) { + auto* argv = reinterpret_cast*>(lParam); + activate_from_argv(*argv); + delete argv; + return 0; + } + if (msg == WM_EDW_INSTANCE_EVAL) { + auto* m = reinterpret_cast(lParam); + eval_rpc(m->expr, std::move(m->done)); + delete m; + return 0; + } if (msg == WM_EDW_DISCONNECT) { client_disconnected(); return 0; @@ -369,6 +398,9 @@ void HostController::spawn_beam() { env["EDW_PORT"] = std::to_string(server_.port()); env["EDW_HOST"] = config_.host; for (auto& [k, v] : config_.extra_env) env[k] = v; + if (env.find("RELEASE_DISTRIBUTION") == env.end()) { + env["RELEASE_DISTRIBUTION"] = "none"; + } std::wstring env_block; for (auto& [k, v] : env) { @@ -406,6 +438,51 @@ void HostController::spawn_beam() { watch_beam_process(); } +void HostController::activate_from_argv(const std::vector& argv) { + if (hwnd_ && GetCurrentThreadId() != GetWindowThreadProcessId(hwnd_, nullptr)) { + auto* heap = new std::vector(argv); + SendMessageW(hwnd_, WM_EDW_INSTANCE_ACTIVATE, 0, reinterpret_cast(heap)); + return; + } + if (argv.empty()) { + server_.notify("event.system.reopen", jsonutil::Json::object()); + } else { + for (const auto& a : argv) { + if (looks_like_scheme(a)) { + server_.notify("event.system.open_url", jsonutil::Json{{"url", a}}); + } else if (file_exists(a)) { + server_.notify("event.system.open_file", jsonutil::Json{{"path", a}}); + } else { + server_.notify("event.system.open_url", jsonutil::Json{{"url", a}}); + } + } + } + for (auto& [_, w] : windows_) { + w->show(); + w->raise(); + } +} + +void HostController::eval_rpc(const std::string& expr, std::function done) { + if (hwnd_ && GetCurrentThreadId() != GetWindowThreadProcessId(hwnd_, nullptr)) { + auto* m = new EvalMsg{expr, std::move(done)}; + PostMessageW(hwnd_, WM_EDW_INSTANCE_EVAL, 0, reinterpret_cast(m)); + return; + } + if (!initialized_ || !server_.has_client()) { + done(false, "no initialized Elixir client"); + return; + } + server_.request("rpc.eval", jsonutil::Json{{"expr", expr}}, [done](jsonutil::Json result) { + auto inspect = jsonutil::get_string(result, "inspect"); + if (inspect) { + done(true, *inspect); + } else { + done(false, "rpc.eval failed"); + } + }); +} + void HostController::handle_request(jsonutil::Json id, const std::string& method, jsonutil::Json params, RpcServer::ReplyFn reply) { if (method.rfind("test.", 0) == 0) { diff --git a/native/windows/src/host_controller.hpp b/native/windows/src/host_controller.hpp index 8389284..d02b4d9 100644 --- a/native/windows/src/host_controller.hpp +++ b/native/windows/src/host_controller.hpp @@ -6,9 +6,11 @@ #include "web_window.hpp" #include +#include #include #include #include +#include struct HostError { int code; @@ -39,6 +41,8 @@ class HostController { bool start(); RpcServer& server() { return server_; } HWND hwnd() const { return hwnd_; } + void activate_from_argv(const std::vector& argv); + void eval_rpc(const std::string& expr, std::function done); static LRESULT CALLBACK HostWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); static constexpr UINT WM_EDW_REQUEST = WM_APP + 10; @@ -46,6 +50,8 @@ class HostController { static constexpr UINT WM_EDW_TRAY = WM_APP + 12; static constexpr UINT WM_EDW_BEAM_EXIT = WM_APP + 13; static constexpr UINT WM_EDW_RESPAWN = WM_APP + 14; + static constexpr UINT WM_EDW_INSTANCE_ACTIVATE = WM_APP + 15; + static constexpr UINT WM_EDW_INSTANCE_EVAL = WM_APP + 16; private: void client_disconnected(); diff --git a/native/windows/src/instance_lock.cpp b/native/windows/src/instance_lock.cpp new file mode 100644 index 0000000..ac548ae --- /dev/null +++ b/native/windows/src/instance_lock.cpp @@ -0,0 +1,317 @@ +#include "instance_lock.hpp" + +#include "json_util.hpp" +#include "win_util.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr DWORD kTimeoutMs = 15000; + +uint32_t to_be32(uint32_t x) { + return ((x & 0xffu) << 24) | ((x & 0xff00u) << 8) | ((x & 0xff0000u) >> 8) | (x >> 24); +} + +uint32_t from_be32(uint32_t x) { return to_be32(x); } + +std::string windows_uid() { + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return "0"; + DWORD len = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &len); + if (len == 0) { + CloseHandle(token); + return "0"; + } + std::vector buf(len); + DWORD rid = 0; + if (GetTokenInformation(token, TokenUser, buf.data(), len, &len)) { + auto* tu = reinterpret_cast(buf.data()); + PUCHAR count = GetSidSubAuthorityCount(tu->User.Sid); + if (count && *count > 0) { + rid = *GetSidSubAuthority(tu->User.Sid, static_cast(*count - 1)); + } + } + CloseHandle(token); + return std::to_string(rid); +} + +bool write_all(HANDLE h, const void* data, size_t n) { + const char* p = static_cast(data); + size_t left = n; + while (left) { + DWORD w = 0; + if (!WriteFile(h, p, static_cast(left), &w, nullptr) || w == 0) return false; + p += w; + left -= w; + } + return true; +} + +bool read_all(HANDLE h, void* data, size_t n) { + char* p = static_cast(data); + size_t left = n; + while (left) { + DWORD r = 0; + if (!ReadFile(h, p, static_cast(left), &r, nullptr) || r == 0) return false; + p += r; + left -= r; + } + return true; +} + +bool write_frame(HANDLE h, const std::string& json) { + uint32_t len = to_be32(static_cast(json.size())); + return write_all(h, &len, 4) && write_all(h, json.data(), json.size()); +} + +bool read_frame(HANDLE h, std::string* out) { + uint32_t nlen = 0; + if (!read_all(h, &nlen, 4)) return false; + uint32_t len = from_be32(nlen); + if (len == 0 || len > 8 * 1024 * 1024) return false; + out->assign(len, '\0'); + return read_all(h, out->data(), len); +} + +HANDLE connect_pipe(const std::wstring& name) { + DWORD start = GetTickCount(); + while (GetTickCount() - start < kTimeoutMs) { + HANDLE h = CreateFileW(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, + 0, nullptr); + if (h != INVALID_HANDLE_VALUE) return h; + if (GetLastError() != ERROR_PIPE_BUSY && GetLastError() != ERROR_FILE_NOT_FOUND) return INVALID_HANDLE_VALUE; + WaitNamedPipeW(name.c_str(), 200); + } + return INVALID_HANDLE_VALUE; +} + +int rpc_call(const std::string& instance_id, const std::string& method, jsonutil::Json params, + jsonutil::Json* out_result, std::string* err_out) { + HANDLE h = connect_pipe(InstanceLock::pipe_name(instance_id)); + if (h == INVALID_HANDLE_VALUE) { + if (err_out) *err_out = "no running single-instance host"; + return 1; + } + jsonutil::Json req = jsonutil::rpc_request(1, method, std::move(params)); + std::string json = jsonutil::stringify(req); + if (!write_frame(h, json)) { + CloseHandle(h); + if (err_out) *err_out = "control pipe write failed"; + return 1; + } + std::string payload; + if (!read_frame(h, &payload)) { + CloseHandle(h); + if (err_out) *err_out = "control pipe read failed"; + return 1; + } + CloseHandle(h); + auto root = jsonutil::parse(payload); + if (!root.is_object()) { + if (err_out) *err_out = "control pipe parse failed"; + return 1; + } + if (root.contains("error")) { + std::string msg = "instance request failed"; + if (root["error"].is_object()) { + if (auto m = jsonutil::get_string(root["error"], "message")) msg = *m; + } + if (err_out) *err_out = msg; + return 1; + } + if (out_result && root.contains("result")) *out_result = root["result"]; + return 0; +} + +} // namespace + +InstanceLock::~InstanceLock() { + running_ = false; + if (stop_event_) SetEvent(stop_event_); + if (!instance_id_.empty()) { + HANDLE wake = CreateFileW(pipe_name(instance_id_).c_str(), GENERIC_READ | GENERIC_WRITE, 0, + nullptr, OPEN_EXISTING, 0, nullptr); + if (wake != INVALID_HANDLE_VALUE) CloseHandle(wake); + } + if (thread_.joinable()) thread_.join(); + if (mutex_) { + ReleaseMutex(mutex_); + CloseHandle(mutex_); + mutex_ = nullptr; + } + if (stop_event_) { + CloseHandle(stop_event_); + stop_event_ = nullptr; + } +} + +std::string InstanceLock::sanitize_id(const std::string& raw) { + std::string out; + out.reserve(raw.size()); + for (unsigned char c : raw) { + if (std::isalnum(c) || c == '.' || c == '_' || c == '-') + out.push_back(static_cast(c)); + else + out.push_back('_'); + } + if (out.size() > 64) out.resize(64); + return out.empty() ? "DesktopWebView" : out; +} + +std::wstring InstanceLock::mutex_name(const std::string& instance_id) { + return utf8_to_wide("Local\\edw-" + sanitize_id(instance_id)); +} + +std::wstring InstanceLock::pipe_name(const std::string& instance_id) { + return utf8_to_wide("\\\\.\\pipe\\edw-" + windows_uid() + "-" + sanitize_id(instance_id)); +} + +bool InstanceLock::try_serve(const std::string& instance_id) { + instance_id_ = instance_id; + mutex_ = CreateMutexW(nullptr, TRUE, mutex_name(instance_id).c_str()); + if (!mutex_) return false; + if (GetLastError() == ERROR_ALREADY_EXISTS) { + CloseHandle(mutex_); + mutex_ = nullptr; + return false; + } + stop_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); + return true; +} + +void InstanceLock::set_handlers(ActivateFn activate, EvalFn eval) { + activate_ = std::move(activate); + eval_ = std::move(eval); +} + +void InstanceLock::start() { + if (!mutex_ || running_) return; + running_ = true; + thread_ = std::thread([this] { accept_loop(); }); +} + +void InstanceLock::accept_loop() { + auto name = pipe_name(instance_id_); + while (running_) { + HANDLE pipe = CreateNamedPipeW(name.c_str(), PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, 64 * 1024, 64 * 1024, 1000, nullptr); + if (pipe == INVALID_HANDLE_VALUE) { + if (!running_) break; + Sleep(50); + continue; + } + BOOL connected = ConnectNamedPipe(pipe, nullptr) ? TRUE : (GetLastError() == ERROR_PIPE_CONNECTED); + if (!connected || !running_) { + CloseHandle(pipe); + break; + } + handle_client(pipe); + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + } +} + +void InstanceLock::handle_client(HANDLE pipe) { + std::string payload; + if (!read_frame(pipe, &payload)) return; + auto root = jsonutil::parse(payload); + if (!root.is_object()) return; + auto method = jsonutil::get_string(root, "method").value_or(""); + jsonutil::Json id = root.contains("id") ? root["id"] : jsonutil::Json(1); + jsonutil::Json params = root.contains("params") ? root["params"] : jsonutil::Json::object(); + + if (method == "instance.activate") { + std::vector argv; + if (params.contains("argv") && params["argv"].is_array()) { + for (auto& v : params["argv"]) { + if (v.is_string()) argv.push_back(v.get()); + } + } + if (activate_) activate_(argv); + write_frame(pipe, jsonutil::stringify(jsonutil::rpc_ok(id, true))); + return; + } + + if (method == "instance.eval") { + auto expr = jsonutil::get_string(params, "expr").value_or(""); + if (expr.empty()) { + write_frame(pipe, jsonutil::stringify(jsonutil::rpc_error(id, -32602, "expr required"))); + return; + } + if (!eval_) { + write_frame(pipe, jsonutil::stringify(jsonutil::rpc_error(id, -32000, "no initialized Elixir client"))); + return; + } + struct EvalWait { + bool ok = false; + std::string text; + std::mutex mu; + std::condition_variable cv; + bool done = false; + }; + auto ew = std::make_shared(); + eval_(expr, [ew](bool ok, std::string text) { + { + std::lock_guard lock(ew->mu); + ew->ok = ok; + ew->text = std::move(text); + ew->done = true; + } + ew->cv.notify_one(); + }); + { + std::unique_lock lock(ew->mu); + if (!ew->cv.wait_for(lock, std::chrono::milliseconds(kTimeoutMs), [&] { return ew->done; })) { + write_frame(pipe, jsonutil::stringify(jsonutil::rpc_error(id, -32000, "rpc.eval timed out"))); + return; + } + } + if (!ew->ok) { + write_frame(pipe, jsonutil::stringify( + jsonutil::rpc_error(id, -32000, ew->text.empty() ? "rpc.eval failed" : ew->text))); + return; + } + write_frame(pipe, jsonutil::stringify(jsonutil::rpc_ok(id, jsonutil::Json{{"inspect", ew->text}}))); + return; + } + + write_frame(pipe, jsonutil::stringify(jsonutil::rpc_error(id, -32601, "Method not found"))); +} + +int InstanceLock::client_activate(const std::string& instance_id, + const std::vector& argv) { + jsonutil::Json arr = jsonutil::Json::array(); + for (auto& a : argv) arr.push_back(a); + std::string err; + int code = rpc_call(instance_id, "instance.activate", jsonutil::Json{{"argv", arr}}, nullptr, &err); + if (code != 0) fprintf(stderr, "edw: instance.activate failed: %s\n", err.c_str()); + return code; +} + +int InstanceLock::client_eval(const std::string& instance_id, const std::string& expr, + std::string* inspect_out) { + jsonutil::Json result; + std::string err; + int code = rpc_call(instance_id, "instance.eval", jsonutil::Json{{"expr", expr}}, &result, &err); + if (code != 0) { + fprintf(stderr, "edw: instance.eval failed: %s\n", err.c_str()); + return code; + } + auto inspect = jsonutil::get_string(result, "inspect"); + if (!inspect) { + fprintf(stderr, "edw: instance.eval missing inspect\n"); + return 1; + } + if (inspect_out) *inspect_out = *inspect; + return 0; +} diff --git a/native/windows/src/instance_lock.hpp b/native/windows/src/instance_lock.hpp new file mode 100644 index 0000000..d5d6900 --- /dev/null +++ b/native/windows/src/instance_lock.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "win_prefix.hpp" + +#include +#include +#include +#include +#include + +class InstanceLock { + public: + using ActivateFn = std::function& argv)>; + using EvalDone = std::function; + using EvalFn = std::function; + + InstanceLock() = default; + ~InstanceLock(); + + InstanceLock(const InstanceLock&) = delete; + InstanceLock& operator=(const InstanceLock&) = delete; + + bool try_serve(const std::string& instance_id); + void set_handlers(ActivateFn activate, EvalFn eval); + void start(); + + static int client_activate(const std::string& instance_id, + const std::vector& argv); + static int client_eval(const std::string& instance_id, const std::string& expr, + std::string* inspect_out); + + static std::wstring pipe_name(const std::string& instance_id); + static std::wstring mutex_name(const std::string& instance_id); + static std::string sanitize_id(const std::string& raw); + + private: + void accept_loop(); + void handle_client(HANDLE pipe); + + HANDLE mutex_ = nullptr; + HANDLE stop_event_ = nullptr; + std::atomic running_{false}; + std::thread thread_; + std::string instance_id_; + ActivateFn activate_; + EvalFn eval_; +}; diff --git a/native/windows/src/main.cpp b/native/windows/src/main.cpp index 9f60016..5d408c5 100644 --- a/native/windows/src/main.cpp +++ b/native/windows/src/main.cpp @@ -1,14 +1,16 @@ +#include "beam_cli.hpp" #include "config.hpp" #include "host_controller.hpp" +#include "instance_lock.hpp" #include "web_window.hpp" #include "win_util.hpp" -#include "beam_cli.hpp" #include #include #include #include + namespace { std::vector argv_utf8() { @@ -41,9 +43,28 @@ int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) { CoUninitialize(); return exclusive; } + + std::unique_ptr lock; + if (config.instances == Instances::Single) { + lock = std::make_unique(); + if (!lock->try_serve(config.resolved_instance_id())) { + int code = InstanceLock::client_activate(config.resolved_instance_id(), config.forwarded_argv); + CoUninitialize(); + return code; + } + } + WebWindow::register_class(); auto host = std::make_unique(std::move(config)); + if (lock) { + HostController* h = host.get(); + lock->set_handlers([h](const std::vector& argv) { h->activate_from_argv(argv); }, + [h](const std::string& expr, InstanceLock::EvalDone done) { + h->eval_rpc(expr, std::move(done)); + }); + lock->start(); + } if (!host->start()) { fprintf(stderr, "failed to start host\n"); CoUninitialize(); diff --git a/native/windows/src/rpc_server.cpp b/native/windows/src/rpc_server.cpp index be09ff9..f29c5da 100644 --- a/native/windows/src/rpc_server.cpp +++ b/native/windows/src/rpc_server.cpp @@ -209,6 +209,10 @@ void RpcServer::notify(const std::string& method, jsonutil::Json params) { } void RpcServer::request(const std::string& method, jsonutil::Json params, PendingCallback cb) { + if (client_sock_ == INVALID_SOCKET) { + cb(nullptr); + return; + } int id_num = next_outbound_id_++; jsonutil::Json id = id_num; pending_[jsonutil::id_key(id)] = std::move(cb); diff --git a/native/windows/src/rpc_server.hpp b/native/windows/src/rpc_server.hpp index b0e6f15..8feec74 100644 --- a/native/windows/src/rpc_server.hpp +++ b/native/windows/src/rpc_server.hpp @@ -33,6 +33,7 @@ class RpcServer { void notify(const std::string& method, jsonutil::Json params); void request(const std::string& method, jsonutil::Json params, PendingCallback cb); void close_connection(); + bool has_client() const { return client_sock_ != INVALID_SOCKET; } static constexpr UINT WM_EDW_SOCKET = WM_APP + 1; diff --git a/test/e2e/instance_test.exs b/test/e2e/instance_test.exs new file mode 100644 index 0000000..d283649 --- /dev/null +++ b/test/e2e/instance_test.exs @@ -0,0 +1,108 @@ +defmodule DesktopWebview.E2E.InstanceTest do + use ExUnit.Case, async: false + + @moduletag :e2e + + alias DesktopWebview.{BeamFixture, Binary, Launcher, Transport} + + setup do + unless Binary.available?() do + flunk("DesktopWebView binary missing at #{Binary.path()}") + end + + :ok + end + + test "second host with URL exits 0 and first gets open_url" do + {launcher, ctx} = start_single_host!() + Transport.subscribe(self()) + + {out, status} = + Launcher.oneshot([ + "--edw-no-beam", + "--edw-config=#{ctx.ini}", + "--edw-instance-id=#{ctx.id}", + "ddrive://invite/x" + ]) + + assert status == 0, out + refute out =~ "listening " + assert_receive {:edw_event, "event.system.open_url", %{"url" => "ddrive://invite/x"}}, 5_000 + stop_host(launcher) + end + + test "empty forwarded argv emits reopen" do + {launcher, ctx} = start_single_host!() + Transport.subscribe(self()) + + {out, status} = + Launcher.oneshot([ + "--edw-no-beam", + "--edw-config=#{ctx.ini}", + "--edw-instance-id=#{ctx.id}" + ]) + + assert status == 0, out + refute out =~ "listening " + assert_receive {:edw_event, "event.system.reopen", _params}, 5_000 + stop_host(launcher) + end + + test "instances=multi keeps both hosts up" do + ctx = BeamFixture.write_instance_ini!(BeamFixture.unique_id("multi"), "multi") + on_exit(fn -> File.rm_rf(ctx.dir) end) + + {:ok, first} = + Launcher.start( + test_rpc: false, + lifetime: :reconnect, + extra_args: ["--edw-config=#{ctx.ini}", "--edw-instance-id=#{ctx.instance_id}"] + ) + + {:ok, second} = + Launcher.start( + test_rpc: false, + lifetime: :reconnect, + extra_args: ["--edw-config=#{ctx.ini}", "--edw-instance-id=#{ctx.instance_id}"] + ) + + on_exit(fn -> + stop_host(first) + stop_host(second) + end) + + assert first.listen_port != second.listen_port + assert is_integer(first.listen_port) + assert is_integer(second.listen_port) + stop_host(first) + stop_host(second) + end + + defp start_single_host! do + ctx = BeamFixture.write_instance_ini!(BeamFixture.unique_id("si")) + + {:ok, launcher} = + Launcher.start( + test_rpc: false, + lifetime: :reconnect, + extra_args: ["--edw-config=#{ctx.ini}", "--edw-instance-id=#{ctx.instance_id}"] + ) + + if pid = Process.whereis(Transport), do: GenServer.stop(pid, :normal, 1000) + {:ok, _} = Transport.start_link([]) + assert {:ok, _caps} = Transport.connect("127.0.0.1", launcher.listen_port) + + on_exit(fn -> + stop_host(launcher) + File.rm_rf(ctx.dir) + end) + + {launcher, %{id: ctx.instance_id, ini: ctx.ini, dir: ctx.dir}} + end + + defp stop_host(launcher) do + Launcher.stop(launcher) + rescue + _ -> :ok + end +end diff --git a/test/e2e/rpc_test.exs b/test/e2e/rpc_test.exs index b611a04..a1cc002 100644 --- a/test/e2e/rpc_test.exs +++ b/test/e2e/rpc_test.exs @@ -3,7 +3,7 @@ defmodule DesktopWebview.E2E.RpcTest do @moduletag :e2e - alias DesktopWebview.{BeamFixture, Binary, Launcher} + alias DesktopWebview.{BeamFixture, Binary, Launcher, Transport} setup do unless Binary.available?() do @@ -13,61 +13,49 @@ defmodule DesktopWebview.E2E.RpcTest do :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, - node: node, - cookie: to_string(cookie) - ) - - beam_dir - end - test "inspects 1+1 as 2" do - beam_dir = rpc_beam_dir!() + {launcher, ctx} = start_single_host!() {out, status} = Launcher.oneshot([ "--edw-rpc", "1+1", - "--edw-beam-path=#{beam_dir}" + "--edw-config=#{ctx.ini}", + "--edw-instance-id=#{ctx.id}" ]) assert status == 0, out assert String.split(String.trim(out), "\n", trim: true) |> Enum.any?(&(&1 == "2")) + stop_host(launcher) end - test "evaluates a module on the test node" do - beam_dir = rpc_beam_dir!() + test "evaluates a module on the connected client" do + {launcher, ctx} = start_single_host!() {out, status} = Launcher.oneshot([ "--edw-rpc", "DesktopWebview.Binary.available?()", - "--edw-beam-path=#{beam_dir}" + "--edw-config=#{ctx.ini}", + "--edw-instance-id=#{ctx.id}" ]) assert status == 0, out assert String.trim(out) |> String.split("\n", trim: true) |> Enum.any?(&(&1 == "true")) + stop_host(launcher) end - 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" - ) + test "fails when no single-instance host is running" do + ctx = BeamFixture.write_instance_ini!(BeamFixture.unique_id("rpc-missing")) + ini = ctx.ini + id = ctx.instance_id {_out, status} = Launcher.oneshot([ "--edw-rpc", "1+1", - "--edw-beam-path=#{beam_dir}" + "--edw-config=#{ini}", + "--edw-instance-id=#{id}" ]) assert status != 0 @@ -81,4 +69,32 @@ defmodule DesktopWebview.E2E.RpcTest do refute out =~ "listening " assert out =~ "mutually exclusive" end + + defp start_single_host! do + ctx = BeamFixture.write_instance_ini!(BeamFixture.unique_id("rpc")) + + {:ok, launcher} = + Launcher.start( + test_rpc: false, + lifetime: :reconnect, + extra_args: ["--edw-config=#{ctx.ini}", "--edw-instance-id=#{ctx.instance_id}"] + ) + + if pid = Process.whereis(Transport), do: GenServer.stop(pid, :normal, 1000) + {:ok, _} = Transport.start_link([]) + assert {:ok, _caps} = Transport.connect("127.0.0.1", launcher.listen_port) + + on_exit(fn -> + stop_host(launcher) + File.rm_rf(ctx.dir) + end) + + {launcher, %{id: ctx.instance_id, ini: ctx.ini, dir: ctx.dir}} + end + + defp stop_host(launcher) do + Launcher.stop(launcher) + rescue + _ -> :ok + end end diff --git a/test/support/beam_fixture.ex b/test/support/beam_fixture.ex index 614788a..50b4b2f 100644 --- a/test/support/beam_fixture.ex +++ b/test/support/beam_fixture.ex @@ -1,6 +1,23 @@ defmodule DesktopWebview.BeamFixture do @moduledoc false + def unique_id(prefix) do + "#{prefix}-#{System.unique_integer([:positive])}" + end + + def write_instance_ini!(instance_id, instances \\ "single") do + dir = tmp_dir("edw-inst-#{instance_id}") + ini = Path.join(dir, "edw.ini") + + File.write!(ini, """ + [lifetime] + instances = #{instances} + instance_id = #{instance_id} + """) + + %{dir: dir, ini: ini, instance_id: instance_id} + end + def tmp_dir(prefix) do dir = Path.join( From de26070a7d43cd39c0eff770d5ffda8e1a762c43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 14:39:01 +0000 Subject: [PATCH 07/10] Mark single-instance and control-socket rpc.eval done. Linux Elixir E2E covers instance.activate and --edw-rpc. Shared suite is the source of truth for all hosts. Co-authored-by: Dominic Letz --- docs/specs/feature-edw-rpc.md | 8 ++++---- docs/specs/feature-single-instance.md | 10 +++++----- docs/status/linux.md | 4 ++-- docs/status/macos.md | 4 ++-- docs/status/windows.md | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/specs/feature-edw-rpc.md b/docs/specs/feature-edw-rpc.md index f14e3a7..bea0e15 100644 --- a/docs/specs/feature-edw-rpc.md +++ b/docs/specs/feature-edw-rpc.md @@ -129,10 +129,10 @@ Packaging CLI table. Porting checklist row for `--edw-rpc`. Protocol ## Implementation Checklist -- [ ] macOS / Windows / Linux one-shot CLI via `instance.eval` -- [ ] Mutual exclusion with `--edw-recover` -- [ ] E2E cases from tests-edw-rpc.yaml -- [ ] Status row `done` only when E2E is green +- [x] macOS / Windows / Linux one-shot CLI via `instance.eval` +- [x] Mutual exclusion with `--edw-recover` +- [x] E2E cases from tests-edw-rpc.yaml +- [x] Status row `done` only when E2E is green ## Version History diff --git a/docs/specs/feature-single-instance.md b/docs/specs/feature-single-instance.md index 7ebcf29..4674bd4 100644 --- a/docs/specs/feature-single-instance.md +++ b/docs/specs/feature-single-instance.md @@ -149,11 +149,11 @@ row, AGENTS.md hard rule, status rows. ## Implementation Checklist -- [ ] macOS / Windows / Linux control socket -- [ ] Activate argv classification + raise windows -- [ ] `RELEASE_DISTRIBUTION=none` when unset -- [ ] E2E cases from tests-single-instance.yaml -- [ ] Status row `done` only when E2E is green +- [x] macOS / Windows / Linux control socket +- [x] Activate argv classification + raise windows +- [x] `RELEASE_DISTRIBUTION=none` when unset +- [x] E2E cases from tests-single-instance.yaml +- [x] Status row `done` only when E2E is green ## Version History diff --git a/docs/status/linux.md b/docs/status/linux.md index 4fa55da..e886df0 100644 --- a/docs/status/linux.md +++ b/docs/status/linux.md @@ -34,8 +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` (control socket) | todo | One-shot `instance.eval` → `rpc.eval`; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | -| Single-instance lock + activate | todo | [feature-single-instance.md](../specs/feature-single-instance.md) | +| `--edw-rpc` (control socket) | done | One-shot `instance.eval` → `rpc.eval`; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | +| Single-instance lock + activate | done | [feature-single-instance.md](../specs/feature-single-instance.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 | | diff --git a/docs/status/macos.md b/docs/status/macos.md index f5e6a48..361c640 100644 --- a/docs/status/macos.md +++ b/docs/status/macos.md @@ -37,8 +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` (control socket) | todo | One-shot `instance.eval` → `rpc.eval`; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | -| Single-instance lock + activate | todo | [feature-single-instance.md](../specs/feature-single-instance.md) | +| `--edw-rpc` (control socket) | done | One-shot `instance.eval` → `rpc.eval`; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | +| Single-instance lock + activate | done | [feature-single-instance.md](../specs/feature-single-instance.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 | diff --git a/docs/status/windows.md b/docs/status/windows.md index 4a903d0..4e58257 100644 --- a/docs/status/windows.md +++ b/docs/status/windows.md @@ -35,8 +35,8 @@ Release asset: `DesktopWebView-windows-x64.exe` (GitHub Releases; not Hex `priv/ | 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 + backoff | done | Reset counters on `initialize`; shared E2E | -| `--edw-rpc` (control socket) | todo | One-shot `instance.eval` → `rpc.eval`; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | -| Single-instance lock + activate | todo | [feature-single-instance.md](../specs/feature-single-instance.md) | +| `--edw-rpc` (control socket) | done | One-shot `instance.eval` → `rpc.eval`; [feature-edw-rpc.md](../specs/feature-edw-rpc.md) | +| Single-instance lock + activate | done | [feature-single-instance.md](../specs/feature-single-instance.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 | From 953c9d8d32c2eb2461fc29516aa47a951678150d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 14:42:03 +0000 Subject: [PATCH 08/10] Stop host drain without killing a reused pid. on_exit Process.exit/2 on a dead drain pid can hit the next ExUnit test after pid reuse. Only kill the drain while it still owns the port. Co-authored-by: Dominic Letz --- lib/desktop_webview/launcher.ex | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/desktop_webview/launcher.ex b/lib/desktop_webview/launcher.ex index c98a17c..5a1d57d 100644 --- a/lib/desktop_webview/launcher.ex +++ b/lib/desktop_webview/launcher.ex @@ -49,8 +49,10 @@ defmodule DesktopWebview.Launcher do 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) + # Drain without linking to the caller. A link would kill this process + # when the test exits, and a later on_exit Process.exit/2 could hit a + # reused pid (the next ExUnit test). + drain_pid = spawn(fn -> drain_port(port) end) true = Port.connect(port, drain_pid) {:ok, @@ -79,8 +81,9 @@ defmodule DesktopWebview.Launcher do def stop(%{port: port} = launcher) when is_port(port) do if pid = Map.get(launcher, :drain_pid) do - Process.unlink(pid) - Process.exit(pid, :kill) + if drain_owns_port?(port, pid) do + Process.exit(pid, :kill) + end end close_port(port) @@ -89,6 +92,16 @@ defmodule DesktopWebview.Launcher do def stop(_), do: :ok + defp drain_owns_port?(port, pid) do + Process.alive?(pid) and pid != self() and + case Port.info(port, :connected) do + {:connected, ^pid} -> true + _ -> false + end + rescue + ArgumentError -> false + end + defp close_port(port) do case Port.info(port) do nil -> :ok From 7fe1f745ceb4a650d9601406342271956e7eae56 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 14:45:57 +0000 Subject: [PATCH 09/10] Avoid Process.exit kill when stopping the host. Close the port and terminate the host OS pid. Do not kill a stored drain pid. Unlink Transport from the E2E test process. Co-authored-by: Dominic Letz --- lib/desktop_webview/launcher.ex | 33 +++++++++++++------------ test/e2e/instance_test.exs | 44 ++++++--------------------------- test/e2e/rpc_test.exs | 38 ++++------------------------ test/support/beam_fixture.ex | 41 ++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 85 deletions(-) diff --git a/lib/desktop_webview/launcher.ex b/lib/desktop_webview/launcher.ex index 5a1d57d..1414e7e 100644 --- a/lib/desktop_webview/launcher.ex +++ b/lib/desktop_webview/launcher.ex @@ -49,9 +49,9 @@ defmodule DesktopWebview.Launcher do case await_listening(port, Keyword.get(opts, :timeout, 10_000)) do {:ok, listen_port} -> - # Drain without linking to the caller. A link would kill this process - # when the test exits, and a later on_exit Process.exit/2 could hit a - # reused pid (the next ExUnit test). + # Drain without linking to the caller. A link would exit the caller + # when the drain stops, and a stored drain pid must never be killed + # later — ExUnit can reuse that pid for the next test. drain_pid = spawn(fn -> drain_port(port) end) true = Port.connect(port, drain_pid) @@ -80,28 +80,29 @@ defmodule DesktopWebview.Launcher do end def stop(%{port: port} = launcher) when is_port(port) do - if pid = Map.get(launcher, :drain_pid) do - if drain_owns_port?(port, pid) do - Process.exit(pid, :kill) - end - end - close_port(port) + terminate_os(Map.get(launcher, :os_pid)) :ok end def stop(_), do: :ok - defp drain_owns_port?(port, pid) do - Process.alive?(pid) and pid != self() and - case Port.info(port, :connected) do - {:connected, ^pid} -> true - _ -> false - end + defp terminate_os(os_pid) when is_integer(os_pid) and os_pid > 0 do + case :os.type() do + {:win32, _} -> + System.cmd("taskkill", ["/PID", Integer.to_string(os_pid), "/T", "/F"], + stderr_to_stdout: true + ) + + _ -> + System.cmd("kill", ["-TERM", Integer.to_string(os_pid)], stderr_to_stdout: true) + end rescue - ArgumentError -> false + _ -> :ok end + defp terminate_os(_), do: :ok + defp close_port(port) do case Port.info(port) do nil -> :ok diff --git a/test/e2e/instance_test.exs b/test/e2e/instance_test.exs index d283649..8afcb8b 100644 --- a/test/e2e/instance_test.exs +++ b/test/e2e/instance_test.exs @@ -14,7 +14,7 @@ defmodule DesktopWebview.E2E.InstanceTest do end test "second host with URL exits 0 and first gets open_url" do - {launcher, ctx} = start_single_host!() + {launcher, ctx} = BeamFixture.start_single_instance_host!("si") Transport.subscribe(self()) {out, status} = @@ -28,11 +28,11 @@ defmodule DesktopWebview.E2E.InstanceTest do assert status == 0, out refute out =~ "listening " assert_receive {:edw_event, "event.system.open_url", %{"url" => "ddrive://invite/x"}}, 5_000 - stop_host(launcher) + BeamFixture.stop_host(launcher) end test "empty forwarded argv emits reopen" do - {launcher, ctx} = start_single_host!() + {launcher, ctx} = BeamFixture.start_single_instance_host!("si") Transport.subscribe(self()) {out, status} = @@ -45,7 +45,7 @@ defmodule DesktopWebview.E2E.InstanceTest do assert status == 0, out refute out =~ "listening " assert_receive {:edw_event, "event.system.reopen", _params}, 5_000 - stop_host(launcher) + BeamFixture.stop_host(launcher) end test "instances=multi keeps both hosts up" do @@ -67,42 +67,14 @@ defmodule DesktopWebview.E2E.InstanceTest do ) on_exit(fn -> - stop_host(first) - stop_host(second) + BeamFixture.stop_host(first) + BeamFixture.stop_host(second) end) assert first.listen_port != second.listen_port assert is_integer(first.listen_port) assert is_integer(second.listen_port) - stop_host(first) - stop_host(second) - end - - defp start_single_host! do - ctx = BeamFixture.write_instance_ini!(BeamFixture.unique_id("si")) - - {:ok, launcher} = - Launcher.start( - test_rpc: false, - lifetime: :reconnect, - extra_args: ["--edw-config=#{ctx.ini}", "--edw-instance-id=#{ctx.instance_id}"] - ) - - if pid = Process.whereis(Transport), do: GenServer.stop(pid, :normal, 1000) - {:ok, _} = Transport.start_link([]) - assert {:ok, _caps} = Transport.connect("127.0.0.1", launcher.listen_port) - - on_exit(fn -> - stop_host(launcher) - File.rm_rf(ctx.dir) - end) - - {launcher, %{id: ctx.instance_id, ini: ctx.ini, dir: ctx.dir}} - end - - defp stop_host(launcher) do - Launcher.stop(launcher) - rescue - _ -> :ok + BeamFixture.stop_host(first) + BeamFixture.stop_host(second) end end diff --git a/test/e2e/rpc_test.exs b/test/e2e/rpc_test.exs index a1cc002..562a8b0 100644 --- a/test/e2e/rpc_test.exs +++ b/test/e2e/rpc_test.exs @@ -3,7 +3,7 @@ defmodule DesktopWebview.E2E.RpcTest do @moduletag :e2e - alias DesktopWebview.{BeamFixture, Binary, Launcher, Transport} + alias DesktopWebview.{BeamFixture, Binary, Launcher} setup do unless Binary.available?() do @@ -14,7 +14,7 @@ defmodule DesktopWebview.E2E.RpcTest do end test "inspects 1+1 as 2" do - {launcher, ctx} = start_single_host!() + {launcher, ctx} = BeamFixture.start_single_instance_host!("rpc") {out, status} = Launcher.oneshot([ @@ -26,11 +26,11 @@ defmodule DesktopWebview.E2E.RpcTest do assert status == 0, out assert String.split(String.trim(out), "\n", trim: true) |> Enum.any?(&(&1 == "2")) - stop_host(launcher) + BeamFixture.stop_host(launcher) end test "evaluates a module on the connected client" do - {launcher, ctx} = start_single_host!() + {launcher, ctx} = BeamFixture.start_single_instance_host!("rpc") {out, status} = Launcher.oneshot([ @@ -42,7 +42,7 @@ defmodule DesktopWebview.E2E.RpcTest do assert status == 0, out assert String.trim(out) |> String.split("\n", trim: true) |> Enum.any?(&(&1 == "true")) - stop_host(launcher) + BeamFixture.stop_host(launcher) end test "fails when no single-instance host is running" do @@ -69,32 +69,4 @@ defmodule DesktopWebview.E2E.RpcTest do refute out =~ "listening " assert out =~ "mutually exclusive" end - - defp start_single_host! do - ctx = BeamFixture.write_instance_ini!(BeamFixture.unique_id("rpc")) - - {:ok, launcher} = - Launcher.start( - test_rpc: false, - lifetime: :reconnect, - extra_args: ["--edw-config=#{ctx.ini}", "--edw-instance-id=#{ctx.instance_id}"] - ) - - if pid = Process.whereis(Transport), do: GenServer.stop(pid, :normal, 1000) - {:ok, _} = Transport.start_link([]) - assert {:ok, _caps} = Transport.connect("127.0.0.1", launcher.listen_port) - - on_exit(fn -> - stop_host(launcher) - File.rm_rf(ctx.dir) - end) - - {launcher, %{id: ctx.instance_id, ini: ctx.ini, dir: ctx.dir}} - end - - defp stop_host(launcher) do - Launcher.stop(launcher) - rescue - _ -> :ok - end end diff --git a/test/support/beam_fixture.ex b/test/support/beam_fixture.ex index 50b4b2f..ef3b30d 100644 --- a/test/support/beam_fixture.ex +++ b/test/support/beam_fixture.ex @@ -5,6 +5,47 @@ defmodule DesktopWebview.BeamFixture do "#{prefix}-#{System.unique_integer([:positive])}" end + def start_single_instance_host!(prefix) do + ctx = write_instance_ini!(unique_id(prefix)) + + {:ok, launcher} = + DesktopWebview.Launcher.start( + test_rpc: false, + lifetime: :reconnect, + extra_args: ["--edw-config=#{ctx.ini}", "--edw-instance-id=#{ctx.instance_id}"] + ) + + attach_transport!(launcher.listen_port) + + ExUnit.Callbacks.on_exit(fn -> + stop_host(launcher) + File.rm_rf(ctx.dir) + end) + + {launcher, %{id: ctx.instance_id, ini: ctx.ini, dir: ctx.dir}} + end + + def attach_transport!(listen_port) do + if pid = Process.whereis(DesktopWebview.Transport) do + Process.unlink(pid) + GenServer.stop(pid, :normal, 1000) + end + + {:ok, pid} = DesktopWebview.Transport.start_link([]) + Process.unlink(pid) + assert_connect!(listen_port) + end + + def stop_host(launcher) do + DesktopWebview.Launcher.stop(launcher) + rescue + _ -> :ok + end + + defp assert_connect!(listen_port) do + {:ok, _caps} = DesktopWebview.Transport.connect("127.0.0.1", listen_port) + end + def write_instance_ini!(instance_id, instances \\ "single") do dir = tmp_dir("edw-inst-#{instance_id}") ini = Path.join(dir, "edw.ini") From c39634f88e1bcfa485bf12fb58991835ec0d9f83 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 14:48:44 +0000 Subject: [PATCH 10/10] Fix Swift exclusivity error in control-socket read. Copy the remaining byte count before withUnsafeMutableBytes so the exclusive buffer access does not overlap buf.count. Co-authored-by: Dominic Letz --- native/macos/Sources/DesktopWebView/InstanceLock.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/native/macos/Sources/DesktopWebView/InstanceLock.swift b/native/macos/Sources/DesktopWebView/InstanceLock.swift index ba1600c..9a0f0dc 100644 --- a/native/macos/Sources/DesktopWebView/InstanceLock.swift +++ b/native/macos/Sources/DesktopWebView/InstanceLock.swift @@ -296,8 +296,9 @@ final class InstanceLock { private static func readAll(_ fd: Int32, _ buf: inout [UInt8]) -> Bool { var offset = 0 while offset < buf.count { + let remaining = buf.count - offset let n = buf.withUnsafeMutableBytes { raw in - read(fd, raw.baseAddress!.advanced(by: offset), buf.count - offset) + read(fd, raw.baseAddress!.advanced(by: offset), remaining) } if n < 0 { if errno == EINTR { continue }