Files
mesh-tools/node-tools/internal/runtime/watch.go
T
jschoubben 2cd5550dc7
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
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)
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.
2026-10-25 15:44:10 +01:00

207 lines
6.7 KiB
Go

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). 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 {
target := filepath.Dir(p)
watched := ""
for {
dir := nearestDir(target)
if dir == watched {
break
}
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
}
w.byWD[wd] = dir
watched = dir
}
}
}
// 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
}
up := filepath.Dir(dir)
if up == dir {
return dir
}
dir = up
}
}
func (w *bundleWatch) loop() {
defer close(w.done)
buf := make([]byte, 64*1024)
// Looked at once as the watch begins: a bundle that came between its load and the watch is not missed.
pending := true
quietAt := time.Now()
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; "" is a file that does not exist. Only the entrypoint: a delivery puts every file
// of a bundle in place anew (a directory swapped in, or each file renamed in), so the entrypoint is new
// whenever its build is, while a file beside it may be one the bundle itself writes (a cache, a socket),
// which would load it again after every quiet spell, and modules sharing a directory each other.
func signatureOf(path string) string {
info, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
return ""
}
if err != nil {
return "unreadable: " + err.Error()
}
return statSignature(info)
}
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 ""
}