Skip to content
Merged
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
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@

<!-- GPU-accelerated LLM inference on Qualcomm SoCs -->
<uses-native-library android:name="libOpenCL.so" android:required="false" />
<uses-native-library android:name="libvndksupport.so" android:required="false" />
<uses-native-library android:name="libcdsprpc.so" android:required="false" />
<activity
android:name=".MainActivity"
Expand Down
59 changes: 36 additions & 23 deletions android/app/src/main/java/ai/offgridmobile/litert/LiteRTModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ai.offgridmobile.litert
import android.util.Log
import android.app.ActivityManager
import android.content.Context
import android.os.Build

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

To prevent concurrent access and race conditions on the conversation and engine lifecycles, we should introduce a Mutex to synchronize these operations.

Suggested change
import android.os.Build
import android.os.Build
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
private val lifecycleMutex = Mutex()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed differently — stopGeneration no longer calls closeConversationSafely at all. It now only signals stop synchronously: currentJob?.cancel() + conversation?.cancelProcess(). This eliminates the close/close race architecturally since only resetConversation and unloadModel ever call close(), and those are serialized via currentJob?.join() in closeConversationSafely.

The remaining concurrent-close case (two unloadModel calls racing) is handled by @Volatile on conversation plus a null-first swap: val conv = conversation ?: return; conversation = null. Only one caller gets the non-null handle and proceeds to close().

A Mutex here would also need to guard the conversation = eng.createConversation() assignment in resetConversation to be correct — otherwise a concurrent caller can still acquire the lock after close and later close a freshly created conversation.

import android.os.Debug
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
Expand Down Expand Up @@ -68,7 +69,7 @@ class LiteRTModule(private val reactContext: ReactApplicationContext) :
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

private var engine: Engine? = null
private var conversation: com.google.ai.edge.litertlm.Conversation? = null
@Volatile private var conversation: com.google.ai.edge.litertlm.Conversation? = null
private var activeBackend: String = "cpu"
private var supportsVision: Boolean = false
private var currentJob: Job? = null
Expand Down Expand Up @@ -111,14 +112,18 @@ class LiteRTModule(private val reactContext: ReactApplicationContext) :
}

