Files
VoiceStudio/backend/core/execstack.py
T
sean park 67c61fe357 fix(asr): repair CTranslate2's exec-stack request instead of only routing around it (#692)
ctranslate2 <=4.4.0 marks its native library's stack RWE; kernels that
refuse the request fail the dlopen, taking whisperx, faster-whisper and
Argos translation down. main learned to detect and fall back; this also
fixes the library: core.execstack clears the one ELF bit in place on
first probe (Linux-only, memoized, never raises), so the engines load
normally on hardened kernels. Argos's probe moves to
argostranslate.translate so it cannot advertise an engine whose every
request 500s, and the dub route returns one actionable 400.

Also: pytorch-whisper survives a CUDA OOM at transcribe time by
stepping the batch down (16->4->1, 8->2->1 with word timestamps) and
finishing on CPU rather than dropping the chunk.
2026-09-17 13:19:30 +09:00

248 lines
9.4 KiB
Python

"""Repair wheel-shipped shared libraries that request an executable stack.
Why this exists
---------------
CTranslate2 wheels up to and including 4.4.0 ship
``ctranslate2.libs/libctranslate2-*.so`` with ``PT_GNU_STACK`` marked
``RWE`` — a request for an executable stack. Linux kernels that refuse to
grant it (hardened kernels, and mainline from 6.x onwards) fail the
``dlopen`` outright::
ImportError: libctranslate2-d3638643.so.4.4.0: cannot enable executable
stack as shared object requires: Invalid argument
Everything that links CTranslate2 dies with it: the **whisperx** and
**faster-whisper** ASR engines (#692) *and* Argos translation, which is the
default dub translation engine (``argostranslate.translate`` imports
``ctranslate2``). #692 taught the ASR selector to fall through to another
engine; it never fixed the library, so Linux users on Python 3.11 lost both
engines. The pin is upstream and not ours to lift: whisperx 3.4.5 — the last
release that supports Python 3.11, which is what ``.python-version``, CI and
the installers use — requires ``ctranslate2<4.5.0``, and 4.5.0 is the first
release whose ``.so`` drops the exec-stack request.
The flag is a single bit in the ELF program header, so we clear it in place
rather than shipping a patched wheel or asking users for ``patchelf`` (which
is not installed on a typical desktop). Inspection is a ~100-byte read with
no imports, so :func:`ensure_ctranslate2_loadable` is cheap enough to call
from an availability probe: it only rewrites a file when that file would
otherwise refuse to load.
Everything here is a no-op off Linux (macOS/Windows have no such rejection)
and never raises — a repair that cannot happen returns a reason, and the
caller degrades exactly as it did before.
"""
from __future__ import annotations
import glob
import logging
import os
import struct
import sys
logger = logging.getLogger("omnivoice.execstack")
#: ELF segment type for the stack-permission marker, and its executable bit.
_PT_GNU_STACK = 0x6474E551
_PF_X = 0x1
#: Memoized :func:`ensure_ctranslate2_loadable` result — the repair is
#: process-wide and idempotent, so probe it once per backend process.
_CT2_CHECKED: tuple[bool, str] | None = None
def _elf_header(fh) -> tuple[str, int, int, int, bool] | None:
"""Return ``(endian_prefix, e_phoff, e_phentsize, e_phnum, is_64)`` or None.
None means "not an ELF file we understand" — which is a normal answer
(a ``.so`` stub, a text file, a Mach-O), never an error.
"""
fh.seek(0)
ident = fh.read(16)
if len(ident) < 16 or ident[:4] != b"\x7fELF":
return None
if ident[4] not in (1, 2) or ident[5] not in (1, 2):
return None
is_64 = ident[4] == 2
endian = "<" if ident[5] == 1 else ">"
# e_phoff / e_phentsize / e_phnum live at class-dependent offsets.
if is_64:
fh.seek(0x20)
(e_phoff,) = struct.unpack(endian + "Q", fh.read(8))
fh.seek(0x36)
e_phentsize, e_phnum = struct.unpack(endian + "HH", fh.read(4))
else:
fh.seek(0x1C)
(e_phoff,) = struct.unpack(endian + "I", fh.read(4))
fh.seek(0x2A)
e_phentsize, e_phnum = struct.unpack(endian + "HH", fh.read(4))
if not e_phoff or not e_phentsize or not e_phnum:
return None
# p_flags sits at a different offset per class (ELF64 puts it right after
# p_type; ELF32 puts it last), so the caller needs the class too.
return endian, e_phoff, e_phentsize, e_phnum, is_64
def _gnu_stack_flags_offset(fh) -> tuple[int, int, str] | None:
"""Locate the ``PT_GNU_STACK`` ``p_flags`` field.
Returns ``(file_offset, flags_value, endian_prefix)``, or None when the
file is not an ELF or carries no such segment.
"""
parsed = _elf_header(fh)
if parsed is None:
return None
endian, e_phoff, e_phentsize, e_phnum, is_64 = parsed
flags_rel = 4 if is_64 else 24 # p_flags offset inside the program header
for i in range(e_phnum):
base = e_phoff + i * e_phentsize
fh.seek(base)
raw = fh.read(e_phentsize)
if len(raw) < flags_rel + 4:
continue
(p_type,) = struct.unpack_from(endian + "I", raw, 0)
if p_type != _PT_GNU_STACK:
continue
(p_flags,) = struct.unpack_from(endian + "I", raw, flags_rel)
return base + flags_rel, p_flags, endian
return None
def has_execstack(path: str) -> bool | None:
"""True when ``path`` requests an executable stack.
None when the question does not apply: unreadable, not an ELF, or no
``PT_GNU_STACK`` segment.
"""
try:
with open(path, "rb") as fh:
found = _gnu_stack_flags_offset(fh)
except OSError:
return None
if found is None:
return None
_offset, flags, _endian = found
return bool(flags & _PF_X)
def clear_execstack(path: str) -> tuple[bool, str]:
"""Clear the executable-stack request on ``path``.
Returns ``(changed, detail)``. ``changed`` is False both when there was
nothing to do and when the write was refused (a read-only bundle, for
instance) — ``detail`` says which.
"""
try:
with open(path, "rb") as fh:
found = _gnu_stack_flags_offset(fh)
except OSError as e:
return False, f"unreadable ({e.__class__.__name__})"
if found is None:
return False, "no PT_GNU_STACK segment"
offset, flags, endian = found
if not flags & _PF_X:
return False, "already non-executable"
try:
with open(path, "r+b") as fh:
fh.seek(offset)
fh.write(struct.pack(endian + "I", flags & ~_PF_X))
fh.flush()
os.fsync(fh.fileno())
except OSError as e:
return False, f"not writable ({e.__class__.__name__}: {e})"
return True, "cleared PT_GNU_STACK executable bit"
def ctranslate2_library_paths() -> list[str]:
"""Native libraries shipped with the installed ``ctranslate2`` wheel.
Found without importing ``ctranslate2`` — importing it is the very thing
that fails when the exec-stack bit is set.
"""
import importlib.util
roots: list[str] = []
try:
spec = importlib.util.find_spec("ctranslate2")
except (ImportError, ValueError): # pragma: no cover — defensive
spec = None
locations = list(getattr(spec, "submodule_search_locations", None) or []) if spec else []
for pkg_dir in locations:
roots.append(pkg_dir)
roots.append(os.path.join(os.path.dirname(pkg_dir), "ctranslate2.libs"))
# Frozen builds flatten the wheel into the bundle directory.
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
roots.append(meipass)
roots.append(os.path.join(meipass, "ctranslate2.libs"))
out: list[str] = []
for root in roots:
if not os.path.isdir(root):
continue
for pattern in ("libctranslate2*.so*", "libctranslate2*.dylib"):
out.extend(sorted(glob.glob(os.path.join(root, pattern))))
# Dedupe, preserving order.
return list(dict.fromkeys(out))
def ensure_ctranslate2_loadable() -> tuple[bool, str]:
"""Make ``import ctranslate2`` possible on kernels that refuse exec stacks.
Returns ``(ok, detail)`` where ``ok`` is False only when a library needs
the repair and could not get it — the caller should then report its
engine unavailable with ``detail`` as the reason. Memoized: the repair is
idempotent and process-wide.
"""
global _CT2_CHECKED
if _CT2_CHECKED is not None:
return _CT2_CHECKED
result: tuple[bool, str]
if sys.platform != "linux":
# Only Linux rejects an exec-stack request at dlopen time.
result = (True, "not applicable off Linux")
else:
libs = ctranslate2_library_paths()
if not libs:
result = (True, "no ctranslate2 library found")
else:
repaired: list[str] = []
blocked: list[str] = []
for lib in libs:
if has_execstack(lib) is not True:
continue
changed, detail = clear_execstack(lib)
if changed:
repaired.append(os.path.basename(lib))
logger.warning(
"Repaired %s: %s — its executable-stack request is "
"rejected by this kernel, which broke whisperx, "
"faster-whisper and Argos translation (#692)",
os.path.basename(lib), detail,
)
else:
blocked.append(f"{lib} ({detail})")
if blocked:
result = (
False,
"ctranslate2's native library requests an executable stack, "
"which this kernel refuses, and it could not be patched: "
+ "; ".join(blocked)
+ ". Reinstall the backend on Python 3.12+ (which resolves "
"ctranslate2 4.8+, without the exec-stack request), or run "
"`patchelf --clear-execstack <library>` once.",
)
elif repaired:
result = (True, "repaired " + ", ".join(repaired))
else:
result = (True, "no exec-stack request")
_CT2_CHECKED = result
return result
def reset_ctranslate2_cache() -> None:
"""Forget the memoized probe result (tests, and after a reinstall)."""
global _CT2_CHECKED
_CT2_CHECKED = None