node-tools: start no bundle again once the runtime itself is stopping (issue 1126)

The service manager stops node-tools by signalling its whole group, so every
bundle gets SIGTERM with it; the launcher took each exit for a fault and started
the bundle again, for the 15 s the runtime took to stop. On ace's reboot of
2026-10-28 those restarted bundles dialled the system bus once polkit and the bus
had stopped, the bus and polkit came back during the shutdown, nothing stopped
them a second time, and the reboot waited 29 minutes on its job timeout.

ShutDown, called first on SIGTERM, keeps any bundle from starting again, by a
restart or by a call; a running one still answers.
This commit is contained in:
2026-10-28 15:30:12 +01:00
parent 6cc3398880
commit f016c67a72
3 changed files with 101 additions and 3 deletions
@@ -3,7 +3,12 @@ package launch
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
type lostWrite struct{}
@@ -28,3 +33,74 @@ func TestALostWriteIsAnsweredWithItsOwnCodeAndNothingElseIs(t *testing.T) {
t.Errorf("a plain refusal is answered %d", got)
}
}
// A bundle that answers the handshake and an empty tool list, and notes each start and its pid in $OUT.
const notingBundle = `#!/bin/sh
printf 'started %s\n' "$$" >> "$OUT"
while IFS= read -r line; do
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\)[,}].*/\1/p')
case "$line" in
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
*'"method":"tools/list"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[]}}\n' "$id" ;;
esac
done
`
// The service manager stops the runtime by signalling its whole group, every bundle with it (issue 1126, ace's
// reboot of 2026-10-28: every bundle "exited (SIGTERM); started again in 500ms" for the 15 s the runtime took
// to stop, the power bundle among them dialling the system bus again, which restarted the bus during the
// shutdown and held it 29 minutes). A bundle that exits while it serves is started again; once the runtime
// itself is stopping, none is, by a restart or by a call.
func TestABundleIsStartedAgainUnlessTheRuntimeIsStopping(t *testing.T) {
saved := RestartFirst
RestartFirst = 50 * time.Millisecond
t.Cleanup(func() { RestartFirst = saved; shuttingDown.Store(false) })
for _, row := range []struct {
name string
stopping bool
starts int
}{
{"a bundle that dies while the runtime serves", false, 2},
{"a bundle signalled with the stopping runtime", true, 1},
} {
t.Run(row.name, func(t *testing.T) {
shuttingDown.Store(false)
dir := t.TempDir()
entry, out := filepath.Join(dir, "bundle"), filepath.Join(dir, "out")
if err := os.WriteFile(entry, []byte(notingBundle), 0o755); err != nil {
t.Fatal(err)
}
l, err := Start("noting", entry, append(os.Environ(), "OUT="+out), &subscribing{}, t.Logf)
if err != nil {
t.Fatal(err)
}
t.Cleanup(l.Stop)
said, _ := os.ReadFile(out)
var pid int
if _, err := fmt.Sscanf(string(said), "started %d", &pid); err != nil {
t.Fatalf("the bundle noted %q", said)
}
if row.stopping {
ShutDown()
}
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for {
said, _ = os.ReadFile(out)
n := strings.Count(string(said), "started")
if n > row.starts {
t.Fatalf("started %d times, want %d", n, row.starts)
}
if time.Now().After(deadline) {
if n != row.starts {
t.Fatalf("started %d times, want %d", n, row.starts)
}
break
}
time.Sleep(20 * time.Millisecond)
}
})
}
}