Skip to content

fix(engine-client): a call that is never answered is a failure, not a wait - #678

Merged
Wirasm merged 5 commits into
mainfrom
fix/engine-fetch-timeout
Jul 28, 2026
Merged

fix(engine-client): a call that is never answered is a failure, not a wait#678
Wirasm merged 5 commits into
mainfrom
fix/engine-fetch-timeout

Conversation

@Wirasm

@Wirasm Wirasm commented Jul 28, 2026

Copy link
Copy Markdown
Owner

fetch has no timeout of its own, so a connection the engine accepts and never answers hangs forever. Only drainInbox was bounded; every other call — including the kildMessages that kild watch polls with — waited indefinitely.

Why this is urgent rather than tidy

It defeats a guarantee that shipped two hours ago. kild watch exists to distinguish a quiet engine (exit 2) from a dead one (exit 3). A hung call sits inside a single poll past the entire window and reports neither — so the verb looks patient at precisely the moment it has stopped working. That is the failure it was built to remove, reintroduced underneath it.

Found by the kild watch review, correctly left out of that PR, and helm's agent independently argued it should jump the queue for the same reason: it is the one open item that undermines something already shipped.

The fix

engineFetch applies a 30s backstop when a caller supplies no signal. Deliberately generous — it exists to stop an unbounded wait, not to budget latency, so it must clear the slowest legitimate call (a land's merge, a new kild's worktree) without argument.

A caller's own signal wins outright rather than being combined. A narrower deadline was chosen for a reason, and silently capping it at the default would make the tighter bound a lie.

kild watch now bounds each poll to its own cadence, floored at 1s so a deliberately tiny --interval cannot turn a healthy engine into a failing one. A hang becomes an ordinary tolerated failure, and three of those an honest unreachable.

The message says what happened

An abort surfaces as "the operation was aborted", which tells a caller nothing about whether to retry — and a hook that swallowed it would be swallowing the one symptom separating a wedged engine from a quiet one. It now reads:

/api/kilds/<id>/messages?since=1 timed out — the engine accepted the connection and did not answer

A test of mine was wrong

I wrote one asserting the timeout wording via a 5s hang. A 5s hang never trips a 30s default, so it proved nothing and passed for the wrong reason. Moved rather than weakened — the wording is now asserted where the path is genuinely reachable: the drain's own 1.5s bound (loud when run as a verb, silent inside the hook) and watch's per-poll bound.

Gates

Gate Result
bun test 442 pass, 0 fail
bun run typecheck pass
bun run lint pass
./scripts/e2e.sh 70 checks, 0 fail

Dogfooded against a real server that accepts the connection and never answers: exit 3 in 4s, where before it would have waited out the full 300s window and reported nothing.

Wirasm added 5 commits July 28, 2026 15:30
… wait

`fetch` has no timeout, so a connection the engine ACCEPTS and never answers
hangs forever. Only the drain was bounded; every other call — including the
one `kild watch` polls with — waited indefinitely.

That defeats a guarantee already shipped. `kild watch` distinguishes a quiet
engine (exit 2) from a dead one (exit 3), and a hung call sits inside a single
poll past the whole window, reporting neither. The verb looks patient at
exactly the moment it has stopped working, which is the failure it was built
to remove.

`engineFetch` now applies a 30s backstop when a caller supplies no signal of
its own. Deliberately generous: it exists to stop an unbounded wait, not to
budget latency, so it has to clear the slowest legitimate call — a land's
merge, a new kild's worktree — without argument. A caller's own signal wins
outright rather than being combined, because a narrower deadline was chosen
for a reason and capping it at the default would make the tighter bound a lie.

`kild watch` bounds each poll to its own cadence, floored at 1s so a
deliberately tiny --interval cannot turn a healthy engine into a failing one.
A hang is then an ordinary tolerated failure, and three of them an honest
`unreachable`.

The timeout is reported as what it is. An abort surfaces as "the operation was
aborted", which tells a caller nothing about whether to retry — and a hook
that swallowed it would be swallowing the one symptom separating a wedged
engine from a quiet one. It now names the path and says the engine accepted
the connection and did not answer.

One test I wrote was wrong and I moved it rather than weakening it: a 5s hang
never trips a 30s default, so it proved nothing. The wording is now asserted
where the path is actually reachable — the drain's own 1.5s bound, and watch's
per-poll bound — instead of behind a 30s test.

Gates: 442 tests, typecheck, lint, e2e 70/70. Dogfooded against a real server
that accepts and never answers: exit 3 in 4s, where before it would have
waited out the full 300s window and reported nothing.
…t unheard

Review found this PR made a confidently-wrong exit reachable, which is worse
than the hang it fixed. Six findings; all addressed.

THE CRITICAL ONE, and it is the same defect for the third time on this feature:
two decisions answering one question. The per-poll bound used the interval; the
loop's deadline used the deadline. With `--interval 10 --timeout 3` against a
hung engine the first poll blocked ten seconds, overran the window threefold,
and then reported QUIET — asserting the engine had answered and had nothing,
after every request had failed. `watchRequestTimeout` now takes what remains of
the window as a required argument, so the two answers cannot drift because
there is only one.

The other half: "quiet" is a claim ABOUT the engine — that it responded and had
nothing new. A watcher that never heard from it cannot make that claim. A window
closing with no successful poll now exits `unreachable`, not `quiet`. That is a
behaviour change and one existing test asserted the old answer; its expectation
was wrong and is corrected rather than relaxed — what it exists to check is the
elapsed time, which is unchanged.

