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/__init__.py +0 -0
- ibcontroller/actions.py +273 -0
- ibcontroller/agent_client.py +466 -0
- ibcontroller/app_dirs.py +44 -0
- ibcontroller/cli.py +211 -0
- ibcontroller/config.py +410 -0
- ibcontroller/control_loop.py +636 -0
- ibcontroller/data/builtin_settings.toml +37 -0
- ibcontroller/data/ibcontroller.toml.example +178 -0
- ibcontroller/data/ibkr_settings.toml.example +135 -0
- ibcontroller/data/labels.json +81 -0
- ibcontroller/data/labels.json.example +44 -0
- ibcontroller/diagnostics.py +119 -0
- ibcontroller/dispatch.py +288 -0
- ibcontroller/ibcontroller-agent.jar +0 -0
- ibcontroller/labels.py +242 -0
- ibcontroller/launcher.py +909 -0
- ibcontroller/logging_setup.py +277 -0
- ibcontroller/login.py +578 -0
- ibcontroller/main.py +156 -0
- ibcontroller/py.typed +0 -0
- ibcontroller/recognisers.py +448 -0
- ibcontroller/schedule.py +157 -0
- ibcontroller/settings.py +554 -0
- py_ib_controller-0.0.0.dist-info/METADATA +490 -0
- py_ib_controller-0.0.0.dist-info/RECORD +30 -0
- py_ib_controller-0.0.0.dist-info/WHEEL +5 -0
- py_ib_controller-0.0.0.dist-info/entry_points.txt +2 -0
- py_ib_controller-0.0.0.dist-info/licenses/LICENSE.txt +176 -0
- py_ib_controller-0.0.0.dist-info/top_level.txt +1 -0
ibcontroller/launcher.py
ADDED
|
@@ -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()
|