omkit 0.0.7 → 0.0.8

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.
@@ -1,6 +1,7 @@
1
1
  import { watch as fsWatch } from "node:fs";
2
2
  import { open, stat } from "node:fs/promises";
3
3
  import { basename, dirname, resolve } from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
4
5
  import { action } from "../core/action.js";
5
6
  /**
6
7
  * Tails a (possibly not-yet-existing) text/log file and streams each appended
@@ -23,15 +24,13 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
23
24
  const { source = name, level = "info", fromStart = false, encoding = "utf8", debounceMs = 50, } = opts;
24
25
  let pos = 0; // bytes consumed so far
25
26
  let pending = ""; // partial trailing line, buffered until its newline
27
+ // Decodes bytes to text across reads. It holds back an incomplete trailing
28
+ // multi-byte char (split across a chunk or read boundary) until its remaining
29
+ // bytes arrive, so a character straddling a boundary never decodes to `�`.
30
+ let decoder = new StringDecoder(encoding);
26
31
  // Append every complete line in `text`, keeping any remainder for next time.
27
32
  const feed = (text) => {
28
- // Strip NUL bytes first. Log rotators (log4js/streamroller) can pre-allocate
29
- // or leave a rotated file padded with \0, and a NUL run contains no newline —
30
- // so without this the whole padded region buffers and flushes as one huge
31
- // bogus line (the tell-tale "line of blanks/spaces"). NUL is never valid in
32
- // a text log, so dropping it is safe; a NUL-only stretch collapses to "" and
33
- // is skipped by the empty-line guard below.
34
- pending += text.replace(/\0/g, "");
33
+ pending += text;
35
34
  let newline;
36
35
  while ((newline = pending.indexOf("\n")) !== -1) {
37
36
  const line = pending.slice(0, newline).replace(/\r$/, "");
@@ -49,8 +48,11 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
49
48
  return; // missing/unreadable — wait for the next event
50
49
  }
51
50
  if (size < pos) {
51
+ // Truncation/rotation: restart from the top and drop any buffered partial
52
+ // line and half-decoded char — they belong to the old file's bytes.
52
53
  pos = 0;
53
54
  pending = "";
55
+ decoder = new StringDecoder(encoding);
54
56
  }
55
57
  if (size <= pos)
56
58
  return;
@@ -64,15 +66,9 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
64
66
  if (bytesRead === 0)
65
67
  break;
66
68
  pos += bytesRead;
67
- // Strip NUL bytes before decoding. Some log rotators on Windows
68
- // temporarily expose sparse/preallocated regions filled with 0x00.
69
- let out = 0;
70
- for (let i = 0; i < bytesRead; i++) {
71
- if (buffer[i] !== 0)
72
- buffer[out++] = buffer[i];
73
- }
74
- if (out > 0)
75
- feed(buffer.subarray(0, out).toString(encoding));
69
+ // decoder.write returns only fully-decoded chars, buffering any partial
70
+ // trailing multi-byte char for the next chunk/read.
71
+ feed(decoder.write(buffer.subarray(0, bytesRead)));
76
72
  }
77
73
  }
78
74
  finally {
@@ -113,10 +113,20 @@ export const streamLog = async (logs) => {
113
113
  }
114
114
  }
115
115
  finally {
116
- jsonl.end();
117
- pretty.end();
116
+ // End AND wait for both streams to fully close before resolving. {@link writeLog}
117
+ // truncates and rewrites these same files at finalize; if a buffered chunk from
118
+ // this stream landed *after* that truncation, the OS would zero-fill the gap —
119
+ // a multi-MB run of NUL bytes in run.log. `run` awaits this promise before
120
+ // calling `writeLog`, so this writer is guaranteed done before the rewrite.
121
+ await Promise.all([streamClosed(jsonl), streamClosed(pretty)]);
118
122
  }
119
123
  };
124
+ /** Resolve once a write stream has flushed and closed its file descriptor. */
125
+ const streamClosed = (stream) => new Promise((resolve) => {
126
+ stream.on("close", () => resolve());
127
+ stream.on("error", () => resolve()); // a stream error still frees the file; don't hang
128
+ stream.end();
129
+ });
120
130
  let seq = 0;
121
131
  /** Serialize a value for a snapshot file, tolerating circular refs / non-JSON values. */
122
132
  const serialize = (value) => {
package/dist/core/run.js CHANGED
@@ -126,8 +126,11 @@ export function run(...actions) {
126
126
  process.on("unhandledRejection", onUnhandled);
127
127
  narrate(`run started · ${scriptPath()}`);
128
128
  // Start streaming log entries to disk immediately — incremental writes survive
129
- // hard kills where the final writeLog never runs.
130
- void streamLog(logs).catch(() => { });
129
+ // hard kills where the final writeLog never runs. Kept so finalize can await it
130
+ // *before* writeLog rewrites the same files: otherwise a late flush from this
131
+ // stream lands past writeLog's truncation and the OS zero-fills the gap (a
132
+ // multi-MB NUL run in run.log).
133
+ const streaming = streamLog(logs).catch(() => { });
131
134
  const launch = (instance) => {
132
135
  narrate(`launch ${instance.name}`);
133
136
  // The action logs into the one shared store (entries carry their `source`;
@@ -184,6 +187,10 @@ export function run(...actions) {
184
187
  process.off("uncaughtException", onUncaught);
185
188
  process.off("unhandledRejection", onUnhandled);
186
189
  narrate("finished");
190
+ // Wait for the incremental streamer to fully close its files, so its last
191
+ // flush can't land after writeLog truncates+rewrites them (which would leave
192
+ // a NUL gap). The log is already closed, so this resolves promptly.
193
+ await streaming;
187
194
  const file = await writeLog(logs);
188
195
  // Direct to the terminal — the file's already written, so this line isn't in it.
189
196
  console.log(marker("run", `log → ${file}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",