Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/script_chainer/config/script_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ class ScriptConfig:
notify_log_interval: int = 0
enabled: bool = True
attach_direction: str = AttachDirection.NONE
no_log_timeout_seconds: int = 0
no_log_max_retries: int = 3

# 不参与序列化的元数据
idx: int = field(default=0, repr=False, compare=False)
Expand Down
57 changes: 57 additions & 0 deletions src/script_chainer/gui/page/script_setting_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
PrimaryDropDownPushButton,
PushButton,
RoundMenu,
SpinBox,
SubtitleLabel,
SwitchButton,
TransparentToolButton,
Expand Down Expand Up @@ -188,6 +189,38 @@ def __init__(self, config: ScriptConfig, parent=None):
)
self.viewLayout.addWidget(self.notify_log_opt)

# ── 静默超时重启 ──
self.no_log_timeout_input = SpinBox()
self.no_log_timeout_input.setRange(1, 86400)
self.no_log_timeout_input.setSingleStep(1)
self.no_log_timeout_input.setFixedWidth(140)

self.no_log_timeout_switch = SwitchButton()
self.no_log_timeout_switch.setOnText('')
self.no_log_timeout_switch.setOffText('')
self.no_log_timeout_switch.checkedChanged.connect(self._on_no_log_timeout_toggled)

self.no_log_timeout_opt = MultiPushSettingCard(
icon=FluentIcon.SYNC,
title='无日志超时重启(秒)',
content='超过设定秒数无日志输出时,判定为未响应并重新执行',
btn_list=[self.no_log_timeout_input, self.no_log_timeout_switch],
)
self.viewLayout.addWidget(self.no_log_timeout_opt)

self.no_log_max_retries_input = SpinBox()
self.no_log_max_retries_input.setRange(1, 99)
self.no_log_max_retries_input.setSingleStep(1)
self.no_log_max_retries_input.setFixedWidth(140)

self.no_log_max_retries_opt = MultiPushSettingCard(
icon=FluentIcon.SYNC,
title='最大重启次数',
content='无日志超时时最多重启的次数',
btn_list=[self.no_log_max_retries_input],
)
self.viewLayout.addWidget(self.no_log_max_retries_opt)

self.init_by_config(config)

def init_by_config(self, config: ScriptConfig):
Expand Down Expand Up @@ -216,10 +249,28 @@ def init_by_config(self, config: ScriptConfig):
self.notify_log_interval_input.blockSignals(False)
self.notify_log_interval_input.setEnabled(notify_log_enabled)

no_log_enabled = config.no_log_timeout_seconds > 0
self.no_log_timeout_switch.blockSignals(True)
self.no_log_timeout_switch.setChecked(no_log_enabled)
self.no_log_timeout_switch.blockSignals(False)
self.no_log_timeout_input.blockSignals(True)
self.no_log_timeout_input.setValue(config.no_log_timeout_seconds if no_log_enabled else 300)
self.no_log_timeout_input.blockSignals(False)
self.no_log_timeout_input.setEnabled(no_log_enabled)
self.no_log_max_retries_input.blockSignals(True)
self.no_log_max_retries_input.setValue(max(1, config.no_log_max_retries))
self.no_log_max_retries_input.blockSignals(False)
self.no_log_max_retries_input.setEnabled(no_log_enabled)

def _on_notify_log_toggled(self, checked: bool) -> None:
"""日志推送开关切换时启用/禁用间隔输入框"""
self.notify_log_interval_input.setEnabled(checked)

def _on_no_log_timeout_toggled(self, checked: bool) -> None:
"""静默超时重启开关切换时启用/禁用相关输入框"""
self.no_log_timeout_input.setEnabled(checked)
self.no_log_max_retries_input.setEnabled(checked)

@staticmethod
def _set_editable_combo_value(card: EditableComboBoxSettingCard, value: str) -> None:
"""设置可编辑下拉框的值,若预设列表中无匹配则直接设置文本"""
Expand Down Expand Up @@ -281,6 +332,12 @@ def get_config_value(self) -> ScriptConfig:
else:
config.notify_log_interval = 0

if self.no_log_timeout_switch.isChecked():
config.no_log_timeout_seconds = max(1, self.no_log_timeout_input.value())
else:
config.no_log_timeout_seconds = 0
config.no_log_max_retries = self.no_log_max_retries_input.value()

return config

def validate(self) -> bool:
Expand Down
4 changes: 4 additions & 0 deletions src/script_chainer/services/log_notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ def stop(self) -> None:
self._timer = None
self._flush()

