@gmickel/gno 2.9.1 → 2.10.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/README.md +2 -2
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.9.1.zip → gno-browser-clipper-v2.10.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.10.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +3 -2
- package/spec/cli.md +73 -2
- package/spec/output-schemas/collection-list.schema.json +5 -0
- package/src/app/bun-runtime.ts +41 -0
- package/src/cli/commands/daemon.ts +12 -0
- package/src/cli/commands/doctor.ts +61 -0
- package/src/cli/commands/mcp/config-editors.ts +56 -8
- package/src/cli/commands/mcp/install.ts +159 -24
- package/src/cli/commands/mcp/launch-path.ts +87 -0
- package/src/cli/commands/mcp/paths.ts +30 -6
- package/src/cli/commands/mcp/repair-entry.ts +160 -0
- package/src/cli/commands/mcp/status.ts +55 -15
- package/src/cli/commands/mcp.ts +4 -0
- package/src/cli/commands/serve.ts +3 -0
- package/src/cli/commands/status.ts +24 -0
- package/src/cli/detach.ts +52 -5
- package/src/cli/program.ts +19 -5
- package/src/cli/runtime-health.ts +328 -0
- package/src/config/types.ts +3 -0
- package/src/core/network-boundary-inventory.ts +16 -0
- package/src/core/project-profile-apply-state.ts +3 -0
- package/src/core/resident-shutdown-handler.ts +23 -0
- package/src/core/sighup-disposition.ts +66 -0
- package/src/core/time-slice.ts +34 -0
- package/src/ingestion/file-processor.ts +118 -17
- package/src/ingestion/graph-reconciliation.ts +18 -4
- package/src/ingestion/prepare-worker.ts +20 -2
- package/src/ingestion/sync.ts +70 -4
- package/src/serve/parent-lifeline.ts +44 -0
- package/src/serve/public/components/BootstrapStatus.tsx +3 -3
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/resident-runtime.ts +24 -1
- package/src/serve/resident-shutdown.ts +4 -0
- package/src/serve/server.ts +22 -1
- package/src/serve/status.ts +2 -1
- package/src/serve/watch-reconciliation.ts +134 -1
- package/src/serve/watch-service-flush-generation.ts +77 -3
- package/src/serve/watch-service.ts +27 -3
- package/src/sessions/import-child.ts +79 -11
- package/src/store/migrations/035-vector-length-index.ts +30 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +4 -1
- package/src/store/vector/freshness.ts +13 -6
- package/browser-extension/artifacts/gno-browser-clipper-v2.9.1.zip.sha256 +0 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a resident may take over SIGHUP (fn-210).
|
|
3
|
+
*
|
|
4
|
+
* Registering any SIGHUP listener replaces the inherited disposition, so a
|
|
5
|
+
* resident started under `nohup` (or `trap '' HUP`) would start shutting down
|
|
6
|
+
* when its terminal closes. The disposition is read once with sigaction(2):
|
|
7
|
+
* if SIGHUP was ignored at start, it stays ignored. When it cannot be read
|
|
8
|
+
* (Windows, no FFI), the default is kept and no handler is installed.
|
|
9
|
+
*
|
|
10
|
+
* @module src/core/sighup-disposition
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const SIGHUP = 1;
|
|
14
|
+
/** `SIG_IGN` is handler value 1 on Linux and macOS. */
|
|
15
|
+
const SIG_IGN = 1n;
|
|
16
|
+
/** Comfortably larger than `struct sigaction` on Linux and macOS. */
|
|
17
|
+
const SIGACTION_BYTES = 256;
|
|
18
|
+
const LIBC_NAMES: Record<string, string[]> = {
|
|
19
|
+
linux: ["libc.so.6", "libc.so"],
|
|
20
|
+
darwin: ["libc.dylib", "libSystem.B.dylib"],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let cached: boolean | undefined;
|
|
24
|
+
|
|
25
|
+
function readSighupIgnored(): boolean | null {
|
|
26
|
+
const names = LIBC_NAMES[process.platform];
|
|
27
|
+
if (!names) return null;
|
|
28
|
+
try {
|
|
29
|
+
// bun:ffi is built into Bun (no native dependency).
|
|
30
|
+
const { dlopen, FFIType, ptr } =
|
|
31
|
+
require("bun:ffi") as typeof import("bun:ffi");
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
try {
|
|
34
|
+
const libc = dlopen(name, {
|
|
35
|
+
sigaction: {
|
|
36
|
+
args: [FFIType.i32, FFIType.ptr, FFIType.ptr],
|
|
37
|
+
returns: FFIType.i32,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
const old = new BigUint64Array(SIGACTION_BYTES / 8);
|
|
42
|
+
if (libc.symbols.sigaction(SIGHUP, null, ptr(old)) !== 0) return null;
|
|
43
|
+
// The handler is the first member of struct sigaction on both.
|
|
44
|
+
return old[0] === SIG_IGN;
|
|
45
|
+
} finally {
|
|
46
|
+
libc.close();
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Try the next library name.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// No FFI in this runtime.
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True when SIGHUP was not ignored at start and its disposition is known, so
|
|
60
|
+
* a resident can shut down gracefully on it. Read before any SIGHUP listener
|
|
61
|
+
* is installed; the answer is cached.
|
|
62
|
+
*/
|
|
63
|
+
export function sighupHandlerAllowed(): boolean {
|
|
64
|
+
if (cached === undefined) cached = readSighupIgnored() === false;
|
|
65
|
+
return cached;
|
|
66
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cooperative time slicing for long synchronous work on a resident's main
|
|
3
|
+
* thread.
|
|
4
|
+
*
|
|
5
|
+
* Bun runs SQLite synchronously, so a loop of store calls never lets the
|
|
6
|
+
* event loop serve a request until it ends, however many `await`s it
|
|
7
|
+
* contains (they resolve as microtasks). Call `maybeYield()` between units of
|
|
8
|
+
* work: once the current slice has run longer than its budget, it yields a
|
|
9
|
+
* macrotask turn so pending requests (status, search) are answered.
|
|
10
|
+
*
|
|
11
|
+
* @module src/core/time-slice
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Longest a slice runs before yielding; keeps status well under 100 ms. */
|
|
15
|
+
export const DEFAULT_TIME_SLICE_MS = 20;
|
|
16
|
+
|
|
17
|
+
export interface TimeSlicer {
|
|
18
|
+
/** Yield to the event loop if the current slice is over budget. */
|
|
19
|
+
maybeYield(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createTimeSlicer(
|
|
23
|
+
budgetMs = DEFAULT_TIME_SLICE_MS,
|
|
24
|
+
now: () => number = performance.now.bind(performance)
|
|
25
|
+
): TimeSlicer {
|
|
26
|
+
let sliceStart = now();
|
|
27
|
+
return {
|
|
28
|
+
async maybeYield() {
|
|
29
|
+
if (now() - sliceStart < budgetMs) return;
|
|
30
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
31
|
+
sliceStart = now();
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
* If no backend can start, the file fails closed with
|
|
20
20
|
* ISOLATION_UNAVAILABLE; it is never prepared unbounded.
|
|
21
21
|
*
|
|
22
|
+
* A message that cannot be deserialized on the way to or from the processor
|
|
23
|
+
* (fn-213) fails the file with PROCESSOR_MESSAGE_FAILED and replaces the
|
|
24
|
+
* processor, and one stderr line names the direction, backend, file and
|
|
25
|
+
* step. Bun 1.4.0 and later report it as a `messageerror` event on the
|
|
26
|
+
* receiving side of a worker; over the child's IPC channel Bun closes the
|
|
27
|
+
* channel on both ends instead, so the child backend cannot tell which
|
|
28
|
+
* direction failed.
|
|
29
|
+
*
|
|
22
30
|
* One processor is reused across files (starting one costs about a third of
|
|
23
31
|
* a second of module loading). It is replaced after an overrun, after a
|
|
24
32
|
* file that leaves memory above half the budget, and after a short idle
|
|
@@ -78,8 +86,26 @@ export interface ReadyMessage {
|
|
|
78
86
|
type: "ready";
|
|
79
87
|
}
|
|
80
88
|
|
|
89
|
+
/** Posted by the worker when a job it was sent could not be deserialized. */
|
|
90
|
+
export interface UndeliverableJobMessage {
|
|
91
|
+
type: "undeliverable-job";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Which message could not be delivered; the child backend cannot tell. */
|
|
95
|
+
export type DeliveryDirection = "job" | "result" | "unknown";
|
|
96
|
+
|
|
97
|
+
/** Why a processor stopped working. */
|
|
98
|
+
interface ProcessorFailure {
|
|
99
|
+
reason: string;
|
|
100
|
+
/** Set when a message could not be delivered. */
|
|
101
|
+
delivery?: DeliveryDirection;
|
|
102
|
+
}
|
|
103
|
+
|
|
81
104
|
export type FileProcessorBackendKind = "worker" | "child";
|
|
82
105
|
|
|
106
|
+
/** Error code of a file whose message to or from the processor was lost. */
|
|
107
|
+
export const PROCESSOR_MESSAGE_FAILED = "PROCESSOR_MESSAGE_FAILED";
|
|
108
|
+
|
|
83
109
|
/** Error codes of a file stopped at its budget. */
|
|
84
110
|
export const BUDGET_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
85
111
|
"TIMEOUT",
|
|
@@ -149,7 +175,7 @@ interface Processor {
|
|
|
149
175
|
/** Settles once the processor has loaded and can take a file. */
|
|
150
176
|
readonly ready: Promise<void>;
|
|
151
177
|
/** Settles, with a reason, if the processor dies or errors. */
|
|
152
|
-
readonly failed: Promise<
|
|
178
|
+
readonly failed: Promise<ProcessorFailure>;
|
|
153
179
|
/** Send one file; its messages go to `onMessage`. */
|
|
154
180
|
run(
|
|
155
181
|
request: PrepareFileRequest,
|
|
@@ -164,6 +190,8 @@ interface Processor {
|
|
|
164
190
|
/** Child process id (child backend), for shutdown and tests. */
|
|
165
191
|
readonly pid: number | null;
|
|
166
192
|
stop(): void;
|
|
193
|
+
/** Tests only: raise the event Bun raises for an undeliverable message. */
|
|
194
|
+
simulateUndeliverable?(direction: "job" | "result"): void;
|
|
167
195
|
}
|
|
168
196
|
|
|
169
197
|
const phaseName = (index: number): string => PREPARE_PHASES[index] ?? "startup";
|
|
@@ -176,25 +204,38 @@ function startWorker(): Processor {
|
|
|
176
204
|
const phaseSlot = new Int32Array(new SharedArrayBuffer(4));
|
|
177
205
|
let onMessage: ((message: FileWorkerMessage) => void) | null = null;
|
|
178
206
|
let markReady: () => void = () => undefined;
|
|
179
|
-
let markFailed: (
|
|
207
|
+
let markFailed: (failure: ProcessorFailure) => void = () => undefined;
|
|
180
208
|
let dead = false;
|
|
181
209
|
const ready = new Promise<void>((resolve) => {
|
|
182
210
|
markReady = resolve;
|
|
183
211
|
});
|
|
184
|
-
const failed = new Promise<
|
|
185
|
-
markFailed = (
|
|
212
|
+
const failed = new Promise<ProcessorFailure>((resolve) => {
|
|
213
|
+
markFailed = (failure) => {
|
|
186
214
|
dead = true;
|
|
187
|
-
resolve(
|
|
215
|
+
resolve(failure);
|
|
188
216
|
};
|
|
189
217
|
});
|
|
190
218
|
worker.onmessage = (
|
|
191
|
-
event: MessageEvent<
|
|
219
|
+
event: MessageEvent<
|
|
220
|
+
FileWorkerMessage | ReadyMessage | UndeliverableJobMessage
|
|
221
|
+
>
|
|
192
222
|
) => {
|
|
193
223
|
if (event.data.type === "ready") markReady();
|
|
194
|
-
else
|
|
224
|
+
else if (event.data.type === "undeliverable-job") {
|
|
225
|
+
markFailed({
|
|
226
|
+
reason: "the file worker could not deserialize its job",
|
|
227
|
+
delivery: "job",
|
|
228
|
+
});
|
|
229
|
+
} else onMessage?.(event.data);
|
|
230
|
+
};
|
|
231
|
+
worker.onmessageerror = () => {
|
|
232
|
+
markFailed({
|
|
233
|
+
reason: "a message from the file worker could not be deserialized",
|
|
234
|
+
delivery: "result",
|
|
235
|
+
});
|
|
195
236
|
};
|
|
196
237
|
worker.onerror = (event: ErrorEvent) => {
|
|
197
|
-
markFailed(event.message || "file worker failed");
|
|
238
|
+
markFailed({ reason: event.message || "file worker failed" });
|
|
198
239
|
};
|
|
199
240
|
return {
|
|
200
241
|
kind: "worker",
|
|
@@ -212,7 +253,12 @@ function startWorker(): Processor {
|
|
|
212
253
|
pid: null,
|
|
213
254
|
stop() {
|
|
214
255
|
worker.terminate();
|
|
215
|
-
markFailed("file processor was stopped");
|
|
256
|
+
markFailed({ reason: "file processor was stopped" });
|
|
257
|
+
},
|
|
258
|
+
simulateUndeliverable(direction) {
|
|
259
|
+
if (direction === "result") {
|
|
260
|
+
worker.dispatchEvent(new MessageEvent("messageerror"));
|
|
261
|
+
} else worker.postMessage({ simulateUndeliverable: true });
|
|
216
262
|
},
|
|
217
263
|
};
|
|
218
264
|
}
|
|
@@ -267,12 +313,21 @@ function startChild(): Processor {
|
|
|
267
313
|
child.unref();
|
|
268
314
|
liveChildren.add(child);
|
|
269
315
|
ensureShutdownHooks();
|
|
270
|
-
const failed = child.exited.then((code) => {
|
|
316
|
+
const failed = child.exited.then((code): ProcessorFailure => {
|
|
271
317
|
exited = true;
|
|
272
318
|
liveChildren.delete(child);
|
|
273
|
-
return
|
|
274
|
-
|
|
275
|
-
|
|
319
|
+
if (stopping) return { reason: "file processor was stopped" };
|
|
320
|
+
// Bun closes the channel on both ends when either side receives a
|
|
321
|
+
// message it cannot deserialize. The child exits cleanly only once its
|
|
322
|
+
// channel is gone (file-child.ts), and a crash never exits cleanly; the
|
|
323
|
+
// exit can be reported before Bun's own disconnect callback.
|
|
324
|
+
if (code === 0) {
|
|
325
|
+
return {
|
|
326
|
+
reason: "the IPC channel to the file processor child closed",
|
|
327
|
+
delivery: "unknown",
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return { reason: `file processor child exited (${code})` };
|
|
276
331
|
});
|
|
277
332
|
return {
|
|
278
333
|
kind: "child",
|
|
@@ -367,6 +422,19 @@ export function activeFileProcessorPid(): number | null {
|
|
|
367
422
|
return processor?.pid ?? null;
|
|
368
423
|
}
|
|
369
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Tests only: make the running worker processor raise the event Bun raises
|
|
427
|
+
* when a message cannot be deserialized (a real payload cannot be made to
|
|
428
|
+
* fail from JavaScript). False when no worker processor is running.
|
|
429
|
+
*/
|
|
430
|
+
export function simulateUndeliverableMessage(
|
|
431
|
+
direction: "job" | "result"
|
|
432
|
+
): boolean {
|
|
433
|
+
if (!processor?.simulateUndeliverable) return false;
|
|
434
|
+
processor.simulateUndeliverable(direction);
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
|
|
370
438
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
371
439
|
// Processor lifecycle
|
|
372
440
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -429,11 +497,30 @@ const awaitReady = (active: Processor): Promise<string | null> => {
|
|
|
429
497
|
});
|
|
430
498
|
return Promise.race([
|
|
431
499
|
active.ready.then(() => null),
|
|
432
|
-
active.failed,
|
|
500
|
+
active.failed.then((failure) => failure.reason),
|
|
433
501
|
timedOut,
|
|
434
502
|
]).finally(() => clearTimeout(timer));
|
|
435
503
|
};
|
|
436
504
|
|
|
505
|
+
/** Failure of a file whose message to or from the processor was lost. */
|
|
506
|
+
function undeliverableFailure(
|
|
507
|
+
request: PrepareFileRequest,
|
|
508
|
+
kind: FileProcessorBackendKind,
|
|
509
|
+
phase: string,
|
|
510
|
+
failure: ProcessorFailure
|
|
511
|
+
): PrepareFailure {
|
|
512
|
+
const direction = failure.delivery ?? "unknown";
|
|
513
|
+
const file = `${request.input.collection}/${request.input.relativePath}`;
|
|
514
|
+
console.error(
|
|
515
|
+
`gno: file processor message lost (direction ${direction}, backend ${kind}) for ${file} during ${phase}: ${failure.reason}; the processor was replaced`
|
|
516
|
+
);
|
|
517
|
+
return {
|
|
518
|
+
code: PROCESSOR_MESSAGE_FAILED,
|
|
519
|
+
message: `The file processor could not deliver a message during ${phase} (${failure.reason}); the processor was replaced`,
|
|
520
|
+
details: { direction, backend: kind, phase },
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
437
524
|
const runOne = async (
|
|
438
525
|
request: PrepareFileRequest,
|
|
439
526
|
budget: FileBudgetLimits,
|
|
@@ -511,9 +598,23 @@ const runOne = async (
|
|
|
511
598
|
const sampler = setInterval(() => {
|
|
512
599
|
void checkMemory();
|
|
513
600
|
}, MEMORY_SAMPLE_INTERVAL_MS);
|
|
514
|
-
void active.failed.then((
|
|
515
|
-
|
|
516
|
-
|
|
601
|
+
void active.failed.then((failure) => {
|
|
602
|
+
if (settled) return;
|
|
603
|
+
finish(
|
|
604
|
+
{
|
|
605
|
+
ok: false,
|
|
606
|
+
error: failure.delivery
|
|
607
|
+
? undeliverableFailure(
|
|
608
|
+
request,
|
|
609
|
+
active.kind,
|
|
610
|
+
active.phase(),
|
|
611
|
+
failure
|
|
612
|
+
)
|
|
613
|
+
: { code: "INTERNAL", message: failure.reason },
|
|
614
|
+
},
|
|
615
|
+
false
|
|
616
|
+
);
|
|
617
|
+
});
|
|
517
618
|
// A process already over the budget does not start another file.
|
|
518
619
|
const rss = process.memoryUsage.rss();
|
|
519
620
|
if (rss > budget.maxMemoryBytes) {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
normalizeWikiName,
|
|
20
20
|
parseTargetParts,
|
|
21
21
|
} from "../core/links";
|
|
22
|
+
import { createTimeSlicer } from "../core/time-slice";
|
|
22
23
|
import {
|
|
23
24
|
createInMemoryWorkspaceResolver,
|
|
24
25
|
membershipsFromCollectionRows,
|
|
@@ -137,6 +138,9 @@ function relationResolver(
|
|
|
137
138
|
};
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
/** Changed identities per incoming-link lookup (one statement each). */
|
|
142
|
+
const INCOMING_LINK_CHUNK = 8;
|
|
143
|
+
|
|
140
144
|
export async function projectGraph(
|
|
141
145
|
store: StorePort,
|
|
142
146
|
options: SyncOptions,
|
|
@@ -144,6 +148,7 @@ export async function projectGraph(
|
|
|
144
148
|
forceFull = false
|
|
145
149
|
): Promise<ProjectionError[]> {
|
|
146
150
|
const errors: ProjectionError[] = [];
|
|
151
|
+
const slicer = createTimeSlicer();
|
|
147
152
|
try {
|
|
148
153
|
const graph = store.graphReferenceStore?.();
|
|
149
154
|
let fingerprint = "";
|
|
@@ -221,10 +226,16 @@ export async function projectGraph(
|
|
|
221
226
|
// No durable per-source link journal exists; conservatively rebuild in that case.
|
|
222
227
|
if (changed.size === 0 && state.dirty) selected = undefined;
|
|
223
228
|
else {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
))
|
|
227
|
-
|
|
229
|
+
// One statement over every changed identity scales with links x
|
|
230
|
+
// targets and cannot yield; chunk it so the event loop gets turns.
|
|
231
|
+
const identities = [...changed.values()].flat();
|
|
232
|
+
for (let i = 0; i < identities.length; i += INCOMING_LINK_CHUNK) {
|
|
233
|
+
await slicer.maybeYield();
|
|
234
|
+
for (const id of graph.incomingLinkSources(
|
|
235
|
+
identities.slice(i, i + INCOMING_LINK_CHUNK)
|
|
236
|
+
))
|
|
237
|
+
selected.add(id);
|
|
238
|
+
}
|
|
228
239
|
const resolveOld = relationResolver(
|
|
229
240
|
previous.map((row) => row.document),
|
|
230
241
|
memberships
|
|
@@ -264,7 +275,10 @@ export async function projectGraph(
|
|
|
264
275
|
);
|
|
265
276
|
if (!backfill.ok) throw new Error(backfill.error.message);
|
|
266
277
|
for (const [index, doc] of targets.entries()) {
|
|
278
|
+
// Every 25 documents at least, and sooner when slow documents
|
|
279
|
+
// (large link resolution) would hold the event loop past its budget.
|
|
267
280
|
if (index > 0 && index % 25 === 0) await Bun.sleep(0);
|
|
281
|
+
else await slicer.maybeYield();
|
|
268
282
|
if (!doc.active) {
|
|
269
283
|
const cleared = await store.setDocEdges(
|
|
270
284
|
doc.id,
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
FileWorkerMessage,
|
|
9
9
|
FileWorkerRequest,
|
|
10
10
|
ReadyMessage,
|
|
11
|
+
UndeliverableJobMessage,
|
|
11
12
|
} from "./file-processor";
|
|
12
13
|
|
|
13
14
|
import { defaultChunker } from "./chunker";
|
|
@@ -16,14 +17,31 @@ import { PREPARE_PHASES, prepareFile } from "./prepare-file";
|
|
|
16
17
|
|
|
17
18
|
declare const self: Worker;
|
|
18
19
|
|
|
19
|
-
const post = (
|
|
20
|
+
const post = (
|
|
21
|
+
message: FileWorkerMessage | ReadyMessage | UndeliverableJobMessage
|
|
22
|
+
): void => {
|
|
20
23
|
self.postMessage(message);
|
|
21
24
|
};
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
// A job that cannot be deserialized never reaches onmessage (Bun 1.4.0+
|
|
27
|
+
// raises messageerror instead); tell the parent so the file fails at once.
|
|
28
|
+
// A listener, not `onmessageerror`: Bun's worker scope never calls that
|
|
29
|
+
// property.
|
|
30
|
+
self.addEventListener("messageerror", () => {
|
|
31
|
+
post({ type: "undeliverable-job" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
self.onmessage = async (
|
|
35
|
+
event: MessageEvent<FileWorkerRequest | { simulateUndeliverable: true }>
|
|
36
|
+
) => {
|
|
24
37
|
// Only the parent that created this worker may drive it; a Bun Worker sees
|
|
25
38
|
// its parent's messages with an empty origin.
|
|
26
39
|
if (event.origin !== "") return;
|
|
40
|
+
if ("simulateUndeliverable" in event.data) {
|
|
41
|
+
// Tests only (simulateUndeliverableMessage): raise the real event.
|
|
42
|
+
self.dispatchEvent(new MessageEvent("messageerror"));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
27
45
|
const { request, phaseSlot } = event.data;
|
|
28
46
|
const outcome = await prepareFile(request, {
|
|
29
47
|
convert: convertForPreparation,
|
package/src/ingestion/sync.ts
CHANGED
|
@@ -229,11 +229,73 @@ async function gitPull(path: string): Promise<void> {
|
|
|
229
229
|
/**
|
|
230
230
|
* Run collection update command (best effort).
|
|
231
231
|
*/
|
|
232
|
-
|
|
232
|
+
/** Default cap on a collection's updateCmd; `updateCmdTimeoutMs` overrides it. */
|
|
233
|
+
export const UPDATE_CMD_TIMEOUT_MS = 10 * 60_000;
|
|
234
|
+
/**
|
|
235
|
+
* POSIX wrapper run as `sh -c WRAPPER sh <updateCmd>` in a new process group
|
|
236
|
+
* (detached spawn). It runs the command with stdin from /dev/null and blocks
|
|
237
|
+
* a subshell on gno's stdin pipe: when gno exits or is killed, by any signal
|
|
238
|
+
* including SIGKILL, the pipe closes and the subshell stops the whole group,
|
|
239
|
+
* so no part of the command (a hung `git` or `rsync`) outlives gno; anything
|
|
240
|
+
* still running 5 s after SIGTERM gets SIGKILL. The pipe goes through fd 3
|
|
241
|
+
* because an asynchronous list's stdin is /dev/null.
|
|
242
|
+
*/
|
|
243
|
+
const UPDATE_CMD_WRAPPER = [
|
|
244
|
+
"exec 3<&0",
|
|
245
|
+
'"$0" -c "$1" </dev/null 3<&- &',
|
|
246
|
+
"cmd=$!",
|
|
247
|
+
// The watcher ignores the TERM it sends, gives the command up to 5 s to
|
|
248
|
+
// exit, then SIGKILLs whatever is left of the group (itself included).
|
|
249
|
+
[
|
|
250
|
+
"( trap '' TERM; read -r _ <&3; kill -TERM 0",
|
|
251
|
+
'i=0; while kill -0 "$cmd" 2>/dev/null && [ "$i" -lt 10 ]; do sleep 0.5; i=$((i + 1)); done',
|
|
252
|
+
"kill -KILL 0 ) &",
|
|
253
|
+
].join("; "),
|
|
254
|
+
"exec 3<&-",
|
|
255
|
+
'wait "$cmd"',
|
|
256
|
+
].join("\n");
|
|
257
|
+
|
|
258
|
+
async function runUpdateCmd(
|
|
259
|
+
path: string,
|
|
260
|
+
cmd: string,
|
|
261
|
+
timeoutMs = UPDATE_CMD_TIMEOUT_MS
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
const posix = process.platform !== "win32";
|
|
264
|
+
let child: Bun.Subprocess<"pipe", "ignore", "ignore">;
|
|
233
265
|
try {
|
|
234
|
-
|
|
266
|
+
child = Bun.spawn({
|
|
267
|
+
cmd: posix
|
|
268
|
+
? ["sh", "-c", UPDATE_CMD_WRAPPER, "sh", cmd]
|
|
269
|
+
: ["sh", "-c", cmd],
|
|
270
|
+
cwd: path,
|
|
271
|
+
stdin: "pipe",
|
|
272
|
+
stdout: "ignore",
|
|
273
|
+
stderr: "ignore",
|
|
274
|
+
detached: posix,
|
|
275
|
+
});
|
|
235
276
|
} catch {
|
|
236
|
-
// Ignore update command failures
|
|
277
|
+
// Ignore update command failures (no shell, bad cwd).
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const signalAll = (signal: NodeJS.Signals): void => {
|
|
281
|
+
try {
|
|
282
|
+
if (posix) process.kill(-child.pid, signal);
|
|
283
|
+
else child.kill(signal);
|
|
284
|
+
} catch {
|
|
285
|
+
// already gone
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
// On timeout, TERM the group. POSIX: that ends the wrapper, the finally
|
|
289
|
+
// below closes the lifeline, and the wrapper's watcher escalates whatever
|
|
290
|
+
// ignores TERM to SIGKILL. On Windows SIGTERM already terminates.
|
|
291
|
+
const timer = setTimeout(() => signalAll("SIGTERM"), timeoutMs);
|
|
292
|
+
try {
|
|
293
|
+
// Ignore update command failures (non-zero exit, timeout).
|
|
294
|
+
await child.exited;
|
|
295
|
+
} finally {
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
// Closing the lifeline also reaps anything the command left running.
|
|
298
|
+
await Promise.resolve(child.stdin.end()).catch(() => undefined);
|
|
237
299
|
}
|
|
238
300
|
}
|
|
239
301
|
|
|
@@ -1605,7 +1667,11 @@ export class SyncService {
|
|
|
1605
1667
|
caller: { authenticated: true, operationAuthorized: true },
|
|
1606
1668
|
contentClass: "source",
|
|
1607
1669
|
});
|
|
1608
|
-
await runUpdateCmd(
|
|
1670
|
+
await runUpdateCmd(
|
|
1671
|
+
collection.path,
|
|
1672
|
+
collection.updateCmd,
|
|
1673
|
+
collection.updateCmdTimeoutMs
|
|
1674
|
+
);
|
|
1609
1675
|
}
|
|
1610
1676
|
|
|
1611
1677
|
if (options.gitPull && (await isGitRepo(collection.path))) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent lifeline for a resident started by another process (fn-207).
|
|
3
|
+
*
|
|
4
|
+
* A launcher that owns `gno serve` (the desktop shell) sets
|
|
5
|
+
* `GNO_PARENT_LIFELINE=stdin` and keeps a pipe on the child's stdin open.
|
|
6
|
+
* When the launcher exits or is killed, even with SIGKILL, the kernel closes
|
|
7
|
+
* that pipe; stdin reaches EOF and the resident shuts down as on SIGTERM.
|
|
8
|
+
* Event-driven on every platform, with no polling timer. Without the opt-in a
|
|
9
|
+
* resident keeps running when its parent goes away (e.g. `nohup gno serve`).
|
|
10
|
+
*
|
|
11
|
+
* @module src/serve/parent-lifeline
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const PARENT_LIFELINE_ENV = "GNO_PARENT_LIFELINE";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* When the launcher opted in, call `onLost` once stdin reaches EOF. Returns a
|
|
18
|
+
* stop function that cancels the watch (normal shutdown). The opt-in is
|
|
19
|
+
* removed from the environment so this process's own children never inherit
|
|
20
|
+
* it.
|
|
21
|
+
*/
|
|
22
|
+
export function watchParentLifeline(onLost: () => void): () => void {
|
|
23
|
+
const mode = process.env[PARENT_LIFELINE_ENV];
|
|
24
|
+
delete process.env[PARENT_LIFELINE_ENV];
|
|
25
|
+
if (mode !== "stdin") return () => undefined;
|
|
26
|
+
|
|
27
|
+
let stopped = false;
|
|
28
|
+
const reader = Bun.stdin.stream().getReader();
|
|
29
|
+
void (async () => {
|
|
30
|
+
try {
|
|
31
|
+
// The launcher never writes; any bytes are drained and ignored.
|
|
32
|
+
while (!(await reader.read()).done) {
|
|
33
|
+
// keep reading until EOF
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// A read error means the pipe is gone, the same as EOF.
|
|
37
|
+
}
|
|
38
|
+
if (!stopped) onLost();
|
|
39
|
+
})();
|
|
40
|
+
return () => {
|
|
41
|
+
stopped = true;
|
|
42
|
+
void reader.cancel().catch(() => undefined);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -65,8 +65,8 @@ export function BootstrapStatus({
|
|
|
65
65
|
|
|
66
66
|
return (
|
|
67
67
|
<section className="space-y-4">
|
|
68
|
-
<div className="flex items-start justify-between
|
|
69
|
-
<div>
|
|
68
|
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
69
|
+
<div className="min-w-0">
|
|
70
70
|
<div className="mb-2 flex items-center gap-2">
|
|
71
71
|
<PackageIcon className="size-4 text-primary" />
|
|
72
72
|
<h2 className="font-semibold text-2xl">Bootstrap & Storage</h2>
|
|
@@ -77,7 +77,7 @@ export function BootstrapStatus({
|
|
|
77
77
|
</p>
|
|
78
78
|
</div>
|
|
79
79
|
{missingModels > 0 && !bootstrap.models.downloading && (
|
|
80
|
-
<Button onClick={onDownloadModels} size="sm">
|
|
80
|
+
<Button className="self-start" onClick={onDownloadModels} size="sm">
|
|
81
81
|
<DownloadIcon className="mr-2 size-4" />
|
|
82
82
|
Download missing models
|
|
83
83
|
</Button>
|