Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const config: KnipConfig = {
'@babel/preset-react', // Still used in jest
'@babel/preset-typescript', // Still used in jest
'@emotion/babel-plugin', // Still used in jest
'odiff-bin', // raw binary consumed by Python backend, not a JS import
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, why is this needed in knip? That's only a frontend check IIUC

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is needed because odiff is used for dev purposes and knip freaks out that we have a package that doesnt seem to be referenced in the frontend code

],
rules: {
binaries: 'off',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@
"jest-fail-on-console": "3.3.1",
"jest-junit": "16.0.0",
"knip": "5.83.1",
"odiff-bin": "^4.3.2",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the package.json change? This is only touching backend

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, i removed it initially as you remember, but as it turns out, for local development, this is the easiest way to have access to odiff

"postcss-styled-syntax": "0.7.0",
"react-refresh": "0.18.0",
"stylelint": "16.10.0",
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 17 additions & 1 deletion self-hosted/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
FROM scratch AS odiff-amd64
ADD --chmod=755 \
--checksum=sha256:2c17e6bcf92a58e6668f19f17f4a27fa4b1d70840994f31bd837b55bb6b297d7 \
https://github.com/dmtrKovalenko/odiff/releases/download/v4.3.2/odiff-linux-x64 \
/odiff

FROM scratch AS odiff-arm64
ADD --chmod=755 \
--checksum=sha256:d65f748c463a6aa78fa7bcdd31acd797eaed5160867e7769a3b291cfea42c9a0 \
https://github.com/dmtrKovalenko/odiff/releases/download/v4.3.2/odiff-linux-arm64 \
/odiff

ARG TARGETARCH
FROM odiff-${TARGETARCH} AS odiff

FROM python:3.13.1-slim-bookworm

LABEL maintainer="oss@sentry.io"
Expand All @@ -20,6 +35,8 @@ RUN : \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

COPY --from=odiff /odiff /usr/local/bin/odiff

WORKDIR /usr/src/sentry

ENV PATH="/.venv/bin:$PATH" UV_PROJECT_ENVIRONMENT=/.venv \
Expand All @@ -45,7 +62,6 @@ RUN set -x \
&& buildDeps=" \
gcc \
libpcre2-dev \
wget \
zlib1g-dev \
" \
&& apt-get update \
Expand Down
172 changes: 172 additions & 0 deletions src/sentry/preprod/snapshots/image_diff/odiff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
from __future__ import annotations

import platform
import select
import shutil
import subprocess
import threading
from pathlib import Path

from sentry.preprod.snapshots.image_diff.types import OdiffResponse
from sentry.utils import json

ODIFF_PLATFORM_SUFFIXES = {
("arm64", "Darwin"): "macos-arm64",
("aarch64", "Darwin"): "macos-arm64",
("x86_64", "Darwin"): "macos-x64",
("x86_64", "Linux"): "linux-x64",
("aarch64", "Linux"): "linux-arm64",
("arm64", "Linux"): "linux-arm64",
}


def _find_odiff_binary() -> str:
suffix = ODIFF_PLATFORM_SUFFIXES.get((platform.machine(), platform.system()))

current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "pyproject.toml").exists():
if suffix:
raw = parent / "node_modules" / "odiff-bin" / "raw_binaries" / f"odiff-{suffix}"
if raw.exists():
return str(raw)
break

found = shutil.which("odiff")
if found:
return found
raise FileNotFoundError("odiff binary not found. Run 'pnpm install' to install odiff-bin.")


class OdiffServer:
def __init__(self) -> None:
self._process: subprocess.Popen[bytes] | None = None
self._request_id = 0
self._lock = threading.Lock()

def __enter__(self) -> OdiffServer:
with self._lock:
if self._process is None:
self._start()
return self
Comment thread
NicoHinderling marked this conversation as resolved.

def __exit__(self, *args: object) -> None:
self.close()

def _read_response(self, line: bytes) -> OdiffResponse:
if not line:

