Skip to content

✨ 新增 sctl service:把 serve 注册为用户级系统服务 - #11

Open
lonelyman0108 wants to merge 2 commits into
mainfrom
feat/service
Open

lonelyman0108 wants to merge 2 commits into
mainfrom
feat/service

Conversation

@lonelyman0108

Copy link
Copy Markdown
Collaborator

背景

sctl serve 只能在前台运行。文档让用户“交给系统服务管理器”,但没有提供工具;手写的服务定义也容易和 CLI 的数据目录、监听地址对不上。Spec:docs/specs/2026-10-23-service.md。

改动

  • sctl service install|uninstall|start|stop|restart|status
    • macOS:launchd LaunchAgent(~/Library/LaunchAgents/com.scriptscat.sctl.plist)
    • Linux:systemd 用户单元(~/.config/systemd/user/sctl.service,按绝对路径 enable)
    • Windows:当前用户登录时触发的计划任务(XML 定义;取消 72 小时运行上限和电池条件;StopExisting)
    • 都不需要管理员权限
  • install 固化本次调用实际生效的 --data-dir(转成绝对路径,包括来自 SCTL_DATA_DIR 的)、--listen-address 和非默认的 --log-level。二进制路径优先用 PATH 里的同一个文件,Homebrew 升级后服务不会失效。
  • 隐藏参数 serve --service:日志只写 <data dir>/logs,stderr 只留给运行时崩溃信息;Windows 上进程独占控制台时脱离它。
  • 判断 daemon 是否起来:要求健康检查通过,且控制令牌与操作前不同(daemon 绑定端口后才写新令牌)。这样另一个 daemon 占着端口时不会误报成功。install 前和 start 前,只要已有 daemon 能连上(包括其他数据目录的 daemon),就不再启动服务。
  • 新增 control.Address() / control.Healthy(),logging.Setup 增加 toStderr 参数。
  • 文档:cli.md 新增 “Background service” 一节;mcp.md、architecture.md、两份 README、skills/sctl 同步更新。

验证

  • go build/vet ./...(含 GOOS=windows)、go test ./... -race 通过;golangci-lint 在 darwin/linux/windows 三个平台上都是 0 issues。
  • macOS launchd 真机验证(隔离数据目录与端口),步骤与结果:
    • install:daemon 可连,plist 内容正确
    • 进程运行 10 秒以上后 kill -9:1 秒内被拉起
    • stop / start / restart:都正常,restart 后 pid 变化
    • 运行中重复 install:覆盖定义并重启
    • 服务模式下 service.stderr.log 为 0 字节,日志只写 sctl.log
    • uninstall 幂等;未安装时 start 退出码 3
    • 前台 daemon 占着端口:install 被拒绝;已安装时 start 报告已在运行、不启动服务,restart 退出码 3,不误报成功
  • 真机发现:二进制放在 ~/Documents 下时,launchd 启动的进程会被 macOS 隐私保护(TCC)卡住,cli.md 已写明。

未验证 / 已知限制

  • Linux systemd 和 Windows 任务计划程序没有真机验证,只有单元测试覆盖定义内容和命令序列。
  • Windows 的 RestartOnFailure 是否能拉起启动后才崩溃的 daemon 没有确认;文档如实写了,建议崩溃后用 sctl service start。
  • 与 brew services 二选一(同一个端口),文档已说明。

已按 review 修正:waitDaemon 误报成功、安装前检查依赖令牌、schtasks 吞错误、/End 与 /Run 的竞态、bootstrap 重试窗口太短、XDG_CONFIG_HOME 不一致、ProcessType=Background、uninstall 报告不准、--log-level 帮助文字过时。

sctl serve 只能前台运行,文档让用户自己配置服务管理器却不提供工具,手写定义还容易
和 CLI 的数据目录、监听地址对不上。

- sctl service install|uninstall|start|stop|restart|status:macOS launchd LaunchAgent、
  Linux systemd --user、Windows 登录触发的计划任务,不需要管理员权限
- install 固化当前生效的 --data-dir(绝对路径)、--listen-address、非默认 --log-level;
  二进制优先用 PATH 里的同一文件,Homebrew 升级后不失效
- 隐藏参数 serve --service:日志只写文件;Windows 上脱离独占的控制台
- 等 daemon 时以控制令牌变化识别新进程;安装前和 start 前遇到已在运行的 daemon 时不启动服务
- schtasks / launchctl 只把非零退出当作“不存在”,命令本身无法运行时如实报错
- spec:docs/specs/2026-10-23-service.md
@CodFrm

CodFrm commented Oct 23, 2026

Copy link
Copy Markdown
Member

是只适配了linux么?mac/windows是不是也要处理一下

@whyyy-404

whyyy-404 commented Oct 30, 2026 •

Copy link
Copy Markdown

我这边在 Windows 上试了一下,想补上 PR 描述里「Windows 的 RestartOnFailure 是否能拉起启动后才崩溃的 daemon 没有确认」这个坑。

结论是:拉不起来。

测试方式:用 PowerShell 的 Register-ScheduledTask 和 schtasks /XML 复现了 schtasksXML() 生成的任务定义(同一个 <RestartOnFailure> 节点、同样的 Count/Interval),直接验证 Windows 对它的实际行为。

环境

  • Windows 11 家庭版 25H2,Build 26200(10.0.26200)
  • PowerShell 5.1.26100.9711
  • sctl 0.2.0,commit 340200b3,go1.26.3 windows/amd64

启动

动作直接指向 sctl.exe serve 时,任务状态能正确显示 Running,进程跟踪是正常的——不需要额外包一层启动器。

但是崩了之后不会重启

四组实验,RestartOnFailure 全部设成 Count=999, Interval=PT1M:

A. 手动启动

executions = 1   LastTaskResult = 1
run at 13:13:31.261

等 180 秒,只有一次执行。

B. TimeTrigger 自动触发(排除「手动启动不算失败」)

executions = 1   LastTaskResult = 1
run at 13:17:40.422

等 240 秒,还是只有一次。

C. schtasks /XML 注册,不写 UseUnifiedSchedulingEngine(排除调度引擎)

create : SUCCESS
executions = 1   LastTaskResult = 1
run at 13:20:31.569

等 180 秒,仍然只有一次。

D. 真实 sctl.exe serve,直接 kill

daemon PID 87812, task state = Running
kill at 13:23:37, waiting 180s
RESULT: NOT restarted. state=Ready LastTaskResult=4294967295

4294967295 就是 -1,进程被强制终止的退出码。任务从 Running 回到 Ready,180 秒内没有被拉起。

截图:

01-999次机会与0次回头

顺便确认设置确实写进去了

Export-ScheduledTask 导出,<RestartOnFailure> 在任务定义里:

<RestartOnFailure>
  <Count>999</Count>
  <Interval>PT1M</Interval>
</RestartOnFailure>

另外,UseUnifiedSchedulingEngine 显式设成 false 时,schtasks /Create 会直接拒绝:

错误: 任务 XML 包含意外节点。
System.Management.Automation.RemoteException
(21,7):UseUnifiedSchedulingEngine:

截图:

02-白纸黑字

我的体感:Count=999,已读,不拉

RestartOnFailure 大概率只对「因故没能启动」有感情,对「启动之后异常退出」基本已读不回。四组实验都指向这一点,而且手动触发、触发器触发、不同注册方式都排除了。

如果是这样,那 schtasksXML 里的 <RestartOnFailure> 在 Windows 上基本是个摆设。PR 描述里写的「建议崩溃后用 sctl service start」其实是对的,但如果文档能明确写一句「Windows 上不会自动重启,需要手动或另配 watchdog」,用户就不会误以为崩溃能自愈。

这个实现自动恢复,要不要:外层套个 while 循环,或者用 Task Scheduler COM 的 ProcessId 做轮询。

@CodFrm

CodFrm commented Oct 30, 2026

Copy link
Copy Markdown
Member

我晚点去windows里面跑一下

@whyyy-404

whyyy-404 commented Oct 30, 2026 •

Copy link
Copy Markdown

辛苦哥哥。 哥哥这个sctl原生功能真是太好了,ggnb!!已在本机严肃配置,哈哈哈哈。我现在在本机是用一个每 2 分钟的计划任务轮询 127.0.0.1:8643,连不上就 Start-ScheduledTask 重启,实测崩溃后约 46 秒恢复。

在 Windows 11 真机上验证发现:

- restart 和覆盖安装只靠 /Run 配 StopExisting,任务计划程序停掉旧实例后立刻启动新实例,新实例绑定端口
  失败就退出,服务从此不在运行。/End 约 20ms 返回,旧进程约 1 秒后才退出,所以 stop、uninstall、restart
  和覆盖安装结束实例后都等监听地址能被绑定(/End 前地址就空着时不等),再按需 /Run;不等的话 stop 后紧接着
  start 会把还没退出的旧实例当成在运行,什么都不启动。MultipleInstancesPolicy 改为 IgnoreNew
- RestartOnFailure 不会拉起启动后崩溃的 daemon(强制结束后 3 分钟内没有重新运行),删掉它,cli.md 与
  spec 改为如实说明 Windows 上崩溃后要 sctl service start
- service.New 接收监听地址
- logging 测试在 Windows 上死锁:对管道 Sync(FlushFileBuffers)会阻塞到读端读完,读端改为并行;日志文件
  句柄不关闭,Windows 上不用 t.TempDir
- TestLaunchd 的期望路径按宿主平台拼接
@CodFrm

CodFrm commented Oct 30, 2026

Copy link
Copy Markdown
Member

在一台 Windows 11(Build 26200)真机上把这个 PR 跑了一遍,修复已推到本分支(69b17b0)。

发现的问题

  1. restart 和覆盖安装后 daemon 起不来(必现)。只靠 /Run 配 StopExisting:任务计划程序停掉旧实例后立刻启动新实例,不等旧进程退出,新实例绑定端口失败(bind: Only one usage of each socket address...)就退出,服务从此不在运行。
  2. stop / uninstall 返回时 daemon 还没退出。实测 /End 约 20ms 返回,进程约 1 秒后才退出(6/6 次一致)。于是 stop 紧接着 start 时,start 看到旧 daemon 还能连上,什么都不启动,一秒后什么都不在跑。
  3. 崩溃后不会被拉起:和 @whyyy-404 的结论一致,taskkill /F 后 3 分钟内没有重新运行,任务回到「就绪」。
  4. 测试在 Windows 上失败:internal/pkg/logging 的测试死锁到 10 分钟超时(对管道 Sync 即 FlushFileBuffers,会阻塞到读端读完,而读端在函数返回后才读);日志文件句柄不关闭,t.TempDir 清理失败;TestLaunchd 的期望路径在 Windows 上分隔符不对。

修复

  • 结束实例(stop、uninstall、restart、覆盖安装)后,若 /End 之前地址被占着,就等到监听地址能被绑定再返回或再 /Run。MultipleInstancesPolicy 改为 IgnoreNew,/Run 不再碰正在运行的实例。service.New 接收监听地址。
  • 去掉 RestartOnFailure;docs/cli.md 和 spec 改为如实说明:Windows 上崩溃后不会自动拉起,需要 sctl service start。
  • logging 测试改为并行读管道;Windows 上不用 t.TempDir。TestLaunchd 期望路径按宿主平台拼接。

修复后真机验证(隔离数据目录与端口)

  • install;restart ×5、覆盖 install ×3:每次 pid 都变,daemon 都能连上
  • stop 紧接 start ×3、uninstall 紧接 install:都正常;stop 返回时进程已退出,status 显示连不上
  • kill 后不会被拉起(与文档一致),start 能恢复
  • 前台 daemon 占着端口时 install 被拒绝;uninstall 幂等;结束后无残留任务和进程
  • Windows 上 go test ./... 全部通过

遗留

  • Windows 崩溃后自动拉起没有实现,只改了文档。@whyyy-404 提到的定时重新触发(重复触发器配 IgnoreNew)能做到,但会让 stop 在一分钟内被撤销,stop 的语义要先定下来。另外,用每 2 分钟轮询端口、连不上就 Start-ScheduledTask 的方案:daemon 崩溃退出时没问题;但如果进程还活着只是不响应,按原来的 StopExisting 会撞上问题 1,改成 IgnoreNew 后则不会被重启。
  • 服务已安装但端口被你手动启动的前台 daemon 占着时,stop 会等满 10 秒再以退出码 3 报「地址仍被占用」。
  • PR 描述里「Windows 任务计划程序没有真机验证」和「/End 与 /Run 的竞态」那条已经不准确,需要更新。

@whyyy-404

Copy link
Copy Markdown

辛苦哥哥,ggnb!! /End 20ms 返回、进程 1 秒后才退出这个我压根没考虑到。

哥哥说的这个「给 sctl 任务加重复触发器」。我实际是另开了一个任务轮询端口,跟 sctl 任务分开的,所以 stop 语义那个问题我这边是用 .paused 标记文件绕的——停之前写标记,轮询任务看见就跳过。

哥哥说的这个「进程活着只是不响应」确实我漏了。原来 Stop 完固定睡 2 秒再 Start。现在改成两个条件都满足才继续:

$released = $false
for ($i = 0; $i -lt 40; $i++) {
    $state = Get-State          # 任务状态
    $busy  = Test-Port -P $port # 端口占用
    if ($state -ne 'Running' -and -not $busy) { $released = $true; break }
    Start-Sleep -Milliseconds 500
}
if (-not $released) {
    # 20 秒还没释放,记一笔跳过这轮,下轮再说
    return
}
Start-ScheduledTask -TaskName 'sctl serve'

任务得不再是 Running(否则 IgnoreNew 下 Start 无效),端口也得真空出来(否则新实例绑不上)。等不到就跳过这轮,下轮再说。

stop 语义我觉得是该先定,systemd 是 stop 停实例、disable 取消自启,感觉分开之后预期清楚些,不过要多一个子命令。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants