Skip to content
Merged
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: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (963) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (967) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
35 changes: 22 additions & 13 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,13 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-18 (module-holder enumeration + createfile-probe + taskkill RM + excluded-chain self-exclusion + self-lock guard)"
_STOP_ALL_STAMP = "built 2026-08-19 (fixed-port daemon shutdown — rant 08:05:21)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
# (runs standalone inside the installer) so it cannot import emrg.connect —
# keep in sync with emrg.connect.EMRGD_PORT.
_EMRGD_PORT = 56031

_EMRG_CLIENT_RE = re.compile(r"-m\s+emrg(\.server)?(\s|$)")
_APPIMAGE_RE = re.compile(r"EMRG-[\w.\-]*AppImage(\s|$)")
Expand DownExpand Up@@ -328,20 +334,23 @@ def stop_daemon() -> None:
cannot, so we clean it up — the next daemon start re-asserts both files).
"""
port_path = config_dir() / "emrgd.port"
# Fixed-port shutdown (rant 2026-08-19T08:05:21): the daemon always
# listens on _EMRGD_PORT; the port file only supplies the auth token. If
# the file is missing/stale, fall through to the pid + cmdline paths.
try:
port_tok = port_path.read_text(encoding="utf-8").split()
if len(port_tok) == 2:
if ws_graceful_shutdown(int(port_tok[0]), port_tok[1]):
# wait for the daemon to exit + remove its pid file
# (~10s grace: old stop-emrg.cmd v2 polled emrgd.pid up to
# 10s; a busy daemon mid-tool-loop needs the full window)
for _ in range(60):
pid = _read_pid_file()
if pid is None or not _pid_alive(pid):
break
time.sleep(0.15)
text = port_path.read_text(encoding="utf-8").split()
token = text[1] if len(text) == 2 else ""
except (OSError, ValueError):
pass # port file missing/corrupt → fall through to pid path
token = ""
if token and ws_graceful_shutdown(_EMRGD_PORT, token):
# wait for the daemon to exit + remove its pid file
# (~10s grace: old stop-emrg.cmd v2 polled emrgd.pid up to
# 10s; a busy daemon mid-tool-loop needs the full window)
for _ in range(60):
pid = _read_pid_file()
if pid is None or not _pid_alive(pid):
break
time.sleep(0.15)

pid = _read_pid_file()
if pid is not None and _pid_alive(pid):
Expand Down
42 changes: 26 additions & 16 deletions emrg/connect.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,12 @@
ws://127.0.0.1:<port> (local, all platforms)
wss://<host>:<port> (remote, Phase 5 — same protocol + TLS + token)

The daemon writes its dynamic port and auth token to ``~/.emrg/emrgd.port``
(``port\\n token``, mode 0o600). Clients read that file, connect, and send
The daemon listens on the fixed port ``127.0.0.1:EMRGD_PORT`` (56031, rant
2026-08-19T08:05:21 — fixed-port bind exclusivity is the single-instance
admission) and writes its auth token to ``~/.emrg/emrgd.port``
(``port\\n token``, mode 0o600). Clients read that file for the token, and
connect to the fixed port.
Clients then send
a first-frame auth message; the daemon confirms with ``auth_ok`` before the
normal protocol loop. Auth failure raises :class:`AuthError` so callers can
distinguish it from a transient disconnect (which should be retried).
Expand All@@ -32,6 +36,13 @@
# Port/token file lives at ~/.emrg/emrgd.port (port\n token, mode 0o600)
CONNECT_ID = "emrgd"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a FIXED
# loopback port so kernel-level bind exclusivity (EADDRINUSE) is the single-
# instance admission — no PID file to forge/delete, no race window. The port
# file still carries the auth token; the port itself is now a constant.
# Keep in sync with emrg._stop_all._EMRGD_PORT (that module is pure stdlib).
EMRGD_PORT = 56031


class AuthError(Exception):
"""Raised when the daemon rejects the auth handshake.
Expand All@@ -49,17 +60,19 @@ def get_server_path() -> str:
async def connect_to_server():
"""Connect to the emrgd server over WebSocket.

Reads ``~/.emrg/emrgd.port``, connects to ``ws://127.0.0.1:<port>``,
sends the first-frame auth message and waits for the ``auth_ok``
confirmation. Returns the connected WebSocket object (single ws — no
Reads the auth token from ``~/.emrg/emrgd.port``, connects to the FIXED
daemon port ``ws://127.0.0.1:<EMRGD_PORT>`` (rant 2026-08-19T08:05:21 —
the port is a constant; the file only carries the token), sends the
first-frame auth message and waits for the ``auth_ok`` confirmation.
Returns the connected WebSocket object (single ws — no
``(reader, writer)`` tuple anymore).

Raises:
AuthError: auth rejected (bad/missing token, or daemon version mismatch).
ConnectionRefusedError / OSError / FileNotFoundError: daemon not running.
"""
port_path = Path(get_server_path())
port, token = port_path.read_text(encoding="utf-8").split()
_, token = port_path.read_text(encoding="utf-8").split()
# proxy=None: loopback connections must never go through a system proxy.
# websockets 17 defaults proxy=True and reads the OS proxy settings — when a
# Windows system proxy is enabled (e.g. 10.10.0.28:6501 for HN/Reddit access),
Expand All@@ -68,7 +81,7 @@ async def connect_to_server():
# local daemon, while the Node.js GUI is unaffected (2026-08-14 incident; root
# cause of continuous emrg-task/emrg-promote-task crashes since 2026-08-13).
ws = await connect(
f"ws://127.0.0.1:{port}",
f"ws://127.0.0.1:{EMRGD_PORT}",
proxy=None,
max_size=16 * 1024 * 1024,
)
Expand DownExpand Up@@ -97,18 +110,15 @@ def cleanup_server() -> None:
def is_server_running_sync(timeout: float = 2.0) -> bool:
"""Synchronous health-check probe (for client startup).

Uses blocking TCP connect to the port in ``emrgd.port``. Reads only the
first line (port), never the token — this is a low-cost liveness probe;
real auth happens on the first frame of a real connection.
Blocking TCP connect to the FIXED daemon port ``127.0.0.1:EMRGD_PORT``
(rant 2026-08-19T08:05:21). No port-file read: the fixed port is the
ground truth, so a missing/stale ``emrgd.port`` never makes the probe
report "not running" while a daemon is actually alive (the dual-instance
root cause). Real auth happens on the first frame of a real connection.
"""
port_path = Path(get_server_path())
try:
port = int(port_path.read_text(encoding="utf-8").splitlines()[0])
except (OSError, ValueError, IndexError):
return False
sock = None
try:
sock = _socket.create_connection(("127.0.0.1", port), timeout=timeout)
sock = _socket.create_connection(("127.0.0.1", EMRGD_PORT), timeout=timeout)
return True
except (ConnectionRefusedError, OSError):
return False
Expand Down
Loading
Loading