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)"