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.
207 lines
6.7 KiB
Go
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 ""
|
|
}
|