Support declared graceful stop and steam.update probe

- LifecycleActionTemplate 新增 gracefulStop:stop 动作可声明插件自有的优雅关闭命令、
  参数、环境、超时与 fallback;超时且 fallback=report 时任务失败,避免默认杀进程。
- 依赖探针新增 steam.update:通过 steamcmd +app_info_print 读取公开分支 buildid,
  与本地 appmanifest_<appid>.acf 比较,输出 installed/latest/update=yes|no|unknown。
- 依赖执行输入新增 ServerRoot,供探针定位插件声明的服务器安装目录。
This commit is contained in:
npc0-hue
2026-09-15 13:31:36 +08:00
parent a695aafd0f
commit 4e88199d18
7 changed files with 464 additions and 26 deletions
+112
View File
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -50,6 +51,17 @@ func TestRunHelperProcess(t *testing.T) {
time.Sleep(100 * time.Millisecond)
}
}
if pidText := os.Getenv("RUN_GRACEFUL_STOP_PID"); pidText != "" {
// Stands in for a game-owned graceful shutdown: the plugin-declared
// stop command asks the running game to exit instead of Run
// terminating the supervised process.
if target, convErr := strconv.Atoi(pidText); convErr == nil {
if process, findErr := os.FindProcess(target); findErr == nil {
_ = process.Kill()
}
}
return
}
if os.Getenv("RUN_EXIT_NOW") == "1" {
return
}
@@ -132,6 +144,106 @@ func TestTypedProcessOutputCaptureResumesAfterRunRestart(t *testing.T) {
waitForManagedTailers(t, restarted.managed.(*OSManagedProcessSupervisor))
}
func TestGracefulStopRunsDeclaredShutdownBeforeTermination(t *testing.T) {
root := t.TempDir()
start := executionAssignment(protocol.RunCapabilityProcessStart)
setupProcessWorkspace(t, root, start, false)
scope := processScope(root, start)
executor := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(root), WithProcessLogSink(&recordingLogSink{}))
if result := executor.Execute(start); result.State != lifecycleResultStateSucceeded {
t.Fatalf("start managed process: %+v", result)
}
managed := executor.managed.(*OSManagedProcessSupervisor)
identity := managed.Status(ProcessIdentity{Scope: scope})
writeJSONFixture(t, filepath.Join(scope, "actions", "stop.json"), map[string]any{
"version": 1,
"action": "stop",
"mode": "control",
"stopTimeoutMs": 30000,
"gracefulStop": map[string]any{
"executableKey": "bin/game-server",
"arguments": []string{"-test.run=TestRunHelperProcess"},
"environment": map[string]string{"RUN_TEST_HELPER": "1", "RUN_GRACEFUL_STOP_PID": strconv.Itoa(identity.PID)},
"timeoutMs": 30000,
"fallback": "report",
},
})
stop := executionAssignment(protocol.RunCapabilityProcessStop)
stop.TargetKey = "actions/stop.json"
result := executor.Execute(stop)
if result.State != lifecycleResultStateSucceeded {
t.Fatalf("graceful stop result: %+v", result)
}
if result.ExecutionResult.ProcessState != "stopped" {
t.Fatalf("graceful stop process state: %+v", result.ExecutionResult)
}
if result.ExecutionResult.ExitClassification == "forced-stop" {
t.Fatalf("declared graceful stop terminated the process: %+v", result.ExecutionResult)
}
if _, err := os.FindProcess(identity.PID); err == nil && processAliveForTest(identity.PID) {
t.Fatalf("declared shutdown did not stop process %d", identity.PID)
}
}
func TestGracefulStopReportsWhenDeclaredShutdownDoesNotStopProcess(t *testing.T) {
root := t.TempDir()
start := executionAssignment(protocol.RunCapabilityProcessStart)
setupProcessWorkspace(t, root, start, false)
scope := processScope(root, start)
executor := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(root), WithProcessLogSink(&recordingLogSink{}))
if result := executor.Execute(start); result.State != lifecycleResultStateSucceeded {
t.Fatalf("start managed process: %+v", result)
}
managed := executor.managed.(*OSManagedProcessSupervisor)
identity := managed.Status(ProcessIdentity{Scope: scope})
writeStopAction := func(fallback string) {
writeJSONFixture(t, filepath.Join(scope, "actions", "stop.json"), map[string]any{
"version": 1,
"action": "stop",
"mode": "control",
"stopTimeoutMs": 30000,
"gracefulStop": map[string]any{
"executableKey": "bin/game-server",
"arguments": []string{"-test.run=TestRunHelperProcess"},
"environment": map[string]string{"RUN_TEST_HELPER": "1", "RUN_LOG_LINES": "1"},
"timeoutMs": 30000,
"fallback": fallback,
},
})
}
stop := executionAssignment(protocol.RunCapabilityProcessStop)
stop.TargetKey = "actions/stop.json"
t.Cleanup(func() {
writeStopAction("terminate")
_ = executor.Execute(stop)
})
writeStopAction("report")
reported := executor.Execute(stop)
if reported.State != lifecycleResultStateFailed || reported.ErrorCode != "graceful_stop_timeout" {
t.Fatalf("expected unverified graceful stop failure, got %+v", reported)
}
if current := managed.Status(ProcessIdentity{Scope: scope}); current.State != "running" {
t.Fatalf("report-only graceful stop terminated the process: %+v", current)
}
writeStopAction("terminate")
terminated := executor.Execute(stop)
if terminated.State != lifecycleResultStateSucceeded || terminated.ExecutionResult.ProcessState != "stopped" {
t.Fatalf("expected declared fallback termination, got %+v", terminated)
}
if current := managed.Status(ProcessIdentity{Scope: scope}); current.State == "running" {
t.Fatalf("declared fallback termination left the process running: %+v", current)
}
_ = identity
}
func processAliveForTest(pid int) bool {
return processAlivePID(pid)
}
func TestManagedProcessOutputAfterCanceledRunContextIsNotSpooledOnRestart(t *testing.T) {
root := t.TempDir()
assignment := executionAssignment(protocol.RunCapabilityProcessStart)