// EXPERIMENTAL SPIKE — does the codec VIDEO plane's actual present time differ from a // GL OSD overlay's, and is that difference measurable in-app? (See the memory note // project_libass_present_time_instrumentation.) // // Android exposes no present time for a codec-owned video layer. The one runtime lead is // API 34 `SurfaceView.applyTransactionToFrame()`: it merges an otherwise-empty, // callback-bearing transaction with the codec's *next buffer* transaction. The completion // stats then carry, per surface, the PREVIOUS-RELEASE fence — the moment the buffer the new // frame replaced stopped being read by the display ≈ when the new codec frame actually // reached the video plane. Compared in Kotlin to that frame's `releaseTimeNs` target (the // overlay's own present error is ~0), the delta is the inter-plane lag. // // The SurfaceControl transaction-stats / fromJava APIs are API 29/34 while this module's // minSdk is 21; clang's __builtin_available doesn't guard the Android availability domain in // this toolchain. So the libandroid entry points are resolved by dlsym (no // availability-attributed declarations get compiled). Every call site is additionally // hard-gated to API >= 34 in Kotlin (VideoLayerLatencyProbe). // // Fence timestamps are read with the raw SYNC_IOC_FILE_INFO ioctl rather than libsync: // libsync.so is NOT a public NDK library, so an app's linker namespace blocks dlopen of it. // // Compiled into the libass "asskt" .so; called from app Kotlin SurfaceTxProbe. #include #include #include #include #include #include #include #include #include #include #include #include #define LOG_TAG "SurfaceTxProbe" #define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) #ifndef SYNC_IOC_MAGIC #define SYNC_IOC_MAGIC '>' #endif struct stp_fence_info { char obj_name[32]; char driver_name[32]; int32_t status; uint32_t flags; uint64_t timestamp_ns; }; struct stp_file_info { char name[32]; int32_t status; uint32_t flags; uint32_t num_fences; uint32_t pad; uint64_t sync_fence_info; }; #ifndef SYNC_IOC_FILE_INFO #define SYNC_IOC_FILE_INFO _IOWR(SYNC_IOC_MAGIC, 4, struct stp_file_info) #endif // libsync is hidden from app linker namespaces, so use the kernel ioctl directly. static int64_t readFenceTimeNs(int fd, int* outStatus) { *outStatus = -1; if (fd < 0) return -1; struct stp_file_info probe; memset(&probe, 0, sizeof(probe)); if (ioctl(fd, SYNC_IOC_FILE_INFO, &probe) < 0) return -1; *outStatus = probe.status; if (probe.status != 1 || probe.num_fences == 0) return -1; struct stp_fence_info* arr = (struct stp_fence_info*)calloc(probe.num_fences, sizeof(struct stp_fence_info)); if (!arr) return -1; struct stp_file_info req; memset(&req, 0, sizeof(req)); req.num_fences = probe.num_fences; req.sync_fence_info = (uint64_t)(uintptr_t)arr; int64_t t = -1; if (ioctl(fd, SYNC_IOC_FILE_INFO, &req) == 0) { for (uint32_t i = 0; i < req.num_fences; i++) { if ((int64_t)arr[i].timestamp_ns > t) t = (int64_t)arr[i].timestamp_ns; } } free(arr); return t; } typedef struct ASurfaceTransaction ASurfaceTransaction; typedef struct ASurfaceControl ASurfaceControl; typedef struct ASurfaceTransactionStats ASurfaceTransactionStats; typedef void (*OnCompleteFn)(void* context, ASurfaceTransactionStats* stats); typedef ASurfaceTransaction* (*pf_fromJava)(JNIEnv*, jobject); typedef void (*pf_setOnComplete)(ASurfaceTransaction*, void*, OnCompleteFn); typedef int64_t (*pf_latchTime)(ASurfaceTransactionStats*); typedef void (*pf_getControls)(ASurfaceTransactionStats*, ASurfaceControl***, size_t*); typedef int (*pf_prevRelease)(ASurfaceTransactionStats*, ASurfaceControl*); typedef void (*pf_releaseControls)(ASurfaceControl**); static pf_fromJava p_fromJava = NULL; static pf_setOnComplete p_setOnComplete = NULL; static pf_latchTime p_latchTime = NULL; static pf_getControls p_getControls = NULL; static pf_prevRelease p_prevRelease = NULL; static pf_releaseControls p_releaseControls = NULL; static int gScResolved = 0; static int gScOk = 0; static int gScWarningLogged = 0; // Runtime lookup keeps this API-34 probe buildable with the module's minSdk 21. static int resolveSc(void) { if (gScResolved) return gScOk; gScResolved = 1; void* h = dlopen("libandroid.so", RTLD_NOW | RTLD_GLOBAL); if (!h) return 0; p_fromJava = (pf_fromJava)dlsym(h, "ASurfaceTransaction_fromJava"); p_setOnComplete = (pf_setOnComplete)dlsym(h, "ASurfaceTransaction_setOnComplete"); p_latchTime = (pf_latchTime)dlsym(h, "ASurfaceTransactionStats_getLatchTime"); p_getControls = (pf_getControls)dlsym(h, "ASurfaceTransactionStats_getASurfaceControls"); p_prevRelease = (pf_prevRelease)dlsym(h, "ASurfaceTransactionStats_getPreviousReleaseFenceFd"); p_releaseControls = (pf_releaseControls)dlsym(h, "ASurfaceTransactionStats_releaseASurfaceControls"); gScOk = (p_fromJava && p_setOnComplete && p_latchTime && p_getControls && p_prevRelease && p_releaseControls) ? 1 : 0; return gScOk; } // The completion context is pointer-width, so 32-bit devices need an index side-channel. #define TAG_RING_SIZE 256 static int64_t gTagRing[TAG_RING_SIZE]; static int gSrcRing[TAG_RING_SIZE]; static int gTagSeq = 0; static JavaVM* gVm = NULL; static jclass gProbeClass = NULL; static jmethodID gOnResult = NULL; static int gCallbackLookupAttempted = 0; static pthread_mutex_t gInitLock = PTHREAD_MUTEX_INITIALIZER; static int64_t nowMonoNs(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec; } static void reportResult(JNIEnv* env, int64_t tag, int64_t latchNs, int64_t releaseNs, int count, int fenceState, int source, int64_t cbNs) { if (!env || !gProbeClass || !gOnResult) return; (*env)->CallStaticVoidMethod(env, gProbeClass, gOnResult, (jlong)tag, (jlong)latchNs, (jlong)releaseNs, (jint)count, (jint)fenceState, (jint)source, (jlong)cbNs); if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); } // Previous-release fences can still be pending in the binder callback; a worker // thread keeps SurfaceFlinger's callback dispatch unblocked. struct fenceJob { int fd; int64_t tag; int64_t latch; int count; int source; }; #define JOB_RING 64 static struct fenceJob gJobs[JOB_RING]; static int gJobHead = 0, gJobTail = 0; static int gJobDrops = 0; static pthread_mutex_t gJobLock = PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t gJobCond = PTHREAD_COND_INITIALIZER; static pthread_t gReader; static int gReaderStarted = 0; static void* readerMain(void* arg); static int ensureProbeInit(JNIEnv* env, jclass clazz) { int scOk = 0; pthread_mutex_lock(&gInitLock); if (!gVm && (*env)->GetJavaVM(env, &gVm) != JNI_OK) { LOGW("GetJavaVM failed — transaction probe disabled"); pthread_mutex_unlock(&gInitLock); return 0; } if (!gProbeClass) { gProbeClass = (*env)->NewGlobalRef(env, clazz); if (!gProbeClass) { LOGW("NewGlobalRef(SurfaceTxProbe) failed — transaction probe disabled"); pthread_mutex_unlock(&gInitLock); return 0; } } if (!gCallbackLookupAttempted) { gCallbackLookupAttempted = 1; gOnResult = (*env)->GetStaticMethodID(env, clazz, "onResult", "(JJJIIIJ)V"); if (!gOnResult) { (*env)->ExceptionClear(env); LOGW("onResult(JJJIIIJ)V not found — JNI callback disabled (R8 stripped it?)"); } } if (!gOnResult) { pthread_mutex_unlock(&gInitLock); return 0; } scOk = resolveSc(); if (!scOk) { if (!gScWarningLogged) { gScWarningLogged = 1; LOGW("libandroid SurfaceControl transaction-stats API unavailable"); } pthread_mutex_unlock(&gInitLock); return 0; } if (!gReaderStarted) { const int rc = pthread_create(&gReader, NULL, readerMain, NULL); if (rc != 0) { LOGW("fence reader thread start failed: %d", rc); pthread_mutex_unlock(&gInitLock); return 0; } gReaderStarted = 1; } pthread_mutex_unlock(&gInitLock); return scOk; } static void* readerMain(void* arg) { (void)arg; JNIEnv* env = NULL; if ((*gVm)->AttachCurrentThread(gVm, &env, NULL) != JNI_OK) return NULL; for (;;) { pthread_mutex_lock(&gJobLock); while (gJobHead == gJobTail) pthread_cond_wait(&gJobCond, &gJobLock); struct fenceJob job = gJobs[gJobTail]; gJobTail = (gJobTail + 1) % JOB_RING; pthread_mutex_unlock(&gJobLock); int64_t releaseNs = -1; int fenceState; struct pollfd pfd = {.fd = job.fd, .events = POLLIN, .revents = 0}; int pr = poll(&pfd, 1, 250); if (pr > 0 && (pfd.revents & POLLIN)) { int st = -1; releaseNs = readFenceTimeNs(job.fd, &st); fenceState = (releaseNs > 0) ? 3 : 1; } else { fenceState = 2; } close(job.fd); reportResult(env, job.tag, job.latch, releaseNs, job.count, fenceState, job.source, nowMonoNs()); } return NULL; } static void onComplete(void* context, ASurfaceTransactionStats* stats) { int slot = (int)(intptr_t)context; __atomic_thread_fence(__ATOMIC_ACQUIRE); int64_t tag = gTagRing[slot & (TAG_RING_SIZE - 1)]; int source = gSrcRing[slot & (TAG_RING_SIZE - 1)]; int64_t latchNs = p_latchTime ? p_latchTime(stats) : -1; // Display-global present fences are abort-prone; per-surface release fences are stable here. int chosenFd = -1; int count = 0; ASurfaceControl** controls = NULL; size_t n = 0; if (p_getControls) p_getControls(stats, &controls, &n); count = (int)n; for (size_t i = 0; i < n; i++) { int rfd = p_prevRelease ? p_prevRelease(stats, controls[i]) : -1; if (rfd < 0) continue; if (chosenFd < 0) chosenFd = rfd; else close(rfd); } if (controls && p_releaseControls) p_releaseControls(controls); if (chosenFd < 0) { JNIEnv* env = NULL; int didAttach = 0; if ((*gVm)->GetEnv(gVm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) { if ((*gVm)->AttachCurrentThread(gVm, &env, NULL) != JNI_OK) return; didAttach = 1; } reportResult(env, tag, latchNs, -1, count, 0, source, nowMonoNs()); if (didAttach) (*gVm)->DetachCurrentThread(gVm); return; } pthread_mutex_lock(&gJobLock); int next = (gJobHead + 1) % JOB_RING; if (next == gJobTail) { close(chosenFd); if (gJobDrops++ == 0) LOGW("fence job ring full — dropping samples (slow fence reader?)"); } else { gJobs[gJobHead].fd = chosenFd; gJobs[gJobHead].tag = tag; gJobs[gJobHead].latch = latchNs; gJobs[gJobHead].count = count; gJobs[gJobHead].source = source; gJobHead = next; pthread_cond_signal(&gJobCond); } pthread_mutex_unlock(&gJobLock); } JNIEXPORT void JNICALL Java_com_edde746_plezy_exoplayer_SurfaceTxProbe_nativeAttach( JNIEnv* env, jclass clazz, jobject jtransaction, jlong tag, jint source) { if (!ensureProbeInit(env, clazz)) return; int slot = __atomic_fetch_add(&gTagSeq, 1, __ATOMIC_RELAXED) & (TAG_RING_SIZE - 1); gTagRing[slot] = (int64_t)tag; gSrcRing[slot] = (int)source; // Binder carries only the slot index, so the ring write has to win publication. __atomic_thread_fence(__ATOMIC_RELEASE); ASurfaceTransaction* tx = p_fromJava(env, jtransaction); if (!tx) { LOGW("ASurfaceTransaction_fromJava returned null"); return; } p_setOnComplete(tx, (void*)(intptr_t)slot, onComplete); }