fix(worker): kein CMD-Blitzen mehr - und die 7200-Sekunden-Meldung ist erklaert
Ampel / ampel (push) Successful in 31s

Zwei Meldungen, beide auf dieselbe Wurzel zurueckgefuehrt: Der Worker startet
Konsolenprogramme, und das hat unter Windows Nebenwirkungen.

1. "Es geht immer alle paar Sekunden ne CMD auf." Kein Geist, sondern der
   HERZSCHLAG. Der Worker laeuft als pythonw.exe, also ohne eigene Konsole. Wer
   ohne Konsole ein Konsolenprogramm startet, bekommt von Windows eine NEUE - und
   die ist sichtbar. `capture_output=True` hilft nicht: es leitet die Datenstroeme
   um, unterdrueckt aber kein Fenster. Und caps.py fragt jede Minute
   `HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu makemkvcon aus
   der Schluessel-Automatik: vier bis fuenf Fenster pro Minute, in Schueben.

   Neues winlauf.py haelt CREATE_NO_WINDOW an EINER Stelle; alle elf Aufrufe in
   caps.py, ripping.py und schluessel.py benutzen es. Auf Linux ist der Wert 0 und
   creationflags=0 eine Nulloperation - im Worker-Container gegengeprueft, damit
   derselbe Code auf beiden Seiten laeuft.

2. Die 7200 Sekunden sind CELERYS EIGENE UMRECHNUNG, nicht die Uhren. Erst fiel
   auf, dass die Sekundenbruchteile nur 40 ms auseinanderliegen (.177269 vs
   .134799) - derselbe Augenblick, zweimal verschieden gerechnet. Die Quelle sagt
   warum (celery/utils/time.py):

     def utcoffset():                    # Sekunden WEST von UTC, in Stunden
         return time.altzone // 3600     # CEST -> -2 ;  UTC -> 0
     def adjust_timestamp(ts, offset, here=utcoffset):
         return ts - (offset - here()) * 3600

   Absender VM (offset 0), Empfaenger Windows-PC (here() = -2):
   ts - (0 - (-2)) * 3600 = ts - 7200. Celery verschiebt den empfangenen Stempel
   also selbst und vergleicht ihn dann mit der eigenen Uhr. Die Annahme dahinter -
   Ereignisse truegen Ortszeit - stimmt nicht, `Event()` stempelt Epoch.

   Abhilfe gemessen, nicht geraten: Mit TZ=UTC meldet Windows utcoffset 0
   (timezone=0, isdst=0 - auf dem Commander-PC geprueft), damit wird die
   Umrechnung zur Nulloperation. Beide .bat-Dateien setzen es jetzt. Nebeneffekt
   ist erwuenscht: Der Worker rechnet dann in derselben Zone wie die VM und wie
   Rippys UI.

   Fuer Rippy war die Meldung ohnehin folgenlos - /capabilities fragt per
   Celery-Ping (Frage/Antwort, ohne Zeitstempel), nicht ueber die
   Gossip-Ereignisse. Aber eine Warnung, die bei jedem Blick ins Log steht und
   nichts bedeutet, kostet Vertrauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 16:05:54 +02:00
parent 5f01aa89fc
commit 6438beec05
7 changed files with 57 additions and 7 deletions
Binary file not shown.
+2 -2
View File
@@ -482,10 +482,10 @@ function Do-Install {
$poolArg = if ($slots -le 1) { "--pool=solo" } else { "--pool=threads --concurrency=$slots" } $poolArg = if ($slots -le 1) { "--pool=solo" } else { "--pool=threads --concurrency=$slots" }
Log "Gleichzeitige Auftraege: $slots" Log "Gleichzeitige Auftraege: $slots"
$trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`nset RIPPY_SLOTS=$slots`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py" $trayBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_TRAY_HOST=$rHost`r`nset RIPPY_SLOTS=$slots`r`nset TZ=UTC`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nstart `"`" venv\Scripts\pythonw.exe tray.py"
Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII Set-Content -Path "start-tray.bat" -Value $trayBat -Encoding ASCII
$workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_SLOTS=$slots`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode $poolArg -n ${wName}@%%h" $workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_SLOTS=$slots`r`nset TZ=UTC`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode $poolArg -n ${wName}@%%h"
Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII
# --- Deinstaller --------------------------------------------------- # --- Deinstaller ---------------------------------------------------
+2
View File
@@ -184,6 +184,7 @@ set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
set API_URL=http://${RippyHost}:8000 set API_URL=http://${RippyHost}:8000
set WORKER_NAME=$WorkerName set WORKER_NAME=$WorkerName
set RIPPY_SLOTS=$Slots set RIPPY_SLOTS=$Slots
set TZ=UTC
${mapZeile}set PATH=%~dp0;%PATH% ${mapZeile}set PATH=%~dp0;%PATH%
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode $poolArg -n ${WorkerName}@%%h venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode $poolArg -n ${WorkerName}@%%h
"@ "@
@@ -200,6 +201,7 @@ set API_URL=http://${RippyHost}:8000
set WORKER_NAME=$WorkerName set WORKER_NAME=$WorkerName
set RIPPY_TRAY_HOST=$RippyHost set RIPPY_TRAY_HOST=$RippyHost
set RIPPY_SLOTS=$Slots set RIPPY_SLOTS=$Slots
set TZ=UTC
${mapZeile}set PATH=%~dp0;%PATH% ${mapZeile}set PATH=%~dp0;%PATH%
start "" venv\Scripts\pythonw.exe tray.py start "" venv\Scripts\pythonw.exe tray.py
"@ "@
+6 -1
View File
@@ -11,6 +11,8 @@ import re
import shutil import shutil
import subprocess import subprocess
from winlauf import OHNE_FENSTER
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b") HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
@@ -152,6 +154,7 @@ def hole_handbrake_presets() -> list:
aus = subprocess.run( aus = subprocess.run(
["HandBrakeCLI", "--preset-list"], ["HandBrakeCLI", "--preset-list"],
capture_output=True, text=True, timeout=30, capture_output=True, text=True, timeout=30,
creationflags=OHNE_FENSTER,
) )
return parse_preset_liste((aus.stdout or "") + (aus.stderr or "")) return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
@@ -263,7 +266,8 @@ def hole_handbrake_hilfe() -> str:
return "" return ""
try: try:
aus = subprocess.run( aus = subprocess.run(
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30 ["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30,
creationflags=OHNE_FENSTER
) )
return (aus.stdout or "") + (aus.stderr or "") return (aus.stdout or "") + (aus.stderr or "")
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
@@ -352,6 +356,7 @@ def werkzeug_versionen() -> dict:
aus = subprocess.run( aus = subprocess.run(
["HandBrakeCLI", "--version"], ["HandBrakeCLI", "--version"],
capture_output=True, text=True, timeout=15, capture_output=True, text=True, timeout=15,
creationflags=OHNE_FENSTER,
) )
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or "")) treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
info["handbrake"] = treffer.group(1) if treffer else "installiert" info["handbrake"] = treffer.group(1) if treffer else "installiert"
+11 -3
View File
@@ -17,6 +17,8 @@ import shutil
import subprocess import subprocess
import tempfile import tempfile
from winlauf import OHNE_FENSTER
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media") RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
@@ -318,6 +320,7 @@ def lies_titel_info(device_path: str, timeout: int = 300) -> tuple:
ergebnis = subprocess.run( ergebnis = subprocess.run(
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"], ["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
capture_output=True, text=True, timeout=timeout, capture_output=True, text=True, timeout=timeout,
creationflags=OHNE_FENSTER,
) )
ausgabe = ergebnis.stdout or "" ausgabe = ergebnis.stdout or ""
return parse_titel_info(ausgabe), parse_stream_info(ausgabe) return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
@@ -358,6 +361,7 @@ def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
ergebnis = subprocess.run( ergebnis = subprocess.run(
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"], ["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
capture_output=True, text=True, timeout=timeout, capture_output=True, text=True, timeout=timeout,
creationflags=OHNE_FENSTER,
) )
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return {} return {}
@@ -386,6 +390,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
ergebnis = subprocess.run( ergebnis = subprocess.run(
["HandBrakeCLI", "--scan", "-i", pfad], ["HandBrakeCLI", "--scan", "-i", pfad],
capture_output=True, text=True, timeout=timeout, capture_output=True, text=True, timeout=timeout,
creationflags=OHNE_FENSTER,
) )
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return 0 return 0
@@ -624,7 +629,8 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
bufsize=1 bufsize=1,
creationflags=OHNE_FENSTER,
) )
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb) return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
except Exception as e: except Exception as e:
@@ -753,7 +759,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
bufsize=1 bufsize=1,
creationflags=OHNE_FENSTER,
) )
letzte_meldung = "" letzte_meldung = ""
@@ -883,7 +890,8 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = N
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
bufsize=1 bufsize=1,
creationflags=OHNE_FENSTER,
) )
try: try:
+4 -1
View File
@@ -45,6 +45,8 @@ import re
import subprocess import subprocess
import urllib.request import urllib.request
from winlauf import OHNE_FENSTER
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"` # `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"') DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
@@ -163,7 +165,7 @@ def laufwerke_lesen(programm: str, laufen=None, timeout: int = 120) -> list:
ergebnis = starten( ergebnis = starten(
[programm, "-r", "--cache=1", "info", "disc:9999"], [programm, "-r", "--cache=1", "info", "disc:9999"],
capture_output=True, text=True, timeout=timeout, capture_output=True, text=True, timeout=timeout,
errors="replace", errors="replace", creationflags=OHNE_FENSTER,
) )
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return [] return []
@@ -184,6 +186,7 @@ def disc_oeffnen(programm: str, index: int, laufen=None, timeout: int = 600) ->
starten( starten(
[programm, "-r", "--noscan", "info", f"disc:{index}"], [programm, "-r", "--noscan", "info", f"disc:{index}"],
capture_output=True, text=True, timeout=timeout, errors="replace", capture_output=True, text=True, timeout=timeout, errors="replace",
creationflags=OHNE_FENSTER,
) )
return True return True
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
+32
View File
@@ -0,0 +1,32 @@
"""Kind-Prozesse starten, ohne dass ein Konsolenfenster aufblitzt.
## Der Befund, der das nötig gemacht hat (26.07.2026)
Commander: *Es geht übrigens immer alle paar Sekunden ne CMD auf."* Und er hatte
recht es war kein Geist, sondern der Herzschlag des Workers.
Der Windows-Worker läuft als `pythonw.exe`, also ohne eigene Konsole (das Tray
startet ihn mit CREATE_NO_WINDOW). Startet ein Prozess OHNE Konsole ein
Konsolenprogramm, legt Windows dafür eine NEUE Konsole an und die ist sichtbar.
`capture_output=True` hilft nicht: Es leitet die Datenströme um, unterdrückt aber
kein Fenster.
Und der Worker startet solche Programme oft: `caps.py` fragt jede Minute
`HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu kommt die
Schlüssel-Automatik mit `makemkvcon`. Vier bis fünf Fenster pro Minute, in
Schüben genau das alle paar Sekunden".
## Warum eine eigene Datei
Damit es an EINER Stelle richtig ist. Die betroffenen Aufrufe stehen in caps.py,
ripping.py und schluessel.py; jeder hätte das Flag einzeln vergessen können, und
genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und
`creationflags=0` eine Nulloperation im Worker-Container gegengeprüft, damit
derselbe Code auf beiden Seiten läuft.
"""
import subprocess
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0)