py-ib-controller 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ibcontroller/login.py ADDED
@@ -0,0 +1,578 @@
1
+ """The Login domain's own state machine. `LoginState` has six states:
2
+ `LOGGED_OUT`, `AWAITING_CREDENTIALS`, `LOGGING_IN`, `MFA_IN_PROGRESS`,
3
+ `LOGGED_IN`, `LOGIN_FAILED`.
4
+
5
+ **Distinguishing the login frame from the main window, given both can share
6
+ the same title.** This module records the login frame's own window *class*
7
+ the moment it's first recognised, and treats a later window sharing the same
8
+ title but a *different* class as the main window -- a relative comparison,
9
+ not a hardcoded class string, since title alone can't tell the two apart.
10
+
11
+ **Restart check, before anything else.** If Gateway/TWS's own `autorestart`
12
+ marker file is present in its settings directory, this module skips straight
13
+ to waiting for the outcome instead of filling credentials, matching an
14
+ automatic relogin already in progress.
15
+
16
+ **Existing-session/login-failed/non-brokerage dialogs are `recognisers.py`'s
17
+ job, not this module's** -- `LoginManager` never dispatches to that registry
18
+ itself. Its own `wait_for_event` calls are narrowed to only the two outcomes
19
+ it actually cares about (MFA, the main window); anything else is left
20
+ entirely to the separate, always-on `recognisers.watch_for_unprompted_windows`
21
+ background task, so a real dialog occurrence is never handled twice at once.
22
+
23
+ **Two distinct timeout/retry mechanisms, not one:**
24
+
25
+ 1. An MFA-timeout watchdog, internal to this module (`_after_mfa_closed_*`):
26
+ measures elapsed time since login started against
27
+ `mfa_timeout` (IB's own budget for completing
28
+ 2FA). If 2FA closed within that budget and `relogin_after_mfa_timeout` is
29
+ enabled, arms a second, shorter watchdog
30
+ (`mfa_exit_interval`) and raises `LoginError` if
31
+ login still hasn't completed by then -- there's no restart primitive at
32
+ this layer yet. If 2FA's own timeout expired while its dialog was still
33
+ open, retries the whole login after a fixed `_IBC_RELOGIN_DELAY_SECONDS`.
34
+ 2. `recognisers.TooManyFailedLoginAttemptsRecognizer`, a real server-side
35
+ rate-limit dialog, unrelated to the MFA timing above. Its `handle()`
36
+ calls `LoginManager.schedule_retry`, which re-fills credentials, resubmits,
37
+ and waits for the outcome independently -- it doesn't assume the original
38
+ `run()` call is still alive to notice the result.
39
+
40
+ **Gateway and TWS need different `LOGGED_IN` signals.** Gateway's main
41
+ window already exists, with an enabled menu, before login completes -- so
42
+ "main window opened" can't be Gateway's completion signal the way it is for
43
+ TWS. `_wait_for_outcome` dispatches on `config.program`:
44
+ `_wait_for_outcome_tws` waits for a newly opened window to carry a
45
+ `File > Lock Application` menu item; `_wait_for_outcome_gateway` waits for
46
+ the splash/"Authenticating..." window's own `WINDOW_CLOSED` event instead,
47
+ the one signal that's correct in both trading modes without needing to know
48
+ which one it is.
49
+
50
+ **Not live-validated against real IBKR servers, unlike everything else in
51
+ this file.** Triggering IB's real 180-second 2FA timeout, or "too many
52
+ failed login attempts", both carry real account-level risk for no safe
53
+ benefit -- built and unit-tested against synthetic timing/dialogs instead.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import asyncio
59
+ import contextlib
60
+ import logging
61
+ import time
62
+ from enum import Enum, auto
63
+ from pathlib import Path
64
+
65
+ from anyio import Path as AsyncPath
66
+
67
+ from ibcontroller.actions import (
68
+ click,
69
+ menu_item_exists,
70
+ type_text,
71
+ wait_for_event,
72
+ )
73
+ from ibcontroller.agent_client import WindowEvent
74
+ from ibcontroller.config import Config, TradingMode
75
+ from ibcontroller.dispatch import Dispatcher
76
+ from ibcontroller.labels import Labels
77
+
78
+ logger = logging.getLogger(__name__)
79
+
80
+ # IBC's own hardcoded constant (LoginManager.java's
81
+ # secondFactorAuthenticationDialogClosed), not a config value -- kept literal
82
+ # here for the same reason IBC never exposed it as one.
83
+ _IBC_RELOGIN_DELAY_SECONDS = 5.0
84
+
85
+
86
+ class LoginState(Enum):
87
+ LOGGED_OUT = auto()
88
+ AWAITING_CREDENTIALS = auto()
89
+ LOGGING_IN = auto()
90
+ MFA_IN_PROGRESS = auto()
91
+ LOGGED_IN = auto()
92
+ LOGIN_FAILED = auto()
93
+
94
+
95
+ class LoginError(Exception):
96
+ """The login frame itself was never recognised within the timeout, or some
97
+ other condition this module can't recover from on its own."""
98
+
99
+
100
+ async def find_autorestart_hash(settings_dir: str | Path) -> str | None:
101
+ """Locates Gateway/TWS's own `autorestart` marker file under
102
+ `settings_dir` and returns the account-hash subdirectory name it lives
103
+ in (`<settings_dir>/<hash>/autorestart`) -- the value passed on the JVM
104
+ command line as `-Drestart=<hash>` on relaunch. Returns `None` if zero
105
+ or more than one marker file is found: with more than one, there's no
106
+ way to tell which is the right one, so both are left in place (not
107
+ deleted) and the caller falls back to a full credential fill.
108
+
109
+ The recursive scan runs off the event loop (`anyio.Path`, one thread
110
+ hop per call) -- called repeatedly in a tight retry loop
111
+ (`control_loop._is_restart_with_grace`) concurrent with the Dispatcher's
112
+ own event/command tasks, which a blocking `pathlib` scan would stall."""
113
+ found = [p async for p in AsyncPath(settings_dir).rglob("autorestart")]
114
+ if len(found) != 1:
115
+ return None
116
+ return found[0].parent.name
117
+
118
+
119
+ async def is_restart(settings_dir: str | Path) -> bool:
120
+ """Returns whether exactly one `autorestart` marker file exists under
121
+ `settings_dir` (via `find_autorestart_hash`)."""
122
+ return await find_autorestart_hash(settings_dir) is not None
123
+
124
+
125
+ class LoginManager:
126
+ """One instance per running agent -- this module's own state, not shared
127
+ across instances, matching every other per-instance object in this
128
+ project (`Dispatcher`, the recogniser registry)."""
129
+
130
+ def __init__(
131
+ self,
132
+ config: Config,
133
+ labels: Labels,
134
+ dispatcher: Dispatcher,
135
+ *,
136
+ settings_dir: str | Path,
137
+ restart_expected: bool | None = None,
138
+ ) -> None:
139
+ """`config`/`labels`/`dispatcher`: this instance's configuration,
140
+ labels, and `Dispatcher`. `settings_dir`: the Gateway/TWS settings
141
+ directory, checked for the autorestart marker. `restart_expected`:
142
+ `None` (default) checks `is_restart(settings_dir)` directly at
143
+ `run()` time; `True`/`False` overrides that check -- for callers
144
+ (`control_loop.py`) that already know the answer from having
145
+ computed their own restart hash moments earlier, avoiding a race
146
+ against Gateway's own consumption of the marker file."""
147
+ self._config = config
148
+ self._labels = labels
149
+ self._dispatcher = dispatcher
150
+ self._settings_dir = settings_dir
151
+ self._restart_expected = restart_expected
152
+ self.state = LoginState.LOGGED_OUT
153
+ self._login_frame_class: str | None = None
154
+ self._login_frame_window_id: str | None = None
155
+ self._login_start_time: float | None = None
156
+ self._retry_task: asyncio.Task[None] | None = None
157
+ self.main_window_id: str | None = None
158
+
159
+ def is_logged_in(self) -> bool:
160
+ """Returns whether `state` is `LOGGED_IN`. Supplied to
161
+ `ExistingSessionRecognizer` (`recognisers.py`) as its
162
+ `is_logged_in` callable -- that recogniser reacts to login state, it
163
+ doesn't own it."""
164
+ return self.state is LoginState.LOGGED_IN
165
+
166
+ def _gateway_or_tws_titles(self) -> list[str]:
167
+ return (
168
+ self._labels.login.gateway_titles
169
+ if self._config.program.lower() == "gateway"
170
+ else self._labels.login.tws_titles
171
+ )
172
+
173
+ async def run(
174
+ self,
175
+ *,
176
+ login_timeout: float | None = None,
177
+ outcome_timeout: float | None = None,
178
+ ) -> None:
179
+ """Drives the whole Login domain sequence, end to end: waits for the
180
+ login frame, skips credential fill if a restart is already in
181
+ progress, otherwise fills and submits credentials, then waits for
182
+ the outcome. Raises `LoginError`/`recognisers.LoginFailedError`/
183
+ `TimeoutError` on failure; returns normally once `state` is
184
+ `LOGGED_IN`. `login_timeout`/`outcome_timeout` default from
185
+ `Config.login_dialog_display_timeout`/
186
+ `mfa_timeout` when not given explicitly."""
187
+ login_timeout = (
188
+ login_timeout
189
+ if login_timeout is not None
190
+ else self._config.login_dialog_display_timeout
191
+ )
192
+ outcome_timeout = (
193
+ outcome_timeout if outcome_timeout is not None else self._config.mfa_timeout
194
+ )
195
+ titles = self._gateway_or_tws_titles()
196
+ try:
197
+ login_event = await wait_for_event(
198
+ self._dispatcher,
199
+ "window_opened",
200
+ lambda e: e.window.title in titles,
201
+ timeout=login_timeout,
202
+ )
203
+ except TimeoutError as exc:
204
+ raise LoginError(
205
+ f"login frame never appeared within {login_timeout}s "
206
+ f"(expected one of {titles})"
207
+ ) from exc
208
+ self._login_frame_class = login_event.window.class_
209
+ self._login_frame_window_id = login_event.window.window_id
210
+
211
+ if self._restart_expected is not None:
212
+ restart = self._restart_expected
213
+ reason = "restart_expected flag"
214
+ else:
215
+ restart = await is_restart(self._settings_dir)
216
+ reason = "autorestart marker file"
217
+ if restart:
218
+ logger.info("IBController > %s -- skipping credential fill", reason)
219
+ self.state = LoginState.LOGGING_IN
220
+ self._login_start_time = time.monotonic()
221
+ await self._wait_for_outcome(outcome_timeout)
222
+ return
223
+
224
+ await self._fill_credentials_and_submit()
225
+ await self._wait_for_outcome(outcome_timeout)
226
+
227
+ async def _fill_credentials(self) -> None:
228
+ """Fills the trading-mode/username/password fields on the login
229
+ frame, scoped to `self._login_frame_window_id` (the login frame's
230
+ FIX Login and IB API sections both carry Username/Password pairs
231
+ with identical accessible names, so an unscoped search would be
232
+ ambiguous). On Gateway, first clicks the API-type toggle to select
233
+ IB API -- TWS has no such control, so that click is skipped there."""
234
+ labels = self._labels.login
235
+ window_id = self._login_frame_window_id
236
+ if self._config.program.lower() == "gateway":
237
+ await click(
238
+ self._dispatcher,
239
+ labels.api_type_ib_api,
240
+ window_id=window_id,
241
+ )
242
+ trading_mode_label = (
243
+ labels.trading_mode_live
244
+ if self._config.trading_mode is TradingMode.LIVE
245
+ else labels.trading_mode_paper
246
+ )
247
+ await click(self._dispatcher, trading_mode_label, window_id=window_id)
248
+ await type_text(
249
+ self._dispatcher,
250
+ labels.username_field,
251
+ self._config.userid,
252
+ window_id=window_id,
253
+ )
254
+ await type_text(
255
+ self._dispatcher,
256
+ labels.password_field,
257
+ self._config.password,
258
+ window_id=window_id,
259
+ )
260
+
261
+ async def _fill_credentials_and_submit(self) -> None:
262
+ """Fills credentials, then clicks the login button. Factored out of
263
+ `run()` so a retry can redo just this part on an already-open login
264
+ frame, without re-waiting for a fresh login-frame `window_opened`
265
+ event that will never re-fire for a frame that's already open."""
266
+ self.state = LoginState.AWAITING_CREDENTIALS
267
+ await self._fill_credentials()
268
+ self.state = LoginState.LOGGING_IN
269
+ self._login_start_time = time.monotonic()
270
+ await click(
271
+ self._dispatcher,
272
+ self._labels.login.login_buttons,
273
+ window_id=self._login_frame_window_id,
274
+ )
275
+ logger.info("IBController > credentials submitted, awaiting outcome")
276
+
277
+ async def retry_login(
278
+ self, wait_seconds: float, *, outcome_timeout: float | None = None
279
+ ) -> None:
280
+ """Waits `wait_seconds`, re-submits credentials, then independently
281
+ waits for the outcome -- doesn't assume whichever `run()` call
282
+ originally started this session is still alive to notice the
283
+ result. Redundant but harmless if the original wait is still alive
284
+ too -- both independently converge on the same `LOGGED_IN` state."""
285
+ await asyncio.sleep(wait_seconds)
286
+ await self._fill_credentials_and_submit()
287
+ await self._wait_for_outcome(
288
+ outcome_timeout if outcome_timeout is not None else self._config.mfa_timeout
289
+ )
290
+
291
+ def schedule_retry(self, wait_seconds: float) -> None:
292
+ """Synchronous callback given to `TooManyFailedLoginAttemptsRecognizer`
293
+ (`recognisers.py`): fires `retry_login` as a background task after
294
+ `wait_seconds`, so the caller (running inside
295
+ `watch_for_unprompted_windows`'s own loop) never blocks on the
296
+ cooldown itself. Keeps a reference on `self` so the task isn't
297
+ garbage-collected mid-flight."""
298
+ self._retry_task = asyncio.ensure_future(self.retry_login(wait_seconds))
299
+
300
+ async def _wait_for_outcome(self, outcome_timeout: float) -> None:
301
+ """Waits for the login outcome, dispatching on `config.program` --
302
+ Gateway and TWS need different completion signals (see the module
303
+ docstring). Every nested wait bounded by `outcome_timeout` itself
304
+ (as opposed to `mfa_exit_interval`, already
305
+ wrapped where it's used) converges here, so a raw `TimeoutError` is
306
+ caught once, in this one place, and turned into `LoginError` --
307
+ matching every other timeout in this module and keeping
308
+ `cli.py`'s clean operational-error reporting intact."""
309
+ try:
310
+ if self._config.program.lower() == "gateway":
311
+ await self._wait_for_outcome_gateway(outcome_timeout)
312
+ else:
313
+ await self._wait_for_outcome_tws(outcome_timeout)
314
+ except TimeoutError as exc:
315
+ raise LoginError(
316
+ f"login did not complete within {outcome_timeout}s of "
317
+ "credentials being submitted "
318
+ "(mfa_timeout)"
319
+ ) from exc
320
+
321
+ async def _wait_for_outcome_tws(self, timeout: float | None = None) -> None:
322
+ """Loops over `window_opened` events (other than the login frame's
323
+ own class) until one carries a `File > Lock Application` menu item
324
+ (the main window, checked via `menu_item_exists`) or is the MFA
325
+ dialog. On MFA, waits for it to close, then delegates to
326
+ `_after_mfa_closed_tws`.
327
+
328
+ `timeout` defaults to `None` (unbounded) only as a safety net for a
329
+ direct call with no argument -- every real call site passes an
330
+ explicit bound: `_wait_for_outcome` passes `outcome_timeout`,
331
+ `_after_mfa_closed_tws` passes either that same budget (its
332
+ unconditional "keep waiting" fallbacks) or its own shorter
333
+ `mfa_exit_interval` watchdog, tracked as
334
+ one deadline across every loop iteration -- including the MFA-closed
335
+ wait, which uses `deadline`'s own remaining time rather than a fresh
336
+ `timeout`, matching IBC's `LoginManager.secondFactorAuthenticationDialogClosed`
337
+ (elapsed measured from one fixed login-start anchor, never re-armed
338
+ per stage).
339
+
340
+ Deliberately does *not* fall back to the shared recogniser registry
341
+ for anything else it sees (existing-session, login-failed,
342
+ non-brokerage, a stray pop-up, an intermediate window like
343
+ "Downloading settings from server") -- those are left entirely to
344
+ the separate, always-on `recognisers.watch_for_unprompted_windows`
345
+ background task."""
346
+ mfa_title = self._labels.mfa.title
347
+ deadline = None if timeout is None else time.monotonic() + timeout
348
+
349
+ while True:
350
+ remaining = (
351
+ None if deadline is None else max(0.0, deadline - time.monotonic())
352
+ )
353
+ event = await wait_for_event(
354
+ self._dispatcher,
355
+ "window_opened",
356
+ lambda e: e.window.class_ != self._login_frame_class,
357
+ timeout=remaining,
358
+ )
359
+ if event.window.title == mfa_title:
360
+ self.state = LoginState.MFA_IN_PROGRESS
361
+ logger.info("IBController > second factor authentication in progress")
362
+ await wait_for_event(
363
+ self._dispatcher,
364
+ "window_closed",
365
+ lambda e: e.window.title == mfa_title,
366
+ timeout=(
367
+ None
368
+ if deadline is None
369
+ else max(0.0, deadline - time.monotonic())
370
+ ),
371
+ )
372
+ await self._after_mfa_closed_tws()
373
+ return
374
+
375
+ if event.window.window_id is None:
376
+ # No window_id to scope menu_item_exists to (real
377
+ # window_opened events always carry one -- confirmed live --
378
+ # but WindowInfo's own type doesn't guarantee it); can't be
379
+ # the main window without one, keep looping.
380
+ continue
381
+
382
+ exists, _enabled = await menu_item_exists(
383
+ self._dispatcher,
384
+ self._labels.login.tws_main_window_menu_item,
385
+ window_id=event.window.window_id,
386
+ )
387
+ if exists:
388
+ self.state = LoginState.LOGGED_IN
389
+ # Keep the window_id this same check just validated (#40) --
390
+ # Gateway's path below never sets this (no ambiguity there,
391
+ # see _wait_for_outcome_gateway's docstring), so consumers
392
+ # must treat None as "no scoping available, fall back".
393
+ self.main_window_id = event.window.window_id
394
+ logger.info("IBController > login completed")
395
+ return
396
+ # Not the main window (an intermediate dialog, e.g. "Downloading
397
+ # settings from server") -- keep looping for the next candidate.
398
+
399
+ async def _after_mfa_closed_tws(self) -> None:
400
+ """Measures elapsed time since login started against
401
+ `mfa_timeout` (IB's own real limit for
402
+ completing 2FA, default 180s).
403
+
404
+ If 2FA closed within that budget: waits for the outcome again, bounded
405
+ by that same `mfa_timeout` budget (an
406
+ unconditional wait if `relogin_after_mfa_timeout` is off), or arms a
407
+ shorter `mfa_exit_interval` watchdog and
408
+ raises `LoginError` if login still hasn't completed by then -- there
409
+ is no restart primitive at this layer yet.
410
+
411
+ If 2FA's own timeout already expired while its dialog was still
412
+ open: retries the whole login after `_IBC_RELOGIN_DELAY_SECONDS`, or
413
+ keeps waiting for the outcome (same bounded budget) if retry is
414
+ disabled."""
415
+ elapsed = time.monotonic() - (self._login_start_time or time.monotonic())
416
+
417
+ if elapsed < self._config.mfa_timeout:
418
+ # 2FA was handled within IB's own budget -- authentication should
419
+ # be under way.
420
+ if not self._config.relogin_after_mfa_timeout:
421
+ await self._wait_for_outcome_tws(self._config.mfa_timeout)
422
+ return
423
+ # A second, shorter watchdog: if login still hasn't completed by
424
+ # now, we have no restart primitive at this layer (Management/L1,
425
+ # not built), so this raises a clear signal instead.
426
+ try:
427
+ await self._wait_for_outcome_tws(self._config.mfa_exit_interval)
428
+ except TimeoutError as exc:
429
+ raise LoginError(
430
+ "login did not complete within "
431
+ f"{self._config.mfa_exit_interval}s "
432
+ "of second factor authentication completing (IBC's own "
433
+ "SecondFactorAuthenticationExitInterval watchdog)"
434
+ ) from exc
435
+ return
436
+
437
+ # 2FA's own IB-side timeout expired while the dialog was still open --
438
+ # the user answered too slowly.
439
+ if not self._config.relogin_after_mfa_timeout:
440
+ logger.info(
441
+ "IBController > re-login after second factor authentication timeout not"
442
+ " required"
443
+ )
444
+ # Our own run() is a bounded coroutine the caller awaits for a
445
+ # definitive outcome (returns normally once state is LOGGED_IN)
446
+ # -- returning here instead would complete run() while state
447
+ # stays stuck at MFA_IN_PROGRESS, so keep waiting for the
448
+ # outcome, same as the "on-time, relogin disabled" branch above.
449
+ await self._wait_for_outcome_tws(self._config.mfa_timeout)
450
+ return
451
+ logger.info(
452
+ "IBController > re-login after second factor authentication timeout in %ss",
453
+ _IBC_RELOGIN_DELAY_SECONDS,
454
+ )
455
+ await self.retry_login(_IBC_RELOGIN_DELAY_SECONDS)
456
+
457
+ async def _wait_for_outcome_gateway(self, outcome_timeout: float) -> None:
458
+ """Waits for the splash/"Authenticating..." window's own
459
+ `WINDOW_CLOSED` event (matched by its final title,
460
+ "Starting application...") -- Gateway's real login-completion
461
+ signal, since its main window already exists, with an enabled menu,
462
+ before login finishes, so "main window opened" can't be used here.
463
+
464
+ An MFA dialog opening/closing along the way only updates `state` and
465
+ arms `_after_mfa_closed_gateway`'s watchdog -- unlike the TWS path,
466
+ it is never itself a terminal signal. Runs two concurrent
467
+ `wait_for_event` waits (splash-closed, MFA-opened), since they're
468
+ different event kinds (`window_closed` vs `window_opened`); both are
469
+ always cancelled in `finally` before this method returns, whichever
470
+ path was actually taken.
471
+
472
+ `deadline`, tracked once from entry, matches `_wait_for_outcome_tws`'s
473
+ own `deadline`/`remaining` pattern: the MFA-closed wait below uses
474
+ what's left of `outcome_timeout`, not a fresh copy of it -- MFA
475
+ opening late in the budget must not grant a second full budget's
476
+ worth of extra waiting."""
477
+ splash_title = self._labels.login.starting_application_title
478
+ mfa_title = self._labels.mfa.title
479
+ deadline = time.monotonic() + outcome_timeout
480
+
481
+ splash_closed = asyncio.ensure_future(
482
+ wait_for_event(
483
+ self._dispatcher,
484
+ "window_closed",
485
+ lambda e: e.window.title == splash_title,
486
+ timeout=outcome_timeout,
487
+ )
488
+ )
489
+ mfa_opened = asyncio.ensure_future(
490
+ wait_for_event(
491
+ self._dispatcher,
492
+ "window_opened",
493
+ lambda e: e.window.title == mfa_title,
494
+ timeout=outcome_timeout,
495
+ )
496
+ )
497
+ pending: set[asyncio.Task[WindowEvent]] = {splash_closed, mfa_opened}
498
+ try:
499
+ while splash_closed in pending:
500
+ done, pending = await asyncio.wait(
501
+ pending, return_when=asyncio.FIRST_COMPLETED
502
+ )
503
+ if mfa_opened not in done:
504
+ continue
505
+ mfa_exc = mfa_opened.exception()
506
+ if mfa_exc is not None:
507
+ if not isinstance(mfa_exc, TimeoutError):
508
+ raise mfa_exc
509
+ # MFA simply never appeared within outcome_timeout (the
510
+ # normal paper case, and any Gateway login that doesn't
511
+ # need it) -- keep waiting on splash_closed alone.
512
+ continue
513
+ self.state = LoginState.MFA_IN_PROGRESS
514
+ logger.info("IBController > second factor authentication in progress")
515
+ await wait_for_event(
516
+ self._dispatcher,
517
+ "window_closed",
518
+ lambda e: e.window.title == mfa_title,
519
+ timeout=max(0.0, deadline - time.monotonic()),
520
+ )
521
+ await self._after_mfa_closed_gateway(splash_closed, outcome_timeout)
522
+ await splash_closed
523
+ finally:
524
+ for task in (mfa_opened, splash_closed):
525
+ if not task.done():
526
+ task.cancel()
527
+ for task in (mfa_opened, splash_closed):
528
+ with contextlib.suppress(asyncio.CancelledError):
529
+ await task
530
+
531
+ self.state = LoginState.LOGGED_IN
532
+ logger.info("IBController > login completed")
533
+
534
+ async def _after_mfa_closed_gateway(
535
+ self, splash_closed: asyncio.Task[WindowEvent], outcome_timeout: float
536
+ ) -> None:
537
+ """Gateway variant of `_after_mfa_closed_tws` -- never recurses into
538
+ a fresh wait: `_wait_for_outcome_gateway`'s own `splash_closed`
539
+ future is already pending and will resolve on its own once Gateway
540
+ actually finishes authenticating. Only arms the shorter
541
+ exit-interval watchdog (raising `LoginError` on timeout, if
542
+ `relogin_after_mfa_timeout` is enabled) or schedules a retry.
543
+ `asyncio.shield` on the watchdog wait is deliberate: a timeout there
544
+ must not cancel `splash_closed` itself, since the caller's own loop
545
+ still owns and awaits that future regardless of what this method
546
+ does."""
547
+ elapsed = time.monotonic() - (self._login_start_time or time.monotonic())
548
+
549
+ if elapsed < self._config.mfa_timeout:
550
+ if not self._config.relogin_after_mfa_timeout:
551
+ return
552
+ try:
553
+ await asyncio.wait_for(
554
+ asyncio.shield(splash_closed),
555
+ timeout=self._config.mfa_exit_interval,
556
+ )
557
+ except TimeoutError as exc:
558
+ raise LoginError(
559
+ "login did not complete within "
560
+ f"{self._config.mfa_exit_interval}s "
561
+ "of second factor authentication completing (IBC's own "
562
+ "SecondFactorAuthenticationExitInterval watchdog)"
563
+ ) from exc
564
+ return
565
+
566
+ if not self._config.relogin_after_mfa_timeout:
567
+ logger.info(
568
+ "IBController > re-login after second factor authentication "
569
+ "timeout not required"
570
+ )
571
+ return
572
+ logger.info(
573
+ "IBController > re-login after second factor authentication timeout in %ss",
574
+ _IBC_RELOGIN_DELAY_SECONDS,
575
+ )
576
+ await self.retry_login(
577
+ _IBC_RELOGIN_DELAY_SECONDS, outcome_timeout=outcome_timeout
578
+ )