Serve a tools bundle as soon as it is in place, changed or gone, with no restart; say why a bundle cannot start (issue 1945)
node-tools loaded every bundle once at start, so a bundle delivered after it started (mesh-files on shanks, 2026-10-25 12:08) went unserved until a hand restart, and a missing file was said as "not executable". The runtime now watches the entrypoints' directories with inotify, loads a module again once a burst of changes settles, and discovery answers with what is served now. A bundle that cannot start says it does not exist, is not executable with its mode, or the exec error. Tests: 1945/AC19-AC22 in internal/runtime/reload_test.go.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Settle is how long the bundles' directories stay quiet before the runtime acts on what changed there:
|
||||
// a delivery unpacks a bundle in many writes, and the runtime loads it once, when they are done
|
||||
// (issue 1945, note 23).
|
||||
var Settle = 300 * time.Millisecond
|
||||
|
||||
// watchMask is what the runtime hears of a watched directory: a file or directory made, written,
|
||||
// renamed in or out, removed or given another mode, and the directory itself removed or renamed.
|
||||
const watchMask = unix.IN_CREATE | unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_MOVED_FROM | unix.IN_DELETE |
|
||||
unix.IN_ATTRIB | unix.IN_DELETE_SELF | unix.IN_MOVE_SELF | unix.IN_ONLYDIR
|
||||
|
||||
// bundleWatch hears, through inotify, the directory of every entrypoint the runtime serves, or for one
|
||||
// not there yet the nearest directory above it that is, and calls changed once a burst of changes has
|
||||
// settled. Nothing is looked at on a timer: between bursts the watch sleeps in poll(2).
|
||||
type bundleWatch struct {
|
||||
fd int
|
||||
wake [2]int // a pipe: a byte written to wake[1] ends the loop
|
||||
paths []string
|
||||
byWD map[int]string
|
||||
settle time.Duration
|
||||
changed func()
|
||||
logf func(string, ...any)
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// watchBundles starts the watch of the entrypoints at paths. It answers its stop.
|
||||
func watchBundles(paths []string, changed func(), logf func(string, ...any)) (func(), error) {
|
||||
fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inotify: %w", err)
|
||||
}
|
||||
w := &bundleWatch{fd: fd, paths: paths, byWD: map[int]string{}, settle: Settle, changed: changed, logf: logf,
|
||||
done: make(chan struct{})}
|
||||
if err := unix.Pipe2(w.wake[:], unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("pipe: %w", err)
|
||||
}
|
||||
w.arm()
|
||||
go w.loop()
|
||||
return func() {
|
||||
_, _ = unix.Write(w.wake[1], []byte{1})
|
||||
<-w.done
|
||||
unix.Close(w.fd)
|
||||
unix.Close(w.wake[0])
|
||||
unix.Close(w.wake[1])
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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).
|
||||
func (w *bundleWatch) arm() {
|
||||
for _, p := range w.paths {
|
||||
dir := filepath.Dir(p)
|
||||
for {
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
break
|
||||
}
|
||||
up := filepath.Dir(dir)
|
||||
if up == dir {
|
||||
break
|
||||
}
|
||||
dir = up
|
||||
}
|
||||
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
|
||||
}
|
||||
w.byWD[wd] = dir
|
||||
}
|
||||
}
|
||||
|
||||
func (w *bundleWatch) loop() {
|
||||
defer close(w.done)
|
||||
buf := make([]byte, 64*1024)
|
||||
pending := false
|
||||
var quietAt time.Time
|
||||
for {
|
||||
timeout := -1
|
||||
if pending {
|
||||
timeout = int((time.Until(quietAt) + time.Millisecond - 1) / time.Millisecond)
|
||||
if timeout < 0 {
|
||||
timeout = 0
|
||||
}
|
||||
}
|
||||
fds := []unix.PollFd{{Fd: int32(w.fd), Events: unix.POLLIN}, {Fd: int32(w.wake[0]), Events: unix.POLLIN}}
|
||||
n, err := unix.Poll(fds, timeout)
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
w.logf("[mesh-tools] the watch of the bundles stopped: %v; a bundle placed or changed later is served only after a restart", err)
|
||||
return
|
||||
}
|
||||
if fds[1].Revents != 0 {
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
// Quiet for the settle: watch the directories that came to be, then act on what changed.
|
||||
pending = false
|
||||
w.arm()
|
||||
w.changed()
|
||||
continue
|
||||
}
|
||||
if fds[0].Revents&unix.POLLIN != 0 {
|
||||
w.drain(buf)
|
||||
pending = true
|
||||
quietAt = time.Now().Add(w.settle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drain reads every event queued, dropping the watches of directories that went away or moved: the
|
||||
// next arm watches what is at their path now.
|
||||
func (w *bundleWatch) drain(buf []byte) {
|
||||
for {
|
||||
n, err := unix.Read(w.fd, buf)
|
||||
if err != nil || n <= 0 {
|
||||
return
|
||||
}
|
||||
for off := 0; off+unix.SizeofInotifyEvent <= n; {
|
||||
wd := int(int32(binary.NativeEndian.Uint32(buf[off:])))
|
||||
mask := binary.NativeEndian.Uint32(buf[off+4:])
|
||||
length := int(binary.NativeEndian.Uint32(buf[off+12:]))
|
||||
switch {
|
||||
case mask&unix.IN_IGNORED != 0:
|
||||
delete(w.byWD, wd)
|
||||
case mask&unix.IN_MOVE_SELF != 0:
|
||||
_, _ = unix.InotifyRmWatch(w.fd, uint32(wd))
|
||||
delete(w.byWD, wd)
|
||||
}
|
||||
off += unix.SizeofInotifyEvent + length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// signatureOf is what the runtime compares to tell that an entrypoint changed: the file's identity,
|
||||
// mode, size and times, and those of every file beside it (a launcher starts what its build wrote
|
||||
// next to it). "" is a file that does not exist.
|
||||
func signatureOf(path string) string {
|
||||
info, err := os.Stat(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
return "unreadable: " + err.Error()
|
||||
}
|
||||
sig := statSignature(info)
|
||||
if entries, err := os.ReadDir(filepath.Dir(path)); err == nil {
|
||||
for _, e := range entries {
|
||||
if i, err := e.Info(); err == nil {
|
||||
sig += "|" + e.Name() + " " + statSignature(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
func statSignature(info fs.FileInfo) string {
|
||||
sig := fmt.Sprintf("%s %d %d", info.Mode(), info.Size(), info.ModTime().UnixNano())
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
sig += fmt.Sprintf(" %d:%d %d.%d", st.Dev, st.Ino, st.Ctim.Sec, st.Ctim.Nsec)
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
// notStartable says why the runtime cannot start an entrypoint, in its real reason, or "" when it can
|
||||
// (issue 1945, note 23: a missing file was said as "not executable").
|
||||
func notStartable(path string) string {
|
||||
info, err := os.Stat(path)
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return path + " does not exist; it is served as soon as it is in place"
|
||||
case err != nil:
|
||||
return path + " cannot be read: " + err.Error()
|
||||
case info.IsDir():
|
||||
return path + " is a directory, not a bundle's entrypoint"
|
||||
case info.Mode().Perm()&0o111 == 0:
|
||||
return fmt.Sprintf("%s is not executable (mode %s); a bundle the runtime serves is started, never imported, "+
|
||||
"and its build makes it executable (novox/hq ADR 0193)", path, info.Mode().Perm())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user