SimCore
Shared transport-layer plumbing for the ADR-029 Train/IL/CTC RBC simulators
Toggle main menu visibility
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
2
site's C at all times, reconnecting independently, so a Train/IL sim
3
never visibly drops contact across a site failover: whichever site is
4
currently ONLINE is the only one that ever answers, but both sockets stay
5
open so there is nothing to notice or reconnect from the sim's own point
6
of view when ONLINE moves from one site to the other. See ADR-029's own
7
"Failover continuity" design note.
8
9
Transport: UDP with a HELLO/HELLO-ACK handshake (ADR-027), NOT plain TCP -
10
this project's own sapi_netlink POSIX backend
11
(safeAPIRBC2oo2/src/posix_backend/sapi_posix_backend_netlink.c) moved off
12
TCP project-wide; C's LISTEN side for these links speaks the exact same
13
protocol it already does for A/B. This module is a hand-ported Python
14
mirror of that CONNECT-side handshake (open_connect_udp() in that C
15
file) - 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.
21
Once handshake completes, every later send()/recv() on that socket is
22
exactly one RBC envelope datagram - sapi_netlink's own message-per-
23
datagram framing, matching RBC_ENVELOPE_WIRE_SIZE-sized reads.
24
25
First simple pass, not a general-purpose framing layer: each recv() is
26
expected to be exactly one whole datagram (true for UDP, unlike TCP's own
27
stream semantics) of exactly message_size bytes; a differently-sized
28
datagram is dropped (logged), not reassembled or truncated-and-kept.
29
"""
30
31
import
logging
32
import
select
33
import
selectors
34
import
socket
35
import
time
36
37
SITES = (
"WEST"
,
"EAST"
)
38
39
HELLO = 0xA5
40
HELLO_ACK = 0x5A
41
HELLO_PERIOD_S = 0.02
# matches POSIX_NETLINK_UDP_HELLO_PERIOD_MS (sapi_posix_backend_netlink.c)
42
HANDSHAKE_BUDGET_S = 0.3
# bounded per ensure_connected() call - see that method's own doc
43
44
45
class
DualHomedLink
:
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
54
def
ensure_connected
(self):
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
126
def
connected_sites
(self):
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
simcore.dual_link.DualHomedLink
Definition
dual_link.py:45
simcore.dual_link.DualHomedLink.sockets
dict sockets
Definition
dual_link.py:51
simcore.dual_link.DualHomedLink._handshake
_handshake(self, site)
Definition
dual_link.py:65
simcore.dual_link.DualHomedLink.connected_sites
connected_sites(self)
Definition
dual_link.py:126
simcore.dual_link.DualHomedLink.send_all
send_all(self, payload)
Definition
dual_link.py:145
simcore.dual_link.DualHomedLink.ensure_connected
ensure_connected(self)
Definition
dual_link.py:54
simcore.dual_link.DualHomedLink.__init__
__init__(self, log, targets, message_size)
Definition
dual_link.py:46
simcore.dual_link.DualHomedLink.log
log
Definition
dual_link.py:48
simcore.dual_link.DualHomedLink._close
_close(self, site)
Definition
dual_link.py:199
simcore.dual_link.DualHomedLink.targets
targets
Definition
dual_link.py:49
simcore.dual_link.DualHomedLink.disconnect
disconnect(self, site)
Definition
dual_link.py:134
simcore.dual_link.DualHomedLink.poll_recv
poll_recv(self, timeout=0.0)
Definition
dual_link.py:161
simcore.dual_link.DualHomedLink.message_size
message_size
Definition
dual_link.py:50
simcore.dual_link.DualHomedLink.sel
sel
Definition
dual_link.py:52
src
simcore
dual_link.py
Generated by
1.18.0