From cd074335a19349b58f2595eeef8bd6b2e81f3276 Mon Sep 17 00:00:00 2001 From: tekstrand Date: Tue, 25 Aug 2026 18:46:36 -0500 Subject: [PATCH] Moved rig control teardown off the main thread. Stopping the engine released PTT through a latch the main thread waited on with no timeout, and then closed the hamlib link, the serial bridges and the QMX and TruSDX sessions in line. With the radio gone those calls block: a wedged CAT command runs to hamlib's own timeout, and every HamlibRigControl method shares one monitor, so close() waits behind whatever is stuck. The USB bridge has the same shape, where close takes a write lock a stuck read still holds. Stopping now captures the rig references, hands them to the TX handler, which already owns rig I/O and queues them in order behind the wedged call, and returns. The transports are rebuilt in the same breath, so a restart never reaches a link that is still closing; that is why their construction moved out of onCreate and into createRigTransports. Unregistering the serial bridges stays on the main thread, because nativeUnregister clears one native global whichever instance calls it, and a deferred one could wipe the registration a restart had already made. The blocking release helper is gone, along with the ad-hoc thread that disconnected the network client. Verified on the emulator against a rigctld that accepts the connection and never answers. Before, the main thread sat in stopEngine for 5.06 seconds and the retry threw NetworkOnMainThreadException. Now stopping returns in 63 ms, the PTT release gives up on the TX handler five seconds later, and the next start reconnects. --- .../example/service/JS8EngineService.kt | 212 +++++++++--------- 1 file changed, 110 insertions(+), 102 deletions(-) diff --git a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt index a41c6de8..7c408764 100644 --- a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt +++ b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt @@ -40,7 +40,6 @@ import com.js8call.example.util.TxMessageClassifier import java.util.Calendar import java.util.Locale import java.util.TimeZone -import java.util.concurrent.CountDownLatch import java.util.concurrent.LinkedBlockingDeque import java.util.concurrent.TimeUnit @@ -249,59 +248,7 @@ class JS8EngineService : Service() { override fun onCreate() { super.onCreate() createNotificationChannel() - usbSerialBridge = UsbSerialBridge(applicationContext) - bluetoothSerialBridge = BluetoothSerialBridge(applicationContext) - trusdxDirectSerial = TruSdxDirectSerial(applicationContext) - qmxDirectSerial = QmxDirectSerial(applicationContext) - hamlibRigControl = HamlibRigControl() - trusdxSerialSession = trusdxDirectSerial?.let { direct -> - TruSdxSerialSession( - direct, - object : TruSdxSerialSession.Listener { - override fun onCatMessage(message: String) { - if (isTruSdxDiagnosticsEnabled()) { - Log.v(TAG, "TruSDX CAT <= $message") - } - } - - override fun onAudioFrame(samplesU8: ByteArray) { - handleTruSdxAudioFrame(samplesU8) - } - - override fun onParserResync(reason: String) { - trusdxParserResyncs += 1 - if (isTruSdxDiagnosticsEnabled()) { - Log.w(TAG, "TruSDX parser resync ($reason), count=$trusdxParserResyncs") - } - } - - override fun onIoError(message: String) { - Log.w(TAG, "TruSDX I/O error: $message") - trusdxConnected = false - broadcastError("TruSDX serial link lost") - } - - } - ) - } - qmxCatSession = qmxDirectSerial?.let { direct -> - QmxCatSession(direct, object : QmxCatSession.Listener { - override fun onFrequency(frequencyHz: Long) { - currentDialHz = frequencyHz - mainHandler.post { broadcastRadioFrequency(frequencyHz) } - } - - override fun onMessage(message: String) { - Log.d(TAG, "QMX CAT <= $message") - } - - override fun onIoError(message: String) { - Log.w(TAG, "QMX CAT I/O error: $message") - qmxConnected = false - mainHandler.post { broadcastError("QMX CAT link lost") } - } - }) - } + createRigTransports() txHandlerThread.start() txHandler = Handler(txHandlerThread.looper) txMonitorHandler = Handler(Looper.getMainLooper()) @@ -450,6 +397,66 @@ class JS8EngineService : Service() { } } + /** + * Build the rig transports. Called again on stop, once the old ones have + * been handed to the teardown, so a restart never reaches a closing link. + */ + private fun createRigTransports() { + usbSerialBridge = UsbSerialBridge(applicationContext) + bluetoothSerialBridge = BluetoothSerialBridge(applicationContext) + trusdxDirectSerial = TruSdxDirectSerial(applicationContext) + qmxDirectSerial = QmxDirectSerial(applicationContext) + hamlibRigControl = HamlibRigControl() + trusdxSerialSession = trusdxDirectSerial?.let { direct -> + TruSdxSerialSession( + direct, + object : TruSdxSerialSession.Listener { + override fun onCatMessage(message: String) { + if (isTruSdxDiagnosticsEnabled()) { + Log.v(TAG, "TruSDX CAT <= $message") + } + } + + override fun onAudioFrame(samplesU8: ByteArray) { + handleTruSdxAudioFrame(samplesU8) + } + + override fun onParserResync(reason: String) { + trusdxParserResyncs += 1 + if (isTruSdxDiagnosticsEnabled()) { + Log.w(TAG, "TruSDX parser resync ($reason), count=$trusdxParserResyncs") + } + } + + override fun onIoError(message: String) { + Log.w(TAG, "TruSDX I/O error: $message") + trusdxConnected = false + broadcastError("TruSDX serial link lost") + } + + } + ) + } + qmxCatSession = qmxDirectSerial?.let { direct -> + QmxCatSession(direct, object : QmxCatSession.Listener { + override fun onFrequency(frequencyHz: Long) { + currentDialHz = frequencyHz + mainHandler.post { broadcastRadioFrequency(frequencyHz) } + } + + override fun onMessage(message: String) { + Log.d(TAG, "QMX CAT <= $message") + } + + override fun onIoError(message: String) { + Log.w(TAG, "QMX CAT I/O error: $message") + qmxConnected = false + mainHandler.post { broadcastError("QMX CAT link lost") } + } + }) + } + } + private fun startEngine(resumeGeneration: Int? = null) { val generation = if (resumeGeneration == null) { if (engineStartInProgress || engine != null) { @@ -1500,14 +1507,26 @@ class JS8EngineService : Service() { rigPttCommandPending = false rigPttCompletion = null } - if (isRigControlConnected()) { - if (!releaseRigPttForShutdown()) { - Log.e(TAG, "Unable to confirm PTT release during shutdown") - } - } - - // Disconnect rig control on background thread - val networkClientToDisconnect = rigCtlClient + // Rig teardown belongs on the TX handler, not here. With the radio + // gone a CAT command blocks for hamlib's full timeout, and every + // HamlibRigControl method shares one monitor, so close() waits + // behind it. That was nine seconds on the main thread. + val shutdownMode = rigControlMode + val shutdownTransport = rtsPttTransport + val shutdownHamlib = hamlibRigControl + val shutdownUsb = usbSerialBridge + val shutdownBluetooth = bluetoothSerialBridge + val shutdownTruSdx = trusdxSerialSession + val shutdownQmx = qmxCatSession + val shutdownNetwork = rigCtlClient + val shouldReleasePtt = isRigControlConnected() + + // Unregistering clears one native global whichever bridge asks, so + // it stays here: deferred, it could wipe a restart's registration. + shutdownUsb?.unregisterNative() + shutdownBluetooth?.unregisterNative() + + createRigTransports() rigCtlClient = null rigCtlConnected = false rigCtlErrorShown = false @@ -1523,9 +1542,6 @@ class JS8EngineService : Service() { trusdxRxKeepaliveCount = 0L rtsPttTransport = null rigControlMode = "none" - hamlibRigControl?.close() - trusdxSerialSession?.stop() - qmxCatSession?.stop() if (isTruSdxDiagnosticsEnabled() && (trusdxRxFrames > 0 || trusdxTxFrames > 0 || trusdxParserResyncs > 0 || trusdxTxDrops > 0 || trusdxRxUnderruns > 0 || trusdxRxFrameDrops > 0) ) { @@ -1534,15 +1550,36 @@ class JS8EngineService : Service() { "TruSDX diagnostics: rxFrames=$trusdxRxFrames rxSamples=$trusdxRxSamples rxFrameDrops=$trusdxRxFrameDrops rxSubmitDrops=$trusdxRxSubmitDrops rxUnderruns=$trusdxRxUnderruns txFrames=$trusdxTxFrames txSamples=$trusdxTxSamples txSilent=$trusdxTxSilentFrames txDrops=$trusdxTxDrops parserResyncs=$trusdxParserResyncs" ) } - usbSerialBridge?.unregisterNative() - usbSerialBridge?.close() - bluetoothSerialBridge?.close() - bluetoothSerialBridge?.unregisterNative() - if (networkClientToDisconnect != null) { - Thread { - networkClientToDisconnect.disconnect() - }.start() + txHandler.post { + if (shouldReleasePtt) { + // Captured references, not setRigPtt: the fields already + // hold the transports the next start will use. + val released = when (shutdownMode) { + "network" -> shutdownNetwork?.setPtt(false) == true + "hamlib_usb" -> shutdownHamlib?.setPtt(false) == true + "rts_ptt" -> when (shutdownTransport) { + SerialTransport.USB -> shutdownUsb?.setRts(false) == true + SerialTransport.BLUETOOTH -> shutdownBluetooth?.setRts(false) == true + else -> false + } + "trusdx_serial" -> shutdownTruSdx?.setPtt(false) == true + "qmx_serial" -> shutdownQmx?.setPtt(false) == true + else -> false + } + if (released) { + synchronized(pttStateLock) { rigPttAsserted = false } + } else { + Log.e(TAG, "Unable to confirm PTT release during shutdown") + } + } + shutdownHamlib?.close() + shutdownTruSdx?.stop() + shutdownQmx?.stop() + shutdownUsb?.close() + shutdownBluetooth?.close() + shutdownNetwork?.disconnect() + Log.i(TAG, "Rig control torn down") } pskReporterClient?.stop(flush = true) @@ -2848,35 +2885,6 @@ class JS8EngineService : Service() { broadcastTxState(TX_STATE_FAILED) } - private fun releaseRigPttForShutdown(): Boolean { - if (Looper.myLooper() == txHandler.looper) { - val released = setRigPtt(false) - if (released) rigPttAsserted = false - return released - } - - val completed = CountDownLatch(1) - var released = false - txHandler.post { - try { - released = setRigPtt(false) - if (released) { - synchronized(pttStateLock) { - rigPttAsserted = false - } - } - } finally { - completed.countDown() - } - } - completed.await() - if (!released) { - released = setRigPtt(false) - if (released) rigPttAsserted = false - } - return released - } - /** * Handle incoming MSG commands - always runs regardless of autoreply setting. * This ensures messages are saved to inbox even if auto-ACK is disabled.