fix(libreoffice): harden headless conversion on macOS

LibreOffice headless conversion on macOS can SIGABRT ("Trace/BPT trap")
in some environments — see upstream bug 169711. The current harness
already isolates the user profile via -env:UserInstallation but is
still vulnerable to stale .~lock files, the start center, and crashed-
session restore prompts. When the direct soffice invocation aborts,
the harness produces a generic "exit -6: <stderr>" error that doesn't
tell the user what to do next.

Changes:
  - Always pass --nodefault, --norestore, --nolockcheck alongside
    --headless --nologo --nofirststartwizard. Factored into a shared
    tuple so both code paths stay in sync.
  - On macOS, when the direct soffice run returns a signal or stderr
    contains an abort marker, retry once through LaunchServices via
    `open -W -n -a <LibreOffice.app bundle> --args …`. This drives
    the conversion through the normal macOS app activation pathway
    instead of a raw fork/exec, which sidesteps several of the
    documented abort scenarios.
  - If both paths fail on macOS, raise a clear, actionable error that
    links to the upstream bug and includes copy-pasteable manual
    workarounds (direct soffice with an isolated profile, and the
    LaunchServices form).
  - Add 8 unit tests covering: conservative flag set, bundle path
    resolution, abort detection (signal + stderr text), macOS retry
    invocation, no retry on Linux, and the helpful double-failure
    error message.
  - Bump cli-anything-libreoffice 1.0.0 → 1.0.1.

This follows the mitigation plan that @yuh-yang outlined in the issue
thread on 2026-04-21 (isolated profile, conservative flags, macOS-
specific error, LaunchServices fallback). It does not — and per
upstream cannot — guarantee a fix for every macOS + LibreOffice
build combination, but it covers the documented mitigations and
fails loudly with a workaround when it can't.

Fixes #221

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Mubashir Rahim
2026-05-19 02:54:14 +05:00
co-authored by Claude Opus 4.7
parent a32f11fc67
commit 4e93ab6461
4 changed files with 297 additions and 10 deletions
@@ -9,6 +9,7 @@ import sys
import tempfile
import shutil
import zipfile
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
@@ -819,3 +820,158 @@ class TestSession:
assert len(sess.get_project()["content"]) == 1
sess.undo()
assert len(sess.get_project()["content"]) == 0
# ── LibreOffice backend (subprocess-mocked) ────────────────────────
from unittest.mock import patch, MagicMock
import subprocess as _subprocess
from cli_anything.libreoffice.utils import lo_backend
def _make_completed(returncode=0, stderr="", stdout=""):
"""Build a subprocess.CompletedProcess for monkeypatched runs."""
return _subprocess.CompletedProcess(
args=[], returncode=returncode, stdout=stdout, stderr=stderr,
)
class TestBackend:
def test_conservative_flags_present(self, monkeypatch, tmp_path):
"""Direct headless invocation includes the full conservative flag set."""
monkeypatch.setattr(lo_backend, "find_libreoffice",
lambda: "/Applications/LibreOffice.app/Contents/MacOS/soffice")
monkeypatch.setattr(lo_backend.sys, "platform", "linux")
input_file = tmp_path / "doc.odt"
input_file.write_bytes(b"fake")
out_dir = tmp_path / "out"
out_dir.mkdir()
captured = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
# Simulate the output file being produced
base = os.path.splitext(os.path.basename(cmd[-1]))[0]
out_path = os.path.join(kwargs.get("env", {}).get("OUTDIR", str(out_dir)),
f"{base}.pdf")
# The real cmd has --outdir <dir> as positional args; pluck from cmd
outdir_idx = cmd.index("--outdir") + 1
out_path = os.path.join(cmd[outdir_idx], f"{base}.pdf")
Path(out_path).write_bytes(b"%PDF-")
return _make_completed(returncode=0)
monkeypatch.setattr(lo_backend.subprocess, "run", fake_run)
result = lo_backend.convert(str(input_file), "pdf", output_dir=str(out_dir))
assert result == str((out_dir / "doc.pdf").resolve())
cmd = captured["cmd"]
for flag in ("--headless", "--nologo", "--nodefault",
"--norestore", "--nolockcheck", "--nofirststartwizard"):
assert flag in cmd, f"missing flag {flag} in cmd: {cmd}"
def test_macos_app_bundle_resolution(self):
with patch.object(lo_backend.sys, "platform", "darwin"):
bundle = lo_backend._macos_app_bundle(
"/Applications/LibreOffice.app/Contents/MacOS/soffice")
assert bundle == "/Applications/LibreOffice.app"
def test_macos_app_bundle_returns_none_on_linux(self):
with patch.object(lo_backend.sys, "platform", "linux"):
assert lo_backend._macos_app_bundle(
"/Applications/LibreOffice.app/Contents/MacOS/soffice") is None
def test_looks_like_macos_abort_signal(self):
assert lo_backend._looks_like_macos_abort(_make_completed(returncode=-6))
assert not lo_backend._looks_like_macos_abort(_make_completed(returncode=0))
def test_looks_like_macos_abort_stderr_text(self):
assert lo_backend._looks_like_macos_abort(
_make_completed(returncode=1, stderr="something Trace/BPT trap: 5"))
assert lo_backend._looks_like_macos_abort(
_make_completed(returncode=1, stderr="Abort trap: 6"))
assert not lo_backend._looks_like_macos_abort(
_make_completed(returncode=1, stderr="some other failure"))
def test_macos_fallback_invoked_on_abort(self, monkeypatch, tmp_path):
"""On macOS, when direct soffice aborts, LaunchServices is retried."""
monkeypatch.setattr(lo_backend, "find_libreoffice",
lambda: "/Applications/LibreOffice.app/Contents/MacOS/soffice")
monkeypatch.setattr(lo_backend.sys, "platform", "darwin")
input_file = tmp_path / "doc.odt"
input_file.write_bytes(b"fake")
out_dir = tmp_path / "out"
out_dir.mkdir()
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
if len(calls) == 1:
# Direct soffice invocation: simulate SIGABRT
return _make_completed(returncode=-6, stderr="Trace/BPT trap: 5")
# LaunchServices retry: write the output and return success
outdir_idx = cmd.index("--outdir") + 1
out_path = os.path.join(cmd[outdir_idx], "doc.pdf")
Path(out_path).write_bytes(b"%PDF-")
return _make_completed(returncode=0)
monkeypatch.setattr(lo_backend.subprocess, "run", fake_run)
result = lo_backend.convert(str(input_file), "pdf", output_dir=str(out_dir))
assert result == str((out_dir / "doc.pdf").resolve())
assert len(calls) == 2
# Second call must be the LaunchServices `open -W -n -a <bundle> --args`
assert calls[1][0] == "open"
assert "-W" in calls[1] and "-n" in calls[1]
assert "/Applications/LibreOffice.app" in calls[1]
assert "--args" in calls[1]
def test_no_fallback_on_linux(self, monkeypatch, tmp_path):
"""Linux/Windows: direct soffice failure is surfaced without `open`."""
monkeypatch.setattr(lo_backend, "find_libreoffice", lambda: "/usr/bin/soffice")
monkeypatch.setattr(lo_backend.sys, "platform", "linux")
input_file = tmp_path / "doc.odt"
input_file.write_bytes(b"fake")
out_dir = tmp_path / "out"
out_dir.mkdir()
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
return _make_completed(returncode=1, stderr="some failure")
monkeypatch.setattr(lo_backend.subprocess, "run", fake_run)
with pytest.raises(RuntimeError, match="exit 1"):
lo_backend.convert(str(input_file), "pdf", output_dir=str(out_dir))
assert len(calls) == 1
assert calls[0][0] == "/usr/bin/soffice"
def test_macos_double_failure_raises_helpful_error(self, monkeypatch, tmp_path):
"""When both direct and LaunchServices paths fail on macOS, the error
mentions the upstream bug + manual workarounds."""
monkeypatch.setattr(lo_backend, "find_libreoffice",
lambda: "/Applications/LibreOffice.app/Contents/MacOS/soffice")
monkeypatch.setattr(lo_backend.sys, "platform", "darwin")
input_file = tmp_path / "doc.odt"
input_file.write_bytes(b"fake")
out_dir = tmp_path / "out"
out_dir.mkdir()
# Both invocations report failure and produce no output file
monkeypatch.setattr(lo_backend.subprocess, "run",
lambda cmd, **kw: _make_completed(returncode=-6,
stderr="Trace/BPT trap: 5"))
with pytest.raises(RuntimeError) as exc:
lo_backend.convert(str(input_file), "pdf", output_dir=str(out_dir))
msg = str(exc.value)
assert "bugs.documentfoundation.org/show_bug.cgi?id=169711" in msg
assert "open -W -n -a" in msg
assert "soffice" in msg
@@ -13,11 +13,35 @@ Requires: libreoffice (system package)
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Optional
# Conservative flags used for every headless invocation. Each one suppresses an
# interactive UI surface that headless conversions should never need:
# --headless no GUI
# --nologo no startup splash
# --nodefault do not open any default doc/start center
# --norestore do not try to restore a crashed session
# --nolockcheck ignore stale .~lock files left by a prior Office run
# --nofirststartwizard skip first-run setup
#
# These are particularly important on macOS, where stale lock files from prior
# Office runs and the start center can both trigger SIGABRT ("Trace/BPT trap")
# during headless conversion — see upstream bug
# https://bugs.documentfoundation.org/show_bug.cgi?id=169711.
_HEADLESS_FLAGS = (
"--headless",
"--nologo",
"--nodefault",
"--norestore",
"--nolockcheck",
"--nofirststartwizard",
)
def find_libreoffice() -> str:
"""Find the LibreOffice executable.
@@ -32,7 +56,6 @@ def find_libreoffice() -> str:
return path
# 2) Check common installation paths (Windows)
import sys
if sys.platform == "win32" or os.name == "nt" or "MSYS" in os.environ.get("MSYSTEM", "") or "msys" in sys.platform or os.path.exists("C:/"):
win_candidates = [
os.path.join(os.environ.get("PROGRAMFILES", r"C:\Program Files"),
@@ -78,6 +101,93 @@ def get_version() -> str:
return f"LibreOffice (path: {lo})"
def _macos_app_bundle(soffice_path: str) -> Optional[str]:
"""Return the .app bundle path for a macOS soffice binary, or None.
Detects layouts like ``/Applications/LibreOffice.app/Contents/MacOS/soffice``
and returns ``/Applications/LibreOffice.app``. Used to drive the
``open -W -n -a <bundle>`` fallback when a direct headless invocation
aborts during conversion.
"""
if sys.platform != "darwin":
return None
p = Path(soffice_path).resolve()
for ancestor in p.parents:
if ancestor.suffix == ".app":
return str(ancestor)
return None
def _looks_like_macos_abort(result: subprocess.CompletedProcess) -> bool:
"""Detect the macOS SIGABRT pattern reported in issue #221.
On macOS, soffice's headless conversion sometimes aborts with a negative
return code (signal) or a "Trace/BPT trap" / "Abort trap" stderr message
before producing any output. Treat any of those as a candidate for the
LaunchServices retry path.
"""
if result.returncode < 0:
return True
stderr = (result.stderr or "").lower()
return "trace/bpt trap" in stderr or "abort trap" in stderr
def _convert_via_launchservices(
app_bundle: str,
output_format: str,
output_dir: str,
input_path: str,
profile_uri: str,
timeout: int,
) -> subprocess.CompletedProcess:
"""Re-attempt a conversion through ``open -W -n -a <bundle> --args …``.
LaunchServices spawns the .app bundle through the standard macOS app
activation pathway, which avoids some of the headless-abort scenarios that
direct ``soffice`` invocation can hit. ``open`` does not proxy the child's
stdout/stderr, so callers must verify success by checking for the expected
output file.
"""
cmd = [
"open", "-W", "-n", "-a", app_bundle, "--args",
*_HEADLESS_FLAGS,
f"-env:UserInstallation={profile_uri}",
"--convert-to", output_format,
"--outdir", output_dir,
input_path,
]
return subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout,
)
def _macos_conversion_error(
cmd: list[str],
result: subprocess.CompletedProcess,
input_path: str,
output_dir: str,
) -> RuntimeError:
"""Build a clear, actionable error for the macOS headless-abort case."""
return RuntimeError(
"LibreOffice headless conversion failed on macOS even after the "
"LaunchServices fallback. This commonly tracks upstream LibreOffice "
"bug https://bugs.documentfoundation.org/show_bug.cgi?id=169711, "
"where headless conversion can SIGABRT in some macOS environments.\n"
f" Last command: {' '.join(cmd)}\n"
f" Return code: {result.returncode}\n"
f" stderr: {(result.stderr or '').strip()}\n"
"Manual workarounds to try:\n"
f" 1) Re-run with an isolated profile yourself:\n"
f" /Applications/LibreOffice.app/Contents/MacOS/soffice \\\n"
f" -env:UserInstallation=file:///tmp/lo-profile-$$ \\\n"
f" --headless --nologo --nodefault --norestore --nolockcheck \\\n"
f" --convert-to <fmt> --outdir {output_dir} {input_path}\n"
f" 2) Force the LaunchServices path manually:\n"
f" open -W -n -a 'LibreOffice' --args --headless --convert-to <fmt> "
f"--outdir {output_dir} {input_path}"
)
def convert(
input_path: str,
output_format: str,
@@ -129,9 +239,7 @@ def convert(
cmd = [
lo,
"--headless",
"--nologo",
"--nofirststartwizard",
*_HEADLESS_FLAGS,
f"-env:UserInstallation={profile_uri}",
"--convert-to", output_format,
"--outdir", output_dir,
@@ -145,6 +253,29 @@ def convert(
env=env,
)
base = os.path.splitext(os.path.basename(input_path))[0]
output_path = os.path.join(output_dir, f"{base}.{output_format}")
# macOS: if the direct invocation aborted or produced no output,
# retry through LaunchServices once. See issue #221.
if (sys.platform == "darwin"
and (result.returncode != 0
or _looks_like_macos_abort(result)
or not os.path.exists(output_path))):
app_bundle = _macos_app_bundle(lo)
if app_bundle is not None:
fallback = _convert_via_launchservices(
app_bundle, output_format, output_dir,
input_path, profile_uri, timeout,
)
if os.path.exists(output_path):
return os.path.abspath(output_path)
# LaunchServices also failed — surface a macOS-specific error
# with manual workarounds before the generic path takes over.
raise _macos_conversion_error(
cmd, fallback, input_path, output_dir,
)
if result.returncode != 0:
raise RuntimeError(
f"LibreOffice conversion failed (exit {result.returncode}):\n"
@@ -152,10 +283,6 @@ def convert(
f" stderr: {result.stderr.strip()}"
)
# Determine the output filename
base = os.path.splitext(os.path.basename(input_path))[0]
output_path = os.path.join(output_dir, f"{base}.{output_format}")
if not os.path.exists(output_path):
raise RuntimeError(
f"LibreOffice conversion produced no output file.\n"
+1 -1
View File
@@ -13,7 +13,7 @@ with open("cli_anything/libreoffice/README.md", "r", encoding="utf-8") as fh:
setup(
name="cli-anything-libreoffice",
version="1.0.0",
version="1.0.1",
author="cli-anything contributors",
author_email="",
description="CLI harness for LibreOffice - import, create, edit, and export ODF/Office documents via LibreOffice headless",
+5 -1
View File
@@ -293,7 +293,7 @@
{
"name": "libreoffice",
"display_name": "LibreOffice",
"version": "1.0.0",
"version": "1.0.1",
"description": "Create and manipulate ODF documents, export to PDF/DOCX/XLSX/PPTX via headless mode",
"requires": "libreoffice",
"homepage": "https://www.libreoffice.org",
@@ -306,6 +306,10 @@
{
"name": "CLI-Anything-Team",
"url": "https://github.com/HKUDS/CLI-Anything"
},
{
"name": "Mubashirrrr",
"url": "https://github.com/Mubashirrrr"
}
]
},