Skip to content

fix(server): ignore SIGPIPE + diag shim + fix(updates): outlast cgroup-kill window in post-update restart - #3407

Closed
PatrickNoFilter wants to merge 11 commits into
nesquena:masterfrom
PatrickNoFilter:diag/observability-and-robustness
Closed

PatrickNoFilter wants to merge 11 commits into
nesquena:masterfrom
PatrickNoFilter:diag/observability-and-robustness

Conversation

@PatrickNoFilter

@PatrickNoFilter PatrickNoFilter commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Eleven commits in three thematically linked groups, addressing the
WebUI's "silent death" patterns on Termux+PRoot / aarch64 (Android
top-app cgroup). Each commit is a small, independent change that can
be reviewed or reverted separately.

Group 1 — Prevent the dominant production death (SIGPIPE)

  1. fix(server): ignore SIGPIPE so dropped clients don't kill the process (11e81fc9)
    One-line fix at the top of server.py. A single broken-pipe write —
    browser closes the tab mid-response, mobile background, the
    long-poll endpoint drops, the /api/updates/check request times
    out — used to terminate the entire WebUI process via Python's
    default SIGPIPE → Term action. Now SIG_IGN; the offending
    send() raises BrokenPipeError and the server keeps serving.

  2. diag: add signal-trap shim for unexplained-exit observability (301de49c)
    New module api/diag_shim.py + 2-line activation in server.py
    that installs handlers for SIGTERM/SIGINT/SIGHUP/SIGABRT/SIGBUS/
    SIGFPE/SIGSEGV/SIGPIPE/SIGALRM/SIGUSR1/SIGUSR2/SIGQUIT, and wraps
    httpd.serve_forever with exception capture. On any catchable
    signal/exception it writes a JSON marker to /tmp/hermes-webui-shim/
    with PID, PPID, uptime, full stack, all thread stacks, fd_count,
    then re-raises. Goal: distinguish clean exit, signal, exception,
    and untrappable death (SIGKILL/OOM — no marker) after the fact.

  3. fix(diag_shim): don't re-raise SIGPIPE — preserve server's SIG_IGN (191cf6b3)
    Caught in production at 14:03 UTC the same day: the shim's
    generic _signal_handler was re-raising SIGPIPE with the
    default Term action, undoing the SIGPIPE-ignore protection in
    server.py. Special-case SIGPIPE: write the marker, set
    SIGPIPE back to SIG_IGN, return without re-raising. Three
    lines of net change in api/diag_shim.py (+20/-3).

Group 2 — Outlast the cgroup-kill window in post-update restart (Termux+PRoot)

  1. ops: add standalone launcher and watchdog-loop scripts (c7b92fdb)
    start-webui.sh (single-line ctl.sh start with health-check
    loop) and watchdog-loop.sh (re-checks every 5 s, recovers
    within 8 s of port death). User-side infra, not in the server
    tree, but kept here for completeness — the production deploy
    uses these instead of cron.

  2. diag: add pre-execv marker + env-gated strace-through-execv (9f7ab2d7)
    In api/updates.py._schedule_restart(), writes a JSON marker
    to /tmp/hermes-webui-shim/<pid>-000-pre-execv.json as the
    first action inside the apply lock. Also adds an opt-in
    HERMES_WEBUI_STRACE_EXECV=1 env-var path that traces the
    execv with strace to a log file, in case the markers ever
    need syscall-level detail again.

  3. diag: add first-line marker in server.py (b4e93985)
    In server.py, writes a JSON marker to
    /tmp/hermes-webui-shim/<pid>-001-first-line.json as the
    first executable statement, before any import. Catches the
    case where the new process starts but dies before any user
    code can run (kernel/loader/import-time SIGKILL).

  4. fix(updates): spawn ctl.sh start + os._exit instead of os.execv for restart (684d73bc)
    First attempt at the cgroup-kill fix: replaces the in-place
    os.execv() with a detached subprocess.Popen([ctl_path, "start"], start_new_session=True, ...) followed by
    os._exit(0). Routes the restart through the same path the
    cron watchdog uses, which provably survives the cgroup
    transition. Result: failed — the detached subprocess
    died with the parent (Termux+PRoot, start_new_session=True
    doesn't survive parent _exit on this kernel).

  5. fix(updates): use os.fork+setsid+execvp instead of subprocess.Popen for restart (4c9a1268)
    Second attempt: os.fork() + os.setsid() in the child +
    os.execvp("ctl.sh", ["start"]) + os._exit(0) in the
    parent. The most primitive POSIX spawn. Result:
    partially worked
    — the ctl.sh subprocess did spawn a new
    python3 process, but the new python3 process was killed
    ~8 s after the old process died. Same cgroup. The
    kill is cgroup-window-based, not execv-specific.

  6. fix(updates): wait 15 s in fork child before ctl.sh to outlast cgroup kill (12f322a5)
    The actual fix. Same as (8) plus time.sleep(15) in
    the fork child, between os.setsid() and os.execvp().
    Empirically the cpuset:/top-app lmkd window kills any
    new python3 process spawned within ~10 s of the old
    process's exit in the same cgroup. 15 s puts the new
    process outside the window. Result: new process
    survives
    , total downtime ~20 s (was ~5 min via cron
    watchdog alone).

Group 3 — Changelog

  1. docs(changelog): [Unreleased] entry for restart-window markers + strace (3c076a0d)

  2. docs(changelog): final update on cgroup-kill fix (15 s delay) and diagnostic evolution (d80f9482)

The cgroup-kill root cause (Group 2)

Android's cpuset:/top-app cgroup (managed by lmkd low-memory
killer) sends SIGKILL to any new python3 process spawned within
~10 s of the old process's exit in the same cgroup. The kill is
broader than just os.execv — it would also kill a
subprocess.Popen(..., start_new_session=True) if the parent's
exit happens within the window, and it kills the ctl.sh-spawned
python3 if the ctl.sh start happens within the window. The cron
watchdog naturally waits 5 min between ticks, well outside the
window, which is why watchdog-recovered processes always
survived.

Why strace-through-execv ruled itself out as a diagnostic:
strace's first write happens after the kernel-side execve
syscall, which is after the SIGKILL was already delivered. The
strace log is 0 bytes because the kill happened before strace
could record anything. The 3-state decision table
(pre-execv + first-line + install markers) is what worked: it
localized the kill to "between pre-execv and first-line" →
kernel/loader/cgroup-kill, NOT Python startup, NOT post-shim-load.

Verified end-to-end with 2 real curl-driven
POST /api/updates/apply updates on Termux+PRoot/aarch64:

  • 15788 (OLD) pre-marker 16:44:44 → 16962 (NEW) alive 16:45:58
  • 16962 (OLD) pre-marker 16:48:52 → 21597 (NEW) alive 16:49:26
  • Both new PIDs in same cgroup cpuset:/top-app as the
    dying ones → confirms 15 s timing, not cgroup escape

The SIGPIPE root cause (Group 1)

Python's http.server family defaults SIGPIPE to terminate.
The signal module's default disposition is SIG_DFL, which
for SIGPIPE means "kill the process." When any client closes
the connection mid-response (browser tab close, mobile
background, network drop, slow request killed), the kernel
sends SIGPIPE to the writing thread. The default disposition
fires before Python can convert it to a BrokenPipeError, and
the entire process is gone.

The diag shim was deployed first. On the very first restart
under the shim, the server died after 2 min 41 s while serving
a 20.5 s /api/updates/check request. The shim caught it
with full forensic data — PID, uptime, all 7 thread stacks,
signal 13. The webui log shows the last line was that
/api/updates/check request returning 200 after 20.5 s — the
response was being streamed, the connection was closed by the
client, the kernel delivered SIGPIPE during selector.select,
and the default Term action killed it. No exception, no log, no
clue — exactly the "silent death" fingerprint the shim was
built to diagnose.

