A stopped tool drops its membership handler and a stopped bundle starts no copy on a call, so a replaced bundle's old tools stay gone (issue 1945, review round 2)
mesh/delivery merged: source.main moved to novox/mesh-tools@5d6cee9d844e
mesh/check pass 2cd5550dc70f on 77b05089c8df | unit 0 run 126 kept | int 0 run 128 kept | clone 9.5s | composition passed on 4 machines

Its events are refused before the old bundle stops, the bundle watch follows a directory made between
its stat and its watch, and a bundle's reason is read without node's colour codes.
This commit is contained in:
2026-10-25 15:44:10 +01:00
parent d8ac69904d
commit 2cd5550dc7
5 changed files with 78 additions and 16 deletions
@@ -132,6 +132,7 @@ func fastSettle(t *testing.T) {
}
type reloadBed struct {
mesh *mt.Mesh
nodeTools *bus.Conn
asker *bus.Conn
logs *syncLogs
@@ -144,6 +145,7 @@ func newReloadBed(t *testing.T) *reloadBed {
mesh := mt.New(t)
mesh.Issue(t, mt.MembershipOf("ledger", "anchor", false, nil))
return &reloadBed{
mesh: mesh,
nodeTools: connect(t, "node-tools", "anchor"),
asker: connect(t, "console", "workstation"),
logs: &syncLogs{},
@@ -259,6 +261,27 @@ func TestAReplacedBundleIsReloaded(t *testing.T) {
b.answers(t, "fourth")
}
// A replaced bundle's old tool stays gone when the module's membership is issued again: a stopped tool
// leaves no membership handler behind to bind it once more, and a call on it starts no unmanaged second
// copy of the bundle (issue 1945, review round 2).
func TestAReplacedBundlesOldToolStaysGoneOnTheNextMembership(t *testing.T) {
b := newReloadBed(t)
place(t, b.entry, "first")
b.run(t)
b.answers(t, "first")
place(t, b.entry, "second")
b.answers(t, "second")
b.silent(t, "first")
b.mesh.Issue(t, mt.MembershipOf("ledger", "anchor", false, nil))
b.answers(t, "second")
// Long enough for the reissued membership to have been heard, before the old tool is asked.
time.Sleep(300 * time.Millisecond)
if got, err := call(t, b.asker, "ledger.first@anchor", map[string]any{}); err == nil {
t.Fatalf("ledger.first is served again after the membership was reissued, and answered %s\n%s", got, b.logs.All())
}
}
// 1945/AC21: a bundle removed while served is no longer served, and its module says why.
func TestARemovedBundleIsNoLongerServed(t *testing.T) {
b := newReloadBed(t)
+2 -1
View File
@@ -480,8 +480,9 @@ func (r *runner) reload(module, what string) {
old := r.of[module]
r.of[module] = &loadedModule{served: old.served, seen: old.seen}
r.mu.Unlock()
old.unload()
// Its events are refused first, so one heard while the old bundle stops is refused at once.
r.events.dropModule(module)
old.unload()
r.logf("[mesh-tools] %s's bundle %s; loading it again", module, what)
fresh := r.load(old.served)
+25 -11
View File
@@ -61,26 +61,40 @@ func watchBundles(paths []string, changed func(), logf func(string, ...any)) (fu
}
// arm watches, for every entrypoint, its directory, or the nearest directory above it that exists. A
// watch already held on the same directory is kept (inotify answers its descriptor again).
// watch already held on the same directory is kept (inotify answers its descriptor again). A directory
// made below the watched one between its stat and its watch would be heard by nobody, so after each watch
// the path is looked at again, and a deeper directory that came to be is watched in turn.
func (w *bundleWatch) arm() {
for _, p := range w.paths {
dir := filepath.Dir(p)
target := filepath.Dir(p)
watched := ""
for {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
dir := nearestDir(target)
if dir == watched {
break
}
up := filepath.Dir(dir)
if up == dir {
wd, err := unix.InotifyAddWatch(w.fd, dir, watchMask)
if err != nil {
w.logf("[mesh-tools] cannot watch %s for %s's bundle: %v; a bundle placed or changed there is served only after a restart", dir, p, err)
break
}
dir = up
w.byWD[wd] = dir
watched = dir
}
wd, err := unix.InotifyAddWatch(w.fd, dir, watchMask)
if err != nil {
w.logf("[mesh-tools] cannot watch %s for %s's bundle: %v; a bundle placed or changed there is served only after a restart", dir, p, err)
continue
}
}
// nearestDir is dir when it is a directory, else the nearest directory above it.
func nearestDir(dir string) string {
for {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
w.byWD[wd] = dir
up := filepath.Dir(dir)
if up == dir {
return dir
}
dir = up
}
}