@jmcombs/pi-steward 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmcombs/pi-steward",
3
- "version": "0.0.0",
3
+ "version": "1.0.0",
4
4
  "description": "Steward — the llama.cpp control panel for Pi. A local browser dashboard for service control, resident models, host health, and streamed logs.",
5
5
  "homepage": "https://github.com/jmcombs/pi-extensions/tree/main/packages/steward",
6
6
  "repository": {
@@ -144,6 +144,22 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
144
144
  let seq = 0;
145
145
  /** Byte offset already consumed from the current file. */
146
146
  let position = 0;
147
+ /**
148
+ * An open descriptor on the file being followed, held for the whole time we
149
+ * follow it.
150
+ *
151
+ * It is what makes the inode comparison below mean anything. Linux frees an
152
+ * inode number the moment its last link goes and hands the very next file the
153
+ * same number: measured in a `node:22` container, delete-then-recreate
154
+ * returned an identical `ino` — and an identical `birthtimeMs`, so creation
155
+ * time cannot break the tie either. An open descriptor holds the old inode
156
+ * alive, so its number cannot be reissued and a replacement is guaranteed to
157
+ * stat differently. macOS happened not to recycle and hid this.
158
+ *
159
+ * It also removes the stat-then-open race: reads come from the handle whose
160
+ * identity we checked, never from whatever the path resolves to now.
161
+ */
162
+ let heldFd: number | null = null;
147
163
  /** Identity of the file we are following, so a replacement is detectable. */
148
164
  let inode: number | null = null;
149
165
  let device: number | null = null;
@@ -222,27 +238,63 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
222
238
  resetStream();
223
239
  }
224
240
 
225
- /** Reads at most one chunk of new bytes and feeds them to the splitter. */
226
- function drain(size: number): void {
227
- if (position >= size) return;
228
- const length = Math.min(size - position, MAX_READ_PER_POLL);
241
+ /** Lets go of the file we were following, if any. */
242
+ function release(): void {
243
+ if (heldFd === null) return;
244
+ try {
245
+ closeSync(heldFd);
246
+ } catch {
247
+ // Already gone, or never really open. Nothing here can be recovered from
248
+ // and nothing depends on it.
249
+ }
250
+ heldFd = null;
251
+ }
229
252
 
253
+ /**
254
+ * Starts following whatever `path` resolves to right now, from its tail.
255
+ *
256
+ * Identity and size are taken from the handle rather than from the caller's
257
+ * `stat`, so the file that gets read is the file that got measured even if the
258
+ * path is replaced in between. Returns false if it could not be opened, in
259
+ * which case the state has already been reported.
260
+ */
261
+ function follow(): boolean {
262
+ release();
230
263
  let fd: number;
231
264
  try {
232
265
  fd = openSync(path, "r");
233
266
  } catch (error) {
234
267
  markUnavailable(error);
235
- return;
268
+ inode = null;
269
+ device = null;
270
+ return false;
236
271
  }
237
272
  try {
238
- // The file can be replaced between the stat above and this open; reading
239
- // a different inode at our offset would emit garbage. Check identity
240
- // against the handle we actually hold and let the next poll re-anchor.
241
273
  const open = fstatSync(fd);
242
- if (open.ino !== inode || open.dev !== device) return;
274
+ heldFd = fd;
275
+ inode = open.ino;
276
+ device = open.dev;
277
+ anchor(open.size);
278
+ return true;
279
+ } catch (error) {
280
+ closeSync(fd);
281
+ markUnavailable(error);
282
+ return false;
283
+ }
284
+ }
285
+
286
+ /** Reads at most one chunk of new bytes and feeds them to the splitter. */
287
+ function drain(): void {
288
+ if (heldFd === null) return;
289
+ try {
290
+ // The held handle's own size, not the path's: they differ exactly when the
291
+ // file was replaced mid-poll, and this is the one that matches `position`.
292
+ const size = fstatSync(heldFd).size;
293
+ if (position >= size) return;
294
+ const length = Math.min(size - position, MAX_READ_PER_POLL);
243
295
 
244
296
  const buffer = Buffer.allocUnsafe(length);
245
- const read = readSync(fd, buffer, 0, length, position);
297
+ const read = readSync(heldFd, buffer, 0, length, position);
246
298
  if (read <= 0) return;
247
299
  position += read;
248
300
 
@@ -257,8 +309,6 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
257
309
  splitter.push(text);
258
310
  } catch (error) {
259
311
  markUnavailable(error);
260
- } finally {
261
- closeSync(fd);
262
312
  }
263
313
  }
264
314
 
@@ -290,9 +340,7 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
290
340
  if (!anchored) {
291
341
  // First sight: start at the tail, so a log that has grown for months
292
342
  // costs one backlog window rather than a whole-file read.
293
- inode = stats.ino;
294
- device = stats.dev;
295
- anchor(stats.size);
343
+ if (!follow()) return;
296
344
  anchored = true;
297
345
  } else if (replaced) {
298
346
  // Unlinked and recreated (the `tmp_cleaner` case, once the router writes
@@ -302,11 +350,10 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
302
350
  // console with a whole log.
303
351
  //
304
352
  // Recovery from `missing` lands here, because a recreated file always
305
- // has a new inode while a transient stat failure on the SAME file does
306
- // not, and so cannot make us re-read and re-emit what we already sent.
307
- inode = stats.ino;
308
- device = stats.dev;
309
- anchor(stats.size);
353
+ // stats differently while we hold the old one open whereas a transient
354
+ // stat failure on the SAME file does not, and so cannot make us re-read
355
+ // and re-emit what we already sent.
356
+ if (!follow()) return;
310
357
  } else if (stats.size < position) {
311
358
  // Truncated in place (`copytruncate`): same file, fresh content, and the
312
359
  // same cap again — a truncate followed by a large write before the next
@@ -316,7 +363,7 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
316
363
 
317
364
  source = "ok";
318
365
  detail = null;
319
- drain(stats.size);
366
+ drain();
320
367
  } catch (error) {
321
368
  // Belt and braces: a tail that throws would take the dashboard's poll
322
369
  // loop with it.
@@ -376,6 +423,9 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
376
423
  closed = true;
377
424
  if (timer !== null) clearInterval(timer);
378
425
  listeners.clear();
426
+ // The held descriptor pins its inode; leaking it would keep a rotated log
427
+ // occupying disk for as long as the process lives.
428
+ release();
379
429
  },
380
430
  };
381
431
  }