def flush(self) -> None:
"""立即推送当前通知池中的日志。"""
self._flush()

def _schedule_next(self) -> None:
if self._stopped:
return
Expand Down
145 changes: 116 additions & 29 deletions src/script_chainer/win_exe/script_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,17 @@
_active_pm: ProcessManager | None = None


class _NoLogTimeoutError(Exception):
"""脚本长时间无日志输出时抛出,触发外层重试。"""


@dataclass
class _RunMonitorState:
"""单次脚本运行监控过程中的瞬态状态。"""

script_ever_existed: bool = False
game_ever_existed: bool = False
last_log_time: float | None = None


class _TeeWriter:
Expand Down Expand Up @@ -143,29 +148,17 @@ def _push_chain_notification(
)


def _run_group_script(
script_config: ScriptConfig,
log_notifier: LogNotifier | None = None,
) -> None:
"""运行运行组中的单个脚本。"""
try:
if script_config.script_type == ScriptType.PYTHON:
_run_python_script(script_config, log_notifier=log_notifier)
else:
run_script(script_config, log_notifier=log_notifier)
except Exception:
log.error('脚本执行异常', exc_info=True)


def _make_stdout_callback(
display_name: str,
log_notifier: LogNotifier | None = None,
state: _RunMonitorState | None = None,
) -> Callable[[str], None]:
"""创建 stdout 回调,为每行输出添加前缀。

Args:
display_name: 显示名称。
log_notifier: 可选的日志通知器,用于定时推送日志。
state: 可选的运行监控状态,用于记录最后一次收到日志的时间戳。
"""
prefix = f'{Style.DIM}[{display_name}]{Style.RESET_ALL}'

Expand All @@ -174,13 +167,16 @@ def _on_stdout(line: str) -> None:
log.info('[脚本] %s', line)
if log_notifier is not None:
log_notifier.add(line)
if state is not None:
state.last_log_time = time.time()

return _on_stdout


def _launch_script(
script_config: ScriptConfig,
log_notifier: LogNotifier | None = None,
state: _RunMonitorState | None = None,
) -> ProcessManager:
"""启动脚本子进程并返回 ProcessManager。

Expand All @@ -192,6 +188,7 @@ def _launch_script(
Args:
script_config: 脚本配置。
log_notifier: 可选的日志通知器,用于定时推送日志。
state: 可选的运行监控状态,用于记录最后一次收到日志的时间戳。

Returns:
已初始化的 ProcessManager。
Expand All @@ -216,10 +213,7 @@ def _launch_script(
args=args_list,
target_process=target,
search_timeout=30,
stdout_callback=_make_stdout_callback(
display_name,
log_notifier=log_notifier,
),
stdout_callback=_make_stdout_callback(display_name, log_notifier, state),
)
except LauncherExitError as e:
log.error('启动器异常退出: %s', e, exc_info=True)
Expand Down Expand Up @@ -300,10 +294,17 @@ def _monitor_script_done(
script_config: ScriptConfig,
state: _RunMonitorState,
) -> None:
"""监控脚本运行状态,等待完成条件满足。"""
"""监控脚本运行状态,等待完成条件满足。

Args:
script_config: 脚本配置。
state: 运行监控状态(跨 _wait_for_subprocess_ready 持久化的进程存在标志)。
"""
start_time = time.time()
last_status: str = ''

no_log_timeout = script_config.no_log_timeout_seconds

while True:
is_done: bool = False
status: str = ''
Expand Down Expand Up @@ -350,27 +351,43 @@ def _monitor_script_done(
print_message(f'未知的检查结束方式 {script_config.check_done}', level='ERROR')
is_done = True

if time.time() - start_time > script_config.run_timeout_seconds:
now = time.time()

# 总运行超时检查
if now - start_time > script_config.run_timeout_seconds:
is_done = True
print_message(f'脚本运行超时 {script_config.script_display_name}', level='ERROR')

if is_done:
break

# 静默超时检查(无日志输出超时,触发重启)
if (
no_log_timeout > 0
and state.last_log_time is not None
and now - state.last_log_time > no_log_timeout
):
print_message(
f'脚本超过 {no_log_timeout} 秒无日志输出,判定为未响应 {script_config.script_display_name}',
level='ERROR',
)
raise _NoLogTimeoutError()

if _exit_controller.wait(1):
break


def _cleanup_processes(script_config: ScriptConfig, pm: ProcessManager) -> None:
def _cleanup_processes(script_config: ScriptConfig, pm: ProcessManager, force_script: bool = False) -> None:
"""清理脚本和游戏进程。

通过 ProcessManager.kill() 精确终止已追踪的进程及其子进程树(基于 PID)。

