From 6be73afea21d2ecb7aecfc5d65d02f5f8484ccbc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:16:38 +0000 Subject: [PATCH] Fix packaged backend crashing on Windows at startup The first Windows CI run showed server.exe dying immediately with UnicodeEncodeError: when stdout is a pipe (as when Electron launches the backend), Windows Python uses cp1252, which cannot encode the emoji in the startup banner and OCR log messages. Every Windows install would have shown "processing engine did not start". - server.py: reconfigure stdout/stderr to UTF-8 (errors=replace) before importing the app - tests/test_entrypoint.py: start server.py with PYTHONIOENCODING=cp1252 and piped output and require /api/health (fails without the fix) - smoke-test-backend.ps1: stop waiting as soon as the backend exits and print its stdout/stderr, instead of polling for 90 seconds Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Jze3nDgZGqUXGceiu5RCAf --- backend/server.py | 13 ++++++++-- backend/tests/test_entrypoint.py | 44 ++++++++++++++++++++++++++++++++ scripts/smoke-test-backend.ps1 | 6 +++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_entrypoint.py diff --git a/backend/server.py b/backend/server.py index ef08a62..d92156a 100644 --- a/backend/server.py +++ b/backend/server.py @@ -5,10 +5,19 @@ package; this file stays here so PyInstaller (server.spec), run-app.bat and the Electron shell keep working unchanged. """ +import sys from datetime import datetime -from app import create_app -from app.services.ocr import tesseract_available +# On Windows, stdout/stderr use the legacy code page (cp1252) when they are a +# pipe, as when Electron launches the packaged backend. Printing any emoji +# (startup banner, OCR progress messages) then raises UnicodeEncodeError and +# kills the server, so force UTF-8 before anything else is imported. +for _stream in (sys.stdout, sys.stderr): + if _stream is not None and hasattr(_stream, 'reconfigure'): + _stream.reconfigure(encoding='utf-8', errors='replace') + +from app import create_app # noqa: E402 +from app.services.ocr import tesseract_available # noqa: E402 app = create_app() diff --git a/backend/tests/test_entrypoint.py b/backend/tests/test_entrypoint.py new file mode 100644 index 0000000..266150b --- /dev/null +++ b/backend/tests/test_entrypoint.py @@ -0,0 +1,44 @@ +"""The real server.py entrypoint, started the way the Electron shell starts it.""" +import json +import os +import subprocess +import sys +import time +import urllib.request + +BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PORT = 5098 + + +def test_server_starts_with_legacy_windows_encoding_and_piped_output(tmp_path): + # Electron pipes stdout; on Windows that means cp1252, which cannot encode + # the emoji in the startup banner and used to crash the backend. + env = { + **os.environ, + 'PYTHONIOENCODING': 'cp1252', + 'LIBRADIGIT_PORT': str(PORT), + 'LIBRADIGIT_DATABASE': str(tmp_path / 'db.sqlite'), + 'LIBRADIGIT_UPLOAD_FOLDER': str(tmp_path / 'uploads'), + 'LIBRADIGIT_ARCHIVE_FOLDER': str(tmp_path / 'Archive'), + } + proc = subprocess.Popen([sys.executable, 'server.py'], cwd=BACKEND_DIR, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + try: + health = None + deadline = time.time() + 30 + while time.time() < deadline and proc.poll() is None: + try: + with urllib.request.urlopen(f'http://127.0.0.1:{PORT}/api/health', timeout=1) as res: + health = json.load(res) + break + except OSError: + time.sleep(0.3) + if health is None: + proc.kill() + output = proc.communicate(timeout=10)[0].decode('utf-8', 'replace') + raise AssertionError(f'backend did not start (exit {proc.returncode}):\n{output[-2000:]}') + assert health['status'] == 'healthy' + finally: + if proc.poll() is None: + proc.terminate() + proc.wait(timeout=10) diff --git a/scripts/smoke-test-backend.ps1 b/scripts/smoke-test-backend.ps1 index 9e4e1fa..1c1ffd5 100644 --- a/scripts/smoke-test-backend.ps1 +++ b/scripts/smoke-test-backend.ps1 @@ -25,10 +25,16 @@ try { $health = $null for ($i = 0; $i -lt 90 -and -not $health; $i++) { Start-Sleep -Seconds 1 + # A crashed backend will never answer; stop waiting and show why. + if ($proc.HasExited) { break } try { $health = Invoke-RestMethod 'http://127.0.0.1:5001/api/health' } catch { } } if (-not $health) { + Write-Host '--- backend stdout ---' + Get-Content (Join-Path $env:TEMP 'backend-out.txt') -ErrorAction SilentlyContinue | Select-Object -Last 40 + Write-Host '--- backend stderr ---' Get-Content (Join-Path $env:TEMP 'backend-err.txt') -ErrorAction SilentlyContinue | Select-Object -Last 40 + if ($proc.HasExited) { throw "Backend exited with code $($proc.ExitCode) before becoming healthy" } throw 'Backend did not become healthy' } Write-Host "Health: $($health | ConvertTo-Json -Compress)"