Skip to content

Commit efea809

Browse files
Pigbibicodex
andcommitted
fix: classify gateway recovery epochs in Python
Co-Authored-By: Codex <noreply@openai.com>
1 parent e001c6d commit efea809

7 files changed

Lines changed: 624 additions & 60 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,8 @@ jobs:
2121
set -euo pipefail
2222
bash tests/test_install_2fa_bot_watcher.sh
2323
bash tests/test_wait_for_ib_gateway_ready.sh
24+
bash tests/test_gateway_recovery_scripts.sh
25+
bash tests/test_gateway_recovery_classifier_invocation.sh
26+
python3 tests/test_gateway_recovery_classifier.py
2427
bash tests/test_workflow_shared_config.sh
2528
bash tests/test_docker_compose_ports.sh
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
script_dir="$(cd "$(dirname "$0")" && pwd)"
5+
classifier_python="${IB_GATEWAY_RECOVERY_CLASSIFIER_PYTHON:-python3}"
6+
7+
exec "${classifier_python}" "${script_dir}/gateway_recovery_classifier.py" "$@"
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
"""Pure, bounded classifier for one immutable IB Gateway recovery epoch snapshot."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import re
7+
import sys
8+
from dataclasses import dataclass
9+
from datetime import datetime, timezone
10+
from enum import StrEnum
11+
from typing import Iterable, TextIO
12+
13+
14+
MAX_SNAPSHOT_EVENTS = 512
15+
MAX_SNAPSHOT_BYTES = 256 * 1024
16+
MAX_LINE_BYTES = 4096
17+
18+
DOCKER_TIMESTAMP = re.compile(
19+
r"^(?P<date>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(?P<fraction>\d{1,9}))?Z(?:\s|$)"
20+
)
21+
FILE_TIMESTAMP = re.compile(
22+
r"^(?P<date>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?:(?P<separator>[.,:])(?P<fraction>\d{1,9}))?(?:\s|$)"
23+
)
24+
TIMESTAMP_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}[ T]")
25+
PROGRESS_MARKER = re.compile(
26+
r"IBC: (Starting Gateway|Login attempt|Second Factor Authentication|Login has completed|"
27+
r"Configuration tasks completed|Found Gateway main window|Getting config dialog|Getting main window)|"
28+
r"Authentication window found|Auto-fill submitted|Passed token authentication|"
29+
r"Authentication completed|Security code:",
30+
re.IGNORECASE,
31+
)
32+
AUTH_CONTEXT_MARKER = re.compile(r"login|authentication|security code|token authentication", re.IGNORECASE)
33+
EXPLICIT_TERMINAL_MARKER = re.compile(
34+
r"IBC closing because login has not completed|"
35+
r"(?:authentication|login).*(?:timed out|timeout|failed)|"
36+
r"(?:timed out|timeout|failed).*(?:authentication|login)",
37+
re.IGNORECASE,
38+
)
39+
GENERIC_DISCONNECT_MARKER = re.compile(r"connection reset by peer|server disconnected", re.IGNORECASE)
40+
41+
42+
class SnapshotError(ValueError):
43+
"""The primitive event snapshot cannot be safely classified."""
44+
45+
46+
class Decision(StrEnum):
47+
READY = "ready"
48+
TERMINAL = "terminal"
49+
PROGRESS = "progress"
50+
NONE = "none"
51+
INVALID = "invalid"
52+
53+
54+
@dataclass(frozen=True)
55+
class Epoch:
56+
container_id: str
57+
started_at: str
58+
old_container_id: str | None = None
59+
replacement_identity: bool = False
60+
61+
def __post_init__(self) -> None:
62+
if not self.container_id:
63+
raise SnapshotError("missing epoch container identity")
64+
if self.replacement_identity and (
65+
not self.old_container_id or self.old_container_id == self.container_id
66+
):
67+
raise SnapshotError("invalid replacement identity")
68+
parse_rfc3339_nanos(self.started_at)
69+
70+
@property
71+
def started_key(self) -> tuple[datetime, int]:
72+
return parse_rfc3339_nanos(self.started_at)
73+
74+
75+
@dataclass(frozen=True)
76+
class PrimitiveEvent:
77+
source: str
78+
container_id: str
79+
line: str
80+
81+
82+
@dataclass(frozen=True)
83+
class FrozenSnapshot:
84+
events: tuple[PrimitiveEvent, ...]
85+
86+
87+
@dataclass(frozen=True)
88+
class ParsedTimestamp:
89+
key: tuple[datetime, int]
90+
precision: str
91+
92+
93+
def parse_rfc3339_nanos(value: str) -> tuple[datetime, int]:
94+
match = re.fullmatch(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z", value)
95+
if match is None:
96+
raise SnapshotError("invalid epoch StartedAt")
97+
seconds = datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
98+
return seconds, _fraction_to_nanos(match.group(2))
99+
100+
101+
def _fraction_to_nanos(fraction: str | None) -> int:
102+
if fraction is None:
103+
return 0
104+
return int((fraction + "0" * 9)[:9])
105+
106+
107+
def parse_event_timestamp(source: str, line: str) -> ParsedTimestamp | None:
108+
if source == "docker":
109+
match = DOCKER_TIMESTAMP.match(line)
110+
if match is None:
111+
return _reject_malformed_timestamp(line)
112+
seconds = datetime.strptime(match.group("date"), "%Y-%m-%dT%H:%M:%S").replace(
113+
tzinfo=timezone.utc
114+
)
115+
return ParsedTimestamp((seconds, _fraction_to_nanos(match.group("fraction"))), "exact")
116+
if source == "file":
117+
match = FILE_TIMESTAMP.match(line)
118+
if match is None:
119+
return _reject_malformed_timestamp(line)
120+
seconds = datetime.strptime(match.group("date"), "%Y-%m-%d %H:%M:%S").replace(
121+
tzinfo=timezone.utc
122+
)
123+
fraction = match.group("fraction")
124+
precision = "seconds" if fraction is None else "fractional"
125+
return ParsedTimestamp((seconds, _fraction_to_nanos(fraction)), precision)
126+
raise SnapshotError("unknown snapshot source")
127+
128+
129+
def _reject_malformed_timestamp(line: str) -> ParsedTimestamp | None:
130+
if TIMESTAMP_PREFIX.match(line):
131+
raise SnapshotError("malformed timestamp")
132+
return None
133+
134+
135+
def freeze_snapshot(events: Iterable[PrimitiveEvent]) -> FrozenSnapshot:
136+
frozen: list[PrimitiveEvent] = []
137+
total_bytes = 0
138+
for event in events:
139+
if event.source not in {"docker", "file"}:
140+
raise SnapshotError("unknown snapshot source")
141+
encoded_size = len(event.line.encode("utf-8", errors="replace"))
142+
if encoded_size > MAX_LINE_BYTES:
143+
raise SnapshotError("snapshot line exceeds limit")
144+
total_bytes += encoded_size
145+
if total_bytes > MAX_SNAPSHOT_BYTES:
146+
raise SnapshotError("snapshot exceeds byte limit")
147+
frozen.append(event)
148+
if len(frozen) > MAX_SNAPSHOT_EVENTS:
149+
raise SnapshotError("snapshot exceeds event limit")
150+
return FrozenSnapshot(tuple(frozen))
151+
152+
153+
class RecoveryEpochMachine:
154+
"""Sticky terminal state scoped to one explicit recovery attempt."""
155+
156+
def __init__(self, epoch: Epoch) -> None:
157+
self.epoch = epoch
158+
self._terminal = False
159+
160+
def begin_new_attempt(self, epoch: Epoch) -> None:
161+
self.epoch = epoch
162+
self._terminal = False
163+
164+
def classify(self, snapshot: FrozenSnapshot, *, stable_ready: bool = False) -> Decision:
165+
if stable_ready:
166+
return Decision.READY
167+
if self._terminal:
168+
return Decision.TERMINAL
169+
try:
170+
decision = self._classify_frozen_snapshot(snapshot)
171+
except SnapshotError:
172+
return Decision.INVALID
173+
if decision is Decision.TERMINAL:
174+
self._terminal = True
175+
return decision
176+
177+
def _classify_frozen_snapshot(self, snapshot: FrozenSnapshot) -> Decision:
178+
progress_seen = False
179+
auth_context_seen = False
180+
generic_disconnect_seen = False
181+
182+
for event in snapshot.events:
183+
if event.container_id == self.epoch.old_container_id:
184+
continue
185+
if event.container_id != self.epoch.container_id:
186+
continue
187+
timestamp = parse_event_timestamp(event.source, event.line)
188+
if timestamp is None or not self._is_in_epoch(timestamp):
189+
continue
190+
if EXPLICIT_TERMINAL_MARKER.search(event.line):
191+
return Decision.TERMINAL
192+
if PROGRESS_MARKER.search(event.line):
193+
progress_seen = True
194+
if AUTH_CONTEXT_MARKER.search(event.line):
195+
auth_context_seen = True
196+
if GENERIC_DISCONNECT_MARKER.search(event.line):
197+
generic_disconnect_seen = True
198+
199+
if generic_disconnect_seen and auth_context_seen:
200+
return Decision.TERMINAL
201+
if progress_seen:
202+
return Decision.PROGRESS
203+
return Decision.NONE
204+
205+
def _is_in_epoch(self, timestamp: ParsedTimestamp) -> bool:
206+
epoch_seconds, epoch_nanos = self.epoch.started_key
207+
event_seconds, event_nanos = timestamp.key
208+
if event_seconds > epoch_seconds:
209+
return True
210+
if event_seconds < epoch_seconds:
211+
return False
212+
if timestamp.precision == "seconds":
213+
# A replacement ID proves the file stream belongs to this attempt,
214+
# but a seconds-only line cannot be ordered against StartedAt nanos.
215+
# Include it conservatively; without that identity fail closed.
216+
if self.epoch.replacement_identity:
217+
return True
218+
raise SnapshotError("ambiguous same-second file event")
219+
return event_nanos >= epoch_nanos
220+
221+
222+
def parse_protocol_snapshot(stream: TextIO) -> FrozenSnapshot:
223+
events: list[PrimitiveEvent] = []
224+
total_bytes = 0
225+
protocol_line_limit = MAX_LINE_BYTES + 256
226+
while True:
227+
raw_line = stream.readline(protocol_line_limit + 1)
228+
if not raw_line:
229+
break
230+
if len(raw_line) > protocol_line_limit or (
231+
len(raw_line) == protocol_line_limit and not raw_line.endswith("\n")
232+
):
233+
raise SnapshotError("snapshot protocol line exceeds limit")
234+
line = raw_line.rstrip("\n")
235+
if line.startswith("X\t"):
236+
raise SnapshotError("snapshot source failed")
237+
fields = line.split("\t", 2)
238+
if len(fields) != 3:
239+
raise SnapshotError("invalid snapshot protocol")
240+
source_code, container_id, message = fields
241+
source = {"D": "docker", "F": "file"}.get(source_code)
242+
if source is None:
243+
raise SnapshotError("unknown snapshot source")
244+
encoded_size = len(message.encode("utf-8", errors="replace"))
245+
if encoded_size > MAX_LINE_BYTES:
246+
raise SnapshotError("snapshot line exceeds limit")
247+
total_bytes += encoded_size
248+
if total_bytes > MAX_SNAPSHOT_BYTES:
249+
raise SnapshotError("snapshot exceeds byte limit")
250+
events.append(PrimitiveEvent(source=source, container_id=container_id, line=message))
251+
if len(events) > MAX_SNAPSHOT_EVENTS:
252+
raise SnapshotError("snapshot exceeds event limit")
253+
return FrozenSnapshot(tuple(events))
254+
255+
256+
def main() -> int:
257+
parser = argparse.ArgumentParser(description=__doc__)
258+
parser.add_argument("--epoch-container-id", required=True)
259+
parser.add_argument("--epoch-started-at", required=True)
260+
parser.add_argument("--old-container-id")
261+
parser.add_argument("--replacement-identity", action="store_true")
262+
args = parser.parse_args()
263+
try:
264+
epoch = Epoch(
265+
container_id=args.epoch_container_id,
266+
started_at=args.epoch_started_at,
267+
old_container_id=args.old_container_id,
268+
replacement_identity=args.replacement_identity,
269+
)
270+
snapshot = parse_protocol_snapshot(sys.stdin)
271+
print(RecoveryEpochMachine(epoch).classify(snapshot).value)
272+
except SnapshotError:
273+
print(Decision.INVALID.value)
274+
return 0
275+
276+
277+
if __name__ == "__main__":
278+
raise SystemExit(main())

0 commit comments

Comments
 (0)