Args:
script_config: 脚本配置。
pm: ProcessManager 实例。
force_script: 是否忽略用户配置,强制终止当前被管理的脚本进程。
"""
if script_config.kill_script_after_done:
if force_script or script_config.kill_script_after_done:
print_message(f'尝试关闭脚本进程 {pm.main_name} (pid={pm.main_pid})')
try:
pm.kill()
Expand All @@ -394,11 +411,11 @@ def _cleanup_processes(script_config: ScriptConfig, pm: ProcessManager) -> None:
log.error('关闭游戏进程失败', exc_info=True)


def run_script(
def _run_script_once(
script_config: ScriptConfig,
log_notifier: LogNotifier | None = None,
) -> None:
"""运行单个脚本的完整生命周期
"""运行单个脚本的一次完整生命周期

流程:
1. 校验配置。
Expand All @@ -407,6 +424,11 @@ def run_script(
4. 监控运行状态。
5. 清理进程。

静默超时逻辑:
当 script_config.no_log_timeout_seconds > 0 时,若脚本在指定时间内没有任何
日志输出,则认为游戏/脚本未响应,会终止当前进程并向调用方抛出
_NoLogTimeoutError,由链编排层决定是否重试和发送通知。

Args:
script_config: 脚本配置。
log_notifier: 可选的日志通知器,用于定时推送日志。
Expand All @@ -419,11 +441,12 @@ def run_script(
return

script_path = script_config.script_path
no_log_timeout = script_config.no_log_timeout_seconds

# 1. 启动脚本子进程
pm = _launch_script(script_config, log_notifier=log_notifier)
_active_pm = pm
state = _RunMonitorState()
pm = _launch_script(script_config, log_notifier, state)
_active_pm = pm
try:
# 2. 等待子进程就绪
# 仅当脚本进程名与启动文件名不同时才期望追踪目标进程(launcher 场景)
Expand All @@ -437,16 +460,80 @@ def run_script(
return

print_message(f'脚本子进程创建成功 {script_path}', level='PASS')
if no_log_timeout > 0:
state.last_log_time = time.time()

# 3. 监控脚本运行状态
_monitor_script_done(script_config, state)
try:
_monitor_script_done(script_config, state)
except _NoLogTimeoutError:
_cleanup_processes(script_config, pm, force_script=True)
raise

# 4. 清理进程
# 4. 清理进程(正常退出路径)
_cleanup_processes(script_config, pm)
finally:
_active_pm = None


def _run_external_script_with_retries(
script_config: ScriptConfig,
log_notifier: LogNotifier | None = None,
ctx: ScriptChainerContext | None = None,
chain_name: str = '',
) -> None:
"""运行外部脚本,并在静默超时时按配置重试。"""
max_retries = (
script_config.no_log_max_retries
if script_config.no_log_timeout_seconds > 0
else 0
)
for retry_count in range(max_retries + 1):
if retry_count > 0:
if log_notifier is not None:
log_notifier.flush()
print_message(
f'重试运行脚本 ({retry_count}/{max_retries}) '
f'{script_config.script_display_name}',
level='INFO',
)
if script_config.notify_start:
_push_chain_notification(
ctx,
chain_name,
f'无日志超时重试 ({retry_count}/{max_retries})',
script_config,
)
try:
_run_script_once(script_config, log_notifier)
return
except _NoLogTimeoutError:
if retry_count < max_retries:
continue
print_message(
f'已达最大重试次数 ({max_retries}),放弃重启 '
f'{script_config.script_display_name}',
level='ERROR',
)
return


def _run_script_in_group(
script_config: ScriptConfig,
log_notifier: LogNotifier | None = None,
ctx: ScriptChainerContext | None = None,
chain_name: str = '',
) -> None:
"""运行运行组中的单个脚本。"""
try:
if script_config.script_type == ScriptType.PYTHON:
_run_python_script(script_config, log_notifier)
else:
_run_external_script_with_retries(script_config, log_notifier, ctx, chain_name)
except Exception:
log.error('脚本执行异常', exc_info=True)


def _run_python_script(
script_config: ScriptConfig,
log_notifier: LogNotifier | None = None,
Expand Down Expand Up @@ -608,7 +695,7 @@ def run_chain(chain_name: str = '01', shutdown_delay: int = 0, debug_index: int
)

for script_config in group.scripts:
_run_group_script(script_config, log_notifier=log_notifier)
_run_script_in_group(script_config, log_notifier, ctx, chain_name)

if group.host.notify_done:
_push_chain_notification(
Expand Down