diff --git a/CHANGELOG.md b/CHANGELOG.md
index dbdeb7c8..b2431f6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,20 @@
-## 24.1.3
+## 24.1.5
+* Fixed a bug where a non-JSON server response would cause a permanent networking deadlock, preventing all subsequent requests from being sent.
+* Fixed a bug where a NullPointerException in SDKCore.recover() would permanently block SDK initialization when a crash file from a previous session existed on disk.
+
+## 24.1.4
+* ! Minor breaking change ! User properties will now be automatically saved under the following conditions:
+ * When an event is recorded
+ * During an internal timer tick
+ * Upon flushing the event queue
+ * When a session call made
+* Cleaned up unused gradle dependencies from root build.gradle.
+## 24.1.3
* Extended minimum JDK support to 8.
## 24.1.2
+
* !! Major Breaking Change !! Minimum JDK support is 19 for this minor.
* Migrated from Sonatype OSSRH.
@@ -214,3 +226,4 @@
* initial SDK release
* MavenCentral rerelease
+
diff --git a/app-java/build.gradle b/app-java/build.gradle
index 6c0872f6..3b90276a 100644
--- a/app-java/build.gradle
+++ b/app-java/build.gradle
@@ -16,4 +16,4 @@ dependencies {
//implementation "ly.count.sdk:java:${CLY_VERSION}"
}
-mainClassName = 'ly.count.java.demo.Sample'
+mainClassName = 'ly.count.java.demo.ReproduceIssue264'
diff --git a/app-java/src/main/java/ly/count/java/demo/ReproduceIssue263.java b/app-java/src/main/java/ly/count/java/demo/ReproduceIssue263.java
new file mode 100644
index 00000000..bd9a89be
--- /dev/null
+++ b/app-java/src/main/java/ly/count/java/demo/ReproduceIssue263.java
@@ -0,0 +1,118 @@
+package ly.count.java.demo;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import ly.count.sdk.java.Config;
+import ly.count.sdk.java.Countly;
+import org.json.JSONObject;
+
+/**
+ * Reproducer for GitHub issue #263:
+ * "NullPointerException in SDKCore.recover() permanently blocks SDK initialization
+ * when a crash file exists"
+ *
+ * This simulates the scenario where:
+ * 1. A previous app run crashed and left a [CLY]_crash_* file on disk
+ * 2. The app restarts and calls Countly.init()
+ * 3. SDKCore.recover() finds the crash file and tries to process it
+ *
+ * WITHOUT the fix: NPE at networking.check(config) because networking is null
+ * WITH the fix: SDK initializes normally, crash is queued as a request
+ */
+public class ReproduceIssue263 {
+
+ public static void main(String[] args) {
+ String[] sdkStorageRootPath = { System.getProperty("user.home"), "__COUNTLY", "java_issue_263" };
+ File sdkStorageRootDirectory = new File(String.join(File.separator, sdkStorageRootPath));
+
+ if (!(sdkStorageRootDirectory.exists() && sdkStorageRootDirectory.isDirectory())) {
+ if (!sdkStorageRootDirectory.mkdirs()) {
+ System.out.println("[FAIL] Directory creation failed");
+ return;
+ }
+ }
+
+ // Step 1: Plant a fake crash file as if a previous run crashed
+ long crashTimestamp = System.currentTimeMillis() - 2000;
+ File crashFile = new File(sdkStorageRootDirectory, "[CLY]_crash_" + crashTimestamp);
+
+ JSONObject crashData = new JSONObject();
+ crashData.put("_error", "java.lang.RuntimeException: simulated crash from previous session\n"
+ + "\tat com.example.App.doSomething(App.java:42)\n"
+ + "\tat com.example.App.main(App.java:10)");
+ crashData.put("_nonfatal", false);
+ crashData.put("_os", "Java");
+ crashData.put("_os_version", System.getProperty("java.version"));
+ crashData.put("_device", "ReproducerDevice");
+ crashData.put("_resolution", "1920x1080");
+
+ try (BufferedWriter writer = Files.newBufferedWriter(crashFile.toPath())) {
+ writer.write(crashData.toString());
+ } catch (IOException e) {
+ System.out.println("[FAIL] Could not write crash file: " + e.getMessage());
+ return;
+ }
+
+ System.out.println("[INFO] Planted crash file: " + crashFile.getAbsolutePath());
+ System.out.println("[INFO] Crash file exists: " + crashFile.exists());
+ System.out.println();
+
+ // Step 2: Initialize SDK — this is where the NPE would occur
+ System.out.println("[TEST] Initializing SDK with crash file present...");
+ System.out.println("[TEST] If issue #263 is NOT fixed, you will see a NullPointerException below.");
+ System.out.println();
+
+ try {
+ Config config = new Config("https://test.server.ly", "TEST_APP_KEY", sdkStorageRootDirectory)
+ .setLoggingLevel(Config.LoggingLevel.DEBUG)
+ .enableFeatures(Config.Feature.CrashReporting, Config.Feature.Events, Config.Feature.Sessions);
+
+ Countly.instance().init(config);
+
+ System.out.println();
+ System.out.println("[PASS] SDK initialized successfully!");
+ System.out.println("[INFO] Crash file still exists: " + crashFile.exists());
+
+ if (!crashFile.exists()) {
+ System.out.println("[PASS] Crash file was processed and removed during recovery.");
+ } else {
+ System.out.println("[WARN] Crash file was NOT removed — recovery may have partially failed.");
+ }
+
+ // Check for request files (crash should be converted to a request)
+ File[] requestFiles = sdkStorageRootDirectory.listFiles(
+ (dir, name) -> name.startsWith("[CLY]_request_"));
+ if (requestFiles != null && requestFiles.length > 0) {
+ System.out.println("[PASS] Found " + requestFiles.length + " request file(s) — crash was queued for sending.");
+ }
+
+ // Clean shutdown
+ Countly.instance().halt();
+ System.out.println("[INFO] SDK stopped cleanly.");
+
+ } catch (NullPointerException e) {
+ System.out.println();
+ System.out.println("[FAIL] *** NullPointerException — Issue #263 is NOT fixed! ***");
+ System.out.println("[FAIL] " + e.getMessage());
+ e.printStackTrace();
+ } catch (Exception e) {
+ System.out.println();
+ System.out.println("[FAIL] Unexpected exception: " + e.getClass().getSimpleName() + ": " + e.getMessage());
+ e.printStackTrace();
+ } finally {
+ // Cleanup: remove test files
+ System.out.println();
+ System.out.println("[INFO] Cleaning up test directory...");
+ File[] files = sdkStorageRootDirectory.listFiles();
+ if (files != null) {
+ for (File f : files) {
+ f.delete();
+ }
+ }
+ sdkStorageRootDirectory.delete();
+ System.out.println("[INFO] Done.");
+ }
+ }
+}
diff --git a/app-java/src/main/java/ly/count/java/demo/ReproduceIssue264.java b/app-java/src/main/java/ly/count/java/demo/ReproduceIssue264.java
new file mode 100644
index 00000000..d7b23963
--- /dev/null
+++ b/app-java/src/main/java/ly/count/java/demo/ReproduceIssue264.java
@@ -0,0 +1,179 @@
+package ly.count.java.demo;
+
+import com.sun.net.httpserver.HttpServer;
+import java.io.File;
+import java.io.OutputStream;
+import java.lang.reflect.Field;
+import java.net.InetSocketAddress;
+import java.util.concurrent.atomic.AtomicInteger;
+import ly.count.sdk.java.Config;
+import ly.count.sdk.java.Countly;
+
+/**
+ * Reproduces GitHub Issue #264:
+ * "Non-JSON Server Response Causes Permanent Networking Deadlock"
+ *
+ * This app starts a local HTTP server that returns HTML (simulating a 502 error page),
+ * initializes the Countly SDK against it, records events, and checks whether the SDK
+ * gets permanently stuck.
+ *
+ * Run with: ./gradlew app-java:run
+ * (after setting mainClassName = 'ly.count.java.demo.ReproduceIssue264' in app-java/build.gradle)
+ */
+public class ReproduceIssue264 {
+
+ public static void main(String[] args) throws Exception {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ AtomicInteger successCount = new AtomicInteger(0);
+
+ // Start a local HTTP server that returns HTML for the first 3 requests,
+ // then valid JSON for subsequent requests (simulating server recovery)
+ HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+ int port = server.getAddress().getPort();
+
+ server.createContext("/", exchange -> {
+ int count = requestCount.incrementAndGet();
+ String body;
+ int code;
+
+ if (count <= 3) {
+ code = 502;
+ body = "
502 Bad Gateway
The server is temporarily unavailable.
";
+ System.out.println("[Mock Server] Request #" + count + " -> returning HTML 502 (simulating outage)");
+ } else {
+ code = 200;
+ body = "{\"result\":\"Success\"}";
+ successCount.incrementAndGet();
+ System.out.println("[Mock Server] Request #" + count + " -> returning JSON 200 (server recovered)");
+ }
+
+ exchange.sendResponseHeaders(code, body.length());
+ OutputStream os = exchange.getResponseBody();
+ os.write(body.getBytes());
+ os.close();
+ });
+
+ server.start();
+ System.out.println("=== Issue #264 Reproduction ===");
+ System.out.println("[Mock Server] Started on port " + port);
+ System.out.println();
+
+ // Setup SDK storage directory
+ String[] sdkStorageRootPath = { System.getProperty("user.home"), "__COUNTLY", "java_issue264" };
+ File sdkStorageRootDirectory = new File(String.join(File.separator, sdkStorageRootPath));
+ if (!(sdkStorageRootDirectory.exists() && sdkStorageRootDirectory.isDirectory())) {
+ sdkStorageRootDirectory.mkdirs();
+ }
+
+ // Initialize SDK pointing to our mock server
+ Config config = new Config("http://localhost:" + port, "TEST_APP_KEY", sdkStorageRootDirectory)
+ .setLoggingLevel(Config.LoggingLevel.WARN)
+ .setDeviceIdStrategy(Config.DeviceIdStrategy.UUID)
+ .enableFeatures(Config.Feature.Events, Config.Feature.Sessions)
+ .setEventQueueSizeToSend(1);
+
+ Countly.instance().init(config);
+ System.out.println("[SDK] Initialized against mock server");
+
+ // Start session (triggers first request -> will get HTML 502)
+ Countly.session().begin();
+ System.out.println("[SDK] Session started");
+
+ // Record an event (triggers another request -> will get HTML 502)
+ Countly.instance().events().recordEvent("test_event_during_outage");
+ System.out.println("[SDK] Event recorded");
+
+ // Wait for requests to be attempted
+ System.out.println();
+ System.out.println("[Test] Waiting 3 seconds for initial requests...");
+ Thread.sleep(3000);
+
+ // Check if SDK is deadlocked via reflection (SDKCore.instance.networking is protected)
+ boolean isSending = isNetworkingSending();
+
+ System.out.println();
+ System.out.println("============================================================");
+ if (isSending) {
+ System.out.println(" BUG REPRODUCED: isSending() = true (DEADLOCKED!)");
+ System.out.println(" The SDK is permanently stuck. No further requests");
+ System.out.println(" will ever be sent, even when the server recovers.");
+ } else {
+ System.out.println(" FIX CONFIRMED: isSending() = false (recovered)");
+ System.out.println(" The SDK handled the non-JSON response gracefully.");
+ }
+ System.out.println("============================================================");
+ System.out.println();
+
+ // Try to trigger recovery by calling check
+ System.out.println("[Test] Triggering networking check cycles (server now returns JSON)...");
+ triggerNetworkingChecks(5);
+
+ int totalRequests = requestCount.get();
+ int successes = successCount.get();
+
+ System.out.println();
+ System.out.println("============================================================");
+ System.out.println(" Total requests received by server: " + totalRequests);
+ System.out.println(" Successful (JSON 200) responses: " + successes);
+ if (successes > 0) {
+ System.out.println(" SDK successfully retried after server recovered!");
+ } else if (!isNetworkingSending()) {
+ System.out.println(" SDK recovered from error. Requests will retry on");
+ System.out.println(" the next timer tick (no deadlock).");
+ } else {
+ System.out.println(" SDK is STILL deadlocked. Bug confirmed.");
+ }
+ System.out.println("============================================================");
+
+ // Cleanup
+ Countly.instance().stop();
+ server.stop(0);
+ System.out.println();
+ System.out.println("[Done] Cleanup complete.");
+ }
+
+ /**
+ * Access SDKCore.instance.networking.isSending() via reflection
+ * since these fields are protected/package-private.
+ */
+ private static boolean isNetworkingSending() throws Exception {
+ Class> sdkCoreClass = Class.forName("ly.count.sdk.java.internal.SDKCore");
+ Field instanceField = sdkCoreClass.getDeclaredField("instance");
+ instanceField.setAccessible(true);
+ Object sdkCore = instanceField.get(null);
+
+ Field networkingField = sdkCoreClass.getDeclaredField("networking");
+ networkingField.setAccessible(true);
+ Object networking = networkingField.get(sdkCore);
+
+ return (boolean) networking.getClass().getMethod("isSending").invoke(networking);
+ }
+
+ /**
+ * Trigger SDKCore.instance.networking.check(config) via reflection.
+ */
+ private static void triggerNetworkingChecks(int count) throws Exception {
+ Class> sdkCoreClass = Class.forName("ly.count.sdk.java.internal.SDKCore");
+ Field instanceField = sdkCoreClass.getDeclaredField("instance");
+ instanceField.setAccessible(true);
+ Object sdkCore = instanceField.get(null);
+
+ Field networkingField = sdkCoreClass.getDeclaredField("networking");
+ networkingField.setAccessible(true);
+ Object networking = networkingField.get(sdkCore);
+
+ Field configField = sdkCoreClass.getDeclaredField("config");
+ configField.setAccessible(true);
+ Object internalConfig = configField.get(sdkCore);
+
+ java.lang.reflect.Method checkMethod = networking.getClass().getMethod("check",
+ Class.forName("ly.count.sdk.java.internal.InternalConfig"));
+
+ for (int i = 0; i < count; i++) {
+ if (!isNetworkingSending()) {
+ checkMethod.invoke(networking, internalConfig);
+ }
+ Thread.sleep(1000);
+ }
+ }
+}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 8a55cf4f..2c3b316d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,37 +1,21 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
- repositories {
- google()
- mavenCentral()
- jcenter()
- maven {
- url "https://maven.google.com"
- }
- }
- dependencies {
- classpath 'com.android.tools.build:gradle:8.1.4'
- classpath 'com.github.dcendents:android-maven-plugin:1.2'
- classpath 'com.google.gms:google-services:4.4.2'
-
- // NOTE: Do not place your application dependencies here; they belong
- // in the individual module build.gradle files
- }
+ repositories {
+ google()
+ mavenCentral()
+ }
}
allprojects {
- ext.CLY_VERSION = "24.1.3"
- ext.POWERMOCK_VERSION = "1.7.4"
+ ext.CLY_VERSION = "24.1.5"
+ ext.POWERMOCK_VERSION = "1.7.4"
- tasks.withType(Javadoc) {
- options.addStringOption('Xdoclint:none', '-quiet')
- }
- repositories {
- google()
- jcenter()
- //mavenLocal()
- maven {
- url "https://maven.google.com" // Google's Maven repository
- }
- }
+ tasks.withType(Javadoc) {
+ options.addStringOption('Xdoclint:none', '-quiet')
+ }
+ repositories {
+ google()
+ mavenCentral()
+ }
}
diff --git a/gradle.properties b/gradle.properties
index 93f08405..77c0d779 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -18,7 +18,7 @@
# org.gradle.parallel=true
# RELEASE FIELD SECTION
-VERSION_NAME=24.1.3
+VERSION_NAME=24.1.5
GROUP=ly.count.sdk
POM_URL=https://github.com/Countly/countly-sdk-java
diff --git a/sdk-java/build.gradle b/sdk-java/build.gradle
index 5c80b57c..a7db0009 100644
--- a/sdk-java/build.gradle
+++ b/sdk-java/build.gradle
@@ -8,7 +8,11 @@ buildscript {
//mavenLocal()
}
dependencies {
- classpath 'com.vanniktech:gradle-maven-publish-plugin:0.28.0' //for publishing
+ // Load publish plugin ONLY when publish task is requested
+ if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) {
+ // This requires minimum java 11 to work
+ classpath 'com.vanniktech:gradle-maven-publish-plugin:0.28.0'
+ }
}
}
@@ -22,10 +26,10 @@ dependencies {
implementation 'com.google.code.findbugs:jsr305:3.0.2'
testImplementation 'junit:junit:4.13.1'
- testImplementation 'org.mockito:mockito-core:2.8.9'
- testImplementation "org.powermock:powermock-core:${POWERMOCK_VERSION}"
- testImplementation "org.powermock:powermock-module-junit4:${POWERMOCK_VERSION}"
+ testImplementation 'org.mockito:mockito-core:4.11.0'
//testImplementation 'com.squareup.okhttp3:mockwebserver:3.7.0'
}
-apply plugin: "com.vanniktech.maven.publish"
+if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) {
+ apply plugin: "com.vanniktech.maven.publish"
+}
diff --git a/sdk-java/src/main/java/ly/count/sdk/java/Config.java b/sdk-java/src/main/java/ly/count/sdk/java/Config.java
index c4f2372b..cf5df62f 100644
--- a/sdk-java/src/main/java/ly/count/sdk/java/Config.java
+++ b/sdk-java/src/main/java/ly/count/sdk/java/Config.java
@@ -68,7 +68,7 @@ public class Config {
/**
* Countly SDK version to be sent in HTTP requests
*/
- protected String sdkVersion = "24.1.3";
+ protected String sdkVersion = "24.1.5";
/**
* Countly SDK version to be sent in HTTP requests
@@ -243,6 +243,7 @@ public class Config {
protected String city = null;
protected String country = null;
protected boolean locationEnabled = true;
+ protected boolean autoSendUserProperties = true;
// TODO: storage limits & configuration
// protected int maxRequestsStored = 0;
@@ -1480,4 +1481,20 @@ public String toString() {
return "DID " + id + " ( " + strategy + ")";
}
}
+
+ // Disabling new Added features
+
+ /**
+ * Disable automatic sending of user properties on
+ * - When an event is recorded
+ * - During an internal timer tick
+ * - Upon flushing the event queue
+ * - When a session call made
+ *
+ * @return {@code this} instance for method chaining
+ */
+ public Config disableAutoSendUserProperties() {
+ this.autoSendUserProperties = false;
+ return this;
+ }
}
diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java
index 5c51185b..d446315d 100644
--- a/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java
+++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java
@@ -32,6 +32,7 @@ public class InternalConfig extends Config {
protected IdGenerator viewIdGenerator;
protected IdGenerator eventIdGenerator;
protected ViewIdProvider viewIdProvider;
+
/**
* Shouldn't be used!
*/
@@ -211,4 +212,8 @@ String[] getLocationParams() {
boolean isLocationDisabled() {
return !locationEnabled;
}
+
+ boolean isAutoSendUserProperties() {
+ return autoSendUserProperties;
+ }
}
diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java
index 4cae3bb5..1e48a875 100644
--- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java
+++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java
@@ -109,6 +109,10 @@ protected void recordEventInternal(String key, int count, Double sum, Double dur
Utils.removeInvalidDataFromSegments(segmentation, L);
+ if (internalConfig.isAutoSendUserProperties() && internalConfig.sdk.userProfile() != null) {
+ internalConfig.sdk.module(ModuleUserProfile.class).saveInternal();
+ }
+
String eventId, pvid = null, cvid = null;
if (Utils.isEmptyOrNull(eventIdOverride)) {
L.d("[ModuleEvents] recordEventInternal, Generating new event id because it was null or empty");
@@ -139,7 +143,7 @@ private void addEventToQueue(EventImpl event) {
checkEventQueueToSend(false);
}
- private void checkEventQueueToSend(boolean forceSend) {
+ void checkEventQueueToSend(boolean forceSend) {
L.d("[ModuleEvents] queue size:[" + eventQueue.eqSize() + "] || forceSend: " + forceSend);
if (forceSend || eventQueue.eqSize() >= internalConfig.getEventsBufferSize()) {
addEventsToRequestQ(null);
diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java
index f783a29d..41aeb5da 100644
--- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java
+++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java
@@ -259,7 +259,17 @@ protected void saveInternal() {
if (internalConfig.sdk.location() != null) {
internalConfig.sdk.module(ModuleLocation.class).saveLocationToParamsLegacy(generatedParams);
}
+
L.d("[ModuleUserProfile] saveInternal, generated params [" + generatedParams + "]");
+ if (generatedParams.length() <= 0) {
+ L.d("[ModuleUserProfile] saveInternal, nothing to save returning");
+ return;
+ }
+
+ if (internalConfig.isAutoSendUserProperties() && internalConfig.sdk.events() != null) {
+ internalConfig.sdk.module(ModuleEvents.class).checkEventQueueToSend(true);
+ }
+
ModuleRequests.pushAsync(internalConfig, new Request(generatedParams));
clearInternal();
}
@@ -288,6 +298,22 @@ public void stop(InternalConfig config, boolean clearData) {
userProfileInterface = null;
}
+ @Override
+ protected void onTimer() {
+ if (internalConfig.isAutoSendUserProperties()) {
+ saveInternal();
+ }
+ }
+
+ @Override
+ public void deviceIdChanged(String oldDeviceId, boolean withMerge) {
+ super.deviceIdChanged(oldDeviceId, withMerge);
+ L.d("[ModuleUserProfile] deviceIdChanged: oldDeviceId = " + oldDeviceId + ", withMerge = " + withMerge);
+ if (internalConfig.isAutoSendUserProperties() && !withMerge) {
+ saveInternal();
+ }
+ }
+
public class UserProfile {
/**
diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java
index b912c567..1a273436 100644
--- a/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java
+++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java
@@ -459,8 +459,6 @@ public void init(final InternalConfig givenConfig) {
modules.remove(feature);
}
- recover(config);
-
if (config.isDefaultNetworking()) {
networking = new DefaultNetworking();
@@ -513,17 +511,23 @@ public Integer remaningRequests() {
}
}
+ recover(config);
+
user = new UserImpl(config);
initFinished(config);
}
- private void initFinished(final InternalConfig config) {
- modules.forEach((feature, module) -> module.initFinished(config));
- if (config.isDefaultNetworking()) {
+ private void checkNetworking(InternalConfig config) {
+ if (networking != null) {
networking.check(config);
}
}
+ private void initFinished(final InternalConfig config) {
+ modules.forEach((feature, module) -> module.initFinished(config));
+ checkNetworking(config);
+ }
+
public UserImpl user() {
return user;
}
@@ -650,13 +654,13 @@ protected void recover(InternalConfig config) {
public void onSignal(InternalConfig config, int id) {
if (id == Signal.DID.getIndex()) {
- networking.check(config);
+ checkNetworking(config);
}
}
public void onSignal(InternalConfig config, int id, String param) {
if (id == Signal.Ping.getIndex()) {
- networking.check(config);
+ checkNetworking(config);
} else if (id == Signal.Crash.getIndex()) {
processCrash(config, Long.parseLong(param));
}
@@ -679,7 +683,7 @@ private boolean processCrash(InternalConfig config, Long id) {
if (Storage.push(config, request)) {
L.i("[SDKCore] Added request " + request.storageId() + " instead of crash " + crash.storageId());
- networking.check(config);
+ checkNetworking(config);
Boolean success = Storage.remove(config, crash);
return (success != null) && success;
} else {
diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java
index 22a59e8d..295bbe7b 100644
--- a/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java
+++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java
@@ -118,6 +118,9 @@ Future begin(Long now) {
}
this.consents = SDKCore.instance.consents;
+ if (config.isAutoSendUserProperties() && config.sdk.userProfile() != null) {
+ config.sdk.module(ModuleUserProfile.class).saveInternal();
+ }
if (pushOnChange) {
Storage.pushAsync(config, this);
@@ -157,6 +160,9 @@ Future update(Long now) {
}
this.consents = SDKCore.instance.consents;
+ if (config.isAutoSendUserProperties() && config.sdk.userProfile() != null) {
+ config.sdk.module(ModuleUserProfile.class).saveInternal();
+ }
Long duration = updateDuration(now);
@@ -192,6 +198,9 @@ Future end(Long now, final Tasks.Callback callback, String did
ended = now == null ? System.nanoTime() : now;
this.consents = SDKCore.instance.consents;
+ if (config.isAutoSendUserProperties() && config.sdk.userProfile() != null) {
+ config.sdk.module(ModuleUserProfile.class).saveInternal();
+ }
if (currentView != null) {
currentView.stop(true);
diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/Tasks.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/Tasks.java
index 9d165970..6eb7cb8c 100644
--- a/sdk-java/src/main/java/ly/count/sdk/java/internal/Tasks.java
+++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/Tasks.java
@@ -22,7 +22,7 @@ public class Tasks {
* Service which runs {@link Callable}s
*/
private final ExecutorService executor;
- private Long running = null;
+ private volatile Long running = null;
/**
* Map of {@link Future}s for {@link Callable}s not yet resolved
@@ -92,18 +92,20 @@ Future run(final Task task, final Callback callback) {
@Override
public T call() throws Exception {
running = task.id;
- T result = task.call();
- synchronized (pending) {
- if (!task.id.equals(0L)) {
- pending.remove(task.id);
+ try {
+ T result = task.call();
+ if (callback != null) {
+ callback.call(result);
+ }
+ return result;
+ } finally {
+ synchronized (pending) {
+ if (!task.id.equals(0L)) {
+ pending.remove(task.id);
+ }
+ running = null;
}
- running = null;
- // L.d("pending " + pending.keySet() + ", done running " + task.id);
- }
- if (callback != null) {
- callback.call(result);
}
- return result;
}
});
diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/Transport.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/Transport.java
index b016a4c1..0b7957f7 100644
--- a/sdk-java/src/main/java/ly/count/sdk/java/internal/Transport.java
+++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/Transport.java
@@ -342,6 +342,9 @@ public Boolean send() {
} catch (IOException e) {
L.w("[network] Error while sending request " + request + " " + e);
return false;
+ } catch (Exception e) {
+ L.e("[network] Unexpected error while sending request " + request + " " + e);
+ return false;
} finally {
if (connection != null) {
connection.disconnect();
@@ -354,12 +357,22 @@ public Boolean send() {
Boolean processResponse(int code, String response, Long requestId) {
L.i("[network] [processResponse] Code [" + code + "] response [" + response + "] for request[" + requestId + "]");
- JSONObject jsonObject = new JSONObject(response);
- if (code >= 200 && code < 300 && jsonObject.has("result")) {
- L.d("[network] Success");
- return true;
- } else {
- L.w("[network] Fail: code :" + code + ", result: " + response);
+ if (response == null) {
+ L.w("[network] Null response for request [" + requestId + "]");
+ return false;
+ }
+
+ try {
+ JSONObject jsonObject = new JSONObject(response);
+ if (code >= 200 && code < 300 && jsonObject.has("result")) {
+ L.d("[network] Success");
+ return true;
+ } else {
+ L.w("[network] Fail: code :" + code + ", result: " + response);
+ return false;
+ }
+ } catch (Exception e) {
+ L.w("[network] Failed to parse response as JSON for request [" + requestId + "], response: [" + response + "], error: [" + e.getMessage() + "]");
return false;
}
}
diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/LogTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/LogTests.java
index fd1f0b3d..9d7c95a4 100644
--- a/sdk-java/src/test/java/ly/count/sdk/java/internal/LogTests.java
+++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/LogTests.java
@@ -6,8 +6,6 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
-import org.powermock.reflect.Whitebox;
-
import ly.count.sdk.java.Config;
import static ly.count.sdk.java.Config.LoggingLevel.DEBUG;
diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java
index 66f7c7ed..ac776174 100644
--- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java
+++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java
@@ -442,6 +442,137 @@ public void timedEventFlow() throws InterruptedException {
TestUtils.validateEventInEQ(eKeys[0], null, 2, 4.0, 2.0, 1, 2, TestUtils.keysValues[1], null, "", TestUtils.keysValues[0]);
}
+ /**
+ * Recording events with user properties and with flushing events
+ * Validating that if a user property set before a recordEvent call it is sent before adding the event to EQ
+ * And also user properties packed after flushing events.
+ *
+ * @throws InterruptedException when sleep is interrupted
+ */
+ @Test
+ public void eventsUserProps() throws InterruptedException {
+ init(TestUtils.getConfigEvents(4).setUpdateSessionTimerDelay(2));
+
+ Countly.instance().userProfile().setProperty("before_event", "value1");
+ Countly.instance().events().recordEvent(eKeys[0]);
+
+ Map[] RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(1, RQ.length);
+ Assert.assertEquals(TestUtils.json("custom", TestUtils.map("before_event", "value1")), RQ[0].get("user_details"));
+ TestUtils.validateEventInEQ(eKeys[0], null, 1, null, null, 0, 1, "_CLY_", null, "", null);
+
+ Countly.instance().userProfile().setProperty("after_event", "value2");
+ Thread.sleep(2500); // wait for the tick
+ RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(3, RQ.length);
+ Assert.assertTrue(RQ[1].containsKey("events"));
+ Assert.assertEquals(TestUtils.json("custom", TestUtils.map("after_event", "value2")), RQ[2].get("user_details"));
+ }
+
+ /**
+ * Recording events with user properties and with flushing events will not work because reversed
+ *
+ * @throws InterruptedException when sleep is interrupted
+ */
+ @Test
+ public void eventsUserProps_reversed() throws InterruptedException {
+ init(TestUtils.getConfigEvents(4).setUpdateSessionTimerDelay(2).disableAutoSendUserProperties());
+
+ Countly.instance().userProfile().setProperty("before_event", "value1");
+ Countly.instance().events().recordEvent(eKeys[0]);
+
+ Map[] RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(0, RQ.length);
+ TestUtils.validateEventInEQ(eKeys[0], null, 1, null, null, 0, 1, "_CLY_", null, "", null);
+
+ Countly.instance().userProfile().setProperty("after_event", "value2");
+ Thread.sleep(2500); // wait for the tick
+ RQ = TestUtils.getCurrentRQ();
+
+ Assert.assertEquals(1, RQ.length);
+ Assert.assertTrue(RQ[0].containsKey("events"));
+ }
+
+ /**
+ * Recording events with user properties and with flushing events
+ * Validating that if a user property save called, it flushes EQ before saving user properties
+ */
+ @Test
+ public void eventsUserProps_propsSave() {
+ init(TestUtils.getConfigEvents(4));
+
+ Countly.instance().events().recordEvent(eKeys[0]);
+
+ Map[] RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(0, RQ.length);
+ TestUtils.validateEventInEQ(eKeys[0], null, 1, null, null, 0, 1, "_CLY_", null, "", null);
+
+ Countly.instance().userProfile().setProperty("after_event", "value2");
+ Countly.instance().userProfile().save();
+
+ RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(2, RQ.length);
+ Assert.assertTrue(RQ[0].containsKey("events"));
+ Assert.assertEquals(TestUtils.json("custom", TestUtils.map("after_event", "value2")), RQ[1].get("user_details"));
+ }
+
+ /**
+ * Recording events with user properties and with flushing events
+ * Validating that if a user property save called, it does not flush EQ before saving user properties
+ */
+ @Test
+ public void eventsUserProps_propsSave_reversed() {
+ init(TestUtils.getConfigEvents(4).disableAutoSendUserProperties());
+
+ Countly.instance().events().recordEvent(eKeys[0]);
+
+ Map[] RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(0, RQ.length);
+ TestUtils.validateEventInEQ(eKeys[0], null, 1, null, null, 0, 1, "_CLY_", null, "", null);
+
+ Countly.instance().userProfile().setProperty("after_event", "value2");
+ Countly.instance().userProfile().save();
+
+ RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(1, RQ.length);
+ Assert.assertEquals(TestUtils.json("custom", TestUtils.map("after_event", "value2")), RQ[0].get("user_details"));
+ }
+
+ /**
+ * Validate that user properties are sent with timer tick if no events are recorded
+ */
+ @Test
+ public void eventsUserProps_timer() throws InterruptedException {
+ init(TestUtils.getConfigEvents(4).setUpdateSessionTimerDelay(2));
+
+ Countly.instance().userProfile().setProperty("before_timer", "value1");
+
+ Map[] RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(0, RQ.length);
+
+ Thread.sleep(2500); // wait for the tick
+ RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(1, RQ.length);
+ Assert.assertEquals(TestUtils.json("custom", TestUtils.map("before_timer", "value1")), RQ[0].get("user_details"));
+ }
+
+ /**
+ * Validate that user properties does not send with timer tick if no events are recorded
+ */
+ @Test
+ public void eventsUserProps_timer_reversed() throws InterruptedException {
+ init(TestUtils.getConfigEvents(4).setUpdateSessionTimerDelay(2).disableAutoSendUserProperties());
+
+ Countly.instance().userProfile().setProperty("before_timer", "value1");
+
+ Map[] RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(0, RQ.length);
+
+ Thread.sleep(2500); // wait for the tick
+ RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(0, RQ.length);
+ }
+
private void validateTimedEventSize(int expectedQueueSize, int expectedTimedEventSize) {
TestUtils.validateEQSize(expectedQueueSize, TestUtils.getCurrentEQ(), moduleEvents.eventQueue);
Assert.assertEquals(expectedTimedEventSize, moduleEvents.timedEvents.size());
diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/RequestTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/RequestTests.java
index 4a7c8b26..5535dd27 100644
--- a/sdk-java/src/test/java/ly/count/sdk/java/internal/RequestTests.java
+++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/RequestTests.java
@@ -1,12 +1,12 @@
package ly.count.sdk.java.internal;
+import java.lang.reflect.Field;
import java.net.URL;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
-import org.powermock.reflect.Whitebox;
@RunWith(JUnit4.class)
public class RequestTests {
@@ -18,31 +18,36 @@ public void setupEveryTest() throws Exception {
url = new URL(urlString);
}
+ private static String getEOR() throws Exception {
+ Field field = Request.class.getDeclaredField("EOR");
+ field.setAccessible(true);
+ return (String) field.get(null);
+ }
+
@Test
- public void request_constructorString() throws Exception {
+ public void request_constructorString() {
String paramVals = "a=1&b=2";
Params params = new Params(paramVals);
- Request request = Whitebox.invokeConstructor(Request.class, paramVals);
+ Request request = new Request(paramVals);
Params requestParams = request.params;
Assert.assertEquals(params.toString(), requestParams.toString());
}
@Test
- public void request_constructorObjectsNull() throws Exception {
+ public void request_constructorObjectsNull() {
String[] paramsVals = new String[] { "asd", "123" };
- Object[] vals = new Object[] { new Object[] { paramsVals[0], paramsVals[1] } };
- Request request = Whitebox.invokeConstructor(Request.class, vals);
+ Request request = new Request((Object[]) new Object[] { paramsVals[0], paramsVals[1] });
Assert.assertEquals(paramsVals[0] + "=" + paramsVals[1], request.params.toString());
}
@Test
- public void request_constructorObjects() throws Exception {
+ public void request_constructorObjects() {
String[] paramsParts = new String[] { "abc", "123", "qwe", "456" };
String paramVals = paramsParts[0] + "=" + paramsParts[1] + "&" + paramsParts[2] + "=" + paramsParts[3];
Params params = new Params(paramVals);
- Request request = Whitebox.invokeConstructor(Request.class, paramsParts[0], paramsParts[1], paramsParts[2], paramsParts[3]);
+ Request request = new Request(paramsParts[0], paramsParts[1], paramsParts[2], paramsParts[3]);
Params requestParams = request.params;
Assert.assertEquals(params.toString(), requestParams.toString());
}
@@ -61,17 +66,17 @@ public void request_build() {
@Test
public void request_serialize() throws Exception {
String paramVals = "a=1&b=2";
- Request request = Whitebox.invokeConstructor(Request.class, paramVals);
+ Request request = new Request(paramVals);
- String manualSerialization = paramVals + Whitebox.getInternalState(Request.class, "EOR");
+ String manualSerialization = paramVals + getEOR();
String serializationRes = new String(request.store(null));
Assert.assertEquals(manualSerialization, serializationRes);
}
@Test
- public void request_loadSimple() throws Exception {
+ public void request_loadSimple() {
String paramVals = "a=1&b=2";
- Request request = Whitebox.invokeConstructor(Request.class, paramVals);
+ Request request = new Request(paramVals);
byte[] serializationRes = request.store(null);
Request requestNew = new Request();
@@ -92,13 +97,13 @@ public void request_loadNull() {
}
@Test
- public void isGettable_ParamsEmptyUnderLimit() throws Exception {
- Request request = Whitebox.invokeConstructor(Request.class, "");
+ public void isGettable_ParamsEmptyUnderLimit() {
+ Request request = new Request("");
Assert.assertTrue(request.isGettable(url, 0));
}
@Test
- public void isGettable_ParamsFilledAboveLimitLarge() throws Exception {
+ public void isGettable_ParamsFilledAboveLimitLarge() {
StringBuilder sbParams = new StringBuilder();
for (int a = 0; a < 1000; a++) {
@@ -109,8 +114,8 @@ public void isGettable_ParamsFilledAboveLimitLarge() throws Exception {
sbParams.append('=').append(a);
}
- Request request = Whitebox.invokeConstructor(Request.class, sbParams.toString());
+ Request request = new Request(sbParams.toString());
Assert.assertFalse(request.isGettable(url, 0));
}
-}
\ No newline at end of file
+}
diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInitRecoveryTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInitRecoveryTests.java
new file mode 100644
index 00000000..da006423
--- /dev/null
+++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInitRecoveryTests.java
@@ -0,0 +1,359 @@
+package ly.count.sdk.java.internal;
+
+import java.io.File;
+import ly.count.sdk.java.Config;
+import ly.count.sdk.java.Countly;
+import org.json.JSONObject;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for SDK initialization ordering and recovery scenarios.
+ *
+ * These tests verify that the SDK initializes correctly when leftover
+ * crash files or session files exist from a previous run. This covers
+ * the fix for GitHub issue #263 (NPE in SDKCore.recover() when a crash
+ * file exists) and related initialization ordering concerns.
+ */
+@RunWith(JUnit4.class)
+public class ScenarioInitRecoveryTests {
+
+ /** Time to wait for async storage/networking operations to settle */
+ private static final int ASYNC_SETTLE_MS = 200;
+
+ @Before
+ public void beforeTest() {
+ TestUtils.createCleanTestState();
+ }
+
+ @After
+ public void stop() {
+ Countly.instance().halt();
+ }
+
+ // ── Test helpers ────────────────────────────────────────────────────
+
+ private JSONObject createCrashData(String error, boolean nonfatal) {
+ JSONObject data = new JSONObject();
+ data.put("_error", error);
+ data.put("_nonfatal", nonfatal);
+ return data;
+ }
+
+ private long plantCrashFile(JSONObject crashData) {
+ long crashId = TimeUtils.uniqueTimestampMs();
+ TestUtils.writeToFile("crash_" + crashId, crashData.toString());
+ return crashId;
+ }
+
+ private long plantCrashFileAndInit(Config config) throws InterruptedException {
+ long crashId = plantCrashFile(createCrashData("java.lang.RuntimeException: test", false));
+ Countly.instance().init(config);
+ Thread.sleep(ASYNC_SETTLE_MS);
+ return crashId;
+ }
+
+ private void assertCrashFileRemoved(long crashId) {
+ File crashFile = new File(TestUtils.getTestSDirectory(), "[CLY]_crash_" + crashId);
+ Assert.assertFalse("Crash file " + crashId + " should be removed after recovery", crashFile.exists());
+ }
+
+ private void assertCrashFileExists(long crashId) {
+ File crashFile = new File(TestUtils.getTestSDirectory(), "[CLY]_crash_" + crashId);
+ Assert.assertTrue("Crash file " + crashId + " should still exist", crashFile.exists());
+ }
+
+ private void assertMinRequestFiles(int expectedMin) {
+ File testDir = TestUtils.getTestSDirectory();
+ File[] requestFiles = testDir.listFiles((dir, name) -> name.startsWith("[CLY]_request_"));
+ Assert.assertNotNull("Request file listing should not be null", requestFiles);
+ Assert.assertTrue("Expected at least " + expectedMin + " request file(s), found " + requestFiles.length,
+ requestFiles.length >= expectedMin);
+ }
+
+ private void withNullNetworking(Runnable action) {
+ Networking saved = SDKCore.instance.networking;
+ SDKCore.instance.networking = null;
+ try {
+ action.run();
+ } finally {
+ SDKCore.instance.networking = saved;
+ }
+ }
+
+ // ── Crash recovery tests ────────────────────────────────────────────
+
+ /**
+ * "init_withExistingCrashFile"
+ * Primary regression test for issue #263.
+ * Crash file should be converted into a request and removed from disk.
+ */
+ @Test
+ public void init_withExistingCrashFile() throws InterruptedException {
+ JSONObject crashData = createCrashData(
+ "java.lang.RuntimeException: test crash\n\tat com.test.App.main(App.java:10)", false);
+ crashData.put("_os", "Java");
+ crashData.put("_os_version", "17");
+ crashData.put("_device", "TestDevice");
+
+ long crashId = plantCrashFile(crashData);
+ assertCrashFileExists(crashId);
+
+ Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting));
+ Thread.sleep(ASYNC_SETTLE_MS);
+
+ assertCrashFileRemoved(crashId);
+ assertMinRequestFiles(1);
+ }
+
+ /**
+ * "init_withExistingCrashFile_noFeatureEnabled"
+ * Crash file recovery happens at SDKCore level regardless of feature flags.
+ * The crash file should still be processed and removed.
+ */
+ @Test
+ public void init_withExistingCrashFile_noFeatureEnabled() throws InterruptedException {
+ long crashId = plantCrashFile(createCrashData("java.lang.NullPointerException: test", true));
+
+ Countly.instance().init(TestUtils.getBaseConfig());
+ Thread.sleep(ASYNC_SETTLE_MS);
+
+ Assert.assertTrue("SDK should be initialized", Countly.isInitialized());
+ assertCrashFileRemoved(crashId);
+ assertMinRequestFiles(1);
+ }
+
+ /**
+ * "init_withMultipleCrashFiles"
+ * All crash files should be converted to requests and removed.
+ */
+ @Test
+ public void init_withMultipleCrashFiles() throws InterruptedException {
+ long[] crashIds = new long[3];
+ for (int i = 0; i < 3; i++) {
+ crashIds[i] = plantCrashFile(createCrashData("Exception #" + i, i % 2 == 0));
+ }
+
+ Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting));
+ Thread.sleep(ASYNC_SETTLE_MS);
+
+ for (int i = 0; i < 3; i++) {
+ assertCrashFileRemoved(crashIds[i]);
+ }
+ assertMinRequestFiles(3);
+ }
+
+ // ── Session recovery tests ──────────────────────────────────────────
+
+ /**
+ * "init_withExistingSessionFile"
+ * Session recovery calls session.end() -> onSignal(Ping) -> networking.check().
+ * Uses stop() (not halt()) to preserve session files on disk.
+ */
+ @Test
+ public void init_withExistingSessionFile() throws InterruptedException {
+ Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Sessions));
+ Countly.instance().session().begin();
+ Thread.sleep(ASYNC_SETTLE_MS);
+
+ // stop() preserves files; halt() would delete them
+ Countly.instance().stop();
+
+ Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Sessions));
+ Thread.sleep(ASYNC_SETTLE_MS);
+
+ Assert.assertTrue("SDK should be initialized after session recovery", Countly.isInitialized());
+ }
+
+ /**
+ * "init_withCrashAndSessionFiles"
+ * Both crash files and session files present simultaneously.
+ * Verifies recover() processes both crash loop and session loop without interference.
+ */
+ @Test
+ public void init_withCrashAndSessionFiles() throws InterruptedException {
+ // First init: create a session file
+ Countly.instance().init(TestUtils.getBaseConfig()
+ .enableFeatures(Config.Feature.Sessions, Config.Feature.CrashReporting));
+ Countly.instance().session().begin();
+ Thread.sleep(ASYNC_SETTLE_MS);
+ Countly.instance().stop();
+
+ // Plant a crash file on top of the leftover session file
+ long crashId = plantCrashFile(createCrashData("java.lang.RuntimeException: dual recovery", false));
+
+ // Re-init should recover both
+ Countly.instance().init(TestUtils.getBaseConfig()
+ .enableFeatures(Config.Feature.Sessions, Config.Feature.CrashReporting));
+ Thread.sleep(ASYNC_SETTLE_MS);
+
+ Assert.assertTrue("SDK should initialize with both crash and session files", Countly.isInitialized());
+ assertCrashFileRemoved(crashId);
+ }
+
+ // ── Corrupt/empty file resilience ───────────────────────────────────
+
+ /**
+ * "init_withCorruptCrashFile"
+ * Corrupt crash file should not block initialization.
+ * processCrash returns false when Storage.read fails, so the file is NOT removed
+ * (the remove only happens after successful push). The SDK should still init.
+ */
+ @Test
+ public void init_withCorruptCrashFile() throws InterruptedException {
+ long crashId = System.currentTimeMillis() - 1000;
+ TestUtils.writeToFile("crash_" + crashId, "this is not valid json {{{");
+
+ Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting));
+ Thread.sleep(ASYNC_SETTLE_MS);
+
+ Assert.assertTrue("SDK should be initialized even with corrupt crash file", Countly.isInitialized());
+ // Corrupt file is NOT cleaned up by processCrash (it returns false at the null check)
+ // This is expected — the file will be retried on next init
+ }
+
+ /**
+ * "init_emptyCrashFile"
+ * Empty crash file should not cause initialization failure.
+ */
+ @Test
+ public void init_emptyCrashFile() throws InterruptedException {
+ long crashId = System.currentTimeMillis() - 1000;
+ TestUtils.writeToFile("crash_" + crashId, "");
+
+ Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting));
+ Thread.sleep(ASYNC_SETTLE_MS);
+
+ Assert.assertTrue("SDK should initialize with empty crash file", Countly.isInitialized());
+ }
+
+ // ── Feature combination tests ───────────────────────────────────────
+
+ /**
+ * "init_withCrashFile_remoteConfigEnabled"
+ * Exercises both crash recovery and ModuleRemoteConfig.initFinished()
+ * needing networking to be ready.
+ */
+ @Test
+ public void init_withCrashFile_remoteConfigEnabled() throws InterruptedException {
+ long crashId = plantCrashFileAndInit(TestUtils.getBaseConfig()
+ .enableFeatures(Config.Feature.CrashReporting, Config.Feature.RemoteConfig));
+
+ Assert.assertTrue("SDK should initialize with crash file + remote config", Countly.isInitialized());
+ assertCrashFileRemoved(crashId);
+ assertMinRequestFiles(1);
+ }
+
+ /**
+ * "init_withCrashFile_locationEnabled"
+ * ModuleLocation.initFinished() -> sendLocation() -> onSignal(Ping) -> networking.check().
+ */
+ @Test
+ public void init_withCrashFile_locationEnabled() throws InterruptedException {
+ long crashId = plantCrashFileAndInit(TestUtils.getBaseConfig()
+ .enableFeatures(Config.Feature.CrashReporting, Config.Feature.Location)
+ .setLocation("US", "New York", "40.7128,-74.0060", null));
+
+ Assert.assertTrue("SDK should initialize with crash file + location", Countly.isInitialized());
+ assertCrashFileRemoved(crashId);
+ assertMinRequestFiles(1);
+ }
+
+ /**
+ * "init_withCrashFile_allFeaturesEnabled"
+ * Stress test: all module initFinished() paths exercised simultaneously.
+ */
+ @Test
+ public void init_withCrashFile_allFeaturesEnabled() throws InterruptedException {
+ long crashId = plantCrashFileAndInit(TestUtils.getBaseConfig()
+ .enableFeatures(
+ Config.Feature.CrashReporting,
+ Config.Feature.Events,
+ Config.Feature.Sessions,
+ Config.Feature.Views,
+ Config.Feature.Location,
+ Config.Feature.RemoteConfig,
+ Config.Feature.Feedback
+ ));
+
+ Assert.assertTrue("SDK should initialize with crash file + all features", Countly.isInitialized());
+ assertCrashFileRemoved(crashId);
+ assertMinRequestFiles(1);
+ }
+
+ // ── Networking null-safety tests ────────────────────────────────────
+
+ /**
+ * "init_networkingNullSafety_onSignalDID"
+ * Validates the null guard in SDKCore.onSignal(config, id) for DID signal.
+ */
+ @Test
+ public void init_networkingNullSafety_onSignalDID() {
+ Countly.instance().init(TestUtils.getBaseConfig());
+
+ withNullNetworking(() ->
+ SDKCore.instance.onSignal(SDKCore.instance.config, SDKCore.Signal.DID.getIndex()));
+
+ Assert.assertTrue("SDK should remain functional", Countly.isInitialized());
+ }
+
+ /**
+ * "init_networkingNullSafety_onSignalPing"
+ * Validates the null guard in SDKCore.onSignal(config, id, param) for Ping signal.
+ */
+ @Test
+ public void init_networkingNullSafety_onSignalPing() {
+ Countly.instance().init(TestUtils.getBaseConfig());
+
+ withNullNetworking(() ->
+ SDKCore.instance.onSignal(SDKCore.instance.config, SDKCore.Signal.Ping.getIndex(), null));
+
+ Assert.assertTrue("SDK should remain functional", Countly.isInitialized());
+ }
+
+ /**
+ * "init_networkingNullSafety_processCrash"
+ * Plants a crash file AFTER init, nulls networking, then triggers the crash signal.
+ * This exercises the null guard inside processCrash() directly.
+ */
+ @Test
+ public void init_networkingNullSafety_processCrash() throws InterruptedException {
+ Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting));
+ Thread.sleep(ASYNC_SETTLE_MS);
+
+ // Plant crash after init so it hasn't been processed yet
+ long crashId = plantCrashFile(createCrashData("java.lang.RuntimeException: post-init crash", false));
+
+ withNullNetworking(() ->
+ SDKCore.instance.onSignal(SDKCore.instance.config, SDKCore.Signal.Crash.getIndex(), String.valueOf(crashId)));
+
+ Assert.assertTrue("SDK should remain functional after processCrash with null networking", Countly.isInitialized());
+ }
+
+ // ── Regression tests ────────────────────────────────────────────────
+
+ /**
+ * "init_repeatInit_withCrashFile"
+ * Verifies crash file is removed on first init so it doesn't permanently block startup.
+ * This was the user-visible symptom of issue #263.
+ */
+ @Test
+ public void init_repeatInit_withCrashFile() throws InterruptedException {
+ long crashId = plantCrashFileAndInit(
+ TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting));
+
+ Assert.assertTrue("First init should succeed", Countly.isInitialized());
+ assertCrashFileRemoved(crashId);
+
+ Countly.instance().halt();
+
+ // Second init — no crash file to recover
+ Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting));
+ Thread.sleep(ASYNC_SETTLE_MS);
+ Assert.assertTrue("Second init should succeed without leftover crash files", Countly.isInitialized());
+ }
+}
diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioNetworkDeadlockTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioNetworkDeadlockTests.java
new file mode 100644
index 00000000..5a2cd31a
--- /dev/null
+++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioNetworkDeadlockTests.java
@@ -0,0 +1,202 @@
+package ly.count.sdk.java.internal;
+
+import com.sun.net.httpserver.HttpServer;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.util.concurrent.atomic.AtomicInteger;
+import ly.count.sdk.java.Config;
+import ly.count.sdk.java.Countly;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * End-to-end scenario tests for GitHub issue #264:
+ * "Non-JSON Server Response Causes Permanent Networking Deadlock"
+ *
+ * These tests spin up a local HTTP server that returns non-JSON responses
+ * (simulating 502/503 error pages), then verify the SDK recovers gracefully
+ * and resumes sending requests when the server comes back.
+ */
+@RunWith(JUnit4.class)
+public class ScenarioNetworkDeadlockTests {
+
+ private HttpServer server;
+ private int port;
+
+ @Before
+ public void setUp() throws Exception {
+ TestUtils.createCleanTestState();
+ }
+
+ @After
+ public void tearDown() {
+ Countly.instance().halt();
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ /**
+ * Start a local HTTP server with a custom handler.
+ * Returns the port number.
+ */
+ private void startServer(ServerBehavior behavior) throws Exception {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ port = server.getAddress().getPort();
+ server.createContext("/", exchange -> {
+ Response resp = behavior.respond();
+ exchange.sendResponseHeaders(resp.code, resp.body.length());
+ OutputStream os = exchange.getResponseBody();
+ os.write(resp.body.getBytes());
+ os.close();
+ });
+ server.start();
+ }
+
+ private Config configForLocalServer() {
+ return new Config("http://localhost:" + port, TestUtils.SERVER_APP_KEY, TestUtils.getTestSDirectory())
+ .setLoggingLevel(Config.LoggingLevel.VERBOSE)
+ .setDeviceIdStrategy(Config.DeviceIdStrategy.UUID)
+ .enableFeatures(Config.Feature.Events, Config.Feature.Sessions)
+ .setEventQueueSizeToSend(1);
+ }
+
+ // ==================== Scenario tests ====================
+
+ /**
+ * Helper: starts a server with the given response, inits the SDK, records an event,
+ * waits for the request round-trip, and asserts isSending() is false.
+ */
+ private void assertResponseDoesNotDeadlock(int code, String body) throws Exception {
+ startServer(() -> new Response(code, body));
+
+ Countly.instance().init(configForLocalServer());
+ Countly.session().begin();
+
+ Countly.instance().events().recordEvent("test_event");
+ Thread.sleep(2000);
+
+ Assert.assertFalse(
+ "SDK networking should NOT be stuck after response [" + code + ": " + body.substring(0, Math.min(body.length(), 40)) + "]",
+ SDKCore.instance.networking.isSending()
+ );
+ }
+
+ /**
+ * Server returns HTML 502 error — the primary scenario from issue #264.
+ * Before the fix: JSONException propagates, executor deadlocks permanently.
+ */
+ @Test
+ public void html502Response_sdkDoesNotDeadlock() throws Exception {
+ assertResponseDoesNotDeadlock(502, "502 Bad Gateway
");
+ }
+
+ /** Plain text response from a load balancer should not deadlock. */
+ @Test
+ public void plainTextResponse_sdkDoesNotDeadlock() throws Exception {
+ assertResponseDoesNotDeadlock(200, "OK");
+ }
+
+ /** Empty response body should not deadlock. */
+ @Test
+ public void emptyResponse_sdkDoesNotDeadlock() throws Exception {
+ assertResponseDoesNotDeadlock(200, "");
+ }
+
+ /** Valid JSON with non-2xx code should not deadlock. */
+ @Test
+ public void jsonErrorResponse_sdkDoesNotDeadlock() throws Exception {
+ assertResponseDoesNotDeadlock(500, "{\"result\":\"Internal Server Error\"}");
+ }
+
+ /**
+ * Scenario: Server initially returns HTML, then starts returning valid JSON.
+ * Verifies the SDK can resume sending after transient 502 errors.
+ */
+ @Test
+ public void serverRecovery_sdkResumesSending() throws Exception {
+ AtomicInteger requestCount = new AtomicInteger(0);
+ AtomicInteger successCount = new AtomicInteger(0);
+
+ startServer(() -> {
+ int count = requestCount.incrementAndGet();
+ if (count <= 1) {
+ return new Response(502, "502 Bad Gateway
");
+ } else {
+ successCount.incrementAndGet();
+ return new Response(200, "{\"result\":\"Success\"}");
+ }
+ });
+
+ Countly.instance().init(configForLocalServer());
+ Countly.session().begin();
+ Thread.sleep(2000);
+
+ Assert.assertFalse(
+ "SDK should recover from 502 HTML response",
+ SDKCore.instance.networking.isSending()
+ );
+
+ for (int i = 0; i < 5; i++) {
+ if (!SDKCore.instance.networking.isSending()) {
+ SDKCore.instance.networking.check(SDKCore.instance.config);
+ }
+ Thread.sleep(1000);
+ }
+
+ Assert.assertTrue(
+ "SDK should have sent requests after server recovered (got " + successCount.get() + " successes)",
+ successCount.get() > 0
+ );
+ }
+
+ /**
+ * Scenario: Server closes connection abruptly without sending a response body.
+ * Transport.response() returns null, which used to cause NPE in processResponse().
+ * The catch(Exception) in send() is the last safety net for this path.
+ */
+ @Test
+ public void connectionReset_sdkDoesNotDeadlock() throws Exception {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ port = server.getAddress().getPort();
+ server.createContext("/", exchange -> {
+ // Send headers but close immediately — some JVMs produce IOException,
+ // others may produce unexpected exceptions in the response reader
+ exchange.sendResponseHeaders(200, 0);
+ exchange.getResponseBody().close();
+ });
+ server.start();
+
+ Countly.instance().init(configForLocalServer());
+ Countly.session().begin();
+
+ Countly.instance().events().recordEvent("test_event");
+ Thread.sleep(2000);
+
+ Assert.assertFalse(
+ "SDK networking should NOT be stuck after abrupt connection close",
+ SDKCore.instance.networking.isSending()
+ );
+ }
+
+ // ==================== Helpers ====================
+
+ @FunctionalInterface
+ interface ServerBehavior {
+ Response respond();
+ }
+
+ static class Response {
+ final int code;
+ final String body;
+
+ Response(int code, String body) {
+ this.code = code;
+ this.body = body;
+ }
+ }
+}
diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java
index 859d50c3..74b38403 100644
--- a/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java
+++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java
@@ -629,6 +629,81 @@ public void view_stopStartedAndNext() {
ModuleViewsTests.validateView("next", 0.0, 2, 3, false, true, null, TestUtils.keysValues[1], TestUtils.keysValues[0]);
}
+ /**
+ * Validates that when session calls are made, if any user properties are set,
+ * they are sent before sending that session call
+ * Validated with all session calls: begin, update, end
+ *
+ * @throws InterruptedException if thread is interrupted
+ */
+ @Test
+ public void userPropsOnSessions() throws InterruptedException {
+ Countly.instance().init(TestUtils.getConfigSessions(Config.Feature.UserProfiles));
+ Countly.instance().userProfile().setProperty("name", "John Doe");
+ Countly.instance().userProfile().setProperty("custom_key", "custom_value");
+
+ Countly.session().begin();
+ Map[] RQ = TestUtils.getCurrentRQ();
+ UserEditorTests.validateUserDetailsRequestInRQ(TestUtils.map("user_details", TestUtils.json("name", "John Doe", "custom", TestUtils.map("custom_key", "custom_value"))), 0, 2);
+ Assert.assertEquals("1", RQ[1].get("begin_session"));
+
+ Thread.sleep(2000); // wait for session to update
+ Countly.instance().userProfile().save();
+ RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(2, RQ.length); // Validate that user properties are flushed
+
+ Countly.instance().userProfile().setProperty("email", "john@doe.com");
+ Countly.session().update();
+
+ RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(TestUtils.json("email", "john@doe.com"), RQ[2].get("user_details"));
+ Assert.assertEquals("2", RQ[3].get("session_duration"));
+
+ Thread.sleep(2000); // wait for session to update
+ Countly.instance().userProfile().save();
+ RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(4, RQ.length); // Validate that user properties are flushed with update call
+
+ Countly.instance().userProfile().setProperty("done", "yes");
+ Countly.session().end();
+
+ RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(TestUtils.json("custom", TestUtils.map("done", "yes")), RQ[4].get("user_details"));
+ Assert.assertEquals("1", RQ[5].get("end_session"));
+ }
+
+ /**
+ * Validates that when session calls are made, if any user properties are set,
+ * they are not packed because auto-send is disabled
+ *
+ * @throws InterruptedException if thread is interrupted
+ */
+ @Test
+ public void userPropsOnSessions_reversed() throws InterruptedException {
+ Countly.instance().init(TestUtils.getConfigSessions(Config.Feature.UserProfiles).disableAutoSendUserProperties());
+ Countly.instance().userProfile().setProperty("name", "John Doe");
+ Countly.instance().userProfile().setProperty("custom_key", "custom_value");
+
+ Countly.session().begin();
+ Map[] RQ = TestUtils.getCurrentRQ();
+ Assert.assertEquals(1, RQ.length);
+ Assert.assertEquals("1", RQ[0].get("begin_session"));
+
+ Thread.sleep(2000); // wait for session to update
+ Countly.session().update();
+ RQ = TestUtils.getCurrentRQ();
+
+ Assert.assertEquals(2, RQ.length);
+ Assert.assertEquals("2", RQ[1].get("session_duration"));
+
+ Thread.sleep(2000);
+ Countly.session().end();
+ RQ = TestUtils.getCurrentRQ();
+
+ Assert.assertEquals(3, RQ.length);
+ Assert.assertEquals("1", RQ[2].get("end_session"));
+ }
+
private void validateNotEquals(int idOffset, BiFunction> setter) {
Countly.instance().init(TestUtils.getConfigSessions());
long ts = TimeUtils.timestampMs();
diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksExceptionRecoveryTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksExceptionRecoveryTests.java
new file mode 100644
index 00000000..af67ec05
--- /dev/null
+++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksExceptionRecoveryTests.java
@@ -0,0 +1,290 @@
+package ly.count.sdk.java.internal;
+
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests that the Tasks executor properly recovers from exceptions thrown by tasks.
+ * These tests verify the fix for issue #264 where an uncaught exception in a task
+ * would leave the "running" state permanently set, causing a networking deadlock.
+ */
+@RunWith(JUnit4.class)
+public class TasksExceptionRecoveryTests {
+ private Tasks tasks;
+
+ @Before
+ public void setUp() {
+ tasks = new Tasks("test-recovery", null);
+ }
+
+ @After
+ public void tearDown() {
+ tasks.shutdown();
+ }
+
+ /**
+ * When a task throws a RuntimeException, the executor should recover
+ * and isRunning() should return false after the task completes.
+ * This is the core scenario from issue #264.
+ */
+ @Test
+ public void taskRuntimeException_executorRecovers() throws Exception {
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() {
+ throw new RuntimeException("Simulated JSONException from non-JSON response");
+ }
+ });
+
+ Thread.sleep(200);
+ Assert.assertFalse("Executor should not be stuck in running state after RuntimeException", tasks.isRunning());
+ }
+
+ /**
+ * After a task throws an exception, the executor should be able to
+ * successfully run subsequent tasks.
+ */
+ @Test
+ public void taskException_subsequentTaskSucceeds() throws Exception {
+ // First task: throws exception
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() {
+ throw new RuntimeException("Simulated failure");
+ }
+ });
+
+ Thread.sleep(200);
+
+ // Second task: should succeed
+ final boolean[] secondTaskRan = { false };
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() {
+ secondTaskRan[0] = true;
+ return true;
+ }
+ });
+
+ Thread.sleep(200);
+ Assert.assertTrue("Subsequent task should have executed after previous task threw exception", secondTaskRan[0]);
+ Assert.assertFalse("Executor should not be running after second task completes", tasks.isRunning());
+ }
+
+ /**
+ * When a task throws a NullPointerException (e.g., from SDKCore.instance being null),
+ * the executor should recover.
+ */
+ @Test
+ public void taskNullPointerException_executorRecovers() throws Exception {
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() {
+ String nullStr = null;
+ nullStr.length(); // throws NPE
+ return true;
+ }
+ });
+
+ Thread.sleep(200);
+ Assert.assertFalse("Executor should recover from NullPointerException", tasks.isRunning());
+ }
+
+ /**
+ * When a task with a non-zero ID throws an exception, it should be
+ * removed from the pending map so new tasks with the same ID can be submitted.
+ */
+ @Test
+ public void taskWithId_exceptionClearsPending() throws Exception {
+ Long taskId = 42L;
+
+ // First task with ID: throws exception
+ tasks.run(new Tasks.Task(taskId) {
+ @Override
+ public Boolean call() {
+ throw new RuntimeException("Simulated failure");
+ }
+ });
+
+ Thread.sleep(200);
+
+ // Second task with same ID: should be accepted and run (not deduplicated against the failed one)
+ final boolean[] secondTaskRan = { false };
+ Future future = tasks.run(new Tasks.Task(taskId) {
+ @Override
+ public Boolean call() {
+ secondTaskRan[0] = true;
+ return true;
+ }
+ });
+
+ Thread.sleep(200);
+ Assert.assertTrue("Task with same ID should run after previous one failed", secondTaskRan[0]);
+ Assert.assertFalse("Executor should not be running", tasks.isRunning());
+ }
+
+ /**
+ * When a callback throws an exception, the executor should still recover
+ * and not deadlock. The callback runs inside the try block, so its exception
+ * is caught by the finally block.
+ */
+ @Test
+ public void callbackException_executorRecovers() throws Exception {
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() {
+ return true;
+ }
+ }, result -> {
+ throw new RuntimeException("Simulated callback failure");
+ });
+
+ Thread.sleep(200);
+ Assert.assertFalse("Executor should recover from callback exception", tasks.isRunning());
+ }
+
+ /**
+ * After a callback throws an exception, subsequent tasks should still execute.
+ */
+ @Test
+ public void callbackException_subsequentTaskSucceeds() throws Exception {
+ // First task: succeeds but callback throws
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() {
+ return true;
+ }
+ }, result -> {
+ throw new RuntimeException("Callback failure");
+ });
+
+ Thread.sleep(200);
+
+ // Second task: should succeed
+ final boolean[] secondTaskRan = { false };
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() {
+ secondTaskRan[0] = true;
+ return true;
+ }
+ });
+
+ Thread.sleep(200);
+ Assert.assertTrue("Task should run after previous callback threw exception", secondTaskRan[0]);
+ }
+
+ /**
+ * Multiple consecutive failing tasks should not accumulate stuck state.
+ * The executor should recover after each one.
+ */
+ @Test
+ public void multipleConsecutiveFailures_executorRecovers() throws Exception {
+ for (int i = 0; i < 5; i++) {
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() {
+ throw new RuntimeException("Failure #" + System.currentTimeMillis());
+ }
+ });
+ }
+
+ Thread.sleep(500);
+ Assert.assertFalse("Executor should recover after multiple consecutive failures", tasks.isRunning());
+
+ // Verify executor still works
+ final boolean[] taskRan = { false };
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() {
+ taskRan[0] = true;
+ return true;
+ }
+ });
+
+ Thread.sleep(200);
+ Assert.assertTrue("Executor should still work after multiple failures", taskRan[0]);
+ }
+
+ /**
+ * When a task throws a checked Exception (e.g. IOException), the executor
+ * should recover. The original bug equally applied to checked exceptions since
+ * the cleanup code was not in a finally block. The ExecutorService wraps
+ * checked exceptions in ExecutionException, and running must still be reset.
+ */
+ @Test
+ public void taskCheckedException_executorRecovers() throws Exception {
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() throws Exception {
+ throw new IOException("Simulated I/O failure during request");
+ }
+ });
+
+ Thread.sleep(200);
+ Assert.assertFalse("Executor should recover from checked IOException", tasks.isRunning());
+
+ // Verify executor still works after checked exception
+ final boolean[] taskRan = { false };
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() {
+ taskRan[0] = true;
+ return true;
+ }
+ });
+
+ Thread.sleep(200);
+ Assert.assertTrue("Subsequent task should run after checked exception", taskRan[0]);
+ }
+
+ /**
+ * Deterministic test for volatile correctness of the "running" field.
+ * Uses a CountDownLatch instead of Thread.sleep to verify that the calling
+ * thread sees running=null immediately after the task signals completion.
+ * Without volatile, a stale cached value could cause isRunning() to return
+ * true even though the executor thread already set running=null.
+ */
+ @Test
+ public void volatileCorrectness_isRunningVisibleAcrossThreads() throws Exception {
+ CountDownLatch taskStarted = new CountDownLatch(1);
+ CountDownLatch taskCanFinish = new CountDownLatch(1);
+
+ // Submit a task that signals when it starts, then waits for permission to finish
+ tasks.run(new Tasks.Task(0L) {
+ @Override
+ public Boolean call() throws Exception {
+ taskStarted.countDown();
+ taskCanFinish.await(5, TimeUnit.SECONDS);
+ return true;
+ }
+ });
+
+ // Wait for the task to start executing on the executor thread
+ Assert.assertTrue("Task should have started", taskStarted.await(2, TimeUnit.SECONDS));
+
+ // While the task is running, isRunning() must be true
+ Assert.assertTrue("isRunning() should be true while task is executing", tasks.isRunning());
+
+ // Allow the task to finish
+ taskCanFinish.countDown();
+
+ // Submit a no-op task and wait for it — this guarantees the previous task
+ // (including its finally block) has fully completed
+ tasks.await();
+
+ // Without volatile, this read from the test thread could see the stale value
+ Assert.assertFalse(
+ "isRunning() should be false immediately after task completes (volatile visibility)",
+ tasks.isRunning()
+ );
+ }
+}
diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksTests.java
index bcbce23f..6cbf8825 100644
--- a/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksTests.java
+++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksTests.java
@@ -1,5 +1,6 @@
package ly.count.sdk.java.internal;
+import java.lang.reflect.Field;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import org.junit.After;
@@ -8,7 +9,6 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
-import org.powermock.reflect.Whitebox;
@RunWith(JUnit4.class)
public class TasksTests {
@@ -24,14 +24,20 @@ public void tearDown() throws Exception {
tasks.shutdown();
}
+ private static Object getField(Object target, String fieldName) throws Exception {
+ Field field = target.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return field.get(target);
+ }
+
@Test
- public void testSetup() {
- Assert.assertNotNull(Whitebox.getInternalState(tasks, "executor"));
- Assert.assertNotNull(Whitebox.getInternalState(tasks, "pending"));
+ public void testSetup() throws Exception {
+ Assert.assertNotNull(getField(tasks, "executor"));
+ Assert.assertNotNull(getField(tasks, "pending"));
}
@Test
- public void testShutdown() {
+ public void testShutdown() throws Exception {
Tasks other = new Tasks("test", null);
other.run(new Tasks.Task