Why ibg-controller looks the way it does. This is both a design
document and a record of what we learned during the initial spike — if
you're trying to understand why a particular piece is necessary,
look here.
The IBC replacement has to be in-process with Gateway's JVM. The Python controller is the brain; an in-JVM Java agent is the only path that can actually drive Swing.
IB Gateway's Swing-based login form rejects every external input mechanism we tried during the initial spike:
| Mechanism | Result |
|---|---|
Atspi.EditableText.set_text_contents(node, text) |
Returns False. No exception. Field stays empty. |
Atspi.EditableText.delete_text + insert_text |
Both return False. Field stays empty. |
Atspi.generate_keyboard_event(0, text, KeySynthType.STRING) |
No exception. Field stays empty. |
xdotool key/xdotool type (XTest) |
Runs cleanly (rc=0). Field stays empty. |
xdotool key --window <wid> (XSendEvent) |
Runs cleanly. Field stays empty. Swing's AWT subsystem rejects synthetic events. |
xdotool windowfocus + click + key + type (XSetInputFocus) |
Same. Field stays empty. |
| Click on the on-screen security keyboard via AT-SPI | Labels have no Action interface — can't click them. |
| Click those labels by bounds via xdotool | Bounds are -1,-1 -1x-1 (not rendered unless field is focused, and even then unreliable). |
Inside the JVM, JTextField.setText() works instantly. IBC works
because it runs as the JVM's main class; Lcstyle's ibctl works because
it uses -javaagent: to inject a Java agent. We use the same trick.
We originally drove component discovery and button clicks from the
Python side via AT-SPI2. v0.5.12 retired that path after the
java-atk-wrapper bridge was found to deadlock on JProgressBar.setValue
calls during login (intra-JVM, manifest as CCP LOCKOUT DETECTED
warnings — see CHANGELOG.md v0.5.12 for the SIGQUIT thread-dump
evidence). v0.5.13 removed the AT-SPI install steps and JRE
configuration from the image entirely.
So the architecture splits:
- Python controller, out-of-process: state machine, monitoring, re-auth loop, IBC-compat command server. All the logic and the bulk of the code.
- Java agent, in-process: loaded via
-javaagent:, exposes a Unix-socket protocol for window discovery, text input, button clicks, JTree navigation, and clean-logout dispatch. ~750 lines of Java.
Gateway is a Swing GUI app and refuses to start in java.awt.headless
mode. Xvfb provides a display. That much is standard.
But Xvfb alone isn't enough — we also need a window manager. Why?
Gateway's installer-bundled JVM routes some input through X11 focus
tracking, and Xvfb has no concept of "focused window" without a WM.
When we tried to inject events without a WM, they went nowhere. With
matchbox-window-manager (chosen for being tiny and not adding titlebars:
matchbox-window-manager -use_titlebar no), focus routing works.
Historical context. Pre-v0.5.12 the controller drove component discovery and button clicks via Python's
pyatspi2against Gateway'sorg.GNOME.Accessibility.AtkWrapperJava bridge. That required a D-Bus session bus, theat-spi-bus-launcher/at-spi2-registryddaemons, thelibatk-wrapper-java*packages, and a build-time write ofaccessibility.properties+ a copy oflibatk-wrapper.sointo$JAVA_HOME/lib/.v0.5.12 disabled the bridge in the JVM after a SIGQUIT thread dump showed
AtkWrapper$5.propertyChangedeadlocking onJProgressBar.setValueduring login (CCP LOCKOUT DETECTEDwas a misnomer — IBKR was never reached). v0.5.13 removed the install steps, the bus daemons, and the JRE accessibility configuration from the image. All component discovery, text input, and clicks now route through the in-JVM agent (see section 4 below). The JVM is still launched with-Djavax.accessibility.assistive_technologies=as defense-in-depth in case a base image ships the JAR pre-installed.The full thread-dump and rationale lives in CHANGELOG.md v0.5.12.
The controller launches Gateway via its bundled launcher script at
$TWS_PATH/ibgateway/<version>/ibgateway. That script is generated by
install4j. The controller passes two kinds of arguments:
Command-line -V flags (install4j variable substitution):
-VjtsConfigDir=/home/ibgateway/Jts
-VinstallerType=standalone
The install4j launcher has literal unsubstituted placeholders like
${installer:jtsConfigDir} in its embedded config. These get passed
to Java as -DjtsConfigDir=${installer:jtsConfigDir} — a broken
literal string. The -V flag substitutes the variable BEFORE the
launcher constructs the Java command line, so the placeholder resolves
correctly. Without -V, the first -D (the placeholder) wins over
any override in INSTALL4J_ADD_VM_PARAMS because Java uses the first
definition of a system property. This was the root cause of the
persistent auth-timeout bug found during initial deployment.
INSTALL4J_ADD_VM_PARAMS (additional JVM arguments):
--add-opens=java.base/java.util=ALL-UNNAMED
--add-opens=java.desktop/javax.swing=ALL-UNNAMED
... (19 module-access flags total)
-Djavax.accessibility.assistive_technologies=
-DjtsConfigDir=/home/ibgateway/Jts
-javaagent:/home/ibgateway/gateway-input-agent.jar=/tmp/gateway-input.sock
- Module-access flags: Gateway's auth and UI code uses reflection
into
java.desktopandjava.baseinternals. Java 17's module system blocks this by default. These are the same 19 flags IBC'sibcstart.shpasses; the install4j launcher's.vmoptionsfile does not include them. -Djavax.accessibility.assistive_technologies=(empty value) prevents the JRE from instantiating any AT-SPI assistive technology even on a base image that haslibatk-wrapper-javapre-installed (the controller's own image dropped that package in v0.5.13). See the historical-context block above for why.-DjtsConfigDir=is also set here as a belt-and-suspenders backup to the-Vflag (in case the install4j launcher doesn't support-Von some platform).-javaagent:loads our input agent at JVM startup. The argument after=is passed to the agent as itsagentArgs— we use it to pass the Unix socket path.
~750 lines of Java. Three core responsibilities (extended with additional commands in v0.2):
- Listen on a Unix domain socket (via JEP 380 — JDK 16+). Single client at a time, line-protocol, no concurrency.
- Walk
Window.getWindows()recursively viaContainer.getComponents()to find Swing components. Search byAccessibleContext.getAccessibleName()OR by window title (for components that have no accessible name — see the 2FA dialog section). - Dispatch operations on the EDT:
SwingUtilities.invokeAndWaitforSETTEXT/GETTEXT(synchronous),SwingUtilities.invokeLaterThread.sleep(50)forCLICK. We useinvokeLaterfor clicks becausedoClick()may open a modal dialog that blocks the EDT, andinvokeAndWaitwould deadlock. This threading rule was borrowed directly fromibctl.
Protocol commands (line-based, \n-terminated):
v0.1 (Phase 1) commands:
PING → OK pong
SETTEXT <name> <text...> → OK
GETTEXT <name> → OK <current text>
CLICK <name> → OK
LIST [substring] → OK\n<text|button> <name>\n...END
WINDOWS → OK\n<type> | <title> | modal=<bool>\n...END
WINDOW [title_substring] → OK\n<recursive component dump>\nEND
LABELS [substring] → OK\n[<window>] <label text>\nEND
SETTEXT_IN_WIN <title_substr>|<text> → OK
CLICK_IN_WIN <title_substr>|<button> → OK
v0.2 (Phase 2) additions:
GET_PID → OK <jvm_pid>
JTREE_SELECT_PATH <title>|<p1>/<p2>/... → OK selected=<path>
JCHECK <title>|<name>|<true|false> → OK unchanged=<v> | OK changed=<v>
SETTEXT_BY_LABEL <title>|<label>|<value> → OK set label=<label> value=<v>
v0.3 additions (multi-method 2FA device selector, #20/#21):
JLIST_SELECT <title_substr>|<item_text> → OK selected=<item> | ERR ...
Selects an item in the first JList of the first showing window whose
title contains the substring. Used to pick TWOFA_DEVICE in the 2FA
device-selector dialog (the same dialog shape IBC's SecondFactorDevice
handling targets). An exact match on the entry's value or its painted
label wins; otherwise a single entry that matches without regard to
case, runs of whitespace or HTML markup is accepted (issue #33). Two
entries matching that way are refused with ERR jlist_item_ambiguous,
and a miss returns ERR jlist_item_not_found want=… have=[a | b] listing
the entries. OK selected= reports the entry's painted label.
v0.16.1 addition (Read-Only API read-back, #69):
JSTATE <title_substr>|<name> → OK value=<true|false> | ERR ...
Reads a toggle button's selected state without clicking it, matching
the same control JCHECK would set (showing, enabled, exactly one
match). Used to read Read-Only API back after OK.
Why each was added:
GET_PID— returnsProcessHandle.current().pid(). Used by the Python controller to disambiguate its own Gateway JVM from any other Gateway JVM in the same container. Critical for dual mode (TRADING_MODE=both), where two Gateway JVMs run in parallel.JTREE_SELECT_PATH— navigates aJTreeto a slash-separated path by matchingnode.toString()at each level, expanding parents as it walks. Gateway's ConfigurationTree renders cells on demand via aCellRendererPane— the cell components aren't realContainerchildren, so external traversal + click can't reach them. The only way to drive the tree is through theJTreemodel API directly from inside the JVM.JCHECK— idempotent toggle of anyJToggleButton(coversJCheckBox,JRadioButton, and install4j subclasses) matched by accessible name or button text. Reads the current selected state before acting and skips the click if already at target, so applying the same config repeatedly is a no-op instead of a toggle-thrash.SETTEXT_BY_LABEL— sets a text field identified by its adjacentJLabel's text instead of by the field's own accessible name. Needed for spinners and unnamed fields (e.g. Gateway's "Master API client ID" field is aJSpinnerwhose editor has no accessible name of its own, but sits next to aJLabelwith the descriptive text). Also callscommitEdit()onJFormattedTextFieldeditors so spinner values actually propagate to the underlying model.
~2000 lines. The state machine, in rough order:
- Launch:
launch_gateway()— spawn the install4j launcher withINSTALL4J_ADD_VM_PARAMSset. SetsGATEWAY_PROCglobal so the command server'sRESTARThandler can terminate + re-launch the JVM in place. - Wait for agent:
agent_wait_ready()— poll the Unix socket untilPINGreturnsOK pong. Then callGET_PIDand store the JVM PID in theJVM_PIDglobal. - Resolve app handle:
find_app(APP_NAME_CANDIDATES, timeout=120, match_pid=JVM_PID)— returns a stub Accessible carrying the agent-reported JVM PID. Pre-v0.5.12 this polled the AT-SPI desktop tree; post-v0.5.12 the JVM doesn't register there, so the function short-circuits to a stub immediately. The handle is just used as a passthrough argument and for logging. - Login:
handle_login(app)— wait for the login frame viaWAIT_LOGIN_FRAME, select trading mode viaJCHECK, type credentials viaSETTEXT_LOGIN_USER/SETTEXT_LOGIN_PASSWORD, click Log In viaCLICK. Entirely agent-driven. - Post-login dialogs:
handle_post_login_dialogs(app)— polls up to 6s for any modal to appear, dumps each viaWINDOW, recognizesExisting session detectedby body text, clicksContinue LoginviaCLICK_IN_WIN. Leaves unrecognized modals alone. - 2FA:
handle_2fa(app)— ifTWOFACTOR_CODEis set, poll for a window titledSecond Factor. When present: if the dialog is the multi-method device selector (detected via theWINDOWdump — its heading is a JTextArea thatLABELScan't see), selectTWOFA_DEVICEviaJLIST_SELECT, click OK, and poll for the code-entry prompt (failing loudly if it never appears — IBKR rejects mid-challenge switches server-side, issue #20). Then read the "Enter code" prompt viaLABELSand only type if it matchesTWOFA_DEVICE(v0.7.0, issue #7): generate the TOTP, type viaSETTEXT_IN_WIN,CLICK_IN_WIN Second Factor|OK. Early-exits if the API port opens. Also opportunistically handles a late-arrivingExisting session detecteddialog in case the initialhandle_post_login_dialogspoll missed it. - Disclaimers:
dismiss_post_login_disclaimers()— click any "I understand and accept" style buttons. Conservative: never clicks bare "OK". - Wait for API port:
wait_for_api_port()— poll TCP port 4001 (live) / 4002 (paper) until it accepts a connection. This is the definitive readiness signal. - Post-login config (v0.2):
handle_post_login_config()— if any ofTWS_MASTER_CLIENT_ID/READ_ONLY_API/BYPASS_ORDER_PRECAUTIONS/AUTO_LOGOFF_TIME/AUTO_RESTART_TIMEis set, open Configure → Settings, navigate tree viaJTREE_SELECT_PATH, apply values viaSETTEXT_BY_LABELandJCHECK, commit viaCLICK_IN_WIN Configuration|OK. Gateway shows either Auto Log Off Time or Auto Restart Time in Lock and Exit, depending on which of its Auto logoff / Auto restart radio buttons is selected. With onlyAUTO_RESTART_TIMEset the handler selects Auto restart first (JCHECK), then sets the AM/PM radio to match the time. When the commit newly selects Auto restart, Gateway 10.50.1f answers OK with a modal Automatic Restart notice and holds the dialog open until it's answered (#69). The notice is titled "IBKR Gateway", like the main frame, so it's recognised by its text ("restart automatically on a daily basis") in aWINDOWdump of the modal dialogs.CLICK_IN_WINonly queues the OK click, so the handler then waits for the dialog to close, clicking the notice's OK (CLICK_IN_WIN IBKR Gateway|OK, which tries modal dialogs first) if it appears and no other modal shares its title. A dialog still open after 15 s meansALERT_CONFIG_NOT_APPLIED, the open dialogs' text in the log, and no read-back. If the agent never returns a window list, nothing is read back either (a WARNING says so). Once the dialog has closed, it re-opens it and reads back what it wrote: the Lock and Exit time, and Read-Only API and the API → Precautions bypass box (JSTATE). Gateway labels that box one of two ways depending on the account; the handler asksJSTATEfor each and sets the one that answers. - Command server (v0.2):
start_command_server()— ifCONTROLLER_COMMAND_SERVER_PORTis set, start a daemon thread listening on that TCP port for IBC-compat commands (STOP,RESTART,RECONNECTACCOUNT,ENABLEAPI,RECONNECTDATA). - Signal ready: touch
$CONTROLLER_READY_FILEsorun.shcan start socat. Defaults to/tmp/gateway_readyin single mode,/tmp/gateway_ready_{mode}in dual mode. - Monitor loop:
monitor_loop(app)— re-reads theGATEWAY_PROCandCURRENT_APPglobals each iteration (so a RESTART command can transparently swap them mid-loop). Checks JVM exit every iteration, heartbeats the API port every 30s, triggersattempt_reauth()after three consecutive port-closed heartbeats + a visible login dialog. On JVM exit,_recover_jvm_or_escalate()first asks whether install4j'srestarter.log(next to the launcher) was just written — Gateway's ownAUTO_RESTART_TIMErestart (issue #23). If so it adopts the JVM install4j brings up (_AdoptedProcess, a Popen-shaped stand-in for a process the controller didn't spawn; the new JVM inheritsINSTALL4J_ADD_VM_PARAMSand so binds the same agent socket) instead of relaunching; otherwise the v0.5.10 maintenance-window guard anddo_restart_in_place()follow.
When TRADING_MODE=both and USE_IBG_CONTROLLER=yes, run.sh
spawns start_process twice in sequence — once with live env, then
with paper env. Each invocation exports distinct per-instance values:
| Env var | Live | Paper |
|---|---|---|
TRADING_MODE |
live |
paper |
TWS_SETTINGS_PATH |
$TWS_PATH_live |
$TWS_PATH_paper |
GATEWAY_INPUT_AGENT_SOCKET |
/tmp/gateway-input-live.sock |
/tmp/gateway-input-paper.sock |
CONTROLLER_READY_FILE |
/tmp/gateway_ready_live |
/tmp/gateway_ready_paper |
CONTROLLER_COMMAND_SERVER_PORT |
(as configured) | (auto-offset by +1 to avoid collision) |
Each controller launches its own Gateway JVM in its own
JTS_CONFIG_DIR (Jts_live / Jts_paper) so both can write to
jts.ini, encrypted state, and launcher.log without stepping on
each other. The agent's GET_PID reply lets the Python controller
identify its own JVM unambiguously.
The legacy wait_for_controller_ready behavior (return non-zero
under set -Eeo pipefail) was changed to always return 0 on
timeout, because in dual mode a stuck live instance previously
crashed the whole container before paper even started.
A catalog of dead ends and non-obvious behaviors, so future maintainers don't retrace them.
After Gateway tears down the login frame and creates the post-login
main window, the AT-SPI application accessible's child_count returns
-1 and tree-walking finds nothing. Even a fresh Python AT-SPI
connection inside the same container sees the broken view. This is a
java-atk-wrapper bridge bug, not stale caching on the client side.
Workaround: for all post-login state detection, use the agent's
Window.getWindows() walk inside the JVM instead of AT-SPI. The
in-JVM view is the source of truth for the live Swing component tree.
See the table at the top. The JDK marks XTest events as synthetic and
Swing's AWT subsystem filters them. The accessibility EditableText
write methods return false without warning. You cannot type into IB
Gateway's JTextFields from outside the JVM. This is the entire reason
the Java agent exists.
On a fresh container with a minimal jts.ini, Gateway tries to connect
to IBKR's "misc URLs" server on port 4000 and the SSL handshake fails
with Remote host terminated the handshake. This cascades to the auth
dispatcher being interrupted. Gateway never progresses past
"Authenticating".
The cause is that Gateway needs a SupportsSSL cache entry in its
jts.ini to skip the TLS negotiation. The cache format is
<host>:<port>,<supported_bool>,<cache_date YYYYMMDD>,<secondary_flag>.
Workaround: when TWS_SERVER is set, the controller writes a
jts.ini that includes a SupportsSSL entry with today's date. After
a successful login, Gateway populates the cache itself and subsequent
starts don't need the pre-seeding.
A modal JDialog titled "Gateway" with only an OK button appears
during the login network round-trip. It is NOT an informational popup
to be dismissed — it's a "Connecting to server..." progress modal.
Clicking OK cancels the in-progress login and bounces back to the
login form.
Don't click bare "OK" on post-login modals unless you've verified
what the dialog is. The controller's handle_post_login_dialogs is
conservative: it dumps the dialog content for diagnostics, recognizes
specific dialogs by body text (existing-session-detected), and leaves
unrecognized ones alone.
Buttons like Show log, Clear, File, Configure, Help appear in
the main Gateway window shell from the moment Gateway starts — not
after a successful login. We initially used them as readiness markers
and got false positives. The only reliable readiness signal is the
API port being open.
The JTextField inside the "Second Factor Authentication" dialog is
a bare class (u after install4j obfuscation) with no accessible name
or description. You can't find it via AT-SPI by name, and the agent's
original SETTEXT command (which looks up by getAccessibleName())
can't find it either. We had to add SETTEXT_IN_WIN which takes a
window title substring and types into the first JTextComponent
found inside. This pattern handles every dialog whose fields have no
names.
An early check suggested libatk-wrapper-java-jni was only packaged
for amd64 on Ubuntu 24.04, not arm64. The check was wrong — running
apt-cache policy after rm -rf /var/lib/apt/lists/*, which returned
Candidate: (none) for every package because the lists were empty.
It IS in the noble arm64 archive as 0.40.0-3build2. Verified by
installing it and checking dpkg -L.
spike/PHASE1_FINDINGS.mdin the development repo — the raw spike writeup with every dead end and every step that worked.spike/PHASE0_FINDINGS.md— the feasibility spike that came before, establishing that AT-SPI2 exposes enough of Gateway's Swing tree to drive it at all.- Lcstyle/ibctl — the Rust+Java prior art. Different language choice but the same architectural conclusion about the in-JVM agent.
- IBC source — the canonical reference for Gateway's dialogs, even though IBC is being deprecated.
- java-atk-wrapper (GNOME) — the bridge that makes AT-SPI work for Swing.