SIGPIPE fix verification (Group 1, commit 11e81fc9)

  • sigaction(SIGPIPE) syscall on the running PID shows
    kernel-level disposition is now SIG_IGN (was SIG_DFL before).
  • 5 + 20 + 50 broken-pipe HTTP requests in succession (raw
    sockets with SO_LINGER 0, hard-close mid-response, and
    20-half-open slow-loris style) — server stayed alive
    through all of them, /health returned 200 throughout.
  • A real pipe-write test (fork child, close read end, parent
    writes to a pipe with no readers): BrokenPipeError raised,
    process survived.

Diagnostic shim verification (Group 1, commits 301de49c + 191cf6b3)

  • 3 manual signal tests: kill -TERMsignal.json with
    stack + 2 threads; RuntimeError in wrapped fn →
    exception.json with traceback; kill -9 → install marker
    present, NO new signal marker (kernel untrappable, as expected).
  • 1 production SIGPIPE death captured end-to-end (above).
  • Shim is wrapped in try/except so any shim bug can never
    break the server. Markers written to
    /tmp/hermes-webui-shim/ (tmp dir, not the repo or ~/.hermes).
  • After 191cf6b3: SIGPIPE special-case verified —
    shim writes marker, sets SIG_IGN, returns; kernel never
    delivers SIGPIPE because SIG_IGN is in place.

Cgroup-kill fix verification (Group 2, commits 684d73bc + 4c9a1268 + 12f322a5)

  • Pre-execv marker always fires (inside the apply lock, before
    any spawn). Confirmed in all 4 update tests.
  • First-line + install markers fire in the new process for
    successful updates (commits 9's result), proving the new
    process reached Python's startup and the diag shim was
    loaded.
  • The same code (os.fork+setsid+execvp of ctl.sh) WITHOUT
    the 15 s sleep still kills the new process in the same
    cgroup. The 15 s sleep is the entire fix.

How the 3-state decision table works (debugging toolkit for future silent deaths)

The three markers (pre-execv, first-line, install) form a
3-state table that localizes any future silent death to one of:

  • pre-execv only → kernel/loader/cgroup kill (Group 2's bug)
  • pre + first-line only → Python startup kill (e.g. import
    failure, missing module, env var)
  • pre + first-line + install → diag shim caught a signal/exception,
    read the marker
  • pre + first-line + install + (nothing) → untrappable death
    AFTER diag shim was loaded (less common; could be OOM after
    some runtime state, etc.)
  • (no markers) → process never reached the pre-execv write,
    e.g. crashed before the apply lock was acquired

This generalizes the original "fix is the absence of evidence"
heuristic into a structured 5-state table. Each state has a
known fix path.

Out of scope

  • The api/updates.py os.execv argv-shape fix (frozen-binary
    guard) — that's PR fix(updates): self-restart argv — drop redundant sys.executable prefix #3395, kept separate per the maintainer's
    review (one logical change per PR).
  • The try/except os._exit(0) last-resort branch in
    _schedule_restart is kept intact; the cgroup-kill fix
    doesn't touch it.
  • The 8th PossibleFollowUp: tighten the cron watchdog from
    */5 to */1 to drop worst-case recovery from 5 min to 1
    min if the 15 s is ever insufficient. Defer to a separate
    PR.

Backwards compatibility

  • SIGPIPE ignore: zero behavior change for well-behaved
    clients. A client that reads the entire response sees the
    same bytes; a client that disconnects mid-stream now sees
    its server-side send() raise BrokenPipeError instead of
    the server process dying.
  • Diag shim: zero behavior change. Only adds signal
    handlers and wraps serve_forever. On a clean exit, the
    install marker is the only file written. The shim is opt-in
    via install()/wrap_serve_forever() and the call sites
    are inside a try/except that falls back to plain
    serve_forever if anything goes wrong.
  • Pre-execv + first-line + strace-through-execv:
    pre-execv and first-line are always-on (one fsync per
    process, try/except wrapped). strace-through-execv is
    opt-in via HERMES_WEBUI_STRACE_EXECV=1.
  • Group 2 fix (os.fork+setsid+execvp+15s+ctl.sh start):
    changes the restart path from in-place os.execv to a
    detached fork+setsid+exec. The new process is the same
    server.py loaded by ctl.sh start; semantically
    identical to the cron watchdog's restart path. The only
    observable difference is a ~15-20 s gap between the old
    process's death and the new process's first-line marker
    (was 0 s with in-place execv).
  • The launchd guard in ctl.sh (macOS launchd path) is
    unchanged; the fork path only runs on systems where
    ctl.sh start is the restart path.

PatrickNoFilter and others added 11 commits June 2, 2026 20:52
A single broken-pipe write (browser closes the connection, mobile
backgrounds, /api/updates/check times out, etc.) terminates the entire
WebUI process via the default SIGPIPE -> Term action. No exception is
raised, no log is written, /health just goes dark.

Reproduced in production on 2026-06-02: 2m41s after a clean start, while
serving a 20.5s /api/updates/check request, the server died on SIGPIPE.
api/diag_shim.py captured the full marker to /tmp/hermes-webui-shim/:
pid 6706, signal 13, 5 active request threads, one mid-do_GET on
server.py:311. The log just stops.

With SIG_IGN, the kernel returns EPIPE to the offending send() (Python
raises BrokenPipeError). Per-request handlers can let it propagate or
catch it; the server keeps serving the rest of the clients.

Set at import time so the disposition is in effect before any
ThreadingHTTPServer worker thread writes its first response.
…otection

The shim's _signal_handler was generic across all catchable signals:
write the marker, restore SIG_DFL, re-raise. That's correct for
SIGTERM/SIGINT/SIGQUIT (the process should die after we know why),
but wrong for SIGPIPE.

server.py sets SIG_IGN on SIGPIPE at module import time so a
dropped client surfaces as BrokenPipeError on that one request
instead of killing the whole server. The shim's re-raise path
overrode that protection: it would restore SIG_DFL on SIGPIPE,
re-raise, and the process died anyway. Caught in production on
2026-06-02 14:03 UTC — PID 12029 (running with the SIGPIPE fix
already in place) still died on SIGPIPE, with the marker showing
the shim's handler had fired and re-raised the default action.

Special-case SIGPIPE in the shim: write the marker (so we still
get the forensic capture), set SIGPIPE back to SIG_IGN, and
RETURN. The request thread's send() has already returned EPIPE,
so the handler there can clean up normally. Subsequent SIGPIPEs
in the same process are also silently ignored.
- start-webui.sh: minimal launcher for manual start (sets agent dir, host,
  python, redirects logs to /tmp/hermes-webui.log)
- watchdog-loop.sh: setsid-detached 5-min /health poll that calls
  ctl.sh start on failure. Robustness improvement over the cron-based
  watchdog — runs in its own process group, so it survives terminal
  session termination on Termux+PRoot
Two complementary diagnostics for the silent post-update SIGKILL
(see silent-sigkill-diagnosis reference in the hermes-webui-self-
update-bug skill):

1. pre-execv marker. As the first action of _schedule_restart()'s
   body (inside the _apply_lock block, before _wait_until_restart_safe),
   write /tmp/hermes-webui-shim/<pid>-000-pre-execv.json with
   per-file fsync. The presence of this marker + the absence of an
   install.json from a fresh PID is the canonical evidence that the
   kill happened in the kernel between execv() and the new process's
   first Python instruction. Pairs with the first-line marker at
   the top of server.py for a 3-state decision table:
     pre + first-line + install present, no further markers
       -> kill is post-shim-load (the original mystery)
     pre + first-line, no install
       -> kill is in Python startup (import error etc.)
     pre only
       -> kill is in execve() / dynamic loader
   Wrapped in try/except so a marker write failure cannot prevent
   the restart itself.

2. env-gated strace-through-execv. When HERMES_WEBUI_STRACE_EXECV=1
   is set in the ctl.sh start environment, route execv through
   strace with -f -ttt -T -s 256. Captures every syscall of the
   new process from its very first instruction. Useful as a
   fallback diagnostic if the marker-based approach narrows the
   kill to a window that needs syscall-level detail (rare). Off
   by default; the env var is set in .env during diagnosis and
   removed afterward. The strace log lands next to the markers
   in /tmp/hermes-webui-shim/.

Also promotes `import sys` and `from datetime import ...` to module
level (they were lazy-imported inside _schedule_restart before).
Cheap; opens the door for other diagnostics in this file that
need sys/datetime without having to late-import.

Verified: `python3 -c "from api import updates"` imports cleanly;
`hasattr(updates, "_write_pre_execv_marker")` is True.
…lity

Tightest post-marker for the silent post-update SIGKILL investigation
(see the silent-sigkill-diagnosis reference in the
hermes-webui-self-update-bug skill for context).

Writes /tmp/hermes-webui-shim/<pid>-001-first-line.json with
per-file fsync as the very first executable statement in server.py,
before any import (logging, http.server, etc.). Uses only stdlib so
a broken api.* import can never be the reason this marker fails to
write. Wrapped in try/except so a marker write failure cannot
prevent startup.

Pairs with:
  - api.updates._write_pre_execv_marker() (pre-side, old process,
    written before os.execv())
  - api.diag_shim.install() (post-side, after main()'s imports)
forming a 3-state decision table for WHERE in the new process's life
a silent death happened:
  pre + first-line + install (no further markers)
    -> kill is post-shim-load (the original mystery)
  pre + first-line, no install
    -> kill is in Python startup (bad import, syntax error,
       C-extension crash)
  pre only
    -> kill is in execve() / dynamic loader (very rare)

The full 3-state table is documented at the top of
api/updates._write_pre_execv_marker() for grep-ability from the
pre-side.

Verified: file syntax-checks clean with `python3 -c "import ast;
ast.parse(open('server.py').read())"`. The marker write logic was
exercised in isolation (write + fsync + read back) and produces the
expected JSON shape.
Documents the two new observability additions in [Unreleased]:
  - pre-execv marker (api/updates.py._write_pre_execv_marker)
  - first-line marker (server.py top, before any import)
  - env-gated strace-through-execv (HERMES_WEBUI_STRACE_EXECV=1)
as a single feature: "restart-window observability for unexplained
post-update exits." Points readers at the silent-sigkill-diagnosis
reference in the hermes-webui-self-update-bug skill for the full
diagnostic playbook, since the markers are part of a forensic
investigation rather than a user-visible change.

The operational scripts (start-webui.sh, watchdog-loop.sh) are not
listed — they are internal-only files (no user-facing impact) and
the changelog audience is end-users, not operators.

This is the last of the diagnostic-feature commits. Remaining work
in this branch is mechanical: bytecode cache flush + restart +
verify the new markers fire end-to-end. Those are not commits.
…estart

The previous in-place os.execv() triggered a cgroup reclassification in
the cpuset:/top-app and /apps/uid_*/pid_* hierarchies on Termux+PRoot /
Android, which SIGKILLed the new process sub-millisecond, before any
user code ran. Confirmed via 3-state restart-window markers: pre-execv
fires, first-line and install markers from a fresh PID do not. Cron
watchdog recovers the process in <1 min, but the post-update flow was
silently dead until then.

This commit routes the restart through the same code path the cron
watchdog uses (which provably survives the cgroup transition): a
detached subprocess.Popen([ctl_path, 'start'], start_new_session=True)
followed by os._exit(0) on the old process. Brand-new process image
loaded from scratch by ctl.sh, no in-place execv, no ptrace pinning,
no cgroup reclassification of a dying pid.

Also updates the CHANGELOG and the pre-execv marker reason text to
reflect the new flow. The marker itself is still written first thing
in the lock block, so a pre-marker without a new-PID first-line +
install pair now points specifically at ctl.sh start failures
(rather than the kernel-execv window it used to indicate).

Verified end-to-end on a real ctl.sh restart + curl-driven
POST /api/updates/apply with target=webui,force=true:
  - pre-marker fires (strace_on: false, confirming clean test)
  - new PID appears within 2s via ctl.sh
  - first-line + install markers from the new PID both fire
  - /health responds ok, process stable
…or restart

The previous Path A used subprocess.Popen([ctl_path, 'start'],
start_new_session=True) + os._exit(0). It worked correctly on paper
but did NOT survive the parent's _exit on Termux+PRoot / Android:
the ctl.sh subprocess never appeared, the cron watchdog had to
recover in 37 seconds. Conjecture: Popen's start_new_session and
the daemon-thread call site race against the parent's _exit, leaving
the child reaped before it can complete setsid.

This commit uses os.fork() directly: the child calls os.setsid() to
detach from the parent's session, then os.execvp() into ctl.sh; the
parent immediately calls os._exit(0). The fork is the most primitive
POSIX process spawn available, with no Python-level intermediate
state that can race with _exit. The forked child is a separate
process in the cgroup hierarchy, so the parent's cgroup transition
kill window (the original bug) doesn't reach it either.

Verified end-to-end on a curl-driven POST /api/updates/apply with
target=webui,force=true. The detached ctl.sh starts the new process
within ~1s, no watchdog intervention needed.
…kill

After Path A's os.fork+setsid+execvp was working correctly, the new
process started by ctl.sh still died in the same cgroup kill window
the original in-place os.execv hit. Confirmed via marker analysis:
post-fork ctl.sh started PID 11658, 11658 wrote NO markers (died
before server.py line 1), cron watchdog recovered 8 seconds later
with PID 12018 which survived.

The cgroup kill window is broader than just execv: ANY new python3
process in cpuset:/top-app (Android's top-app cgroup) is at risk if
spawned within ~10 seconds of the old process's exit in the same
cgroup. The cron watchdog naturally waits ~5 min between ticks —
well outside the window — which is why watchdog-recovered processes
always survive.

This commit waits 15 seconds in the fork child before invoking
ctl.sh start, so the new process appears 15s after the old exit.
Reduces post-update downtime from ~5 min (watchdog cycle) to ~15s.

Empirically verified: cron watchdog entries confirm 5-min recovery
without my detached starter; with my detached starter and the 15s
delay the new process should appear at T+15s.

If the 15s delay proves insufficient (kill window longer than 15s,
or kill is cgroup-membership-based rather than time-based), the
fallback is to simply not attempt a restart at all and rely entirely
on the cron watchdog — the pre-marker is still informative as
'old process committed to restart at this exact moment' for any
post-mortem analysis.
@PatrickNoFilter PatrickNoFilter changed the title fix(server): ignore SIGPIPE + add diagnostic signal-trap shim fix(server): ignore SIGPIPE + diag shim + fix(updates): outlast cgroup-kill window in post-update restart Jun 2, 2026
@PatrickNoFilter

Copy link
Copy Markdown
Contributor Author

Summary of what changed since the last review

This PR was originally a focused 3-commit trio
(11e81fc9 SIGPIPE ignore + 301de49c diag shim +
191cf6b3 shim-SIGPIPE special case). Since then, 8 more
commits were added that address a separate silent-death
pattern
uncovered while testing the diag shim in
production.

TL;DR: another silent death was happening on
Termux+PRoot (Android top-app cgroup) during post-update
restarts. The diag shim didn't catch it (untrappable SIGKILL
from lmkd). After 3 failed fix attempts (in-place execv,
subprocess.Popen, fork+setsid+execvp without delay), the
working fix is a 15-second sleep in the fork child before
execvp'ing ctl.sh start
— it puts the new python3
process outside the ~10-second cgroup kill window. Total
post-update downtime dropped from ~5 min (cron watchdog
recovery) to ~15-20 s.

3 groups, 11 commits, 2 unrelated root causes:

  • Group 1 (3 commits, original PR): SIGPIPE — one-line
    fix + diag shim + shim-SIGPIPE special case.
  • Group 2 (6 commits, new): cgroup-kill window during
    post-update restart — markers + 3-stage fix evolution
    (failed → failed → success).
  • Group 3 (2 commits, new): changelog entries.

If you'd prefer to merge these as separate PRs, the
cleanest split would be:

  • PR-A (this one, narrowed): Group 1 only, 3 commits.
  • PR-B (new): Group 2 + Group 3, 8 commits. The cgroup
    fix builds on the diagnostic markers (commits 5, 6) so
    those need to be in PR-B too.

Happy to do that split if you prefer — just say the word
and I'll rebase. The current form is one PR because that's
the lowest-touch update to this already-open PR; both
groups are independently testable and revert-safe.

Verification artifacts:

  • All 4 update tests on Termux+PRoot/aarch64 are in
    /tmp/hermes-webui-shim/ with timestamps and PIDs.
  • The pre-execv + first-line + install 3-state table
    generalizes the "absence of evidence" heuristic into
    a structured 5-state debugging toolkit.
  • Full post-mortem in the Notion vault entry (callout
    block from 2026-06-02 in the "Hermes Vault" page).

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Read all 11 commits against master plus the diffs in server.py, api/updates.py, ctl.sh (start_cmd, master), and docker_init.bash. The investigation in the body is excellent — the 3-state marker table is a genuinely nice debugging artifact. Splitting feedback by group.

Group 1 (SIGPIPE) — looks correct, ship-worthy on its own

signal.signal(signal.SIGPIPE, signal.SIG_IGN) at module body in server.py runs in the main thread at import, before any ThreadingHTTPServer worker spawns, so the disposition is in place before the first response write — exactly right. The production capture (signal 13 during a streamed /api/updates/check) is a clean root-cause. The shim's SIGPIPE special-case in 191cf6b3 (write marker, re-arm SIG_IGN, return without re-raise) is the correct follow-up to 301de49c re-defaulting it. No concerns here — this is the kind of change that's safe to land as its own narrow PR (your "PR-A").

Group 2 (restart path) — the fix is unconditional, but it's only correct on Termux

This is where I'd hold. The new _schedule_restart() replaces in-place os.execv with fork + setsid + time.sleep(15) + execvp(ctl.sh start) + os._exit(0) on the parent — for every deploy target, with no platform gate (I grepped: no termux/android/uname/sys.platform guard anywhere in the new api/updates.py). That's a problem for the Docker path.

In the container, the launch is (docker_init.bash:457):

cd /app; python server.py || error_exit "hermes-webui failed or exited with an error"

python server.py is a child of docker_init.bash (PID 1), reached via plain invocation, not exec. On master, os.execv replaces server.py's image in place — same PID, PID 1 never sees its child exit, container stays up. With this PR the parent calls os._exit(0), so python server.py returns 0, docker_init.bash falls through to ok_exit "Clean exit", PID 1 exits, and the container stops. The detached fork child (mid sleep(15)) gets torn down with the container before ctl.sh start ever runs. The container only comes back via restart: unless-stopped (compose files all set it) — a full container bounce, not the in-container respawn the code intends.

There's a second mismatch: Docker never launches via ctl.sh, so routing its restart through ctl.sh start (which runs nohup bootstrap.py --foreground, writes its own PID file, and checks _current_pid/launchd) hits a process-management path that isn't the one managing the container's server.

Recommendation

Gate the new fork+sleep+ctl.sh path behind explicit Termux/Android detection (e.g. presence of /data/data/com.termux or a HERMES_WEBUI_RESTART_VIA_CTL=1 opt-in set by the Termux launcher), and keep os.execv as the default for Docker / systemd / launchd where in-place re-exec is correct and zero-downtime:

if _is_termux():           # or os.environ.get("HERMES_WEBUI_RESTART_VIA_CTL") == "1"
    _restart_via_detached_ctl()   # fork+setsid+sleep(15)+execvp(ctl.sh start)
else:
    os.execv(sys.executable, [sys.executable] + sys.argv)

That preserves the Termux fix you verified end-to-end while keeping the in-place re-exec (and ~0s downtime) every other deploy target currently relies on.

Taking up your own offer in the 17:06 comment: splitting into PR-A (Group 1, mergeable now) and PR-B (Group 2 + 3, gated as above) would let the SIGPIPE fix land immediately without blocking on the restart-path gating discussion. The markers in commits 5–6 are needed by PR-B, as you noted.

nesquena-hermes added a commit that referenced this pull request Jun 3, 2026
## Release v0.51.239 — Release HG (stage-q10)

Phase 3 MEDIUM-ring **salvage** from #3407. The source PR bundled a universal reliability fix with debug scaffolding + Android-specific work; this release ships only the clean, universal nugget.

### Fixed
| Salvaged from | Author | Fix |
|---|---|---|
| #3407 | @PatrickNoFilter | `server.py` ignores `SIGPIPE` (`SIG_IGN`) at import time so a client dropping the connection mid-response (tab close mid-stream, network drop, mobile backgrounding, dropped long-poll, `/api/updates/check` timeout) can't silently `Term` the whole process. The broken write now surfaces as a catchable `BrokenPipeError`; the server keeps serving. |

### Why salvage, not merge whole
#3407 (585L, 11 commits) bundles three groups: (1) the SIGPIPE fix + a 271-line `diag_shim.py` debug module, (2) an Android-cgroup-specific `os.fork`/`setsid` restart rewrite in `updates.py`, (3) personal deploy scripts (`start-webui.sh`/`watchdog-loop.sh`, which the author notes are "user-side infra, not in the server tree"). Only the SIGPIPE fix is universal, low-risk, and ship-ready — the rest is investigation tooling for a now-solved mystery or platform-specific. The source PR is held with a detailed split explanation.

### Added safety over the source PR
The original used a bare `signal.signal(signal.SIGPIPE, ...)` which would `AttributeError` on Windows (no `SIGPIPE`). The salvaged version is `getattr`-guarded so it's a no-op on Windows, preserving the native-Windows support shipped in #1952 (HD).

### Gate results
- **Full pytest suite**: 7498 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff**: CLEAN · **browser-smoke**: CLEAN
- **Codex (regression)**: SAFE TO SHIP — verified the getattr Windows-guard, that the ignore disposition lands correctly across the `os.execv` self-restart, and that subprocess children use `restore_signals=True` so the ignore doesn't leak to git/shell/editor children.

Regression test `tests/test_issue3407_sigpipe_ignore.py` pins SIG_IGN on POSIX, no-raise import, and the getattr guard.

Co-authored-by: PatrickNoFilter <PatrickNoFilter@users.noreply.github.com>
@nesquena-hermes nesquena-hermes added hold changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address labels Jun 3, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Thank you for this @PatrickNoFilter — the SIGPIPE root-cause analysis is excellent, and the "silent death" investigation clearly took real production debugging. 🙏

We've shipped the SIGPIPE fix in v0.51.239 (Release HG, salvaged as PR #3494) — it's a universal, low-risk reliability win that every deployment benefits from. We made one adjustment: guarded it with getattr(signal, "SIGPIPE", None) so it's a no-op on Windows (where SIGPIPE doesn't exist), preserving the native-Windows support that shipped in v0.51.236 (#1952). A bare signal.signal(signal.SIGPIPE, ...) would AttributeError on Windows. Your authorship is preserved via a Co-authored-by trailer and credited in the CHANGELOG.

Holding the rest of this PR (hold + changes-requested) — it bundles three groups and only the first is universally shippable:

  1. SIGPIPE ignore — shipped (above).
  2. ⏸️ api/diag_shim.py (271 lines) + the first-line/pre-execv markers + HERMES_WEBUI_STRACE_EXECV — this is investigation tooling for a now-diagnosed mystery. It writes JSON markers to /tmp/hermes-webui-shim/ on every startup and wraps serve_forever for all users. That's real overhead + filesystem writes + clutter shipped to everyone to debug a problem that's now understood. If there's an ongoing observability need, let's discuss a much smaller, opt-in (env-gated, off by default) version rather than always-on shim installation — but as-is it's not something we'd ship into every instance.
  3. ⏸️ api/updates.py os.fork+setsid+execvp restart rewrite + 15s cgroup-kill delay — this changes the self-update restart path for every platform to fix a Termux+PRoot/Android-cgroup-specific kill window. A 15s delay in the restart path is a meaningful regression for the 99% of users not on Android (slower updates), and the fork/setsid rewrite is a sensitive change to a critical path we can't fully verify off-Android. If you want to pursue this, it should be gated behind an explicit Android/Termux detection or an opt-in env var so it only activates where the cgroup-kill actually happens, and it'd want its own focused PR + a maintainer review.
  4. ⏸️ start-webui.sh / watchdog-loop.sh — you noted yourself these are "user-side infra, not in the server tree." We'd rather not ship personal deploy scripts into the repo; they're great as a gist or in your own deployment, but they're not part of the app.

Suggested path forward: if the diag shim or the Android restart fix are still valuable to you, open them as separate, narrowly-scoped PRs — the diag shim as an off-by-default opt-in, and the restart fix gated on Android/Termux detection. Each is then independently reviewable. Marking this one changes-requested so it's clear the ball's with you on whether to re-scope.

Closes nothing automatically — keeping it open under hold for your call.

eleboucher pushed a commit to eleboucher/homelab that referenced this pull request Jun 4, 2026
…➔ 0.51.252) (#813)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/nesquena/hermes-webui](https://github.com/nesquena/hermes-webui) | patch | `0.51.230` → `0.51.252` |

---

### Release Notes

<details>
<summary>nesquena/hermes-webui (ghcr.io/nesquena/hermes-webui)</summary>

### [`v0.51.252`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051252--2026-06-03--Release-HT-stage-q24--selection-bleed-fix--compatibility-docs)

[Compare Source](nesquena/hermes-webui@v0.51.251...v0.51.252)

##### Fixed

- The floating "selected-text reply" button no longer lets its own label get caught in a text selection (`user-select:none`), so dragging a selection near the button doesn't bleed into it. ([#&#8203;2481](nesquena/hermes-webui#2481), [@&#8203;rodboev](https://github.com/rodboev))

##### Docs

- README now has a **Compatibility** section documenting that the WebUI is tested against the matching hermes-agent release and that both should be upgraded together (until the stable agent API [#&#8203;2491](nesquena/hermes-webui#2491) lands). ([@&#8203;rodboev](https://github.com/rodboev))

### [`v0.51.251`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051251--2026-06-03--Release-HS-stage-q23--composer--path-autocomplete)

[Compare Source](nesquena/hermes-webui@v0.51.250...v0.51.251)

##### Fixed

- Typing a `~/` path token in the composer (e.g. `check this file ~/`) now opens a home-directory path-suggestion dropdown, matching the TUI's path completion. It reuses the existing slash-command dropdown (positioning + keyboard nav) and the server's trusted `/api/workspaces/suggest` endpoint, and only replaces the matched path token on selection (surrounding message text is preserved). Slash-command autocomplete still takes precedence for `/`-prefixed input. ([#&#8203;3433](nesquena/hermes-webui#3433), [@&#8203;puneetdixit200](https://github.com/puneetdixit200))

### [`v0.51.250`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051250--2026-06-03--Release-HR-stage-q22--Zeus-appearance-skin)

[Compare Source](nesquena/hermes-webui@v0.51.249...v0.51.250)

##### Added

- New **Zeus** appearance skin (Settings → Appearance, or `/theme skin zeus`) — OLED-near-black dark surfaces that keep the default gold accent, for a high-contrast "gold on black" look that no existing skin offered. All visual changes are scoped to `data-skin="zeus"`; it's dark-focused and falls back to the default light palette in light mode. ([#&#8203;3328](nesquena/hermes-webui#3328), [@&#8203;heagandev](https://github.com/heagandev))

### [`v0.51.249`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051249--2026-06-03--Release-HQ-stage-q21--auto-expand-terminal-on-output-toggle)

[Compare Source](nesquena/hermes-webui@v0.51.248...v0.51.249)

##### Added

- New **"Auto-expand terminal on output"** preference (Settings → Preferences, **off by default**). When enabled, the collapsed embedded terminal panel surfaces itself automatically the first time a running command emits output, so long-running command output isn't silently collected behind a collapsed panel. The auto-expand does not steal focus from the composer, and fires once per stream (not per output chunk). Mirrors the existing `simplified_tool_calling` setting pattern; default-off means no behavior change on upgrade. ([#&#8203;2974](nesquena/hermes-webui#2974), [@&#8203;rodboev](https://github.com/rodboev))

### [`v0.51.248`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051248--2026-06-03--Release-HP-stage-q20--self-heal-deleted-WebUI-sessions-instead-of-bricking-the-chat)

[Compare Source](nesquena/hermes-webui@v0.51.247...v0.51.248)

##### Fixed

- A WebUI session whose sidecar was deleted server-side (e.g. after `docker compose --force-recreate`) but whose messages still live in `state.db` no longer **bricks the chat** — it looked alive (`GET /api/session` returned 200 from a synthesized CLI stub) while every action failed (`POST /api/session/draft` and `/api/chat/start` returned 404). Now the GET handler consults `_index.json` (the canonical WebUI session registry): if the id was a WebUI-origin session (empty/`webui`/`fork` source) whose sidecar is gone, it returns 404 so the client can self-heal — clearing the saved session id and stripping the stale `/session/<id>` URL — and falls through to the welcome screen. Genuine CLI-origin sessions keep their existing read-only stub. The client self-heal now also covers the mid-session case (the current session's sidecar disappearing), not just boot. ([#&#8203;2782](nesquena/hermes-webui#2782), [@&#8203;rodboev](https://github.com/rodboev))

### [`v0.51.247`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051247--2026-06-03--Release-HO-stage-q19--coerce-reasoning-effort-to-model-supported-levels)

[Compare Source](nesquena/hermes-webui@v0.51.246...v0.51.247)

##### Fixed

- A globally-configured reasoning effort (`agent.reasoning_effort`) is now **coerced to the closest level the active model/provider actually supports** before each request, instead of being sent verbatim and rejected. For example `openai-codex` `gpt-5` rejects `max` (now degraded to `xhigh`) and `o1`/`o3`/`o4` only accept `low`/`medium`/`high` (so `max`/`xhigh` degrade to `high`). Coercion only ever steps *down* to a supported level (never escalates), and `none`/unset are preserved. The model/provider effort-capability filter is applied consistently across the heuristic, models.dev metadata, GitHub Copilot, and LM Studio detection paths. ([#&#8203;3505](nesquena/hermes-webui#3505), [@&#8203;franksong2702](https://github.com/franksong2702))

### [`v0.51.246`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051246--2026-06-03--Release-HN-stage-q18--WebUI-rename-syncs-to-agent-statedb)

[Compare Source](nesquena/hermes-webui@v0.51.245...v0.51.246)

##### Fixed

- Renaming a session in the WebUI now writes the new title through to the agent's `state.db`, so the TUI and CLI no longer keep showing the old name. The `/api/session/rename` handler now calls `_sync_session_title_to_insights()` (gated on the `sync_to_insights` setting) — exactly like the sibling `/api/session/title/regenerate` handler already did. ([#&#8203;3225](nesquena/hermes-webui#3225), [@&#8203;rodboev](https://github.com/rodboev))

### [`v0.51.245`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051245--2026-06-03--Release-HM-stage-q17--messaging-source-badge-in-chat-topbar)

[Compare Source](nesquena/hermes-webui@v0.51.244...v0.51.245)

##### Fixed

- Messaging sessions (Telegram, Discord, WeChat, etc.) now show their platform source badge in the **chat-pane topbar**, not just the sidebar. The topbar badge was gated on `is_cli_session`, which is intentionally `false` for messaging sources, so the badge silently disappeared once you opened the session. The gate is removed; a recovered native session whose sidecar stamps `source_label: "WebUI"` is still left un-badged (it isn't a foreign source). ([#&#8203;3338](nesquena/hermes-webui#3338), [@&#8203;rodboev](https://github.com/rodboev))

### [`v0.51.244`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051244--2026-06-03--Release-HL-stage-q16--workspace-OS-import-drop--composer-drop-zone-polish)

[Compare Source](nesquena/hermes-webui@v0.51.243...v0.51.244)

##### Added

- **Drop OS files/folders onto a specific workspace folder row or breadcrumb segment** to upload into that directory (not only the current directory). OS folder drops are traversed via `webkitGetAsEntry`/`readEntries` and their nested structure is preserved on upload. Composer `@path` drags ([#&#8203;1097](nesquena/hermes-webui#1097)), the internal tree-move ([#&#8203;3402](nesquena/hermes-webui#3402)), and OS-drop isolation ([#&#8203;3411](nesquena/hermes-webui#3411)) are all preserved. ([#&#8203;3402](nesquena/hermes-webui#3402), [#&#8203;3424](nesquena/hermes-webui#3424), [@&#8203;pamnard](https://github.com/pamnard))

##### Fixed

- The composer drop-zone overlay no longer looks garbled when you drag a workspace file (or OS file) over the footer. Previously the translucent overlay let the textarea, attach/mic icons, and model/profile chips bleed through and collide with the hint text. The overlay is now a clean, fully-opaque box with a single centered, context-aware label — **"Drop to insert workspace reference"** when dragging a workspace file (which inserts an `@path` reference) vs **"Drop files to attach"** for an OS file (which attaches it to the message).

### [`v0.51.243`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051243--2026-06-03--Release-HK-stage-q15--drag-to-move-files-within-the-workspace)

[Compare Source](nesquena/hermes-webui@v0.51.242...v0.51.243)

##### Added

- You can now **drag a file or folder in the workspace tree onto another folder row (or a breadcrumb segment) to move it** within the workspace. A new `POST /api/file/move` performs the move server-side, confined to the workspace root (`safe_resolve` on both source and destination, rejects `..` destinations, and refuses to move a folder into itself or a descendant). Name collisions and no-op moves are handled, and the drop handlers use `stopPropagation` so the existing composer `@path` drag ([#&#8203;1097](nesquena/hermes-webui#1097)) and OS-file upload-on-drop ([#&#8203;3411](nesquena/hermes-webui#3411)) are unchanged. ([#&#8203;3402](nesquena/hermes-webui#3402), [#&#8203;3422](nesquena/hermes-webui#3422), [@&#8203;pamnard](https://github.com/pamnard))

### [`v0.51.242`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051242--2026-06-03--Release-HJ-stage-q14--Graphite-skin)

[Compare Source](nesquena/hermes-webui@v0.51.241...v0.51.242)

##### Added

- New **Graphite** appearance skin — a quiet, neutral-gray "workbench" alternative to the default gold/cream, selectable from Settings → Appearance (and `/theme skin graphite`). All visual changes are scoped to `data-skin="graphite"` so the default appearance is unchanged; the skin ships both light and dark palettes built on the existing CSS-variable token system (no new dependency or build step). Tightens typography, shadows, active-sidebar spacing, and code-block framing, and uses a neutral gray palette rather than an olive-tinted one. ([#&#8203;3440](nesquena/hermes-webui#3440), [@&#8203;t3chn0pr13st](https://github.com/t3chn0pr13st))

### [`v0.51.241`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051241--2026-06-03--Release-HI-stage-q13--New-Chat-returns-to-your-unsent-draft-after-visiting-history)

[Compare Source](nesquena/hermes-webui@v0.51.240...v0.51.241)

##### Fixed

- Starting a **New Chat** draft, peeking at a previous conversation, then clicking **New Chat** again no longer loses your unsent prompt. Zero-message New Chat sessions are intentionally hidden from the sidebar, so after you navigated away there was no way back to the empty session that held your draft — New Chat just created another fresh empty session and the draft was stranded. The New Chat entrypoint now remembers the candidate empty draft session (a single `localStorage` pointer) and, before creating a fresh session, re-validates it through `/api/session` and routes back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, and a non-empty server-side `composer_draft`). The composer draft is also flushed to the server before a session switch so typing and immediately navigating away can't drop it. Clearing the draft (e.g. after sending) clears the pointer, so an emptied draft never traps you on New Chat. ([#&#8203;3333](nesquena/hermes-webui#3333), [#&#8203;3471](nesquena/hermes-webui#3471), [@&#8203;starGazerK](https://github.com/starGazerK))

### [`v0.51.240`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051240--2026-06-03--Release-HH-stage-q12--mobile-swipe-up-stops-streaming-auto-scroll)

[Compare Source](nesquena/hermes-webui@v0.51.239...v0.51.240)

##### Fixed

- On mobile/touch devices you can now swipe up to stop the auto-scroll-during-streaming behavior. Previously the stream snapped back to the bottom on every token and there was no way to read earlier content while a response was arriving: `_recordNonMessageScrollIntent()` only detected upward intent on the wheel path (`typeof e.deltaY === 'number'`), but touch events carry no `deltaY`, so a finger swipe never unpinned the view. The handler now tracks the `touchstart` Y position and treats a `touchmove` that moves the finger up by >8px as upward-scroll intent — the same authoritative unpin (`_messageUserUnpinned`) the wheel path uses — so auto-follow stops until you scroll back to the bottom or tap the ↓ button. ([#&#8203;3470](nesquena/hermes-webui#3470), [@&#8203;cnogrin](https://github.com/cnogrin))

### [`v0.51.239`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051239--2026-06-03--Release-HG-stage-q10--ignore-SIGPIPE-so-a-dropped-client-cant-kill-the-server)

[Compare Source](nesquena/hermes-webui@v0.51.238...v0.51.239)

##### Fixed

- The server no longer dies silently when a client drops the connection mid-response. Python's default action for `SIGPIPE` is `Term`, so a single broken-pipe `socket.send()` in any `ThreadingHTTPServer` worker thread (browser tab closed mid-stream, network drop, mobile backgrounding, a dropped long-poll, an `/api/updates/check` timeout) could terminate the entire WebUI process — no exception, no log, no `/health` response. `server.py` now sets `SIGPIPE` to `SIG_IGN` at import time: the kernel surfaces the broken pipe as a catchable `BrokenPipeError`, the per-request handler unwinds, the connection closes, and the server keeps serving. The handler is `getattr`-guarded so it is a no-op on Windows, where `SIGPIPE` does not exist (preserves native-Windows support, [#&#8203;1952](nesquena/hermes-webui#1952)) (salvaged from [#&#8203;3407](nesquena/hermes-webui#3407), [@&#8203;PatrickNoFilter](https://github.com/PatrickNoFilter)).

### [`v0.51.238`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051238--2026-06-03--Release-HF-stage-q9--New-Conversation-hits-the-fast-path-on-cold-start)

[Compare Source](nesquena/hermes-webui@v0.51.237...v0.51.238)

##### Fixed

- Clicking **New Conversation** on a cold start no longer hangs for 3–4s on a catalog rebuild. `POST /api/session/new`'s fast path (`_resolve_compatible_session_model_state`) returns immediately only when the request carries both a `model` and a truthy `model_provider`; on a cold/unhydrated dropdown the client sent `model_provider=null`, so the request fell into `get_available_models()` and rebuilt the full catalog (the "first click slow, later clicks fast" asymmetry from [#&#8203;2518](nesquena/hermes-webui#2518)). `newSession()` (`static/sessions.js`) now falls back to `window._activeProvider` (then the previous session's `model_provider`) when the dropdown option carries no provider, so the first click takes the fast path too. **Two guards keep this safe:** (1) a slash-qualified (`gemini/…`) or `@provider:model` slug already carries a foreign provider namespace from a prior backend, so the fallback deliberately leaves `model_provider=null` for those; (2) even a *bare* model can carry a known family prefix (`gpt`→openai, `claude`→anthropic, `gemini`→google) — if that family maps to a different provider than the fallback we'd attach, `model_provider` is left null too. Both cases preserve the server slow-path's family-aware cross-provider repair rather than silently re-pointing the new session at the wrong backend ([#&#8203;2518](nesquena/hermes-webui#2518) follow-up, [@&#8203;franksong2702](https://github.com/franksong2702)).

### [`v0.51.237`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051237--2026-06-03--Release-HE-stage-q8--reconcile-early-cancel-against-live-worker-state)

[Compare Source](nesquena/hermes-webui@v0.51.236...v0.51.237)

##### Fixed

- Cancelling a live turn immediately after sending now reliably stops the worker and settles the session to a cancelled state, instead of leaving the UI showing a running spinner over a blank session page. The bug was an early-cancel race: the browser SSE could detach (removing the entry from `STREAMS`) before the worker was fully reflected there, so `cancel_stream()` returned early and never interrupted the agent. `cancel_stream()` now falls back to the live active-run registry (`ACTIVE_RUNS`) and the session agent cache when `STREAMS` has already detached, so the worker still receives `interrupt("Cancelled by user")` and the session is cleaned up. Relatedly, `/api/session` now reports run-journal active state from the live active-run registry rather than treating any persisted `active_stream_id` as proof the worker is still alive ([#&#8203;3475](nesquena/hermes-webui#3475), [@&#8203;franksong2702](https://github.com/franksong2702)).

### [`v0.51.236`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051236--2026-06-03--Release-HD-stage-q7--native-Windows-support-for-bootstrap-and-terminal)

[Compare Source](nesquena/hermes-webui@v0.51.235...v0.51.236)

##### Added

- Native Windows support for `bootstrap.py` and the embedded terminal ([#&#8203;1952](nesquena/hermes-webui#1952)). Hermes WebUI already ran on Windows when invoked as `python server.py` directly; this unblocks the supported `python bootstrap.py` path. `api/terminal.py` no longer hard-imports the POSIX-only `fcntl`/`termios`/`select` at module load — they're guarded behind `_TERMINAL_SUPPORTED = sys.platform != "win32"`, and the embedded-terminal entry points raise `NotImplementedError` (or no-op) on Windows, following the existing optional-feature guard pattern (`api/turn_journal.py`, `api/providers.py`). The bootstrap native-Windows block becomes a warning instead of a hard `RuntimeError`; auto-install (which shells out to `/bin/bash`) still errors clearly on native Windows (WSL is unaffected), and the foreground launch path uses `subprocess.Popen` + exit on Windows (where `os.execv` spawns rather than replaces the process, orphaning it from a supervisor) instead of `os.execv`. POSIX behavior is unchanged on every path ([#&#8203;1952](nesquena/hermes-webui#1952), [@&#8203;rodboev](https://github.com/rodboev)).

### [`v0.51.235`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051235--2026-06-03--Release-HC-stage-q5--no-duplicate-transcript-replay-on-repeated-questions-after-compression)

[Compare Source](nesquena/hermes-webui@v0.51.234...v0.51.235)

##### Fixed

- The chat transcript no longer accumulates duplicate messages after multiple context-compression cycles when the user asks similar (or identical) questions across turns. `_find_current_user_turn` (`api/streaming.py`) located the slice point for the current turn's new messages by scanning `result_messages` for the user text — but after compression `result_messages` carries the full conversation history, so a *first*-match scan returned an **older** turn's index, making the merge re-append the entire replayed history from that point (observed: a 137-message session where 89 were duplicate replays, burying the real new messages). It now returns the **last** matching user turn, so the candidate slice begins at the current turn and the replayed history is not re-appended. To stay correct when the agent loop appends synthetic `role:"user"` continuation prompts (e.g. "Continue" / empty-recovery nudges) after the real turn, an exact (strong) match is preferred over a later substring (weak) match — so a synthetic continuation can't anchor the merge past the real turn and drop the assistant/tool output in between. Behavior on the no-match path (fall back to the last user index) is unchanged ([#&#8203;3468](nesquena/hermes-webui#3468), [@&#8203;jasonjcwu](https://github.com/jasonjcwu)). A regression test pins the unit behavior, the strong-beats-later-weak invariant, and the end-to-end no-duplicate-replay invariant (each verified to fail against the pre-fix logic).

### [`v0.51.234`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051234--2026-06-03--Release-HB-stage-q4--duplicate-instance-startup-guard--remote-terminal-workspace-paths)

[Compare Source](nesquena/hermes-webui@v0.51.233...v0.51.234)

##### Fixed

- The server now refuses to start when a live instance is already responding on the configured port, instead of silently sharing it (a Windows/macOS hazard where `SO_REUSEADDR` semantics let two processes bind 8787 at once, [#&#8203;3289](nesquena/hermes-webui#3289)). Rather than globally disabling `SO_REUSEADDR` (which would brick legitimate fast restarts — `ctl.sh restart` and the `os.execv` self-update path rebind immediately and would hit the TIME\_WAIT window), startup now runs a live-listener probe (`_abort_if_already_serving`): a TCP connect + `GET /health` with a 2s timeout. A live instance answers and startup aborts with a clear message; a dying instance whose socket still lingers in the kernel backlog accepts the connection but never responds, so the probe times out and startup proceeds — preserving fast restart. On Windows, `SO_EXCLUSIVEADDRUSE` is set in a `server_bind()` override to get true exclusive binding (POSIX keeps the inherited `allow_reuse_address = True`) ([#&#8203;3289](nesquena/hermes-webui#3289), [@&#8203;rodboev](https://github.com/rodboev)).
- Remote/SSH terminal profiles can now use target-side workspace paths that don't exist on the WebUI host. Workspace validation/resolution previously `stat()`-ed every path against the WebUI server's local filesystem, so a `terminal.cwd` (or session workspace) living on the remote target was rejected as nonexistent. For profiles whose terminal backend is non-local, paths **under the configured `terminal.cwd`** now pass validation without a server-local existence check, and stale server-local `last_workspace` values are ignored unless they fall under the remote cwd. Local profiles are unchanged — the bypass only fires for remote backends and only for paths contained within `terminal.cwd` ([#&#8203;3486](nesquena/hermes-webui#3486), [@&#8203;dso2ng](https://github.com/dso2ng)).

### [`v0.51.233`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051233--2026-06-03--Release-HA-stage-q3--session-truncate-keepcount-guard-against-silent-transcript-loss)

[Compare Source](nesquena/hermes-webui@v0.51.232...v0.51.233)

##### Fixed

- `POST /api/session/truncate` no longer silently wipes a session transcript on a negative `keep_count`, and no longer returns an HTTP 500 on a non-numeric one. `keep_count` fed a bare `int()` straight into the destructive `s.messages = s.messages[:keep]` slice followed by `s.save()`, so a negative value sliced as `messages[:-N]` — **deleting the most recent N messages and persisting the result to disk** (e.g. `keep_count=-5` on a 3-message session wiped the entire transcript and returned HTTP 200). `keep_count` is now validated before the slice — non-integer → `400 "keep_count must be an integer"`, negative → `400 "keep_count must be non-negative"` — mirroring the guard the sibling `/api/session/branch` handler already applies (`keep_count=0` keeps its existing "clear all messages" meaning) ([#&#8203;3472](nesquena/hermes-webui#3472), [@&#8203;Mubashirrrr](https://github.com/Mubashirrrr)).

### [`v0.51.232`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051232--2026-06-03--Release-GZ-stage-q2--cron-endpoint-query-param-guards--Japanese-locale-translations)

[Compare Source](nesquena/hermes-webui@v0.51.231...v0.51.232)

##### Fixed

- The cron output (`/api/crons/output`) and cron recent (`/api/crons/recent`) endpoints no longer return a confusing HTTP 500 on a malformed numeric query param. A non-numeric `limit` (e.g. `?limit=abc`) or `since` previously let `int()`/`float()` raise `ValueError` up to the top-level handler; both are now parsed defensively (falling back to their defaults). The cron-output `limit` is also clamped to `[1, 500]` so a negative value can't reach the newest-first `files[:limit]` slice as `files[:-n]` (which would drop the oldest entries — or return an empty list when the magnitude exceeds the count — instead of the newest outputs), mirroring the guard `_handle_cron_run_detail` already uses ([#&#8203;3473](nesquena/hermes-webui#3473), [@&#8203;Mubashirrrr](https://github.com/Mubashirrrr)).

##### Changed

- Japanese (`ja`) locale: translated 80 previously-untranslated UI strings (MCP server controls, tool summaries, and related toasts) from their English fallbacks to Japanese, with all `${…}` interpolation placeholders preserved. No locale keys added or removed ([#&#8203;3480](nesquena/hermes-webui#3480), [@&#8203;koshikai](https://github.com/koshikai)).

### [`v0.51.231`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051231--2026-06-03--Release-GY-stage-q1--model-extras-tail-resolution--plugins-tab-auto-hide--search-depth-guard--symlink-home-suggestions)

[Compare Source](nesquena/hermes-webui@v0.51.230...v0.51.231)

##### Fixed

- `/model <name>` can now select a model that lives in the **truncated `extra_models` tail** of a large provider catalog, completing the [#&#8203;3368](nesquena/hermes-webui#3368) fix that v0.51.229 left half-done. On Nous-style catalogs with >25 models the picker renders only a featured subset as `<option>` entries and pushes the rest into `extra_models`; the `/model` resolver previously matched only against the rendered `sel.options`, so a bare model living only in the extras tail (e.g. `xiaomi/mimo-v2.5` alongside the featured `xiaomi/mimo-v2.5-pro`) was un-selectable and produced a misleading "did you mean -pro?" toast. A new `_buildModelCandidates()` (`static/commands.js`) now builds the candidate set from the full `/api/models` catalog (featured `models` + `extra_models`) — the same complete list the CLI and `/model` autocomplete use — and an extras-only winner is injected via `_ensureModelOptionInDropdown()` before selection so the correct `model` + `model_provider` persist end-to-end. The [#&#8203;3437](nesquena/hermes-webui#3437) tier-guard is fully preserved: a genuinely off-catalog versioned name still refuses to snap to a `-pro`/`-flash` tier and shows the suggestion toast ([#&#8203;3368](nesquena/hermes-webui#3368), [@&#8203;nesquena-hermes](https://github.com/nesquena-hermes); with [@&#8203;garyd9](https://github.com/garyd9), confirmation [@&#8203;yutaotie](https://github.com/yutaotie)).
- The **Plugins** tab in Settings is now auto-hidden when no plugins are installed (`/api/plugins` returns `empty: true`), and deep-linking to the hidden plugins pane falls back to the Conversation section. The tab reappears automatically when plugins are detected ([#&#8203;3457](nesquena/hermes-webui#3457), [@&#8203;pix0127](https://github.com/pix0127)).
- `GET /api/sessions/search?...&depth=<x>` no longer returns a confusing HTTP 500 on a non-numeric `depth` (e.g. `?depth=deep`) and no longer silently excludes the newest messages on a negative `depth` (which sliced as `messages[:-n]`). `depth` is now parsed defensively and clamped to `>= 0` (0 keeps its existing "search the full transcript" meaning), mirroring the guard sibling handlers already use ([#&#8203;3474](nesquena/hermes-webui#3474), [@&#8203;Mubashirrrr](https://github.com/Mubashirrrr)).
- Workspace path autocomplete now expands `~/` suggestions even when the WebUI process home path is a symlink or alias of the trusted home root, so prefixes like `~/Doc` still list home-directory matches instead of returning an empty dropdown. The typed `~` target is now resolved before the trust comparison ([#&#8203;3433](nesquena/hermes-webui#3433), [@&#8203;sjh9714](https://github.com/sjh9714)).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEwMS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/813
@nesquena-hermes nesquena-hermes added the size:L Large PR (>10 files or >250 LOC) label Jun 19, 2026
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
## Release v0.51.239 — Release HG (stage-q10)

Phase 3 MEDIUM-ring **salvage** from nesquena#3407. The source PR bundled a universal reliability fix with debug scaffolding + Android-specific work; this release ships only the clean, universal nugget.

### Fixed
| Salvaged from | Author | Fix |
|---|---|---|
| nesquena#3407 | @PatrickNoFilter | `server.py` ignores `SIGPIPE` (`SIG_IGN`) at import time so a client dropping the connection mid-response (tab close mid-stream, network drop, mobile backgrounding, dropped long-poll, `/api/updates/check` timeout) can't silently `Term` the whole process. The broken write now surfaces as a catchable `BrokenPipeError`; the server keeps serving. |

### Why salvage, not merge whole
nesquena#3407 (585L, 11 commits) bundles three groups: (1) the SIGPIPE fix + a 271-line `diag_shim.py` debug module, (2) an Android-cgroup-specific `os.fork`/`setsid` restart rewrite in `updates.py`, (3) personal deploy scripts (`start-webui.sh`/`watchdog-loop.sh`, which the author notes are "user-side infra, not in the server tree"). Only the SIGPIPE fix is universal, low-risk, and ship-ready — the rest is investigation tooling for a now-solved mystery or platform-specific. The source PR is held with a detailed split explanation.

### Added safety over the source PR
The original used a bare `signal.signal(signal.SIGPIPE, ...)` which would `AttributeError` on Windows (no `SIGPIPE`). The salvaged version is `getattr`-guarded so it's a no-op on Windows, preserving the native-Windows support shipped in nesquena#1952 (HD).

### Gate results
- **Full pytest suite**: 7498 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff**: CLEAN · **browser-smoke**: CLEAN
- **Codex (regression)**: SAFE TO SHIP — verified the getattr Windows-guard, that the ignore disposition lands correctly across the `os.execv` self-restart, and that subprocess children use `restore_signals=True` so the ignore doesn't leak to git/shell/editor children.

Regression test `tests/test_issue3407_sigpipe_ignore.py` pins SIG_IGN on POSIX, no-raise import, and the getattr guard.

Co-authored-by: PatrickNoFilter <PatrickNoFilter@users.noreply.github.com>
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Closing this out — the universally-valuable part already shipped, and the rest is better as separate, narrowly-scoped follow-ups.

SIGPIPE ignore shipped in v0.51.239 (salvaged as #3494), with a getattr(signal, "SIGPIPE", None) guard so it no-ops on Windows, and your authorship preserved via Co-authored-by + a CHANGELOG credit. Genuinely excellent root-cause work on the "silent death" — thank you @PatrickNoFilter. 🙏

The other three groups we're not taking into core as-is, for the reasons discussed:

  • diag_shim.py — it was investigation tooling for a mystery that's now diagnosed; shipping an always-on shim (JSON markers to /tmp on every startup + serve_forever wrapping) to every instance is overhead we don't want for a solved problem. A tiny, env-gated, off-by-default version could be its own PR if there's an ongoing need.
  • updates.py fork/setsid restart + 15s delay — this changes the self-update restart path for every platform to fix an Android/Termux-cgroup-specific kill window; the 15s delay would regress update speed for the ~99% not on Android. Worth pursuing only gated behind explicit Android/Termux detection, in its own focused PR.
  • start-webui.sh / watchdog-loop.sh — as you noted, these are user-side deploy infra, not app code.

Closing as "best part landed + rest needs re-scoping," not a rejection. If you come back to the Android restart fix (Android-gated) or an opt-in diag mode, open them as separate PRs and we'll review each. Thanks again for the careful production debugging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address hold size:L Large PR (>10 files or >250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants