mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-10-30 18:12:56 +08:00
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:
co-authored by
Claude Opus 4.7
parent
a32f11fc67
commit
4e93ab6461
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user