refactor: manage Docker processes with supervisor

This commit is contained in:
jxxghp
2026-09-06 21:21:06 +08:00
parent d11ed310e6
commit e6f531d47f
20 changed files with 208 additions and 621 deletions
+2 -2
View File
@@ -1111,9 +1111,9 @@ def _check_docker(runner: DoctorRunnerProtocol) -> None:
title="Docker 诊断入口可用",
detail=(
f"CONFIG_DIR={get_runtime_setting('CONFIG_PATH')}VENV_PATH={os.getenv('VENV_PATH', '/opt/venv')}"
f"MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE={os.getenv('MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE', 'true')}"
"supervisor 托管 Nginx 和后端进程"
),
recommendation="主进程异常退出后容器会保活,仍可通过 `docker exec <container> moviepilot doctor` 诊断。",
recommendation="后端异常由 supervisor 自动拉起;启动失败时可通过 `docker exec <container> moviepilot doctor` 诊断。",
)
-3
View File
@@ -677,9 +677,6 @@ class ConfigModel(BaseModel):
# 对阿里云盘进行快照对比时,是否检查文件夹的修改时间(默认关闭,因为阿里云盘目录时间不随子文件变更而更新)
ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME: bool = False
# ==================== Docker配置 ====================
# Docker Client API地址
DOCKER_CLIENT_API: Optional[str] = "tcp://127.0.0.1:38379"
# Playwright浏览器类型,供智能体浏览器工具和插件直接使用 Playwright 时读取
PLAYWRIGHT_BROWSER_TYPE: str = "chromium"
+48 -171
View File
@@ -4,11 +4,9 @@ import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Optional, Tuple
import docker
import psutil
from app.runtime.settings import get_runtime_setting
@@ -40,11 +38,9 @@ class SystemHelper(ConfigReloadMixin):
__one_shot_dev_update_flag_file = (
get_runtime_setting('TEMP_PATH') / "moviepilot.pending_dev_update"
)
__docker_restart_intent_file = (
get_runtime_setting('TEMP_PATH') / "moviepilot.intentional_restart"
)
__graceful_shutdown_monitor_lock = threading.Lock()
__graceful_shutdown_monitor: Optional[threading.Thread] = None
__supervisor_config = Path("/etc/supervisor/supervisord.conf")
__supervisorctl = Path("/usr/bin/supervisorctl")
__supervisor_socket = Path("/run/moviepilot/supervisor.sock")
def on_config_changed(self):
"""配置变化后重新应用日志设置。"""
@@ -56,10 +52,17 @@ class SystemHelper(ConfigReloadMixin):
@staticmethod
def can_restart() -> bool:
"""
判断是否可以内部重启
"""
return is_docker() or SystemHelper._is_local_cli_managed() or (is_windows() and not is_frozen())
"""判断当前部署是否具备宿主无关的进程重启能力。"""
return (
(
is_docker()
and SystemHelper.__supervisor_config.exists()
and SystemHelper.__supervisorctl.exists()
and SystemHelper.__supervisor_socket.exists()
)
or SystemHelper._is_local_cli_managed()
or (is_windows() and not is_frozen())
)
@staticmethod
def _load_runtime_file(path: Path) -> Optional[dict]:
@@ -170,81 +173,31 @@ class SystemHelper(ConfigReloadMixin):
logger.info(f"已创建本地 CLI 重启任务,辅助进程 PID: {process.pid}")
@staticmethod
def _get_container_id() -> str:
"""
获取当前容器ID
"""
container_id = None
try:
with open("/proc/self/mountinfo", "r", encoding="utf-8", errors="replace") as f:
data = f.read()
index_resolv_conf = data.find("resolv.conf")
if index_resolv_conf != -1:
index_second_slash = data.rfind("/", 0, index_resolv_conf)
index_first_slash = data.rfind("/", 0, index_second_slash) + 1
container_id = data[index_first_slash:index_second_slash]
if len(container_id) < 20:
index_resolv_conf = data.find("/sys/fs/cgroup/devices")
if index_resolv_conf != -1:
index_second_slash = data.rfind(" ", 0, index_resolv_conf)
index_first_slash = (
data.rfind("/", 0, index_second_slash) + 1
)
container_id = data[index_first_slash:index_second_slash]
except Exception as e:
logger.debug(f"获取容器ID失败: {str(e)}")
return container_id.strip() if container_id else None
def _schedule_supervisor_restart() -> None:
"""延迟调用本地 supervisor,确保重启接口有机会完成响应。"""
def restart_backend() -> None:
command = [
str(SystemHelper.__supervisorctl),
"-c",
str(SystemHelper.__supervisor_config),
"restart",
"all",
]
try:
subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
start_new_session=True,
)
except OSError as err:
logger.error(f"调用 supervisor 重启后端失败: {err}")
@staticmethod
def _check_restart_policy() -> bool:
"""
检查当前容器是否配置了自动重启策略
"""
try:
# 获取当前容器ID
container_id = SystemHelper._get_container_id()
if not container_id:
return False
# 创建 Docker 客户端
client = docker.DockerClient(
base_url=get_runtime_setting('DOCKER_CLIENT_API')
)
# 获取容器信息
container = client.containers.get(container_id)
restart_policy = container.attrs.get('HostConfig', {}).get('RestartPolicy', {})
policy_name = restart_policy.get('Name', 'no')
# 检查是否有有效的重启策略
auto_restart_policies = ['always', 'unless-stopped', 'on-failure']
has_restart_policy = policy_name in auto_restart_policies
logger.info(f"容器重启策略: {policy_name}, 支持自动重启: {has_restart_policy}")
return has_restart_policy
except Exception as e:
logger.warning(f"检查重启策略失败: {str(e)}")
return False
@staticmethod
def _mark_docker_intentional_restart() -> None:
"""写入 Docker 主动重启标记,避免守护逻辑误判崩溃。"""
try:
SystemHelper.__docker_restart_intent_file.parent.mkdir(
parents=True, exist_ok=True
)
SystemHelper.__docker_restart_intent_file.write_text(
str(os.getpid()), encoding="utf-8"
)
except OSError as err:
logger.warning(f"写入内置重启标记失败: {err}")
@staticmethod
def _clear_docker_intentional_restart() -> None:
"""清理 Docker 主动重启标记。"""
try:
SystemHelper.__docker_restart_intent_file.unlink(missing_ok=True)
except OSError as err:
logger.warning(f"清理内置重启标记失败: {err}")
restart_timer = threading.Timer(0.5, restart_backend)
restart_timer.daemon = True
restart_timer.start()
@staticmethod
def _windows_restart() -> tuple[bool, str]:
@@ -269,9 +222,7 @@ class SystemHelper(ConfigReloadMixin):
@staticmethod
def restart() -> Tuple[bool, str]:
"""
执行Docker重启操作
"""
"""请求容器内 supervisor 重启受管的前后端进程。"""
if not is_frozen() and is_windows():
success, message = SystemHelper._windows_restart()
return success, message
@@ -287,28 +238,15 @@ class SystemHelper(ConfigReloadMixin):
logger.error(f"本地 CLI 重启失败: {str(err)}")
return False, f"本地 CLI 重启失败:{str(err)}"
try:
# 检查容器是否配置了自动重启策略
has_restart_policy = SystemHelper._check_restart_policy()
if has_restart_policy:
# 有重启策略,使用优雅退出方式
logger.info("检测到容器配置了自动重启策略,使用优雅重启方式...")
SystemHelper._mark_docker_intentional_restart()
# 启动优雅退出超时监控
SystemHelper._start_graceful_shutdown_monitor()
# 发送SIGTERM信号给当前进程,触发优雅停止
os.kill(os.getpid(), signal.SIGTERM)
return True, ""
else:
# 没有重启策略,使用Docker API强制重启
logger.info("容器未配置自动重启策略,使用Docker API重启...")
return SystemHelper._docker_api_restart()
except Exception as err:
logger.error(f"重启失败: {str(err)}")
SystemHelper._clear_docker_intentional_restart()
# 降级为Docker API重启
logger.warning("降级为Docker API重启...")
return SystemHelper._docker_api_restart()
if not (
SystemHelper.__supervisor_config.exists()
and SystemHelper.__supervisorctl.exists()
and SystemHelper.__supervisor_socket.exists()
):
return False, "容器内 supervisor 未安装"
logger.info("请求容器内 supervisor 重启后端服务")
SystemHelper._schedule_supervisor_restart()
return True, ""
@staticmethod
def upgrade_dev() -> Tuple[bool, str]:
@@ -326,67 +264,6 @@ class SystemHelper(ConfigReloadMixin):
return False, message
return True, "已安排 Dev 更新并重启"
@staticmethod
def _start_graceful_shutdown_monitor():
"""
启动唯一的优雅退出超时监控。
如果 180 秒内进程没有退出,则使用 Docker API 强制重启;重复重启请求
复用当前 monitor,避免并行触发多次容器重启。
"""
def monitor_thread():
try:
time.sleep(180)
logger.warning("优雅退出超时180秒,使用Docker API强制重启...")
try:
SystemHelper._docker_api_restart()
except Exception as e:
logger.error(f"强制重启失败: {str(e)}")
finally:
with SystemHelper.__graceful_shutdown_monitor_lock:
if (
SystemHelper.__graceful_shutdown_monitor
is threading.current_thread()
):
SystemHelper.__graceful_shutdown_monitor = None
with SystemHelper.__graceful_shutdown_monitor_lock:
running = SystemHelper.__graceful_shutdown_monitor
if running is not None and running.is_alive():
logger.debug("优雅退出超时监控已在运行,跳过重复启动")
return
thread = threading.Thread(
target=monitor_thread,
name="MoviePilot-GracefulRestartFallback",
daemon=True,
)
SystemHelper.__graceful_shutdown_monitor = thread
try:
thread.start()
except BaseException:
SystemHelper.__graceful_shutdown_monitor = None
raise
@staticmethod
def _docker_api_restart() -> Tuple[bool, str]:
"""
使用Docker API重启容器,并尝试优雅停止
"""
try:
# 创建 Docker 客户端
client = docker.DockerClient(
base_url=get_runtime_setting('DOCKER_CLIENT_API')
)
container_id = SystemHelper._get_container_id()
if not container_id:
return False, "获取容器ID失败!"
# 重启容器
client.containers.get(container_id).restart()
return True, ""
except Exception as docker_err:
return False, f"重启时发生错误:{str(docker_err)}"
def set_system_modified(self):
"""
设置系统已修改标志
+4 -2
View File
@@ -90,6 +90,7 @@ RUN apt-get update \
nano \
unar \
openssl \
supervisor \
postgresql-client-18 \
libchromaprint-tools \
libjemalloc2 \
@@ -278,7 +279,8 @@ RUN mkdir -p \
&& cp -f launcher.sh /bundle/rootfs/entrypoint.sh \
&& cp -f nginx.common.conf /bundle/rootfs/etc/nginx/common.conf \
&& cp -f nginx.template.conf /bundle/rootfs/etc/nginx/nginx.template.conf \
&& cp -f docker_http_proxy.conf /bundle/rootfs/etc/nginx/docker_http_proxy.conf \
&& mkdir -p /bundle/rootfs/etc/supervisor \
&& cp -f supervisord.conf /bundle/rootfs/etc/supervisor/supervisord.conf \
&& printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' 'cd /app' 'exec "${VENV_PATH:-/opt/venv}/bin/python3" -m app.cli "$@"' > /bundle/rootfs/usr/local/bin/moviepilot \
&& bash -n /bundle/rootfs/entrypoint.sh \
&& for control_script in /bundle/rootfs/usr/local/lib/moviepilot/control/*.sh; do bash -n "${control_script}" || exit 1; done \
@@ -289,7 +291,7 @@ RUN mkdir -p \
FROM prepare_package AS final
ENV LD_PRELOAD="/usr/local/lib/libjemalloc.so" \
MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE="true"
MOVIEPILOT_DOCKER_SUPERVISOR="true"
# 引入支持 amr 编码的静态 ffmpeg
COPY --from=ffmpeg /ffmpeg /ffprobe /usr/local/bin/
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -e
cd /app
umask "${UMASK:-000}"
if [ "${START_NOGOSU:-false}" = "true" ]; then
exec "${VENV_PATH:-/opt/venv}/bin/python3" app/main.py
fi
exec gosu moviepilot:moviepilot "${VENV_PATH:-/opt/venv}/bin/python3" app/main.py
-44
View File
@@ -1,44 +0,0 @@
worker_processes 1;
user root;
daemon on;
pid /var/run/nginx_proxy.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
upstream docker {
server unix:/var/run/docker.sock fail_timeout=0;
}
server {
listen 127.0.0.1:38379;
server_name localhost;
access_log /dev/stdout combined;
error_log /dev/stdout;
location / {
proxy_pass http://docker;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 10m;
client_body_buffer_size 128k;
proxy_connect_timeout 90;
proxy_send_timeout 120;
proxy_read_timeout 120;
proxy_buffer_size 4k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
proxy_temp_file_write_size 64k;
}
}
}
+12 -173
View File
@@ -20,8 +20,6 @@ function WARN() {
echo -e "${WARN} ${1}"
}
ENTRYPOINT_START_TIME="$(date +%s)"
function normalize_env_value() {
printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]'
}
@@ -47,42 +45,6 @@ function apply_package_cache_env() {
mkdir -p "${UV_CACHE_DIR}"
}
function wait_backend_ready() {
local entrypoint_start_time="${1:-$(date +%s)}"
local backend_start_time="${2:-$(date +%s)}"
local python_pid="${3:-}"
local backend_port="${PORT:-3001}"
local web_port="${NGINX_PORT:-3000}"
local timeout="${MOVIEPILOT_BACKEND_READY_TIMEOUT:-300}"
local ready_url="http://127.0.0.1:${backend_port}/health/ready"
local deadline
if ! [[ "${timeout}" =~ ^[0-9]+$ ]] || [ "$((10#${timeout}))" -le 0 ]; then
WARN "→ MOVIEPILOT_BACKEND_READY_TIMEOUT=${timeout} 无效,使用默认 300 秒。"
timeout=300
else
timeout=$((10#${timeout}))
fi
deadline=$(( $(date +%s) + timeout ))
while [ "$(date +%s)" -lt "${deadline}" ]; do
if [ -n "${python_pid}" ] && ! kill -0 "${python_pid}" >/dev/null 2>&1; then
WARN "→ 后端服务启动完成探测已停止:后端进程已退出。"
return 1
fi
if curl -fsS --max-time 2 "${ready_url}" >/dev/null 2>&1; then
local now
now="$(date +%s)"
INFO "→ MoviePilot Web 已可访问,启动总耗时 $(( now - entrypoint_start_time )) 秒,后端就绪耗时 $(( now - backend_start_time )) 秒,后端端口 ${backend_port},前端端口 ${web_port}"
return 0
fi
sleep 1
done
WARN "→ 后端服务启动完成探测超时,已等待 ${timeout} 秒,后端端口 ${backend_port},继续等待进程日志..."
return 1
}
# 环境变量补全
# 优先级: 系统环境变量 -> .env 文件 (即使为空字符串) -> 预设默认值
# 精准适配 Python 端 set_key (quote_mode="always", 单引号包裹, \' 转义)
@@ -100,7 +62,6 @@ function load_config_from_app_env() {
["PROXY_HOST"]=""
["GITHUB_TOKEN"]=""
["MOVIEPILOT_AUTO_UPDATE"]="false"
["MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE"]="true"
["MOVIEPILOT_FORCE_CHOWN"]="false"
["MOVIEPILOT_SAFE_MODE"]="false"
["BROWSER_EMULATION"]="cloakbrowser"
@@ -250,77 +211,6 @@ EOF
envsubst '${NGINX_PORT}${PORT}${NGINX_CLIENT_MAX_BODY_SIZE}${HTTPS_SERVER_CONF}' < /etc/nginx/nginx.template.conf > /etc/nginx/nginx.conf
}
# 优雅退出
function graceful_exit() {
local exit_code=${1:-0}
local reason=${2:-python_exit}
if [ "$reason" = "signal" ]; then
INFO "→ 收到停止信号,执行精准清理程序..."
elif [ "$reason" = "intentional_restart" ]; then
INFO "→ 检测到内置重启流程,执行清理程序..."
else
INFO "→ 主进程已退出 (代码: $exit_code),执行清理程序..."
fi
# 第一步:停止前端 Nginx
# 默认配置启动的 Nginx,默认 PID 在 /var/run/nginx.pid
INFO "→ [1/3] 正在关闭前端 Nginx..."
nginx -c /etc/nginx/nginx.conf -s stop 2>/dev/null || true
# 第二步:等待 Python 退出
# 由于使用了 tini -g,Python 已经收到了信号,我们只需等待
if [ -n "$PYTHON_PID" ] && ps -p "$PYTHON_PID" > /dev/null; then
INFO "→ [2/3] 正在等待 Python (PID: $PYTHON_PID) 完成清理..."
# 这里的 wait 会阻塞,直到 Python 真正退出
wait "$PYTHON_PID" 2>/dev/null || true
fi
# 第三步:最后关闭 Docker Proxy
# 必须指定配置文件路径,否则 nginx -s stop 找不到它
INFO "→ [3/3] 后端已安全退出,正在关闭 Docker Proxy..."
if [ -S "/var/run/docker.sock" ]; then
nginx -c /etc/nginx/docker_http_proxy.conf -s stop 2>/dev/null || true
fi
# 根据退出码判断最终日志性质
# 0: 正常退出
# 130/143: 被系统信号终止(通常也视为预期的清理退出)
if [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 130 ] || [ "$exit_code" -eq 143 ] || [ "$reason" = "intentional_restart" ]; then
INFO "→ 所有服务已按序清理,容器正常退出 (ExitCode: $exit_code)。"
else
# 非预期退出码,使用 ERROR 级别并加重提示
ERROR "→ 清理完成,但主进程检测到异常退出 (ExitCode: $exit_code)"
fi
exit "$exit_code"
}
# 后端异常退出时默认保留容器,避免无法 docker exec 进入容器运行 doctor。
function diagnostic_keepalive() {
local exit_code=${1:-1}
local keepalive
keepalive="$(normalize_env_value "${MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE:-true}")"
if [ "${keepalive}" = "false" ] || [ "${keepalive}" = "0" ] || [ "${keepalive}" = "no" ]; then
graceful_exit "$exit_code" "python_exit"
fi
ERROR "→ 后端主进程异常退出 (ExitCode: ${exit_code}),容器将保持运行以便执行 moviepilot doctor。"
WARN "→ 可运行:docker exec <container> moviepilot doctor"
WARN "→ 如需恢复旧行为,可设置 MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=false。"
if [ "${START_NOGOSU:-false}" = "true" ]; then
"${VENV_PATH}/bin/python3" -m app.cli doctor || true
else
gosu moviepilot:moviepilot "${VENV_PATH}/bin/python3" -m app.cli doctor || true
fi
while true; do
sleep 3600 &
wait $! || true
done
}
# 启动前先检查后端核心依赖是否仍然可导入。
# 插件依赖和主程序共用同一套 venv 时,历史安装记录可能已经污染环境,
# 这里优先在真正拉起后端前做一次自愈,避免容器反复起不来。
@@ -336,18 +226,18 @@ function ensure_backend_runtime_dependencies() {
WARN "→ 检测到后端核心依赖异常,开始尝试恢复主程序依赖..."
if ! configure_package_route; then
ERROR "→ 无法选择可用的主程序依赖源,后端无法启动。"
diagnostic_keepalive 1
exit 1
fi
PACKAGE_ROUTE_READY="true"
INFO "依赖源:${PACKAGE_LOG}"
if ! sync_project_dependencies_for "/app" > /dev/stdout 2> /dev/stderr; then
ERROR "→ 自动恢复主程序依赖失败,后端无法启动。"
diagnostic_keepalive 1
exit 1
fi
if ! "${VENV_PATH}/bin/python3" -m "${probe_module}" >/dev/null 2>&1; then
ERROR "→ 主程序依赖恢复后仍然异常,后端无法启动。"
diagnostic_keepalive 1
exit 1
fi
INFO "→ 已自动恢复主程序依赖,继续启动后端。"
@@ -551,8 +441,8 @@ cd /
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"
if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ]; then
if ! recover_pending_update; then
ERROR "→ 上一次容器更新未能恢复,容器将保持运行以便执行 moviepilot doctor。"
diagnostic_keepalive 1
ERROR "→ 上一次容器更新未能恢复,停止启动。"
exit 1
fi
if [ "${UPDATE_RECOVERY_COMPLETED:-false}" = "true" ]; then
INFO "→ 已恢复到更新前版本,本次启动跳过自动更新。"
@@ -567,8 +457,8 @@ if [ "${ONE_SHOT_DEV_UPDATE}" = "true" ]; then
MOVIEPILOT_AUTO_UPDATE="${MOVIEPILOT_AUTO_UPDATE_ORIGINAL}"
fi
if [ "${UPDATE_RECOVERY_REQUIRED:-false}" = "true" ]; then
ERROR "→ 容器更新回滚未完成,容器将保持运行以便执行 moviepilot doctor。"
diagnostic_keepalive 1
ERROR "→ 容器更新回滚未完成,停止启动。"
exit 1
fi
maybe_reexec_control_bundle
@@ -581,7 +471,7 @@ groupmod -o -g "${PGID}" moviepilot
usermod -o -u "${PUID}" moviepilot
# 启动前优先确认主运行环境仍然健康,避免插件依赖污染导致服务直接起不来。
ensure_backend_runtime_dependencies
ensure_backend_runtime_dependencies || exit 1
# 依赖阶段恢复会保留当前程序,待自愈成功后再清理旧代际备份和事务标记。
if [ "${UPDATE_RECOVERY_BLOCKED:-false}" = "true" ]; then
@@ -608,58 +498,7 @@ ensure_browser_kernel
# 证书管理
source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/cert.sh"
# 启动前端nginx服务
INFO "→ 启动前端nginx服务..."
nginx
# 捕获信号并跳转到函数
trap 'graceful_exit 130 "signal"' SIGINT
trap 'graceful_exit 143 "signal"' SIGTERM
# 启动docker http proxy nginx
if [ -S "/var/run/docker.sock" ]; then
INFO "→ 启动 Docker Proxy..."
nginx -c /etc/nginx/docker_http_proxy.conf
# 上面nginx是通过root启动的,会将目录权限改成root,所以需要重新再设置一遍权限
chown -R moviepilot:moviepilot \
/var/lib/nginx \
/var/log/nginx
fi
# 设置后端服务权限掩码
umask "${UMASK}"
# 启动后端服务
INFO "→ 启动后端服务..."
BACKEND_START_TIME="$(date +%s)"
if [ "${START_NOGOSU:-false}" = "true" ]; then
"${VENV_PATH}/bin/python3" app/main.py > /dev/stdout 2> /dev/stderr &
else
gosu moviepilot:moviepilot "${VENV_PATH}/bin/python3" app/main.py > /dev/stdout 2> /dev/stderr &
fi
PYTHON_PID=$!
wait_backend_ready "${ENTRYPOINT_START_TIME}" "${BACKEND_START_TIME}" "${PYTHON_PID}" &
# 等待 Python 进程退出。
# 如果收到信号,trap 会中断 wait,并执行 graceful_exit。
# 如果 Python 正常退出,wait 会结束,然后我们手动调用 graceful_exit。
wait "$PYTHON_PID" 2>/dev/null
exit_code=$?
# 如果 Python 自己退出了(非信号触发),执行清理
INTENTIONAL_RESTART_FLAG="${CONFIG_DIR}/temp/moviepilot.intentional_restart"
if [ -f "${INTENTIONAL_RESTART_FLAG}" ]; then
rm -f "${INTENTIONAL_RESTART_FLAG}"
restart_exit_code="$exit_code"
if [ "$restart_exit_code" -eq 0 ]; then
restart_exit_code=1
fi
WARN "→ 检测到内置手动重启标记,退出容器并交给 Docker 重启策略处理..."
graceful_exit "$restart_exit_code" "intentional_restart"
fi
if [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 130 ] || [ "$exit_code" -eq 143 ]; then
graceful_exit "$exit_code" "python_exit"
fi
diagnostic_keepalive "$exit_code"
# supervisord 常驻前台并统一托管 Nginx 与后端;容器停止信号由它转发给两个进程组。
install -d -m 0755 /run/moviepilot
INFO "→ 启动容器进程 supervisor..."
exec /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf
+47
View File
@@ -0,0 +1,47 @@
[unix_http_server]
file=/run/moviepilot/supervisor.sock
chmod=0770
chown=root:moviepilot
[supervisord]
nodaemon=true
logfile=/dev/null
pidfile=/run/moviepilot/supervisord.pid
childlogdir=/tmp
[rpcinterface:supervisor]
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///run/moviepilot/supervisor.sock
[program:moviepilot-nginx]
command=/usr/sbin/nginx -g "daemon off;" -c /etc/nginx/nginx.conf
priority=10
autostart=true
autorestart=true
startsecs=1
stopsignal=TERM
stopwaitsecs=30
stopasgroup=true
killasgroup=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:moviepilot-backend]
command=/bin/bash %(ENV_MP_CONTROL_DIR)s/backend.sh
directory=/app
priority=20
autostart=true
autorestart=true
startsecs=1
stopsignal=TERM
stopwaitsecs=120
stopasgroup=true
killasgroup=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+18 -47
View File
@@ -12,13 +12,14 @@
| --- | --- |
| `docker/Dockerfile` | 构建 Python 环境、后端、前端、插件、站点资源和容器控制面;声明 `tini` 入口与 readiness 健康检查。 |
| `docker/launcher.sh` | 构建时复制为镜像根目录 `/entrypoint.sh`;以 root 校验、选择并固化本轮启动使用的控制脚本。 |
| `docker/entrypoint.sh` | 真正的容器启动编排器;加载配置,驱动更新、权限、浏览器证书、Nginx、后端和退出处理。 |
| `docker/entrypoint.sh` | 真正的容器启动编排器;加载配置,驱动更新、权限、浏览器证书准备,最后启动 supervisor。 |
| `docker/backend.sh` | supervisor 托管的后端进程命令,负责工作目录、权限和 Python 进程。 |
| `docker/supervisord.conf` | 容器内 supervisor 配置,同时托管 Nginx 和后端。 |
| `docker/update.sh` | 被 `entrypoint.sh` source;处理未完成更新恢复、已准备 Release 安装、Dev 更新、依赖同步和载荷事务。 |
| `docker/browser.sh` | 被 `entrypoint.sh` source;选择持久化 CloakBrowser 缓存、校正权限并按需安装浏览器内核。 |
| `docker/cert.sh` | 被 `entrypoint.sh` source;校验证书、按需安装 acme.sh、签发证书并配置续期任务。 |
| `docker/nginx.template.conf` | 由环境变量渲染为 `/etc/nginx/nginx.conf`,提供前端静态文件、API 和 SSE 反向代理。 |
| `docker/nginx.common.conf` | HTTP/HTTPS server 共用的前端、API、SSE 和静态资源规则。 |
| `docker/docker_http_proxy.conf` | 挂载 Docker Socket 时启动的本地只监听代理,后端通过 `127.0.0.1:38379` 访问 Docker API。 |
## 2. 总体调用链
@@ -41,8 +42,9 @@ Docker
-> 准备 CloakBrowser 内核
-> source cert.sh
-> 启动前端 Nginx
-> 可选启动 Docker Socket 代理
-> gosu moviepilot python3 app/main.py
-> supervisord
-> Nginx
-> gosu moviepilot python3 app/main.py
-> Uvicorn/FastAPI lifespan
-> 数据库迁移和全部生命周期组件
-> /health/ready 返回 200
@@ -305,10 +307,11 @@ entrypoint 使用 `PUID`、`PGID` 修改镜像内 `moviepilot` 用户和组。
自动签发证书保存到 `/config/certs/<domain>/`,并维护 `/config/certs/latest` 符号链接。存在 `cron` 时,
脚本写入 `/etc/cron.d/acme`,每天 03:00 执行续期检查。续期任务配置失败不会阻断已有证书启动。
### 7.2 Nginx 和 Docker Proxy
### 7.2 Nginx 和 supervisor
证书检查完成后启动主 Nginx。若 `/var/run/docker.sock` 是 Unix Socket,再使用独立配置启动 root Nginx,
监听 `127.0.0.1:38379` 代理 Docker API,之后重新修正 Nginx 目录权限
证书检查完成后entrypoint 以前台模式启动 supervisor。supervisor 同时托管 `moviepilot-nginx`
`moviepilot-backend`,两者异常退出时自动拉起。控制 socket 位于 `/run/moviepilot/supervisor.sock`权限
`root:moviepilot``0770`,后端运行用户可访问;镜像不再挂载或代理 Docker Socket。
## 8. Python 后端启动
@@ -379,60 +382,30 @@ Transfer、Workflow 和 MoviePilot Server 服务,注册站点资源版本读
所有启用的 fail-fast 启动组件成功后,lifespan 才把应用标记为 `ready`
entrypoint 同时在后台每秒请求:
```text
http://127.0.0.1:${PORT}/health/ready
```
成功后输出容器总启动耗时和后端就绪耗时。默认等待 300 秒,可通过
`MOVIEPILOT_BACKEND_READY_TIMEOUT` 调整。该等待任务只负责日志,不替代 Docker 健康状态。
Dockerfile 的 `HEALTHCHECK` 每 30 秒请求同一地址:数据库迁移和完整 lifespan 成功后返回 200;启动、
Dockerfile 的 `HEALTHCHECK` 每 30 秒请求 `http://127.0.0.1:${PORT}/health/ready`:数据库迁移和完整 lifespan 成功后返回 200;启动、
失败或关停阶段返回 503。`/health/live` 只表示进程和事件循环仍可响应。
## 9. 异常、重启和退出
### 9.1 信号退出
entrypoint 捕获 SIGINT/SIGTERM 后按顺序:
容器的 supervisor 接收 SIGINT/SIGTERM 后按顺序停止受管进程
1. 停止前端 Nginx
2. 等待 Python 完成 lifespan 逆序关停;
3. 停止 Docker Proxy Nginx
4. 使用原退出码退出容器。
3. supervisor 退出,容器按原退出状态结束。
Python lifespan 会先撤销 readiness,再按组件声明的 `stop_order` 停止工作流、命令、插件、事件、Agent、
整理任务、模块服务、数据库和日志等资源。启动中途失败时,只清理已经启动或正在启动的组件。
### 9.2 应用内重启
应用请求重启时写入:
应用请求重启时,通过本地 `supervisorctl restart all` 同时重启 Nginx 和后端。supervisor 先向旧进程组发送
SIGTERM,等待后端完成 lifespan 关停,再拉起新进程。该过程不访问 Docker API,也不依赖 Docker restart policy。
```text
/config/temp/moviepilot.intentional_restart
```
### 9.3 异常诊断
Python 退出后,entrypoint 删除标记,并确保容器以非零状态退出,把真正的重新创建/重启交给 Docker
restart policy。entrypoint 本身不在容器内递归拉起第二个 Python 主进程。
### 9.3 异常诊断保活
镜像默认:
```text
MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=true
```
后端非预期退出、依赖恢复失败或更新回滚无法完成时,entrypoint 会运行一次 `moviepilot doctor`,然后
通过长时间 sleep 保持容器存活,便于执行:
```shell
docker exec -it <container> moviepilot doctor
```
设置 `MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=false` 可恢复异常后直接退出容器的行为。保活只保留诊断
入口,不代表服务健康;Docker `HEALTHCHECK` 仍会保持失败。
后端异常退出由 supervisor 自动拉起;启动前的依赖、更新或迁移失败会直接终止容器启动并保留日志。
## 10. 关键运行文件
@@ -451,7 +424,7 @@ docker exec -it <container> moviepilot doctor
| `/public.__update_previous__` | 更新前前端备份。 |
| `/config/temp/moviepilot-update/` | 后台下载的 Release/资源包及安装状态。 |
| `/config/temp/moviepilot.pending_dev_update` | 单次 Dev 更新请求。 |
| `/config/temp/moviepilot.intentional_restart` | 应用内重启请求。 |
| `/run/moviepilot/supervisor.sock` | 容器内 supervisor 控制 socket,权限为 `root:moviepilot``0770`。 |
| `/config/certs/latest/` | Nginx 使用的稳定证书路径。 |
## 11. 关键环境变量
@@ -465,9 +438,7 @@ docker exec -it <container> moviepilot doctor
| `NGINX_PORT` | `3000` | HTTP 前端入口。 |
| `MOVIEPILOT_AUTO_UPDATE` | `false` | 只有 `dev` 会触发启动时分支更新;稳定版使用准备清单。 |
| `MOVIEPILOT_SAFE_MODE` | `false` | 跳过普通模式专属的插件及后台控制面。 |
| `MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE` | `true` | 后端异常后是否保留容器供 doctor 诊断。 |
| `MOVIEPILOT_FORCE_CHOWN` | `false` | 是否执行大范围递归权限修复。 |
| `MOVIEPILOT_BACKEND_READY_TIMEOUT` | `300` | entrypoint readiness 日志等待秒数。 |
| `PACKAGE_CACHE_ROOT` | `/config/.cache` | 包管理缓存根目录。 |
| `UV_CACHE_DIR` | `/config/.cache/uv` | uv 缓存目录。 |
| `PIP_PROXY` | 空 | Python 包索引镜像。 |
+4 -9
View File
@@ -76,21 +76,16 @@ MoviePilot V3 的全功能模式只支持 `API_WORKERS=1`。配置更大的值
lifespan 都会在数据库迁移及后台任务启动前拒绝运行,Doctor 同时给出失败项。安全模式因跳过
控制面而允许临时使用多 worker,但 Doctor 会将其标记为降级;故障排除后应恢复单 worker。
## Docker 诊断保活
## Docker 诊断
Docker 镜像默认设置 `MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=true`。当后端进程非正常退出时,entrypoint 不会立刻退出容器,而是打印一次 doctor 报告并保持容器运行,方便执行:
Docker 镜像由容器内 supervisor 托管 Nginx 和后端进程,后端异常退出时 supervisor 会自动拉起。需要诊断时可执行:
```shell
docker exec -it <container> moviepilot doctor
```
如果需要恢复旧行为,可设置:
```env
MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=false
```
Dockerfile 同时提供 `HEALTHCHECK`,用于标记容器健康状态。是否自动重启仍由 Docker Compose、NAS 平台或 Docker restart policy 决定。
Dockerfile 同时提供 `HEALTHCHECK`,用于标记容器健康状态。应用进程重启由容器内 supervisor 负责,
Docker restart policy 只影响整个容器的恢复。
镜像健康检查与 entrypoint 的后端就绪等待统一访问公开的 `/health/ready`:只有数据库迁移、
Alembic head 校验和生命周期启动完成后才返回 200;启动失败或关停时返回 503。单纯确认进程
+1 -1
View File
@@ -28,7 +28,6 @@ dependencies = [
"dateparser~=1.4.0",
"ddgs~=9.14.4",
"discord.py==2.7.1",
"docker~=7.1.0",
"fast-bencode~=1.1.8",
"fastapi~=0.141.1",
"google-genai~=2.8.0",
@@ -102,6 +101,7 @@ dependencies = [
[dependency-groups]
dev = [
"cython~=3.2.5",
"docker~=7.1.0",
"mypy~=1.18.2",
"pylint~=4.0.6",
"pytest~=9.0.3",
-2
View File
@@ -79,7 +79,6 @@ LAB_ENVIRONMENT = {
"DEBUG": "false",
"MOVIEPILOT_SAFE_MODE": "false",
"MOVIEPILOT_AUTO_UPDATE": "false",
"MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE": "false",
"AUTO_UPDATE_RESOURCE": "false",
"PLUGIN_MARKET": "",
"PLUGIN_AUTO_RELOAD": "false",
@@ -923,7 +922,6 @@ def startup_sample(client, args: argparse.Namespace, image: str, variant: str, i
try:
volume = client.volumes.create(name=volume_name, labels=labels)
environment = dict(LAB_ENVIRONMENT)
environment["MOVIEPILOT_BACKEND_READY_TIMEOUT"] = str(args.ready_timeout)
container = client.containers.create(
image,
name=name,
-2
View File
@@ -588,8 +588,6 @@ def fixed_environment(args: argparse.Namespace, instrument: bool) -> dict[str, s
"DEBUG": "false",
"MOVIEPILOT_SAFE_MODE": "false",
"MOVIEPILOT_AUTO_UPDATE": "false",
"MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE": "false",
"MOVIEPILOT_BACKEND_READY_TIMEOUT": str(args.ready_timeout),
"AUTO_UPDATE_RESOURCE": "false",
"PLUGIN_MARKET": "",
"PLUGIN_AUTO_RELOAD": "false",
-6
View File
@@ -588,12 +588,6 @@
},
"target": "asyncio.to_thread"
},
"app/runtime/state.py:threading.Thread": {
"owners": {
"SystemHelper._start_graceful_shutdown_monitor": 1
},
"target": "threading.Thread"
},
"app/runtime/tasks.py:asyncio.create_task": {
"owners": {
"TaskRegistry.create": 1
-9
View File
@@ -209,15 +209,6 @@
{"source": "app.startup.lifecycle", "target": "urllib3", "kind": "raw_transport", "fingerprint": "cb6f0a314aeb1e2d3e76c240aa20460ac0c36d9f5c18c1a6ea3170f64dd3366b"}
]
},
{
"classification": "approved_exception",
"reason_code": "daemon_control_plane",
"owner": "$source",
"reason": "runtime state 连接配置指定的 Docker daemon 控制面,并由该进程能力 owner 收口。",
"facts": [
{"source": "app.runtime.state", "target": "docker", "kind": "network_sdk", "fingerprint": "20a91ec521f7dfe6a0153dfd8ea49c4bac7f0a16b55f1c0655dfb33f54a01215"}
]
},
{
"classification": "approved_exception",
"reason_code": "test_network_guard",
-4
View File
@@ -78,9 +78,6 @@ FROZEN_EGRESS_EDGES_BY_REASON = {
("app.modules.zspace.zspace", "requests"),
("app.startup.lifecycle", "urllib3"),
},
"daemon_control_plane": {
("app.runtime.state", "docker"),
},
"test_network_guard": {
("app.testing.network", "socket.getaddrinfo"),
},
@@ -141,7 +138,6 @@ FROZEN_EGRESS_FINGERPRINT_BY_EDGE = {
("app.modules.webpush", "pywebpush"): "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76",
("app.modules.wechat.wechatbot", "websocket"): "1bae78270eadce0571e2caaa111a5c0a9065ba2da97ebb26b8a5b76d3ed5eef6",
("app.modules.zspace.zspace", "requests"): "9df3fd27b9696d45a72e7c8f67b5a9ad79a7371d1fe690bbaa17485bd1960d51",
("app.runtime.state", "docker"): "20a91ec521f7dfe6a0153dfd8ea49c4bac7f0a16b55f1c0655dfb33f54a01215",
("app.startup.lifecycle", "urllib3"): "cb6f0a314aeb1e2d3e76c240aa20460ac0c36d9f5c18c1a6ea3170f64dd3366b",
("app.testing.network", "socket.getaddrinfo"): "ebf33718b54c81e1f575401da4a0bef5aa0e1ddc284e201f557897feeca71c6d",
}
+16
View File
@@ -1018,6 +1018,22 @@ def test_entrypoint_does_not_keep_retired_package_command_wrapper() -> None:
assert "function run_package_command()" not in entrypoint
def test_entrypoint_delegates_restart_to_external_supervisor() -> None:
"""容器入口使用内部 supervisor 托管前后端且不包含 Docker 控制面。"""
entrypoint = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
dockerfile = (ROOT / "docker" / "Dockerfile").read_text(encoding="utf-8")
supervisor = (ROOT / "docker" / "supervisord.conf").read_text(encoding="utf-8")
assert "docker_http_proxy" not in entrypoint
assert "/var/run/docker.sock" not in entrypoint
assert "docker_http_proxy" not in dockerfile
assert "exec /usr/bin/supervisord -n" in entrypoint
assert "supervisor" in dockerfile
assert "[program:moviepilot-nginx]" in supervisor
assert "[program:moviepilot-backend]" in supervisor
assert supervisor.count("autorestart=true") == 2
@pytest.mark.parametrize(
("pyproject_changed", "lock_changed", "expected_route_calls", "expected_sync_calls"),
(
+11 -63
View File
@@ -540,59 +540,6 @@ def test_site_resource_permissions_are_repaired_even_when_owner_matches(tmp_path
assert not any(line.startswith("-R ") and f"{tmp_path}/public" in line for line in lines)
def test_backend_ready_log_uses_configured_ports(tmp_path: Path) -> None:
curl_log = tmp_path / "curl.log"
output = _run_entrypoint_case(
tmp_path,
"""
INFO() { printf '[INFO] %s\\n' "$1"; }
curl() {
printf '%s\\n' "$*" > "${CURL_LOG}"
return 0
}
PORT=4321 NGINX_PORT=8765 wait_backend_ready 1 2 "$$"
""",
env={"CURL_LOG": str(curl_log)},
)
assert curl_log.read_text(encoding="utf-8") == (
"-fsS --max-time 2 http://127.0.0.1:4321/health/ready\n"
)
assert "MoviePilot Web 已可访问" in output
assert "后端就绪耗时" in output
assert "后端端口 4321" in output
assert "前端端口 8765" in output
def test_backend_ready_timeout_falls_back_to_default_for_invalid_value(tmp_path: Path) -> None:
output = _run_entrypoint_case(
tmp_path,
"""
WARN() { printf '[WARN] %s\\n' "$1"; }
curl() { return 1; }
MOVIEPILOT_BACKEND_READY_TIMEOUT=invalid wait_backend_ready 1 2 999999 || true
""",
)
assert "MOVIEPILOT_BACKEND_READY_TIMEOUT=invalid 无效,使用默认 300 秒" in output
assert "后端服务启动完成探测已停止:后端进程已退出" in output
def test_backend_ready_timeout_accepts_leading_zero_decimal(tmp_path: Path) -> None:
output = _run_entrypoint_case(
tmp_path,
"""
INFO() { printf '[INFO] %s\\n' "$1"; }
WARN() { printf '[WARN] %s\\n' "$1"; }
curl() { return 0; }
MOVIEPILOT_BACKEND_READY_TIMEOUT=08 wait_backend_ready 1 2 "$$"
""",
)
assert "MOVIEPILOT_BACKEND_READY_TIMEOUT=08 无效" not in output
assert "MoviePilot Web 已可访问" in output
def test_backend_dependency_recovery_uses_runtime_profile_sync(tmp_path: Path) -> None:
"""启动自愈必须复用按当前解释器选择依赖组的同步入口。"""
venv_bin = tmp_path / "venv" / "bin"
@@ -633,14 +580,15 @@ def test_backend_dependency_recovery_uses_runtime_profile_sync(tmp_path: Path) -
assert marker.exists()
def test_backend_failure_keepalive_contract_is_explicit() -> None:
"""后端异常默认保活诊断,显式关闭后才退出容器"""
content = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
function = content.split(
"function diagnostic_keepalive() {", 1
)[1].split("\n}", 1)[0]
def test_supervisor_manages_backend_and_nginx() -> None:
"""后端异常恢复和应用重启都由容器内 supervisor 托管"""
entrypoint = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8")
supervisor = (ROOT / "docker" / "supervisord.conf").read_text(encoding="utf-8")
assert 'MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE:-true' in function
assert 'if [ "${keepalive}" = "false" ]' in function
assert 'graceful_exit "$exit_code" "python_exit"' in function
assert "容器将保持运行以便执行 moviepilot doctor" in function
assert "supervisord -n" in entrypoint
assert "[program:moviepilot-nginx]" in supervisor
assert "[program:moviepilot-backend]" in supervisor
assert "file=/run/moviepilot/supervisor.sock" in supervisor
assert "chmod=0770" in supervisor
assert "chown=root:moviepilot" in supervisor
assert supervisor.count("autorestart=true") == 2
+31 -81
View File
@@ -6,7 +6,6 @@ import struct
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, call, patch
@@ -15,7 +14,7 @@ import psutil
import pytest
from app.runtime.state import SystemHelper
from app.runtime.config import ConfigModel, Settings, settings
from app.runtime.config import ConfigModel, Settings
from app.adapters.system.host import SystemUtils
@@ -86,94 +85,45 @@ def test_execute_with_subprocess_reports_empty_failure_output():
assert "无标准输出或错误输出" in message
def test_docker_restart_policy_marks_intent_before_sigterm():
"""Docker 优雅重启前应写入意图标记,避免 entrypoint 误进入 doctor 保活"""
with tempfile.TemporaryDirectory() as temp_dir:
original_config_dir = settings.CONFIG_DIR
original_intent_file = SystemHelper._SystemHelper__docker_restart_intent_file
settings.CONFIG_DIR = temp_dir
SystemHelper._SystemHelper__docker_restart_intent_file = (
settings.TEMP_PATH / "moviepilot.intentional_restart"
)
try:
with patch("app.runtime.state.is_docker", return_value=True), \
patch.object(SystemHelper, "_check_restart_policy", return_value=True), \
patch.object(SystemHelper, "_start_graceful_shutdown_monitor"), \
patch("app.runtime.state.os.kill") as kill_mock:
ret, msg = SystemHelper.restart()
def test_docker_restart_delegates_to_supervisor():
"""容器内重启只委托 supervisor,不向当前进程或 Docker daemon 发信号"""
with patch("app.runtime.state.is_docker", return_value=True), \
patch.object(SystemHelper, "_SystemHelper__supervisor_config") as supervisor_config, \
patch.object(SystemHelper, "_SystemHelper__supervisorctl") as supervisorctl, \
patch.object(SystemHelper, "_SystemHelper__supervisor_socket") as supervisor_socket, \
patch.object(SystemHelper, "_schedule_supervisor_restart") as restart_mock, \
patch("app.runtime.state.os.kill") as kill_mock:
supervisor_config.exists.return_value = True
supervisorctl.exists.return_value = True
supervisor_socket.exists.return_value = True
ret, msg = SystemHelper.restart()
assert ret
assert msg == ""
assert (settings.TEMP_PATH / "moviepilot.intentional_restart").exists()
kill_mock.assert_called_once()
finally:
SystemHelper._SystemHelper__docker_restart_intent_file = original_intent_file
settings.CONFIG_DIR = original_config_dir
assert ret
assert msg == ""
restart_mock.assert_called_once_with()
kill_mock.assert_not_called()
def test_graceful_shutdown_monitor_has_single_owner_and_releases_it(monkeypatch):
"""重复重启请求应共享唯一兜底线程,线程结束后必须释放 owner"""
sleep_started = threading.Event()
release_sleep = threading.Event()
restart = MagicMock(return_value=(True, ""))
monitor_attr = "_SystemHelper__graceful_shutdown_monitor"
original_monitor = getattr(SystemHelper, monitor_attr)
thread = None
def test_supervisor_restart_command_restarts_frontend_and_backend(monkeypatch):
"""延迟任务必须通过本地 supervisor 同时重启前后端进程"""
callback = None
def wait_for_shutdown(_seconds: float) -> None:
"""用事件屏障模拟 180 秒等待,确保第二次启动发生在首线程存活期间。"""
sleep_started.set()
release_sleep.wait(timeout=1)
setattr(SystemHelper, monitor_attr, None)
try:
monkeypatch.setattr("app.runtime.state.time.sleep", wait_for_shutdown)
monkeypatch.setattr(SystemHelper, "_docker_api_restart", restart)
SystemHelper._start_graceful_shutdown_monitor()
assert sleep_started.wait(timeout=1)
thread = getattr(SystemHelper, monitor_attr)
SystemHelper._start_graceful_shutdown_monitor()
assert getattr(SystemHelper, monitor_attr) is thread
release_sleep.set()
thread.join(timeout=1)
assert thread.is_alive() is False
assert getattr(SystemHelper, monitor_attr) is None
restart.assert_called_once_with()
finally:
release_sleep.set()
if thread is not None:
thread.join(timeout=1)
setattr(SystemHelper, monitor_attr, original_monitor)
def test_graceful_shutdown_monitor_releases_owner_when_thread_start_fails(monkeypatch):
"""兜底线程启动失败时必须释放 owner,允许后续请求重试。"""
monitor_attr = "_SystemHelper__graceful_shutdown_monitor"
original_monitor = getattr(SystemHelper, monitor_attr)
class FailingThread:
"""模拟在登记 owner 后启动失败的线程对象。"""
def __init__(self, **_kwargs):
"""接收真实 Thread 构造参数,但不创建系统线程。"""
class ImmediateTimer:
def __init__(self, _delay, timer_callback):
nonlocal callback
callback = timer_callback
self.daemon = False
def start(self):
"""模拟底层线程资源不足导致的启动失败。"""
raise RuntimeError("thread start failed")
callback()
setattr(SystemHelper, monitor_attr, None)
try:
monkeypatch.setattr("app.runtime.state.threading.Thread", FailingThread)
popen_mock = MagicMock()
monkeypatch.setattr("app.runtime.state.threading.Timer", ImmediateTimer)
monkeypatch.setattr("app.runtime.state.subprocess.Popen", popen_mock)
with pytest.raises(RuntimeError, match="thread start failed"):
SystemHelper._start_graceful_shutdown_monitor()
SystemHelper._schedule_supervisor_restart()
assert getattr(SystemHelper, monitor_attr) is None
finally:
setattr(SystemHelper, monitor_attr, original_monitor)
assert popen_mock.call_args.args[0][-2:] == ["restart", "all"]
def test_execute_with_subprocess_passes_env_to_subprocess():
Generated
+2 -2
View File
@@ -1669,7 +1669,6 @@ dependencies = [
{ name = "dateparser" },
{ name = "ddgs" },
{ name = "discord-py" },
{ name = "docker" },
{ name = "fast-bencode" },
{ name = "fastapi" },
{ name = "google-genai" },
@@ -1743,6 +1742,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "cython" },
{ name = "docker" },
{ name = "mypy" },
{ name = "pylint" },
{ name = "pytest" },
@@ -1788,7 +1788,6 @@ requires-dist = [
{ name = "dateparser", specifier = "~=1.4.0" },
{ name = "ddgs", specifier = "~=9.14.4" },
{ name = "discord-py", specifier = "==2.7.1" },
{ name = "docker", specifier = "~=7.1.0" },
{ name = "fast-bencode", specifier = "~=1.1.8" },
{ name = "fastapi", specifier = "~=0.141.1" },
{ name = "google-genai", specifier = "~=2.8.0" },
@@ -1862,6 +1861,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "cython", specifier = "~=3.2.5" },
{ name = "docker", specifier = "~=7.1.0" },
{ name = "mypy", specifier = "~=1.18.2" },
{ name = "pylint", specifier = "~=4.0.6" },
{ name = "pytest", specifier = "~=9.0.3" },