Wait for a stopped bundle's exit instead of sleeping after it
mesh/delivery delivered
mesh/merge-gate pass: builds mesh-tools, node-tools → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of …
mesh/repo-check pass: its merge-check.sh passed

Launched.Stop now returns once the bundle's exit is handled, bounded by
StopWait, and the launcher reads its restart pauses once at Start, so
the exit goroutine no longer races a test that changes them. The caller
tests drop their half-second pause (novox/hq issue 476).
This commit is contained in:
2026-10-12 10:43:22 +02:00
parent 172b5af589
commit 494d0787e2
2 changed files with 29 additions and 19 deletions
+27 -9
View File
@@ -107,6 +107,10 @@ var (
RestartSettle = time.Minute
)
// StopWait bounds how long Launched.Stop waits for its bundle's exit to be handled; a bundle that
// ignores SIGTERM longer than this is left to exit on its own (novox/hq issue 476).
var StopWait = 5 * time.Second
// Launched is a running bundle: what it registered, and how to stop it.
type Launched struct {
Registrations []Registration
@@ -143,6 +147,9 @@ type child struct {
pending map[int64]chan message
next int64
dead chan struct{}
// handled is closed once the child's exit has been handled whole — its state cleared and its
// restart decided — which Stop waits for (novox/hq issue 476).
handled chan struct{}
why error
// watches are the state watches this child asked for, stopped when it exits (ADR 0201): a child
// that starts again watches again, as its code runs again.
@@ -195,7 +202,10 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
}
var restart func(after time.Duration)
var startedAt time.Time
pause := RestartFirst
// The restart pauses are read once, here: the exit handling runs on its own goroutine and must not
// read the package's settings while a caller (a test) changes them (novox/hq issue 476).
pauseFirst, pauseMost, settle := RestartFirst, RestartMost, RestartSettle
pause := pauseFirst
start := func() (*child, error) {
cmd := exec.Command(entry)
@@ -215,7 +225,8 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
if err := cmd.Start(); err != nil {
return nil, err
}
c := &child{cmd: cmd, stdin: stdin, pending: map[int64]chan message{}, next: 1, dead: make(chan struct{})}
c := &child{cmd: cmd, stdin: stdin, pending: map[int64]chan message{}, next: 1, dead: make(chan struct{}),
handled: make(chan struct{})}
var lastSaid string
var saidMu sync.Mutex
stderrDone := make(chan struct{})
@@ -237,6 +248,7 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
}
}()
go func() {
defer close(c.handled)
scan := bufio.NewScanner(stdout)
scan.Buffer(make([]byte, 64*1024), 16<<20)
for scan.Scan() {
@@ -387,14 +399,14 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
}
wasStopped := stopped
// Started again at once if it ran a while; after a growing pause while it keeps exiting.
wait := RestartFirst
if time.Since(startedAt) < RestartSettle {
wait := pauseFirst
if time.Since(startedAt) < settle {
wait = pause
if pause *= 2; pause > RestartMost {
pause = RestartMost
if pause *= 2; pause > pauseMost {
pause = pauseMost
}
} else {
pause = RestartFirst
pause = pauseFirst
}
mu.Unlock()
if !wasStopped && wasServing {
@@ -451,8 +463,8 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
if err != nil {
if !stopped {
wait := pause
if pause *= 2; pause > RestartMost {
pause = RestartMost
if pause *= 2; pause > pauseMost {
pause = pauseMost
}
logf("[mesh-tools] %s's bundle did not start again: %v; trying in %s", module, err, wait)
go restart(wait)
@@ -557,6 +569,12 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
mu.Unlock()
if c != nil && c.cmd.Process != nil {
_ = c.cmd.Process.Signal(syscall.SIGTERM)
// Returns once the bundle's exit is handled, so nothing of it runs on after Stop
// (novox/hq issue 476); bounded, for a bundle that does not exit on SIGTERM.
select {
case <-c.handled:
case <-time.After(StopWait):
}
}
}}
for _, under := range order {
+2 -10
View File
@@ -27,7 +27,7 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T)
if err != nil {
t.Fatal(err)
}
defer settled(stop)
defer stop() // returns once the bundle's exit is handled (novox/hq issue 476)
asker, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "shanks",
User: "shanks.node-tools", Password: "unused"})
if err != nil {
@@ -65,7 +65,7 @@ func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer settled(stop)
defer stop() // returns once the bundle's exit is handled (novox/hq issue 476)
old, err := nats.Connect(mt.URL(t))
if err != nil {
t.Fatal(err)
@@ -89,11 +89,3 @@ func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) {
}
}
}
// settled stops a runtime and waits for its bundle's exit to be handled: the launcher reads its restart pauses
// (launch.RestartFirst) as a bundle exits, and a later test in this package sets them, so a test that leaves its
// bundle exiting behind it races the next one.
func settled(stop func()) {
stop()
time.Sleep(500 * time.Millisecond)
}