Skip to content

Commit 46f57d9

Browse files
authored
Merge pull request #11 from padosoft/fix/proactive-reconnect
fix(transport): proactive reconnect — detect peer-closed socket before sending
2 parents 232cf41 + 1b7b5fc commit 46f57d9

6 files changed

Lines changed: 200 additions & 25 deletions

File tree

PROGRESS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ Target PR: to be opened against `main` once Phase 0 lands (or reuse a draft).
3737
- [x] Auto-reconnect on mid-session drop — SAFE policy (financial never replayed;
3838
RetryPolicy.hpp unit-tested; recover via sendLastResult 'G'). cpp 82/82.
3939
- [x] Money-safety reviewed by Copilot (no double-charge path) + README enterprise section.
40+
- [x] PROACTIVE reconnect (PR #11, fix/proactive-reconnect): ECR17/Nexi terminals
41+
close TCP between transactions → detect the peer-closed/half-open socket
42+
BEFORE sending (Kotlin isConnected() = write-free 1-byte peek-with-pushback on
43+
a PushbackInputStream; NOT sendUrgentData — OOB would corrupt a financial frame
44+
under SO_OOBINLINE). Removes the FALSE "transport disconnected during exchange"
45+
on financial commands; money-safety (RetryPolicy / 'G') untouched. Also reverted
46+
the LRC ETX-fold regression in Lcr.cpp (NOEXT, not STD) introduced by 44f178e.
47+
LESSON.md updated.
4048

4149
### Genuinely remaining (need hardware/macOS — documented in README roadmap)
4250
- iOS Swift transport verification: NO macOS CI runner. Swift written best-effort.

docs/LESSON.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,41 @@
8080
- **Emit `DISCONNECTED` on a failed connect**, else listeners stay stuck on
8181
`CONNECTING`. `connect()` delegates to `ensureConnected()` which emits
8282
CONNECTING→(CONNECTED | DISCONNECTED on throw).
83+
- **ECR17/Nexi terminals close the TCP socket BETWEEN transactions → detect the
84+
drop PROACTIVELY (before sending), not reactively (after).** Observed on a real
85+
device: financial commands (verifyCard/pay) INTERMITTENTLY failed with
86+
"transport disconnected during exchange" while safe commands (status/totals)
87+
succeeded — apparent "works once, fails next". Root cause: the terminal closes
88+
the socket after a transaction (and the Kotlin reader thread may not have
89+
observed the EOF `read()<0` yet — a race), so `isConnected()` returned `true`
90+
for a half-open socket. The command was then SENT on a dead socket; the read
91+
failed MID-exchange; `runTransaction`'s catch reconnected and applied the
92+
money-safety RetryPolicy → safe/idempotent ops were replayed (→ ok) but
93+
financial ops were (correctly) NOT replayed → a FALSE error surfaced. The
94+
reactive reconnect left a fresh socket, so the user's NEXT manual attempt landed
95+
on a good socket → the alternation. The money-safety behavior was correct; the
96+
bug was discovering the drop AFTER the send instead of BEFORE. Fix: make Kotlin
97+
`isConnected()` a synchronous, NON-DESTRUCTIVE, WRITE-FREE liveness probe — a
98+
1-byte peek on a `PushbackInputStream` (short `soTimeout`): `read()==-1` ⇒ peer
99+
closed (dead); `SocketTimeoutException` ⇒ idle but alive; any read byte is
100+
`unread()` so a protocol byte is NEVER consumed. ⚠️ Do NOT use
101+
`socket.sendUrgentData(0xFF)` for this (the first version did): it WRITES a TCP
102+
out-of-band byte, and on a terminal with `SO_OOBINLINE` that 0xFF lands INLINE
103+
right before the next `STX` frame — corrupting a financial command. The probe
104+
must never put bytes on the peer's protocol stream. The reader thread and the
105+
probe share the input stream under one `ioLock` (reader uses a short read timeout
106+
so it releases the lock between reads); a single-shot `AtomicBoolean` makes a drop
107+
fire onDisconnect exactly once across both paths, and the reader/probe close the
108+
socket on a drop so `isConnected()`'s `isClosed` check is an immediate signal.
109+
When the probe trips it marks the socket dead, so the existing `ensureConnected()`
110+
(called at the start of every command) reconnects BEFORE the send and the
111+
financial command starts on a verified-live socket. Money-safety is UNCHANGED —
112+
RetryPolicy and sendLastResult ('G') recovery are untouched; we only removed the
113+
FALSE drop from a stale pre-send socket; a genuine mid-exchange drop still
114+
surfaces and is recovered via 'G'. iOS (`NWConnection.state == .ready`) reflects
115+
peer-close too but state updates are async (small residual race; no iOS CI →
116+
best-effort). Only reproducible by RUNNING against a real terminal — no build/unit
117+
CI catches it.
83118

84119
## Build wiring
85120
- **Nitro C++ HybridObject impl header MUST be named after `implementationClassName`

package/android/src/main/java/com/margelo/nitro/ecr17/HybridEcr17Transport.kt

Lines changed: 140 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ package com.margelo.nitro.ecr17
22

33
import com.margelo.nitro.core.ArrayBuffer
44
import com.margelo.nitro.core.Promise
5+
import java.io.IOException
6+
import java.io.PushbackInputStream
57
import java.net.InetSocketAddress
68
import java.net.Socket
9+
import java.net.SocketTimeoutException
10+
import java.util.concurrent.atomic.AtomicBoolean
711
import 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
}

