181 lines
6.3 KiB
Python
181 lines
6.3 KiB
Python
|
|
"""Start/stop the loopback-only development server without modifying saved config."""
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import shutil
|
||
|
|
import socket
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
from datetime import datetime
|
||
|
|
from urllib.request import build_opener, ProxyHandler
|
||
|
|
|
||
|
|
import psutil
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
PYTHON = ROOT / '.venv' / 'Scripts' / 'python.exe'
|
||
|
|
MANAGE = ROOT / 'manage.py'
|
||
|
|
RUNTIME = ROOT / '.runtime'
|
||
|
|
PID_FILE = RUNTIME / 'local-server.json'
|
||
|
|
URL = 'http://127.0.0.1:10001/login'
|
||
|
|
|
||
|
|
|
||
|
|
def local_environment():
|
||
|
|
import imageio_ffmpeg
|
||
|
|
(RUNTIME / 'ultralytics').mkdir(parents=True, exist_ok=True)
|
||
|
|
ffmpeg = Path(imageio_ffmpeg.__file__).parent / 'binaries'
|
||
|
|
executables = list(ffmpeg.glob('ffmpeg*.exe'))
|
||
|
|
if len(executables) != 1 or not executables[0].resolve().is_relative_to(ROOT / '.venv'):
|
||
|
|
raise RuntimeError('Expected one FFmpeg executable bundled inside the project virtualenv')
|
||
|
|
env = os.environ.copy()
|
||
|
|
env.update({
|
||
|
|
'DJANGO_SETTINGS_MODULE': 'framework.settings',
|
||
|
|
'MONITOR_DEBUG': 'true',
|
||
|
|
'MONITOR_ALLOWED_HOSTS': '127.0.0.1,localhost',
|
||
|
|
'MONITOR_HTTPS': 'false',
|
||
|
|
'MONITOR_SERVICE_MODE': 'disabled',
|
||
|
|
'MONITOR_BOOTSTRAP_SERVICES': 'false',
|
||
|
|
'MONITOR_TELEMETRY_ENDPOINT': '',
|
||
|
|
'MONITOR_UPDATE_ENDPOINT': '',
|
||
|
|
'MONITOR_FFMPEG': str(executables[0].resolve()),
|
||
|
|
'PYTHONUNBUFFERED': '1',
|
||
|
|
'YOLO_CONFIG_DIR': str(RUNTIME / 'ultralytics'),
|
||
|
|
'YOLO_OFFLINE': 'true',
|
||
|
|
'YOLO_AUTOINSTALL': 'false',
|
||
|
|
# OpenVINO 2026 initializes import telemetry with disable_in_ci=True.
|
||
|
|
# Use its upstream unattended-validation opt-out in this child only.
|
||
|
|
'CI': 'true',
|
||
|
|
})
|
||
|
|
return env
|
||
|
|
|
||
|
|
|
||
|
|
def backup():
|
||
|
|
target = RUNTIME / 'backups' / datetime.now().strftime('%Y%m%d-%H%M%S-%f')
|
||
|
|
target.mkdir(parents=True)
|
||
|
|
for name in ('monitor.sqlite3', '.runtime-secrets.json', 'config.json', 'settings.json'):
|
||
|
|
source = ROOT / name
|
||
|
|
if source.is_file():
|
||
|
|
shutil.copy2(source, target / name)
|
||
|
|
print('Backup:', target)
|
||
|
|
return target
|
||
|
|
|
||
|
|
|
||
|
|
def is_project_process(proc):
|
||
|
|
try:
|
||
|
|
args = proc.cmdline()
|
||
|
|
return (len(args) >= 5 and Path(args[0]).resolve() == PYTHON.resolve()
|
||
|
|
and Path(args[1]).resolve() == MANAGE.resolve()
|
||
|
|
and args[2:] == ['runserver', '127.0.0.1:10001', '--noreload'])
|
||
|
|
except (psutil.Error, OSError):
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def recorded_process():
|
||
|
|
if not PID_FILE.exists():
|
||
|
|
return None
|
||
|
|
data = json.loads(PID_FILE.read_text(encoding='utf-8'))
|
||
|
|
if Path(data['root']).resolve() != ROOT:
|
||
|
|
raise RuntimeError('Recorded project path mismatch; refusing to manage this process')
|
||
|
|
try:
|
||
|
|
proc = psutil.Process(data['pid'])
|
||
|
|
if abs(proc.create_time() - data['create_time']) > 0.01:
|
||
|
|
return None
|
||
|
|
if not is_project_process(proc):
|
||
|
|
raise RuntimeError('PID ownership mismatch; refusing to manage this process')
|
||
|
|
return proc
|
||
|
|
except psutil.NoSuchProcess:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def port_open():
|
||
|
|
with socket.socket() as sock:
|
||
|
|
sock.settimeout(1)
|
||
|
|
return sock.connect_ex(('127.0.0.1', 10001)) == 0
|
||
|
|
|
||
|
|
|
||
|
|
def save_process(proc):
|
||
|
|
PID_FILE.write_text(json.dumps({'pid': proc.pid, 'create_time': proc.create_time(),
|
||
|
|
'root': str(ROOT)}, indent=2), encoding='utf-8')
|
||
|
|
|
||
|
|
|
||
|
|
def discover_project_listener():
|
||
|
|
"""Recover ownership if the PID file was lost, including the Windows venv launcher."""
|
||
|
|
for conn in psutil.net_connections(kind='tcp'):
|
||
|
|
if (conn.status != psutil.CONN_LISTEN or not conn.pid
|
||
|
|
or conn.laddr.ip != '127.0.0.1' or conn.laddr.port != 10001):
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
listener = psutil.Process(conn.pid)
|
||
|
|
candidates = [listener, *listener.parents()]
|
||
|
|
for candidate in reversed(candidates):
|
||
|
|
if is_project_process(candidate):
|
||
|
|
return candidate
|
||
|
|
except psutil.Error:
|
||
|
|
continue
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def start():
|
||
|
|
RUNTIME.mkdir(exist_ok=True)
|
||
|
|
proc = recorded_process()
|
||
|
|
if proc and port_open():
|
||
|
|
print('Already running:', URL, 'PID:', proc.pid)
|
||
|
|
return
|
||
|
|
if port_open() and not proc:
|
||
|
|
owner = discover_project_listener()
|
||
|
|
if owner:
|
||
|
|
save_process(owner)
|
||
|
|
print('Reused project server:', URL, 'PID:', owner.pid)
|
||
|
|
return
|
||
|
|
if port_open() or proc:
|
||
|
|
raise RuntimeError('Port 10001 is occupied or recorded server is still starting; no process was stopped')
|
||
|
|
backup()
|
||
|
|
with open(RUNTIME / 'local-server.stdout.log', 'ab') as out, open(RUNTIME / 'local-server.stderr.log', 'ab') as err:
|
||
|
|
child = subprocess.Popen(
|
||
|
|
[str(PYTHON), str(MANAGE), 'runserver', '127.0.0.1:10001', '--noreload'],
|
||
|
|
cwd=str(ROOT), env=local_environment(), stdin=subprocess.DEVNULL,
|
||
|
|
stdout=out, stderr=err, creationflags=subprocess.CREATE_NO_WINDOW,
|
||
|
|
)
|
||
|
|
info = psutil.Process(child.pid)
|
||
|
|
save_process(info)
|
||
|
|
opener = build_opener(ProxyHandler({}))
|
||
|
|
for _ in range(60):
|
||
|
|
if child.poll() is not None:
|
||
|
|
raise RuntimeError('Server exited; inspect .runtime/local-server.stderr.log')
|
||
|
|
try:
|
||
|
|
with opener.open(URL, timeout=2) as response:
|
||
|
|
if response.status == 200:
|
||
|
|
print('Ready:', URL, 'PID:', child.pid)
|
||
|
|
return
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
time.sleep(1)
|
||
|
|
raise RuntimeError('Server readiness timeout; process left running for diagnosis')
|
||
|
|
|
||
|
|
|
||
|
|
def stop():
|
||
|
|
proc = recorded_process()
|
||
|
|
if not proc:
|
||
|
|
print('No recorded project server is running')
|
||
|
|
return
|
||
|
|
children = proc.children(recursive=True)
|
||
|
|
proc.terminate()
|
||
|
|
for child in children:
|
||
|
|
try:
|
||
|
|
child.terminate()
|
||
|
|
except psutil.NoSuchProcess:
|
||
|
|
pass
|
||
|
|
_, remaining = psutil.wait_procs([proc] + children, timeout=10)
|
||
|
|
if remaining:
|
||
|
|
raise RuntimeError('Some owned processes did not stop; inspect before retrying')
|
||
|
|
PID_FILE.unlink(missing_ok=True)
|
||
|
|
print('Stopped project server')
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
parser = argparse.ArgumentParser()
|
||
|
|
parser.add_argument('action', choices=('start', 'stop', 'backup'))
|
||
|
|
args = parser.parse_args()
|
||
|
|
{'start': start, 'stop': stop, 'backup': backup}[args.action]()
|