diff --git a/webrtc-media/src/main/cpp/include/JNI_AllocationFault.h b/webrtc-media/src/main/cpp/include/JNI_AllocationFault.h new file mode 100644 index 00000000..a079a8a2 --- /dev/null +++ b/webrtc-media/src/main/cpp/include/JNI_AllocationFault.h @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +/* Header for class dev_onvoid_webrtc_media_player_AllocationFault */ + +#ifndef _Included_dev_onvoid_webrtc_media_player_AllocationFault +#define _Included_dev_onvoid_webrtc_media_player_AllocationFault +#ifdef __cplusplus +extern "C" { +#endif + /* + * Class: dev_onvoid_webrtc_media_player_AllocationFault + * Method: failNextStringCopy + * Signature: ()V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_AllocationFault_failNextStringCopy + (JNIEnv *, jclass); + + /* + * Class: dev_onvoid_webrtc_media_player_AllocationFault + * Method: disarm + * Signature: ()V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_AllocationFault_disarm + (JNIEnv *, jclass); + + /* + * Class: dev_onvoid_webrtc_media_player_AllocationFault + * Method: heldStrings + * Signature: ()I + */ + JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_player_AllocationFault_heldStrings + (JNIEnv *, jclass); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/webrtc-media/src/main/cpp/include/media/AllocationFault.h b/webrtc-media/src/main/cpp/include/media/AllocationFault.h new file mode 100644 index 00000000..12a8868e --- /dev/null +++ b/webrtc-media/src/main/cpp/include/media/AllocationFault.h @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef WEBRTC_JAVA_MEDIA_ALLOCATION_FAULT_H_ +#define WEBRTC_JAVA_MEDIA_ALLOCATION_FAULT_H_ + +namespace ffmpeg +{ + // Makes the copy of a Java string fail as an exhausted heap does, so that + // the tests can see what an entry point does then: the C++ heap does not + // run out on demand. + // + // Only the tests arm it, through JNI_AllocationFault. A copy that is not + // armed pays a single atomic load. + class AllocationFault + { + public: + // Fails the next copy of a Java string, once. + static void FailNextStringCopy(); + // Forgets a fault that has not fired. + static void Disarm(); + // Throws std::bad_alloc if a fault is armed, and disarms it. + static void CheckStringCopy(); + }; +} + +#endif diff --git a/webrtc-media/src/main/cpp/include/media/JniBoundary.h b/webrtc-media/src/main/cpp/include/media/JniBoundary.h new file mode 100644 index 00000000..edb5b13d --- /dev/null +++ b/webrtc-media/src/main/cpp/include/media/JniBoundary.h @@ -0,0 +1,137 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef WEBRTC_JAVA_MEDIA_JNI_BOUNDARY_H_ +#define WEBRTC_JAVA_MEDIA_JNI_BOUNDARY_H_ + +#include + +#include + +namespace ffmpeg +{ + // Throws a Java exception of the given class, unless one is pending + // already, which then stays the one Java sees. Allocates nothing on the + // C++ heap, so that it can also report that an allocation failed. + void ThrowJavaException(JNIEnv * env, const char * class_name, const char * message) noexcept; + + void ThrowIOException(JNIEnv * env, const std::string & message) noexcept; + + // Turns the C++ exception being handled into a Java one: a failed + // allocation into an OutOfMemoryError, anything else into a + // RuntimeException. Only to be called from a catch block. + void ThrowCurrentException(JNIEnv * env) noexcept; + + // Runs the body of a JNI entry point, which must not let a C++ exception + // reach the VM: one that does ends the process. A C++ exception becomes a + // Java one, and the entry point returns `on_error`. Whatever the body + // holds is released before the Java exception is thrown, so a destructor + // may still call into Java. + template + R CallFromJava(JNIEnv * env, R on_error, Body && body) noexcept + { + try { + return body(); + } + catch (...) { + ThrowCurrentException(env); + } + + return on_error; + } + + template + void CallFromJava(JNIEnv * env, Body && body) noexcept + { + try { + body(); + } + catch (...) { + ThrowCurrentException(env); + } + } + + // A local reference, deleted when this goes out of scope. + template + class LocalRef + { + public: + LocalRef(JNIEnv * env, T ref) noexcept : env_(env), ref_(ref) + { + } + + ~LocalRef() + { + if (ref_ != nullptr) { + env_->DeleteLocalRef(ref_); + } + } + + LocalRef(const LocalRef &) = delete; + LocalRef & operator=(const LocalRef &) = delete; + + T get() const noexcept + { + return ref_; + } + + // Gives the reference to the caller, such as the VM it is returned to. + T release() noexcept + { + T ref = ref_; + ref_ = nullptr; + + return ref; + } + + private: + JNIEnv * env_; + T ref_; + }; + + // The modified UTF-8 of a Java string, released when this goes out of + // scope, also when a copy of it fails on the way out. + class JavaStringChars + { + public: + // get() is null when the string is null or the VM could not + // provide the characters, and a Java exception is pending then. + JavaStringChars(JNIEnv * env, jstring string) noexcept; + ~JavaStringChars(); + + JavaStringChars(const JavaStringChars &) = delete; + JavaStringChars & operator=(const JavaStringChars &) = delete; + + const char * get() const noexcept + { + return chars_; + } + + // A copy that outlives this object. Throws std::bad_alloc. + std::string str() const; + + // How many strings are held right now, for the tests to see that + // none is left behind. + static int Held() noexcept; + + private: + JNIEnv * env_; + jstring string_; + const char * chars_; + }; +} + +#endif diff --git a/webrtc-media/src/main/cpp/src/JNI_AllocationFault.cpp b/webrtc-media/src/main/cpp/src/JNI_AllocationFault.cpp new file mode 100644 index 00000000..527baf2a --- /dev/null +++ b/webrtc-media/src/main/cpp/src/JNI_AllocationFault.cpp @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "JNI_AllocationFault.h" +#include "media/AllocationFault.h" +#include "media/JniBoundary.h" + +// The Java class this belongs to exists in the tests only, so that nothing but +// them can make an allocation fail. + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_AllocationFault_failNextStringCopy +(JNIEnv * env, jclass caller) +{ + ffmpeg::AllocationFault::FailNextStringCopy(); +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_AllocationFault_disarm +(JNIEnv * env, jclass caller) +{ + ffmpeg::AllocationFault::Disarm(); +} + +JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_player_AllocationFault_heldStrings +(JNIEnv * env, jclass caller) +{ + return ffmpeg::JavaStringChars::Held(); +} diff --git a/webrtc-media/src/main/cpp/src/JNI_MediaPlayer.cpp b/webrtc-media/src/main/cpp/src/JNI_MediaPlayer.cpp index 731204e3..eb8c13e3 100644 --- a/webrtc-media/src/main/cpp/src/JNI_MediaPlayer.cpp +++ b/webrtc-media/src/main/cpp/src/JNI_MediaPlayer.cpp @@ -18,6 +18,7 @@ #include "media/ApiCheck.h" #include "media/ErrorText.h" #include "media/JavaPlayerObserver.h" +#include "media/JniBoundary.h" #include "media/MediaPlayer.h" #include "media/MediaReader.h" #include "webrtc_java_api.h" @@ -32,16 +33,6 @@ extern "C" { namespace { - void ThrowIOException(JNIEnv * env, const std::string & message) - { - jclass cls = env->FindClass("java/io/IOException"); - - if (cls != nullptr) { - env->ThrowNew(cls, message.c_str()); - env->DeleteLocalRef(cls); - } - } - ffmpeg::MediaPlayer * PlayerOf(jlong handle) { return reinterpret_cast(handle); @@ -57,90 +48,104 @@ JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_create std::unique_ptr reader( reinterpret_cast(readerHandle)); - if (reader == nullptr) { - ThrowIOException(env, "The reader is closed"); + return ffmpeg::CallFromJava(env, 0, [&]() -> jlong { + if (reader == nullptr) { + ffmpeg::ThrowIOException(env, "The reader is closed"); - return 0; - } + return 0; + } - const webrtc_java_api * api = - reinterpret_cast(tableAddress); + const webrtc_java_api * api = + reinterpret_cast(tableAddress); - std::string apiError = ffmpeg::CheckApi(api); + std::string apiError = ffmpeg::CheckApi(api); - if (!apiError.empty()) { - ThrowIOException(env, apiError); + if (!apiError.empty()) { + ffmpeg::ThrowIOException(env, apiError); - return 0; - } + return 0; + } - auto player = std::make_unique(std::move(reader), api, - reinterpret_cast(videoSourceHandle), - reinterpret_cast(audioSourceHandle)); + auto player = std::make_unique(std::move(reader), api, + reinterpret_cast(videoSourceHandle), + reinterpret_cast(audioSourceHandle)); - player->SetObserver(std::make_unique(env, caller)); - player->SetHardwareDecoding(hardwareDecoding == JNI_TRUE); + player->SetObserver(std::make_unique(env, caller)); + player->SetHardwareDecoding(hardwareDecoding == JNI_TRUE); - int result = player->Initialize(); + int result = player->Initialize(); - if (result < 0) { - // Released before throwing: closing reports the closed state to the - // observer, which calls into Java, and a call into Java with an - // exception pending is not allowed, and would also clear it. Nothing - // is listening yet anyway, so the observer goes first. - player->SetObserver(nullptr); - player.reset(); + if (result < 0) { + // Released before throwing: closing reports the closed state to the + // observer, which calls into Java, and a call into Java with an + // exception pending is not allowed, and would also clear it. Nothing + // is listening yet anyway, so the observer goes first. + player->SetObserver(nullptr); + player.reset(); - ThrowIOException(env, "Opening the decoders failed: " + ffmpeg::ErrorText(result)); + ffmpeg::ThrowIOException(env, "Opening the decoders failed: " + ffmpeg::ErrorText(result)); - return 0; - } + return 0; + } - return reinterpret_cast(player.release()); + return reinterpret_cast(player.release()); + }); } JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_start (JNIEnv * env, jclass caller, jlong handle) { - if (handle != 0) { - PlayerOf(handle)->Play(); - } + ffmpeg::CallFromJava(env, [&] { + if (handle != 0) { + PlayerOf(handle)->Play(); + } + }); } JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_suspend (JNIEnv * env, jclass caller, jlong handle) { - if (handle != 0) { - PlayerOf(handle)->Pause(); - } + ffmpeg::CallFromJava(env, [&] { + if (handle != 0) { + PlayerOf(handle)->Pause(); + } + }); } JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_seek (JNIEnv * env, jclass caller, jlong handle, jlong positionUs) { - if (handle != 0) { - PlayerOf(handle)->Seek(positionUs); - } + ffmpeg::CallFromJava(env, [&] { + if (handle != 0) { + PlayerOf(handle)->Seek(positionUs); + } + }); } JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_setLooping (JNIEnv * env, jclass caller, jlong handle, jboolean looping) { - if (handle != 0) { - PlayerOf(handle)->SetLooping(looping == JNI_TRUE); - } + ffmpeg::CallFromJava(env, [&] { + if (handle != 0) { + PlayerOf(handle)->SetLooping(looping == JNI_TRUE); + } + }); } JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_position (JNIEnv * env, jclass caller, jlong handle) { - return handle != 0 ? PlayerOf(handle)->GetPositionUs() : 0; + return ffmpeg::CallFromJava(env, 0, [&]() -> jlong { + return handle != 0 ? PlayerOf(handle)->GetPositionUs() : 0; + }); } JNIEXPORT jboolean JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_hardwareDecoding (JNIEnv * env, jclass caller, jlong handle) { - return handle != 0 && PlayerOf(handle)->IsHardwareDecoding() ? JNI_TRUE : JNI_FALSE; + return ffmpeg::CallFromJava(env, JNI_FALSE, [&]() -> jboolean { + return handle != 0 && PlayerOf(handle)->IsHardwareDecoding() ? JNI_TRUE : JNI_FALSE; + }); } JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_state @@ -148,7 +153,9 @@ JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_state { // A player that is gone is closed, which is what the Java side reports // once it has let go of the handle. - return handle != 0 ? PlayerOf(handle)->GetState() : ffmpeg::kClosed; + return ffmpeg::CallFromJava(env, ffmpeg::kClosed, [&]() -> jint { + return handle != 0 ? PlayerOf(handle)->GetState() : ffmpeg::kClosed; + }); } JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_dispose @@ -160,18 +167,20 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_dispose return; } - ffmpeg::MediaPlayer * player = PlayerOf(handle); + ffmpeg::CallFromJava(env, [&] { + ffmpeg::MediaPlayer * player = PlayerOf(handle); - if (player->IsOwnThread()) { - // Called from something the player is calling, such as a sink of a - // track it feeds. Destroying it here would wait for this very thread, - // and the delivery that made the call still uses the player once the - // call returns. Another thread destroys it, and its wait ends as soon - // as this thread is out of the player. - std::thread([player] { delete player; }).detach(); + if (player->IsOwnThread()) { + // Called from something the player is calling, such as a sink of a + // track it feeds. Destroying it here would wait for this very thread, + // and the delivery that made the call still uses the player once the + // call returns. Another thread destroys it, and its wait ends as soon + // as this thread is out of the player. + std::thread([player] { delete player; }).detach(); - return; - } + return; + } - delete player; + delete player; + }); } diff --git a/webrtc-media/src/main/cpp/src/JNI_MediaReader.cpp b/webrtc-media/src/main/cpp/src/JNI_MediaReader.cpp index 07781a6f..945bb3f5 100644 --- a/webrtc-media/src/main/cpp/src/JNI_MediaReader.cpp +++ b/webrtc-media/src/main/cpp/src/JNI_MediaReader.cpp @@ -16,8 +16,10 @@ #include "JNI_MediaReader.h" #include "media/ErrorText.h" +#include "media/JniBoundary.h" #include "media/MediaReader.h" +#include #include extern "C" { @@ -49,25 +51,10 @@ namespace return source.substr(0, authority_start) + source.substr(at + 1); } - void ThrowIOException(JNIEnv * env, const std::string & message) - { - jclass cls = env->FindClass("java/io/IOException"); - - if (cls != nullptr) { - env->ThrowNew(cls, message.c_str()); - env->DeleteLocalRef(cls); - } - } - ffmpeg::MediaReader * ReaderOf(JNIEnv * env, jlong handle) { if (handle == 0) { - jclass cls = env->FindClass("java/lang/IllegalStateException"); - - if (cls != nullptr) { - env->ThrowNew(cls, "MediaReader is closed"); - env->DeleteLocalRef(cls); - } + ffmpeg::ThrowJavaException(env, "java/lang/IllegalStateException", "MediaReader is closed"); return nullptr; } @@ -79,88 +66,92 @@ namespace JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_player_MediaReader_open (JNIEnv * env, jclass caller, jstring source, jlong timeoutUs) { - if (source == nullptr) { - ThrowIOException(env, "Source must not be null"); + return ffmpeg::CallFromJava(env, 0, [&]() -> jlong { + if (source == nullptr) { + ffmpeg::ThrowIOException(env, "Source must not be null"); - return 0; - } + return 0; + } - const char * chars = env->GetStringUTFChars(source, nullptr); + std::string url; - if (chars == nullptr) { - // The VM is out of memory and has already thrown. - return 0; - } + { + // Released before opening, which may wait for the network. + ffmpeg::JavaStringChars chars(env, source); - std::string url(chars); + if (chars.get() == nullptr) { + return 0; + } - env->ReleaseStringUTFChars(source, chars); + url = chars.str(); + } - auto reader = new ffmpeg::MediaReader(static_cast(timeoutUs)); - int result = reader->Open(url); + auto reader = std::make_unique(static_cast(timeoutUs)); + int result = reader->Open(url); - if (result < 0) { - delete reader; + if (result < 0) { + reader.reset(); - ThrowIOException(env, "Opening '" + WithoutCredentials(url) + "' failed: " + ffmpeg::ErrorText(result)); + ffmpeg::ThrowIOException(env, "Opening '" + WithoutCredentials(url) + "' failed: " + ffmpeg::ErrorText(result)); - return 0; - } + return 0; + } - return reinterpret_cast(reader); + return reinterpret_cast(reader.release()); + }); } JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_player_MediaReader_info (JNIEnv * env, jclass caller, jlong handle) { - ffmpeg::MediaReader * reader = ReaderOf(env, handle); + return ffmpeg::CallFromJava(env, nullptr, [&]() -> jobject { + ffmpeg::MediaReader * reader = ReaderOf(env, handle); - if (reader == nullptr) { - return nullptr; - } + if (reader == nullptr) { + return nullptr; + } - jclass cls = env->FindClass("dev/onvoid/webrtc/media/player/MediaInfo"); + ffmpeg::LocalRef cls(env, env->FindClass("dev/onvoid/webrtc/media/player/MediaInfo")); - if (cls == nullptr) { - return nullptr; - } + if (cls.get() == nullptr) { + return nullptr; + } - jmethodID ctor = env->GetMethodID(cls, "", - "(JIIDLjava/lang/String;IILjava/lang/String;)V"); + jmethodID ctor = env->GetMethodID(cls.get(), "", + "(JIIDLjava/lang/String;IILjava/lang/String;)V"); - if (ctor == nullptr) { - env->DeleteLocalRef(cls); + if (ctor == nullptr) { + return nullptr; + } - return nullptr; - } + // A stream that is not there is reported as a null codec name and zero + // everything else, which is the contract MediaInfo documents. A name + // the VM could not make has left an exception, and no JNI call may + // follow but cleaning up. + ffmpeg::LocalRef videoCodec(env, reader->HasVideo() + ? env->NewStringUTF(reader->GetVideoCodecName()) : nullptr); - // A stream that is not there is reported as a null codec name and zero - // everything else, which is the contract MediaInfo documents. - jstring videoCodec = reader->HasVideo() - ? env->NewStringUTF(reader->GetVideoCodecName()) : nullptr; - jstring audioCodec = reader->HasAudio() - ? env->NewStringUTF(reader->GetAudioCodecName()) : nullptr; - - jobject info = env->NewObject(cls, ctor, - static_cast(reader->GetDurationUs()), - static_cast(reader->GetVideoWidth()), - static_cast(reader->GetVideoHeight()), - static_cast(reader->GetFrameRate()), - videoCodec, - static_cast(reader->GetSampleRate()), - static_cast(reader->GetChannels()), - audioCodec); - - if (videoCodec != nullptr) { - env->DeleteLocalRef(videoCodec); - } - if (audioCodec != nullptr) { - env->DeleteLocalRef(audioCodec); - } + if (reader->HasVideo() && videoCodec.get() == nullptr) { + return nullptr; + } - env->DeleteLocalRef(cls); + ffmpeg::LocalRef audioCodec(env, reader->HasAudio() + ? env->NewStringUTF(reader->GetAudioCodecName()) : nullptr); + + if (reader->HasAudio() && audioCodec.get() == nullptr) { + return nullptr; + } - return info; + return env->NewObject(cls.get(), ctor, + static_cast(reader->GetDurationUs()), + static_cast(reader->GetVideoWidth()), + static_cast(reader->GetVideoHeight()), + static_cast(reader->GetFrameRate()), + videoCodec.get(), + static_cast(reader->GetSampleRate()), + static_cast(reader->GetChannels()), + audioCodec.get()); + }); } JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaReader_dispose diff --git a/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp b/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp index a6983ca5..47a5c215 100644 --- a/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp +++ b/webrtc-media/src/main/cpp/src/JNI_MediaRecorder.cpp @@ -18,6 +18,7 @@ #include "media/ApiCheck.h" #include "media/ErrorText.h" #include "media/JavaRecorderObserver.h" +#include "media/JniBoundary.h" #include "media/MediaRecorder.h" #include "webrtc_java_api.h" @@ -26,16 +27,6 @@ namespace { - void ThrowIOException(JNIEnv * env, const std::string & message) - { - jclass cls = env->FindClass("java/io/IOException"); - - if (cls != nullptr) { - env->ThrowNew(cls, message.c_str()); - env->DeleteLocalRef(cls); - } - } - ffmpeg::MediaRecorder * RecorderOf(jlong handle) { return reinterpret_cast(handle); @@ -45,40 +36,44 @@ namespace JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_create (JNIEnv * env, jobject caller, jstring jPath, jlong tableAddress) { - const webrtc_java_api * api = reinterpret_cast(tableAddress); - std::string apiError = ffmpeg::CheckApi(api); + return ffmpeg::CallFromJava(env, 0, [&]() -> jlong { + const webrtc_java_api * api = reinterpret_cast(tableAddress); + std::string apiError = ffmpeg::CheckApi(api); - if (!apiError.empty()) { - ThrowIOException(env, apiError); + if (!apiError.empty()) { + ffmpeg::ThrowIOException(env, apiError); - return 0; - } + return 0; + } - const char * chars = env->GetStringUTFChars(jPath, nullptr); + std::string path; - if (chars == nullptr) { - return 0; - } + { + ffmpeg::JavaStringChars chars(env, jPath); - std::string path(chars); + if (chars.get() == nullptr) { + return 0; + } - env->ReleaseStringUTFChars(jPath, chars); + path = chars.str(); + } - auto recorder = std::make_unique(api, path); + auto recorder = std::make_unique(api, path); - int result = recorder->Open(); + int result = recorder->Open(); - if (result < 0) { - recorder.reset(); + if (result < 0) { + recorder.reset(); - ThrowIOException(env, "Opening " + path + " for recording failed: " + ffmpeg::ErrorText(result)); + ffmpeg::ThrowIOException(env, "Opening " + path + " for recording failed: " + ffmpeg::ErrorText(result)); - return 0; - } + return 0; + } - recorder->SetObserver(std::make_unique(env, caller)); + recorder->SetObserver(std::make_unique(env, caller)); - return reinterpret_cast(recorder.release()); + return reinterpret_cast(recorder.release()); + }); } JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_addTrack @@ -88,15 +83,20 @@ JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_addTr return -1; } - return RecorderOf(handle)->AddTrack(reinterpret_cast(frames)); + // A track that fails to be added has its frames released already. + return ffmpeg::CallFromJava(env, -1, [&]() -> jint { + return RecorderOf(handle)->AddTrack(reinterpret_cast(frames)); + }); } JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_start (JNIEnv * env, jclass caller, jlong handle) { - if (handle != 0) { - RecorderOf(handle)->Start(); - } + ffmpeg::CallFromJava(env, [&] { + if (handle != 0) { + RecorderOf(handle)->Start(); + } + }); } JNIEXPORT jboolean JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_stop @@ -106,7 +106,9 @@ JNIEXPORT jboolean JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_s return JNI_FALSE; } - return RecorderOf(handle)->Stop() ? JNI_TRUE : JNI_FALSE; + return ffmpeg::CallFromJava(env, JNI_FALSE, [&]() -> jboolean { + return RecorderOf(handle)->Stop() ? JNI_TRUE : JNI_FALSE; + }); } JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_recorder_MediaRecorder_dispose diff --git a/webrtc-media/src/main/cpp/src/media/AllocationFault.cpp b/webrtc-media/src/main/cpp/src/media/AllocationFault.cpp new file mode 100644 index 00000000..7b18864f --- /dev/null +++ b/webrtc-media/src/main/cpp/src/media/AllocationFault.cpp @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "media/AllocationFault.h" + +#include +#include + +namespace +{ + std::atomic fail_string_copy{ false }; +} + +namespace ffmpeg +{ + void AllocationFault::FailNextStringCopy() + { + fail_string_copy.store(true); + } + + void AllocationFault::Disarm() + { + fail_string_copy.store(false); + } + + void AllocationFault::CheckStringCopy() + { + if (fail_string_copy.load() && fail_string_copy.exchange(false)) { + throw std::bad_alloc(); + } + } +} diff --git a/webrtc-media/src/main/cpp/src/media/JniBoundary.cpp b/webrtc-media/src/main/cpp/src/media/JniBoundary.cpp new file mode 100644 index 00000000..445260ce --- /dev/null +++ b/webrtc-media/src/main/cpp/src/media/JniBoundary.cpp @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "media/JniBoundary.h" +#include "media/AllocationFault.h" + +#include +#include +#include + +namespace +{ + std::atomic held_strings{ 0 }; +} + +namespace ffmpeg +{ + void ThrowJavaException(JNIEnv * env, const char * class_name, const char * message) noexcept + { + if (env->ExceptionCheck()) { + return; + } + + jclass cls = env->FindClass(class_name); + + // Not found, the VM has thrown already. + if (cls != nullptr) { + env->ThrowNew(cls, message); + env->DeleteLocalRef(cls); + } + } + + void ThrowIOException(JNIEnv * env, const std::string & message) noexcept + { + ThrowJavaException(env, "java/io/IOException", message.c_str()); + } + + void ThrowCurrentException(JNIEnv * env) noexcept + { + try { + throw; + } + catch (const std::bad_alloc &) { + ThrowJavaException(env, "java/lang/OutOfMemoryError", "Native memory allocation failed"); + } + catch (const std::exception & e) { + ThrowJavaException(env, "java/lang/RuntimeException", e.what()); + } + catch (...) { + ThrowJavaException(env, "java/lang/RuntimeException", "Unknown native exception"); + } + } + + JavaStringChars::JavaStringChars(JNIEnv * env, jstring string) noexcept : + env_(env), + string_(string), + chars_(nullptr) + { + if (string == nullptr) { + ThrowJavaException(env, "java/lang/NullPointerException", "String is null"); + return; + } + + // Null when the VM is out of memory, and it has thrown then. + chars_ = env->GetStringUTFChars(string, nullptr); + + if (chars_ != nullptr) { + held_strings.fetch_add(1); + } + } + + JavaStringChars::~JavaStringChars() + { + if (chars_ != nullptr) { + env_->ReleaseStringUTFChars(string_, chars_); + held_strings.fetch_sub(1); + } + } + + std::string JavaStringChars::str() const + { + AllocationFault::CheckStringCopy(); + + return std::string(chars_); + } + + int JavaStringChars::Held() noexcept + { + return held_strings.load(); + } +} diff --git a/webrtc-media/src/main/cpp/src/media/MediaRecorder.cpp b/webrtc-media/src/main/cpp/src/media/MediaRecorder.cpp index b8b06318..d69acdcb 100644 --- a/webrtc-media/src/main/cpp/src/media/MediaRecorder.cpp +++ b/webrtc-media/src/main/cpp/src/media/MediaRecorder.cpp @@ -118,12 +118,29 @@ namespace ffmpeg return -1; } - auto input = std::make_unique(); + const int index = static_cast(inputs_.size()); + std::unique_ptr input; + std::unique_ptr track; + + try { + input = std::make_unique(); + track = std::make_unique(index); + + // Room for both first, so that no track is added without its input. + tracks_.reserve(tracks_.size() + 1); + inputs_.reserve(inputs_.size() + 1); + } + catch (...) { + // The frames were handed over, and nothing else releases them. + api_->encoded_frames_release(frames); + throw; + } + input->recorder = this; - input->index = static_cast(inputs_.size()); + input->index = index; input->frames = frames; - tracks_.push_back(std::make_unique(input->index)); + tracks_.push_back(std::move(track)); inputs_.push_back(std::move(input)); return static_cast(inputs_.size()) - 1; @@ -143,7 +160,17 @@ namespace ffmpeg start_us_ = api_->now_us(); } - thread_ = std::thread(&MediaRecorder::Run, this); + try { + thread_ = std::thread(&MediaRecorder::Run, this); + } + catch (...) { + // Without a writer nothing may be queued, and stopping only has to + // release the frames and close the file. + std::lock_guard lock(mutex_); + + accepting_ = false; + throw; + } // The inputs do not change once started, so the callbacks may hold on // to them without the lock. diff --git a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/AllocationFault.java b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/AllocationFault.java new file mode 100644 index 00000000..f4a94180 --- /dev/null +++ b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/AllocationFault.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc.media.player; + +/** + * Makes an allocation of the native library fail on purpose, so that the + * tests can see what a native entry point does when the C++ heap is + * exhausted, which it is not on demand. It lives in the tests so that nothing + * else can reach it. + * + * @author Alex Andres + */ +public final class AllocationFault { + + static { + FFmpeg.load(); + } + + + private AllocationFault() { + // Static access only. + } + + /** + * Fails the next copy of a Java string that a native entry point makes, + * such as the source of a reader or the file of a recorder. + */ + public static native void failNextStringCopy(); + + /** + * Forgets a fault that has not fired. + */ + public static native void disarm(); + + /** + * Returns how many Java strings the native library holds the characters + * of right now, which is 0 whenever no entry point is running. + */ + public static native int heldStrings(); + +} diff --git a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java index 398550fc..a28fa33a 100644 --- a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java +++ b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java @@ -116,6 +116,27 @@ void missingSourceFails() { assertTrue(e.getMessage().contains("no-such-file.webm"), e.getMessage()); } + @Test + void sourceCopyFails() throws Exception { + // The native copy of the source fails as an exhausted heap does: the + // failure reaches Java instead of ending the process, and the + // characters of the Java string are released all the same. + AllocationFault.failNextStringCopy(); + + try { + assertThrows(OutOfMemoryError.class, () -> new MediaReader(asset())); + } + finally { + AllocationFault.disarm(); + } + + assertEquals(0, AllocationFault.heldStrings()); + + try (MediaReader reader = new MediaReader(asset())) { + assertTrue(reader.getInfo().hasVideo()); + } + } + @Test void closesTwice() throws Exception { MediaReader reader = new MediaReader(asset()); diff --git a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java index 1cbd4c41..e87f3ef8 100644 --- a/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java +++ b/webrtc-media/src/test/java/dev/onvoid/webrtc/media/recorder/MediaRecorderTest.java @@ -34,6 +34,7 @@ import dev.onvoid.webrtc.RTCRtpReceiver; import dev.onvoid.webrtc.media.audio.AudioDeviceModule; import dev.onvoid.webrtc.media.audio.AudioLayer; +import dev.onvoid.webrtc.media.player.AllocationFault; import dev.onvoid.webrtc.media.player.MediaInfo; import dev.onvoid.webrtc.media.player.MediaReader; @@ -220,6 +221,30 @@ void unstartedRecorderDeletesFile() throws Exception { assertFalse(Files.exists(file)); } + @Test + void pathCopyFails() throws Exception { + // The native copy of the path fails as an exhausted heap does: the + // failure reaches Java instead of ending the process, and the + // characters of the Java string are released all the same. + Path file = tempDir.resolve("copy-fails.mkv"); + + AllocationFault.failNextStringCopy(); + + try { + assertThrows(OutOfMemoryError.class, () -> new MediaRecorder(file)); + } + finally { + AllocationFault.disarm(); + } + + assertEquals(0, AllocationFault.heldStrings()); + assertFalse(Files.exists(file)); + + try (MediaRecorder recorder = new MediaRecorder(file)) { + assertEquals(MediaRecorderState.IDLE, recorder.getState()); + } + } + @Test void rejectsTracksOnceStarted() throws Exception { Path file = tempDir.resolve("rejects.mkv");