package/cpp/Ecr17Client/HybridEcr17Client.cpp

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,8 +318,16 @@ void HybridEcr17Client::ensureConnected() {
318318
// Run the transport JNI work under the app class loader (Android); inline on iOS.
319319
runOnJvmThread([&]() {
320320
ensureInit();
321+
// PROACTIVE reconnect: isConnected() performs a synchronous, non-destructive
322+
// liveness probe (Android: a 1-byte peek-with-pushback that detects a peer
323+
// FIN without writing to or consuming from the stream) so a peer-closed/
324+
// half-open socket — common because ECR17/Nexi terminals close TCP between
325+
// transactions — is detected HERE, before any command is sent. This is what
326+
// stops a financial command from being sent on a stale socket and then
327+
// hitting the (correct) money-safety "never replay" path with a FALSE
328+
// "transport disconnected".
321329
if (transport_->isConnected()) {
322-
return; // returns from this lambda onlyno further JNI needed
330+
return; // verified live — returns from this lambda only, no further JNI
323331
}
324332
// Auto-connect: block this worker thread until the native transport connects
325333
// (or throw on failure). keepAlive leaves the socket open for reuse.

package/cpp/Lcr/Lcr.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ uint8_t Lrc::compute(const std::vector<uint8_t>& payload, LrcMode mode) {
2424

2525
switch (mode) {
2626
case LrcMode::STX:
27-
case LrcMode::STD:
27+
case LrcMode::NOEXT:
2828
lrc ^= ETX;
2929
break;
3030

package/ios/HybridEcr17Transport.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ final class HybridEcr17Transport: HybridEcr17TransportSpec {
6767
}
6868

6969
func isConnected() throws -> Bool {
70+
// Used by the C++ client as a PRE-SEND liveness check so a command starts on
71+
// a verified-live socket (the Android transport probes the socket here). With
72+
// Network.framework the connection transitions to .failed/.cancelled when the
73+
// peer closes, so `.ready` is the closest equivalent. NOTE: state updates are
74+
// delivered asynchronously on `queue`, so a peer close observed between
75+
// transactions may take a moment to flip the state — a small residual race
76+
// not present on Android. Best-effort (no iOS CI); verify on a real build.
7077
return connection?.state == .ready
7178
}
7279

0 commit comments

Comments
 (0)