1package live23import (4 "bytes"5 "context"6 "errors"7 "io"8 "io/fs"9 "os"10 "path/filepath"11 "strings"12 "time"1314 "src.rest/cat/012/internal/fileio"15)1617// File follows a file with the standard library's stat alone (no18// inotify or kqueue, which differ by OS and need cgo or a module), which19// polling every Interval makes cheap enough: one stat a poll while20// nothing changes.21//22// A text table (CSV, TSV, JSON lines, NUON; fileio.Kind.Grows) is read23// as it grows: each poll reads what was appended, and a record cut off24// at the end waits for the rest (fileio.Tail). The file is read again25// whole when it shrinks (truncated), is another file (rotated: the path26// names a new inode), or its bytes before where reading stopped differ27// (rewritten in place). Any other format is read again whole when its28// size or modification time changes, once they have held still for29// Debounce, so a file being written isn't read half done.30type File struct {31 // Name is how the file is named to the user, in errors.32 Name string33 path string34 kind fileio.Kind35 opt fileio.Options36 loaded bool37 err string3839 // A growing file: what identifies it, how far it's read, and the40 // bytes before that, which a rewrite changes.41 info os.FileInfo42 offset int6443 mark []byte44 tail *fileio.Tail4546 // A file read whole: its stamp when last read, and the one last47 // seen with when it was first seen, for the debounce.48 read, seen stamp49 seenAt time.Time5051 // Debounce is how long a file read whole must hold still.52 Debounce time.Duration53 // MaxRead bounds what one poll reads of a growing file, so a large54 // file comes in over several polls, each a short step for the UI.55 MaxRead int6456 // Now is the clock; tests replace it.57 Now func() time.Time58}5960// stamp is what tells a file changed: its size and modification time.61type stamp struct {62 size int6463 mod time.Time64}6566func stampOf(st os.FileInfo) stamp { return stamp{st.Size(), st.ModTime()} }6768// markLen is how many bytes before the read offset tell an append from69// a rewrite.70const markLen = 647172// NewFile follows the file at path (on disk) in format k, with opt's73// table, query and locale (fileio.Options); name is how the user named it.74func NewFile(name, path string, k fileio.Kind, opt fileio.Options) *File {75 return &File{Name: name, path: path, kind: k, opt: opt, Debounce: 300 * time.Millisecond, MaxRead: 1 << 20, Now: time.Now}76}7778// Poll reads what changed (see File).79func (f *File) Poll(ctx context.Context) (Update, bool) {80 st, err := os.Stat(f.path)81 if err != nil {82 return f.fail(err)83 }84 var u Update85 var ok bool86 if f.kind.Grows() {87 u, ok = f.pollGrowing(st)88 } else {89 u, ok = f.pollWhole(ctx, st)90 }91 u.At = f.Now()92 if ok && u.Err == "" {93 f.err = ""94 }95 return u, ok96}9798// fail reports err once, until the file can be read again.99func (f *File) fail(err error) (Update, bool) {100 msg := err.Error()101 switch {102 case errors.Is(err, fs.ErrNotExist):103 msg = "the file isn't there"104 case errors.Is(err, fs.ErrPermission):105 msg = "permission denied"106 default:107 // Errors name the path on disk; the user named the file.108 msg = strings.ReplaceAll(msg, f.path, f.Name)109 }110 if msg == f.err {111 return Update{}, false112 }113 f.err = msg114 f.Reload() // it's read whole once it's back115 return Update{Err: msg, At: f.Now()}, true116}117118// Reload has the next poll read the file again, whole.119func (f *File) Reload() {120 f.loaded = false121 f.read, f.seen = stamp{}, stamp{}122 if f.tail != nil {123 f.tail.Close()124 f.tail = nil125 }126}127128// Close lets the file go.129func (f *File) Close() { f.Reload() }130131// pollWhole reads a file that is rewritten, not appended to, once it132// has held still.133func (f *File) pollWhole(ctx context.Context, st os.FileInfo) (Update, bool) {134 s := stampOf(st)135 if f.loaded && s == f.read {136 return Update{}, false137 }138 now := f.Now()139 if s != f.seen {140 f.seen, f.seenAt = s, now141 if f.loaded {142 return Update{}, false // changing: wait for it to settle143 }144 }145 if f.loaded && now.Sub(f.seenAt) < f.Debounce {146 return Update{}, false147 }148 res, err := fileio.Import(ctx, f.path, f.opt)149 if err != nil {150 return f.fail(err)151 }152 f.loaded, f.read = true, s153 rows := fileio.Rows(res.Sheet)154 return Update{Reset: true, Header: rows.Header, Rows: rows.Rows, Note: strings.Join(res.Notes, "; ")}, true155}156157// pollGrowing reads what a growing file gained, or the whole file again158// when it was truncated, rotated or rewritten.159func (f *File) pollGrowing(st os.FileInfo) (Update, bool) {160 reset := !f.loaded || !os.SameFile(f.info, st) || st.Size() < f.offset161 if !reset && st.Size() == f.offset && st.ModTime().Equal(f.info.ModTime()) {162 return Update{}, false163 }164 file, err := os.Open(f.path)165 if err != nil {166 return f.fail(err)167 }168 defer file.Close()169 if !reset && !f.sameMark(file) {170 reset = true171 }172 if reset {173 if err := f.restart(file); err != nil {174 return f.fail(err)175 }176 }177 f.info = st178 u := Update{Reset: reset}179 end := min(st.Size(), f.offset+f.MaxRead)180 if end > f.offset {181 piece := make([]byte, end-f.offset)182 n, err := file.ReadAt(piece, f.offset)183 if err != nil && !errors.Is(err, io.EOF) {184 return f.fail(err)185 }186 piece = piece[:n]187 rows, err := f.tail.Feed(piece)188 if err != nil {189 f.Reload()190 return Update{Err: err.Error(), At: f.Now()}, true191 }192 f.offset += int64(n)193 f.remember(piece)194 u.Header, u.Rows, u.More = rows.Header, rows.Rows, f.offset < st.Size()195 }196 return u, reset || u.Header != nil || len(u.Rows) > 0197}198199// restart reads the file from its start: a new tail, sniffing its head.200func (f *File) restart(file *os.File) error {201 if f.tail != nil {202 f.tail.Close()203 }204 head := make([]byte, 64<<10)205 n, err := file.ReadAt(head, 0)206 if err != nil && !errors.Is(err, io.EOF) {207 return err208 }209 tail, err := fileio.NewTail(f.kind, head[:n], f.opt.Locale)210 if err != nil {211 return err212 }213 f.tail, f.offset, f.mark, f.loaded = tail, 0, nil, true214 return nil215}216217// remember keeps the last bytes read, which a rewrite changes.218func (f *File) remember(piece []byte) {219 f.mark = append(f.mark, piece[max(len(piece)-markLen, 0):]...)220 f.mark = f.mark[max(len(f.mark)-markLen, 0):]221}222223// sameMark reports whether the bytes before the offset are as read.224func (f *File) sameMark(file *os.File) bool {225 if len(f.mark) == 0 {226 return true227 }228 b := make([]byte, len(f.mark))229 n, _ := file.ReadAt(b, f.offset-int64(len(b)))230 return bytes.Equal(b[:n], f.mark)231}232233// KindOf is the format a linked file is read in: format, named as234// fileio.Kind.String names it, or else the one its name tells.235func KindOf(name, format string) (fileio.Kind, bool) {236 for _, k := range fileio.Kinds() {237 if strings.EqualFold(k.String(), format) {238 return k, true239 }240 }241 return fileio.KindOf(filepath.Base(name))242}