Keep what an archive unpacked in the engine's own state directory, never beside the archive, and stop no slower than the host waits (issue 1585, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-host@998d8f737008
mesh/check being carried to 5dc3a8e17f97 on 998d8f737008 | from fabfdf04f78a

The journal of an unpack sat at <target>.unpacked.json, in a directory
the module's account may write: a forged one would make the root engine
remove or adopt any path it named. It now lives under
<statedir>/unpacked/<sha256 of the target>.json, mode 0600, names its
target, and is refused whole when an entry is not filepath.IsLocal or a
parent is not above the target. It goes when the archive is taken off,
whether its state record still said what it unpacked or not.

The launcher's stop grace for a host on trial rises from 20 s to 75 s and
the unit's TimeoutStopSec is 120 s (openrc the same), past the host's own
60 s wait for the apply in flight, so neither cuts a save off again.

Tests folded as rows into existing ones (the repository is over its unit
budget): the journal's rows, a forged entry and parent refused, the
journal gone with a record that lost its Unpacked; the test that passed
on main dropped; the stop test waits on its own line, not a sleep, and no
longer consumes the save it waits for.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
This commit is contained in:
2026-11-04 10:52:42 +01:00
parent 768f637bd0
commit 5dc3a8e17f
13 changed files with 234 additions and 175 deletions
+18 -4
View File
@@ -140,15 +140,25 @@ func TestOnlyOneApplyRunsAtATime(t *testing.T) {
applying.Lock()
var said []string
var mu sync.Mutex
say := func(line string) { mu.Lock(); said = append(said, line); mu.Unlock() }
waiting := make(chan struct{})
say := func(line string) {
mu.Lock()
said = append(said, line)
mu.Unlock()
if strings.Contains(line, "waiting up to") {
close(waiting)
}
}
stopped := make(chan func(), 1)
go func() { stopped <- stopBetweenApplies(say, time.Hour) }()
// Said before it blocks, and it cannot end before the apply lets go: so it has not ended now.
<-waiting
select {
case release := <-stopped:
release()
applying.Unlock()
t.Fatal("the stop did not wait for the apply in flight")
case <-time.After(50 * time.Millisecond):
default:
}
if err := store.Save(path, store.State{Resources: []store.Applied{{ID: "nats.program-snapshot", Type: "archive"}}}); err != nil {
t.Fatal(err)
@@ -157,13 +167,17 @@ func TestOnlyOneApplyRunsAtATime(t *testing.T) {
release := <-stopped
saved := make(chan error, 1)
go func() { saved <- store.Save(path, store.State{}) }()
// A save after the stop blocks; nothing says it blocks, so this one wait is a bounded look.
select {
case <-saved:
t.Error("a save started after the stop")
release()
t.Fatal("a save started after the stop")
case <-time.After(50 * time.Millisecond):
}
release()
<-saved
if err := <-saved; err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(said) != 2 || !strings.Contains(said[0], "waiting up to 1h0m0s") || !strings.Contains(said[1], "its state is saved") {
+4 -4
View File
@@ -610,7 +610,7 @@ func applyScoped(
err = fmt.Errorf("%w: %s", errRemovalWaits, said)
}
default:
action, detail, err = remove(ctx, sys, orphan, run, made)
action, detail, err = remove(ctx, sys, orphan, run, made, journalDir(windows))
}
if errors.Is(err, errNoRemoval) && store.IsFormer(orphan.ID) {
// **A former target of a kind the host cannot remove is left in place and forgotten,
@@ -1572,7 +1572,7 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
case *declaration.User:
return applyUser(ctx, sys, res, run, previous, in.groups, in.handed)
case *declaration.Archive:
return applyArchive(ctx, res, previous, in.away)
return applyArchive(ctx, res, previous, in.away, journalDir(in.windows))
case *declaration.Process:
return applyProcess(ctx, res, run, changed, previous, in.away)
case *declaration.Action:
@@ -2483,7 +2483,7 @@ func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service,
//
// made is the units whose unit file this host wrote where there was none (meshMadeUnits).
func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
made map[string]bool) (string, string, error) {
made map[string]bool, journals string) (string, string, error) {
switch declaration.Type(a.Type) {
case declaration.TypeDirectory, declaration.TypeFile, declaration.TypeArchive:
// Removal below a home follows no link an account put there either (novox/hq ADR 0266).
@@ -2609,7 +2609,7 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
case declaration.TypeArchive:
// Exactly what it unpacked, and the directories the host made for it once they are empty
// (novox/hq issue 162).
return removeArchive(a)
return removeArchive(a, journals)
default:
return "", "", fmt.Errorf("%w: a %q", errNoRemoval, a.Type)
+6 -3
View File
@@ -35,11 +35,14 @@ import (
// enough for a desktop theme and small enough to notice.
const maxArchive = 512 << 20
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied, away *storeAway) (Outcome, error) {
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied, away *storeAway, journals string) (Outcome, error) {
out := begin(r)
out.Action = "unchanged"
recorded := previous.Wrote != ""
previous = withJournal(previous, r.Path)
previous, err := withJournal(journals, previous, r.Path)
if err != nil {
return out, err
}
body, err := fetchMinding(ctx, r.Source, away)
if err != nil {
@@ -81,7 +84,7 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
}
out.unpacked = &unpacked
// At once, before the rest of the apply can fail or the engine stop (novox/hq issue 1585).
if err := keepJournal(r.Path, got, &unpacked); err != nil {
if err := keepJournal(journals, r.Path, got, &unpacked); err != nil {
return out, fmt.Errorf("%s was unpacked, and what it unpacked could not be written down: %w", r.Path, err)
}
out.Action = "updated"
+95 -26
View File
@@ -1,8 +1,13 @@
package apply
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/novox/mesh-host/internal/store"
)
@@ -12,53 +17,117 @@ import (
// The node's state is saved at the end of a module's apply, so an engine that stopped, was replaced or lost its
// state between the unpack and that save held files on the machine it had no record of. The next apply then found
// its own files and refused them as ones "the mesh did not put there" (issue 162), and a module could not move to
// its new build until a person removed them. So beside the directory, at once and atomically, the host keeps the
// digest it unpacked and the files and directories it placed; an apply that holds no record of the archive reads
// this instead of treating everything there as somebody's. The record in the state stays the authority: the
// journal is read only when there is none.
// its new build until a person removed them. So, at once and atomically, the host keeps the digest it unpacked and
// the files and directories it placed; an apply that holds no record of the archive reads this instead of treating
// everything there as somebody's. The record in the state stays the authority: the journal is read only when there
// is none.
//
// **In the node-engine's own state directory, readable and writable by root alone, never beside the archive.** The
// archive's directory may be writable by the module's account (a home, a data directory): a journal there could be
// forged, and a root engine would then remove, or take as its own, any path the forger named. And an entry is read
// only when it names a path inside the archive's directory (filepath.IsLocal), and a parent only when it is above it.
// journalSuffix is appended to an archive's path to name its journal.
const journalSuffix = ".unpacked.json"
// JournalDirName is the directory under the node's state directory that keeps the journals.
const JournalDirName = "unpacked"
type unpackJournal struct {
Target string `json:"target"`
Wrote string `json:"wrote"`
Unpacked *store.Unpacked `json:"unpacked"`
}
func journalPath(path string) string { return path + journalSuffix }
// journalDir is where the journals of this apply are kept: under the state directory every applier on the machine
// shares (the one its windows are kept in). Empty for a caller with no state directory (a test), which keeps none.
func journalDir(w *Windows) string {
if w == nil || w.dir == "" {
return ""
}
return filepath.Join(filepath.Dir(w.dir), JournalDirName)
}
// journalPath names the journal of the archive at target: by the digest of its path, so no path names a file
// outside the journals' directory.
func journalPath(dir, target string) string {
sum := sha256.Sum256([]byte(filepath.Clean(target)))
return filepath.Join(dir, hex.EncodeToString(sum[:])+".json")
}
// keepJournal writes down what an archive unpacked at path.
func keepJournal(path, wrote string, u *store.Unpacked) error {
raw, err := json.Marshal(unpackJournal{Wrote: wrote, Unpacked: u})
func keepJournal(dir, path, wrote string, u *store.Unpacked) error {
if dir == "" {
return nil
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
raw, err := json.Marshal(unpackJournal{Target: filepath.Clean(path), Wrote: wrote, Unpacked: u})
if err != nil {
return err
}
return writeAtomicallyByPath(journalPath(path), raw, 0o600)
return writeAtomicallyByPath(journalPath(dir, path), raw, 0o600)
}
// journalOf is what the journal says of path, or false when there is none or it cannot be read.
func journalOf(path string) (unpackJournal, bool) {
raw, err := os.ReadFile(journalPath(path))
// journalOf is what the journal says of path: false when there is none, and an error when there is one that cannot
// be read or names a path outside the archive's directory, which is never believed.
func journalOf(dir, path string) (unpackJournal, bool, error) {
if dir == "" {
return unpackJournal{}, false, nil
}
raw, err := os.ReadFile(journalPath(dir, path))
if err != nil {
return unpackJournal{}, false
return unpackJournal{}, false, nil
}
var j unpackJournal
if json.Unmarshal(raw, &j) != nil || j.Wrote == "" || j.Unpacked == nil {
return unpackJournal{}, false
if err := json.Unmarshal(raw, &j); err != nil {
return unpackJournal{}, false, fmt.Errorf("the journal of what was unpacked at %s is unreadable: %w", path, err)
}
return j, true
if j.Wrote == "" || j.Unpacked == nil {
return unpackJournal{}, false, fmt.Errorf("the journal of what was unpacked at %s names no digest or files", path)
}
if err := checkJournal(j, filepath.Clean(path)); err != nil {
return unpackJournal{}, false, err
}
return j, true, nil
}
// withJournal is previous, or — when the state holds no record of the archive — what the journal says the host
// unpacked at path.
func withJournal(previous store.Applied, path string) store.Applied {
if previous.Wrote != "" {
return previous
// checkJournal refuses a journal of another target, an entry that is not a path inside the archive's directory, and
// a parent that is not above it.
func checkJournal(j unpackJournal, target string) error {
if j.Target != target {
return fmt.Errorf("the journal kept for %s names %q", target, j.Target)
}
j, ok := journalOf(path)
if !ok {
return previous
for _, rel := range append(append([]string(nil), j.Unpacked.Files...), j.Unpacked.Dirs...) {
if !filepath.IsLocal(filepath.FromSlash(rel)) {
return fmt.Errorf("the journal of what was unpacked at %s names %q, which is not inside it: not believed", target, rel)
}
}
for _, p := range j.Unpacked.Parents {
if p = filepath.Clean(p); !filepath.IsAbs(p) || p == target || !strings.HasPrefix(target, p+string(filepath.Separator)) {
return fmt.Errorf("the journal of what was unpacked at %s names the parent %q, which is not above it: not believed", target, p)
}
}
return nil
}
// withJournal is previous, or, when the state holds no record of the archive, what the journal says the host
// unpacked at path. A journal not believed is an error: the apply stops and says so rather than guess.
func withJournal(dir string, previous store.Applied, path string) (store.Applied, error) {
if previous.Wrote != "" {
return previous, nil
}
j, ok, err := journalOf(dir, path)
if err != nil || !ok {
return previous, err
}
previous.Wrote, previous.Target, previous.Unpacked = j.Wrote, path, j.Unpacked
return previous
return previous, nil
}
// dropJournal removes the journal of the archive at target, once the archive is taken off; whether the state's
// record of it still said what it unpacked or not.
func dropJournal(dir, target string) {
if dir == "" || target == "" {
return
}
_ = os.Remove(journalPath(dir, target))
}
-115
View File
@@ -1,115 +0,0 @@
package apply
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq issue 1585: an archive's files were refused as "not put there by the mesh" when the state held
// no record of them, though the host had unpacked them itself.
// An archive the host unpacked, whose record is gone, is its own: the next version replaces its files, drops the
// ones it no longer has, and leaves a file somebody added; a file the host never unpacked, or an unrecorded file
// with other bytes where no unpack was journaled, still refuses the archive.
func TestAnArchiveWhoseRecordIsGoneIsStillTheHosts(t *testing.T) {
rows := []struct {
name string
journaled bool // the first version was unpacked by the host
foreign string
wantRefuse bool
}{
{"unpacked by the host, record lost", true, "", false},
{"unpacked by the host, somebody's file beside", true, "mine.txt", false},
{"never unpacked by the host", false, "", true},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "tools")
first, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "one", "old": "o"}, "")
if row.journaled {
if _, _, err := Apply(context.Background(), archHost(t), declare(t, first), store.State{},
store.OriginDeclared, noServices, nil, nil); err != nil {
t.Fatal(err)
}
} else {
os.MkdirAll(target, 0o755)
os.WriteFile(filepath.Join(target, "bin"), []byte("one"), 0o644)
}
if row.foreign != "" {
os.WriteFile(filepath.Join(target, row.foreign), []byte("x"), 0o644)
}
second, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "two"}, "")
// The state is empty: the record of the first unpack was lost.
_, _, err := Apply(context.Background(), archHost(t), declare(t, second), store.State{},
store.OriginDeclared, noServices, nil, nil)
if row.wantRefuse {
if err == nil || !strings.Contains(err.Error(), "did not put there") {
t.Fatalf("refused? %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(filepath.Join(target, "bin")); string(got) != "two" {
t.Fatalf("bin is %q", got)
}
if exists(filepath.Join(target, "old")) {
t.Fatal("a file the new version no longer has is still there")
}
if row.foreign != "" && !exists(filepath.Join(target, row.foreign)) {
t.Fatal("somebody's file went")
}
})
}
}
// An unrecorded file already holding exactly the archive's bytes is not a collision; one with other bytes is.
func TestAnUnrecordedFileWithTheArchivesBytesIsNotACollision(t *testing.T) {
for _, row := range []struct {
name, has string
refused bool
}{{"same bytes", "theme", false}, {"other bytes", "somebody's", true}} {
t.Run(row.name, func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "theme")
os.MkdirAll(target, 0o755)
os.WriteFile(filepath.Join(target, "p10k.zsh"), []byte(row.has), 0o644)
archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "theme"}, "")
_, _, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil)
if (err != nil) != row.refused {
t.Fatalf("err = %v", err)
}
})
}
}
// Undeclaring an archive takes its journal with it, and a journal alone never makes a file the host's.
func TestTheJournalGoesWithTheArchive(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "theme")
archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"a": "a"}, "")
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if !exists(journalPath(target)) {
t.Fatal("no journal after the unpack")
}
if _, _, err := Apply(context.Background(), archHost(t),
declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
store.OriginDeclared, noServices, nil, nil); err != nil {
t.Fatal(err)
}
if exists(journalPath(target)) {
t.Fatal("the journal outlived the archive")
}
}
+93
View File
@@ -2,6 +2,7 @@ package apply
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -152,6 +153,98 @@ func TestAnArchiveDoesNotWriteOverAFileItFound(t *testing.T) {
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the directory", len(entries))
}
// Issue 1585: what the host unpacked is written down at once, in its own state directory, so an archive whose
// record in the state was lost is still the host's: the next version replaces its files, drops the ones it no
// longer has and leaves a file somebody added. A file the host never unpacked still refuses it, and so does a
// journal naming a path outside the archive's directory. The journal goes with the archive, record lost or not.
for _, row := range []struct {
name string
journaled bool // the first version was unpacked by the host
foreign string
forged func(j *unpackJournal)
refused string
}{
{"unpacked by the host, record lost", true, "", nil, ""},
{"unpacked by the host, somebody's file beside", true, "mine.txt", nil, ""},
{"never unpacked by the host", false, "", nil, "did not put there"},
{"a journal naming a path outside", true, "", func(j *unpackJournal) {
j.Unpacked.Files = append(j.Unpacked.Files, "../../etc/passwd")
}, "not inside it"},
{"a journal naming a parent not above", true, "", func(j *unpackJournal) {
j.Unpacked.Parents = []string{"/etc"}
}, "not above it"},
} {
t.Run(row.name, func(t *testing.T) {
dir, state := t.TempDir(), t.TempDir()
windows, journals := WindowsIn(state), filepath.Join(state, JournalDirName)
applyIt := func(decl string, known store.State) (store.State, error) {
_, after, err := ApplyMindingWindows(context.Background(), archHost(t), declare(t, decl), known,
store.OriginDeclared, noServices, nil, nil, nil, windows)
return after, err
}
target := filepath.Join(dir, "tools")
first, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "one", "old": "o"}, "")
if row.journaled {
if _, err := applyIt(first, store.State{}); err != nil {
t.Fatal(err)
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the archive: the journal is the state directory's", len(entries))
}
info, err := os.Stat(journalPath(journals, target))
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("no journal of root's alone after the unpack: %v %v", info, err)
}
} else {
os.MkdirAll(target, 0o755)
os.WriteFile(filepath.Join(target, "bin"), []byte("one"), 0o644)
}
if row.forged != nil {
j, _, err := journalOf(journals, target)
if err != nil {
t.Fatal(err)
}
row.forged(&j)
raw, _ := json.Marshal(j)
os.WriteFile(journalPath(journals, target), raw, 0o600)
}
if row.foreign != "" {
os.WriteFile(filepath.Join(target, row.foreign), []byte("x"), 0o644)
}
second, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "two"}, "")
// The state is empty: the record of the first unpack was lost.
after, err := applyIt(second, store.State{})
if row.refused != "" {
if err == nil || !strings.Contains(err.Error(), row.refused) {
t.Fatalf("refused? %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(filepath.Join(target, "bin")); string(got) != "two" {
t.Fatalf("bin is %q", got)
}
if exists(filepath.Join(target, "old")) {
t.Fatal("a file the new version no longer has is still there")
}
if row.foreign != "" && !exists(filepath.Join(target, row.foreign)) {
t.Fatal("somebody's file went")
}
// Taken off, with a record that no longer says what it unpacked: the journal goes too.
for i := range after.Resources {
after.Resources[i].Unpacked = nil
}
if _, err := applyIt(`{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`, after); err != nil {
t.Fatal(err)
}
if exists(journalPath(journals, target)) {
t.Fatal("the journal outlived the archive")
}
})
}
}
func TestADirectoryTheHostMadeGoesOnlyWhenEmpty(t *testing.T) {
+2 -15
View File
@@ -3,7 +3,6 @@ package apply
import (
"context"
"os"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
@@ -33,7 +32,7 @@ func TestAnArchiveReplacesTheTreeItWasUnpackedOver(t *testing.T) {
if _, err := os.Stat(dir + "/tools/node_modules"); err == nil {
t.Fatal("the previous archive's directory is still there")
}
entries := besideTheTree(dir)
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
names := []string{}
for _, e := range entries {
@@ -63,19 +62,7 @@ func TestARefusedArchiveLeavesTheTreeItWouldHaveReplaced(t *testing.T) {
if got, _ := os.ReadFile(dir + "/tools/index.js"); string(got) != "old" {
t.Fatalf("index.js is %q after a refused archive", got)
}
if entries := besideTheTree(dir); len(entries) != 1 {
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the tree after a refused archive", len(entries))
}
}
// besideTheTree is what sits in dir, but for the journals of what archives unpacked (novox/hq issue 1585).
func besideTheTree(dir string) []os.DirEntry {
all, _ := os.ReadDir(dir)
var out []os.DirEntry
for _, e := range all {
if !strings.HasSuffix(e.Name(), journalSuffix) {
out = append(out, e)
}
}
return out
}
+6 -5
View File
@@ -364,12 +364,13 @@ func mergeInto(fresh, path, owner string, files, dirs []string, o ours) (store.U
// module's tools from its bundle — sees the whole tree or none of it, never half, and a file it has
// open stays readable until it closes it. The runtime is told the module went by its own membership,
// not by the files disappearing.
func removeArchive(a store.Applied) (string, string, error) {
// First, so the parents the host made are empty by the time they are looked at.
if !store.IsFormer(a.ID) && a.Unpacked != nil {
os.Remove(journalPath(filepath.Clean(a.Target)))
func removeArchive(a store.Applied, journals string) (string, string, error) {
action, detail, err := removeArchiveFiles(a)
// And its journal (issue 1585), once the record goes: whether the record said what it unpacked or not.
if err == nil {
dropJournal(journals, a.Target)
}
return removeArchiveFiles(a)
return action, detail, err
}
func removeArchiveFiles(a store.Applied) (string, string, error) {
+1 -1
View File
@@ -159,7 +159,7 @@ func TestRemovalThroughALinkUnderAHomeIsRefused(t *testing.T) {
home, outside := aLinkedHome(t)
link(t, outside, filepath.Join(home, ".claude"))
_, _, err := remove(context.Background(), archHost(t), store.Applied{ID: "m.f", Type: "file",
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil)
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil, "")
mustBeLinkRefusal(t, err)
if _, err := os.Stat(filepath.Join(outside, "shadow")); err != nil {
t.Fatal("removed through the link")
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -38,7 +38,9 @@ BACKOFF="${MESH_HOST_BACKOFF:-5}"
# How long a host on trial has to report, in seconds: to-be 45 §8's ten minutes from the apply.
BOUND="${MESH_HOST_TRIAL_BOUND:-600}"
TICK="${MESH_HOST_TRIAL_TICK:-5}"
GRACE="${MESH_HOST_STOP_GRACE:-20}"
# How long a host told to stop is waited for before it is killed, in seconds: past the 60 s the host itself waits for
# the apply in flight to end and save (novox/hq issue 1585), so a stop never cuts a save off.
GRACE="${MESH_HOST_STOP_GRACE:-75}"
ONCE="${MESH_HOST_RUN_ONCE:-}" # tests run one iteration; nothing else sets this
ATTEMPTS="$STATE_DIR/start-attempts"
+2
View File
@@ -5,4 +5,6 @@
name="nox-mesh-host"
command="/usr/lib/nox-mesh-host/launch"
supervisor="supervise-daemon"
# The same stop bound as the systemd unit's TimeoutStopSec (issue 1585): TERM, 120 s, then KILL.
retry="TERM/120/KILL/5"
depend() { need net; }
+3
View File
@@ -12,6 +12,9 @@ Restart=always
RestartSec=5s
StateDirectory=mesh-host
KillMode=mixed
# Past the launcher's own stop grace (75 s), which is past the 60 s the host waits for the apply in
# flight to end and save (novox/hq issue 1585): the service manager's kill is the last resort.
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target