A CLIENT ABORT DOES NOT REACH THE ENGINE. Nothing passes a signal server-side
and no git command it runs is cancellable, so a timed-out `land` may still merge
and a timed-out `rm` may still remove the tree. Reporting that as a plain
failure is the worst available answer: the operator retries a merge that already
happened. Both now report the outcome as UNKNOWN and say to check before
retrying — the same rule the disposal path already follows for its discard list.

`kild show` swallowed every message failure into an empty log. Harmless while a
hung engine hung visibly; a confident lie the moment a timeout made the call
return. It reports the failure now.

The timeout is decided by WHOSE signal fired, not by matching an error name.
Name-matching would relabel the first future caller that cancels for any other
reason — a Ctrl-C — as "the engine did not answer", the opposite of true. The
original error rides along as `cause`.

The backstop is overridable via KILD_ENGINE_TIMEOUT_MS. 30s is a guess about
somebody else's disk, and an operator whose merge legitimately exceeds it needs
a way out that is not editing the source. It also closes the coverage gap the
review proved: removing the default left the suite green, because both hang
tests supply their own signals and bypass it. A test now goes through `kild log`,
which supplies none.

Also corrected a claim of mine in the comment: `kild new` is NOT bounded by this
call. `spawn` returns without awaiting and the worktree is created in the child.
`land` and `rm` are the calls this budget actually has to clear.

Gates: 445 tests, typecheck, lint, e2e 70/70. Dogfooded the exact reported
regression against a server that accepts and never answers: 3040ms and exit 3,
where it was 10036ms and exit 2.
Two defects I introduced in the previous round and found by probing my own
change rather than waiting to be told.

`mayHaveHappened` decided "this may have completed" by searching the error text
for "timed out". That is reading intent out of prose, which this codebase
forbids — and it is wrong for the exact reason the rule exists: an engine-side
failure relayed verbatim can contain those words. A `git merge` that failed
with "operation timed out talking to the object store" would have been reported
as a merge that might have gone through, sending an operator to look for work
that never happened. The timeout is now a type, `EngineTimeout`, and the
decision is `instanceof`.

`KILD_ENGINE_TIMEOUT_MS` was `Number(raw ?? 30_000)` with no validation. An
empty value or "0" parses to zero, which aborts every request the instant it is
made — indistinguishable from an engine refusing to talk, and caused by a
variable the operator set to help. Non-numeric values reached `AbortSignal` and
surfaced as a RangeError naming neither the variable nor the fix. It is parsed
once now and an unusable value is refused by name.

Both were in the fix for a review finding, which is the third time on this
branch that a repair has needed a repair. The difference is that these two were
found by asking what my own change could now do wrong — the string match and
the unvalidated env read were both visible the moment I looked at them as
inputs rather than as implementation.

Gates: 451 tests, typecheck, lint, e2e 70/70.
…es get tests

Second review: NO-MERGE, one critical and three important. All addressed.

CRITICAL, and the same one-decision-two-places class again, in a new spot.
Removing `kild show`'s blanket catch made it CRASH on an orphan kild. `GET /:id`
answers for an orphan tree — resolveKild falls back to an orphan identity —
while `GET /:id/messages` 404s, because the registry's log map holds live and
archived kilds only. Two routes, two answers to "does this exist". The swallow
had been hiding that disagreement; removing it turned it into an exit 1 with no
output at all, against a case `kild show` has an explicit display branch for,
and directly contradicting the comment I had just written two lines above it.

Now a 404 is tolerated and nothing else is. It branches on a typed
`EngineHttpError` carrying the status rather than on the message text — the same
reason the timeout became a type last commit: reading meaning out of an error
string is reading intent out of prose.

The abort branch now reads the SIGNAL'S REASON instead of asking whose signal it
was. `AbortSignal.timeout` sets a TimeoutError reason and a deliberate
cancellation does not, so both deadlines are timeouts and each says which one it
was. My previous version tested only "was it ours", which quietly regressed the
drain — a caller-supplied deadline — to a bare "The operation timed out."
naming neither the path nor the cause.

The env validation is read per call, not at module load. Evaluated at import it
threw before dispatch()'s handler existed, so a fat-fingered override killed
EVERY command, including ones that make no engine call, with a raw Bun stack
trace instead of this CLI's `error: <message>`.

The review proved by reverting that the two highest-severity fixes had NO
coverage — remove `mayHaveHappened` from land and rm, or revert the typed
timeout, and all 451 tests still passed. Both are pinned now, along with the
orphan case, and I verified the same way rather than assuming: reverting each
fix in a scratch copy fails 1, 1 and 3 tests respectively.

Gates: 455 tests, typecheck, lint, e2e 70/70. `kild show` verified against a
real orphan on the running engine.
The verification pass cleared this branch and found one gap by the same method
that caught the last two criticals: reverting the per-call env read failed zero
tests. Both versions exit 1 with KILD_ENGINE_TIMEOUT_MS in the text — the
reverted one as a raw Bun stack trace naming a source line, the fixed one as the
CLI's own formatted error — and asserting only the substring could not tell them
apart.

That is the fix having no coverage at all, which is exactly the shape that hid
the previous two rounds. Asserting the absence of the stack trace closes it;
reverting the fix now fails the suite.

Gates: 455 tests, typecheck, lint, e2e 70/70.
@Wirasm
Wirasm merged commit 55caf59 into main Jul 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant