Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions ft8af/app/src/main/java/com/k1af/ft8af/ExecutorUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.k1af.ft8af;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;

/**
* Helpers for submitting work to executors that may be torn down concurrently.
*
* <p>The decode pipeline runs on its own thread and calls back into
* {@code MainViewModel} after each pass to kick off background work (QTH/grid
* lookups, network TX audio). Those pools are shut down in
* {@code MainViewModel.onCleared()} when the ViewModel is destroyed (app close,
* connection reset/reconnect). A decode result that lands in the same instant
* the pool is shut down would otherwise reach {@link ExecutorService#execute}
* on a terminated pool and throw {@link RejectedExecutionException} on the
* decode thread, crashing the app. See the {@code afterDecode} race fixed
* alongside this helper.
*/
public final class ExecutorUtils {

private ExecutorUtils() {
}

/**
* Submit {@code task} to {@code pool}, tolerating a pool that is shutting
* down. The {@link ExecutorService#isShutdown()} check avoids the common
* case; the catch covers the narrow race where the pool is shut down
* between the check and {@link ExecutorService#execute}.
*
* @return {@code true} if the task was accepted, {@code false} if it was
* dropped because the pool (or task) was unavailable.
*/
public static boolean safeExecute(ExecutorService pool, Runnable task) {
if (pool == null || task == null || pool.isShutdown()) {
return false;
}
try {
pool.execute(task);
return true;
} catch (RejectedExecutionException e) {
// Pool was shut down concurrently (ViewModel teardown). Drop the task.
return false;
}
}
}
11 changes: 7 additions & 4 deletions ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,10 @@ public void afterDecode(long utc, float time_sec, int sequential


getQTHRunnable.messages = messages;
getQTHThreadPool.execute(getQTHRunnable);//query location via thread pool
//query location via thread pool; guarded so a decode landing during
//ViewModel teardown (pool shut down in onCleared()) drops the lookup
//instead of crashing with RejectedExecutionException.
ExecutorUtils.safeExecute(getQTHThreadPool, getQTHRunnable);

//this variable also notifies message list changes
mutable_Decoded_Counter.postValue(
Expand Down Expand Up @@ -685,8 +688,8 @@ public void onTransmitByWifi(Ft8Message msg) {
if (baseRig.isConnected()) {
sendWaveDataRunnable.baseRig = baseRig;
sendWaveDataRunnable.message = msg;
//send network data packets via thread pool
sendWaveDataThreadPool.execute(sendWaveDataRunnable);
//send network data packets via thread pool (guarded against teardown race)
ExecutorUtils.safeExecute(sendWaveDataThreadPool, sendWaveDataRunnable);
}
}
}
Expand Down Expand Up @@ -714,7 +717,7 @@ public void onTransmitOverCAT(Ft8Message msg) {//send audio message via CAT
}
sendWaveDataRunnable.baseRig = baseRig;
sendWaveDataRunnable.message = msg;
sendWaveDataThreadPool.execute(sendWaveDataRunnable);
ExecutorUtils.safeExecute(sendWaveDataThreadPool, sendWaveDataRunnable);
}

}, new OnTransmitSuccess() {//when QSO is successful
Expand Down
61 changes: 61 additions & 0 deletions ft8af/app/src/test/java/com/k1af/ft8af/ExecutorUtilsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.k1af.ft8af;

import static com.google.common.truth.Truth.assertThat;

import org.junit.Test;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
* Unit tests for {@link ExecutorUtils#safeExecute(ExecutorService, Runnable)}.
*
* <p>Locks the invariant behind the {@code afterDecode} crash fix: submitting a
* decode-followup task to a pool that has been shut down (ViewModel teardown)
* must drop the task quietly instead of throwing
* {@link java.util.concurrent.RejectedExecutionException}.
*/
public class ExecutorUtilsTest {

@Test
public void activePool_runsTaskAndReturnsTrue() throws InterruptedException {
ExecutorService pool = Executors.newSingleThreadExecutor();
try {
CountDownLatch ran = new CountDownLatch(1);
boolean accepted = ExecutorUtils.safeExecute(pool, ran::countDown);
assertThat(accepted).isTrue();
assertThat(ran.await(2, TimeUnit.SECONDS)).isTrue();
} finally {
pool.shutdownNow();
}
}

@Test
public void shutdownPool_dropsTaskAndReturnsFalse() {
ExecutorService pool = Executors.newSingleThreadExecutor();
pool.shutdown();
// The pre-fix code path: execute() on a terminated pool threw and crashed
// the decode thread. safeExecute must swallow it and report the drop.
boolean accepted = ExecutorUtils.safeExecute(pool, () -> {
throw new AssertionError("task must not run on a shut-down pool");
});
assertThat(accepted).isFalse();
}

Comment on lines +35 to +46
@Test
public void nullPool_returnsFalse() {
assertThat(ExecutorUtils.safeExecute(null, () -> { })).isFalse();
}

@Test
public void nullTask_returnsFalse() {
ExecutorService pool = Executors.newSingleThreadExecutor();
try {
assertThat(ExecutorUtils.safeExecute(pool, null)).isFalse();
} finally {
pool.shutdownNow();
}
}
}
Loading