From b736cf4ec099e6ce2440ebc704ff6ef06e367093 Mon Sep 17 00:00:00 2001 From: Mark Chapin Date: Tue, 11 Aug 2026 16:27:34 -0600 Subject: [PATCH] Fix 2FA shutdown timer killing a healthy logged-in session Two independent defects in LoginManager can combine to terminate a Gateway session that has successfully logged in and is serving the API. 1. restartAfterTime() assigns shutdownAfterTimeTask without cancelling the ScheduledFuture already held there. setLoginState(LOGGED_IN) cancels only whatever the field currently references, so any earlier task is orphaned: still scheduled, but no longer reachable by the handler meant to cancel it. Every start arms two of these. The SecondFactorDevice selection dialog closes first ("Duration since login: 1 seconds") and arms one; the real 2FA dialog closes a few seconds later and arms a second, overwriting the field. One orphan therefore survives every session. 2. The re-login scheduled after a 2FA timeout runs unconditionally five seconds later. TWS/Gateway sometimes closes the 2FA dialog right on the timeout boundary and then completes the login anyway, so the session can already be LOGGED_IN when that task fires. Re-initiating the login takes it back out of LOGGED_IN, and it never returns. Together they end a working session. Observed with Gateway 1045 and IBC 3.23.0, SecondFactorAuthenticationExitInterval=3700: 15:38:37.697 device dialog closed, orphan timer armed (deadline 16:40:17) 16:01:24 2FA approved; 16:01:27 LOGGED_IN, API port 4002 open 16:07:15 IB forces "Re-login is required"; new 2FA prompt 16:10:16.759 dialog closed at exactly 180s -> re-login scheduled in 5s 16:10:16.868 session reaches LOGGED_IN on its own, 109ms later 16:10:21 the queued re-login fires anyway, state leaves LOGGED_IN 16:40:17.699 orphan timer fires, state != LOGGED_IN, exit 1111 The Gateway process was alive and serving the API throughout: its log was still being written at 16:40:17, and the socat helper logged no connection failures after 16:01:27. Fix both: cancel any outstanding shutdown task before scheduling a new one, and guard the deferred re-login on LOGGED_IN, mirroring the guard restartAfterTime already applies in its own scheduled task. Either change alone prevents the failure above; both are included because each is a defect in its own right. Co-Authored-By: Claude Opus 5 (1M context) --- src/ibcalpha/ibc/LoginManager.java | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/ibcalpha/ibc/LoginManager.java b/src/ibcalpha/ibc/LoginManager.java index 08c13076..16adc95c 100644 --- a/src/ibcalpha/ibc/LoginManager.java +++ b/src/ibcalpha/ibc/LoginManager.java @@ -150,7 +150,17 @@ void secondFactorAuthenticationDialogClosed() { Utils.logToConsole("Re-login after second factor authentication timeout in 5 second"); MyScheduledExecutorService.getInstance().schedule(() -> { GuiDeferredExecutor.instance().execute( - () -> {getLoginHandler().initiateLogin(getLoginFrame());} + () -> { + // The login may have completed by itself during this delay: TWS/Gateway + // sometimes closes the 2FA dialog right on the timeout boundary and then + // proceeds to log in anyway. Re-initiating the login here would take the + // session back out of LOGGED_IN, and it never returns to it. + if (getLoginState() == LoginState.LOGGED_IN) { + Utils.logToConsole("Login has already completed - no need to re-login"); + return; + } + getLoginHandler().initiateLogin(getLoginFrame()); + } ); }, 5, TimeUnit.SECONDS); } @@ -167,6 +177,14 @@ private boolean reloginPermitted() { void restartAfterTime(final int secondsTillShutdown, final String message) { try { + // Only one shutdown task may be outstanding. Overwriting the field without + // cancelling leaves the previous task scheduled but unreachable, so the + // LOGGED_IN handler can no longer cancel it: it fires later and kills a + // perfectly healthy session that happens not to be LOGGED_IN at that instant. + if (shutdownAfterTimeTask != null) { + shutdownAfterTimeTask.cancel(false); + shutdownAfterTimeTask = null; + } shutdownAfterTimeTask = MyScheduledExecutorService.getInstance().schedule(()->{ GuiExecutor.instance().execute(()->{ if (getLoginState() == LoginManager.LoginState.LOGGED_IN) {