Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4dfc57994 | ||
|
|
65e2fddf1a | ||
|
|
4103b8928c | ||
|
|
3c1894f337 | ||
|
|
7ac373179b |
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
mediapreview <path> [-o OUTPUT] [-q QUALITY] [--maxsize N] [--maxzoom Z]
|
mediapreview <path> [-o OUTPUT] [-q QUALITY] [--maxsize N] [--maxzoom Z]
|
||||||
mediapreview oosetup [<name>] [<port>]
|
mediapreview oosetup [<name>]
|
||||||
mediapreview (-h | --help)
|
mediapreview (-h | --help)
|
||||||
|
|
||||||
Generate an AVIF preview for a media file (one-shot, in-process), or set up
|
Generate an AVIF preview for a media file (one-shot, in-process), or set up
|
||||||
the bundled OnlyOffice container.
|
the bundled OnlyOffice container (isolated network, reachable from the host
|
||||||
|
at its fixed container IP).
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
<path> media file to preview
|
<path> media file to preview
|
||||||
<name> container name [default: onlyoffice-mediapreview]
|
<name> container name [default: onlyoffice-mediapreview]
|
||||||
<port> container host port [default: 8988]
|
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-o OUTPUT output .avif file (default: write AVIF bytes to stdout)
|
-o OUTPUT output .avif file (default: write AVIF bytes to stdout)
|
||||||
@@ -43,7 +43,7 @@ def _configure_logging() -> None:
|
|||||||
logging.getLogger("pyvips").setLevel(logging.WARNING)
|
logging.getLogger("pyvips").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
def _oosetup(name: str, port: int) -> None:
|
def _oosetup(name: str) -> None:
|
||||||
try:
|
try:
|
||||||
# Lazy import: keeps the base CLI free of office-extra concerns.
|
# Lazy import: keeps the base CLI free of office-extra concerns.
|
||||||
from mediapreview.office import setup_docker # noqa: PLC0415
|
from mediapreview.office import setup_docker # noqa: PLC0415
|
||||||
@@ -52,7 +52,7 @@ def _oosetup(name: str, port: int) -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
try:
|
try:
|
||||||
# Logs go to stderr; stdout carries only the secret line below.
|
# Logs go to stderr; stdout carries only the secret line below.
|
||||||
secret = setup_docker(name=name, port=port)
|
secret = setup_docker(name=name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sys.stderr.write(f"error: OnlyOffice setup failed: {e}\n")
|
sys.stderr.write(f"error: OnlyOffice setup failed: {e}\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -102,13 +102,8 @@ def main() -> None:
|
|||||||
# by the <path> pattern if it came second. Dispatch it before parsing;
|
# by the <path> pattern if it came second. Dispatch it before parsing;
|
||||||
# the main help above still documents both modes.
|
# the main help above still documents both modes.
|
||||||
if sys.argv[1:2] == ["oosetup"]:
|
if sys.argv[1:2] == ["oosetup"]:
|
||||||
args = docopt(
|
args = docopt("Usage:\n mediapreview oosetup [<name>]", argv=sys.argv[1:])
|
||||||
"Usage:\n mediapreview oosetup [<name>] [<port>]", argv=sys.argv[1:]
|
_oosetup(args["<name>"] or "onlyoffice-mediapreview")
|
||||||
)
|
|
||||||
_oosetup(
|
|
||||||
args["<name>"] or "onlyoffice-mediapreview",
|
|
||||||
int(args["<port>"] or 8988),
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
_preview(docopt(__doc__))
|
_preview(docopt(__doc__))
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,17 @@ RUN apt-get update -qq && \
|
|||||||
ca-certificates && \
|
ca-certificates && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Clone the open-source server components (shallow, ~15 MB).
|
# Pin the open-source server components to a known-good commit (~15 MB).
|
||||||
# The master branch is used because the Linux/web tags are not published
|
# The master branch is a moving target (the Linux/web tags are not published
|
||||||
# in the server repo; the license.js file has been stable for years.
|
# in the server repo): a 2026 convertermaster change there detects community
|
||||||
RUN git clone --depth 1 https://github.com/ONLYOFFICE/server.git /opt/oo-server
|
# edition as a "memory runtime" and forks NO converter workers, silently
|
||||||
|
# breaking all conversions. Pinned to the commit proven in production.
|
||||||
|
ARG ONLYOFFICE_SERVER_REF=4e56b8d056640557ffcd8c860a65535ab6cbd95b
|
||||||
|
RUN git init /opt/oo-server && \
|
||||||
|
cd /opt/oo-server && \
|
||||||
|
git remote add origin https://github.com/ONLYOFFICE/server.git && \
|
||||||
|
git fetch --depth 1 origin "$ONLYOFFICE_SERVER_REF" && \
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
|
||||||
# Patch license.js so the converter worker count is read from an env var
|
# Patch license.js so the converter worker count is read from an env var
|
||||||
# instead of being hardcoded to 1.
|
# instead of being hardcoded to 1.
|
||||||
@@ -47,6 +54,12 @@ RUN sed -i \
|
|||||||
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
|
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
|
||||||
/opt/oo-server/Common/sources/license.js
|
/opt/oo-server/Common/sources/license.js
|
||||||
|
|
||||||
|
# Defense in depth: never take the "memory runtime" branch that forks no
|
||||||
|
# converter workers, even if the pinned ref is bumped carelessly.
|
||||||
|
RUN sed -i \
|
||||||
|
's/runtimeProfile\.isMemoryRuntime()/false \/* patched: always fork converter workers *\//g' \
|
||||||
|
/opt/oo-server/FileConverter/sources/convertermaster.js
|
||||||
|
|
||||||
# Install npm dependencies for the modules the FileConverter touches.
|
# Install npm dependencies for the modules the FileConverter touches.
|
||||||
# DocService deps are also needed because converter.js pulls in baseConnector.
|
# DocService deps are also needed because converter.js pulls in baseConnector.
|
||||||
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
|
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
|
||||||
|
|||||||
@@ -92,9 +92,11 @@ class PreviewTimeoutError(PreviewError):
|
|||||||
*,
|
*,
|
||||||
timeout_seconds: float = 0.0,
|
timeout_seconds: float = 0.0,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
|
fetched: bool | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(message, short, backend=backend)
|
super().__init__(message, short, backend=backend)
|
||||||
self.timeout_seconds = timeout_seconds
|
self.timeout_seconds = timeout_seconds
|
||||||
|
self.fetched = fetched
|
||||||
|
|
||||||
|
|
||||||
class PreviewCancelledError(PreviewError):
|
class PreviewCancelledError(PreviewError):
|
||||||
@@ -154,7 +156,15 @@ def onlyoffice_no_fileurl_error(snippet: str | None = None) -> OnlyOfficeError:
|
|||||||
|
|
||||||
|
|
||||||
def backend_error(backend: str, message: str) -> PreviewBackendError:
|
def backend_error(backend: str, message: str) -> PreviewBackendError:
|
||||||
short = message.splitlines()[0][:60]
|
short = message.splitlines()[0]
|
||||||
|
# Many backend messages look like "source: summary: detail ...".
|
||||||
|
# Drop the source prefix and any trailing detail so the short label
|
||||||
|
# is usable in UIs with limited space.
|
||||||
|
if ": " in short:
|
||||||
|
short = short.split(": ", 1)[1]
|
||||||
|
if ": " in short:
|
||||||
|
short = short.split(": ", 1)[0]
|
||||||
|
short = short[:60]
|
||||||
return PreviewBackendError(
|
return PreviewBackendError(
|
||||||
f"[{backend}] preview failed: {message}",
|
f"[{backend}] preview failed: {message}",
|
||||||
short,
|
short,
|
||||||
@@ -162,12 +172,21 @@ def backend_error(backend: str, message: str) -> PreviewBackendError:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def preview_timeout_error(backend: str, timeout_seconds: float) -> PreviewTimeoutError:
|
def preview_timeout_error(
|
||||||
|
backend: str, timeout_seconds: float, fetched: bool | None = None
|
||||||
|
) -> PreviewTimeoutError:
|
||||||
|
log = f"{backend.capitalize()} preview timed out after {timeout_seconds}s"
|
||||||
|
if fetched is not None:
|
||||||
|
# OnlyOffice: whether it ever downloaded the input file from our
|
||||||
|
# callback server distinguishes network/callback failures from a
|
||||||
|
# stalled conversion.
|
||||||
|
log += " (input file fetched)" if fetched else " (input file never fetched)"
|
||||||
return PreviewTimeoutError(
|
return PreviewTimeoutError(
|
||||||
f"{backend.capitalize()} preview timed out after {timeout_seconds}s",
|
log,
|
||||||
"timeout",
|
"timeout",
|
||||||
backend=backend,
|
backend=backend,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
|
fetched=fetched,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+105
-40
@@ -47,6 +47,16 @@ except ImportError: # pragma: no cover - optional office extra
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Isolated docker network for the OnlyOffice container: internal-only (no
|
||||||
|
# outbound internet), the container can only reach the host on this bridge.
|
||||||
|
# Docker discards published ports on internal networks, so the container is
|
||||||
|
# reached at its fixed IP instead of a published localhost port. The host is
|
||||||
|
# always the first address of the pinned subnet (the bridge gateway).
|
||||||
|
OO_NETWORK = "oonet"
|
||||||
|
OO_SUBNET = "172.30.0.0/24"
|
||||||
|
OO_GATEWAY = "172.30.0.1"
|
||||||
|
OO_CONTAINER_IP = "172.30.0.2"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration helpers
|
# Configuration helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -57,9 +67,11 @@ _httpx_client_loop: asyncio.AbstractEventLoop | None = None
|
|||||||
|
|
||||||
|
|
||||||
def _get_onlyoffice_url() -> str:
|
def _get_onlyoffice_url() -> str:
|
||||||
|
# The container runs on the isolated oonet network at a fixed IP; the
|
||||||
|
# host is the bridge gateway and reaches it directly, no published port.
|
||||||
return os.environ.get(
|
return os.environ.get(
|
||||||
"ONLYOFFICE_URL",
|
"ONLYOFFICE_URL",
|
||||||
os.environ.get("ONLYOFFICE_CISTA_URL", "http://localhost:8988"),
|
os.environ.get("ONLYOFFICE_CISTA_URL", f"http://{OO_CONTAINER_IP}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,26 +81,14 @@ def _get_jwt_secret() -> str:
|
|||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _get_callback_host() -> str:
|
def _get_callback_host() -> str:
|
||||||
"""Return the host IP that OnlyOffice (usually in Docker) can use to reach us."""
|
"""Return the host IP that OnlyOffice (in Docker) can use to reach us.
|
||||||
|
|
||||||
|
The host is always the gateway of the pinned oonet subnet; no detection
|
||||||
|
is needed (the cista service account may not have docker CLI access).
|
||||||
|
"""
|
||||||
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
|
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
|
||||||
return host
|
return host
|
||||||
# Try to auto-detect docker bridge IP
|
return OO_GATEWAY
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["/sbin/ip", "-4", "addr", "show", "docker0"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=2,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
if "inet " in line:
|
|
||||||
parts = line.strip().split()
|
|
||||||
addr_part = parts[1] # e.g. 172.17.0.1/16
|
|
||||||
return addr_part.split("/")[0]
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Failed to auto-detect docker bridge IP")
|
|
||||||
return "127.0.0.1"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -156,12 +156,14 @@ def log_reachable_info() -> None:
|
|||||||
logger.warning("OnlyOffice probe failed%s", suffix)
|
logger.warning("OnlyOffice probe failed%s", suffix)
|
||||||
|
|
||||||
|
|
||||||
def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str:
|
def setup_docker(name: str = "onlyoffice-mediapreview") -> str:
|
||||||
"""Build and run the patched OnlyOffice Docker image.
|
"""Build and run the patched OnlyOffice Docker image.
|
||||||
|
|
||||||
Uses ONLYOFFICE_JWT_SECRET if set, otherwise generates a random secret.
|
The container runs on an isolated internal network (OO_NETWORK) with no
|
||||||
Returns the secret used, so the caller is responsible for persisting it
|
outbound internet and no published ports; the host reaches it at
|
||||||
(the CLI prints it as `ONLYOFFICE_JWT_SECRET=<token>`).
|
OO_CONTAINER_IP. Uses ONLYOFFICE_JWT_SECRET if set, otherwise generates a
|
||||||
|
random secret. Returns the secret used, so the caller is responsible for
|
||||||
|
persisting it (the CLI prints it as `ONLYOFFICE_JWT_SECRET=<token>`).
|
||||||
The Docker build context ships inside the package at `mediapreview/docker`.
|
The Docker build context ships inside the package at `mediapreview/docker`.
|
||||||
"""
|
"""
|
||||||
if secret := _get_jwt_secret():
|
if secret := _get_jwt_secret():
|
||||||
@@ -182,13 +184,33 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError("Failed to build OnlyOffice image")
|
raise RuntimeError("Failed to build OnlyOffice image")
|
||||||
|
|
||||||
|
# Isolated network: internal-only, so the container has no outbound
|
||||||
|
# internet access and can only reach the host on this bridge (needed
|
||||||
|
# for the preview file callback). Already-exists is fine.
|
||||||
|
net_cmd = [
|
||||||
|
"docker",
|
||||||
|
"network",
|
||||||
|
"create",
|
||||||
|
"--internal",
|
||||||
|
"--subnet",
|
||||||
|
OO_SUBNET,
|
||||||
|
OO_NETWORK,
|
||||||
|
]
|
||||||
|
result = subprocess.run(net_cmd, capture_output=True, check=False) # noqa: S603
|
||||||
|
if result.returncode != 0 and b"already exists" not in result.stderr:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to create docker network {OO_NETWORK}: {result.stderr.decode(errors='replace').strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("Starting OnlyOffice container")
|
logger.info("Starting OnlyOffice container")
|
||||||
run_cmd = [
|
run_cmd = [
|
||||||
"docker",
|
"docker",
|
||||||
"run",
|
"run",
|
||||||
"-d",
|
"-d",
|
||||||
"-p",
|
"--network",
|
||||||
f"{port}:80",
|
OO_NETWORK,
|
||||||
|
"--ip",
|
||||||
|
OO_CONTAINER_IP,
|
||||||
"-e",
|
"-e",
|
||||||
f"JWT_SECRET={secret}",
|
f"JWT_SECRET={secret}",
|
||||||
"-e",
|
"-e",
|
||||||
@@ -203,7 +225,10 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str
|
|||||||
result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603
|
result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError("Failed to start OnlyOffice container")
|
raise RuntimeError("Failed to start OnlyOffice container")
|
||||||
logger.info("OnlyOffice is running on http://localhost:%d", port)
|
# Docker discards published ports on internal networks, so the container
|
||||||
|
# is reached at its fixed IP; no localhost port is exposed.
|
||||||
|
logger.info("OnlyOffice is running on http://%s", OO_CONTAINER_IP)
|
||||||
|
logger.info("Callback host for file downloads: %s", OO_GATEWAY)
|
||||||
return secret
|
return secret
|
||||||
|
|
||||||
|
|
||||||
@@ -260,7 +285,9 @@ async def is_available_cached() -> bool:
|
|||||||
|
|
||||||
class _QuietHandler(SimpleHTTPRequestHandler):
|
class _QuietHandler(SimpleHTTPRequestHandler):
|
||||||
def log_message(self, fmt, *args) -> None:
|
def log_message(self, fmt, *args) -> None:
|
||||||
pass
|
# Any request logged here means a client (OnlyOffice) connected to
|
||||||
|
# fetch the file; record it for timeout diagnostics.
|
||||||
|
self.server.oo_fetched = True
|
||||||
|
|
||||||
|
|
||||||
def _get_free_port() -> int:
|
def _get_free_port() -> int:
|
||||||
@@ -277,6 +304,7 @@ def _serve_file_temporarily(file_path: Path):
|
|||||||
|
|
||||||
handler = partial(_QuietHandler, directory=directory)
|
handler = partial(_QuietHandler, directory=directory)
|
||||||
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
|
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
|
||||||
|
httpd.oo_fetched = False
|
||||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
@@ -297,9 +325,14 @@ def _build_jwt_token(payload: dict) -> str | None:
|
|||||||
return jwt.encode(payload, secret, algorithm="HS256")
|
return jwt.encode(payload, secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) -> bytes:
|
async def convert_to_png_async(
|
||||||
|
file_path: Path, request_timeout: float = 7.0, download_timeout: float = 2.0
|
||||||
|
) -> bytes:
|
||||||
"""Convert *file_path* to PNG using OnlyOffice Document Server (async).
|
"""Convert *file_path* to PNG using OnlyOffice Document Server (async).
|
||||||
|
|
||||||
|
With ``async: false`` the conversion itself runs inside the POST request,
|
||||||
|
so *request_timeout* must cover full conversion time. *download_timeout*
|
||||||
|
covers fetching the resulting one-page PNG, which is pure transfer.
|
||||||
Returns the PNG bytes. Raises RuntimeError on failure.
|
Returns the PNG bytes. Raises RuntimeError on failure.
|
||||||
"""
|
"""
|
||||||
if httpx is None or jwt is None:
|
if httpx is None or jwt is None:
|
||||||
@@ -340,7 +373,9 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except httpx.TimeoutException as e:
|
except httpx.TimeoutException as e:
|
||||||
raise preview_timeout_error("onlyoffice", request_timeout) from e
|
raise preview_timeout_error(
|
||||||
|
"onlyoffice", request_timeout, fetched=httpd.oo_fetched
|
||||||
|
) from e
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
raise onlyoffice_http_error(e.response.status_code) from e
|
raise onlyoffice_http_error(e.response.status_code) from e
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
@@ -367,10 +402,10 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
|
|||||||
|
|
||||||
# Download converted PNG
|
# Download converted PNG
|
||||||
try:
|
try:
|
||||||
png_response = await client.get(file_url, timeout=request_timeout)
|
png_response = await client.get(file_url, timeout=download_timeout)
|
||||||
png_response.raise_for_status()
|
png_response.raise_for_status()
|
||||||
except httpx.TimeoutException as e:
|
except httpx.TimeoutException as e:
|
||||||
raise preview_timeout_error("onlyoffice", request_timeout) from e
|
raise preview_timeout_error("onlyoffice", download_timeout) from e
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
raise onlyoffice_http_error(e.response.status_code) from e
|
raise onlyoffice_http_error(e.response.status_code) from e
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
@@ -391,12 +426,23 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
|
|||||||
OO_MAX_CONCURRENT = max(2, min(8, cpu_count()))
|
OO_MAX_CONCURRENT = max(2, min(8, cpu_count()))
|
||||||
|
|
||||||
|
|
||||||
|
class _InFlight:
|
||||||
|
"""A deduplicated conversion: shared future, its task, and waiter count."""
|
||||||
|
|
||||||
|
__slots__ = ("future", "task", "waiters")
|
||||||
|
|
||||||
|
def __init__(self, future: asyncio.Future[bytes], task: asyncio.Task[None]):
|
||||||
|
self.future = future
|
||||||
|
self.task = task
|
||||||
|
self.waiters = 0
|
||||||
|
|
||||||
|
|
||||||
class OOConversionManager:
|
class OOConversionManager:
|
||||||
"""Manages async OnlyOffice conversions with deduplication and concurrency limits."""
|
"""Manages async OnlyOffice conversions with deduplication and concurrency limits."""
|
||||||
|
|
||||||
def __init__(self, max_concurrent: int = OO_MAX_CONCURRENT):
|
def __init__(self, max_concurrent: int = OO_MAX_CONCURRENT):
|
||||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
self._in_flight: dict[str, asyncio.Future[bytes]] = {}
|
self._in_flight: dict[str, _InFlight] = {}
|
||||||
self._tasks: set[asyncio.Task[None]] = set()
|
self._tasks: set[asyncio.Task[None]] = set()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -408,31 +454,50 @@ class OOConversionManager:
|
|||||||
key = f"{filepath}:{stat.st_mtime_ns}"
|
key = f"{filepath}:{stat.st_mtime_ns}"
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if key in self._in_flight:
|
entry = self._in_flight.get(key)
|
||||||
future = self._in_flight[key]
|
if entry is None:
|
||||||
else:
|
|
||||||
future = asyncio.get_running_loop().create_future()
|
future = asyncio.get_running_loop().create_future()
|
||||||
self._in_flight[key] = future
|
|
||||||
task = asyncio.create_task(self._do_convert(filepath, key, future))
|
task = asyncio.create_task(self._do_convert(filepath, key, future))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(self._tasks.discard)
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
entry = _InFlight(future, task)
|
||||||
|
self._in_flight[key] = entry
|
||||||
|
entry.waiters += 1
|
||||||
|
|
||||||
return await future
|
try:
|
||||||
|
# shield: one waiter's cancellation must not cancel the future
|
||||||
|
# shared with other waiters.
|
||||||
|
return await asyncio.shield(entry.future)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# The caller hit the (strict) preview deadline or disconnected.
|
||||||
|
# When no other waiter remains, cancel the background task so it
|
||||||
|
# releases its semaphore slot and aborts the HTTP request instead
|
||||||
|
# of running orphaned and piling load onto OnlyOffice.
|
||||||
|
async with self._lock:
|
||||||
|
entry.waiters -= 1
|
||||||
|
orphan = entry.waiters == 0
|
||||||
|
if orphan:
|
||||||
|
entry.task.cancel()
|
||||||
|
raise
|
||||||
|
|
||||||
async def _do_convert(
|
async def _do_convert(
|
||||||
self, filepath: Path, key: str, future: asyncio.Future[bytes]
|
self, filepath: Path, key: str, future: asyncio.Future[bytes]
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
async with self._semaphore:
|
async with self._semaphore:
|
||||||
png_bytes = await convert_to_png_async(filepath, request_timeout=5.0)
|
png_bytes = await convert_to_png_async(filepath)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# All waiters gave up; cancel the future so nothing hangs on it.
|
||||||
|
if not future.done():
|
||||||
|
future.cancel()
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(e)
|
future.set_exception(e)
|
||||||
async with self._lock:
|
|
||||||
self._in_flight.pop(key, None)
|
|
||||||
else:
|
else:
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_result(png_bytes)
|
future.set_result(png_bytes)
|
||||||
|
finally:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._in_flight.pop(key, None)
|
self._in_flight.pop(key, None)
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ def test_backend_error_pipeline_backend():
|
|||||||
assert isinstance(err, PreviewBackendError)
|
assert isinstance(err, PreviewBackendError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_error_short_message_strips_source_and_detail():
|
||||||
|
"""Backend messages like "source: summary: detail" become just the summary."""
|
||||||
|
err = backend_error(
|
||||||
|
"vips",
|
||||||
|
"pyvips: cannot decode image: unable to load from file b'/mnt/c/Users...",
|
||||||
|
)
|
||||||
|
assert err.short == "cannot decode image"
|
||||||
|
|
||||||
|
|
||||||
def test_error_pickle_round_trip():
|
def test_error_pickle_round_trip():
|
||||||
"""Exceptions survive pickling (the worker pool wire) intact."""
|
"""Exceptions survive pickling (the worker pool wire) intact."""
|
||||||
err = onlyoffice_error_from_code("-8")
|
err = onlyoffice_error_from_code("-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user