A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tail): add -f follow polling and -c +N from-byte

Implements GNU coreutils compatibility for tail:

- -f follow mode with polling (re-reads file on growth,
terminates on context cancel)
- -c +N starts at byte N (1-based; +0/+1 from start)
- -n +N starts at line N (already present)
- Trailing-newline preservation
- -NUM shorthand (tail -2 file)
- Size suffix multipliers on -c
- Multi-file ==> name <== headers with -q/-v

-F (follow by name through renames) deferred since billy
has no inode-equivalent for rename tracking.

Refs: docs/posix2018/CONFORMANCE.md
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 65aa225d 4183dfcc

+546 -23
+241 -20
command/internal/tail/tail.go
··· 8 8 "path" 9 9 "strconv" 10 10 "strings" 11 + "time" 11 12 12 13 "github.com/pborman/getopt/v2" 13 14 "mvdan.cc/sh/v3/interp" 14 15 "tangled.org/xeiaso.net/kefka/command" 15 16 ) 16 17 18 + // followPollInterval is the duration tail -f sleeps between polls for new 19 + // data. It is a package-level variable so tests can shorten it. 20 + var followPollInterval = time.Second 21 + 17 22 type Impl struct{} 18 23 19 - func (Impl) Exec(_ context.Context, ec *command.ExecContext, args []string) error { 24 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 20 25 if ec == nil { 21 26 return errors.New("tail: nil ExecContext") 22 27 } ··· 39 44 fmt.Fprint(stderr, "Print the last 10 lines of each FILE to standard output.\n") 40 45 fmt.Fprint(stderr, "With more than one FILE, precede each with a header giving the file name.\n") 41 46 fmt.Fprint(stderr, "With no FILE, or when FILE is -, read standard input.\n\n") 42 - fmt.Fprint(stderr, " -c, --bytes=NUM print the last NUM bytes\n") 43 - fmt.Fprint(stderr, " -n, --lines=NUM print the last NUM lines (default 10)\n") 44 - fmt.Fprint(stderr, " -n +NUM print starting from line NUM\n") 47 + fmt.Fprint(stderr, " -c, --bytes=[+]NUM print the last NUM bytes; or use -c +NUM to\n") 48 + fmt.Fprint(stderr, " output starting with byte NUM of each file\n") 49 + fmt.Fprint(stderr, " -f, --follow output appended data as the file grows\n") 50 + fmt.Fprint(stderr, " -n, --lines=[+]NUM print the last NUM lines (default 10); or use\n") 51 + fmt.Fprint(stderr, " -n +NUM to output starting with line NUM\n") 45 52 fmt.Fprint(stderr, " -q, --quiet never print headers giving file names\n") 46 53 fmt.Fprint(stderr, " -v, --verbose always print headers giving file names\n") 47 - fmt.Fprint(stderr, " --help display this help and exit\n") 54 + fmt.Fprint(stderr, " --help display this help and exit\n\n") 55 + fmt.Fprint(stderr, "NUM may have a multiplier suffix:\n") 56 + fmt.Fprint(stderr, "b 512, kB 1000, K 1024, MB 1000*1000, M 1024*1024,\n") 57 + fmt.Fprint(stderr, "GB 1000*1000*1000, G 1024*1024*1024, and so on for T, P, E, Z, Y.\n\n") 58 + fmt.Fprint(stderr, "Note: -F (follow by name through renames) is not supported; use -f.\n") 48 59 } 49 60 set.SetUsage(usage) 50 61 51 62 bytesSpec := set.StringLong("bytes", 'c', "", "print the last NUM bytes") 52 63 linesSpec := set.StringLong("lines", 'n', "", "print the last NUM lines (default 10)") 64 + follow := set.BoolLong("follow", 'f', "output appended data as the file grows") 53 65 quiet := set.BoolLong("quiet", 'q', "never print headers giving file names") 54 66 silent := set.BoolLong("silent", 0, "alias for --quiet") 55 67 verbose := set.BoolLong("verbose", 'v', "always print headers giving file names") ··· 71 83 bytes := 0 72 84 bytesSet := false 73 85 fromLine := false 86 + fromByte := false 74 87 75 88 if *bytesSpec != "" { 76 - n, err := strconv.Atoi(*bytesSpec) 89 + spec := *bytesSpec 90 + if strings.HasPrefix(spec, "+") { 91 + fromByte = true 92 + spec = spec[1:] 93 + } 94 + n, err := parseTailCount(spec) 77 95 if err != nil || n < 0 { 78 96 fmt.Fprint(stderr, "tail: invalid number of bytes\n") 79 97 return interp.ExitStatus(1) ··· 87 105 fromLine = true 88 106 spec = spec[1:] 89 107 } 90 - n, err := strconv.Atoi(spec) 108 + n, err := parseTailCount(spec) 91 109 if err != nil || n < 0 { 92 110 fmt.Fprint(stderr, "tail: invalid number of lines\n") 93 111 return interp.ExitStatus(1) ··· 103 121 if err != nil { 104 122 return err 105 123 } 106 - io.WriteString(stdout, getTail(content, lines, bytes, bytesSet, fromLine)) 124 + io.WriteString(stdout, getTail(content, lines, bytes, bytesSet, fromLine, fromByte)) 125 + // POSIX: -f is ignored when reading standard input that is a pipe 126 + // or FIFO. We have no reliable way to tell from a generic Reader, 127 + // so treat stdin as non-followable: -f is a no-op here. 107 128 return nil 108 129 } 109 130 110 131 showHeaders := *verbose || (!isQuiet && len(files) > 1) 132 + 133 + // Track the post-read size for each file operand so that follow mode 134 + // (if requested) knows where to start reading new bytes from. 135 + states := make([]fileState, 0, len(files)) 136 + 111 137 var output strings.Builder 112 138 exitCode := 0 113 139 filesProcessed := 0 114 140 115 141 for _, file := range files { 116 142 content, err := readFile(ec, file) 143 + st := fileState{name: file} 117 144 if err != nil { 118 145 fmt.Fprintf(stderr, "tail: %s: No such file or directory\n", file) 119 146 exitCode = 1 147 + states = append(states, st) 120 148 continue 121 149 } 122 150 if showHeaders { ··· 125 153 } 126 154 fmt.Fprintf(&output, "==> %s <==\n", file) 127 155 } 128 - output.WriteString(getTail(content, lines, bytes, bytesSet, fromLine)) 156 + output.WriteString(getTail(content, lines, bytes, bytesSet, fromLine, fromByte)) 129 157 filesProcessed++ 158 + 159 + if file != "-" && ec.FS != nil { 160 + st.size = int64(len(content)) 161 + st.isRegFS = true 162 + } 163 + states = append(states, st) 130 164 } 131 165 132 166 io.WriteString(stdout, output.String()) 133 167 168 + if *follow && filesProcessed > 0 { 169 + // Flush any deferred header logic before entering the follow loop. 170 + if err := followLoop(ctx, ec, stdout, states, showHeaders); err != nil { 171 + // followLoop only returns errors from context cancellation, 172 + // which is the normal shutdown path; suppress. 173 + _ = err 174 + } 175 + } 176 + 134 177 if exitCode != 0 { 135 178 return interp.ExitStatus(uint8(exitCode)) 136 179 } 137 180 return nil 138 181 } 139 182 183 + // followLoop polls each followable file for appended bytes and writes them 184 + // to stdout. With multiple files, a "==> name <==" header is emitted when 185 + // switching between files, matching GNU coreutils behaviour. 186 + // 187 + // Returns when ctx is done. Per POSIX/GNU, follow mode runs until the 188 + // process is killed; here, we use the supplied context as the kill 189 + // signal. 190 + func followLoop(ctx context.Context, ec *command.ExecContext, stdout io.Writer, states []fileState, showHeaders bool) error { 191 + // Determine if we have anything followable at all. 192 + any := false 193 + for _, s := range states { 194 + if s.isRegFS { 195 + any = true 196 + break 197 + } 198 + } 199 + if !any { 200 + return nil 201 + } 202 + 203 + lastEmittedFile := "" 204 + if showHeaders { 205 + // The header for the last successfully read file was already 206 + // emitted in the synchronous phase; remember it so we don't 207 + // duplicate it on the first new-bytes write. 208 + for i := len(states) - 1; i >= 0; i-- { 209 + if states[i].isRegFS { 210 + lastEmittedFile = states[i].name 211 + break 212 + } 213 + } 214 + } 215 + 216 + for { 217 + select { 218 + case <-ctx.Done(): 219 + return ctx.Err() 220 + case <-time.After(followPollInterval): 221 + } 222 + 223 + for i := range states { 224 + s := &states[i] 225 + if !s.isRegFS { 226 + continue 227 + } 228 + info, err := ec.FS.Stat(resolvePath(ec, s.name)) 229 + if err != nil { 230 + continue 231 + } 232 + cur := info.Size() 233 + if cur <= s.size { 234 + // Truncation: GNU prints "file truncated" and resets. 235 + // For our minimal impl, just reset position so we 236 + // don't emit stale tail. 237 + if cur < s.size { 238 + s.size = cur 239 + } 240 + continue 241 + } 242 + // New bytes available. 243 + data, err := readBytesFrom(ec, s.name, s.size, cur-s.size) 244 + if err != nil { 245 + continue 246 + } 247 + if showHeaders && lastEmittedFile != s.name { 248 + if lastEmittedFile != "" { 249 + io.WriteString(stdout, "\n") 250 + } 251 + fmt.Fprintf(stdout, "==> %s <==\n", s.name) 252 + lastEmittedFile = s.name 253 + } 254 + stdout.Write(data) 255 + s.size = cur 256 + } 257 + } 258 + } 259 + 260 + // readBytesFrom opens the file, seeks to off, and reads up to n bytes. 261 + func readBytesFrom(ec *command.ExecContext, name string, off, n int64) ([]byte, error) { 262 + f, err := ec.FS.Open(resolvePath(ec, name)) 263 + if err != nil { 264 + return nil, err 265 + } 266 + defer f.Close() 267 + if off > 0 { 268 + if _, err := f.Seek(off, io.SeekStart); err != nil { 269 + return nil, err 270 + } 271 + } 272 + buf := make([]byte, n) 273 + read, err := io.ReadFull(f, buf) 274 + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { 275 + return nil, err 276 + } 277 + return buf[:read], nil 278 + } 279 + 280 + // fileState tracks per-file follow-mode state. 281 + type fileState struct { 282 + name string 283 + size int64 284 + isRegFS bool 285 + } 286 + 140 287 // preprocessShortNum rewrites the GNU coreutils -NUM shorthand (e.g. -5) 141 288 // into "-n NUM" so that getopt can parse it. Stops at "--" and skips the 142 289 // value of any preceding -c/-n/--bytes/--lines so that "-c -5" is left ··· 183 330 return out 184 331 } 185 332 186 - func getTail(content string, lines int, bytes int, bytesSet bool, fromLine bool) string { 333 + // parseTailCount accepts a non-negative decimal integer, optionally with a 334 + // GNU coreutils size-suffix multiplier (b, kB, K, MB, M, ...). A leading 335 + // '+' is rejected here; sign handling is the caller's responsibility. 336 + func parseTailCount(s string) (int, error) { 337 + if s == "" { 338 + return 0, errors.New("empty") 339 + } 340 + // Reject signs at this layer; the line/byte parsers strip the 341 + // '+' for fromLine mode before calling us. 342 + if s[0] == '+' || s[0] == '-' { 343 + return 0, errors.New("signed") 344 + } 345 + // Split numeric prefix from suffix. 346 + i := 0 347 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { 348 + i++ 349 + } 350 + if i == 0 { 351 + return 0, errors.New("not a number") 352 + } 353 + numPart := s[:i] 354 + suf := s[i:] 355 + 356 + n, err := strconv.Atoi(numPart) 357 + if err != nil || n < 0 { 358 + return 0, errors.New("not a number") 359 + } 360 + mult, ok := sizeMultiplier(suf) 361 + if !ok { 362 + return 0, fmt.Errorf("invalid suffix: %q", suf) 363 + } 364 + return n * mult, nil 365 + } 366 + 367 + // sizeMultiplier returns the GNU coreutils size suffix multiplier. 368 + func sizeMultiplier(suf string) (int, bool) { 369 + if suf == "" { 370 + return 1, true 371 + } 372 + if suf == "b" { 373 + return 512, true 374 + } 375 + if suf == "kB" { 376 + return 1000, true 377 + } 378 + siLetters := []byte{'M', 'G', 'T', 'P', 'E', 'Z', 'Y'} 379 + for idx, c := range siLetters { 380 + if len(suf) == 2 && suf[0] == c && suf[1] == 'B' { 381 + mult := 1 382 + for k := 0; k <= idx+1; k++ { 383 + mult *= 1000 384 + } 385 + return mult, true 386 + } 387 + } 388 + binLetters := []byte{'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'} 389 + for idx, c := range binLetters { 390 + if suf == string(c) || suf == string(c)+"B" { 391 + mult := 1 392 + for k := 0; k <= idx; k++ { 393 + mult *= 1024 394 + } 395 + return mult, true 396 + } 397 + } 398 + return 0, false 399 + } 400 + 401 + func getTail(content string, lines int, bytes int, bytesSet bool, fromLine bool, fromByte bool) string { 187 402 if bytesSet { 403 + if fromByte { 404 + // GNU semantics: -c +N starts output at byte N (1-based). 405 + // "+0" and "+1" both mean "from the very beginning". 406 + start := bytes - 1 407 + if start < 0 { 408 + start = 0 409 + } 410 + if start >= len(content) { 411 + return "" 412 + } 413 + return content[start:] 414 + } 188 415 if bytes >= len(content) { 189 416 return content 190 417 } ··· 205 432 lineCount++ 206 433 pos += idx + 1 207 434 } 208 - result := content[pos:] 209 - if !strings.HasSuffix(result, "\n") { 210 - result += "\n" 211 - } 212 - return result 435 + // Preserve the input's trailing-newline state: do not synthesize one. 436 + return content[pos:] 213 437 } 214 438 if lines == 0 { 215 439 return "" ··· 232 456 if pos < 0 { 233 457 pos = 0 234 458 } 235 - result := content[pos:] 236 - if content[n-1] != '\n' { 237 - result += "\n" 238 - } 239 - return result 459 + // Preserve the input's trailing-newline state: do not synthesize one. 460 + return content[pos:] 240 461 } 241 462 242 463 func readStdin(ec *command.ExecContext) (string, error) {
+305 -3
command/internal/tail/tail_test.go
··· 5 5 "context" 6 6 "os" 7 7 "strings" 8 + "sync" 8 9 "testing" 10 + "time" 9 11 10 12 "github.com/go-git/go-billy/v5" 11 13 "github.com/go-git/go-billy/v5/memfs" 12 14 "tangled.org/xeiaso.net/kefka/command" 13 15 ) 14 16 17 + // syncBuffer is a goroutine-safe bytes.Buffer wrapper for capturing 18 + // follow-mode output written from one goroutine while the test goroutine 19 + // reads it. 20 + type syncBuffer struct { 21 + mu sync.Mutex 22 + buf bytes.Buffer 23 + } 24 + 25 + func (b *syncBuffer) Write(p []byte) (int, error) { 26 + b.mu.Lock() 27 + defer b.mu.Unlock() 28 + return b.buf.Write(p) 29 + } 30 + 31 + func (b *syncBuffer) String() string { 32 + b.mu.Lock() 33 + defer b.mu.Unlock() 34 + return b.buf.String() 35 + } 36 + 37 + // lockedFS wraps a billy.Filesystem and serialises Open/OpenFile/Stat with 38 + // any writes made through writeAppend below. The memfs implementation has 39 + // internal races between fileInfo.Size() and content.WriteAt; this wrapper 40 + // exists so the -f follow-mode tests can run cleanly under -race. 41 + type lockedFS struct { 42 + billy.Filesystem 43 + mu sync.Mutex 44 + } 45 + 46 + func (l *lockedFS) Open(name string) (billy.File, error) { 47 + l.mu.Lock() 48 + defer l.mu.Unlock() 49 + f, err := l.Filesystem.Open(name) 50 + if err != nil { 51 + return nil, err 52 + } 53 + return &lockedFile{File: f, mu: &l.mu}, nil 54 + } 55 + 56 + func (l *lockedFS) OpenFile(name string, flag int, perm os.FileMode) (billy.File, error) { 57 + l.mu.Lock() 58 + defer l.mu.Unlock() 59 + f, err := l.Filesystem.OpenFile(name, flag, perm) 60 + if err != nil { 61 + return nil, err 62 + } 63 + return &lockedFile{File: f, mu: &l.mu}, nil 64 + } 65 + 66 + func (l *lockedFS) Stat(name string) (os.FileInfo, error) { 67 + l.mu.Lock() 68 + defer l.mu.Unlock() 69 + return l.Filesystem.Stat(name) 70 + } 71 + 72 + // lockedFile serialises Read/Write/Seek through the same lock as the 73 + // surrounding lockedFS, so concurrent appends and stats don't race on 74 + // memfs internal state. 75 + type lockedFile struct { 76 + billy.File 77 + mu *sync.Mutex 78 + } 79 + 80 + func (f *lockedFile) Read(p []byte) (int, error) { 81 + f.mu.Lock() 82 + defer f.mu.Unlock() 83 + return f.File.Read(p) 84 + } 85 + 86 + func (f *lockedFile) Write(p []byte) (int, error) { 87 + f.mu.Lock() 88 + defer f.mu.Unlock() 89 + return f.File.Write(p) 90 + } 91 + 92 + func (f *lockedFile) Seek(off int64, whence int) (int64, error) { 93 + f.mu.Lock() 94 + defer f.mu.Unlock() 95 + return f.File.Seek(off, whence) 96 + } 97 + 98 + func (f *lockedFile) Close() error { 99 + f.mu.Lock() 100 + defer f.mu.Unlock() 101 + return f.File.Close() 102 + } 103 + 15 104 func newFS(t *testing.T) billy.Filesystem { 16 105 t.Helper() 17 106 fs := memfs.New() ··· 130 219 wantStdout: "abcdefghij", 131 220 }, 132 221 { 133 - name: "no trailing newline gets one appended", 222 + name: "fromByte +N space form starts at byte N (1-based)", 223 + args: []string{"-c", "+5", "bytes.txt"}, 224 + wantStdout: "efghij", 225 + }, 226 + { 227 + name: "fromByte +N no space", 228 + args: []string{"-c+5", "bytes.txt"}, 229 + wantStdout: "efghij", 230 + }, 231 + { 232 + name: "fromByte +1 prints all", 233 + args: []string{"-c", "+1", "bytes.txt"}, 234 + wantStdout: "abcdefghij", 235 + }, 236 + { 237 + name: "fromByte beyond eof prints nothing", 238 + args: []string{"-c", "+100", "bytes.txt"}, 239 + wantStdout: "", 240 + }, 241 + { 242 + name: "fromByte +N from stdin preserves no trailing newline", 243 + args: []string{"-c", "+2"}, 244 + stdin: "a\nb\nc", 245 + wantStdout: "\nb\nc", 246 + }, 247 + { 248 + name: "missing final newline is preserved", 134 249 args: []string{"-n", "2", "nofinalnl.txt"}, 135 - wantStdout: "x2\nx3\n", 250 + wantStdout: "x2\nx3", 251 + }, 252 + { 253 + name: "stdin missing final newline single line preserved", 254 + args: []string{"-n", "1"}, 255 + stdin: "a\nb\nc", 256 + wantStdout: "c", 257 + }, 258 + { 259 + name: "stdin with final newline single line preserved", 260 + args: []string{"-n", "1"}, 261 + stdin: "a\nb\nc\n", 262 + wantStdout: "c\n", 263 + }, 264 + { 265 + name: "fewer lines than requested no final newline preserved", 266 + args: []string{"-n", "5"}, 267 + stdin: "a\nb\nc", 268 + wantStdout: "a\nb\nc", 269 + }, 270 + { 271 + name: "fromLine no final newline preserved", 272 + args: []string{"-n", "+2"}, 273 + stdin: "a\nb\nc", 274 + wantStdout: "b\nc", 275 + }, 276 + { 277 + name: "fromLine with final newline preserved", 278 + args: []string{"-n", "+1"}, 279 + stdin: "a\nb\nc\n", 280 + wantStdout: "a\nb\nc\n", 136 281 }, 137 282 { 138 283 name: "empty file produces empty output", ··· 195 340 args: []string{"--nope", "five.txt"}, 196 341 wantErr: true, 197 342 }, 343 + { 344 + name: "byte mode with K suffix", 345 + args: []string{"-c", "1K", "twelve.txt"}, 346 + wantStdout: "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nL11\nL12\n", 347 + }, 348 + { 349 + name: "byte mode with b suffix is 512", 350 + args: []string{"-c", "1b", "twelve.txt"}, 351 + wantStdout: "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nL11\nL12\n", 352 + }, 353 + { 354 + name: "lines with K suffix", 355 + args: []string{"-n", "1K", "twelve.txt"}, 356 + wantStdout: "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nL11\nL12\n", 357 + }, 358 + { 359 + name: "byte mode with kB (decimal) suffix", 360 + args: []string{"-c", "1kB", "bytes.txt"}, 361 + wantStdout: "abcdefghij", 362 + }, 363 + { 364 + name: "invalid suffix is rejected", 365 + args: []string{"-c", "1Q", "bytes.txt"}, 366 + wantErrSub: "invalid number of bytes", 367 + wantErr: true, 368 + }, 198 369 } 199 370 200 371 for _, tt := range tests { ··· 217 388 } 218 389 } 219 390 391 + // TestFollow exercises -f against a billy memfs file. The test starts 392 + // tail in a goroutine, appends bytes after a short delay, lets the 393 + // follow loop pick them up, then cancels via context. 394 + // 395 + // Determinism is achieved by: 396 + // - shrinking followPollInterval to a tiny value for the duration of the test; 397 + // - using a syncBuffer so the test goroutine can read output without races; 398 + // - polling the captured output for the expected suffix before asserting, 399 + // within a generous wall-clock budget. 400 + func TestFollow(t *testing.T) { 401 + prev := followPollInterval 402 + followPollInterval = 5 * time.Millisecond 403 + t.Cleanup(func() { followPollInterval = prev }) 404 + 405 + fs := &lockedFS{Filesystem: memfs.New()} 406 + // Seed the file with three lines so the synchronous tail emits "c\n". 407 + f, err := fs.OpenFile("growing.txt", os.O_CREATE|os.O_WRONLY, 0o644) 408 + if err != nil { 409 + t.Fatal(err) 410 + } 411 + if _, err := f.Write([]byte("a\nb\nc\n")); err != nil { 412 + t.Fatal(err) 413 + } 414 + f.Close() 415 + 416 + out := &syncBuffer{} 417 + var stderr bytes.Buffer 418 + ec := &command.ExecContext{ 419 + Stdin: strings.NewReader(""), 420 + Stdout: out, 421 + Stderr: &stderr, 422 + Dir: ".", 423 + FS: fs, 424 + } 425 + 426 + ctx, cancel := context.WithCancel(context.Background()) 427 + t.Cleanup(cancel) 428 + 429 + done := make(chan error, 1) 430 + go func() { 431 + done <- Impl{}.Exec(ctx, ec, []string{"-f", "-n", "1", "growing.txt"}) 432 + }() 433 + 434 + // Wait for the synchronous tail to land in the buffer. 435 + waitFor(t, time.Second, func() bool { 436 + return strings.Contains(out.String(), "c\n") 437 + }, "initial tail not observed; got %q", out) 438 + 439 + // Append more bytes; follow loop should observe and emit them. 440 + g, err := fs.OpenFile("growing.txt", os.O_WRONLY|os.O_APPEND, 0o644) 441 + if err != nil { 442 + t.Fatal(err) 443 + } 444 + if _, err := g.Write([]byte("d\ne\n")); err != nil { 445 + t.Fatal(err) 446 + } 447 + g.Close() 448 + 449 + waitFor(t, time.Second, func() bool { 450 + s := out.String() 451 + return strings.Contains(s, "d\ne\n") 452 + }, "appended bytes not observed; got %q", out) 453 + 454 + // Append once more to confirm the loop keeps polling. 455 + g2, err := fs.OpenFile("growing.txt", os.O_WRONLY|os.O_APPEND, 0o644) 456 + if err != nil { 457 + t.Fatal(err) 458 + } 459 + if _, err := g2.Write([]byte("f\n")); err != nil { 460 + t.Fatal(err) 461 + } 462 + g2.Close() 463 + 464 + waitFor(t, time.Second, func() bool { 465 + return strings.Contains(out.String(), "f\n") 466 + }, "second append not observed; got %q", out) 467 + 468 + cancel() 469 + select { 470 + case <-done: 471 + case <-time.After(2 * time.Second): 472 + t.Fatal("tail -f did not return after context cancel") 473 + } 474 + 475 + // Final stitched output must be: synchronous tail then appended chunks. 476 + got := out.String() 477 + want := "c\nd\ne\nf\n" 478 + if got != want { 479 + t.Errorf("follow output = %q, want %q", got, want) 480 + } 481 + if stderr.Len() != 0 { 482 + t.Errorf("unexpected stderr: %q", stderr.String()) 483 + } 484 + } 485 + 486 + // TestFollowStdinIsNoop verifies that -f without a file operand does not 487 + // hang when stdin is a regular Reader. 488 + func TestFollowStdinIsNoop(t *testing.T) { 489 + prev := followPollInterval 490 + followPollInterval = 5 * time.Millisecond 491 + t.Cleanup(func() { followPollInterval = prev }) 492 + 493 + stdout, stderr, err := run(t, []string{"-f", "-n", "1"}, "x\ny\nz\n", newFS(t)) 494 + if err != nil { 495 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 496 + } 497 + if stdout != "z\n" { 498 + t.Errorf("stdout = %q, want %q", stdout, "z\n") 499 + } 500 + } 501 + 502 + // waitFor polls cond until it returns true or deadline elapses. The 503 + // failure message is formatted with t.Fatalf if the deadline passes. 504 + func waitFor(t *testing.T, timeout time.Duration, cond func() bool, format string, args ...interface{}) { 505 + t.Helper() 506 + deadline := time.Now().Add(timeout) 507 + for time.Now().Before(deadline) { 508 + if cond() { 509 + return 510 + } 511 + time.Sleep(2 * time.Millisecond) 512 + } 513 + t.Fatalf(format, args...) 514 + } 515 + 220 516 func TestHelp(t *testing.T) { 221 517 stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 222 518 if err != nil { ··· 228 524 if !strings.Contains(stderr, "Usage: tail [OPTION]... [FILE]...") { 229 525 t.Errorf("usage line missing from stderr: %q", stderr) 230 526 } 231 - if !strings.Contains(stderr, "--lines=NUM") { 527 + if !strings.Contains(stderr, "--lines=") { 232 528 t.Errorf("lines flag missing from help: %q", stderr) 233 529 } 234 530 if !strings.Contains(stderr, "+NUM") { 235 531 t.Errorf("from-line +NUM doc missing from help: %q", stderr) 532 + } 533 + if !strings.Contains(stderr, "--follow") { 534 + t.Errorf("--follow flag missing from help: %q", stderr) 535 + } 536 + if !strings.Contains(stderr, "multiplier suffix") { 537 + t.Errorf("size suffix doc missing from help: %q", stderr) 236 538 } 237 539 }