Skip to content

fix(emr): dashboard error-page encoding + loud port-conflict failure#1930

Merged
itomek-amd merged 10 commits into
mainfrom
emr-error-page-encode
Jul 6, 2026
Merged

fix(emr): dashboard error-page encoding + loud port-conflict failure#1930
itomek-amd merged 10 commits into
mainfrom
emr-error-page-encode

Conversation

@itomek

@itomek itomek commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1932.

When the EMR dashboard can't be reached, users got a completely empty black window with no hint of what went wrong — and chasing that down surfaced a second, related failure. This PR fixes both halves of the "blank dashboard" experience:

  1. Error page rendered blank — the did-fail-load handler built the page as an unencoded data:text/html, URL; the first # in the hex colors starts the URL fragment, truncating the document mid-<style> (body rendered with 0 children). Now encoded with encodeURIComponent. Pre-existing bug (reproduced identically on electron 42.3.3 and 43.0.0) — surfaced while verifying chore(deps): bump electron from 42.3.3 to 43.0.0 in /hub/agents/python/emr/gaia_agent_emr/dashboard/electron in the emr-dashboard-dependencies group across 1 directory #1805.
  2. Dashboard server died silently on a taken portgaia api defaults to the same port 8080 as gaia-emr dashboard. When the port is occupied, uvicorn printed one error line and exited with code 0, while the CLI had already launched Electron pointing at :8080 — the user saw the connection-error page (or the wrong app) with no clue why. The dashboard UI then made it worse: when the frontend can't reach its backend, the Settings badge falls back to "Lemonade Server: ○ Not Running", blaming Lemonade for a dashboard-port problem. run_dashboard now probes the port up front and raises an actionable error naming the conflict and the --port escape hatch; the CLI exits 1 with that message instead of launching Electron at a dead URL.

Evidence — verified on electron 43.0.0 (the #1805 bump, now merged into this branch)

Error page (launched electron . with nothing on :8080, captured over CDP --remote-debugging-port=9333 + Page.captureScreenshot):

Before (main, electron 42/43 — identical) After (this PR, electron 43.0.0)
before after

Happy path — the packaged gaia-emr dashboard (editable install of this branch) serving the real dashboard, rendered by the electron 43 wrapper:

dashboard working

Port conflict — real CLI, with gaia api start genuinely holding :8080 (Lemonade healthy on :13305 the whole time):

$ gaia-emr dashboard
Error: EMR dashboard cannot start: 127.0.0.1:8080 is already in use (`gaia api`
also defaults to port 8080). Stop the other server or pass --port to pick a
different one.
$ echo $?
1

Before this PR the same scenario printed uvicorn's [Errno 48] address already in use line, exited 0, and launched Electron at the dead/wrong URL anyway.

🔍 CDP DOM check for the error page (before → after, electron 43.0.0)

Before (unencoded, on main):

doc URL head: data:text/html,%0A      <html>%0A        <head>%0A          <style>%0A ...
body children: 0
h1 text: (none)
body text: (empty)

After (this PR):

doc URL head: data:text/html;charset=utf-8,%0A%20%20%20%20%20%20%3Chtml%3E ...
body children: 4
h1 text: Connection Error
body text: Connection Error | Could not connect to the EMR Dashboard server at http://localhost:8080. | Make sure the dashboard server is running. | Retry

The screenshots are referenced from SHA-pinned evidence commits in this PR's history; the branch tip tree is clean, so a squash merge brings in only the code fixes.

Test plan

  • cd hub/agents/python/emr && PYTHONPATH=. python -m pytest tests/ -q → 57 passed (includes the two new port-conflict tests)
  • With no server on :8080: cd hub/agents/python/emr/gaia_agent_emr/dashboard/electron && npm install && EMR_DASHBOARD_URL=http://localhost:8080 npx electron . → styled "Connection Error" page with a Retry button (not a blank black window)
  • With :8080 occupied (e.g. gaia api start): gaia-emr dashboard → immediate red error naming the conflict, exit 1, no Electron window
  • Happy path: free :8080, gaia-emr dashboard → dashboard loads in the Electron window

Known limitations (pre-existing, not addressed here)

Tomasz Iniewicz added 3 commits July 6, 2026 13:12
The did-fail-load handler built a data:text/html URL from a raw template
literal containing '#' characters (hex colors). A '#' in a URL starts the
fragment, so the document was truncated mid-<style> and the Connection
Error page rendered as an empty black window. Encode the HTML with
encodeURIComponent so the full error page renders.

Pre-existing on electron 42.3.3; reproduced identically on 42.3.3 and
43.0.0, so unrelated to the dependabot bump — split out as a follow-up.
@github-actions github-actions Bot added the agent::emr Medical-intake (EMR) agent changes label Jul 6, 2026
@itomek itomek self-assigned this Jul 6, 2026
gaia api defaults to the same port (8080) as gaia-emr dashboard. When the
port was taken, uvicorn printed one error line and exited 0 while the CLI
had already launched Electron at :8080 — the user saw a connection-error
window (or the wrong app), and the dashboard Settings badge blamed
Lemonade ("Not Running") for what is a dashboard-port problem.

run_dashboard now probes the port before starting the agent and raises an
actionable RuntimeError naming the conflict and the --port escape hatch;
cmd_dashboard prints it and exits 1 instead of launching Electron at a
dead URL. The probe mirrors asyncio's bind semantics (SO_REUSEADDR on
POSIX only — on Windows it would bind over live listeners).
@itomek itomek changed the title fix(emr): URL-encode the dashboard error page data: URL fix(emr): dashboard error-page encoding + loud port-conflict failure Jul 6, 2026
@itomek itomek marked this pull request as ready for review July 6, 2026 18:10
@itomek itomek requested a review from kovtcharov-amd as a code owner July 6, 2026 18:10
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Verdict: Approve

