The 10s-after-healthy failure pattern was reproducing even with
the connection pool disabled. Smoking gun: the inbox loop opens
GET /messages?timeout=30 right after start_daemon returns, and
every subsequent peers/routes call timed out exactly when our
client-side reqwest timeout (10s) fired.
Concluded mycelium 0.6.1's HTTP server serialises requests: while
the long-poll connection is held, no other admin endpoint can
respond. The sidecar process kept logging routes the whole time
(seen in the in-app log buffer) — proof the daemon was alive,
just unable to serve concurrent calls.
Switch to short-poll: timeout=0 returns immediately, sleep 2s
between iterations. Per-iteration server hold time is now
millisecond-scale instead of 30s.
Direct mycelium runs and our pkexec spawns are both healthy
(sidecar logs show acquired routes streaming for 20+s). Yet our
reqwest poller can't reach 127.0.0.1:port after the first
successful request. Smoking gun: failure happens ~10s after the
first reply — exactly when an idle keep-alive connection would
have been reaped.
Disable pooling (pool_max_idle_per_host(0)) so every call opens a
fresh TCP connection. Loopback overhead is negligible (~50us per
request) and we're immune to server-side idle closes. Also pin
connect_timeout to 3s so a wedged half-open doesn't block for
the full 10s request timeout.
Even with --api-addr set, mycelium also opens an internal JSON-RPC
endpoint on 127.0.0.1:8990 by default (visible in --debug as
\"Starting JSON-RPC server listen_addr=127.0.0.1:8990\"). When a
previous run left an orphan mycelium running as root — which we
can't SIGKILL from a user-level Child::kill_on_drop — the new
instance fails to bind 8990 and exits ~10s after startup, with no
sidecar://exited event because the kill never landed.
Pin --metrics-api-address to a fresh ephemeral port alongside the
other three. Known limitation: stopping the daemon still doesn't
reliably kill the root child; the user has to `sudo pkill mycelium`
between runs. Will be addressed by an elevated-shutdown hook.
mycelium defaults --tcp-listen-port and --quic-listen-port to 9651
when not provided. If anything else holds 9651 (a previous test
instance, a Docker container from the level-1 procedure, another
mycelium running on the host), the daemon comes up far enough to
serve the loopback API for a few seconds before tearing itself
down on the listen failure.
Pick three distinct ephemeral ports (api, tcp, quic) per spawn.
Trade-off: inbound peers need the actual port number, which we
already log; the user can pin via a future SidecarConfig field.
`tracing_subscriber::fmt().try_init()` installs a global log
implementation via the tracing-log shim. tauri-plugin-log then
calls `log::set_logger()` which panics with "attempted to set a
logger after the logging system was already initialized".
The plugin was never wired in JS code (no @tauri-apps/plugin-log
import) — backend logging via tracing is sufficient. Drop:
- tauri-plugin-log dep + plugin registration
- log:default capability permission
- @tauri-apps/plugin-log JS dep
Verified: cargo check is clean, app starts past the panic.
Backend
- regenerate_identity command stops the daemon, deletes
priv_key.bin, leaves the user to restart for a fresh identity;
falls back to the canonical XDG path when sidecar.key_path()
isn't populated yet
- tauri.conf.json ships the polkit policy via deb.files mapping;
src-tauri/packaging/polkit/tech.threefold.mycellium-ui.policy
declares the spawn action with auth_admin_keep so the dialog
appears once per session
Frontend
- config store persists SidecarConfig (peers, tunName, noTun)
through tauri-plugin-store; App.vue reads it and forwards to
start_daemon, replacing the hard-coded defaults
- Settings view: daemon-config form, identity panel with the
destructive regenerate button, sidecar log viewer, About
- README rewritten end-to-end: HTTP-loopback architecture, polkit
install path, build commands, verification matrix, and a
honest "known limitations" section
Backend
- api/messages.rs covers send/pop/reply/status with an externally
tagged MessageDestination enum that matches the daemon's
{ip|pk: ...} body shape; pop_message uses an inflated request
timeout to outlast the long-poll window
- api/topics.rs implements default action, topic CRUD, sources
whitelist, and forward-socket get/set/remove. POST /topics ships
the raw base64 string as the body (not JSON); path segments are
percent-encoded inline (topics contain '/' and '+')
- api/pubkey.rs resolves an overlay IPv6 to a hex public key
- poller spawns a third long-poll loop on /messages?peek=false
that fans every inbound message into a 200-deep ring buffer and
emits messages://incoming for the UI
Frontend
- messages store: live inbox via the event, persisted outbox via
tauri-plugin-store keyed under outbox.json
- ComposeMessage form: ip/pk toggle, optional UTF-8 topic and
payload that get base64-encoded with a TextEncoder-based helper
- MessageList renders printable payloads decoded; binary payloads
fall back to a "(N bytes binary)" hint
- Topics view: split layout with whitelist on the left, per-topic
sources/forward editor on the right; default-action toggle is
surfaced at the top
Backend
- api/routes.rs models the Babel-style route shape; metric uses an
untagged enum to round-trip both numeric hop counts and the
literal "infinite" string the daemon emits for poisoned routes
- routes_snapshot() runs the three GETs concurrently with try_join
so the snapshot is internally consistent
- poller spawns a second 5s loop emitting routes://updated; both
loops are owned by the Poller and aborted together on stop_daemon
Frontend
- routes store mirrors the snapshot shape; tabbed view (radix-vue)
with selected, fallback and queried lists
- RouteTable component shared by selected/fallback; metric column
is colour-coded (0 green, low neutral, high yellow, infinite red)
- Queried subnets show a live `expires in 12s` countdown driven by
a 1Hz tick ref instead of mutating the store
Backend
- api/peers.rs: list/add/remove + aggregate() that derives totals,
per-state counts, and tx/rx sums in one pass over the peer list
- poller.rs spawns a 3s tokio loop that emits peers://updated and
stats://updated; cancelled via abort() on stop_daemon
- DELETE peer URL-encodes the endpoint (the path includes ://) with
a small inline percent-encoder to avoid a url crate dep
- Tauri commands: peers_list, peer_add (with empty-string guard),
peer_remove, peers_stats
Frontend
- peers store subscribes to the two events and refreshes after
add/remove for immediate UI feedback
- Peers view renders endpoint, type, color-coded state badge, and
formatBytes-formatted rx/tx; the four stat cards re-use a
reusable Stat component
- AddPeerDialog uses radix-vue's Dialog primitive with regex
validation for tcp:// and quic:// schemes
Backend
- sidecar.rs supervises the bundled `mycelium` binary launched via
pkexec; locates it in resource_dir or CARGO_MANIFEST_DIR/binaries
matching $TAURI_ENV_TARGET_TRIPLE
- ephemeral port via portpicker, key + config persisted in
app_data_dir, kill_on_drop with explicit start_kill on stop
- health-check loop calls /api/v1/admin until 2xx (timeout 20s);
emits sidecar://ready and sidecar://exited
- 500-line ring buffer of stdout/stderr surfaced via sidecar_logs
command for the upcoming Settings page
- elevation::is_auth_failure(126|127) maps pkexec cancel to a
dedicated AppError variant
- AppError uses thiserror, Serialize impl renders messages as
plain strings for the JS side
Frontend
- typed `api` wrapper around invoke() in src/lib/api.ts
- node store (Pinia) bootstraps on mount, listens on
sidecar://ready and sidecar://exited
- StartupOverlay covers the whole window for idle/starting/error
phases; sidebar status dot + start/stop button
- Status view renders subnet, pubkey, api endpoint and key path
with one-click clipboard copy