From f3120feb1c116450e4e4eda0292cbcd2e432f1ae Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Thu, 2 Jul 2026 20:46:28 +0800 Subject: [PATCH 1/4] better error safety --- AGENTS.md | 19 ++ CMakeLists.txt | 9 + include/transcribe.h | 14 + scripts/ci/vulkan_degradation_check.py | 26 +- src/arch/canary/canary.h | 2 +- src/arch/canary/model.cpp | 10 +- src/arch/canary_qwen/model.cpp | 16 +- src/arch/cohere/cohere.h | 2 +- src/arch/cohere/model.cpp | 10 +- src/arch/funasr_nano/model.cpp | 6 +- src/arch/gigaam/model.cpp | 6 +- src/arch/granite/model.cpp | 8 +- src/arch/granite_nar/model.cpp | 8 +- src/arch/medasr/model.cpp | 6 +- src/arch/moonshine/model.cpp | 6 +- src/arch/moonshine/moonshine.h | 2 +- src/arch/moonshine_streaming/model.cpp | 6 +- .../moonshine_streaming/moonshine_streaming.h | 2 +- src/arch/parakeet/decoder.cpp | 34 +-- src/arch/parakeet/model.cpp | 16 +- src/arch/qwen3_asr/model.cpp | 6 +- src/arch/sensevoice/model.cpp | 6 +- src/arch/voxtral/model.cpp | 6 +- src/arch/voxtral_realtime/model.cpp | 10 +- src/arch/whisper/model.cpp | 6 +- src/arch/whisper/whisper.h | 4 +- src/causal_lm/causal_lm.cpp | 5 +- src/transcribe-backend.cpp | 46 +++ src/transcribe-backend.h | 10 + src/transcribe-load-common.cpp | 37 ++- src/transcribe-loader.h | 13 +- src/transcribe-model.h | 6 +- src/transcribe.cpp | 285 +++++++++++++++--- tests/CMakeLists.txt | 44 +++ tests/backend_init_throw_unit.cpp | 184 +++++++++++ tests/lint_teardown.cmake | 34 +++ tests/log_unit.cpp | 25 ++ tests/teardown_safety_unit.cpp | 118 ++++++++ 38 files changed, 906 insertions(+), 147 deletions(-) create mode 100644 tests/backend_init_throw_unit.cpp create mode 100644 tests/lint_teardown.cmake create mode 100644 tests/teardown_safety_unit.cpp diff --git a/AGENTS.md b/AGENTS.md index c1bf31ed..3c547af5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,25 @@ scripts/ci/clang-format.sh --check # verify, no changes formatted. CI gates our C/C++ in `.github/workflows/clang-format.yml`. +## C ABI Exception Discipline + +No C++ exception may escape a public entry point. + +- A new public entry point must either route through an `api_guard_*` + wrapper (`src/transcribe.cpp`) or be nothrow by construction. Device and + registry queries are not pure reads; guard them. +- Entry points with ownership out-params enforce "non-OK => `*out == NULL`, + nothing leaked" in their forwarders on every error return. +- Teardown never uses raw `ggml_backend_free` / `ggml_backend_buffer_free` / + `ggml_backend_sched_free` in library code: use `transcribe::safe_*` from + `src/transcribe-backend.h`. `tests/lint_teardown.cmake` fails CI on + violations. +- Host log callbacks are contained at the emission site + (`transcribe_log_invoke`). +- The `TRANSCRIBE_TEST_*` fault hooks (`_DEV_INIT_THROW`, `_TEARDOWN_THROW`) + intentionally ship in release artifacts for wheel clean-install CI. + Present-but-empty values are inert. + ## Verification - End-to-end numerical checks: diff --git a/CMakeLists.txt b/CMakeLists.txt index 2191715f..dc4735af 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -387,6 +387,15 @@ if(TRANSCRIBE_GGML_BACKEND_DL) set(GGML_NATIVE OFF CACHE BOOL "" FORCE) endif() +# MSVC: use /EHs so exceptions thrown by ggml's C-ABI frames unwind to +# transcribe.cpp's public-entry guards. Apply before ggml and src are added. +if(MSVC) + string(REPLACE "/EHsc" "/EHs" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") + if(NOT CMAKE_CXX_FLAGS MATCHES "/EHs") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /EHs") + endif() +endif() + # ggml's CMake declares its own warning flags; let it. add_subdirectory(ggml) diff --git a/include/transcribe.h b/include/transcribe.h index 5cff1a5d..15bf1d67 100644 --- a/include/transcribe.h +++ b/include/transcribe.h @@ -52,6 +52,12 @@ * releases. Consumers should rebuild against matching headers and * should not assume any layout, enum value, or symbol set is frozen. * + * Exception safety (C ABI): + * - No C++ exception escapes a public entry point. Allocation failure maps + * to TRANSCRIBE_ERR_OOM, backend/driver failure to TRANSCRIBE_ERR_BACKEND, + * and free/teardown functions never fail. The log callback receives + * exception detail when available. + * * Params (size-aware structs): * - Every caller-owned public struct crossing the ABI carries `struct_size` * as field 0, both for inputs (params, family extensions) and outputs @@ -407,6 +413,10 @@ typedef void (*transcribe_log_callback)(transcribe_log_level level, const char * * cb == NULL logging explicitly disabled: library and ggml * messages are dropped, nothing goes to stderr. * + * The callback must not throw. If it does anyway, the exception is contained + * at the emission site, the message is dropped, and a note is written to + * stderr. + * * Call once at process startup (see the threading contract above). */ TRANSCRIBE_API void transcribe_log_set(transcribe_log_callback cb, void * userdata); @@ -1433,6 +1443,10 @@ TRANSCRIBE_API const struct transcribe_model * transcribe_get_model(const struct * On success, results are populated on the session and may be read via * the accessors below. Calling transcribe_run() again replaces the * previous result on the same session. + * + * TRANSCRIBE_ERR_BACKEND from this function (and batch/streaming run calls) + * is recoverable in-process: free the session and model, reload with + * transcribe_model_load_params::backend = TRANSCRIBE_BACKEND_CPU, and retry. */ TRANSCRIBE_API transcribe_status transcribe_run(struct transcribe_session * session, const float * pcm, diff --git a/scripts/ci/vulkan_degradation_check.py b/scripts/ci/vulkan_degradation_check.py index 838a5343..e3b8c7c3 100644 --- a/scripts/ci/vulkan_degradation_check.py +++ b/scripts/ci/vulkan_degradation_check.py @@ -15,7 +15,10 @@ loader A loader (+ driver, e.g. lavapipe or a real GPU) is present. The SAME artifacts must discover a Vulkan device. With - --model: a real transcription runs with backend="vulkan". + --model: a real transcription runs with backend="vulkan"; + then, with TRANSCRIBE_TEST_DEV_INIT_THROW=* injecting a + device-init throw, explicit vulkan fails cleanly and default + backend falls back to CPU. Used by: provider-dl-vulkan (python-bindings.yml, hand-assembled provider dir), clean-install (python-wheels.yml, the shipped wheel — ships in the @@ -29,6 +32,7 @@ import argparse import array +import os import sys import wave @@ -97,6 +101,26 @@ def main() -> int: text = transcribe(t, args.model, args.audio, backend="vulkan") print("text (vulkan):", text.strip()) assert "country" in text.lower(), text + + # Same shipped artifact, injected device-init failure. + os.environ["TRANSCRIBE_TEST_DEV_INIT_THROW"] = "*" + try: + try: + t.Model(args.model, backend="vulkan") + except t.TranscribeError as exc: + print("throwing vulkan device correctly rejected:", exc) + else: + raise AssertionError( + "backend='vulkan' must fail when device init throws") + with t.Model(args.model) as m: + assert m.backend == "cpu", m.backend + with m.session() as s: + text = s.run(load_pcm(args.audio)).text + print("text (throwing devices, default backend):", + text.strip()) + assert "country" in text.lower(), text + finally: + del os.environ["TRANSCRIBE_TEST_DEV_INIT_THROW"] print("ok: loader present — the same artifacts discovered Vulkan") return 0 diff --git a/src/arch/canary/canary.h b/src/arch/canary/canary.h index 558fe85c..ee905c48 100644 --- a/src/arch/canary/canary.h +++ b/src/arch/canary/canary.h @@ -60,7 +60,7 @@ struct CanaryKvCache { void free() { if (buffer != nullptr) { - ggml_backend_buffer_free(buffer); + safe_buffer_free(buffer); buffer = nullptr; } if (ctx != nullptr) { diff --git a/src/arch/canary/model.cpp b/src/arch/canary/model.cpp index 072c3373..84e02976 100644 --- a/src/arch/canary/model.cpp +++ b/src/arch/canary/model.cpp @@ -46,7 +46,7 @@ static_assert(std::is_base_of_v); CanarySession::~CanarySession() { kv_cache.free(); if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -183,7 +183,7 @@ CanaryModel::~CanaryModel() { bn_fused_ctx = nullptr; } if (bn_fused_buffer != nullptr) { - ggml_backend_buffer_free(bn_fused_buffer); + safe_buffer_free(bn_fused_buffer); bn_fused_buffer = nullptr; } if (conv_pw_f32_ctx != nullptr) { @@ -191,7 +191,7 @@ CanaryModel::~CanaryModel() { conv_pw_f32_ctx = nullptr; } if (conv_pw_f32_buffer != nullptr) { - ggml_backend_buffer_free(conv_pw_f32_buffer); + safe_buffer_free(conv_pw_f32_buffer); conv_pw_f32_buffer = nullptr; } if (ctx_meta != nullptr) { @@ -199,11 +199,11 @@ CanaryModel::~CanaryModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/canary_qwen/model.cpp b/src/arch/canary_qwen/model.cpp index 35de0274..71ccdb01 100644 --- a/src/arch/canary_qwen/model.cpp +++ b/src/arch/canary_qwen/model.cpp @@ -61,7 +61,7 @@ CanaryQwenSession::~CanaryQwenSession() { kv_cache.free(); kv_cache_batch.free(); if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -76,11 +76,11 @@ CanaryQwenModel::~CanaryQwenModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } if (bn_fused_buffer != nullptr) { - ggml_backend_buffer_free(bn_fused_buffer); + safe_buffer_free(bn_fused_buffer); bn_fused_buffer = nullptr; } if (bn_fused_ctx != nullptr) { @@ -88,7 +88,7 @@ CanaryQwenModel::~CanaryQwenModel() { bn_fused_ctx = nullptr; } if (conv_pw_f32_buffer != nullptr) { - ggml_backend_buffer_free(conv_pw_f32_buffer); + safe_buffer_free(conv_pw_f32_buffer); conv_pw_f32_buffer = nullptr; } if (conv_pw_f32_ctx != nullptr) { @@ -96,7 +96,7 @@ CanaryQwenModel::~CanaryQwenModel() { conv_pw_f32_ctx = nullptr; } if (linear_f32_buffer != nullptr) { - ggml_backend_buffer_free(linear_f32_buffer); + safe_buffer_free(linear_f32_buffer); linear_f32_buffer = nullptr; } if (linear_f32_ctx != nullptr) { @@ -105,7 +105,7 @@ CanaryQwenModel::~CanaryQwenModel() { } packed_gate_up.free(); for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; @@ -357,7 +357,7 @@ transcribe_status promote_conv_pw_to_f32_on_cpu(CanaryQwenModel & m) { const auto * f16_traits = ggml_get_type_traits(GGML_TYPE_F16); if (f16_traits == nullptr || f16_traits->to_float == nullptr) { log_msg(TRANSCRIBE_LOG_LEVEL_WARN, "canary_qwen: no f16 to_float trait — skipping conv promotion"); - ggml_backend_buffer_free(m.conv_pw_f32_buffer); + safe_buffer_free(m.conv_pw_f32_buffer); m.conv_pw_f32_buffer = nullptr; ggml_free(m.conv_pw_f32_ctx); m.conv_pw_f32_ctx = nullptr; @@ -483,7 +483,7 @@ transcribe_status promote_linears_bf16_to_f32_on_cpu(CanaryQwenModel & m) { const auto * bf16_traits = ggml_get_type_traits(GGML_TYPE_BF16); if (bf16_traits == nullptr || bf16_traits->to_float == nullptr) { log_msg(TRANSCRIBE_LOG_LEVEL_WARN, "canary_qwen: no bf16 to_float trait — skipping linear promotion"); - ggml_backend_buffer_free(m.linear_f32_buffer); + safe_buffer_free(m.linear_f32_buffer); m.linear_f32_buffer = nullptr; ggml_free(m.linear_f32_ctx); m.linear_f32_ctx = nullptr; diff --git a/src/arch/cohere/cohere.h b/src/arch/cohere/cohere.h index 3b369405..319e6151 100644 --- a/src/arch/cohere/cohere.h +++ b/src/arch/cohere/cohere.h @@ -78,7 +78,7 @@ struct CohereKvCache { void free() { if (buffer != nullptr) { - ggml_backend_buffer_free(buffer); + safe_buffer_free(buffer); buffer = nullptr; } if (ctx != nullptr) { diff --git a/src/arch/cohere/model.cpp b/src/arch/cohere/model.cpp index b631a579..22e638d1 100644 --- a/src/arch/cohere/model.cpp +++ b/src/arch/cohere/model.cpp @@ -49,7 +49,7 @@ static_assert(std::is_base_of_v); CohereSession::~CohereSession() { kv_cache.free(); if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -193,7 +193,7 @@ CohereModel::~CohereModel() { bn_fused_ctx = nullptr; } if (bn_fused_buffer != nullptr) { - ggml_backend_buffer_free(bn_fused_buffer); + safe_buffer_free(bn_fused_buffer); bn_fused_buffer = nullptr; } if (conv_pw_f32_ctx != nullptr) { @@ -201,7 +201,7 @@ CohereModel::~CohereModel() { conv_pw_f32_ctx = nullptr; } if (conv_pw_f32_buffer != nullptr) { - ggml_backend_buffer_free(conv_pw_f32_buffer); + safe_buffer_free(conv_pw_f32_buffer); conv_pw_f32_buffer = nullptr; } if (ctx_meta != nullptr) { @@ -209,11 +209,11 @@ CohereModel::~CohereModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/funasr_nano/model.cpp b/src/arch/funasr_nano/model.cpp index 45f365f4..c1aa74c1 100644 --- a/src/arch/funasr_nano/model.cpp +++ b/src/arch/funasr_nano/model.cpp @@ -47,7 +47,7 @@ FunAsrNanoSession::~FunAsrNanoSession() { kv_cache.free(); kv_cache_batch.free(); if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -62,12 +62,12 @@ FunAsrNanoModel::~FunAsrNanoModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } packed_gate_up.free(); for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/gigaam/model.cpp b/src/arch/gigaam/model.cpp index 3675a36a..1935db87 100644 --- a/src/arch/gigaam/model.cpp +++ b/src/arch/gigaam/model.cpp @@ -43,7 +43,7 @@ static_assert(std::is_base_of_v); GigaamSession::~GigaamSession() { if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -59,11 +59,11 @@ GigaamModel::~GigaamModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/granite/model.cpp b/src/arch/granite/model.cpp index dc24f439..f7c1a3ec 100644 --- a/src/arch/granite/model.cpp +++ b/src/arch/granite/model.cpp @@ -41,7 +41,7 @@ GraniteSession::~GraniteSession() { kv.free(); kv_batch.free(); if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -56,7 +56,7 @@ GraniteModel::~GraniteModel() { bn_fused_ctx = nullptr; } if (bn_fused_buffer != nullptr) { - ggml_backend_buffer_free(bn_fused_buffer); + safe_buffer_free(bn_fused_buffer); bn_fused_buffer = nullptr; } packed_gate_up.free(); @@ -65,11 +65,11 @@ GraniteModel::~GraniteModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/granite_nar/model.cpp b/src/arch/granite_nar/model.cpp index 48699d8f..42fed264 100644 --- a/src/arch/granite_nar/model.cpp +++ b/src/arch/granite_nar/model.cpp @@ -54,7 +54,7 @@ static_assert(std::is_base_of_v); GraniteNarSession::~GraniteNarSession() { if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -69,7 +69,7 @@ GraniteNarModel::~GraniteNarModel() { bn_fused_ctx = nullptr; } if (bn_fused_buffer != nullptr) { - ggml_backend_buffer_free(bn_fused_buffer); + safe_buffer_free(bn_fused_buffer); bn_fused_buffer = nullptr; } if (ctx_meta != nullptr) { @@ -77,11 +77,11 @@ GraniteNarModel::~GraniteNarModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/medasr/model.cpp b/src/arch/medasr/model.cpp index a97b0f8d..0bd3f589 100644 --- a/src/arch/medasr/model.cpp +++ b/src/arch/medasr/model.cpp @@ -50,7 +50,7 @@ static_assert(std::is_base_of_v); MedAsrSession::~MedAsrSession() { if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -66,11 +66,11 @@ MedAsrModel::~MedAsrModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/moonshine/model.cpp b/src/arch/moonshine/model.cpp index 13e26f3d..438a5ae0 100644 --- a/src/arch/moonshine/model.cpp +++ b/src/arch/moonshine/model.cpp @@ -45,7 +45,7 @@ static_assert(std::is_base_of_v); MoonshineSession::~MoonshineSession() { kv_cache.free(); if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -60,11 +60,11 @@ MoonshineModel::~MoonshineModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/moonshine/moonshine.h b/src/arch/moonshine/moonshine.h index 23b91038..defd93a2 100644 --- a/src/arch/moonshine/moonshine.h +++ b/src/arch/moonshine/moonshine.h @@ -65,7 +65,7 @@ struct MoonshineKvCache { void free() { if (buffer != nullptr) { - ggml_backend_buffer_free(buffer); + safe_buffer_free(buffer); buffer = nullptr; } if (ctx != nullptr) { diff --git a/src/arch/moonshine_streaming/model.cpp b/src/arch/moonshine_streaming/model.cpp index 7f4ee4b9..84f3ec32 100644 --- a/src/arch/moonshine_streaming/model.cpp +++ b/src/arch/moonshine_streaming/model.cpp @@ -61,7 +61,7 @@ static_assert(std::is_base_of_v); MoonshineStreamingSession::~MoonshineStreamingSession() { kv_cache.free(); if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -76,11 +76,11 @@ MoonshineStreamingModel::~MoonshineStreamingModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/moonshine_streaming/moonshine_streaming.h b/src/arch/moonshine_streaming/moonshine_streaming.h index 7e2793b5..05517a25 100644 --- a/src/arch/moonshine_streaming/moonshine_streaming.h +++ b/src/arch/moonshine_streaming/moonshine_streaming.h @@ -61,7 +61,7 @@ struct MoonshineStreamingKvCache { void free() { if (buffer != nullptr) { - ggml_backend_buffer_free(buffer); + safe_buffer_free(buffer); buffer = nullptr; } if (ctx != nullptr) { diff --git a/src/arch/parakeet/decoder.cpp b/src/arch/parakeet/decoder.cpp index c020ef60..27a83c2f 100644 --- a/src/arch/parakeet/decoder.cpp +++ b/src/arch/parakeet/decoder.cpp @@ -119,7 +119,7 @@ bool build_joint_weight(HostJoint & j, const ggml_tensor * src_out_w) { auto fail = [&]() -> bool { if (j.w_buf != nullptr) { - ggml_backend_buffer_free(j.w_buf); + safe_buffer_free(j.w_buf); j.w_buf = nullptr; } if (j.w_ctx != nullptr) { @@ -127,7 +127,7 @@ bool build_joint_weight(HostJoint & j, const ggml_tensor * src_out_w) { j.w_ctx = nullptr; } if (j.w_backend != nullptr) { - ggml_backend_free(j.w_backend); + safe_backend_free(j.w_backend); j.w_backend = nullptr; } j.g_enc_w = nullptr; @@ -210,7 +210,7 @@ bool build_pred_weights(HostPredictor & p) { auto fail = [&]() -> bool { if (p.lstm_w_buf != nullptr) { - ggml_backend_buffer_free(p.lstm_w_buf); + safe_buffer_free(p.lstm_w_buf); p.lstm_w_buf = nullptr; } if (p.lstm_w_ctx != nullptr) { @@ -218,7 +218,7 @@ bool build_pred_weights(HostPredictor & p) { p.lstm_w_ctx = nullptr; } if (p.lstm_w_backend != nullptr) { - ggml_backend_free(p.lstm_w_backend); + safe_backend_free(p.lstm_w_backend); p.lstm_w_backend = nullptr; } for (auto & lh : p.lstm) { @@ -301,7 +301,7 @@ struct JointGraph { ~JointGraph() { if (buf != nullptr) { - ggml_backend_buffer_free(buf); + safe_buffer_free(buf); } if (ctx != nullptr) { ggml_free(ctx); @@ -328,7 +328,7 @@ bool build_joint_graph(JointGraph & g, const HostJoint & j, ggml_backend_t backe auto fail = [&]() -> bool { if (g.buf != nullptr) { - ggml_backend_buffer_free(g.buf); + safe_buffer_free(g.buf); g.buf = nullptr; } if (g.ctx != nullptr) { @@ -435,14 +435,14 @@ struct PredGraph { ~PredGraph() { if (buf != nullptr) { - ggml_backend_buffer_free(buf); + safe_buffer_free(buf); } if (ctx != nullptr) { ggml_free(ctx); } - free_cpu_threadpool(backend, tp); // before ggml_backend_free(backend) + free_cpu_threadpool(backend, tp); // before safe_backend_free(backend) if (backend != nullptr) { - ggml_backend_free(backend); + safe_backend_free(backend); } } @@ -465,7 +465,7 @@ bool build_pred_graph(PredGraph & g, const HostPredictor & p, int n_threads) { auto fail = [&]() -> bool { if (g.buf != nullptr) { - ggml_backend_buffer_free(g.buf); + safe_buffer_free(g.buf); g.buf = nullptr; } if (g.ctx != nullptr) { @@ -475,7 +475,7 @@ bool build_pred_graph(PredGraph & g, const HostPredictor & p, int n_threads) { free_cpu_threadpool(g.backend, g.tp); g.tp = nullptr; // before freeing backend if (g.backend != nullptr) { - ggml_backend_free(g.backend); + safe_backend_free(g.backend); g.backend = nullptr; } g.x = nullptr; @@ -585,25 +585,25 @@ bool build_pred_graph(PredGraph & g, const HostPredictor & p, int n_threads) { HostJoint::~HostJoint() { if (w_buf != nullptr) { - ggml_backend_buffer_free(w_buf); + safe_buffer_free(w_buf); } if (w_ctx != nullptr) { ggml_free(w_ctx); } if (w_backend != nullptr) { - ggml_backend_free(w_backend); + safe_backend_free(w_backend); } } HostPredictor::~HostPredictor() { if (lstm_w_buf != nullptr) { - ggml_backend_buffer_free(lstm_w_buf); + safe_buffer_free(lstm_w_buf); } if (lstm_w_ctx != nullptr) { ggml_free(lstm_w_ctx); } if (lstm_w_backend != nullptr) { - ggml_backend_free(lstm_w_backend); + safe_backend_free(lstm_w_backend); } } @@ -928,14 +928,14 @@ bool precompute_enc_proj_ggml(const HostJoint & j, ggml_cgraph * graph = ggml_new_graph(ctx); ggml_build_forward_expand(graph, res); if (ggml_backend_graph_compute(backend, graph) != GGML_STATUS_SUCCESS) { - ggml_backend_buffer_free(buf); + safe_buffer_free(buf); ggml_free(ctx); return false; } ggml_backend_tensor_get(res, out.data(), 0, static_cast(T) * static_cast(joint_h) * sizeof(float)); - ggml_backend_buffer_free(buf); + safe_buffer_free(buf); ggml_free(ctx); return true; } diff --git a/src/arch/parakeet/model.cpp b/src/arch/parakeet/model.cpp index 4da16b79..3b49742b 100644 --- a/src/arch/parakeet/model.cpp +++ b/src/arch/parakeet/model.cpp @@ -59,7 +59,7 @@ ParakeetSession::~ParakeetSession() { // Tear down per-call compute state before the model's backend plan // (which outlives the context): scheduler, then context. if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -71,7 +71,7 @@ ParakeetSession::~ParakeetSession() { // Streaming cache tensors live in their own ggml_context + backend // buffer. Free buffer first (may hold a backend ref), then the ctx. if (stream_caches.buffer != nullptr) { - ggml_backend_buffer_free(stream_caches.buffer); + safe_buffer_free(stream_caches.buffer); stream_caches.buffer = nullptr; } if (stream_caches.ctx != nullptr) { @@ -80,7 +80,7 @@ ParakeetSession::~ParakeetSession() { } // Rel-pos projection memo (own ctx + buffer; geometry-keyed). if (stream_caches.pos_proj_buf != nullptr) { - ggml_backend_buffer_free(stream_caches.pos_proj_buf); + safe_buffer_free(stream_caches.pos_proj_buf); stream_caches.pos_proj_buf = nullptr; } if (stream_caches.pos_proj_ctx != nullptr) { @@ -105,7 +105,7 @@ ParakeetModel::~ParakeetModel() { bn_fused_ctx = nullptr; } if (bn_fused_buffer != nullptr) { - ggml_backend_buffer_free(bn_fused_buffer); + safe_buffer_free(bn_fused_buffer); bn_fused_buffer = nullptr; } // CPU conv_pw F32 promotion ctx + buffer (non-null only when @@ -115,7 +115,7 @@ ParakeetModel::~ParakeetModel() { conv_pw_f32_ctx = nullptr; } if (conv_pw_f32_buffer != nullptr) { - ggml_backend_buffer_free(conv_pw_f32_buffer); + safe_buffer_free(conv_pw_f32_buffer); conv_pw_f32_buffer = nullptr; } if (ctx_meta != nullptr) { @@ -123,11 +123,11 @@ ParakeetModel::~ParakeetModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; @@ -1645,7 +1645,7 @@ transcribe_status ensure_pos_proj_cache(ParakeetSession * pc, ParakeetModel * pm auto & sc = pc->stream_caches; if (sc.pos_proj_buf != nullptr) { - ggml_backend_buffer_free(sc.pos_proj_buf); + safe_buffer_free(sc.pos_proj_buf); sc.pos_proj_buf = nullptr; } if (sc.pos_proj_ctx != nullptr) { diff --git a/src/arch/qwen3_asr/model.cpp b/src/arch/qwen3_asr/model.cpp index f208e946..6e382fbe 100644 --- a/src/arch/qwen3_asr/model.cpp +++ b/src/arch/qwen3_asr/model.cpp @@ -44,7 +44,7 @@ QwenAsrSession::~QwenAsrSession() { kv_cache.free(); kv_cache_batch.free(); if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -59,12 +59,12 @@ QwenAsrModel::~QwenAsrModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } packed_gate_up.free(); for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/sensevoice/model.cpp b/src/arch/sensevoice/model.cpp index 6826029a..9316fc9e 100644 --- a/src/arch/sensevoice/model.cpp +++ b/src/arch/sensevoice/model.cpp @@ -44,7 +44,7 @@ static_assert(std::is_base_of_v); SenseVoiceSession::~SenseVoiceSession() { if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -59,11 +59,11 @@ SenseVoiceModel::~SenseVoiceModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/voxtral/model.cpp b/src/arch/voxtral/model.cpp index d9d755ed..5a8be148 100644 --- a/src/arch/voxtral/model.cpp +++ b/src/arch/voxtral/model.cpp @@ -52,7 +52,7 @@ VoxtralSession::~VoxtralSession() { kv_cache.free(); kv_cache_batch.free(); if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -67,12 +67,12 @@ VoxtralModel::~VoxtralModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } packed_gate_up.free(); for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; diff --git a/src/arch/voxtral_realtime/model.cpp b/src/arch/voxtral_realtime/model.cpp index e2a985b7..2a342cc8 100644 --- a/src/arch/voxtral_realtime/model.cpp +++ b/src/arch/voxtral_realtime/model.cpp @@ -54,7 +54,7 @@ Session::~Session() { enc_kv.free(); ada_scale.clear(); if (ada_buffer != nullptr) { - ggml_backend_buffer_free(ada_buffer); + safe_buffer_free(ada_buffer); ada_buffer = nullptr; } if (ada_ctx != nullptr) { @@ -62,7 +62,7 @@ Session::~Session() { ada_ctx = nullptr; } if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { @@ -77,12 +77,12 @@ Model::~Model() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } packed_gate_up.free(); for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; @@ -365,7 +365,7 @@ transcribe_status compute_ada_scales(Session * cc, Model * cm, int num_delay) { // Persistent ada_scale_all. if (cc->ada_buffer != nullptr) { - ggml_backend_buffer_free(cc->ada_buffer); + safe_buffer_free(cc->ada_buffer); cc->ada_buffer = nullptr; } if (cc->ada_ctx != nullptr) { diff --git a/src/arch/whisper/model.cpp b/src/arch/whisper/model.cpp index 3cca310d..e8c6d711 100644 --- a/src/arch/whisper/model.cpp +++ b/src/arch/whisper/model.cpp @@ -56,11 +56,11 @@ WhisperModel::~WhisperModel() { ctx_meta = nullptr; } if (backend_buffer != nullptr) { - ggml_backend_buffer_free(backend_buffer); + safe_buffer_free(backend_buffer); backend_buffer = nullptr; } for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { - ggml_backend_free(*it); + safe_backend_free(*it); } plan.scheduler_list.clear(); plan.primary = nullptr; @@ -71,7 +71,7 @@ WhisperSession::~WhisperSession() { kv_cache.free(); enc_out.free(); if (sched != nullptr) { - ggml_backend_sched_free(sched); + safe_sched_free(sched); sched = nullptr; } if (compute_ctx != nullptr) { diff --git a/src/arch/whisper/whisper.h b/src/arch/whisper/whisper.h index d5b9a649..6066dd5a 100644 --- a/src/arch/whisper/whisper.h +++ b/src/arch/whisper/whisper.h @@ -87,7 +87,7 @@ struct WhisperKvCache { void free() { if (buffer != nullptr) { - ggml_backend_buffer_free(buffer); + safe_buffer_free(buffer); buffer = nullptr; } if (ctx != nullptr) { @@ -121,7 +121,7 @@ struct WhisperEncOut { void free() { if (buffer != nullptr) { - ggml_backend_buffer_free(buffer); + safe_buffer_free(buffer); buffer = nullptr; } if (ctx != nullptr) { diff --git a/src/causal_lm/causal_lm.cpp b/src/causal_lm/causal_lm.cpp index 5f04ea68..f9510882 100644 --- a/src/causal_lm/causal_lm.cpp +++ b/src/causal_lm/causal_lm.cpp @@ -5,6 +5,7 @@ #include "ggml-backend.h" #include "ggml.h" +#include "transcribe-backend.h" #include "transcribe-log.h" #include "transcribe-session.h" @@ -42,7 +43,7 @@ ggml_tensor * mul_mat_f32acc(ggml_context * ctx, ggml_tensor * w, ggml_tensor * void KvCache::free() { if (buffer != nullptr) { - ggml_backend_buffer_free(buffer); + safe_buffer_free(buffer); buffer = nullptr; } if (ctx != nullptr) { @@ -746,7 +747,7 @@ ggml_tensor * block_prefill_batched(ggml_context * ctx, void PackedGateUpHandles::free() { if (buffer != nullptr) { - ggml_backend_buffer_free(buffer); + safe_buffer_free(buffer); buffer = nullptr; } if (ctx != nullptr) { diff --git a/src/transcribe-backend.cpp b/src/transcribe-backend.cpp index 2e6d2ab6..47a6497b 100644 --- a/src/transcribe-backend.cpp +++ b/src/transcribe-backend.cpp @@ -6,7 +6,11 @@ #include "transcribe-backend.h" +#include "transcribe-log.h" + +#include #include +#include namespace transcribe { @@ -82,4 +86,46 @@ BackendKind classify_device(ggml_backend_dev_t dev) { return classify_backend_type(dev_type, reg_name); } +namespace { + +// Shared body for safe_* teardown wrappers. The test hook fires after the +// real free; present-but-empty is inert. +template void contained_free(const char * what, Fn && do_free) noexcept { + try { + do_free(); + if (const char * hook = std::getenv("TRANSCRIBE_TEST_TEARDOWN_THROW"); hook != nullptr && hook[0] != '\0') { + throw std::runtime_error("TRANSCRIBE_TEST_TEARDOWN_THROW fault injection"); + } + } catch (const std::exception & e) { + log_msg(TRANSCRIBE_LOG_LEVEL_WARN, "%s threw during teardown (contained; resource may leak): %s", what, + e.what()); + } catch (...) { + log_msg(TRANSCRIBE_LOG_LEVEL_WARN, + "%s threw an unknown exception during teardown (contained; resource may leak)", what); + } +} + +} // namespace + +void safe_backend_free(ggml_backend_t backend) noexcept { + if (backend == nullptr) { + return; + } + contained_free("ggml_backend_free", [&] { ggml_backend_free(backend); }); +} + +void safe_buffer_free(ggml_backend_buffer_t buffer) noexcept { + if (buffer == nullptr) { + return; + } + contained_free("ggml_backend_buffer_free", [&] { ggml_backend_buffer_free(buffer); }); +} + +void safe_sched_free(ggml_backend_sched_t sched) noexcept { + if (sched == nullptr) { + return; + } + contained_free("ggml_backend_sched_free", [&] { ggml_backend_sched_free(sched); }); +} + } // namespace transcribe diff --git a/src/transcribe-backend.h b/src/transcribe-backend.h index c9a2d2f8..aaea589a 100644 --- a/src/transcribe-backend.h +++ b/src/transcribe-backend.h @@ -76,4 +76,14 @@ struct BackendPlan { std::vector scheduler_list; }; +// No-throw wrappers for ggml backend teardown. Family destructors are +// implicitly noexcept, so raw backend frees must not appear in library code; +// tests/lint_teardown.cmake enforces this. NULL is a no-op. +// +// Test hook: non-empty TRANSCRIBE_TEST_TEARDOWN_THROW injects an internal +// throw after the real free, proving containment without leaking the handle. +void safe_backend_free(ggml_backend_t backend) noexcept; +void safe_buffer_free(ggml_backend_buffer_t buffer) noexcept; +void safe_sched_free(ggml_backend_sched_t sched) noexcept; + } // namespace transcribe diff --git a/src/transcribe-load-common.cpp b/src/transcribe-load-common.cpp index 52e18123..fbaf1be5 100644 --- a/src/transcribe-load-common.cpp +++ b/src/transcribe-load-common.cpp @@ -14,15 +14,44 @@ #include #include +#include #include #include #include +#include #include namespace transcribe::load_common { namespace { +// Device init can throw from GPU drivers. Treat that like nullptr so AUTO can +// keep probing and explicit backend requests fail cleanly. +// +// Test hook: TRANSCRIBE_TEST_DEV_INIT_THROW is inert when unset or empty, +// "*" matches every device, otherwise it matches by device-name substring. +ggml_backend_t dev_init_checked(ggml_backend_dev_t dev, const char * error_tag) { + const char * name = "?"; + try { + if (const char * n = ggml_backend_dev_name(dev); n != nullptr) { + name = n; + } + if (const char * match = std::getenv("TRANSCRIBE_TEST_DEV_INIT_THROW"); match != nullptr && match[0] != '\0') { + if (std::strcmp(match, "*") == 0 || std::strstr(name, match) != nullptr) { + throw std::runtime_error("TRANSCRIBE_TEST_DEV_INIT_THROW fault injection"); + } + } + return ggml_backend_dev_init(dev, nullptr); + } catch (const std::exception & e) { + log_msg(TRANSCRIBE_LOG_LEVEL_WARN, "%s: device \"%s\" init threw: %s - skipping it", error_tag, name, e.what()); + return nullptr; + } catch (...) { + log_msg(TRANSCRIBE_LOG_LEVEL_WARN, "%s: device \"%s\" init threw an unknown exception - skipping it", error_tag, + name); + return nullptr; + } +} + // Try to discover and initialize the first device whose classified // BackendKind matches `wanted`. On success returns the initialized // backend and writes the classified kind of the device that actually @@ -51,7 +80,7 @@ ggml_backend_t try_init_kind(BackendKind wanted, const char * error_tag, Backend continue; } - ggml_backend_t be = ggml_backend_dev_init(dev, nullptr); + ggml_backend_t be = dev_init_checked(dev, error_tag); if (be == nullptr) { continue; } @@ -77,7 +106,7 @@ void append_accel_backends(std::vector & out, const char * error if (ggml_backend_dev_type(dev) != GGML_BACKEND_DEVICE_TYPE_ACCEL) { continue; } - ggml_backend_t be = ggml_backend_dev_init(dev, nullptr); + ggml_backend_t be = dev_init_checked(dev, error_tag); if (be == nullptr) { continue; } @@ -161,7 +190,7 @@ transcribe_status init_backends_explicit_index(transcribe_backend_request reques return TRANSCRIBE_ERR_INVALID_ARG; } - ggml_backend_t gpu_be = ggml_backend_dev_init(dev, nullptr); + ggml_backend_t gpu_be = dev_init_checked(dev, error_tag); if (gpu_be == nullptr) { log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, "%s: failed to initialize gpu_device %d (%s)", error_tag, dev_index, ggml_backend_dev_name(dev)); @@ -457,7 +486,7 @@ transcribe_status promote_conv_pw_f16_to_f32_on_cpu(const BackendPlan & // Partial success: the ctx + buffer are already allocated but // unused. Free them so the caller's outparams stay nullptr, // matching the "do nothing" contract. - ggml_backend_buffer_free(buffer); + safe_buffer_free(buffer); ggml_free(ctx); return TRANSCRIBE_OK; } diff --git a/src/transcribe-loader.h b/src/transcribe-loader.h index 1f18eb78..44b4496c 100644 --- a/src/transcribe-loader.h +++ b/src/transcribe-loader.h @@ -9,6 +9,7 @@ #pragma once +#include "transcribe-model.h" // transcribe::MetaMap (must match the model's type) #include "transcribe.h" #include @@ -65,7 +66,7 @@ class Loader { // All scalar-string metadata KVs read on open(), keyed by GGUF key. // Copied onto the model after dispatch and surfaced publicly via // transcribe_model_meta_val_str(). Never affects dispatch. - const std::map & meta() const { return meta_; } + const MetaMap & meta() const { return meta_; } // Borrowed pointer to the underlying gguf_context. Valid until the // Loader is destroyed or release_gguf() is called. Returns nullptr @@ -78,11 +79,11 @@ class Loader { gguf_context * release_gguf(); private: - std::string path_; - std::string arch_; - std::string variant_; - std::map meta_; - gguf_context * gguf_ = nullptr; + std::string path_; + std::string arch_; + std::string variant_; + MetaMap meta_; + gguf_context * gguf_ = nullptr; }; } // namespace transcribe diff --git a/src/transcribe-model.h b/src/transcribe-model.h index 13837f7d..ec1ebe35 100644 --- a/src/transcribe-model.h +++ b/src/transcribe-model.h @@ -22,6 +22,10 @@ namespace transcribe { struct Arch; class Tokenizer; + +// Transparent comparator keeps transcribe_model_meta_val_str allocation-free. +// Loader and model must use the same map type for assignment. +using MetaMap = std::map>; } // namespace transcribe // Forward declaration for the resolved primary backend handle stored below. @@ -48,7 +52,7 @@ struct transcribe_model { // outlives that call, so no family handler has to populate this. Exposed // read-only via transcribe_model_meta_val_str(); this mirrors llama.cpp's // single generic metadata accessor rather than a typed accessor per field. - std::map meta; + transcribe::MetaMap meta; // Runtime backend currently bound to this model. Empty string means // "no backend bound" (both pre-binding and the model == NULL case diff --git a/src/transcribe.cpp b/src/transcribe.cpp index 1d861e66..c1593188 100644 --- a/src/transcribe.cpp +++ b/src/transcribe.cpp @@ -58,14 +58,66 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include +// Public C ABI entry points must not leak C++ exceptions. The guarded +// paths below map bad_alloc to OOM, other exceptions to BACKEND, and make +// teardown best-effort/no-throw. MSVC builds use /EHs so exceptions thrown +// by ggml C-ABI frames remain catchable here. + +namespace { + +template transcribe_status api_guard_status(const char * fn_name, Fn && body) { + try { + return body(); + } catch (const std::bad_alloc &) { + transcribe::log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, "%s: out of memory", fn_name); + return TRANSCRIBE_ERR_OOM; + } catch (const std::exception & e) { + transcribe::log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, "%s: caught backend exception: %s", fn_name, e.what()); + return TRANSCRIBE_ERR_BACKEND; + } catch (...) { + transcribe::log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, "%s: caught unknown backend exception", fn_name); + return TRANSCRIBE_ERR_BACKEND; + } +} + +// Value-returning variant: `on_throw` is the entry point's documented +// hard-failure value (0 devices, false, INT_MIN, ...). +template T api_guard_value(const char * fn_name, T on_throw, Fn && body) { + try { + return body(); + } catch (const std::exception & e) { + transcribe::log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, "%s: caught backend exception: %s", fn_name, e.what()); + return on_throw; + } catch (...) { + transcribe::log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, "%s: caught unknown backend exception", fn_name); + return on_throw; + } +} + +// Teardown variant: log and swallow. Frees must never fail. +template void api_guard_void(const char * fn_name, Fn && body) { + try { + body(); + } catch (const std::exception & e) { + transcribe::log_msg(TRANSCRIBE_LOG_LEVEL_WARN, "%s: caught backend exception during teardown: %s", fn_name, + e.what()); + } catch (...) { + transcribe::log_msg(TRANSCRIBE_LOG_LEVEL_WARN, "%s: caught unknown backend exception during teardown", fn_name); + } +} + +} // namespace + // Status // Parameter is `int` (not `transcribe_status`) so a caller passing an @@ -410,6 +462,19 @@ extern "C" void transcribe_log_set(transcribe_log_callback cb, void * userdata) ggml_log_set(&transcribe_ggml_log_bridge, nullptr); } +// Log callbacks have no error channel and may run from catch/noexcept paths. +// Treat a throwing callback as a host bug: drop the message, report to stderr. +static void transcribe_log_invoke(transcribe_log_callback cb, + transcribe_log_level level, + const char * msg, + void * userdata) noexcept { + try { + cb(level, msg, userdata); + } catch (...) { + std::fprintf(stderr, "transcribe: log callback threw; message dropped: %s\n", msg); + } +} + // Internal emission helper. Not part of the public ABI, not declared in // any header. Used by transcribe_print_timings and the advisory-warn // path; future logging from the loader / frontend / decode can call @@ -421,7 +486,7 @@ static void transcribe_log_emit(transcribe_log_level level, const char * msg) { return; } void * userdata = g_log_userdata.load(std::memory_order_relaxed); - cb(level, msg, userdata); + transcribe_log_invoke(cb, level, msg, userdata); } // Wrapper for messages we want surfaced even when the caller never @@ -439,7 +504,7 @@ static void transcribe_log_emit_or_stderr(transcribe_log_level level, const char if (cb == &transcribe_log_cb_disabled) { // explicit silence return; } - cb(level, msg, g_log_userdata.load(std::memory_order_relaxed)); + transcribe_log_invoke(cb, level, msg, g_log_userdata.load(std::memory_order_relaxed)); } namespace { @@ -793,7 +858,7 @@ static std::string path_for_c_api(const std::filesystem::path & path) { } #endif -extern "C" transcribe_status transcribe_init_backends(const char * artifact_dir) { +static transcribe_status transcribe_init_backends_impl(const char * artifact_dir) { ensure_ggml_time_init(); if (artifact_dir == nullptr || artifact_dir[0] == '\0') { return TRANSCRIBE_ERR_INVALID_ARG; @@ -858,7 +923,7 @@ extern "C" transcribe_status transcribe_init_backends(const char * artifact_dir) return TRANSCRIBE_OK; } -extern "C" transcribe_status transcribe_init_backends_default(void) { +static transcribe_status transcribe_init_backends_default_impl(void) { ensure_ggml_time_init(); #if !defined(TRANSCRIBE_GGML_BACKEND_DL) return TRANSCRIBE_OK; @@ -927,11 +992,11 @@ void fill_backend_device(ggml_backend_dev_t dev, uint64_t caller_size, struct tr } // namespace -extern "C" int transcribe_backend_device_count(void) { +static int transcribe_backend_device_count_impl(void) { return static_cast(ggml_backend_dev_count()); } -extern "C" transcribe_status transcribe_get_backend_device(int index, struct transcribe_backend_device * out) { +static transcribe_status transcribe_get_backend_device_impl(int index, struct transcribe_backend_device * out) { if (out == nullptr) { return TRANSCRIBE_ERR_INVALID_ARG; } @@ -946,7 +1011,7 @@ extern "C" transcribe_status transcribe_get_backend_device(int index, struct tra return TRANSCRIBE_OK; } -extern "C" bool transcribe_backend_available(transcribe_backend_request kind) { +static bool transcribe_backend_available_impl(transcribe_backend_request kind) { // Raw read before any enum-typed load (see enum_field_raw); unknown // values answer false per the documented probe contract. const int raw = enum_field_raw(&kind); @@ -1300,9 +1365,9 @@ void warn_unsupported_advisory(const struct transcribe_model * model, const stru // Lifecycle -extern "C" transcribe_status transcribe_model_load_file(const char * path, - const struct transcribe_model_load_params * params, - struct transcribe_model ** out_model) { +static transcribe_status transcribe_model_load_file_impl(const char * path, + const struct transcribe_model_load_params * params, + struct transcribe_model ** out_model) { // Set up ggml's timer before any family's load-timing ggml_time_us() runs // (Windows/MSVC ÷0 otherwise — see ensure_ggml_time_init). ensure_ggml_time_init(); @@ -1428,7 +1493,7 @@ extern "C" transcribe_status transcribe_model_load_file(const char * return st; } -extern "C" void transcribe_model_free(struct transcribe_model * model) { +static void transcribe_model_free_impl(struct transcribe_model * model) { // Polymorphic delete: the base has a virtual destructor (anchored // in transcribe-model.cpp), so this dispatches to the per-family // subclass destructor and that destructor frees gguf_context, @@ -1436,9 +1501,9 @@ extern "C" void transcribe_model_free(struct transcribe_model * model) { delete model; } -extern "C" transcribe_status transcribe_session_init(struct transcribe_model * model, - const struct transcribe_session_params * params, - struct transcribe_session ** out_session) { +static transcribe_status transcribe_session_init_impl(struct transcribe_model * model, + const struct transcribe_session_params * params, + struct transcribe_session ** out_session) { if (out_session == nullptr) { return TRANSCRIBE_ERR_INVALID_ARG; } @@ -1493,7 +1558,7 @@ extern "C" transcribe_status transcribe_session_init(struct transcribe_model * return model->arch->init_context(model, params, out_session); } -extern "C" void transcribe_session_free(struct transcribe_session * session) { +static void transcribe_session_free_impl(struct transcribe_session * session) { // Free the session and, if it owns its model (transcribe_open path), // free the model too. The owns_model flag is set only by // transcribe_open; sessions created via the two-step @@ -1516,10 +1581,10 @@ extern "C" void transcribe_session_free(struct transcribe_session * session) { // transcribe_session_free; both honor owns_model and free the owned model // after the session. -extern "C" transcribe_status transcribe_open(const char * path, - const struct transcribe_model_load_params * load_params, - const struct transcribe_session_params * session_params, - struct transcribe_session ** out_session) { +static transcribe_status transcribe_open_impl(const char * path, + const struct transcribe_model_load_params * load_params, + const struct transcribe_session_params * session_params, + struct transcribe_session ** out_session) { if (out_session == nullptr) { return TRANSCRIBE_ERR_INVALID_ARG; } @@ -1595,9 +1660,9 @@ extern "C" bool transcribe_was_truncated(const struct transcribe_session * sessi // last_status, and the public committed/tentative text view. clear_result() // wipes the snapshot but never touches stream_state. -extern "C" transcribe_status transcribe_stream_begin(struct transcribe_session * session, - const struct transcribe_run_params * run_params, - const struct transcribe_stream_params * stream_params) { +static transcribe_status transcribe_stream_begin_impl(struct transcribe_session * session, + const struct transcribe_run_params * run_params, + const struct transcribe_stream_params * stream_params) { if (session == nullptr) { return TRANSCRIBE_ERR_INVALID_ARG; } @@ -1767,10 +1832,10 @@ static transcribe_status prepare_stream_update(struct transcribe_stream_update * return TRANSCRIBE_OK; } -extern "C" transcribe_status transcribe_stream_feed(struct transcribe_session * session, - const float * pcm, - int n_samples, - struct transcribe_stream_update * update) { +static transcribe_status transcribe_stream_feed_impl(struct transcribe_session * session, + const float * pcm, + int n_samples, + struct transcribe_stream_update * update) { if (const auto st = prepare_stream_update(update); st != TRANSCRIBE_OK) { return st; } @@ -1816,8 +1881,8 @@ extern "C" transcribe_status transcribe_stream_feed(struct transcribe_session * return st; } -extern "C" transcribe_status transcribe_stream_finalize(struct transcribe_session * session, - struct transcribe_stream_update * update) { +static transcribe_status transcribe_stream_finalize_impl(struct transcribe_session * session, + struct transcribe_stream_update * update) { if (const auto st = prepare_stream_update(update); st != TRANSCRIBE_OK) { return st; } @@ -1854,7 +1919,7 @@ extern "C" transcribe_status transcribe_stream_finalize(struct transcribe_sessio return TRANSCRIBE_OK; } -extern "C" void transcribe_stream_reset(struct transcribe_session * session) { +static void transcribe_stream_reset_impl(struct transcribe_session * session) { if (session == nullptr) { return; } @@ -2086,10 +2151,10 @@ static transcribe_status run_one_inner(struct transcribe_session * sess return session->model->arch->run(session, pcm, n_samples, params); } -extern "C" transcribe_status transcribe_run(struct transcribe_session * session, - const float * pcm, - int n_samples, - const struct transcribe_run_params * params) { +static transcribe_status transcribe_run_impl(struct transcribe_session * session, + const float * pcm, + int n_samples, + const struct transcribe_run_params * params) { // A well-formed single run has the same result view as a one-item batch: // reset batch_results so the transcribe_batch_* accessors fall back to // reading the scratch slot as utterance 0. @@ -2155,11 +2220,11 @@ void restore_scratch_from_result(transcribe_session * s, const transcribe_sessio } // namespace -extern "C" transcribe_status transcribe_run_batch(struct transcribe_session * session, - const float * const * pcm, - const int * n_samples, - int n, - const struct transcribe_run_params * params) { +static transcribe_status transcribe_run_batch_impl(struct transcribe_session * session, + const float * const * pcm, + const int * n_samples, + int n, + const struct transcribe_run_params * params) { // Top-level argument shape. The arrays themselves must be present and // n strictly positive; an individual malformed utterance (pcm[i] NULL // or n_samples[i] <= 0) is a per-utterance failure handled in the loop, @@ -2398,10 +2463,10 @@ extern "C" const char * transcribe_model_meta_val_str(const struct transcribe_mo return it != model->meta.end() ? it->second.c_str() : ""; } -extern "C" int transcribe_tokenize(const struct transcribe_model * model, - const char * text, - int32_t * tokens, - size_t n_max) { +static int transcribe_tokenize_impl(const struct transcribe_model * model, + const char * text, + int32_t * tokens, + size_t n_max) { if (model == nullptr || text == nullptr) { return INT_MIN; } @@ -2447,8 +2512,8 @@ extern "C" const char * transcribe_model_backend(const struct transcribe_model * return model->backend.c_str(); } -extern "C" transcribe_status transcribe_model_get_device(const struct transcribe_model * model, - struct transcribe_backend_device * out) { +static transcribe_status transcribe_model_get_device_impl(const struct transcribe_model * model, + struct transcribe_backend_device * out) { if (model == nullptr || out == nullptr) { return TRANSCRIBE_ERR_INVALID_ARG; } @@ -2926,3 +2991,135 @@ extern "C" transcribe_status transcribe_batch_get_timings(const struct transcrib copy_out_prefix(out, &staged, caller_size, sizeof(staged)); return TRANSCRIBE_OK; } + +// C ABI forwarders. Entry points that allocate, reach ggml/driver state, +// or transfer ownership route through api_guard_* here. Other public symbols +// above are kept nothrow by construction: pure POD/c_str reads, prefix +// copies, plain stores, or contained callback emission. + +extern "C" transcribe_status transcribe_init_backends(const char * artifact_dir) { + return api_guard_status("transcribe_init_backends", [&] { return transcribe_init_backends_impl(artifact_dir); }); +} + +extern "C" transcribe_status transcribe_init_backends_default(void) { + return api_guard_status("transcribe_init_backends_default", + [&] { return transcribe_init_backends_default_impl(); }); +} + +extern "C" int transcribe_backend_device_count(void) { + return api_guard_value("transcribe_backend_device_count", 0, + [&] { return transcribe_backend_device_count_impl(); }); +} + +extern "C" transcribe_status transcribe_get_backend_device(int index, struct transcribe_backend_device * out) { + return api_guard_status("transcribe_get_backend_device", + [&] { return transcribe_get_backend_device_impl(index, out); }); +} + +extern "C" bool transcribe_backend_available(transcribe_backend_request kind) { + return api_guard_value("transcribe_backend_available", false, + [&] { return transcribe_backend_available_impl(kind); }); +} + +extern "C" transcribe_status transcribe_model_load_file(const char * path, + const struct transcribe_model_load_params * params, + struct transcribe_model ** out_model) { + const transcribe_status st = api_guard_status( + "transcribe_model_load_file", [&] { return transcribe_model_load_file_impl(path, params, out_model); }); + // Boundary-owned postcondition: failure => *out_model == NULL. + if (st != TRANSCRIBE_OK && out_model != nullptr && *out_model != nullptr) { + transcribe_model_free(*out_model); + *out_model = nullptr; + } + return st; +} + +extern "C" void transcribe_model_free(struct transcribe_model * model) { + api_guard_void("transcribe_model_free", [&] { transcribe_model_free_impl(model); }); +} + +extern "C" transcribe_status transcribe_session_init(struct transcribe_model * model, + const struct transcribe_session_params * params, + struct transcribe_session ** out_session) { + const transcribe_status st = api_guard_status( + "transcribe_session_init", [&] { return transcribe_session_init_impl(model, params, out_session); }); + // Boundary-owned postcondition: failure => *out_session == NULL. + if (st != TRANSCRIBE_OK && out_session != nullptr && *out_session != nullptr) { + transcribe_session_free(*out_session); + *out_session = nullptr; + } + return st; +} + +extern "C" void transcribe_session_free(struct transcribe_session * session) { + api_guard_void("transcribe_session_free", [&] { transcribe_session_free_impl(session); }); +} + +extern "C" transcribe_status transcribe_open(const char * path, + const struct transcribe_model_load_params * load_params, + const struct transcribe_session_params * session_params, + struct transcribe_session ** out_session) { + const transcribe_status st = api_guard_status( + "transcribe_open", [&] { return transcribe_open_impl(path, load_params, session_params, out_session); }); + // Boundary-owned postcondition: failure => *out_session == NULL. + if (st != TRANSCRIBE_OK && out_session != nullptr && *out_session != nullptr) { + transcribe_session_free(*out_session); + *out_session = nullptr; + } + return st; +} + +extern "C" transcribe_status transcribe_run(struct transcribe_session * session, + const float * pcm, + int n_samples, + const struct transcribe_run_params * params) { + return api_guard_status("transcribe_run", [&] { return transcribe_run_impl(session, pcm, n_samples, params); }); +} + +extern "C" transcribe_status transcribe_run_batch(struct transcribe_session * session, + const float * const * pcm, + const int * n_samples, + int n, + const struct transcribe_run_params * params) { + return api_guard_status("transcribe_run_batch", + [&] { return transcribe_run_batch_impl(session, pcm, n_samples, n, params); }); +} + +extern "C" transcribe_status transcribe_stream_begin(struct transcribe_session * session, + const struct transcribe_run_params * run_params, + const struct transcribe_stream_params * stream_params) { + return api_guard_status("transcribe_stream_begin", + [&] { return transcribe_stream_begin_impl(session, run_params, stream_params); }); +} + +extern "C" transcribe_status transcribe_stream_feed(struct transcribe_session * session, + const float * pcm, + int n_samples, + struct transcribe_stream_update * update) { + return api_guard_status("transcribe_stream_feed", + [&] { return transcribe_stream_feed_impl(session, pcm, n_samples, update); }); +} + +extern "C" transcribe_status transcribe_stream_finalize(struct transcribe_session * session, + struct transcribe_stream_update * update) { + return api_guard_status("transcribe_stream_finalize", + [&] { return transcribe_stream_finalize_impl(session, update); }); +} + +extern "C" void transcribe_stream_reset(struct transcribe_session * session) { + api_guard_void("transcribe_stream_reset", [&] { transcribe_stream_reset_impl(session); }); +} + +extern "C" transcribe_status transcribe_model_get_device(const struct transcribe_model * model, + struct transcribe_backend_device * out) { + return api_guard_status("transcribe_model_get_device", + [&] { return transcribe_model_get_device_impl(model, out); }); +} + +extern "C" int transcribe_tokenize(const struct transcribe_model * model, + const char * text, + int32_t * tokens, + size_t n_max) { + return api_guard_value("transcribe_tokenize", INT_MIN, + [&] { return transcribe_tokenize_impl(model, text, tokens, n_max); }); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index abbabde3..ce23f4ef 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -248,6 +248,50 @@ transcribe_apply_warnings(transcribe_thread_default_unit) add_test(NAME transcribe_thread_default_unit COMMAND transcribe_thread_default_unit) +# ----------------------------------------------------------------------------- +# Device-init exception containment +# ----------------------------------------------------------------------------- +# +# Pins throw -> skip -> CPU-fallback behavior in backend probing. + +add_executable(transcribe_backend_init_throw_unit + backend_init_throw_unit.cpp) + +target_link_libraries(transcribe_backend_init_throw_unit PRIVATE transcribe ggml) + +target_include_directories(transcribe_backend_init_throw_unit PRIVATE + ${CMAKE_SOURCE_DIR}/src) + +transcribe_apply_warnings(transcribe_backend_init_throw_unit) + +add_test(NAME transcribe_backend_init_throw_unit + COMMAND transcribe_backend_init_throw_unit) + +# ----------------------------------------------------------------------------- +# No-throw teardown wrappers (safe_backend_free & co.) +# ----------------------------------------------------------------------------- +# +# The unit test proves safe_* contains injected throws under noexcept callers. +# The lint keeps raw ggml backend teardown calls out of src/**. + +add_executable(transcribe_teardown_safety_unit + teardown_safety_unit.cpp) + +target_link_libraries(transcribe_teardown_safety_unit PRIVATE transcribe ggml) + +target_include_directories(transcribe_teardown_safety_unit PRIVATE + ${CMAKE_SOURCE_DIR}/src) + +transcribe_apply_warnings(transcribe_teardown_safety_unit) + +add_test(NAME transcribe_teardown_safety_unit + COMMAND transcribe_teardown_safety_unit) + +add_test(NAME transcribe_lint_teardown + COMMAND ${CMAKE_COMMAND} + -DSRC_DIR=${CMAKE_SOURCE_DIR}/src + -P ${CMAKE_CURRENT_SOURCE_DIR}/lint_teardown.cmake) + # ----------------------------------------------------------------------------- # Threadpool barrier under CPU oversubscription (regression for the MSVC # non-OpenMP ggml_barrier livelock; see ggml-cpu.c ggml_thread_yield) diff --git a/tests/backend_init_throw_unit.cpp b/tests/backend_init_throw_unit.cpp new file mode 100644 index 00000000..32902469 --- /dev/null +++ b/tests/backend_init_throw_unit.cpp @@ -0,0 +1,184 @@ +// backend_init_throw_unit.cpp - device-init exception containment. +// +// AUTO -> the probe walks past the throwing GPU/ACCEL devices +// and lands on CPU. +// METAL/VULKAN/ -> a specific-GPU request whose every candidate throws +// CUDA returns TRANSCRIBE_ERR_BACKEND. +// CPU / CPU_ACCEL -> CPU primary still succeeds. + +#include "ggml-backend.h" +#include "transcribe-backend.h" +#include "transcribe-load-common.h" +#include "transcribe.h" + +#include +#include + +namespace { + +int g_failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++g_failures; \ + } \ + } while (0) + +void set_env(const char * key, const char * value) { +#if defined(_WIN32) + _putenv_s(key, value); +#else + setenv(key, value, 1); +#endif +} + +void unset_env(const char * key) { +#if defined(_WIN32) + _putenv_s(key, ""); // MSVC CRT: empty value removes the variable +#else + unsetenv(key); +#endif +} + +// Free every backend the plan created. scheduler_list owns each backend +// exactly once (primary included); reverse order mirrors the model +// destructor's cleanup convention. +void free_plan(transcribe::BackendPlan & plan) { + for (auto it = plan.scheduler_list.rbegin(); it != plan.scheduler_list.rend(); ++it) { + ggml_backend_free(*it); + } + plan = transcribe::BackendPlan{}; +} + +void test_baseline_no_hook() { + unset_env("TRANSCRIBE_TEST_DEV_INIT_THROW"); + transcribe::BackendPlan plan; + const transcribe_status st = transcribe::load_common::init_backends(TRANSCRIBE_BACKEND_AUTO, 0, "test", plan); + CHECK(st == TRANSCRIBE_OK); + CHECK(plan.primary != nullptr); + CHECK(!plan.scheduler_list.empty()); + free_plan(plan); +} + +void test_nonmatching_hook_is_inert() { + set_env("TRANSCRIBE_TEST_DEV_INIT_THROW", "no-such-device-name-xyzzy"); + transcribe::BackendPlan plan; + const transcribe_status st = transcribe::load_common::init_backends(TRANSCRIBE_BACKEND_AUTO, 0, "test", plan); + CHECK(st == TRANSCRIBE_OK); + CHECK(plan.primary != nullptr); + free_plan(plan); + unset_env("TRANSCRIBE_TEST_DEV_INIT_THROW"); +} + +#if !defined(_WIN32) +void test_empty_hook_value_is_inert() { + // POSIX-only: Windows deletes the variable when setting an empty value. + unset_env("TRANSCRIBE_TEST_DEV_INIT_THROW"); + transcribe::BackendPlan baseline; + CHECK(transcribe::load_common::init_backends(TRANSCRIBE_BACKEND_AUTO, 0, "test", baseline) == TRANSCRIBE_OK); + + set_env("TRANSCRIBE_TEST_DEV_INIT_THROW", ""); + transcribe::BackendPlan plan; + const transcribe_status st = transcribe::load_common::init_backends(TRANSCRIBE_BACKEND_AUTO, 0, "test", plan); + CHECK(st == TRANSCRIBE_OK); + CHECK(plan.primary != nullptr); + CHECK(plan.primary_kind == baseline.primary_kind); + unset_env("TRANSCRIBE_TEST_DEV_INIT_THROW"); + + free_plan(plan); + free_plan(baseline); +} +#endif + +void test_auto_falls_back_to_cpu_when_every_device_throws() { + set_env("TRANSCRIBE_TEST_DEV_INIT_THROW", "*"); + transcribe::BackendPlan plan; + const transcribe_status st = transcribe::load_common::init_backends(TRANSCRIBE_BACKEND_AUTO, 0, "test", plan); + CHECK(st == TRANSCRIBE_OK); + CHECK(plan.primary != nullptr); + CHECK(plan.primary_kind == transcribe::BackendKind::Cpu); + free_plan(plan); + unset_env("TRANSCRIBE_TEST_DEV_INIT_THROW"); +} + +void test_specific_gpu_request_fails_cleanly_when_every_device_throws() { + set_env("TRANSCRIBE_TEST_DEV_INIT_THROW", "*"); + const transcribe_backend_request kinds[] = { + TRANSCRIBE_BACKEND_METAL, + TRANSCRIBE_BACKEND_VULKAN, + TRANSCRIBE_BACKEND_CUDA, + }; + for (const auto kind : kinds) { + transcribe::BackendPlan plan; + const transcribe_status st = transcribe::load_common::init_backends(kind, 0, "test", plan); + CHECK(st == TRANSCRIBE_ERR_BACKEND); + CHECK(plan.primary == nullptr); + } + unset_env("TRANSCRIBE_TEST_DEV_INIT_THROW"); +} + +void test_cpu_request_unaffected_by_hook() { + set_env("TRANSCRIBE_TEST_DEV_INIT_THROW", "*"); + for (const auto kind : { TRANSCRIBE_BACKEND_CPU, TRANSCRIBE_BACKEND_CPU_ACCEL }) { + transcribe::BackendPlan plan; + const transcribe_status st = transcribe::load_common::init_backends(kind, 0, "test", plan); + CHECK(st == TRANSCRIBE_OK); + CHECK(plan.primary_kind == transcribe::BackendKind::Cpu); + free_plan(plan); + } + unset_env("TRANSCRIBE_TEST_DEV_INIT_THROW"); +} + +void test_explicit_gpu_device_fails_cleanly_when_it_throws() { + // Only meaningful when a GPU/IGPU device sits at index > 0. + int gpu_index = -1; + const int n = transcribe_backend_device_count(); + for (int i = 1; i < n; ++i) { + struct transcribe_backend_device dev; + transcribe_backend_device_init(&dev); + if (transcribe_get_backend_device(i, &dev) != TRANSCRIBE_OK) { + continue; + } + if (dev.device_type == TRANSCRIBE_DEVICE_TYPE_GPU || dev.device_type == TRANSCRIBE_DEVICE_TYPE_IGPU) { + gpu_index = i; + break; + } + } + if (gpu_index < 0) { + return; + } + set_env("TRANSCRIBE_TEST_DEV_INIT_THROW", "*"); + transcribe::BackendPlan plan; + const transcribe_status st = + transcribe::load_common::init_backends(TRANSCRIBE_BACKEND_AUTO, gpu_index, "test", plan); + CHECK(st == TRANSCRIBE_ERR_BACKEND); + CHECK(plan.primary == nullptr); + unset_env("TRANSCRIBE_TEST_DEV_INIT_THROW"); +} + +} // namespace + +int main() { + // No-op in static builds; loads backend modules in a dynamic-backends + // build so the device registry is populated either way. + transcribe_init_backends_default(); + + test_baseline_no_hook(); + test_nonmatching_hook_is_inert(); +#if !defined(_WIN32) + test_empty_hook_value_is_inert(); +#endif + test_auto_falls_back_to_cpu_when_every_device_throws(); + test_specific_gpu_request_fails_cleanly_when_every_device_throws(); + test_cpu_request_unaffected_by_hook(); + test_explicit_gpu_device_fails_cleanly_when_it_throws(); + + if (g_failures != 0) { + std::fprintf(stderr, "%d check(s) failed\n", g_failures); + return 1; + } + std::printf("ok\n"); + return 0; +} diff --git a/tests/lint_teardown.cmake b/tests/lint_teardown.cmake new file mode 100644 index 00000000..28e279cc --- /dev/null +++ b/tests/lint_teardown.cmake @@ -0,0 +1,34 @@ +# lint_teardown.cmake - forbid raw ggml teardown calls in library code. +# +# Run as: cmake -DSRC_DIR=/src -P lint_teardown.cmake +# +# Keep library code on the no-throw wrappers. Raw ggml backend teardown calls +# are allowed only inside transcribe-backend.{h,cpp}. + +if(NOT DEFINED SRC_DIR) + message(FATAL_ERROR "pass -DSRC_DIR=/src") +endif() + +file(GLOB_RECURSE lint_files "${SRC_DIR}/*.cpp" "${SRC_DIR}/*.h") + +set(violations "") +foreach(f IN LISTS lint_files) + if(f MATCHES "transcribe-backend\\.(cpp|h)$") + continue() + endif() + file(READ "${f}" content) + foreach(fn ggml_backend_free ggml_backend_buffer_free ggml_backend_sched_free) + if(content MATCHES "${fn}[ \t]*\\(") + string(APPEND violations " ${f}: ${fn}(\n") + endif() + endforeach() +endforeach() + +if(violations) + message(FATAL_ERROR + "raw ggml teardown calls in library code — use the no-throw " + "wrappers transcribe::safe_backend_free / safe_buffer_free / " + "safe_sched_free from transcribe-backend.h instead:\n${violations}") +endif() + +message(STATUS "lint_teardown: no raw ggml teardown calls in ${SRC_DIR}") diff --git a/tests/log_unit.cpp b/tests/log_unit.cpp index 22ea4070..f018f8fc 100644 --- a/tests/log_unit.cpp +++ b/tests/log_unit.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -59,6 +60,29 @@ void test_callback_receives_log_msg() { CHECK(rec[0].second == "answer=42"); } +void throwing_cb(transcribe_log_level, const char *, void * userdata) { + // Count the invocation before throwing so the test can tell "contained" + // from "never called". + ++(*static_cast(userdata)); + throw std::runtime_error("host log callback bug"); +} + +void test_throwing_callback_is_contained() { + // If containment is broken, the throw escapes log_msg and the test dies. + int calls = 0; + transcribe_log_set(throwing_cb, &calls); + transcribe::log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, "emitted into a throwing callback"); + CHECK(calls == 1); + + // The sink must remain functional afterwards for a well-behaved + // callback. + Record rec; + transcribe_log_set(recording_cb, &rec); + transcribe::log_msg(TRANSCRIBE_LOG_LEVEL_INFO, "recovered"); + CHECK(rec.size() == 1); + CHECK(rec[0].second == "recovered"); +} + void test_null_disables_then_reinstall_restores() { Record rec; transcribe_log_set(recording_cb, &rec); @@ -164,6 +188,7 @@ void test_ggml_bridge_honors_disable() { int main() { test_callback_receives_log_msg(); + test_throwing_callback_is_contained(); test_null_disables_then_reinstall_restores(); test_ggml_bridge_installed_and_maps_levels(); test_init_backends_emits_device_summary(); diff --git a/tests/teardown_safety_unit.cpp b/tests/teardown_safety_unit.cpp new file mode 100644 index 00000000..a077ca62 --- /dev/null +++ b/tests/teardown_safety_unit.cpp @@ -0,0 +1,118 @@ +// teardown_safety_unit.cpp - safe_* teardown wrappers under noexcept callers. + +#include "ggml-backend.h" +#include "transcribe-backend.h" +#include "transcribe.h" + +#include +#include +#include +#include +#include + +namespace { + +int g_failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++g_failures; \ + } \ + } while (0) + +std::vector g_log; + +void capture_cb(transcribe_log_level, const char * msg, void *) { + g_log.push_back(msg != nullptr ? msg : ""); +} + +bool log_contains(const char * needle) { + for (const auto & m : g_log) { + if (m.find(needle) != std::string::npos) { + return true; + } + } + return false; +} + +void set_env(const char * key, const char * value) { +#if defined(_WIN32) + _putenv_s(key, value); +#else + setenv(key, value, 1); +#endif +} + +void unset_env(const char * key) { +#if defined(_WIN32) + _putenv_s(key, ""); // MSVC CRT: empty value removes the variable +#else + unsetenv(key); +#endif +} + +void test_wrappers_contain_injected_throw() { + ggml_backend_t be = ggml_backend_init_by_type(GGML_BACKEND_DEVICE_TYPE_CPU, nullptr); + CHECK(be != nullptr); + ggml_backend_buffer_t buf = nullptr; + if (be != nullptr) { + buf = ggml_backend_alloc_buffer(be, 256); + CHECK(buf != nullptr); + } + + g_log.clear(); + set_env("TRANSCRIBE_TEST_TEARDOWN_THROW", "1"); + // If a wrapper leaks the injected throw, this noexcept call terminates. + [&]() noexcept { transcribe::safe_buffer_free(buf); }(); + [&]() noexcept { transcribe::safe_backend_free(be); }(); + unset_env("TRANSCRIBE_TEST_TEARDOWN_THROW"); + // The warning proves the hook fired and was contained. + CHECK(log_contains("fault injection")); +} + +void test_null_is_noop_even_with_hook() { + g_log.clear(); + set_env("TRANSCRIBE_TEST_TEARDOWN_THROW", "1"); + [&]() noexcept { + transcribe::safe_backend_free(nullptr); + transcribe::safe_buffer_free(nullptr); + transcribe::safe_sched_free(nullptr); + }(); + unset_env("TRANSCRIBE_TEST_TEARDOWN_THROW"); + CHECK(!log_contains("fault injection")); +} + +#if !defined(_WIN32) +void test_empty_hook_value_is_inert() { + // POSIX-only: Windows cannot represent an empty-valued environment variable. + ggml_backend_t be = ggml_backend_init_by_type(GGML_BACKEND_DEVICE_TYPE_CPU, nullptr); + CHECK(be != nullptr); + g_log.clear(); + set_env("TRANSCRIBE_TEST_TEARDOWN_THROW", ""); + [&]() noexcept { transcribe::safe_backend_free(be); }(); + unset_env("TRANSCRIBE_TEST_TEARDOWN_THROW"); + CHECK(!log_contains("fault injection")); +} +#endif + +} // namespace + +int main() { + transcribe_init_backends_default(); + transcribe_log_set(&capture_cb, nullptr); + + test_wrappers_contain_injected_throw(); + test_null_is_noop_even_with_hook(); +#if !defined(_WIN32) + test_empty_hook_value_is_inert(); +#endif + + if (g_failures != 0) { + std::fprintf(stderr, "%d check(s) failed\n", g_failures); + return 1; + } + std::printf("ok\n"); + return 0; +} From 2ee4ae1e21d489a078c82471048c76cccad1c355 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Fri, 3 Jul 2026 07:19:58 +0800 Subject: [PATCH 2/4] document env vars --- docs/environment-variables.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index c1f21d78..40d00f27 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -15,6 +15,13 @@ copies and writes raw activations to disk, so it carries I/O cost and a data-leak surface. Leave it unset in production unless you are deliberately collecting dumps. +The two `TRANSCRIBE_TEST_*_THROW` fault-injection hooks also ship in release +binaries on purpose: wheel clean-install CI proves exception containment +against the exact artifact users install (see `AGENTS.md`, "C ABI Exception +Discipline"). Unlike the boolean convention above, **any** non-empty value +arms them (including `0`); unset or empty is inert. Leave them unset outside +of tests. + | Variable | Effect | | --- | --- | | `TRANSCRIBE_NO_FLASH` | Disable flash attention on encoder and decoder (forces the manual F32 path). | @@ -24,6 +31,8 @@ collecting dumps. | `TRANSCRIBE_DUMP_DIR=` | Enable the per-stage tensor dumper; writes `.f32` + `.json` per dumped tensor into ``. The basis for the numerical-comparison harness (`scripts/compare_tensors.py`). | | `TRANSCRIBE_PERF_DEBUG` | Print a per-stage timing breakdown to stderr (DEBUG log) on the families that profile (`cohere`, `granite`, `canary`, `canary_qwen`, `moonshine`, `moonshine_streaming`, `qwen3_asr`, `whisper`). For whisper, a value containing `cpu` or `all` additionally prints the CPU sub-section breakdown. | | `TRANSCRIBE_VOXTRAL_REALTIME_STREAM_TIMING` | Print a per-component streaming wall-time breakdown at stream finalize (voxtral_realtime). | +| `TRANSCRIBE_TEST_DEV_INIT_THROW=` | Fault injection: backend device init (`ggml_backend_dev_init`) throws for devices whose name contains `` (`*` matches every device). Exercises throw → skip → CPU-fallback in backend probing; an explicit backend request fails with `TRANSCRIBE_ERR_BACKEND`. Used by `backend_init_throw_unit` and `scripts/ci/vulkan_degradation_check.py`. | +| `TRANSCRIBE_TEST_TEARDOWN_THROW` | Fault injection: any non-empty value injects a throw after each real free inside the `transcribe::safe_*` teardown wrappers, proving containment without leaking the handle. Used by `teardown_safety_unit`. | ## Tier 2 — validation hooks From 54053bed1de38784321ff0657e8052ca6c8a5731 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Fri, 3 Jul 2026 07:26:15 +0800 Subject: [PATCH 3/4] clang format --- tests/teardown_safety_unit.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/teardown_safety_unit.cpp b/tests/teardown_safety_unit.cpp index a077ca62..7f937e9c 100644 --- a/tests/teardown_safety_unit.cpp +++ b/tests/teardown_safety_unit.cpp @@ -65,8 +65,12 @@ void test_wrappers_contain_injected_throw() { g_log.clear(); set_env("TRANSCRIBE_TEST_TEARDOWN_THROW", "1"); // If a wrapper leaks the injected throw, this noexcept call terminates. - [&]() noexcept { transcribe::safe_buffer_free(buf); }(); - [&]() noexcept { transcribe::safe_backend_free(be); }(); + [&]() noexcept { + transcribe::safe_buffer_free(buf); + }(); + [&]() noexcept { + transcribe::safe_backend_free(be); + }(); unset_env("TRANSCRIBE_TEST_TEARDOWN_THROW"); // The warning proves the hook fired and was contained. CHECK(log_contains("fault injection")); @@ -91,7 +95,9 @@ void test_empty_hook_value_is_inert() { CHECK(be != nullptr); g_log.clear(); set_env("TRANSCRIBE_TEST_TEARDOWN_THROW", ""); - [&]() noexcept { transcribe::safe_backend_free(be); }(); + [&]() noexcept { + transcribe::safe_backend_free(be); + }(); unset_env("TRANSCRIBE_TEST_TEARDOWN_THROW"); CHECK(!log_contains("fault injection")); } From e9c54bf4842caa535fbd13e08f00e2dbb152a584 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Fri, 3 Jul 2026 07:31:20 +0800 Subject: [PATCH 4/4] fix tests --- scripts/ci/vulkan_degradation_check.py | 4 +++- src/transcribe.cpp | 15 +++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/ci/vulkan_degradation_check.py b/scripts/ci/vulkan_degradation_check.py index e3b8c7c3..9df69a13 100644 --- a/scripts/ci/vulkan_degradation_check.py +++ b/scripts/ci/vulkan_degradation_check.py @@ -113,7 +113,9 @@ def main() -> int: raise AssertionError( "backend='vulkan' must fail when device init throws") with t.Model(args.model) as m: - assert m.backend == "cpu", m.backend + # m.backend is the ggml backend name ("CPU", "Vulkan0"), + # not a lowercase kind string. + assert m.backend.lower() == "cpu", m.backend with m.session() as s: text = s.run(load_pcm(args.audio)).text print("text (throwing devices, default backend):", diff --git a/src/transcribe.cpp b/src/transcribe.cpp index c1593188..9b203c03 100644 --- a/src/transcribe.cpp +++ b/src/transcribe.cpp @@ -1011,11 +1011,11 @@ static transcribe_status transcribe_get_backend_device_impl(int index, struct tr return TRANSCRIBE_OK; } -static bool transcribe_backend_available_impl(transcribe_backend_request kind) { - // Raw read before any enum-typed load (see enum_field_raw); unknown - // values answer false per the documented probe contract. - const int raw = enum_field_raw(&kind); - const size_t n = ggml_backend_dev_count(); +// Takes the request pre-read as a raw int (see enum_field_raw in the +// forwarder) so no enum-typed load of an unknown probe value ever happens; +// unknown values answer false per the documented probe contract. +static bool transcribe_backend_available_impl(int raw) { + const size_t n = ggml_backend_dev_count(); if (raw == TRANSCRIBE_BACKEND_AUTO) { return n > 0; } @@ -3017,8 +3017,11 @@ extern "C" transcribe_status transcribe_get_backend_device(int index, struct tra } extern "C" bool transcribe_backend_available(transcribe_backend_request kind) { + // Raw read before any enum-typed load (see enum_field_raw): the lambda + // must not read `kind` as an enum, or an unknown probe value is UB. + const int raw = enum_field_raw(&kind); return api_guard_value("transcribe_backend_available", false, - [&] { return transcribe_backend_available_impl(kind); }); + [&] { return transcribe_backend_available_impl(raw); }); } extern "C" transcribe_status transcribe_model_load_file(const char * path,