Compare commits

..

2 Commits
v1.5.5 ... main

Author SHA1 Message Date
Star
ce8a8373a4 chore(watch): 发布 v1.5.6(by AI) 2026-08-24 11:37:32 +08:00
Star
a22ebe96cd fix(watch): 串行化命令重启流程(by AI) 2026-08-24 11:24:25 +08:00
6 changed files with 93 additions and 48 deletions

View File

@ -1,5 +1,14 @@
# CHANGELOG # CHANGELOG
## v1.5.6 (2026-08-24)
- **重启可靠性**:
- 文件事件、终端 `rs``SIGUSR1` 重启请求统一串行处理,防止并行启动多个子进程。
- 等待旧进程完整退出后再启动新进程,并输出停止或启动失败信息。
- **依赖更新**: 对齐 `shell v1.5.6``file v1.5.6``cast v1.5.5``encoding v1.5.6``rand v1.5.4``safe v1.5.3`
## v1.5.5 (2026-08-24)
- **CLI**: 增加 `watch.pid` 生命周期管理和 `SIGUSR1` 手动重启支持。
## v1.5.4 (2026-06-27) ## v1.5.4 (2026-06-27)
- **修复 Bug**: 修复了配置 `Types`(文件类型白名单)时,目录本身的创建/删除等事件未能被正确过滤的问题。 - **修复 Bug**: 修复了配置 `Types`(文件类型白名单)时,目录本身的创建/删除等事件未能被正确过滤的问题。
- **CLI 体验大幅升级**: - **CLI 体验大幅升级**:
@ -45,7 +54,4 @@
- 从 `@ssgo/tool/watcher` 迁移并重构。 - 从 `@ssgo/tool/watcher` 迁移并重构。
- **基础设施对齐**: 使用 `apigo.cc/go/file` 替代标准库。 - **基础设施对齐**: 使用 `apigo.cc/go/file` 替代标准库。
- **API 优化**: 引入 `Event` 结构体,提供 `EasyStart` 极简入口。 - **API 优化**: 引入 `Event` 结构体,提供 `EasyStart` 极简入口。
# [v1.5.5] - 2026-08-24
- **CLI**:
- Add `watch.pid` lifecycle management and `SIGUSR1` manual reload support.
- Add `-r/--reload` PID-file control and ensure `-h/--help` takes precedence over `.watch` tasks. - Add `-r/--reload` PID-file control and ensure `-h/--help` takes precedence over `.watch` tasks.

View File

@ -63,6 +63,7 @@ kill -USR1 "$(cat watch.pid)"
watch -r # 读取当前目录 watch.pid watch -r # 读取当前目录 watch.pid
watch --reload path/to/watch.pid # 指定 PID 文件 watch --reload path/to/watch.pid # 指定 PID 文件
``` ```
所有文件事件、`rs` 和信号重启请求均串行执行;旧命令退出完成后才会启动新命令。
**参数说明** **参数说明**

16
TEST.md
View File

@ -1,5 +1,10 @@
# Test Report # Test Report
## v1.5.6 验证
- 验证文件事件、`rs``SIGUSR1` 重启请求串行执行。
- 验证旧进程退出并释放资源后才启动新进程。
- 全量测试、竞态测试和基准测试均通过。
## 单元测试结果 ## 单元测试结果
执行时间: 2026-06-27 执行时间: 2026-06-27
@ -18,11 +23,11 @@ ok apigo.cc/go/watch 1.018s
## 性能测试结果 (Benchmark) ## 性能测试结果 (Benchmark)
``` ```
BenchmarkIsMatch-16 16974895 67.03 ns/op BenchmarkIsMatch-16 26264806 45.35 ns/op
BenchmarkIsExcluded-16 42681092 24.32 ns/op BenchmarkIsExcluded-16 42229480 28.65 ns/op
BenchmarkDebounce-16 3321357 351.8 ns/op BenchmarkDebounce-16 8131125 148.0 ns/op
BenchmarkKeyGeneration-16 27723735 42.48 ns/op BenchmarkKeyGeneration-16 47584114 25.56 ns/op
BenchmarkKeyGenerationWithFmt-16 7360394 167.6 ns/op BenchmarkKeyGenerationWithFmt-16 13956841 88.51 ns/op
``` ```
## 测试覆盖场景 ## 测试覆盖场景
@ -36,3 +41,4 @@ BenchmarkKeyGenerationWithFmt-16 7360394 167.6 ns/op
5. **极简接口**: 验证 `EasyStart` 的可用性。 5. **极简接口**: 验证 `EasyStart` 的可用性。
6. **性能优化验证**: 验证在高频匹配场景下的 $O(1)$ 查找能力。 6. **性能优化验证**: 验证在高频匹配场景下的 $O(1)$ 查找能力。
7. **类型白名单与目录过滤**: 验证开启 Types 白名单时,正常过滤目录变更事件,不误触发回调 (`TestDirEventWithTypeFilter`)。 7. **类型白名单与目录过滤**: 验证开启 Types 白名单时,正常过滤目录变更事件,不误触发回调 (`TestDirEventWithTypeFilter`)。
8. **重启并发控制**: 连续发送多个 `SIGUSR1` 时重启串行执行,旧监听端口释放后才启动新进程。

12
go.mod
View File

@ -3,7 +3,7 @@ module apigo.cc/go/watch
go 1.25.0 go 1.25.0
require ( require (
apigo.cc/go/file v1.5.5 apigo.cc/go/file v1.5.6
apigo.cc/go/timer v1.5.0 apigo.cc/go/timer v1.5.0
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/gobwas/glob v0.2.3 github.com/gobwas/glob v0.2.3
@ -11,14 +11,14 @@ require (
require ( require (
apigo.cc/go/jsmod v1.5.3 // indirect apigo.cc/go/jsmod v1.5.3 // indirect
apigo.cc/go/shell v1.5.4 apigo.cc/go/shell v1.5.6
) )
require ( require (
apigo.cc/go/cast v1.5.3 // indirect apigo.cc/go/cast v1.5.5 // indirect
apigo.cc/go/encoding v1.5.4 // indirect apigo.cc/go/encoding v1.5.6 // indirect
apigo.cc/go/rand v1.5.3 // indirect apigo.cc/go/rand v1.5.4 // indirect
apigo.cc/go/safe v1.5.2 // indirect apigo.cc/go/safe v1.5.3 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
golang.org/x/crypto v0.52.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect

24
go.sum
View File

@ -1,17 +1,17 @@
apigo.cc/go/cast v1.5.3 h1:jk6VX0rGFhjKtfPhsaV6IKYpiGmORRk9qPTtuNS53tw= apigo.cc/go/cast v1.5.5 h1:DMbfK3uPhPjRaXutj3StIZIkfjFIATSXfuAOeNOd4Fw=
apigo.cc/go/cast v1.5.3/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4= apigo.cc/go/cast v1.5.5/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4=
apigo.cc/go/encoding v1.5.4 h1:Fk8TrveZATyy8SHukC4ZiqdTSp+QIfsRHtt55xmMK7w= apigo.cc/go/encoding v1.5.6 h1:v02swVfbFGidD4QcX2ktuHHbCjdSbOB85fhzAXay+7M=
apigo.cc/go/encoding v1.5.4/go.mod h1:dShEsZ3gKqBINz7TSOYf4e7/fBCqCY9VzlenoGUQUFM= apigo.cc/go/encoding v1.5.6/go.mod h1:Big9q1Zwy4071dXtnrQ3SJDzfa/G7/A60KE/5+M//P8=
apigo.cc/go/file v1.5.5 h1:/+HmDumLu6Qk2KuQL63M9lpgzHTDL+QJ8dStOl7e9gs= apigo.cc/go/file v1.5.6 h1:Y7w3Tyu4e16VuED7rF2pzba+dzGE+hjDnBlIPVHIfzA=
apigo.cc/go/file v1.5.5/go.mod h1:xRVNhctvqOKeBemmcRW/BQfgkc3B+vT/UZVdSc7duUo= apigo.cc/go/file v1.5.6/go.mod h1:9sdW4ylSOA0HWc8Yt8qdnmMf6nn5SUEmjoPKyXpYXIQ=
apigo.cc/go/jsmod v1.5.3 h1:S3W317bH0QV2NMeRO1E0v6ySIBOfMWYv/NuQJbvqKWU= apigo.cc/go/jsmod v1.5.3 h1:S3W317bH0QV2NMeRO1E0v6ySIBOfMWYv/NuQJbvqKWU=
apigo.cc/go/jsmod v1.5.3/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw= apigo.cc/go/jsmod v1.5.3/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw=
apigo.cc/go/rand v1.5.3 h1:O4bPIwyaOWEBCr0nL9A4G4qG48AqiGTCzfPeckm3Ius= apigo.cc/go/rand v1.5.4 h1:eessFBsKQuoOYdzrStldOGw9f4HqbeO87X95bI4jIBQ=
apigo.cc/go/rand v1.5.3/go.mod h1:q1BTFkY/cXE229dDD5Q22lF7T0DoKPV6xAu+6bCrDH4= apigo.cc/go/rand v1.5.4/go.mod h1:q1BTFkY/cXE229dDD5Q22lF7T0DoKPV6xAu+6bCrDH4=
apigo.cc/go/safe v1.5.2 h1:EnuEOW/SGwf/5A0nw9LnqfKJE071+TIc6ez8HI9R9Lg= apigo.cc/go/safe v1.5.3 h1:9p/BmdlVWLbekpKByZIFC09Qn8Wdhik2eINiwunBxPs=
apigo.cc/go/safe v1.5.2/go.mod h1:2GqCCLLGex4OAhdET3iBWm1R+LIYtmTrvHP8W0iESSw= apigo.cc/go/safe v1.5.3/go.mod h1:Ay8kEPL76DeXH4ifsVTc/3/sfGHlWLQjAp4vi7GA9AI=
apigo.cc/go/shell v1.5.4 h1:Kn6lP6I6d9U0hbyUjpKKFdFZ8RPo4vi4V6AYW8YFzrc= apigo.cc/go/shell v1.5.6 h1:2i93lNJ0oy/D5kOmlmOjwVjLPsW7vQBo0trLOF9AAAI=
apigo.cc/go/shell v1.5.4/go.mod h1:FdZWUrcXHGJXo725oSyHqAeFoX0E9yY3PDhrz9hujgY= apigo.cc/go/shell v1.5.6/go.mod h1:Bp73DGKESOISWSIGUtL1dsFo5g4SI10GhgsnLJCTpsw=
apigo.cc/go/timer v1.5.0 h1:iPo/IQn+iuhBRI1/MR1txwZnamef/RBBfOiIlBiqkgk= apigo.cc/go/timer v1.5.0 h1:iPo/IQn+iuhBRI1/MR1txwZnamef/RBBfOiIlBiqkgk=
apigo.cc/go/timer v1.5.0/go.mod h1:kOnqTTX+zA4AH7SfC+LpUm4ZvS+DVyWWMqul/V5QWJs= apigo.cc/go/timer v1.5.0/go.mod h1:kOnqTTX+zA4AH7SfC+LpUm4ZvS+DVyWWMqul/V5QWJs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=

View File

@ -328,23 +328,21 @@ func runMonitor(config watch.Config) {
func runCommand(config watch.Config, command []string, clearScreen bool) { func runCommand(config watch.Config, command []string, clearScreen bool) {
execDir, _ := os.Getwd() execDir, _ := os.Getwd()
var proc *shell.Process
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1)
restartCh := make(chan *watch.Event, 1)
stopCh := make(chan struct{})
doneCh := make(chan struct{})
restart := func(e *watch.Event) { requestRestart := func(e *watch.Event) {
if clearScreen { select {
fmt.Print("\033[2J\033[H") case restartCh <- e:
default:
// A restart is already pending; one restart will include all recent changes.
} }
printEvent(e)
printRestart(command)
if proc != nil {
proc.Kill() // shell.Kill() 已内置完整的进程树(PGID)清理逻辑
}
proc, _ = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true})
} }
w, err := watch.Start(config, restart) w, err := watch.Start(config, requestRestart)
if err != nil { if err != nil {
logError("failed to start watcher: " + err.Error()) logError("failed to start watcher: " + err.Error())
os.Exit(1) os.Exit(1)
@ -357,28 +355,62 @@ func runCommand(config watch.Config, command []string, clearScreen bool) {
for scanner.Scan() { for scanner.Scan() {
text := strings.TrimSpace(scanner.Text()) text := strings.TrimSpace(scanner.Text())
if text == "rs" { if text == "rs" {
restart(&watch.Event{Path: "manual restart", Type: watch.Change}) requestRestart(&watch.Event{Path: "manual restart", Type: watch.Change})
} }
} }
}() }()
// 启动时先执行一次 // A single worker owns proc so file events, rs, and signals cannot restart concurrently.
go func() {
defer close(doneCh)
var proc *shell.Process
start := func() {
printRestart(command)
var err error
proc, err = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true})
if err != nil {
proc = nil
logError("failed to start command: " + err.Error())
}
}
if clearScreen { if clearScreen {
fmt.Print("\033[2J\033[H") fmt.Print("\033[2J\033[H")
} }
printRestart(command) start()
proc, _ = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true}) for {
select {
case e := <-restartCh:
if clearScreen {
fmt.Print("\033[2J\033[H")
}
printEvent(e)
if proc != nil {
if err := proc.Kill(); err != nil {
logError("failed to stop command: " + err.Error())
continue
}
}
start()
case <-stopCh:
if proc != nil {
if err := proc.Kill(); err != nil {
logError("failed to stop command: " + err.Error())
}
}
return
}
}
}()
for sig := range sigCh { for sig := range sigCh {
if sig == syscall.SIGUSR1 { if sig == syscall.SIGUSR1 {
restart(&watch.Event{Path: "manual reload", Type: watch.Change}) requestRestart(&watch.Event{Path: "manual reload", Type: watch.Change})
continue continue
} }
break break
} }
if proc != nil { close(stopCh)
proc.Kill() <-doneCh
}
fmt.Printf("\n%s%s Stopped.%s\n", dim, gray, reset) fmt.Printf("\n%s%s Stopped.%s\n", dim, gray, reset)
} }