// 3-tier fallback: NPU → GPU → CPU
private fun buildBackendChain(requested: Backend): List<Backend> = when (requested) {
is Backend.NPU -> listOf(
Backend.NPU(nativeLibraryDir = reactContext.applicationInfo.nativeLibraryDir),
Backend.GPU(),
Backend.CPU(),
)
is Backend.GPU -> listOf(Backend.GPU(), Backend.CPU())
else -> listOf(Backend.CPU())
private fun buildBackendChain(requested: Backend): List<Backend> {
// GPU init crashes on Pixel 10 — open LiteRT SDK bug.
val skipGpu = Build.MODEL?.lowercase()?.contains("pixel 10") == true
return when (requested) {
is Backend.NPU -> listOfNotNull(
Backend.NPU(nativeLibraryDir = reactContext.applicationInfo.nativeLibraryDir),
if (skipGpu) null else Backend.GPU(),
Backend.CPU(),
)
is Backend.GPU -> if (skipGpu) listOf(Backend.CPU()) else listOf(Backend.GPU(), Backend.CPU())
else -> listOf(Backend.CPU())
}
}

private suspend fun tryInitBackend(modelPath: String, backend: Backend, name: String, visionEnabled: Boolean): Boolean {
Expand Down Expand Up @@ -366,18 +371,17 @@ class LiteRTModule(private val reactContext: ReactApplicationContext) :
@ReactMethod
fun stopGeneration(promise: Promise) {
val safe = SafePromise(promise, TAG)
Log.i(TAG, "stopGeneration — tearing down conversation")

scope.launch {
try {
closeConversationSafely()
Log.i(TAG, "stopGeneration — done")
safe.resolve(null)
} catch (e: Exception) {
Log.w(TAG, "stopGeneration — error during teardown: ${e.message}")
safe.resolve(null)
}
Log.i(TAG, "stopGeneration — signalling stop")
// Cancel the coroutine job — CancellationException propagates into sendMessageAsync
currentJob?.cancel()
// Signal the native inference thread to stop — safe to call from any thread
try {
conversation?.cancelProcess()
} catch (e: Exception) {
Log.w(TAG, "stopGeneration — cancelProcess error: ${e.message}")
}
Log.i(TAG, "stopGeneration — done")
safe.resolve(null)
}

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -417,6 +421,7 @@ class LiteRTModule(private val reactContext: ReactApplicationContext) :
// -------------------------------------------------------------------------

private suspend fun closeConversationSafely() {
// Cancel and wait for any running inference job to fully stop before closing
currentJob?.cancel()
currentJob?.join()
currentJob = null
Expand All @@ -427,13 +432,21 @@ class LiteRTModule(private val reactContext: ReactApplicationContext) :
}
pendingToolCalls.clear()

// Null first — any concurrent caller gets null and returns, preventing double-close
val conv = conversation ?: return
conversation = null

// Safety net: signal stop in case stopGeneration was not called before close
try {
conversation?.close()
conv.cancelProcess()
} catch (e: Exception) {
Log.w(TAG, "closeConversationSafely — cancelProcess error: ${e.message}")
}
try {
conv.close()
Log.d(TAG, "closeConversationSafely — closed")
} catch (e: Exception) {
Log.w(TAG, "closeConversationSafely — error: ${e.message}")
} finally {
conversation = null
}
}

Expand Down
56 changes: 54 additions & 2 deletions patches/whisper.rn+0.5.5.patch
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,44 @@ index 0000000..1675490
+connection.project.dir=../../../android
+eclipse.preferences.version=1
diff --git a/node_modules/whisper.rn/android/src/main/java/com/rnwhisper/WhisperContext.java b/node_modules/whisper.rn/android/src/main/java/com/rnwhisper/WhisperContext.java
index a056c22..f4781e9 100644
index a056c22..346cd72 100644
--- a/node_modules/whisper.rn/android/src/main/java/com/rnwhisper/WhisperContext.java
+++ b/node_modules/whisper.rn/android/src/main/java/com/rnwhisper/WhisperContext.java
@@ -465,6 +465,16 @@ public class WhisperContext {
@@ -81,6 +81,11 @@ public class WhisperContext {
private boolean isTdrzEnable = false;
private Thread rootFullHandler = null;
private Thread fullHandler = null;
+ // Guards against finishRealtimeTranscribe running more than once per job. The
+ // buffer-full path can reach finishRealtimeTranscribe at line 185 and again at
+ // the end-of-loop check, which double-removes the native job and crashes in
+ // finishRealtimeTranscribeJob (SIGSEGV). Reset in rewind() per new job.
+ private boolean isRealtimeFinished = false;

public WhisperContext(int id, ReactApplicationContext reactContext, long context) {
this.id = id;
@@ -102,6 +107,7 @@ public class WhisperContext {
isTdrzEnable = false;
rootFullHandler = null;
fullHandler = null;
+ isRealtimeFinished = false;
}

public int getId() {
@@ -117,7 +123,12 @@ public class WhisperContext {
return vadSimple(jobId, sliceIndex, nSamples, n);
}

- private void finishRealtimeTranscribe(WritableMap result) {
+ private synchronized void finishRealtimeTranscribe(WritableMap result) {
+ // Run at most once per job. Multiple code paths in the capture loop can reach
+ // here for the same job; a second call would remove an already-removed native
+ // job and SIGSEGV in finishRealtimeTranscribeJob.
+ if (isRealtimeFinished) return;
+ isRealtimeFinished = true;
emitTranscribeEvent("@RNWhisper_onRealtimeTranscribeEnd", Arguments.createMap());
finishRealtimeTranscribeJob(jobId, context, sliceNSamples.stream().mapToInt(i -> i).toArray());
}
@@ -465,6 +476,16 @@ public class WhisperContext {

public void release() {
stopCurrentTranscribe();
Expand All @@ -27,3 +61,21 @@ index a056c22..f4781e9 100644
freeContext(id, context);
}

diff --git a/node_modules/whisper.rn/android/src/main/jni.cpp b/node_modules/whisper.rn/android/src/main/jni.cpp
index b1fa548..5f0b7f1 100644
--- a/node_modules/whisper.rn/android/src/main/jni.cpp
+++ b/node_modules/whisper.rn/android/src/main/jni.cpp
@@ -485,6 +485,13 @@ Java_com_rnwhisper_WhisperContext_finishRealtimeTranscribeJob(
UNUSED(context_ptr);

rnwhisper::job *job = rnwhisper::job_get(job_id);
+ // The job may already have been removed by fullWithJob completing/aborting,
+ // or by a duplicate finishRealtimeTranscribe call from the realtime thread.
+ // job_get returns nullptr in that case; dereferencing it here is a SIGSEGV.
+ if (job == nullptr) {
+ rnwhisper::job_remove(job_id);
+ return;
+ }
if (job->audio_output_path != nullptr) {
RNWHISPER_LOG_INFO("job->params.language: %s\n", job->params.language);
std::vector<int> slice_n_samples_vec;
8 changes: 7 additions & 1 deletion src/screens/ModelsScreen/TextModelsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import { View, Text, FlatList, TextInput, ActivityIndicator, RefreshControl, TouchableOpacity, InteractionManager, Platform } from 'react-native';
import DeviceInfo from 'react-native-device-info';
import Icon from 'react-native-vector-icons/Feather';
import { AttachStep, useSpotlightTour } from 'react-native-spotlight-tour';
import { Card, ModelCard } from '../../components';
Expand Down Expand Up @@ -244,6 +245,12 @@ const ModelDetailView: React.FC<DetailProps> = ({
</View>
)}
</Card>
{selectedModel.id === LITERT_PARENT_ID && Platform.OS === 'android' && DeviceInfo.getModel().toLowerCase().includes('pixel 10') && (
<Card style={styles.deviceBanner}>
<Icon name="info" size={14} color={colors.trending} />
<Text style={styles.deviceBannerText}>{'GPU acceleration is not yet supported on Pixel 10. Models will run on CPU. Support coming soon.'}</Text>
</Card>
)}
<Text style={styles.sectionTitle}>Available Files</Text>
{selectedModel.id !== LITERT_PARENT_ID && (
<Text style={styles.sectionSubtitle}>
Expand Down Expand Up @@ -378,7 +385,6 @@ export const TextModelsTab: React.FC<Props> = (props) => {
setTypeFilter, setSourceFilter, setSizeFilter, setQuantFilter, setSortOption,
isModelDownloaded, getDownloadedModel, isRepairingVisionModel,
} = props;

const hasNonSortActiveFilters = hasNonSortFilters(filterState);
const currentSort = SORT_OPTIONS.find(o => o.key === filterState.sort) ?? SORT_OPTIONS[0];
const isSortActive = filterState.sort !== 'recommended';
Expand Down
9 changes: 6 additions & 3 deletions src/screens/ModelsScreen/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,18 @@ const createBaseStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
loadingText: { ...TYPOGRAPHY.body, color: colors.textSecondary },
listContent: { paddingHorizontal: 16, paddingBottom: 32 },
deviceBanner: {
backgroundColor: `${colors.primary}12`,
backgroundColor: `${colors.trending}15`,
borderRadius: 8,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
marginBottom: SPACING.lg,
borderWidth: 1,
borderColor: `${colors.primary}30`,
borderColor: `${colors.trending}40`,
flexDirection: 'row' as const,
alignItems: 'center' as const,
gap: SPACING.xs,
},
deviceBannerText: { ...TYPOGRAPHY.meta, color: colors.primary },
deviceBannerText: { ...TYPOGRAPHY.meta, color: colors.trending, flex: 1 },
deviceBannerWarning: { color: colors.error, marginTop: 2 },
emptyCard: { alignItems: 'center' as const, padding: 32 },
emptyText: { color: colors.textSecondary, textAlign: 'center' as const },
Expand Down
Loading