You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[IBKR] Dead Gateway connection reports health=healthy; orders accepted but never transmitted until manual reconnect (no keepalive) #294
IBKR connection health is derived purely from query failure-counting (_consecutiveFailures), with no active keepalive/heartbeat. When the socket to IB Gateway dies silently (Gateway daily restart, network blip, half-open TCP), account.health stays "healthy" (false positive). Orders placed in this window are accepted at the UTA/wallet layer (get an orderId, status submitted) but never reach IBKR — they only transmit after a manual reconnect, at which point queued orders fill immediately.
Impact
health: healthy while the connection is actually dead → callers place orders that silently don't execute.
Account/position queries return stale cached data during the dead-but-"healthy" window.
No auto-recovery; requires a manual reconnect.
Root cause (v0.40.0-beta.2)
No active heartbeat — requestCurrentTime() (services/uta/src/domain/trading/brokers/ibkr/request-bridge.ts:216) is on-demand only (e.g. getMarketClock), never on a timer. Nothing probes the idle socket.
connectionClosed() only rejects in-flight requests (request-bridge.ts:377 → rejectAll); it does not mark the account offline or trigger a reconnect.
Connectivity errors 502/504/1100 are effectively a no-op (request-bridge.ts:394-398 — comment "These will be followed by connectionClosed() which rejects all", then returns).
Health = passive failure count — UnifiedTradingAccount derives health from _consecutiveFailures (incremented only on a failed query, reset on success). A socket dying while idle never trips it → stays healthy. Order submits on the dead socket don't reliably trip it either.
Net: a silently-dropped connection is invisible to the health model until some query happens to fail — and order submits don't reliably trip it.
Repro
Connect an IBKR paper account via IB Gateway.
Let the OpenAlice↔Gateway socket drop silently while idle (e.g. Gateway daily auto-restart, or a brief network drop).
GET /api/trading/uta → account still health: healthy.
POST .../wallet/place-order → returns orderId, status submitted; it does not fill.
POST /uta/:id/reconnect → the order fills immediately.
Observed repeatedly on 0.40.0-beta.2 with an IB Gateway paper account: orders sat submitted (no fill) until we manually reconnected, then filled instantly.
Suggested fix
Add an active heartbeat (periodic reqCurrentTime with timeout) that marks the account offline on failure.
In connectionClosed() / on error 1100, mark the account offline and trigger (or schedule) a reconnect — not only reject in-flight requests.
Treat error 1102 (connectivity restored) as a recovery signal.
Implemented in #335 (merged) — needs verification on a real Gateway setup, and yours is the canonical repro environment.
Root cause confirmed, plus one mechanism beyond your analysis: the IBKR account surface is cache-backed, so routine health queries never touch the socket at all — a dead-but-idle connection is invisible to failure counting even in principle. That's why health: healthy survived a dead socket indefinitely.
What landed:
Active heartbeat: reqCurrentTime every 45s with a 5s timeout; a failed beat marks the connection dead.
connectionClosed() marks dead immediately (previously it only rejected in-flight requests).
While dead, everything refuses loudly: cache reads (getAccount/getPositions) AND all order paths (place/modify/cancel) return "TWS/Gateway connection lost — reconnect pending… orders will NOT transmit" instead of accepting orders into the void.
init() force-tears-down a half-open socket — isConnected() lies on half-open TCP, which previously made the recovery loop's re-init a silent no-op.
Not yet done from your list: treating error 1102 as an explicit recovery trigger (recovery currently rides the existing re-init loop, which the dead flag now actually un-blocks). Specs cover the gate wiring; what we could NOT reproduce locally is the silent half-open drop itself (we run TWS, not Gateway with daily restarts). If you can run your repro — Gateway daily auto-restart while idle — against master and confirm (1) health leaves healthy, (2) order placement refuses during the window, (3) it recovers without manual reconnect, we'll close this.
Verified on master (#335), IB Gateway 10.47 paper — reproduced the silent half-open deterministically (SIGSTOP the Gateway → socket stays ESTABLISHED, peer mute, same shape as the idle daily-restart drop → SIGCONT).
Confirmed working:
The heartbeat catches the half-open: connectionDead flips ~50s in, getAccount() then refuses with the new 503 instead of serving stale cache forever.
The gate is firm — it rides connectionDead, which is cleared only by a real reconnect (markAlive in init()), so cached reads can't un-dead it. (Health status still flaps healthy↔offline because consecutiveFailures resets on any cached success — cosmetic, the gate doesn't follow it.)
Auto-recovers on reconnect, unattended. So ∞ → ~50s. 👍
One residual gap, order path:_ensureAlive() rides connectionDead, which for a silent half-open (no FIN/RST → connectionClosed never fires) is only set by the 45s heartbeat. So there's a ≤50s window right after the drop where placeOrder/modify/cancel aren't refused and transmit into the dead socket — exactly the daily-restart-while-idle case, narrowed but not closed.
Suggested fix: on the transmit path only, do a synchronous liveness probe — a reqCurrentTime round-trip with a short timeout — immediately before sending, and refuse on timeout. Reads can stay cache-backed; only the irreversible order path needs confirm-then-send rather than trusting a flag that can be ≤50s stale. Closes the window without raising heartbeat frequency. (For what it's worth, we gate our own order path with a forced reconnect-and-verify before every push — confirmed it refuses inside the blind window even while health still reads healthy — so we're covered, but anything placing orders straight through, e.g. the UI, isn't.)
Repro recipe (deterministic, no nightly wait):kill -STOP <gateway-pid> → wait ~50s → heartbeat marks dead, reads 503 → kill -CONT → recovers. Faithful silent half-open without waiting for the real auto-restart.
(error 1102 as an explicit recovery trigger is still open on our side — recovery rode the existing re-init loop fine without it here.)
Implemented the remaining gap in #658 (merged to dev, merge commit fa4c2795).
What was still wrong
#335 correctly added a 45s heartbeat and a hard connectionDead gate, but the order path still trusted that flag. During a silent half-open, the flag can be up to heartbeat + timeout (~50s) behind reality. A place/modify/cancel inside that window could therefore write to a mute socket before the next heartbeat marked it dead.
There was also a presentation/state issue above the adapter: the broker could know the transport was dead while UTA health still depended on passive failure counting. Because IBKR account reads are cache-backed, that is not a reliable health signal.
What changed
placeOrder, modifyOrder, and cancelOrder now require a reqCurrentTime round-trip on the same ordered socket immediately before the client send.
The write probe has a 3s timeout. On failure the adapter marks the transport dead, returns an explicit "order was not transmitted" error, and never calls placeOrder/cancelOrder on the client.
Concurrent heartbeat/write probes share one in-flight current-time request, so the single callback slot cannot be overwritten.
connectionClosed, 502/504/1100, and heartbeat failure publish an authoritative dead signal to UTA. UTA goes directly to offline/down and starts recovery instead of waiting for six caller-driven cache failures.
1101/1102 only nudge recovery immediately; they do not mark the account healthy. Reconnect plus the normal private account read must succeed before health returns to healthy/readable.
Synchronous socket-write failures and timeouts clear the probe slot so a later reconnect can retry normally.
Verification performed
Deterministic specs cover:
probe happens before every place/modify/cancel send;
a failed probe marks dead and the client send is never called;
concurrent current-time probes coalesce;
1100 is immediately dead, while 1102 is only a recovery nudge;
UTA health moves directly offline and becomes green only after the recovery ladder passes.
Live read-only acceptance used IB Gateway 10.37.1r / serverVersion 203 in a paper account:
Started healthy/readable.
Froze the Gateway container with docker pause (TCP remained half-open).
At 46s UTA changed directly to offline/down/recovering.
The account endpoint refused with HTTP 503 instead of serving cached data.
Unpaused Gateway; UTA returned to healthy/readable in 2s and account/position reads returned 200.
No order endpoint was called.
Full repository tests passed (3161 tests); the targeted UTA/IBKR suite passed 151 tests.
Reproduction invitation
Could you please retry against dev or a build containing fa4c2795?
Safe half-open check:
Use a paper account (read-only API is sufficient for the health/recovery part).
Freeze Gateway without closing TCP: docker pause <container> or SIGSTOP the Gateway process. Install a trap/cleanup so it is always resumed.
Expect health to leave healthy within about 50s, cached account reads to refuse once dead is known, and automatic recovery after unpause/SIGCONT.
For the residual write-window check, use paper only: immediately after freezing Gateway, attempt place/modify/cancel. It should fail after the short liveness probe with "order was not transmitted"; after resuming Gateway, no queued order should appear. Please do not run this part against a live-money account.
If you can report the TWS/Gateway version, API serverVersion, transition timings, and sanitized error text, that will let us compare environments. Please omit account IDs, balances, orders, and raw logs.
I am leaving the issue open for external confirmation; if the paper repro behaves differently, this comment should also provide the exact path to investigate next.
That should close the silent half-open window where orders could be accepted into a mute socket.
Ask @rudyll (or anyone with Gateway): please re-run the deterministic recipe on current dev (SIGSTOP / docker pause, then attempt place/modify/cancel during the freeze):
health should leave healthy within ~50s
reads should 503 once dead
orders should fail with “not transmitted” after the short probe
Activity
fix(ibkr,ui): community-issue sweep — currency-keyed account values (#…
luokerenx4 commentedon Jun 13, 2026
Implemented in #335 (merged) — needs verification on a real Gateway setup, and yours is the canonical repro environment.
Root cause confirmed, plus one mechanism beyond your analysis: the IBKR account surface is cache-backed, so routine health queries never touch the socket at all — a dead-but-idle connection is invisible to failure counting even in principle. That's why
health: healthysurvived a dead socket indefinitely.What landed:
reqCurrentTimeevery 45s with a 5s timeout; a failed beat marks the connection dead.connectionClosed()marks dead immediately (previously it only rejected in-flight requests).init()force-tears-down a half-open socket —isConnected()lies on half-open TCP, which previously made the recovery loop's re-init a silent no-op.Not yet done from your list: treating error 1102 as an explicit recovery trigger (recovery currently rides the existing re-init loop, which the dead flag now actually un-blocks). Specs cover the gate wiring; what we could NOT reproduce locally is the silent half-open drop itself (we run TWS, not Gateway with daily restarts). If you can run your repro — Gateway daily auto-restart while idle — against master and confirm (1) health leaves
healthy, (2) order placement refuses during the window, (3) it recovers without manual reconnect, we'll close this.rudyll commentedon Jun 13, 2026
Verified on master (#335), IB Gateway 10.47 paper — reproduced the silent half-open deterministically (
SIGSTOPthe Gateway → socket stays ESTABLISHED, peer mute, same shape as the idle daily-restart drop →SIGCONT).Confirmed working:
connectionDeadflips ~50s in,getAccount()then refuses with the new 503 instead of serving stale cache forever.connectionDead, which is cleared only by a real reconnect (markAliveininit()), so cached reads can't un-dead it. (Health status still flaps healthy↔offline becauseconsecutiveFailuresresets on any cached success — cosmetic, the gate doesn't follow it.)One residual gap, order path:
_ensureAlive()ridesconnectionDead, which for a silent half-open (no FIN/RST →connectionClosednever fires) is only set by the 45s heartbeat. So there's a ≤50s window right after the drop whereplaceOrder/modify/cancelaren't refused and transmit into the dead socket — exactly the daily-restart-while-idle case, narrowed but not closed.Suggested fix: on the transmit path only, do a synchronous liveness probe — a
reqCurrentTimeround-trip with a short timeout — immediately before sending, and refuse on timeout. Reads can stay cache-backed; only the irreversible order path needs confirm-then-send rather than trusting a flag that can be ≤50s stale. Closes the window without raising heartbeat frequency. (For what it's worth, we gate our own order path with a forced reconnect-and-verify before every push — confirmed it refuses inside the blind window even while health still readshealthy— so we're covered, but anything placing orders straight through, e.g. the UI, isn't.)Repro recipe (deterministic, no nightly wait):
kill -STOP <gateway-pid>→ wait ~50s → heartbeat marks dead, reads 503 →kill -CONT→ recovers. Faithful silent half-open without waiting for the real auto-restart.(error 1102 as an explicit recovery trigger is still open on our side — recovery rode the existing re-init loop fine without it here.)
luokerenx4 commentedon Jul 17, 2026
Implemented the remaining gap in #658 (merged to
dev, merge commitfa4c2795).What was still wrong
#335 correctly added a 45s heartbeat and a hard
connectionDeadgate, but the order path still trusted that flag. During a silent half-open, the flag can be up to heartbeat + timeout (~50s) behind reality. A place/modify/cancel inside that window could therefore write to a mute socket before the next heartbeat marked it dead.There was also a presentation/state issue above the adapter: the broker could know the transport was dead while UTA health still depended on passive failure counting. Because IBKR account reads are cache-backed, that is not a reliable health signal.
What changed
placeOrder,modifyOrder, andcancelOrdernow require areqCurrentTimeround-trip on the same ordered socket immediately before the client send.placeOrder/cancelOrderon the client.connectionClosed, 502/504/1100, and heartbeat failure publish an authoritativedeadsignal to UTA. UTA goes directly tooffline/downand starts recovery instead of waiting for six caller-driven cache failures.healthy/readable.Verification performed
Deterministic specs cover:
Live read-only acceptance used IB Gateway 10.37.1r / serverVersion 203 in a paper account:
healthy/readable.docker pause(TCP remained half-open).offline/down/recovering.healthy/readablein 2s and account/position reads returned 200.Full repository tests passed (3161 tests); the targeted UTA/IBKR suite passed 151 tests.
Reproduction invitation
Could you please retry against
devor a build containingfa4c2795?Safe half-open check:
docker pause <container>orSIGSTOPthe Gateway process. Install a trap/cleanup so it is always resumed.healthywithin about 50s, cached account reads to refuse once dead is known, and automatic recovery after unpause/SIGCONT.If you can report the TWS/Gateway version, API serverVersion, transition timings, and sanitized error text, that will let us compare environments. Please omit account IDs, balances, orders, and raw logs.
I am leaving the issue open for external confirmation; if the paper repro behaves differently, this comment should also provide the exact path to investigate next.
RainMona commentedon Aug 17, 2026
Community triage (current
dev, Aug 2026)Status: likely fixed in code — needs a final reporter retest
Current
devincludes both layers discussed in-thread:connectionDeadgatereqCurrentTimeprobe before place/modify/cancel, plus UTA moving offline on transport deathThat should close the silent half-open window where orders could be accepted into a mute socket.
Ask @rudyll (or anyone with Gateway): please re-run the deterministic recipe on current
dev(SIGSTOP/docker pause, then attempt place/modify/cancel during the freeze):healthywithin ~50sIf that matches, we can close.
— triage note
fix(uta): make IBKR liveness a coarse failure detector
fix(ibkr): make transport liveness a coarse failure detector
fix(ibkr): harden transport liveness and UTA recovery against gateway…