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{} } // WatchFile hears, as the bundles are heard, the directory of the file at path, or the nearest directory above it that // exists, and calls changed once at its start and once after each burst of changes there has settled: a runtime waiting // for its broker credential reads it again when the node-engine writes it, never on a clock (issue 1496). It answers its // stop. func WatchFile(path string, changed func(), logf func(string, ...any)) (func(), error) { return watchBundles([]string{path}, changed, logf) } // 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 "" }