fix mali gpu crash, optimize gpu and PCM buffer boundries, reduce gpu memory pressure by 100mb

This commit is contained in:
Boof2015
2026-07-30 18:08:51 -04:00
parent e2934dbf5c
commit 54732c8b85
7 changed files with 411 additions and 77 deletions
+8
View File
@@ -16,6 +16,7 @@ android {
defaultConfig {
versionCode 1
versionName "0.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
cmake {
cppFlags "-O3 -std=c++17 -fexceptions -frtti"
@@ -24,6 +25,11 @@ android {
}
}
}
packagingOptions {
jniLibs {
pickFirsts += ["**/libc++_shared.so"]
}
}
externalNativeBuild {
cmake {
path "CMakeLists.txt"
@@ -36,4 +42,6 @@ android {
dependencies {
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test:runner:1.6.2'
}
@@ -0,0 +1,79 @@
package expo.modules.astrascope
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.nio.ByteBuffer
import java.nio.ByteOrder
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ScopeBridgeBoundaryTest {
@Test
fun rejectsIncompleteOrOverflowingPcmDescriptions() {
ScopeBridge.nativeConfigure(48_000, 2)
// These calls complete without entering the native reader.
ScopeBridge.nativePushFrames(FloatArray(3), frameCount = 2, channelCount = 2)
ScopeBridge.nativePushFrames(
FloatArray(1),
frameCount = Int.MAX_VALUE,
channelCount = Int.MAX_VALUE
)
ScopeBridge.nativePushFramesPostEq(FloatArray(3), frameCount = 2, channelCount = 2)
ScopeBridge.nativePushFramesPostEq(
FloatArray(1),
frameCount = Int.MAX_VALUE,
channelCount = Int.MAX_VALUE
)
}
@Test
fun capsNativeWritesToTheDirectBufferCapacity() {
ScopeBridge.nativeConfigure(48_000, 1)
ScopeBridge.nativePushFrames(FloatArray(12_000), frameCount = 12_000, channelCount = 1)
val spectrum = directFloats(4)
val oscilloscope = directFloats(4)
val postEqSpectrum = directFloats(4)
assertEquals(
4,
ScopeBridge.nativeFillSpectrum(spectrum, Int.MAX_VALUE, smoothing = 0.92f)
)
assertEquals(
4,
ScopeBridge.nativeFillOscilloscope(oscilloscope, Int.MAX_VALUE)
)
assertEquals(
4,
ScopeBridge.nativeFillSpectrumPostEq(
postEqSpectrum,
Int.MAX_VALUE,
smoothing = 0.92f
)
)
}
@Test
fun rejectsNonDirectUndersizedAndMisalignedBuffers() {
val heap = ByteBuffer.allocate(16).order(ByteOrder.nativeOrder())
val undersized = ByteBuffer.allocateDirect(Float.SIZE_BYTES - 1)
.order(ByteOrder.nativeOrder())
val misaligned = ByteBuffer.allocateDirect(17).apply { position(1) }
.slice()
.order(ByteOrder.nativeOrder())
assertEquals(0, ScopeBridge.nativeFillSpectrum(heap, 4, smoothing = 0.92f))
assertEquals(0, ScopeBridge.nativeFillSpectrum(undersized, 4, smoothing = 0.92f))
assertEquals(0, ScopeBridge.nativeFillSpectrum(misaligned, 4, smoothing = 0.92f))
assertEquals(0, ScopeBridge.nativeFillOscilloscope(heap, 4))
assertEquals(
0,
ScopeBridge.nativeFillSpectrumPostEq(heap, 4, smoothing = 0.92f)
)
}
private fun directFloats(count: Int): ByteBuffer =
ByteBuffer.allocateDirect(count * Float.SIZE_BYTES).order(ByteOrder.nativeOrder())
}
@@ -101,14 +101,13 @@ internal class AstraScopeView(
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textureView = TextureView(context)
private val renderGate = ScopeRenderGate()
private val surfaceSession =
ScopeSurfaceSession<SurfaceTexture, Surface> { surface -> surface.release() }
private val powerManager =
context.getSystemService(Context.POWER_SERVICE) as PowerManager
private var attached = false
private var windowVisible = false
@Volatile
private var surfaceAvailable = false
@Volatile
private var renderSurface: Surface? = null
private var scheduledToken = 0
private var lastAnalysisAt = 0L
private var lastDrawAt = 0L
@@ -153,7 +152,7 @@ internal class AstraScopeView(
if (!renderGate.isCurrent(token)) return
preparePaths()
hasNewFrame = false
publishFrame()
publishFrame(token)
}
if (renderGate.isCurrent(token)) {
@@ -185,7 +184,7 @@ internal class AstraScopeView(
preparePaths()
hasNewFrame = false
lastDrawAt = now
publishFrame()
publishFrame(token)
}
val loopCadence = min(analysisCadence, drawCadence)
@@ -201,10 +200,9 @@ internal class AstraScopeView(
textureView.isOpaque = false
textureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
renderSurface?.release()
renderSurface = Surface(surface)
cancelRendering()
surfaceSession.replace(surface, Surface(surface))
appliedFrameRate = Float.NaN
surfaceAvailable = true
restartRendering()
}
@@ -214,10 +212,11 @@ internal class AstraScopeView(
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
surfaceAvailable = false
// Invalidate queued/running work before waiting for any publication
// already holding the session. close() then releases only this exact
// SurfaceTexture's wrapper, after the in-flight canvas has been posted.
cancelRendering()
renderSurface?.release()
renderSurface = null
surfaceSession.close(surface)
return true
}
@@ -250,6 +249,7 @@ internal class AstraScopeView(
override fun onDetachedFromWindow() {
attached = false
cancelRendering()
surfaceSession.closeCurrent()
super.onDetachedFromWindow()
}
@@ -274,7 +274,8 @@ internal class AstraScopeView(
adaptiveFrameDeadline.reset()
hasNewFrame = false
val eligible = attached && windowVisible && surfaceAvailable && width > 0 && height > 0
val eligible =
attached && windowVisible && surfaceSession.available && width > 0 && height > 0
refreshAdaptivePolicy(SystemClock.uptimeMillis(), force = true)
scheduledToken = renderGate.update(eligible)
applyFrameRateVote(
@@ -364,7 +365,7 @@ internal class AstraScopeView(
if (staticSnapshot != null && mode == ScopeMode.SPECTRUM) {
readScopeFrame()
preparePaths()
publishFrame()
publishFrame(token)
return
}
@@ -379,7 +380,7 @@ internal class AstraScopeView(
renderedValues.fill(0f, 0, count)
}
preparePaths()
publishFrame()
publishFrame(token)
if (peak >= AstraScopeProjection.REST_EPSILON && renderGate.isCurrent(token)) {
val decayFrameMs = if (frameMs > 0.0) frameMs else FALLBACK_FRAME_MS
@@ -437,21 +438,22 @@ internal class AstraScopeView(
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val surface = renderSurface ?: return
if (!surface.isValid) return
try {
if (requestedRate > 0f) {
surface.setFrameRate(
requestedRate,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE
)
} else {
surface.clearFrameRate()
surfaceSession.withCurrent { surface ->
if (!surface.isValid) return@withCurrent
try {
if (requestedRate > 0f) {
surface.setFrameRate(
requestedRate,
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE
)
} else {
surface.clearFrameRate()
}
} catch (_: IllegalArgumentException) {
// The TextureView may detach while a frame-rate vote is being updated.
} catch (_: IllegalStateException) {
// The TextureView may detach while a frame-rate vote is being updated.
}
} catch (_: IllegalArgumentException) {
// The TextureView may detach while a frame-rate vote is being updated.
} catch (_: IllegalStateException) {
// The TextureView may detach while a frame-rate vote is being updated.
}
}
}
@@ -524,33 +526,38 @@ internal class AstraScopeView(
* transparent layer. TextureView publication may schedule a platform frame,
* but it never schedules React work or rebuilds the surrounding scene.
*/
private fun publishFrame() {
if (!surfaceAvailable) return
val surface = renderSurface ?: return
if (!surface.isValid) return
val canvas = try {
surface.lockHardwareCanvas()
} catch (_: IllegalArgumentException) {
return
} catch (_: IllegalStateException) {
return
}
try {
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
synchronized(pathLock) {
if (mode == ScopeMode.SPECTRUM && fillOpacity > 0f) {
canvas.drawPath(fillPaths[frontPath], fillPaint)
}
if (glow) canvas.drawPath(linePaths[frontPath], glowPaint)
canvas.drawPath(linePaths[frontPath], strokePaint)
}
} finally {
try {
surface.unlockCanvasAndPost(canvas)
private fun publishFrame(token: Int) {
if (!renderGate.isCurrent(token)) return
surfaceSession.withCurrentIf(
eligible = { renderGate.isCurrent(token) }
) { surface ->
if (!surface.isValid) return@withCurrentIf
val canvas = try {
surface.lockHardwareCanvas()
} catch (_: Surface.OutOfResourcesException) {
return@withCurrentIf
} catch (_: IllegalArgumentException) {
// The TextureView may detach between lock and post.
return@withCurrentIf
} catch (_: IllegalStateException) {
// The TextureView may detach between lock and post.
return@withCurrentIf
}
try {
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
synchronized(pathLock) {
if (mode == ScopeMode.SPECTRUM && fillOpacity > 0f) {
canvas.drawPath(fillPaths[frontPath], fillPaint)
}
if (glow) canvas.drawPath(linePaths[frontPath], glowPaint)
canvas.drawPath(linePaths[frontPath], strokePaint)
}
} finally {
try {
surface.unlockCanvasAndPost(canvas)
} catch (_: IllegalArgumentException) {
// The surface became invalid while the frame was being posted.
} catch (_: IllegalStateException) {
// The surface became invalid while the frame was being posted.
}
}
}
}
@@ -0,0 +1,85 @@
package expo.modules.astrascope
/**
* Owns one replaceable surface-like resource.
*
* Publication holds [lock] for the complete use of the resource. Replacement
* and teardown therefore wait for an in-flight publication before releasing
* it, and an eligibility predicate is rechecked only after the lock is held so
* work cancelled while waiting cannot start against a newer resource.
*/
internal class ScopeSurfaceSession<K : Any, T : Any>(
private val release: (T) -> Unit
) {
private data class Entry<K, T>(
val key: K,
val resource: T
)
private val lock = Any()
private var current: Entry<K, T>? = null
@Volatile
var available: Boolean = false
private set
fun replace(key: K, resource: T) {
synchronized(lock) {
current?.resource?.let(release)
current = Entry(key, resource)
available = true
}
}
/**
* Releases only the resource belonging to [key]. A delayed destruction
* callback for an older key cannot tear down a replacement.
*/
fun close(key: K): Boolean =
synchronized(lock) {
val entry = current ?: return@synchronized false
if (entry.key !== key) return@synchronized false
closeLocked(entry)
}
/**
* Detach is also a terminal surface boundary, but Android can deliver it
* before the matching TextureView destruction callback. Explicitly closing
* the current entry keeps the wrapper out of the runtime cleaner; the later
* key-specific callback observes an empty session and does nothing.
*/
fun closeCurrent(): Boolean =
synchronized(lock) {
val entry = current ?: return@synchronized false
closeLocked(entry)
}
/**
* Runs [block] while teardown/replacement is excluded. [eligible] is checked
* under the same lock immediately before use.
*/
fun <R> withCurrentIf(
eligible: () -> Boolean,
block: (T) -> R
): R? =
synchronized(lock) {
if (!eligible()) return@synchronized null
val entry = current ?: return@synchronized null
block(entry.resource)
}
/** Runs [block] against the current resource, serialized with teardown. */
fun <R> withCurrent(block: (T) -> R): R? =
synchronized(lock) {
val entry = current ?: return@synchronized null
block(entry.resource)
}
private fun closeLocked(entry: Entry<K, T>): Boolean {
current = null
available = false
release(entry.resource)
return true
}
}
@@ -0,0 +1,117 @@
package expo.modules.astrascope
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ScopeSurfaceSessionTest {
@Test
fun teardownWaitsForAnInFlightPublicationBeforeRelease() {
val key = Any()
val released = Collections.synchronizedList(mutableListOf<String>())
val session = ScopeSurfaceSession<Any, String>(released::add)
val publicationEntered = CountDownLatch(1)
val allowPublicationToFinish = CountDownLatch(1)
val closeStarted = CountDownLatch(1)
val closeFinished = CountDownLatch(1)
session.replace(key, "surface")
val publisher = thread(name = "scope-publisher") {
session.withCurrentIf(eligible = { true }) {
publicationEntered.countDown()
assertTrue(allowPublicationToFinish.await(2, TimeUnit.SECONDS))
}
}
assertTrue(publicationEntered.await(2, TimeUnit.SECONDS))
val closer = thread(name = "scope-closer") {
closeStarted.countDown()
session.close(key)
closeFinished.countDown()
}
assertTrue(closeStarted.await(2, TimeUnit.SECONDS))
assertFalse(closeFinished.await(100, TimeUnit.MILLISECONDS))
allowPublicationToFinish.countDown()
assertTrue(closeFinished.await(2, TimeUnit.SECONDS))
publisher.join(2_000)
closer.join(2_000)
assertEquals(listOf("surface"), released)
assertFalse(session.available)
}
@Test
fun cancelledPublicationIsRejectedUnderTheSessionLock() {
val eligible = AtomicBoolean(true)
val publications = AtomicInteger(0)
val session = ScopeSurfaceSession<Any, String> {}
session.replace(Any(), "surface")
assertEquals(
"surface",
session.withCurrentIf(eligible = eligible::get) {
publications.incrementAndGet()
it
}
)
eligible.set(false)
assertNull(
session.withCurrentIf(eligible = eligible::get) {
publications.incrementAndGet()
it
}
)
assertEquals(1, publications.get())
}
@Test
fun staleRenderGenerationCannotUseAReplacementSurface() {
val firstKey = Any()
val secondKey = Any()
val session = ScopeSurfaceSession<Any, String> {}
val gate = ScopeRenderGate()
session.replace(firstKey, "first")
val firstToken = gate.update(true)
gate.update(false)
assertTrue(session.close(firstKey))
session.replace(secondKey, "second")
val secondToken = gate.update(true)
assertNull(
session.withCurrentIf(eligible = { gate.isCurrent(firstToken) }) { it }
)
assertEquals(
"second",
session.withCurrentIf(eligible = { gate.isCurrent(secondToken) }) { it }
)
}
@Test
fun replacementAndDelayedCloseReleaseEachSurfaceExactlyOnce() {
val firstKey = Any()
val secondKey = Any()
val released = mutableListOf<String>()
val session = ScopeSurfaceSession<Any, String>(released::add)
session.replace(firstKey, "first")
session.replace(secondKey, "second")
assertFalse(session.close(firstKey))
assertTrue(session.closeCurrent())
assertFalse(session.close(secondKey))
assertFalse(session.closeCurrent())
assertEquals(listOf("first", "second"), released)
assertFalse(session.available)
}
}
+52 -20
View File
@@ -7,10 +7,48 @@
#include <jni.h>
#include <algorithm>
#include <cstdint>
#include "scope_ring.h"
namespace {
astra::ScopeDriver& driver() { return astra::ScopeDriver::instance(); }
bool hasCompleteInterleavedFrames(
JNIEnv* env, jfloatArray frames, jint frameCount, jint channelCount) {
if (frames == nullptr || frameCount <= 0 || channelCount <= 0) {
return false;
}
// Widen before multiplying so hostile or corrupted JNI arguments cannot
// overflow and turn an undersized Java array into an out-of-bounds native read.
const int64_t required =
static_cast<int64_t>(frameCount) * static_cast<int64_t>(channelCount);
return required <= static_cast<int64_t>(env->GetArrayLength(frames));
}
size_t directFloatCapacity(
JNIEnv* env, jobject buffer, jint requestedFloats, float** address) {
*address = nullptr;
if (buffer == nullptr || requestedFloats <= 0) {
return 0;
}
void* raw = env->GetDirectBufferAddress(buffer);
const jlong capacityBytes = env->GetDirectBufferCapacity(buffer);
if (raw == nullptr || capacityBytes < static_cast<jlong>(sizeof(float))) {
return 0;
}
if (reinterpret_cast<uintptr_t>(raw) % alignof(float) != 0) {
return 0;
}
*address = static_cast<float*>(raw);
const size_t actualFloats =
static_cast<size_t>(capacityBytes / static_cast<jlong>(sizeof(float)));
return std::min(static_cast<size_t>(requestedFloats), actualFloats);
}
} // namespace
extern "C" {
@@ -26,7 +64,7 @@ JNIEXPORT void JNICALL
Java_expo_modules_astrascope_ScopeBridge_nativePushFrames(
JNIEnv* env, jobject /*thiz*/, jfloatArray frames, jint frameCount,
jint channelCount) {
if (frames == nullptr || frameCount <= 0 || channelCount <= 0) {
if (!hasCompleteInterleavedFrames(env, frames, frameCount, channelCount)) {
return;
}
auto* data = static_cast<float*>(
@@ -47,15 +85,13 @@ JNIEXPORT jint JNICALL
Java_expo_modules_astrascope_ScopeBridge_nativeFillSpectrum(
JNIEnv* env, jobject /*thiz*/, jobject buffer, jint capacityFloats,
jfloat smoothing) {
if (buffer == nullptr || capacityFloats <= 0) {
return 0;
}
auto* dst = static_cast<float*>(env->GetDirectBufferAddress(buffer));
if (dst == nullptr) {
float* dst = nullptr;
const size_t capacity = directFloatCapacity(env, buffer, capacityFloats, &dst);
if (capacity == 0) {
return 0;
}
const size_t n = driver().fillSpectrum(
dst, static_cast<size_t>(capacityFloats), static_cast<float>(smoothing));
dst, capacity, static_cast<float>(smoothing));
return static_cast<jint>(n);
}
@@ -64,14 +100,12 @@ Java_expo_modules_astrascope_ScopeBridge_nativeFillSpectrum(
JNIEXPORT jint JNICALL
Java_expo_modules_astrascope_ScopeBridge_nativeFillOscilloscope(
JNIEnv* env, jobject /*thiz*/, jobject buffer, jint capacityFloats) {
if (buffer == nullptr || capacityFloats <= 0) {
float* dst = nullptr;
const size_t capacity = directFloatCapacity(env, buffer, capacityFloats, &dst);
if (capacity == 0) {
return 0;
}
auto* dst = static_cast<float*>(env->GetDirectBufferAddress(buffer));
if (dst == nullptr) {
return 0;
}
const size_t n = driver().fillOscilloscope(dst, static_cast<size_t>(capacityFloats));
const size_t n = driver().fillOscilloscope(dst, capacity);
return static_cast<jint>(n);
}
@@ -82,7 +116,7 @@ JNIEXPORT void JNICALL
Java_expo_modules_astrascope_ScopeBridge_nativePushFramesPostEq(
JNIEnv* env, jobject /*thiz*/, jfloatArray frames, jint frameCount,
jint channelCount) {
if (frames == nullptr || frameCount <= 0 || channelCount <= 0) {
if (!hasCompleteInterleavedFrames(env, frames, frameCount, channelCount)) {
return;
}
auto* data = static_cast<float*>(
@@ -99,15 +133,13 @@ JNIEXPORT jint JNICALL
Java_expo_modules_astrascope_ScopeBridge_nativeFillSpectrumPostEq(
JNIEnv* env, jobject /*thiz*/, jobject buffer, jint capacityFloats,
jfloat smoothing) {
if (buffer == nullptr || capacityFloats <= 0) {
return 0;
}
auto* dst = static_cast<float*>(env->GetDirectBufferAddress(buffer));
if (dst == nullptr) {
float* dst = nullptr;
const size_t capacity = directFloatCapacity(env, buffer, capacityFloats, &dst);
if (capacity == 0) {
return 0;
}
const size_t n = driver().fillSpectrumPostEq(
dst, static_cast<size_t>(capacityFloats), static_cast<float>(smoothing));
dst, capacity, static_cast<float>(smoothing));
return static_cast<jint>(n);
}
+10 -4
View File
@@ -60,15 +60,21 @@ index e8e4c03..0ce542c 100644
jclass surfaceClass = env->FindClass("android/view/Surface");
jmethodID surfaceConstructor = env->GetMethodID(
surfaceClass, "<init>", "(Landroid/graphics/SurfaceTexture;)V");
@@ -95,15 +84,10 @@ void RNSkOpenGLCanvasProvider::surfaceAvailable(jobject jSurfaceTexture,
@@ -95,15 +84,15 @@ void RNSkOpenGLCanvasProvider::surfaceAvailable(jobject jSurfaceTexture,
env->NewObject(surfaceClass, surfaceConstructor, jSurfaceTexture);
+ jmethodID surfaceRelease =
+ env->GetMethodID(surfaceClass, "release", "()V");
window = ANativeWindow_fromSurface(env, jSurface);
- jclass surfaceTextureClass = env->GetObjectClass(_jSurfaceTexture);
- _updateTexImageMethod =
- env->GetMethodID(surfaceTextureClass, "updateTexImage", "()V");
-
// Acquire the native window from the Surface
- // Acquire the native window from the Surface
+ // ANativeWindow_fromSurface() acquired an independent native reference, so
+ // close the temporary Java wrapper before dropping its local reference.
+ env->CallVoidMethod(jSurface, surfaceRelease);
+
// Clean up local references
env->DeleteLocalRef(jSurface);
env->DeleteLocalRef(surfaceClass);
@@ -76,7 +82,7 @@ index e8e4c03..0ce542c 100644
} else {
window = ANativeWindow_fromSurface(env, jSurfaceTexture);
}
@@ -112,6 +96,12 @@ void RNSkOpenGLCanvasProvider::surfaceAvailable(jobject jSurfaceTexture,
@@ -112,6 +101,12 @@ void RNSkOpenGLCanvasProvider::surfaceAvailable(jobject jSurfaceTexture,
#else
_surfaceHolder = OpenGLContext::getInstance().MakeWindow(window);
#endif
@@ -89,7 +95,7 @@ index e8e4c03..0ce542c 100644
// Post redraw request to ensure we paint in the next draw cycle.
_requestRedraw();
@@ -120,11 +110,15 @@ void RNSkOpenGLCanvasProvider::surfaceDestroyed() {
@@ -120,11 +115,15 @@ void RNSkOpenGLCanvasProvider::surfaceDestroyed() {
// destroy the renderer (a unique pointer so the dtor will be called
// immediately.)
_surfaceHolder = nullptr;