SimCore
Shared transport-layer plumbing for the ADR-029 Train/IL/CTC RBC simulators
Loading...
Searching...
No Matches
dual_link.py
Go to the documentation of this file.
1"""Dual-homed UDP link helper (ADR-029) - keeps one socket open to EACH
2site's C at all times, reconnecting independently, so a Train/IL sim
3never visibly drops contact across a site failover: whichever site is
4currently ONLINE is the only one that ever answers, but both sockets stay
5open so there is nothing to notice or reconnect from the sim's own point
6of view when ONLINE moves from one site to the other. See ADR-029's own
7"Failover continuity" design note.
8
9Transport: UDP with a HELLO/HELLO-ACK handshake (ADR-027), NOT plain TCP -
10this project's own sapi_netlink POSIX backend
11(safeAPIRBC2oo2/src/posix_backend/sapi_posix_backend_netlink.c) moved off
12TCP project-wide; C's LISTEN side for these links speaks the exact same
13protocol it already does for A/B. This module is a hand-ported Python
14mirror of that CONNECT-side handshake (open_connect_udp() in that C
15file) - keep the two in sync if that protocol ever changes:
16 - HELLO = 0xA5, HELLO_ACK = 0x5A, one byte each.
17 - CONNECT side: connect() a UDP socket (fixes the default peer address
18 only - no network I/O, no reachability signal by itself), then send
19 HELLO every ~20ms and poll for a 1-byte 0x5A reply until one arrives
20 or a bounded budget elapses.
21Once handshake completes, every later send()/recv() on that socket is
22exactly one RBC envelope datagram - sapi_netlink's own message-per-
23datagram framing, matching RBC_ENVELOPE_WIRE_SIZE-sized reads.
24
25First simple pass, not a general-purpose framing layer: each recv() is
26expected to be exactly one whole datagram (true for UDP, unlike TCP's own
27stream semantics) of exactly message_size bytes; a differently-sized
28datagram is dropped (logged), not reassembled or truncated-and-kept.
29"""
30
31import logging
32import select
33import selectors
34import socket
35import time
36
37SITES = ("WEST", "EAST")
38
39HELLO = 0xA5
40HELLO_ACK = 0x5A
41HELLO_PERIOD_S = 0.02 # matches POSIX_NETLINK_UDP_HELLO_PERIOD_MS (sapi_posix_backend_netlink.c)
42HANDSHAKE_BUDGET_S = 0.3 # bounded per ensure_connected() call - see that method's own doc
43
44
46 def __init__(self, log, targets, message_size):
47 """@param targets: {"WEST": (host, port), "EAST": (host, port)}"""
48 self.log = log
49 self.targets = targets
50 self.message_size = message_size
51 self.sockets = {site: None for site in SITES}
52 self.sel = selectors.DefaultSelector()
53
55 """Bounded (HANDSHAKE_BUDGET_S) HELLO/HELLO-ACK attempt for each
56 not-yet-connected site, per call - short enough not to stall the
57 sim's own overall pacing for long, retried forever (call this
58 every loop iteration) until it succeeds, matching this project's
59 own C-side reconnect-forever convention."""
60 for site in SITES:
61 if self.sockets[site] is not None:
62 continue
63 self._handshake(site)
64
65 def _handshake(self, site):
66 host, port = self.targets[site]
67 try:
68 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
69 s.setblocking(False)
70 s.connect((host, port)) # UDP connect(): fixes the peer locally only, no I/O - see this file's own doc
71 except OSError:
72 return
73
74 deadline = time.monotonic() + HANDSHAKE_BUDGET_S
75 next_hello = 0.0
76 got_ack = False
77 while time.monotonic() < deadline:
78 now = time.monotonic()
79 if now >= next_hello:
80 try:
81 s.send(bytes([HELLO]))
82 except OSError:
83 pass # ECONNREFUSED etc. - peer not up yet, keep retrying within this budget
84 next_hello = now + HELLO_PERIOD_S
85 remaining = min(next_hello - now, deadline - now)
86 if remaining > 0:
87 try:
88 r, _w, _x = select.select([s], [], [], max(remaining, 0.0))
89 except OSError:
90 break
91 if not r:
92 continue
93 try:
94 data = s.recv(1)
95 except OSError:
96 continue
97 if data == bytes([HELLO_ACK]):
98 got_ack = True
99 break
100 # Not our ACK (or a stray real envelope datagram arriving
101 # early, per this file's own header note on why the C side
102 # can start sending before it sees our own ACK) - keep
103 # waiting within the same budget; a non-ACK single byte is
104 # simply dropped here (there is no legitimate 1-byte RBC
105 # envelope, so this can't be mistaken for real traffic).
106
107 if got_ack:
108 # Drain any trailing 1-byte handshake packets in the socket buffer
109 while True:
110 try:
111 trailing = s.recv(1)
112 if not trailing:
113 break
114 except (BlockingIOError, OSError):
115 break
116 s.setblocking(False)
117 self.sockets[site] = s
118 self.sel.register(s, selectors.EVENT_READ, site)
119 self.log.info("connected to %s (%s:%d)", site, host, port)
120 else:
121 try:
122 s.close()
123 except OSError:
124 pass
125
127 """Returns the list of currently-connected site names - generic
128 transport-layer state a caller's own application layer can use
129 for its own purposes (e.g. a "which site do I currently believe
130 is reachable" heuristic); this module has no opinion on what
131 that purpose is."""
132 return [site for site in SITES if self.sockets[site] is not None]
133
134 def disconnect(self, site):
135 """Closes this site's connection right now, if open - the sim's
136 own main loop reconnects it automatically on its next
137 ensure_connected() call, per this project's reconnect-forever
138 convention (this file's own header). Public wrapper around the
139 same internal close a real send/recv failure already uses
140 (_close) - a test-harness-triggered disconnect and a genuine
141 network failure look identical from here on, deliberately: that
142 is exactly the condition a disconnect test wants to reproduce."""
143 self._close(site)
144
145 def send_all(self, payload):
146 """Sends @payload to every currently-connected site - both sites
147 must see identical inputs (mirrors this project's C-side
148 broadcast-to-both-A-and-B convention), since only whichever site
149 is ONLINE will actually act on it. Silently skips a not-yet-
150 connected site; closes+reconnects on a genuine send failure."""
151 for site in SITES:
152 s = self.sockets[site]
153 if s is None:
154 continue
155 try:
156 s.send(payload)
157 self.log.debug("sent %d bytes to %s", len(payload), site)
158 except OSError:
159 self._close(site)
160
161 def poll_recv(self, timeout=0.0):
162 """Yields (site, payload) for each ready socket - see this
163 module's own header for the "one recv() == one datagram" note.
164 Honors @timeout (actually sleeps for it) even while NEITHER site
165 is connected yet, rather than returning instantly - a caller that
166 relies on this call for its own loop's pacing (as every sim here
167 does) would otherwise busy-loop, spamming reconnect attempts as
168 fast as the CPU allows, whenever both sites are simultaneously
169 down (selectors.BaseSelector.select() itself provides no timeout
170 behavior at all when it has nothing registered to select on)."""
171 if not self.sel.get_map():
172 if timeout > 0.0:
173 time.sleep(timeout)
174 return
175 events = self.sel.select(timeout=timeout)
176 for key, _mask in events:
177 site = key.data
178 s = self.sockets.get(site)
179 if s is None:
180 continue
181 try:
182 data = s.recv(self.message_size)
183 except OSError:
184 self._close(site)
185 continue
186 if not data:
187 self.log.info("%s disconnected - will reconnect", site)
188 self._close(site)
189 continue
190 if len(data) != self.message_size:
191 if len(data) == 1 and data[0] in (HELLO, HELLO_ACK):
192 continue # Silently skip leftover 1-byte handshake datagrams
193 self.log.warning("%s: unexpected datagram size (%d/%d bytes) - dropping", site, len(data),
194 self.message_size)
195 continue
196 self.log.debug("received %d bytes from %s", len(data), site)
197 yield site, data
198
199 def _close(self, site):
200 s = self.sockets[site]
201 if s is not None:
202 try:
203 self.sel.unregister(s)
204 except KeyError:
205 pass
206 try:
207 s.close()
208 except OSError:
209 pass
210 self.sockets[site] = None