This PR fixes the "blank EMR dashboard" experience in two places: the Electron error page now URL-encodes its HTML so the # in a CSS hex color no longer truncates the data: URL mid-document, and the dashboard server now probes its port up front and fails with an actionable message (naming the gaia api port-8080 clash and the --port escape hatch) instead of letting uvicorn exit 0 silently while Electron opens a dead URL. Both halves are covered by a new test, and the port probe is thoughtfully written to mirror asyncio's real bind behavior rather than approximate it. Nothing blocking.

🔍 Technical details

Strengths

  • The SO_REUSEADDR-on-POSIX-only probe (server.py:2172) faithfully mirrors what uvicorn/asyncio will actually do at bind time, so a passing probe genuinely predicts a passing bind — the comment explains the why in one line, exactly the WHY-comment style CLAUDE.md asks for. finally: probe.close() cleans up regardless of outcome.
  • Textbook fail-loudly: RuntimeError with what failed, what to do, and the conflicting command — no silent fallback. cmd_dashboard (cli.py:1118) translates it to a clean exit 1 instead of a traceback.
  • encodeURIComponent + ;charset=utf-8 on the data: URL (electron/main.js:66) is the correct, minimal fix for the fragment-truncation bug.
  • Both behaviors are tested (tests/test_dashboard_port.py): the real run_dashboard against an occupied port, and the CLI's exit code via a mocked conflict.

🟢 Minor / non-blocking

  • Launcher thread starts before the probe (cli.py:1104 vs 1111). open_thread.start() fires before run_dashboard() probes the port, so "Electron never launched" holds only because the launcher's delay=1.5s sleep vastly outlasts the ~microsecond probe-then-raise. That margin is enormous and reliable, so this works — but the ordering makes the guarantee implicit. Not worth reworking; noting it so the timing dependency is on record.
  • Test assumes the [api] extra is installed (test_dashboard_port.py:91). run_dashboard checks FastAPI first; without it the test hits the "FastAPI not installed" RuntimeError and the --port/port-number assertions fail rather than skip. Fine if the emr suite always installs the api extra — a skipUnless on the import would make the failure mode explicit, but it's optional.

@itomek itomek enabled auto-merge July 6, 2026 20:06
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

🟡 The IPv4-only probe silently misfires if --host is set to an IPv6 address (e.g. ::1), producing a confusing "port already in use" error before uvicorn even starts — when the real problem is a socket-family mismatch. Before this PR, uvicorn would attempt the bind itself and produce an accurate error; now the new probe intercepts it and reports the wrong cause.

🔍 Technical details

server.py (new probe block, ~line 2181):

probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
...
probe.bind((host, port))

socket.AF_INET is IPv4-only. If host is "::1" (or any IPv6 address the user passes via --host), bind() raises OSError: [Errno 22] Invalid argument, which is caught by except OSError and re-raised as:

RuntimeError: EMR dashboard cannot start: ::1:8080 is already in use (gaia api also defaults to port 8080). Stop the other server or pass --port to pick a different one.

That message is wrong — the port may not be in use at all.

Minimal fix: detect IPv6 hosts and use AF_INET6, or at least distinguish socket.gaierror / EINVAL from EADDRINUSE before constructing the message:

import errno
except OSError as e:
    if e.errno in (errno.EADDRINUSE, errno.EADDRNOTAVAIL):
        raise RuntimeError(
            f"EMR dashboard cannot start: {host}:{port} is already in use ..."
        ) from e
    raise  # propagate socket-family / resolution errors unmasked

The default host="127.0.0.1" is safe; only non-default IPv6 values trigger this.

The pre-flight probe was AF_INET-only and translated every bind error
into "port already in use" — for an IPv6 --host (e.g. ::1) it misfired
with EINVAL and reported the wrong cause. Pick the address family from
the host, translate only EADDRINUSE into the actionable conflict error,
and let uvicorn's own bind report anything else accurately.
@itomek

itomek commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on the IPv6 misfire — fixed in add851a. The probe now picks its address family from the host (AF_INET6 when it contains :) and translates only EADDRINUSE into the port-conflict error; any other bind error (bad family, unresolvable host) is left for uvicorn's own bind to report accurately. Covered by two new tests: an occupied ::1 port raises the same actionable error, and a family/resolution error stays silent instead of claiming the port is busy.

@itomek itomek disabled auto-merge July 6, 2026 21:27
@itomek-amd itomek-amd enabled auto-merge July 6, 2026 21:36
@itomek-amd itomek-amd added this pull request to the merge queue Jul 6, 2026
Merged via the queue into main with commit a3dbf58 Jul 6, 2026
49 of 51 checks passed
@itomek-amd itomek-amd deleted the emr-error-page-encode branch July 6, 2026 21:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent::emr Medical-intake (EMR) agent changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EMR dashboard Electron wrapper shows an empty black window instead of the connection-error page

2 participants