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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions docs/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,32 @@ 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
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
Expand All @@ -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 <expr>` | 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:

Expand All @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions docs/porting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`.

Expand Down
4 changes: 4 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
180 changes: 180 additions & 0 deletions docs/specs/feature-beam-restart.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading