Skip to content

fix(embed): harden daemon and UI lifecycle (#3099, #3100, #3517, #3520, #3527) - #3585

Merged
nicoloboschi merged 3 commits into
mainfrom
fix/embed-daemon-lifecycle
Aug 18, 2026
Merged

fix(embed): harden daemon and UI lifecycle (#3099, #3100, #3517, #3520, #3527)#3585
nicoloboschi merged 3 commits into
mainfrom
fix/embed-daemon-lifecycle

Conversation

@nicoloboschi

Copy link
Copy Markdown
Collaborator

Five open hindsight-embed issues sit in the same two files and share one root
theme: the manager decides who to talk to, and who to kill, from evidence that
isn't good enough. Three of them compound each other, which is why they are
worth doing in one pass — #3517 means no PID is found, #3099 makes a healthy
daemon look dead, and #3520 means that false negative can take out an unrelated
process.

Fixes

#3520_clear_port killed any PID holding the port

_clear_port/stop/stop_ui picked their victim purely by "who holds the
port" and SIGTERMed it. On the reporter's host that terminated a separate,
legitimately-owned hindsight-api service; the embed side logged
"Stale process stopped" and carried on, so the operator had no indication of
what killed it.

A listener is now only signalled once its command line identifies it as our
daemon (or our control plane). Otherwise we log which PIDs hold the port and
refuse, and startup fails with "port in use" instead. A failed start is
recoverable; killing someone else's service is not.

#3517_find_pid_on_port required lsof

On Linux hosts without lsof (minimal containers, Arch-based distros) the
FileNotFoundError was swallowed with no fallback, so every daemon stop logged
Could not find PID for port and stopped nothing. PID discovery now falls back
to ss (iproute2). It also returns every listener rather than an arbitrary
first PID — which is what lets the ownership check above pick the right one.

#3527is_ui_running() hardcoded 127.0.0.1

Next.js started with --hostname localhost binds ::1 only, so the IPv4 probe
got ECONNREFUSED: ui start always timed out after 30s on a UI that was up and
serving, ui status reported it as down, and ui stop could orphan the
process. Both loopback families are now probed — in is_ui_running,
_is_port_in_use, and the Windows netstat parse, which matched an IPv4 literal
and so missed 0.0.0.0/[::1] listeners too. User-facing URLs say localhost
so they resolve whichever way the server bound.

#3099 — 2s /health timeout classified a busy daemon as dead

/health is served from the same event loop as the daemon's LLM calls, so a
slow provider stalls it. The probe budget is now 10s by default (aligned with
the worker-side liveness threshold) and configurable via
HINDSIGHT_EMBED_HEALTH_PROBE_TIMEOUT.

Note the issue's mechanism does not hold up as written: _clear_port has had a
30s grace window since #1858, so a 17s stall never reaches the kill, and #2906
— cited there as the merged worker-side fix — was closed unmerged. The related
stop() bug from the same thread was already fixed as #3169. So this PR fixes
the part that is real (the probe misreporting a busy daemon) and leaves the 30s
reclaim window alone rather than adding the separate liveness server.

#3100 — Windows lock had no timeout

msvcrt.locking(..., LK_LOCK, 1) retries exactly 10 times internally and then
raises, so a concurrent start of the same profile failed non-deterministically
with an opaque OSError. Both platforms now drive the non-blocking primitive
from one bounded retry loop with exponential backoff; on timeout the error names
the lock file and the PID holding it, and _start_daemon turns that into a
normal startup failure. Budget is 300s (the start path holds the lock for the
whole startup sequence), overridable via HINDSIGHT_EMBED_LOCK_TIMEOUT.

Not included

#3253 (an empty llm_api_key clobbering an inherited env var) already has a
fix in the open PR #3359 — left alone rather than duplicated.

Testing

hindsight-embed suite: 180 passed. New regression coverage for each issue,
including a family of tests asserting that a foreign listener survives both
_clear_port and stop. Beyond the mocked tests, PID discovery, the ss
fallback, the ownership refusal and IPv6-only UI detection were each verified
against real listeners.

Tradeoffs worth a look

Closes #3099
Closes #3100
Closes #3517
Closes #3520
Closes #3527

#3527)

Five open hindsight-embed issues all sit in the same two files and share one
root theme: the manager decides who to talk to, and who to kill, from evidence
that isn't good enough.

#3520 — `_clear_port`/`stop`/`stop_ui` picked their victim purely by "who holds
the port" and SIGTERMed it. On a host where an unrelated service shared the
port, that service died with no indication of what killed it. A listener is now
only signalled once its command line identifies it as our daemon (or our control
plane); otherwise we log and refuse, and startup fails with "port in use"
instead. A failed start is recoverable; killing someone else's service is not.

#3517 — `_find_pid_on_port` shelled out to `lsof` only, so on Linux hosts
without it (minimal containers, Arch-based distros) every daemon stop logged
"Could not find PID for port" and stopped nothing. PID discovery now falls back
to `ss` (iproute2). It also returns every listener rather than an arbitrary
first PID, which is what lets the ownership check above pick the right one.

#3527 — `is_ui_running` health-checked 127.0.0.1 regardless of the bind
hostname. Next.js started with `--hostname localhost` binds ::1 only, so
`ui start` always timed out after 30s on a UI that was up and serving, and
`ui status` reported it as down. Both loopback families are now probed, in
`_is_port_in_use` and the Windows netstat parse as well, and user-facing URLs
say `localhost` so they resolve whichever way the server bound.

#3099 — the 2s /health client timeout classified a busy daemon as dead. /health
is served from the same event loop as the daemon's LLM calls, so a slow
provider stalls it. The probe budget is now 10s by default (aligned with the
worker-side liveness threshold) and configurable via
HINDSIGHT_EMBED_HEALTH_PROBE_TIMEOUT. The 30s reclaim grace window is unchanged.

#3100 — the Windows lock used `msvcrt.LK_LOCK`, which retries exactly 10 times
internally and then raises, so a concurrent start of the same profile failed
non-deterministically with an opaque OSError. Both platforms now drive the
non-blocking primitive from one bounded retry loop with backoff; on timeout the
error names the lock file and the PID holding it, and `_start_daemon` turns that
into a normal startup failure.

Not included: #3253 (empty llm_api_key clobbering an inherited env var) already
has a fix in the open PR #3359.
Two defects from the previous commit, found in review.

The UI health probe inherited HEALTH_PROBE_TIMEOUT (10s). That budget exists
for the daemon, whose /health sits behind the event loop its LLM calls run on
(#3099); the control plane's /api/health has nothing blocking behind it. Since
start_ui polls the probe inside a 30s budget and now probes two loopback
families, a listener that binds but does not answer would burn the whole budget
in two probes and report a false "UI failed to start (timeout)" — the exact
symptom #3527 is about. The UI keeps its own 2s probe.

delete_profile removed <name>.lock but not the <name>.lock.owner sidecar the
new locking writes, so a crash while holding the lock orphaned a file that
outlived the profile.

Also adds direct coverage for _process_command_line, which decides every kill
but was only reached through tests that patch it out, and documents that
_wait_for_port_health bounds when the last probe starts rather than when it
returns.
test-embed-windows failed on test_delete_profile_over_http with a client-side
ReadTimeout. The control center's delete handler asks is_running once and the
UI probe once per loopback family; at the 10s budget those three serial probes
could reach 14s against httpx's 5s default client timeout. The same path was
~4s before, so a slow connect that Windows already had was being masked.

The budgets are now split by what a wrong answer costs. HEALTH_PROBE_TIMEOUT
(10s, configurable) applies only to _port_health_ok — the probe whose false
negative gets the listener killed, which is what #3099 is actually about.
is_running and the UI probe use LIVENESS_PROBE_TIMEOUT (2s, the pre-existing
value): they only answer "is it up?", and a false negative there costs a re-run
of ensure_running, which consults the long probe before doing anything
destructive. #3099's real harm — a busy daemon being reclaimed as stale — stays
fixed.

Connect is capped separately at 1s. An address that swallows the SYN hangs in
connect rather than read, so this is what actually bounds the handler: three
probes at 1s is 3s, below both the 5s client default and the 4s the two
uncapped probes could reach before this branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment