@gmickel/gno 2.8.2 → 2.8.4

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.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/assets/spa-production.json.gz +0 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.4.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v2.8.4.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +9 -1
  7. package/spec/cli.md +77 -6
  8. package/spec/mcp.md +5 -6
  9. package/spec/output-schemas/doctor.schema.json +10 -4
  10. package/spec/output-schemas/status.schema.json +4 -2
  11. package/src/cli/commands/daemon.ts +7 -2
  12. package/src/cli/commands/doctor-activation.ts +38 -11
  13. package/src/cli/commands/doctor.ts +46 -25
  14. package/src/cli/commands/index-cmd.ts +10 -2
  15. package/src/cli/commands/links.ts +5 -22
  16. package/src/cli/commands/shared.ts +26 -1
  17. package/src/cli/commands/status.ts +76 -4
  18. package/src/cli/commands/update.ts +6 -1
  19. package/src/cli/commands/vec.ts +42 -2
  20. package/src/cli/program.ts +2 -2
  21. package/src/config/types.ts +19 -0
  22. package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
  23. package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
  24. package/src/converters/adapters/xlsx/adapter.ts +132 -0
  25. package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
  26. package/src/converters/registry.ts +62 -6
  27. package/src/converters/versions.ts +2 -0
  28. package/src/core/activation-empty.ts +16 -0
  29. package/src/core/activation-status.ts +23 -5
  30. package/src/core/audit-freshness.ts +1 -1
  31. package/src/core/audit-outside-index.ts +48 -3
  32. package/src/core/change-diff.ts +16 -4
  33. package/src/core/link-destination-parse.ts +21 -4
  34. package/src/core/link-inventory-markdown.ts +23 -3
  35. package/src/core/links.ts +127 -31
  36. package/src/core/network-boundary-inventory.ts +18 -0
  37. package/src/index.ts +10 -0
  38. package/src/ingestion/document-metadata.ts +256 -0
  39. package/src/ingestion/file-child-env.ts +9 -0
  40. package/src/ingestion/file-child.ts +106 -0
  41. package/src/ingestion/file-processor.ts +546 -0
  42. package/src/ingestion/frontmatter.ts +6 -2
  43. package/src/ingestion/index.ts +1 -0
  44. package/src/ingestion/prepare-convert.ts +32 -0
  45. package/src/ingestion/prepare-file.ts +272 -0
  46. package/src/ingestion/prepare-worker.ts +43 -0
  47. package/src/ingestion/record-container.ts +7 -2
  48. package/src/ingestion/strip.ts +136 -31
  49. package/src/ingestion/sync-options.ts +7 -1
  50. package/src/ingestion/sync.ts +207 -400
  51. package/src/ingestion/types.ts +18 -0
  52. package/src/llm/native-worker/embedding-identity.ts +15 -3
  53. package/src/mcp/http-transport.ts +11 -0
  54. package/src/mcp/tools/links.ts +10 -38
  55. package/src/serve/activation-health.ts +25 -7
  56. package/src/serve/public/components/BootstrapStatus.tsx +9 -4
  57. package/src/serve/resident-request.ts +19 -0
  58. package/src/serve/resident-runtime.ts +84 -8
  59. package/src/serve/routes/links.ts +6 -24
  60. package/src/serve/server.ts +3 -2
  61. package/src/serve/status.ts +27 -8
  62. package/src/sessions/config-refresh.ts +3 -2
  63. package/src/store/sqlite/graph-similarity.ts +5 -13
  64. package/src/store/vector/partition-index.ts +100 -0
  65. package/src/store/vector/stored-vectors.ts +20 -15
  66. package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +0 -1
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Child-process backend of the file processor (fn-198).
3
+ *
4
+ * Where a TypeScript worker cannot start (a standalone compiled executable),
5
+ * sync prepares each file in a child process instead: the same executable
6
+ * re-run with `FILE_PROCESSOR_CHILD_ENV` set (see src/index.ts), or this
7
+ * module under a source runtime. It serves `prepareFile` requests over IPC,
8
+ * one at a time, until its parent disconnects; the parent kills it when a
9
+ * file overruns its budget, exactly as it terminates the worker, and on
10
+ * every shutdown path it controls.
11
+ *
12
+ * A child busy in a synchronous step cannot notice its parent dying. On
13
+ * Linux it asks the kernel to SIGKILL it when the parent dies
14
+ * (PR_SET_PDEATHSIG, through Bun's built-in FFI), which holds even if the
15
+ * parent is SIGKILLed. Elsewhere it checks between steps that its parent is
16
+ * still there and exits if not; a parent killed with SIGKILL on macOS or
17
+ * Windows can leave the child running until its current step ends.
18
+ *
19
+ * @module src/ingestion/file-child
20
+ */
21
+
22
+ import type {
23
+ FileChildMessage,
24
+ FileWorkerRequest,
25
+ ReadyMessage,
26
+ } from "./file-processor";
27
+
28
+ import { defaultChunker } from "./chunker";
29
+ import { FILE_PROCESSOR_CHILD_ENV } from "./file-child-env";
30
+ import { convertForPreparation } from "./prepare-convert";
31
+ import { prepareFile } from "./prepare-file";
32
+
33
+ const send = (message: FileChildMessage | ReadyMessage): void => {
34
+ process.send?.(message);
35
+ };
36
+
37
+ const PR_SET_PDEATHSIG = 1;
38
+ const SIGKILL = 9;
39
+ const LINUX_LIBC_NAMES = ["libc.so.6", "libc.so"];
40
+
41
+ /** Linux: have the kernel SIGKILL this process when its parent dies. */
42
+ async function killWithParent(): Promise<void> {
43
+ if (process.platform !== "linux") return;
44
+ try {
45
+ // bun:ffi is built into Bun (no native dependency); loaded only here.
46
+ const { dlopen, FFIType } = await import("bun:ffi");
47
+ for (const name of LINUX_LIBC_NAMES) {
48
+ try {
49
+ const libc = dlopen(name, {
50
+ prctl: {
51
+ args: [
52
+ FFIType.i32,
53
+ FFIType.u64,
54
+ FFIType.u64,
55
+ FFIType.u64,
56
+ FFIType.u64,
57
+ ],
58
+ returns: FFIType.i32,
59
+ },
60
+ });
61
+ libc.symbols.prctl(PR_SET_PDEATHSIG, BigInt(SIGKILL), 0n, 0n, 0n);
62
+ libc.close();
63
+ return;
64
+ } catch {
65
+ // Try the next libc name; the between-step check still applies.
66
+ }
67
+ }
68
+ } catch {
69
+ // No FFI in this runtime: the between-step check still applies.
70
+ }
71
+ }
72
+
73
+ /** Serve prepare requests from the parent until it disconnects. */
74
+ export async function runFileProcessorChild(): Promise<void> {
75
+ // Nothing this child starts may re-enter child mode.
76
+ delete process.env[FILE_PROCESSOR_CHILD_ENV];
77
+ const parentPid = process.ppid;
78
+ await killWithParent();
79
+ // The parent may have died before the death signal was armed.
80
+ const parentGone = (): boolean => process.ppid !== parentPid;
81
+ if (parentGone()) process.exit(1);
82
+ return new Promise((resolve) => {
83
+ process.on("message", async (job: Pick<FileWorkerRequest, "request">) => {
84
+ const outcome = await prepareFile(job.request, {
85
+ convert: convertForPreparation,
86
+ chunker: defaultChunker,
87
+ onPrevious: (structure) => send({ type: "previous", structure }),
88
+ onPhase: (phase) => send({ type: "phase", phase }),
89
+ // The parent enforces the budget and kills this process; between
90
+ // steps the child only checks that its parent is still alive.
91
+ check: () => {
92
+ if (parentGone()) process.exit(1);
93
+ return null;
94
+ },
95
+ });
96
+ send({ type: "done", outcome });
97
+ });
98
+ process.on("disconnect", () => resolve());
99
+ send({ type: "ready" });
100
+ });
101
+ }
102
+
103
+ if (import.meta.main) {
104
+ await runFileProcessorChild();
105
+ process.exit(0);
106
+ }
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Per-file indexing budget: the terminable file processor (fn-198).
3
+ *
4
+ * Everything CPU-bound between a file's bytes and its database write
5
+ * (`prepareFile`: conversion, metadata, code regions, change-journal
6
+ * structure, chunking, link parsing) runs outside the main thread, one file
7
+ * at a time. The main thread stops it the moment a file runs past its
8
+ * wall-clock budget or resident memory passes the memory budget, mid-step
9
+ * if need be; the file fails with TIMEOUT or MEMORY_LIMIT, the processor is
10
+ * replaced, and the run continues with the next file.
11
+ *
12
+ * Two backends share that contract:
13
+ * - worker: a Bun Worker (source runtimes: npm and desktop installs);
14
+ * - child: a child process of the same executable (standalone compiled
15
+ * executables, which cannot start a TypeScript worker entry), talking
16
+ * over IPC. Its memory is the parent's plus the child's resident set,
17
+ * sampled from /proc on Linux and `ps` elsewhere; on Windows the child
18
+ * backend enforces the time budget only.
19
+ * If no backend can start, the file fails closed with
20
+ * ISOLATION_UNAVAILABLE; it is never prepared unbounded.
21
+ *
22
+ * One processor is reused across files (starting one costs about a third of
23
+ * a second of module loading). It is replaced after an overrun, after a
24
+ * file that leaves memory above half the budget, and after a short idle
25
+ * period, and never keeps a process alive on its own.
26
+ *
27
+ * @module src/ingestion/file-processor
28
+ */
29
+
30
+ // node:os totalmem: Bun has no API for physical memory size.
31
+ import { totalmem } from "node:os";
32
+
33
+ import type { DocumentStructureSnapshot } from "../core/change-diff";
34
+ import type {
35
+ PrepareFailure,
36
+ PrepareFileRequest,
37
+ PrepareOutcome,
38
+ } from "./prepare-file";
39
+
40
+ import { isBunfsPath } from "../serve/spa-production-build";
41
+ import { FILE_PROCESSOR_CHILD_ENV } from "./file-child-env";
42
+ import { PREPARE_PHASES } from "./prepare-file";
43
+
44
+ /** Resource budget for indexing one file. */
45
+ export interface FileBudgetLimits {
46
+ /** Wall-clock budget in milliseconds (`conversion.timeoutMs`). */
47
+ timeoutMs: number;
48
+ /** Resident-memory ceiling in bytes (`conversion.maxMemoryMb`). */
49
+ maxMemoryBytes: number;
50
+ }
51
+
52
+ /** One file for the worker, with the shared slot it records its step in. */
53
+ export interface FileWorkerRequest {
54
+ request: PrepareFileRequest;
55
+ /** Index into PREPARE_PHASES of the running step; -1 before the first. */
56
+ phaseSlot: Int32Array;
57
+ }
58
+
59
+ /** Messages the processor posts while it prepares one file. */
60
+ export type FileWorkerMessage =
61
+ | { type: "previous"; structure: DocumentStructureSnapshot }
62
+ | { type: "done"; outcome: PrepareOutcome };
63
+
64
+ /** Child messages add the running step (no shared memory across processes). */
65
+ export type FileChildMessage =
66
+ | FileWorkerMessage
67
+ | { type: "phase"; phase: string };
68
+
69
+ export interface FileProcessorHooks {
70
+ onPrevious: (structure: DocumentStructureSnapshot) => void;
71
+ /** Called when the processor starts on the file (its clock starts). */
72
+ onStarted?: () => void;
73
+ }
74
+
75
+ /** Posted once by a processor when it has loaded. */
76
+ export interface ReadyMessage {
77
+ type: "ready";
78
+ }
79
+
80
+ export type FileProcessorBackendKind = "worker" | "child";
81
+
82
+ /** Error codes of a file stopped at its budget. */
83
+ export const BUDGET_ERROR_CODES: ReadonlySet<string> = new Set([
84
+ "TIMEOUT",
85
+ "MEMORY_LIMIT",
86
+ ]);
87
+
88
+ /** How often resident memory is sampled while a file is processed. */
89
+ const MEMORY_SAMPLE_INTERVAL_MS = 200;
90
+ /** An idle processor is stopped so a long-running process does not hold it. */
91
+ const PROCESSOR_IDLE_MS = 30_000;
92
+ const BYTES_PER_MB = 1_048_576;
93
+ const BYTES_PER_KB = 1024;
94
+ /** Floor for the default memory budget on small machines. */
95
+ const MIN_DEFAULT_MEMORY_MB = 2048;
96
+
97
+ /**
98
+ * Default resident-memory budget in MB: half of physical memory, at least
99
+ * 2 GB. `conversion.maxMemoryMb` overrides it.
100
+ */
101
+ export function defaultConversionMemoryMb(): number {
102
+ return Math.max(
103
+ MIN_DEFAULT_MEMORY_MB,
104
+ Math.floor(totalmem() / 2 / BYTES_PER_MB)
105
+ );
106
+ }
107
+
108
+ /** Budget failure for elapsed time, reported against the running step. */
109
+ export function timeoutFailure(
110
+ phase: string,
111
+ elapsedMs: number,
112
+ timeoutMs: number
113
+ ): PrepareFailure {
114
+ return {
115
+ code: "TIMEOUT",
116
+ message: `Indexing stopped during ${phase}: ${Math.round(elapsedMs)}ms exceeded the ${timeoutMs}ms budget`,
117
+ details: { phase, elapsedMs: Math.round(elapsedMs), timeoutMs },
118
+ };
119
+ }
120
+
121
+ /** Budget failure for resident memory. */
122
+ export function memoryFailure(
123
+ phase: string,
124
+ rssBytes: number,
125
+ maxMemoryBytes: number
126
+ ): PrepareFailure {
127
+ const rssMb = Math.round(rssBytes / BYTES_PER_MB);
128
+ const maxMemoryMb = Math.round(maxMemoryBytes / BYTES_PER_MB);
129
+ return {
130
+ code: "MEMORY_LIMIT",
131
+ message: `Indexing stopped during ${phase}: resident memory ${rssMb} MB exceeded the ${maxMemoryMb} MB budget`,
132
+ details: { phase, rssMb, maxMemoryMb },
133
+ };
134
+ }
135
+
136
+ const unavailableFailure = (reason: string): PrepareFailure => ({
137
+ code: "ISOLATION_UNAVAILABLE",
138
+ message: `Could not start the file processor, so the file was not indexed without its budget: ${reason}`,
139
+ });
140
+
141
+ // ─────────────────────────────────────────────────────────────────────────────
142
+ // Backends
143
+ // ─────────────────────────────────────────────────────────────────────────────
144
+
145
+ /** A started processor that prepares one file at a time. */
146
+ interface Processor {
147
+ readonly kind: FileProcessorBackendKind;
148
+ /** Settles once the processor has loaded and can take a file. */
149
+ readonly ready: Promise<void>;
150
+ /** Settles, with a reason, if the processor dies or errors. */
151
+ readonly failed: Promise<string>;
152
+ /** Send one file; its messages go to `onMessage`. */
153
+ run(
154
+ request: PrepareFileRequest,
155
+ onMessage: (message: FileWorkerMessage) => void
156
+ ): void;
157
+ /** Name of the running step, for overrun messages. */
158
+ phase(): string;
159
+ /** Resident bytes of this processor outside the main process. */
160
+ externalRss(): Promise<number>;
161
+ /** False once the processor died on its own; it is then replaced. */
162
+ alive(): boolean;
163
+ /** Child process id (child backend), for shutdown and tests. */
164
+ readonly pid: number | null;
165
+ stop(): void;
166
+ }
167
+
168
+ const phaseName = (index: number): string => PREPARE_PHASES[index] ?? "startup";
169
+
170
+ function startWorker(): Processor {
171
+ const worker = new Worker(new URL("./prepare-worker.ts", import.meta.url));
172
+ // An idle worker must never keep a CLI process alive. Bun's Worker has
173
+ // unref(); the DOM lib type in this tsconfig does not declare it.
174
+ (worker as Worker & { unref(): void }).unref();
175
+ const phaseSlot = new Int32Array(new SharedArrayBuffer(4));
176
+ let onMessage: ((message: FileWorkerMessage) => void) | null = null;
177
+ let markReady: () => void = () => undefined;
178
+ let markFailed: (reason: string) => void = () => undefined;
179
+ let dead = false;
180
+ const ready = new Promise<void>((resolve) => {
181
+ markReady = resolve;
182
+ });
183
+ const failed = new Promise<string>((resolve) => {
184
+ markFailed = (reason) => {
185
+ dead = true;
186
+ resolve(reason);
187
+ };
188
+ });
189
+ worker.onmessage = (
190
+ event: MessageEvent<FileWorkerMessage | ReadyMessage>
191
+ ) => {
192
+ if (event.data.type === "ready") markReady();
193
+ else onMessage?.(event.data);
194
+ };
195
+ worker.onerror = (event: ErrorEvent) => {
196
+ markFailed(event.message || "file worker failed");
197
+ };
198
+ return {
199
+ kind: "worker",
200
+ ready,
201
+ failed,
202
+ run(request, messageHandler) {
203
+ Atomics.store(phaseSlot, 0, -1);
204
+ onMessage = messageHandler;
205
+ const job: FileWorkerRequest = { request, phaseSlot };
206
+ worker.postMessage(job);
207
+ },
208
+ phase: () => phaseName(Atomics.load(phaseSlot, 0)),
209
+ externalRss: () => Promise.resolve(0),
210
+ alive: () => !dead,
211
+ pid: null,
212
+ stop() {
213
+ worker.terminate();
214
+ markFailed("file processor was stopped");
215
+ },
216
+ };
217
+ }
218
+
219
+ /** Resident set of another process, in bytes (0 when unknown). */
220
+ async function processRss(pid: number): Promise<number> {
221
+ try {
222
+ if (process.platform === "linux") {
223
+ const status = await Bun.file(`/proc/${pid}/status`).text();
224
+ const kb = /^VmRSS:\s+(\d+)/m.exec(status)?.[1];
225
+ return kb ? Number(kb) * BYTES_PER_KB : 0;
226
+ }
227
+ if (process.platform === "win32") return 0;
228
+ const ps = Bun.spawn(["ps", "-o", "rss=", "-p", String(pid)], {
229
+ stdout: "pipe",
230
+ stderr: "ignore",
231
+ });
232
+ const kb = Number((await new Response(ps.stdout).text()).trim());
233
+ return Number.isFinite(kb) ? kb * BYTES_PER_KB : 0;
234
+ } catch {
235
+ return 0;
236
+ }
237
+ }
238
+
239
+ function startChild(): Processor {
240
+ const compiled = isBunfsPath(import.meta.path);
241
+ let phase = "startup";
242
+ let onMessage: ((message: FileWorkerMessage) => void) | null = null;
243
+ let markReady: () => void = () => undefined;
244
+ let stopping = false;
245
+ let exited = false;
246
+ const ready = new Promise<void>((resolve) => {
247
+ markReady = resolve;
248
+ });
249
+ const child = Bun.spawn({
250
+ // A compiled executable re-runs itself in child mode (src/index.ts); a
251
+ // source runtime runs the child module directly.
252
+ cmd: compiled
253
+ ? [process.execPath]
254
+ : [process.execPath, `${import.meta.dir}/file-child.ts`],
255
+ env: { ...process.env, [FILE_PROCESSOR_CHILD_ENV]: "1" },
256
+ stdin: "ignore",
257
+ stdout: "ignore",
258
+ stderr: "inherit",
259
+ serialization: "advanced",
260
+ ipc(message: FileChildMessage | ReadyMessage) {
261
+ if (message.type === "ready") markReady();
262
+ else if (message.type === "phase") phase = message.phase;
263
+ else onMessage?.(message);
264
+ },
265
+ });
266
+ child.unref();
267
+ liveChildren.add(child);
268
+ ensureShutdownHooks();
269
+ const failed = child.exited.then((code) => {
270
+ exited = true;
271
+ liveChildren.delete(child);
272
+ return stopping
273
+ ? "file processor was stopped"
274
+ : `file processor child exited (${code})`;
275
+ });
276
+ return {
277
+ kind: "child",
278
+ ready,
279
+ failed,
280
+ run(request, messageHandler) {
281
+ phase = "startup";
282
+ onMessage = messageHandler;
283
+ child.send({ request });
284
+ },
285
+ phase: () => phase,
286
+ externalRss: () => processRss(child.pid),
287
+ alive: () => !exited,
288
+ pid: child.pid,
289
+ stop() {
290
+ stopping = true;
291
+ child.kill(9);
292
+ },
293
+ };
294
+ }
295
+
296
+ // ─────────────────────────────────────────────────────────────────────────────
297
+ // Parent shutdown
298
+ // ─────────────────────────────────────────────────────────────────────────────
299
+
300
+ /** Child processors still running; killed on every parent shutdown path. */
301
+ const liveChildren = new Set<ReturnType<typeof Bun.spawn>>();
302
+ let shutdownHooksInstalled = false;
303
+
304
+ /** SIGKILL every child processor (they are disposable). Synchronous. */
305
+ function killChildren(): void {
306
+ for (const child of liveChildren) {
307
+ try {
308
+ child.kill(9);
309
+ } catch {
310
+ // Already gone.
311
+ }
312
+ }
313
+ liveChildren.clear();
314
+ }
315
+
316
+ /**
317
+ * A signal the process would otherwise die from by default: kill the
318
+ * children, and when no one else handles the signal, stop listening and
319
+ * re-raise it so the process still ends the default way (same exit status).
320
+ * The listener is prepended, so it runs before any other handler and counts
321
+ * the real owners: a `process.once` handler (serve, daemon) removes itself
322
+ * before it runs, so a listener running after it would see none.
323
+ */
324
+ function onTerminatingSignal(signal: "SIGINT" | "SIGTERM"): () => void {
325
+ const listener = (): void => {
326
+ killChildren();
327
+ if (process.listenerCount(signal) === 1) {
328
+ process.off(signal, listener);
329
+ process.kill(process.pid, signal);
330
+ }
331
+ };
332
+ return listener;
333
+ }
334
+
335
+ /**
336
+ * Kill child processors whenever this process ends: on exit (the CLI's
337
+ * SIGINT handler and every normal exit end in process.exit), and on SIGTERM.
338
+ * SIGINT gets its own listener only when nothing else owns it: the CLI
339
+ * treats an extra SIGINT listener as a command that finishes its own
340
+ * teardown, so adding one there would stop Ctrl-C from exiting. An
341
+ * uncaught exception is not intercepted (a listener would keep the process
342
+ * alive); the child's parent-death signal (Linux) or its between-step
343
+ * parent check covers it.
344
+ */
345
+ function ensureShutdownHooks(): void {
346
+ if (shutdownHooksInstalled) return;
347
+ shutdownHooksInstalled = true;
348
+ process.on("exit", killChildren);
349
+ process.prependListener("SIGTERM", onTerminatingSignal("SIGTERM"));
350
+ if (process.listenerCount("SIGINT") === 0) {
351
+ process.on("SIGINT", onTerminatingSignal("SIGINT"));
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Stop the file processor and any child process now (resident shutdown).
357
+ * A file being prepared fails; the next file starts a new processor.
358
+ */
359
+ export function disposeFileProcessor(): void {
360
+ stopProcessor();
361
+ killChildren();
362
+ }
363
+
364
+ /** Tests only: the running child processor's pid, if any. */
365
+ export function activeFileProcessorPid(): number | null {
366
+ return processor?.pid ?? null;
367
+ }
368
+
369
+ // ─────────────────────────────────────────────────────────────────────────────
370
+ // Processor lifecycle
371
+ // ─────────────────────────────────────────────────────────────────────────────
372
+
373
+ let forcedBackend: FileProcessorBackendKind | null = null;
374
+ let processor: Processor | null = null;
375
+ let processorsStarted = 0;
376
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
377
+ let queue: Promise<unknown> = Promise.resolve();
378
+
379
+ /** Which backend this runtime uses: the worker unless it cannot start. */
380
+ export function fileProcessorBackend(): FileProcessorBackendKind {
381
+ return forcedBackend ?? (isBunfsPath(import.meta.path) ? "child" : "worker");
382
+ }
383
+
384
+ /**
385
+ * Tests only: force a backend (null restores the default). Stops the current
386
+ * processor so the next file starts the chosen one.
387
+ */
388
+ export function useFileProcessorBackend(
389
+ kind: FileProcessorBackendKind | null
390
+ ): void {
391
+ forcedBackend = kind;
392
+ stopProcessor();
393
+ }
394
+
395
+ /** Number of file processors started in this process (for tests). */
396
+ export function fileProcessorsStarted(): number {
397
+ return processorsStarted;
398
+ }
399
+
400
+ function stopProcessor(): void {
401
+ clearTimeout(idleTimer);
402
+ processor?.stop();
403
+ processor = null;
404
+ }
405
+
406
+ function acquireProcessor(): Processor {
407
+ clearTimeout(idleTimer);
408
+ const kind = fileProcessorBackend();
409
+ if (processor?.kind !== kind || !processor.alive()) {
410
+ processor?.stop();
411
+ processor = kind === "worker" ? startWorker() : startChild();
412
+ processorsStarted += 1;
413
+ }
414
+ return processor;
415
+ }
416
+
417
+ /** How long a new processor may take to load before the file fails closed. */
418
+ const PROCESSOR_START_TIMEOUT_MS = 30_000;
419
+
420
+ /** Wait for the processor to load: null once ready, else why it did not. */
421
+ const awaitReady = (active: Processor): Promise<string | null> => {
422
+ let timer: ReturnType<typeof setTimeout> | undefined;
423
+ const timedOut = new Promise<string>((resolve) => {
424
+ timer = setTimeout(
425
+ () => resolve(`no response within ${PROCESSOR_START_TIMEOUT_MS} ms`),
426
+ PROCESSOR_START_TIMEOUT_MS
427
+ );
428
+ });
429
+ return Promise.race([
430
+ active.ready.then(() => null),
431
+ active.failed,
432
+ timedOut,
433
+ ]).finally(() => clearTimeout(timer));
434
+ };
435
+
436
+ const runOne = async (
437
+ request: PrepareFileRequest,
438
+ budget: FileBudgetLimits,
439
+ hooks: FileProcessorHooks
440
+ ): Promise<PrepareOutcome> => {
441
+ let active: Processor;
442
+ try {
443
+ active = acquireProcessor();
444
+ } catch (cause) {
445
+ processor = null;
446
+ return {
447
+ ok: false,
448
+ error: unavailableFailure(
449
+ cause instanceof Error ? cause.message : String(cause)
450
+ ),
451
+ };
452
+ }
453
+ // Loading the processor is not the file's time: its clock starts once the
454
+ // processor can take it.
455
+ const notReady = await awaitReady(active);
456
+ if (notReady) {
457
+ if (processor === active) stopProcessor();
458
+ return { ok: false, error: unavailableFailure(notReady) };
459
+ }
460
+ hooks.onStarted?.();
461
+ const startedAt = performance.now();
462
+ return new Promise<PrepareOutcome>((resolve) => {
463
+ let settled = false;
464
+ let sampling = false;
465
+ const finish = (outcome: PrepareOutcome, keep: boolean): void => {
466
+ if (settled) return;
467
+ settled = true;
468
+ clearTimeout(deadline);
469
+ clearInterval(sampler);
470
+ if (keep && process.memoryUsage.rss() <= budget.maxMemoryBytes / 2) {
471
+ idleTimer = setTimeout(stopProcessor, PROCESSOR_IDLE_MS);
472
+ idleTimer.unref?.();
473
+ } else if (processor === active) {
474
+ stopProcessor();
475
+ } else {
476
+ active.stop();
477
+ }
478
+ resolve(outcome);
479
+ };
480
+ const deadline = setTimeout(() => {
481
+ finish(
482
+ {
483
+ ok: false,
484
+ error: timeoutFailure(
485
+ active.phase(),
486
+ performance.now() - startedAt,
487
+ budget.timeoutMs
488
+ ),
489
+ },
490
+ false
491
+ );
492
+ }, budget.timeoutMs);
493
+ const checkMemory = async (): Promise<void> => {
494
+ if (sampling || settled) return;
495
+ sampling = true;
496
+ const rss = process.memoryUsage.rss() + (await active.externalRss());
497
+ sampling = false;
498
+ if (rss > budget.maxMemoryBytes) {
499
+ finish(
500
+ {
501
+ ok: false,
502
+ error: memoryFailure(active.phase(), rss, budget.maxMemoryBytes),
503
+ },
504
+ false
505
+ );
506
+ }
507
+ };
508
+ const sampler = setInterval(() => {
509
+ void checkMemory();
510
+ }, MEMORY_SAMPLE_INTERVAL_MS);
511
+ void active.failed.then((reason) =>
512
+ finish({ ok: false, error: { code: "INTERNAL", message: reason } }, false)
513
+ );
514
+ // A process already over the budget does not start another file.
515
+ const rss = process.memoryUsage.rss();
516
+ if (rss > budget.maxMemoryBytes) {
517
+ finish(
518
+ {
519
+ ok: false,
520
+ error: memoryFailure("startup", rss, budget.maxMemoryBytes),
521
+ },
522
+ false
523
+ );
524
+ return;
525
+ }
526
+ active.run(request, (message) => {
527
+ if (message.type === "previous") hooks.onPrevious(message.structure);
528
+ else finish(message.outcome, true);
529
+ });
530
+ });
531
+ };
532
+
533
+ /**
534
+ * Prepare one file in the file processor under its time and memory budget.
535
+ * Files run one at a time; the budget clock starts when a file reaches the
536
+ * processor, not while it queues.
537
+ */
538
+ export function prepareInProcessor(
539
+ request: PrepareFileRequest,
540
+ budget: FileBudgetLimits,
541
+ hooks: FileProcessorHooks
542
+ ): Promise<PrepareOutcome> {
543
+ const result = queue.then(() => runOne(request, budget, hooks));
544
+ queue = result.catch(() => undefined);
545
+ return result;
546
+ }
@@ -11,6 +11,10 @@
11
11
 
12
12
  import { normalizeTag } from "../core/tags";
13
13
 
14
+ /** Frontmatter keys that would write to an object's prototype. */
15
+ const PROTOTYPE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
16
+ const isPrototypeKey = (key: string): boolean => PROTOTYPE_KEYS.has(key);
17
+
14
18
  // ─────────────────────────────────────────────────────────────────────────────
15
19
  // Types
16
20
  // ─────────────────────────────────────────────────────────────────────────────
@@ -233,7 +237,7 @@ function parseNestedMapValue(
233
237
  }
234
238
  }
235
239
 
236
- if (key.length > 0 && values.length > 0) {
240
+ if (key.length > 0 && values.length > 0 && !isPrototypeKey(key)) {
237
241
  result[key] = values;
238
242
  }
239
243
  }
@@ -337,7 +341,7 @@ export function parseFrontmatter(source: string): FrontmatterResult {
337
341
  const value = line.slice(colonIdx + 1).trim();
338
342
  if (key !== "tags") {
339
343
  const metadataValue = parseMetadataValue(value, lines, i);
340
- if (metadataValue !== undefined) {
344
+ if (metadataValue !== undefined && !isPrototypeKey(key)) {
341
345
  result.metadata[key] = metadataValue;
342
346
  }
343
347
  }
@@ -44,6 +44,7 @@ export type {
44
44
  LanguageDetectorPort,
45
45
  ProcessDecision,
46
46
  SkippedEntry,
47
+ SlowConversionEvent,
47
48
  SyncOptions,
48
49
  SyncResult,
49
50
  WalkConfig,