using System.Text; namespace JevOverlay.Core; public sealed record TailBatch(IReadOnlyList Lines, bool Reset = false, string? Error = null, bool CatchingUp = false, int SkippedLines = 0); /// Single-worker, bounded JSONL reader. It never writes to the source. public sealed class LogTail(string path) { public const int MaxReadBytes = 256 * 1024; public const int MaxLineBytes = 128 * 1024; private static readonly UTF8Encoding Utf8 = new(false, true); private readonly MemoryStream pending = new(); private long offset; private byte[] prefix = []; private byte[] checkpoint = []; private bool initialized; private bool discardLine; public TailBatch Poll() { try { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); long length = stream.Length; bool reset = !initialized || length < offset || !Matches(stream, 0, prefix) || !Matches(stream, offset - checkpoint.Length, checkpoint); if (reset) { // Start near the end of an existing log, not at the start of a long run. offset = Math.Max(0, length - MaxReadBytes); pending.SetLength(0); discardLine = offset > 0; prefix = ReadAt(stream, 0, (int)Math.Min(64, length)); initialized = true; } byte[] bytes = ReadAt(stream, offset, (int)Math.Min(MaxReadBytes, length - offset)); offset += bytes.Length; checkpoint = ReadAt(stream, Math.Max(0, offset - 64), (int)Math.Min(64, offset)); List lines = []; int skipped = 0; foreach (byte value in bytes) { if (value == (byte)'\n') { if (!discardLine && pending.Length > 0) { try { lines.Add(Utf8.GetString(pending.GetBuffer(), 0, (int)pending.Length)); } catch (DecoderFallbackException) { skipped++; } } discardLine = false; pending.SetLength(0); } else if (!discardLine) { if (pending.Length == MaxLineBytes) { pending.SetLength(0); discardLine = true; skipped++; } else pending.WriteByte(value); } } return new(lines, reset, CatchingUp: offset < length, SkippedLines: skipped); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return new([], Error: "Log unavailable. Check log_path and file permissions."); } } private static bool Matches(FileStream stream, long position, byte[] expected) => expected.Length == 0 || ReadAt(stream, position, expected.Length).AsSpan().SequenceEqual(expected); private static byte[] ReadAt(FileStream stream, long position, int count) { stream.Position = position; byte[] buffer = new byte[count]; int read = 0; while (read < count) { int next = stream.Read(buffer, read, count - read); if (next == 0) break; read += next; } return read == count ? buffer : buffer[..read]; } }