Check warning on line 57 in src/sentry/preprod/snapshots/image_diff/odiff.py

View check run for this annotation

@sentry/warden / warden: sentry-backend-bugs

JSONDecodeError not caught when parsing odiff server response

The `_read_response` method calls `json.loads(line)` on line 57 without handling `JSONDecodeError`. This method is called from `compare()` where only `RuntimeError` is caught (lines 127-131). If the odiff subprocess outputs malformed JSON (e.g., due to crashes, version incompatibilities, or unexpected stderr mixing with stdout), the unhandled `JSONDecodeError` will propagate up and crash the caller. This matches the SENTRY-5CKF pattern where external data parsing without error handling caused 30,593 production errors.
raise RuntimeError("odiff server exited unexpectedly")
return OdiffResponse.parse_obj(json.loads(line))

def _kill_process(self) -> None:
proc = self._process
self._process = None
if proc is None:
return
proc.kill()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass

def _start(self) -> None:
binary = _find_odiff_binary()
proc = subprocess.Popen(
[binary, "--server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
Comment thread
NicoHinderling marked this conversation as resolved.
try:
readable, _, _ = select.select([proc.stdout], [], [], 30)
if not readable:
raise RuntimeError("odiff server timed out waiting for ready message")
line = proc.stdout.readline() # type: ignore[union-attr]
Comment thread
NicoHinderling marked this conversation as resolved.
ready = json.loads(line)
if not ready.get("ready"):
raise RuntimeError("odiff server failed to start")
except BaseException:
proc.kill()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
raise
self._process = proc

def compare(
self,
base_path: str | Path,
compare_path: str | Path,
output_path: str | Path,
**options: object,
) -> OdiffResponse:
with self._lock:
if self._process is None:
self._start()

process = self._process
if process is None or process.stdin is None or process.stdout is None:
raise RuntimeError("odiff server failed to initialize")
self._request_id += 1
request: dict[str, object] = {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a bit cleaner to have this be a type so we don't use untyped dicts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for this case, I think we can leave this request object as is

"requestId": self._request_id,
"base": str(base_path),
"compare": str(compare_path),
"output": str(output_path),
}
if options:
request["options"] = options

try:
process.stdin.write(json.dumps(request).encode() + b"\n")
process.stdin.flush()
except (BrokenPipeError, OSError) as e:
self._kill_process()
raise RuntimeError("odiff process died unexpectedly") from e

readable, _, _ = select.select([process.stdout], [], [], 30)
if not readable:
self._kill_process()
raise RuntimeError("odiff server timed out after 30s")
line = process.stdout.readline()
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
NicoHinderling marked this conversation as resolved.
try:
response = self._read_response(line)
except RuntimeError:
self._kill_process()
raise
Comment thread
NicoHinderling marked this conversation as resolved.

if response.error:
raise RuntimeError(f"odiff error: {response.error}")

return response

def close(self) -> None:
with self._lock:
if not self._process:
return
proc = self._process
self._process = None

if proc.stdin:
try:
proc.stdin.close()
except OSError:
pass
try:
proc.wait(timeout=3)
return
except subprocess.TimeoutExpired:
pass
proc.terminate()
try:
proc.wait(timeout=5)
return
except subprocess.TimeoutExpired:
pass
proc.kill()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
29 changes: 29 additions & 0 deletions src/sentry/preprod/snapshots/image_diff/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from __future__ import annotations

from pydantic import BaseModel, ConfigDict


class DiffResult(BaseModel):
model_config = ConfigDict(frozen=True)

diff_mask_png: str
diff_score: float
changed_pixels: int
total_pixels: int
aligned_height: int
width: int
before_width: int
before_height: int
after_width: int
after_height: int


class OdiffResponse(BaseModel):
model_config = ConfigDict(frozen=True)

requestId: int
exitCode: int
result: str
diffCount: int | None = None
diffPercentage: float | None = None
error: str | None = None
Loading