@@ -2,8 +2,12 @@ package com.margelo.nitro.ecr17
22
33import com.margelo.nitro.core.ArrayBuffer
44import com.margelo.nitro.core.Promise
5+ import java.io.IOException
6+ import java.io.PushbackInputStream
57import java.net.InetSocketAddress
68import java.net.Socket
9+ import java.net.SocketTimeoutException
10+ import java.util.concurrent.atomic.AtomicBoolean
711import kotlin.concurrent.thread
812
913/* *
@@ -16,12 +20,25 @@ class HybridEcr17Transport : HybridEcr17TransportSpec() {
1620 private var socket: Socket ? = null
1721 private var readerThread: Thread ? = null
1822
23+ // The reader and the liveness probe ([isConnected]) share one input stream;
24+ // PushbackInputStream lets the probe peek a byte (looking only for EOF) and push
25+ // it back so it is never consumed from the protocol stream.
26+ private var input: PushbackInputStream ? = null
27+
1928 @Volatile private var running = false
2029
2130 // True while a caller-initiated disconnect is in progress, so the reader's
2231 // finally block does not emit a spurious onDisconnect for an intentional close.
2332 @Volatile private var intentionalDisconnect = false
2433
34+ // Single-shot guard so a given drop fires onDisconnect exactly once, no matter
35+ // whether the reader thread or the liveness probe observes it first.
36+ private val disconnectEmitted = AtomicBoolean (false )
37+
38+ // Serializes access to the shared input stream between the reader thread and the
39+ // liveness probe so the two never `read()` concurrently.
40+ private val ioLock = Any ()
41+
2542 private var onDataCallback: ((ArrayBuffer ) -> Unit )? = null
2643 private var onDisconnectCallback: (() -> Unit )? = null
2744
@@ -30,40 +47,114 @@ class HybridEcr17Transport : HybridEcr17TransportSpec() {
3047 // Tear down any previous connection (and join its reader) before reconnecting.
3148 closeCurrent()
3249 intentionalDisconnect = false
50+ disconnectEmitted.set(false )
3351
3452 val s = Socket ()
3553 s.tcpNoDelay = true
3654 s.connect(InetSocketAddress (host, port.toInt()), timeoutMs.toInt())
55+ // A short read timeout lets the reader loop release `ioLock` periodically so
56+ // the liveness probe ([isConnected]) can run between reads; a timeout is not
57+ // an error, just "no data yet".
58+ s.soTimeout = READ_TIMEOUT_MS
3759 socket = s
60+ input = PushbackInputStream (s.getInputStream(), 1 )
3861 running = true
39- startReader(s )
62+ startReader()
4063 Unit
4164 }
4265 }
4366
44- private fun startReader (s : Socket ) {
67+ private fun startReader () {
4568 readerThread =
4669 thread(name = " ecr17-reader" , isDaemon = true ) {
4770 val buffer = ByteArray (4096 )
4871 try {
49- val input = s.getInputStream()
5072 while (running) {
51- val read = input.read(buffer)
52- if (read < 0 ) break
53- if (read > 0 ) {
54- onDataCallback?.invoke(ArrayBuffer .copy(buffer.copyOfRange(0 , read)))
73+ val read =
74+ synchronized(ioLock) {
75+ if (! running) return @synchronized SENTINEL_STOP
76+ try {
77+ input?.read(buffer) ? : - 1 // null stream -> treat as EOF
78+ } catch (_: SocketTimeoutException ) {
79+ SENTINEL_TIMEOUT // no data within READ_TIMEOUT_MS — keep looping
80+ }
81+ }
82+ when {
83+ read == SENTINEL_STOP -> break
84+ read == SENTINEL_TIMEOUT -> continue
85+ read < 0 -> break // EOF: peer closed the connection
86+ read > 0 ->
87+ onDataCallback?.invoke(ArrayBuffer .copy(buffer.copyOfRange(0 , read)))
5588 }
5689 }
57- } catch (_: Throwable ) {
58- // socket closed or read error
90+ } catch (_: IOException ) {
91+ // Expected on socket close / read error. Other throwables (Error, unexpected
92+ // RuntimeException) intentionally propagate so they're visible in dev; the
93+ // finally still fires the drop notification before the thread unwinds.
5994 } finally {
60- running = false
61- // Only signal disconnect for unexpected drops, not caller-initiated closes.
62- if (! intentionalDisconnect) {
63- onDisconnectCallback?.invoke()
64- }
95+ // Promptly close the socket so `isConnected()`'s `isClosed` check is an
96+ // immediate, reliable signal of the drop (no reliance on a write probe).
97+ markDropped()
98+ }
99+ }
100+ }
101+
102+ /* *
103+ * Non-destructive liveness probe used to detect a peer-closed / half-open socket
104+ * BEFORE a command is sent. ECR17/Nexi terminals routinely close the TCP socket
105+ * between transactions; `Socket.isConnected` stays `true` for such a half-open
106+ * socket, and the reader thread may not have observed the EOF yet — so without a
107+ * probe a command could be sent on a dead socket, fail MID-exchange, and (because
108+ * the money-safety RetryPolicy correctly refuses to replay a financial command)
109+ * surface a FALSE "transport disconnected" error.
110+ *
111+ * The probe peeks ONE byte with a tiny read timeout and immediately pushes it
112+ * back, so it NEVER consumes a protocol byte and NEVER writes anything to the
113+ * peer (unlike `sendUrgentData`, which can place an inline 0xFF before the next
114+ * STX frame on terminals with SO_OOBINLINE and corrupt a financial command). A
115+ * returned `-1` means the peer closed; a read timeout means the (idle) socket is
116+ * alive. It runs under `ioLock` so it never races the reader's `read()`.
117+ *
118+ * Money-safety is unchanged: this only removes the FALSE drop from a stale
119+ * pre-send socket; a genuine mid-exchange drop still surfaces and is recovered
120+ * via sendLastResult ('G').
121+ */
122+ override fun isConnected (): Boolean {
123+ val s = socket
124+ val pin = input
125+ if (! running || s == null || pin == null || ! s.isConnected || s.isClosed) {
126+ return false
127+ }
128+ return synchronized(ioLock) {
129+ if (! running || s.isClosed) return @synchronized false
130+ // Use a tiny probe timeout instead of the reader's READ_TIMEOUT_MS so a healthy
131+ // IDLE socket (the normal between-transactions case) returns "alive" almost
132+ // immediately rather than blocking every pre-send check for the full read
133+ // timeout. Safe to retune soTimeout here: we hold ioLock, so the reader thread
134+ // is not mid-read; we restore READ_TIMEOUT_MS before releasing the lock.
135+ try {
136+ s.soTimeout = PROBE_TIMEOUT_MS
137+ val b = pin.read() // EOF (-1) if peer closed; SocketTimeoutException if idle+alive
138+ if (b < 0 ) {
139+ markDropped()
140+ false
141+ } else {
142+ pin.unread(b) // push the (unexpected) byte back — never consumed/dropped
143+ true
144+ }
145+ } catch (_: SocketTimeoutException ) {
146+ true // idle but alive (no FIN received within the probe timeout)
147+ } catch (_: IOException ) {
148+ markDropped() // genuine I/O error: treat as dropped (other throwables propagate)
149+ false
150+ } finally {
151+ try {
152+ s.soTimeout = READ_TIMEOUT_MS // restore the reader loop's timeout
153+ } catch (_: IOException ) {
154+ // socket already closed by markDropped(); nothing to restore
65155 }
66156 }
157+ }
67158 }
68159
69160 /* * Closes the current socket and joins its reader thread (intentional close). */
@@ -76,6 +167,7 @@ class HybridEcr17Transport : HybridEcr17TransportSpec() {
76167 // ignore
77168 }
78169 socket = null
170+ input = null
79171 readerThread?.let { t ->
80172 try {
81173 t.join(1000 )
@@ -86,17 +178,27 @@ class HybridEcr17Transport : HybridEcr17TransportSpec() {
86178 readerThread = null
87179 }
88180
89- override fun disconnect () {
90- closeCurrent()
181+ /* *
182+ * Marks the connection as dropped, closes the socket so the drop is synchronously
183+ * observable, and fires onDisconnect exactly once. Safe to call from both the
184+ * reader thread and the liveness probe (single-shot via [disconnectEmitted]).
185+ */
186+ private fun markDropped () {
187+ running = false
188+ try {
189+ socket?.close()
190+ } catch (_: Throwable ) {
191+ // ignore
192+ }
193+ // Only signal disconnect for unexpected drops, not caller-initiated closes, and
194+ // only once per drop.
195+ if (! intentionalDisconnect && disconnectEmitted.compareAndSet(false , true )) {
196+ onDisconnectCallback?.invoke()
197+ }
91198 }
92199
93- override fun isConnected (): Boolean {
94- // `Socket.isConnected` stays true after the peer closes the connection, so it
95- // alone can't detect an unexpected drop. The reader thread clears `running`
96- // when the stream ends (or on an intentional close), making it the source of
97- // truth for an active, usable connection.
98- val s = socket
99- return running && s != null && s.isConnected && ! s.isClosed
200+ override fun disconnect () {
201+ closeCurrent()
100202 }
101203
102204 override fun send (bytes : ArrayBuffer ) {
@@ -113,4 +215,19 @@ class HybridEcr17Transport : HybridEcr17TransportSpec() {
113215 override fun setOnDisconnect (callback : () -> Unit ) {
114216 onDisconnectCallback = callback
115217 }
218+
219+ private companion object {
220+ // Reader-loop read timeout: short enough that the liveness probe never waits
221+ // long for `ioLock`, long enough to avoid busy-spinning.
222+ private const val READ_TIMEOUT_MS = 100
223+
224+ // Probe read timeout used by isConnected(): tiny so a healthy idle socket reports
225+ // "alive" near-instantly instead of paying READ_TIMEOUT_MS on every pre-send check.
226+ private const val PROBE_TIMEOUT_MS = 1
227+
228+ // Sentinels returned by the synchronized read block; kept below the real EOF
229+ // value (-1) so `read < 0` still catches a genuine EOF after these are handled.
230+ private const val SENTINEL_TIMEOUT = - 2
231+ private const val SENTINEL_STOP = - 3
232+ }
116233}
0 commit comments