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.
@@ -0,0 +1,909 @@
1
+ """L1: launches and supervises TWS/Gateway.
2
+
3
+ Resolves everything needed to start TWS or IB Gateway (install location, JRE,
4
+ classpath, JVM options) and spawns the agent-embedded process.
5
+
6
+ Split into two halves with different testing needs: `build_launch_plan` is
7
+ pure, filesystem-only logic (classpath assembly, JRE discovery, `.vmoptions`/
8
+ `Info.plist`/`i4jparams.conf` parsing), testable against a synthetic directory
9
+ tree. `launch_instance` is the async half that spawns the process and waits
10
+ for it to answer a real `ping`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import functools
17
+ import logging
18
+ import platform
19
+ import plistlib
20
+ import re
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ import anyio.to_thread
25
+
26
+ from ibcontroller.actions import navigate_menu
27
+ from ibcontroller.agent_client import (
28
+ AgentClientError,
29
+ AgentCommandConnection,
30
+ AgentEventConnection,
31
+ )
32
+ from ibcontroller.app_dirs import resolve_runtime_dir
33
+ from ibcontroller.config import Config
34
+ from ibcontroller.dispatch import Dispatcher
35
+ from ibcontroller.labels import ShutdownLabels
36
+ from ibcontroller.logging_setup import (
37
+ configure_gateway_stdout,
38
+ configure_logging,
39
+ configure_trace,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ _INSTALL4J_DPROPS_STATIC = [
45
+ "-Dtwslaunch.autoupdate.serviceImpl=com.ib.tws.twslaunch.install4j.Install4jAutoUpdateService",
46
+ "-Dexe4j.isInstall4j=true",
47
+ "-Dinstall4jType=standalone",
48
+ ]
49
+ _ENTRY_CLASSES = {
50
+ "gateway": "ibgateway.GWClient",
51
+ "tws": "jclient.LoginFrame",
52
+ }
53
+
54
+ _MACOS_PROGRAM_NAMES = {
55
+ "gateway": "IB Gateway",
56
+ "tws": "Trader Workstation",
57
+ }
58
+
59
+
60
+ class LauncherError(Exception):
61
+ """Anything about assembling or launching the agent process -- a missing
62
+ install, no JRE found, or the agent never becoming ready."""
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class LaunchPlan:
67
+ """Everything `build_launch_plan` resolves, before anything is actually
68
+ spawned -- kept as its own value so the pure, filesystem-only resolution
69
+ can be tested and inspected without spawning a real process."""
70
+
71
+ java_bin: Path
72
+ command: list[str]
73
+ command_socket_path: str
74
+ event_socket_path: str
75
+ settings_dir: str
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class LaunchedInstance:
80
+ """What `launch_instance` hands back. `clean_shutdown` and ad hoc callers
81
+ build `send_command` calls against `dispatcher.command_conn` directly, so
82
+ there's no separate `command_conn` field here to keep in sync with it."""
83
+
84
+ process: asyncio.subprocess.Process
85
+ dispatcher: Dispatcher
86
+ command_socket_path: str
87
+ event_socket_path: str
88
+ settings_dir: str
89
+ stdout_drain_task: asyncio.Task[None]
90
+
91
+
92
+ def _os_name() -> str:
93
+ return "macos" if platform.system() == "Darwin" else "linux"
94
+
95
+
96
+ def _resolve_tws_path(config: Config, os_name: str) -> Path:
97
+ if config.tws_path:
98
+ return Path(config.tws_path).expanduser()
99
+ return Path.home() / ("Applications" if os_name == "macos" else "Jts")
100
+
101
+
102
+ def resolve_tws_settings_path(config: Config) -> Path:
103
+ """Where TWS/Gateway itself stores its settings -- IBC's own
104
+ `TWS_SETTINGS_PATH` concept, deliberately kept separate from
105
+ `_resolve_tws_path`'s install location. Instances that share a settings
106
+ directory collide on state, since some files TWS creates while running
107
+ aren't separated by username and only one instance can access them at a
108
+ time.
109
+
110
+ Unlike IBC's own default (a shared install-folder settings directory
111
+ unless a user opts into `TWS_SETTINGS_PATH`), ibcontroller always
112
+ defaults to a separate per-instance directory, `~/Jts/{instance}` --
113
+ running live and paper concurrently is this project's normal use case,
114
+ not an edge case to opt into safety for."""
115
+ if config.tws_settings_path:
116
+ return Path(config.tws_settings_path).expanduser()
117
+ return Path.home() / "Jts" / config.instance
118
+
119
+
120
+ def _ini_section_bounds(lines: list[str], section: str) -> tuple[int, int] | None:
121
+ """The line-index range (start, end), exclusive of the header itself, of
122
+ `section`'s own lines -- `None` if the section doesn't exist at all.
123
+ Matches IBC's own `JtsIniManager.getSettingIndex`'s section-scoping."""
124
+ try:
125
+ start = lines.index(section) + 1
126
+ except ValueError:
127
+ return None
128
+ end = start
129
+ while end < len(lines) and not lines[end].startswith("["):
130
+ end += 1
131
+ return start, end
132
+
133
+
134
+ def _ensure_ini_settings(
135
+ lines: list[str], section: str, settings: list[tuple[str, str, bool]]
136
+ ) -> tuple[list[str], bool]:
137
+ """`settings` is `(key, expected_value, overwrite_if_different)` triples.
138
+ A missing key is appended to the section (creating the section if it
139
+ doesn't exist yet); an existing key with the wrong value is only
140
+ overwritten if `overwrite_if_different` is true, so a value a deployment
141
+ set deliberately is never silently reverted. Returns the possibly-
142
+ modified lines and whether anything actually changed, so the caller only
143
+ rewrites the file when needed."""
144
+ lines = list(lines)
145
+ changed = False
146
+ bounds = _ini_section_bounds(lines, section)
147
+ if bounds is None:
148
+ lines.append(section)
149
+ bounds = (len(lines), len(lines))
150
+ start, end = bounds
151
+
152
+ to_append = []
153
+ for key, expected, overwrite in settings:
154
+ prefix = f"{key}="
155
+ found = next(
156
+ (i for i in range(start, end) if lines[i].startswith(prefix)), None
157
+ )
158
+ if found is None:
159
+ to_append.append(f"{key}={expected}")
160
+ changed = True
161
+ elif overwrite and lines[found] != prefix + expected:
162
+ lines[found] = prefix + expected
163
+ changed = True
164
+
165
+ if to_append:
166
+ lines[end:end] = to_append
167
+ return lines, changed
168
+
169
+
170
+ def _ensure_jts_ini(settings_dir: Path, *, is_gateway: bool) -> None:
171
+ """Ensures `jts.ini` contains a known-good minimal set of settings
172
+ *before* Gateway/TWS ever starts, avoiding the "Use SSL encryption"
173
+ dialog (and a Locale/proxy-message quirk) entirely rather than reacting
174
+ to a dialog that may not even appear consistently.
175
+
176
+ Sets `UseSSL=true` under `[Logon]` (without it, TWS/Gateway shows a
177
+ dialog offering to restart using SSL or close the program), plus
178
+ `Locale=en` (force English regardless of OS locale) and
179
+ `displayedproxymsg=1` (suppress an unrelated proxy-access dialog).
180
+ `ApiOnly=true` under `[IBGateway]` is Gateway-specific: without it,
181
+ Gateway shows a login form with a structure this project doesn't expect
182
+ and can't find the trading mode selector in.
183
+
184
+ Deliberately does not set `TrustedIPs`/`LocalServerPort` -- ibcontroller
185
+ has no config field for either yet, and a settings-file line it can't
186
+ populate correctly would be worse than not adding it at all."""
187
+ path = settings_dir / "jts.ini"
188
+ lines = path.read_text().splitlines() if path.is_file() else []
189
+
190
+ lines, changed = _ensure_ini_settings(
191
+ lines,
192
+ "[Logon]",
193
+ [
194
+ ("s3store", "true", False),
195
+ ("Locale", "en", True),
196
+ ("displayedproxymsg", "1", True),
197
+ ("UseSSL", "true", True),
198
+ ],
199
+ )
200
+ if is_gateway:
201
+ lines, gw_changed = _ensure_ini_settings(
202
+ lines, "[IBGateway]", [("ApiOnly", "true", True)]
203
+ )
204
+ changed = changed or gw_changed
205
+
206
+ if changed or not path.is_file():
207
+ path.write_text("\n".join(lines) + "\n")
208
+
209
+
210
+ def _program_path(program: str, os_name: str, tws_path: Path, tws_version: str) -> Path:
211
+ """Resolves `program`'s install directory for `tws_version` under
212
+ `tws_path`. `program` and `tws_version` are plain parameters, not read
213
+ from `config`, so `_resolve_program_path`'s fallback can derive the
214
+ *other* program's path without building a second `Config` -- keeps this
215
+ function pure and testable against plain strings."""
216
+ program = program.lower()
217
+ if os_name == "macos":
218
+ name = _MACOS_PROGRAM_NAMES[program]
219
+ return tws_path / f"{name} {tws_version}"
220
+ if program == "gateway":
221
+ return tws_path / "ibgateway" / tws_version
222
+ return tws_path / tws_version
223
+
224
+
225
+ def _resolve_program_path(
226
+ program: str, os_name: str, tws_path: Path, tws_version: str
227
+ ) -> tuple[Path, str]:
228
+ """Resolves `program`'s install directory. A TWS request whose install
229
+ has no `jars/` directory falls back to the same-version Gateway install
230
+ instead, with the TWS entry class (`jclient.LoginFrame`) unchanged -- the
231
+ Gateway distribution's jars carry both entry classes. Only the
232
+ TWS->Gateway direction falls back; a Gateway request never falls back to
233
+ a TWS install.
234
+
235
+ Returns `(install_dir, resolved_program)` -- the second value is the
236
+ *actual* program of the resolved install (`"gateway"` when a TWS request
237
+ fell back), which the caller uses to pick the right `.vmoptions` file
238
+ (`ibgateway.vmoptions`, not `tws.vmoptions`, in a Gateway dir)."""
239
+ requested_program = program.lower()
240
+ program_path = _program_path(requested_program, os_name, tws_path, tws_version)
241
+ if requested_program == "tws" and not (program_path / "jars").is_dir():
242
+ return _program_path("gateway", os_name, tws_path, tws_version), "gateway"
243
+ return program_path, requested_program
244
+
245
+
246
+ def _scan_install_dirs(
247
+ tws_path: Path, os_name: str, program: str
248
+ ) -> list[tuple[str, Path]]:
249
+ """Every installed version of `program` found under `tws_path`, verified
250
+ by a `jars/` subdirectory. Layout differs by OS and program: TWS lives at
251
+ `Trader Workstation *` (macOS) or as top-level directories excluding
252
+ `ibgateway/` (Linux); Gateway lives at `IB Gateway *` (macOS) or under
253
+ `ibgateway/` (Linux). Returns `(version, path)` pairs. `program`'s own
254
+ tree only -- `_list_version_candidates` calls this twice for a TWS
255
+ request, once for its own tree and once for the Gateway tree as
256
+ fallback."""
257
+ candidates: list[tuple[str, Path]] = []
258
+ if os_name == "macos":
259
+ name = _MACOS_PROGRAM_NAMES[program]
260
+ for entry in sorted(tws_path.glob(f"{name} *")):
261
+ if entry.is_dir() and (entry / "jars").is_dir():
262
+ candidates.append((entry.name.removeprefix(f"{name} "), entry))
263
+ elif program == "gateway":
264
+ gw_dir = tws_path / "ibgateway"
265
+ if gw_dir.is_dir():
266
+ for entry in sorted(gw_dir.iterdir()):
267
+ if entry.is_dir() and (entry / "jars").is_dir():
268
+ candidates.append((entry.name, entry))
269
+ elif tws_path.is_dir():
270
+ for entry in sorted(tws_path.iterdir()):
271
+ if (
272
+ entry.name != "ibgateway"
273
+ and entry.is_dir()
274
+ and (entry / "jars").is_dir()
275
+ ):
276
+ candidates.append((entry.name, entry))
277
+ return candidates
278
+
279
+
280
+ def _list_version_candidates(
281
+ tws_path: Path, os_name: str, program: str
282
+ ) -> list[tuple[str, Path]]:
283
+ """Every installed version found under `tws_path`, verified by a `jars/`
284
+ subdirectory -- the same check `_build_classpath` itself makes, so a
285
+ candidate this returns is guaranteed launchable. Returns `(version,
286
+ program_path)` pairs, sorted by directory name per tree for a
287
+ deterministic iteration order (not by version -- `_detect_tws_version`
288
+ does that numerically once it has the final candidate list).
289
+
290
+ Shares `_resolve_program_path`'s TWS->Gateway fallback trigger: when a
291
+ TWS request finds no TWS install at all, the Gateway installs become the
292
+ candidate pool. `tws_channel`'s filter is applied afterwards, in
293
+ `_detect_tws_version`, uniformly across whichever pool -- a channel
294
+ mismatch is a config error surfaced by that filter, never silently
295
+ relaxed."""
296
+ candidates = _scan_install_dirs(tws_path, os_name, program)
297
+ if candidates:
298
+ return candidates
299
+ if program == "tws":
300
+ return _scan_install_dirs(tws_path, os_name, "gateway")
301
+ return candidates
302
+
303
+
304
+ def _version_sort_key(version: str) -> tuple[int, ...]:
305
+ """Numeric comparison, not lexical -- handles both macOS's dotted `"10.50"`
306
+ and the Windows/Linux single-integer `"1050"` style without assuming
307
+ either format; extracts every digit run and compares them as a tuple of
308
+ ints (`"10.50"` -> `(10, 50)`, `"1050"` -> `(1050,)`)."""
309
+ return tuple(int(part) for part in re.findall(r"\d+", version))
310
+
311
+
312
+ def _read_i4j_variable(install4j_dir: Path, name: str) -> str | None:
313
+ """A plain install4j `<variable name="{name}" value="..." />` entry --
314
+ distinct from `_read_jxbrowser_key`'s own regex, which pulls a `-D` flag
315
+ out of the much larger `javaOptions` variable's own value blob (two
316
+ different shapes in the same `i4jparams.conf` file). Used for `channel`,
317
+ which each installed version carries its own real value for, not a fixed
318
+ label."""
319
+ conf = install4j_dir / "i4jparams.conf"
320
+ if not conf.is_file():
321
+ return None
322
+ match = re.search(rf'name="{re.escape(name)}" value="([^"]*)"', conf.read_text())
323
+ return match.group(1) if match else None
324
+
325
+
326
+ def _candidates_for_channel(
327
+ tws_path: Path, os_name: str, program: str, channel: str | None
328
+ ) -> list[tuple[str, Path]]:
329
+ """`_list_version_candidates`'s output, filtered by `channel` -- separated
330
+ so `_detect_tws_version` can retry a TWS request against the Gateway pool
331
+ after the TWS pool comes up empty under the filter. A TWS install present
332
+ but on the wrong channel must not short-circuit that fallback."""
333
+ candidates = _list_version_candidates(tws_path, os_name, program)
334
+ if channel is not None:
335
+ candidates = [
336
+ (version, path)
337
+ for version, path in candidates
338
+ if _read_i4j_variable(path / ".install4j", "channel") == channel
339
+ ]
340
+ return candidates
341
+
342
+
343
+ def _detect_tws_version(
344
+ tws_path: Path, os_name: str, program: str, channel: str | None
345
+ ) -> str:
346
+ """Auto-detects the installed version of `program` under `tws_path`,
347
+ optionally filtered by `channel` -- a real desktop install can carry
348
+ several versions side by side, each on its own update channel, and
349
+ `jars/` alone can't tell them apart.
350
+
351
+ `channel`, if given, filters candidates by their own
352
+ `_read_i4j_variable(..., "channel")` first. When the requested-program
353
+ pool filters down to nothing, a TWS request retries against the Gateway
354
+ pool: the filter still applies uniformly there, so a channel mismatch is
355
+ still an error, but an empty TWS pool is not -- that's exactly when the
356
+ fallback should engage. Whatever's left (or the unfiltered list, if
357
+ `channel` is `None`) is resolved by picking the greatest version
358
+ numerically, logged clearly either way so it's never a silent choice.
359
+
360
+ Raises `LauncherError` when no candidate remains."""
361
+ candidates = _candidates_for_channel(tws_path, os_name, program, channel)
362
+ if not candidates and program == "tws":
363
+ candidates = _candidates_for_channel(tws_path, os_name, "gateway", channel)
364
+
365
+ if not candidates:
366
+ where = f"channel={channel!r} under {tws_path}" if channel else str(tws_path)
367
+ raise LauncherError(
368
+ f"no {program} installation found ({where}) -- set tws_version "
369
+ "explicitly, or check tws_path/tws_channel"
370
+ )
371
+
372
+ if len(candidates) == 1:
373
+ version, _ = candidates[0]
374
+ logger.info("IBController > auto-detected %s version: %s", program, version)
375
+ return version
376
+
377
+ chosen_version, _ = max(candidates, key=lambda vp: _version_sort_key(vp[0]))
378
+ logger.info(
379
+ "IBController > multiple %s installations found under %s (%s)%s -- using %s "
380
+ "(the greatest)",
381
+ program,
382
+ tws_path,
383
+ ", ".join(version for version, _ in candidates),
384
+ f", channel={channel!r}" if channel else "",
385
+ chosen_version,
386
+ )
387
+ return chosen_version
388
+
389
+
390
+ def _build_classpath(program_path: Path, install4j_dir: Path, agent_jar: Path) -> str:
391
+ jars_dir = program_path / "jars"
392
+ if not jars_dir.is_dir():
393
+ raise LauncherError(
394
+ f"IBController > {jars_dir} not found -- is it installed under "
395
+ "{program_path.parent}?"
396
+ )
397
+ jars = sorted(str(p) for p in jars_dir.glob("*.jar"))
398
+ jars.append(str(install4j_dir / "i4jruntime.jar"))
399
+ jars.append(str(agent_jar))
400
+ return ":".join(jars)
401
+
402
+
403
+ def _find_java_bin(os_name: str, install4j_dir: Path, program_path: Path) -> Path:
404
+ """Two genuinely different cascades, not one path with a branch, for
405
+ locating the bundled java executable per OS."""
406
+ if os_name == "macos":
407
+ candidate = (
408
+ install4j_dir / "jre.bundle" / "Contents" / "Home" / "jre" / "bin" / "java"
409
+ )
410
+ if candidate.is_file():
411
+ return candidate
412
+ candidate = install4j_dir / "jre.bundle" / "Contents" / "Home" / "bin" / "java"
413
+ if candidate.is_file():
414
+ return candidate
415
+ else:
416
+ for cfg_name in ("pref_jre.cfg", "inst_jre.cfg"):
417
+ cfg = install4j_dir / cfg_name
418
+ if cfg.is_file():
419
+ candidate = Path(cfg.read_text().strip()) / "bin" / "java"
420
+ if candidate.is_file():
421
+ return candidate
422
+ candidate = program_path / "jre" / "bin" / "java"
423
+ if candidate.is_file():
424
+ return candidate
425
+ raise LauncherError(
426
+ f"no bundled java executable found (looked under {install4j_dir})"
427
+ )
428
+
429
+
430
+ def _read_vmoptions_file(path: Path) -> list[str]:
431
+ """Skips comments and `-D` lines -- `-D` properties are added explicitly
432
+ instead, see `_INSTALL4J_DPROPS_STATIC`."""
433
+ if not path.is_file():
434
+ return []
435
+ options = []
436
+ for raw_line in path.read_text().splitlines():
437
+ line = raw_line.strip()
438
+ if not line or line.startswith("#") or line.startswith("-D"):
439
+ continue
440
+ options.append(line)
441
+ return options
442
+
443
+
444
+ def _read_jxbrowser_key(install4j_dir: Path) -> str | None:
445
+ """Passkey (WebAuthn) 2FA renders in an embedded browser (JxBrowser),
446
+ which needs `-DjxBrowserKey=<license key>` to initialise at all. Read
447
+ dynamically since the key is version-specific."""
448
+ conf = install4j_dir / "i4jparams.conf"
449
+ if not conf.is_file():
450
+ return None
451
+ match = re.search(r"DjxBrowserKey=([^\"\s]+)", conf.read_text())
452
+ return match.group(1) if match else None
453
+
454
+
455
+ def _read_macos_vmoptions(app_bundle: Path) -> list[str]:
456
+ """`--add-opens`/`--add-exports` (JPMS strong encapsulation) live in the
457
+ `.app` bundle's `Info.plist` (install4j's `JavaVM`/`VMOptionArray`), not
458
+ in `ibgateway.vmoptions`. Read live from the installed app, not
459
+ hardcoded, since it can change per version.
460
+
461
+ Takes the `.app` bundle's own path directly (not `program_path`): the
462
+ bundle may already have been renamed by `_prevent_native_restart`, so
463
+ re-deriving `f"{program_path.name}.app"` here would silently miss it on
464
+ every relaunch after the first."""
465
+ app_plist = app_bundle / "Contents" / "Info.plist"
466
+ if not app_plist.is_file():
467
+ return []
468
+ with app_plist.open("rb") as f:
469
+ data = plistlib.load(f)
470
+ raw_options = data.get("JavaVM", {}).get("VMOptionArray", [])
471
+ return [opt for opt in raw_options if "${" not in opt and not opt.startswith("-D")]
472
+
473
+
474
+ def _read_linux_vmoptions(install4j_dir: Path) -> list[str]:
475
+ """`--add-opens`/`--add-exports` (JPMS strong encapsulation) live in
476
+ install4j's own `javaOptions` variable inside `i4jparams.conf` on Linux,
477
+ not in `tws.vmoptions`/`ibgateway.vmoptions`. Mirrors
478
+ `_read_macos_vmoptions`'s role for `Info.plist` on macOS, just a
479
+ different source file for the same install4j mechanism.
480
+
481
+ Unlike the macOS version, `-D` tokens are kept here rather than filtered:
482
+ on Linux this same variable is also the only place some installs carry
483
+ real `-D` fixes (e.g. Gateway's `-Djdk.xml.elementAttributeLimit`) and a
484
+ `-DjxBrowserKey`. A harmless duplicate of `_read_jxbrowser_key`'s own
485
+ value when both fire -- the JVM just takes the last one."""
486
+ raw = _read_i4j_variable(install4j_dir, "javaOptions")
487
+ if not raw:
488
+ return []
489
+ return [opt for opt in raw.split() if "${" not in opt]
490
+
491
+
492
+ def _prevent_native_restart(program_path: Path) -> Path:
493
+ """Renames the `.app` bundle so Gateway/TWS's own restart logic can't
494
+ find and re-invoke its native launcher stub
495
+ (`<name>.app/Contents/MacOS/JavaApplicationStub`) directly. Left alone,
496
+ a scheduled restart relaunches Gateway that way, completely outside
497
+ `launcher.py`, with no agent embedded -- ibcontroller silently loses
498
+ control of the instance on every restart. Renaming the bundle breaks
499
+ whatever internal reference Gateway's own restart logic uses to find and
500
+ re-invoke it, so only an explicit relaunch through this module can bring
501
+ it back.
502
+
503
+ Idempotent: a second call after the rename already happened is a no-op,
504
+ returning the same renamed path. Returns the `.app` bundle's current real
505
+ path either way, since `_read_macos_vmoptions` needs to read
506
+ `Info.plist` from wherever it actually lives now, renamed or not.
507
+
508
+ macOS only, called only under `os_name == "macos"` in `build_launch_plan`
509
+ -- see `_prevent_native_restart_linux` for the Linux equivalent, called
510
+ under the `else` branch there."""
511
+ original = program_path / f"{program_path.name}.app"
512
+ renamed = program_path / f"{program_path.name}-1.app"
513
+ if renamed.is_dir():
514
+ return renamed
515
+ if original.is_dir():
516
+ original.rename(renamed)
517
+ logger.warning(
518
+ "renamed %s -> %s to prevent Gateway's own restart logic from "
519
+ "relaunching itself outside ibcontroller's control",
520
+ original,
521
+ renamed,
522
+ )
523
+ return renamed
524
+ return original
525
+
526
+
527
+ def _prevent_native_restart_linux(program_path: Path, script_name: str) -> Path:
528
+ """`_prevent_native_restart`'s Linux equivalent -- renames the
529
+ install4j-generated native launch script (`ibgateway`/`tws`, a plain
530
+ shell script sitting directly in `program_path`) instead of a `.app`
531
+ bundle, for the same reason: left alone, Gateway/TWS's own
532
+ scheduled-restart logic invokes that script directly to relaunch itself,
533
+ completely outside `launcher.py`, with no agent embedded -- ibcontroller
534
+ loses control of the instance on every restart. Renaming the script
535
+ breaks whatever internal reference that restart logic uses to find and
536
+ re-invoke it, so only an explicit relaunch through this module can bring
537
+ the instance back.
538
+
539
+ Idempotent, matching `_prevent_native_restart`: a second call after the
540
+ rename already happened is a no-op, returning the same renamed path.
541
+ Unlike the macOS version, the return value doesn't need to be read back by
542
+ a vmoptions reader -- `_read_linux_vmoptions` reads from
543
+ `.install4j/i4jparams.conf`, which doesn't move when this script is
544
+ renamed -- so callers only need this for its side effect."""
545
+ original = program_path / script_name
546
+ renamed = program_path / f"{script_name}-1"
547
+ if renamed.exists():
548
+ return renamed
549
+ if original.exists():
550
+ original.rename(renamed)
551
+ logger.warning(
552
+ "renamed %s -> %s to prevent %s's own restart logic from "
553
+ "relaunching itself outside ibcontroller's control",
554
+ original,
555
+ renamed,
556
+ script_name,
557
+ )
558
+ return renamed
559
+ return original
560
+
561
+
562
+ def build_launch_plan(
563
+ config: Config,
564
+ agent_jar: str | Path,
565
+ *,
566
+ os_name: str | None = None,
567
+ runtime_dir: str | Path | None = None,
568
+ restart_hash: str | None = None,
569
+ ) -> LaunchPlan:
570
+ """The pure, filesystem-only half of launching -- resolves everything
571
+ needed to spawn the agent process (install location, JRE, classpath, JVM
572
+ options), without spawning anything.
573
+
574
+ `runtime_dir` is where the agent's own command/event sockets are created --
575
+ resolved via `app_dirs.resolve_runtime_dir()` (platformdirs) unless a
576
+ caller overrides it (tests do, to avoid depending on the real
577
+ OS-specific runtime location). Java never derives this path itself --
578
+ `AgentMain` takes the command-socket path as a plain CLI argument -- so
579
+ this is the one place the convention lives.
580
+
581
+ `restart_hash`, when given (`login.find_autorestart_hash`'s return
582
+ value), adds `-Drestart=<hash>` to the command -- lets Gateway resume the
583
+ session after a scheduled restart without a full 2FA prompt."""
584
+ os_name = os_name or _os_name()
585
+ sock_dir = Path(runtime_dir) if runtime_dir is not None else resolve_runtime_dir()
586
+ sock_dir.mkdir(parents=True, exist_ok=True)
587
+ tws_path = _resolve_tws_path(config, os_name)
588
+ settings_dir = resolve_tws_settings_path(config)
589
+ settings_dir.mkdir(parents=True, exist_ok=True)
590
+ _ensure_jts_ini(settings_dir, is_gateway=config.program.lower() == "gateway")
591
+ tws_version = config.tws_version or _detect_tws_version(
592
+ tws_path, os_name, config.program.lower(), config.tws_channel
593
+ )
594
+ program_path, resolved_program = _resolve_program_path(
595
+ config.program.lower(), os_name, tws_path, tws_version
596
+ )
597
+ if resolved_program != config.program.lower():
598
+ logger.warning(
599
+ "IBController > no %s installation found (tws_version=%s, tws_path=%s) -- "
600
+ "falling back to the %s installation %s and running it as %s "
601
+ "(IBC's own ibcstart.sh fallback)",
602
+ config.program,
603
+ tws_version,
604
+ tws_path,
605
+ resolved_program,
606
+ program_path,
607
+ config.program,
608
+ )
609
+ install4j_dir = program_path / ".install4j"
610
+ classpath = _build_classpath(program_path, install4j_dir, Path(agent_jar))
611
+ java_bin = _find_java_bin(os_name, install4j_dir, program_path)
612
+
613
+ program = config.program.lower()
614
+ vmoptions_file = program_path / (
615
+ "ibgateway.vmoptions" if resolved_program == "gateway" else "tws.vmoptions"
616
+ )
617
+ vm_options = _read_vmoptions_file(vmoptions_file)
618
+ vm_options.extend(_INSTALL4J_DPROPS_STATIC)
619
+ if config.java_heap_size:
620
+ # Deterministic override: strip the installed file's -Xmx, don't rely
621
+ # on "last -Xmx wins" JVM behavior.
622
+ vm_options = [opt for opt in vm_options if not opt.startswith("-Xmx")]
623
+ vm_options.append(f"-Xmx{config.java_heap_size}")
624
+ channel = _read_i4j_variable(install4j_dir, "channel") or config.tws_channel
625
+ vm_options.append(f"-Dchannel={channel}")
626
+
627
+ jxbrowser_key = _read_jxbrowser_key(install4j_dir)
628
+ if jxbrowser_key:
629
+ vm_options.append(f"-DjxBrowserKey={jxbrowser_key}")
630
+
631
+ if os_name == "macos":
632
+ app_bundle = _prevent_native_restart(program_path)
633
+ vm_options.extend(_read_macos_vmoptions(app_bundle))
634
+ else:
635
+ script_name = "ibgateway" if resolved_program == "gateway" else "tws"
636
+ _prevent_native_restart_linux(program_path, script_name)
637
+ vm_options.extend(_read_linux_vmoptions(install4j_dir))
638
+
639
+ # Agent-side logging (java.util.logging, AgentMain.configureLogging, 2026-09-08):
640
+ # its own per-instance file under the same shared log dir Python uses, so the
641
+ # streams (ibcontroller-{instance}.log / gateway-{instance}.log / this file)
642
+ # never share a file.
643
+ vm_options.append(
644
+ f"-Dibcontroller.logfile="
645
+ f"{Path(config.log_dir) / f'ibcontroller-java-agent-{config.instance}.log'}"
646
+ )
647
+ vm_options.append(
648
+ f"-Dibcontroller.log.level={logging.getLevelName(config.log_level)}"
649
+ )
650
+
651
+ entry_class = _ENTRY_CLASSES[program]
652
+ command_socket_path = str(
653
+ sock_dir / f"ibcontroller-agent-{config.instance}-cmd.sock"
654
+ )
655
+ event_socket_path = str(
656
+ sock_dir / f"ibcontroller-agent-{config.instance}-events.sock"
657
+ )
658
+
659
+ command = [
660
+ str(java_bin),
661
+ *vm_options,
662
+ f"-DjtsConfigDir={settings_dir}",
663
+ *([f"-Drestart={restart_hash}"] if restart_hash is not None else []),
664
+ "-cp",
665
+ classpath,
666
+ "ibcontroller.agent.AgentMain",
667
+ command_socket_path,
668
+ entry_class,
669
+ str(settings_dir),
670
+ ]
671
+ return LaunchPlan(
672
+ java_bin=java_bin,
673
+ command=command,
674
+ command_socket_path=command_socket_path,
675
+ event_socket_path=event_socket_path,
676
+ settings_dir=str(settings_dir),
677
+ )
678
+
679
+
680
+ async def _wait_for_ready(command_socket_path: str, timeout: float) -> None:
681
+ """Matches this project's own L1 framing exactly: "ping succeeding means
682
+ the agent process is up." Retries a real `ping`, not just a raw connect --
683
+ a bare connect succeeding doesn't prove the agent's own accept loop has
684
+ picked the connection up yet (AF_UNIX accepts into the kernel backlog
685
+ regardless), `ping` does."""
686
+ loop = asyncio.get_running_loop()
687
+ deadline = loop.time() + timeout
688
+ last_error: Exception | None = None
689
+ while loop.time() < deadline:
690
+ probe = AgentCommandConnection(command_socket_path)
691
+ try:
692
+ await probe.connect()
693
+ await probe.ping()
694
+ return
695
+ except (OSError, AgentClientError) as exc:
696
+ last_error = exc
697
+ finally:
698
+ await probe.close()
699
+ await asyncio.sleep(0.2)
700
+ raise LauncherError(
701
+ f"agent at {command_socket_path} did not respond to ping within {timeout}s"
702
+ ) from last_error
703
+
704
+
705
+ async def _drain_stdout(
706
+ process: asyncio.subprocess.Process, stdout_logger: logging.Logger
707
+ ) -> None:
708
+ """Reads the subprocess's stdout (Gateway's own console/log4j output,
709
+ `stderr` redirected into the same stream) until EOF, i.e. until the
710
+ process itself exits -- see `launch_instance`'s own callout for why this
711
+ exists at all: an unread pipe eventually blocks the writer.
712
+
713
+ Each line is handed to `stdout_logger` (built by
714
+ `logging_setup.configure_gateway_stdout`) rather than written to a file
715
+ directly -- the actual write happens on that logger's own listener
716
+ thread, off this coroutine, the same way `configure_logging`/
717
+ `configure_trace` already keep their own writes off the event loop."""
718
+ assert process.stdout is not None
719
+ while True:
720
+ line = await process.stdout.readline()
721
+ if not line:
722
+ return
723
+ stdout_logger.info(line.decode("utf-8", errors="replace").rstrip("\n"))
724
+
725
+
726
+ async def launch_instance(
727
+ config: Config,
728
+ agent_jar: str | Path,
729
+ *,
730
+ ready_timeout: float = 30.0,
731
+ restart_hash: str | None = None,
732
+ ) -> LaunchedInstance:
733
+ """Becomes the parent of the agent's own JVM process. Waits for a real
734
+ `ping` before returning, then builds and starts the real `Dispatcher`
735
+ callers actually use -- the process is terminated if readiness times out,
736
+ rather than handed back half-working.
737
+
738
+ `restart_hash` is passed straight through to `build_launch_plan` -- see
739
+ that function's own docstring for what it does and why.
740
+
741
+ `build_launch_plan` itself runs via `anyio.to_thread.run_sync`, not
742
+ called directly -- it's a long, purely synchronous chain of filesystem
743
+ calls, and this runs on every launch *and* every scheduled restart while
744
+ the Dispatcher's own event/command tasks are already live on the same
745
+ loop.
746
+
747
+ Configures the `ibcontroller` logging hierarchy first
748
+ (`configure_logging`/`configure_trace`), and the launched process's own
749
+ stdout logger (`configure_gateway_stdout`), so `_drain_stdout` has it
750
+ ready before the process is spawned below. `configure_trace` gates
751
+ purely on the `trace_enabled` toggle; its files (the raw wire trace) and
752
+ `ibcontroller.log` both land under `config.log_dir`.
753
+ """
754
+ configure_logging(
755
+ level=config.log_level,
756
+ log_dir=config.log_dir,
757
+ filename=f"ibcontroller-{config.instance}.log",
758
+ sink=config.log_sink,
759
+ )
760
+ configure_trace(
761
+ instance=config.instance,
762
+ enabled=config.trace_enabled,
763
+ trace_dir=config.log_dir,
764
+ )
765
+ stdout_logger = configure_gateway_stdout(
766
+ config.instance, config.log_dir, sink=config.log_sink
767
+ )
768
+ plan = await anyio.to_thread.run_sync(
769
+ functools.partial(
770
+ build_launch_plan, config, agent_jar, restart_hash=restart_hash
771
+ )
772
+ )
773
+ logger.info(
774
+ "IBController > launching instance %s (%s)",
775
+ config.instance,
776
+ config.trading_mode,
777
+ )
778
+ logger.info(
779
+ "IBController > launch command for %s: %s", config.instance, plan.command
780
+ )
781
+ process = await asyncio.create_subprocess_exec(
782
+ *plan.command,
783
+ stdout=asyncio.subprocess.PIPE,
784
+ stderr=asyncio.subprocess.STDOUT,
785
+ start_new_session=True,
786
+ # New session/process group: a terminal Ctrl-C's SIGINT is delivered to the
787
+ # whole foreground process group. Without this, the JVM child gets it
788
+ # directly, races our own signal handler, and exits via its own
789
+ # Shutdown/Terminator hook (returncode 128+SIGINT) instead of via
790
+ # clean_shutdown's File/Close -- skipping Gateway/TWS's own UI-close path
791
+ # (and its "Shutdown progress" dialog) entirely. This makes Ctrl-C reach
792
+ # the child only through clean_shutdown, same as a real File/Close.
793
+ )
794
+ # Must stay drained for the process's whole life: Gateway logs continuously
795
+ # (log4j, stderr=STDOUT too), and once the OS pipe buffer fills, any Java
796
+ # thread writing to stdout blocks forever. Self-terminates on EOF, no
797
+ # explicit cancellation needed.
798
+ stdout_drain_task = asyncio.ensure_future(_drain_stdout(process, stdout_logger))
799
+ try:
800
+ await _wait_for_ready(plan.command_socket_path, ready_timeout)
801
+ except LauncherError:
802
+ logger.warning("instance %s never became ready, terminating", config.instance)
803
+ process.terminate()
804
+ raise
805
+
806
+ cmd_conn = AgentCommandConnection(plan.command_socket_path)
807
+ event_conn = AgentEventConnection(plan.event_socket_path)
808
+ dispatcher = Dispatcher(
809
+ cmd_conn,
810
+ event_conn,
811
+ instance=config.instance,
812
+ )
813
+ await dispatcher.start()
814
+ logger.info(
815
+ "IBController > instance %s ready, pid=%s", config.instance, process.pid
816
+ )
817
+ return LaunchedInstance(
818
+ process=process,
819
+ dispatcher=dispatcher,
820
+ command_socket_path=plan.command_socket_path,
821
+ event_socket_path=plan.event_socket_path,
822
+ settings_dir=plan.settings_dir,
823
+ stdout_drain_task=stdout_drain_task,
824
+ )
825
+
826
+
827
+ async def clean_shutdown(
828
+ launched: LaunchedInstance,
829
+ *,
830
+ program: str,
831
+ logged_in: bool,
832
+ labels: ShutdownLabels,
833
+ timeout: float = 15.0,
834
+ ) -> None:
835
+ """Shutdown is not a process signal if login has completed -- invokes the
836
+ app's own File -> Exit (TWS) / File -> Close (Gateway) menu item first, so
837
+ it can save session state and upload settings. Only hard-terminates
838
+ directly when login never completed (nothing to save), or as a last
839
+ resort if the clean path hangs.
840
+
841
+ `labels` supplies the menu path -- see `labels.ShutdownLabels`'s own
842
+ docstring.
843
+
844
+ `logged_in` is the caller's own responsibility to know; this layer
845
+ doesn't track login state itself.
846
+
847
+ Uses `navigate_menu`, not `click`: "Close"/"Exit" are menu items, not
848
+ ordinary buttons -- `click`'s `findByAccessibleName` lookup can never
849
+ find them, since a `JMenu`'s dropdown lives in a `JPopupMenu`, outside
850
+ the ordinary component tree while the menu is closed. Goes through
851
+ `actions.navigate_menu` rather than a direct `dispatcher.send_command`
852
+ call, so a shutdown landing while the menu item is momentarily disabled
853
+ still retries instead of failing immediately; a short `timeout` bounds
854
+ that retry so a genuinely stuck menu still falls through to the
855
+ hard-kill fallback promptly.
856
+
857
+ Checks `launched.process.returncode` first: a process that already
858
+ exited on its own before this was ever called -- the user manually
859
+ closing Gateway, or a scheduled restart -- has no live command socket
860
+ left to send `navigate_menu` over, and no OS PID left for
861
+ `terminate()` to signal, so the already-exited branch only stops the
862
+ dispatcher. `terminate()` stays reserved for the "never logged in, but
863
+ still running" branch, where the process genuinely is still alive. The
864
+ `except` below is broadened to `OSError` as defense in depth for the
865
+ narrower race of the process dying between the `returncode` check and
866
+ the command actually being sent.
867
+ """
868
+ if launched.process.returncode is not None:
869
+ logger.info(
870
+ "IBController > shutting down (process already exited) -- nothing to "
871
+ "terminate"
872
+ )
873
+ await launched.dispatcher.stop()
874
+ return
875
+
876
+ if not logged_in:
877
+ logger.info(
878
+ "IBController > shutting down (never logged in) -- terminating directly"
879
+ )
880
+ launched.process.terminate()
881
+ await launched.dispatcher.stop()
882
+ return
883
+
884
+ target = (
885
+ labels.gateway_menu_path
886
+ if program.lower() == "gateway"
887
+ else labels.tws_menu_path
888
+ )
889
+ try:
890
+ await navigate_menu(launched.dispatcher, target, timeout=5.0)
891
+ except (AgentClientError, TimeoutError, OSError):
892
+ pass # best-effort -- the wait-then-terminate fallback below is what matters
893
+
894
+ try:
895
+ await asyncio.wait_for(launched.process.wait(), timeout=timeout)
896
+ logger.info(
897
+ "IBController > shut down cleanly via %s (returncode=%s)",
898
+ target,
899
+ launched.process.returncode,
900
+ )
901
+ except TimeoutError:
902
+ logger.warning(
903
+ "IBController > clean shutdown via %s did not exit within %ss -- "
904
+ "terminating",
905
+ target,
906
+ timeout,
907
+ )
908
+ launched.process.terminate()
909
+ await launched.dispatcher.stop()