Skip to content

Segfault in qt_init() when a log callback (set_log_callback/qt_log_set) is registered across more than one QwenTTS context #17

Description

@authman

Summary

Installing a log callback via QwenLibrary.set_log_callback() (i.e. qt_log_set) and then constructing a second QwenTTS context in the same process reliably segfaults inside qt_init(), at the point where the native code invokes the registered callback. This reproduces with a single reused callback object kept alive for the whole process (not a GC/dangling-pointer issue on the Python side) and with a fresh callback per instance alike.

Without a log callback installed, repeated construction/close cycles are completely stable (control test below, 6/6 iterations clean).

Environment

  • qwentts-cpp-python 0.3.1 (also reproduces via realtimetts-qwen-native 0.2.0 / RealtimeTTS 0.8.5's QwenEngine, which triggers the same crash through QwenEngine's private _backend.library.set_log_callback)
  • Python 3.12.14
  • numpy 2.5.2
  • Ubuntu 26.04 LTS, Linux 7.0.0-29-generic, x86_64
  • 2x NVIDIA GeForce RTX 3090, driver 580.173.02
  • Model: Qwen/Qwen3-TTS-12Hz-0.6B-Base, quant Q8_0

Minimal repro

Only the public qwentts_cpp API - no other project code involved. Reference audio is a synthetic sine wave, not a real recording.

"""Minimal repro: installing a qt_log_set log callback, then constructing
a second QwenTTS context in the same process, segfaults - regardless of
whether the callback object is freshly created per instance or a single
object reused/kept alive for the whole process (both variants crash below).
"""

import gc

import numpy as np
from qwentts_cpp import QwenTTS

MODEL_ID = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
QUANT = "Q8_0"

# Any 24kHz mono float32 array works to exercise extract_voice_ref - it
# doesn't need to be real speech to trigger the crash.
SR = 24_000
_t = np.linspace(0, 2.0, SR * 2, endpoint=False, dtype=np.float32)
FAKE_REF_AUDIO = (0.2 * np.sin(2 * np.pi * 220.0 * _t)).astype(np.float32)


def _log_callback(level: int, message: str) -> None:
    pass  # a no-op callback triggers the same crash as one that prints


def make_and_use_engine(install_log_callback: bool) -> None:
    engine = QwenTTS.from_pretrained(MODEL_ID, quant=QUANT)
    if install_log_callback:
        engine.library.set_log_callback(_log_callback)
    voice_ref = engine.extract_voice_ref(FAKE_REF_AUDIO)
    samples, sample_rate = engine.synthesize(
        text="This is a short test sentence for reproduction purposes.",
        ref_spk_emb=voice_ref.ref_spk_emb,
    )
    print(f"got {len(samples)} samples at {sample_rate} Hz")
    engine.close()


for i in range(6):
    print(f"=== iteration {i} ===", flush=True)
    make_and_use_engine(install_log_callback=True)
    gc.collect()

print("=== all iterations completed without crashing ===")

Result: segfaults during iteration 1 (the second QwenTTS.from_pretrained(...) call), every run.

Control (same script, install_log_callback=False): all 6 iterations complete cleanly, exit code 0. This isolates the crash to the callback registration itself, not to repeated construction/close in general.

Backtrace (gdb, main thread)

#0  0x000000000084909c in ffi_closure_unix64_inner ()
#1  0x000000000084a1e8 in ffi_closure_unix64 ()
#2  0x00007ffff46adffc in ?? () from qwentts_cpp/lib/libqwen.so
#3  0x00007ffff46b358f in qt_init () from qwentts_cpp/lib/libqwen.so
#4  0x000000000084a052 in ffi_call_unix64 ()
#5  0x0000000000848e66 in ffi_call_int ()
#6  0x0000000000848af0 in ffi_call ()
#7  0x0000000000760246 in _ctypes_callproc ()
#8  0x000000000075e905 in PyCFuncPtr_call ()

This shows qt_init() itself invoking the registered log callback through the ctypes/libffi trampoline (ffi_closure_unix64*) - i.e. qt_init on the second context logs something during initialization, and that call into the still-registered callback is what crashes.

What I've ruled out

  • Not a Python-side GC/dangling-pointer issue: tried both (a) a fresh callback object per QwenTTS instance, and (b) a single callback object created once and kept alive at module scope for the entire process, reused identically across every set_log_callback() call. Both crash, at the same point (second qt_init()), in the same number of iterations.
  • Not general construct/close instability: the control run above (no callback) is fully stable across the same number of iterations.

Suspicion

Given qt_log_set is documented as installing a process-wide callback (mirroring llama.cpp-style logging), my guess is something in qt_init's own internal state (possibly GIL handling around the callback invocation, or state left over from the previous context's qt_free()) isn't safe across a second context's init while a callback from a prior context is still registered - even when that "prior context" callback is, on the Python side, the exact same live object being reused. Happy to help narrow this down further (e.g. testing a build with debug symbols, or testing whether calling set_log_callback(None) before qt_free()/before the next qt_init() avoids it) if useful.

Impact

Anyone constructing more than one QwenTTS/QwenEngine context in a single process (a pool, a warm-up-then-reload cycle, or simply more than one call to from_pretrained) cannot safely use set_log_callback at all today - the only way to avoid the crash we found is to never install a custom log callback, which means the native [Pipeline] .../[Perf] ... etc. stdout logging can't be suppressed or redirected.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions