@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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/assets/spa-production.json.gz +0 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v2.9.1.zip → gno-browser-clipper-v2.10.0.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v2.10.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +3 -2
  7. package/spec/cli.md +73 -2
  8. package/spec/output-schemas/collection-list.schema.json +5 -0
  9. package/src/app/bun-runtime.ts +41 -0
  10. package/src/cli/commands/daemon.ts +12 -0
  11. package/src/cli/commands/doctor.ts +61 -0
  12. package/src/cli/commands/mcp/config-editors.ts +56 -8
  13. package/src/cli/commands/mcp/install.ts +159 -24
  14. package/src/cli/commands/mcp/launch-path.ts +87 -0
  15. package/src/cli/commands/mcp/paths.ts +30 -6
  16. package/src/cli/commands/mcp/repair-entry.ts +160 -0
  17. package/src/cli/commands/mcp/status.ts +55 -15
  18. package/src/cli/commands/mcp.ts +4 -0
  19. package/src/cli/commands/serve.ts +3 -0
  20. package/src/cli/commands/status.ts +24 -0
  21. package/src/cli/detach.ts +52 -5
  22. package/src/cli/program.ts +19 -5
  23. package/src/cli/runtime-health.ts +328 -0
  24. package/src/config/types.ts +3 -0
  25. package/src/core/network-boundary-inventory.ts +16 -0
  26. package/src/core/project-profile-apply-state.ts +3 -0
  27. package/src/core/resident-shutdown-handler.ts +23 -0
  28. package/src/core/sighup-disposition.ts +66 -0
  29. package/src/core/time-slice.ts +34 -0
  30. package/src/ingestion/file-processor.ts +118 -17
  31. package/src/ingestion/graph-reconciliation.ts +18 -4
  32. package/src/ingestion/prepare-worker.ts +20 -2
  33. package/src/ingestion/sync.ts +70 -4
  34. package/src/serve/parent-lifeline.ts +44 -0
  35. package/src/serve/public/components/BootstrapStatus.tsx +3 -3
  36. package/src/serve/public/globals.built.css +1 -1
  37. package/src/serve/resident-runtime.ts +24 -1
  38. package/src/serve/resident-shutdown.ts +4 -0
  39. package/src/serve/server.ts +22 -1
  40. package/src/serve/status.ts +2 -1
  41. package/src/serve/watch-reconciliation.ts +134 -1
  42. package/src/serve/watch-service-flush-generation.ts +77 -3
  43. package/src/serve/watch-service.ts +27 -3
  44. package/src/sessions/import-child.ts +79 -11
  45. package/src/store/migrations/035-vector-length-index.ts +30 -0
  46. package/src/store/migrations/index.ts +2 -0
  47. package/src/store/sqlite/adapter.ts +4 -1
  48. package/src/store/vector/freshness.ts +13 -6
  49. 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<string>;
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: (reason: string) => void = () => undefined;
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<string>((resolve) => {
185
- markFailed = (reason) => {
212
+ const failed = new Promise<ProcessorFailure>((resolve) => {
213
+ markFailed = (failure) => {
186
214
  dead = true;
187
- resolve(reason);
215
+ resolve(failure);
188
216
  };
189
217
  });
190
218
  worker.onmessage = (
191
- event: MessageEvent<FileWorkerMessage | ReadyMessage>
219
+ event: MessageEvent<
220
+ FileWorkerMessage | ReadyMessage | UndeliverableJobMessage
221
+ >
192
222
  ) => {
193
223
  if (event.data.type === "ready") markReady();
194
- else onMessage?.(event.data);
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 stopping
274
- ? "file processor was stopped"
275
- : `file processor child exited (${code})`;
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((reason) =>
515
- finish({ ok: false, error: { code: "INTERNAL", message: reason } }, false)
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
- for (const id of graph.incomingLinkSources(
225
- [...changed.values()].flat()
226
- ))
227
- selected.add(id);
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 = (message: FileWorkerMessage | ReadyMessage): void => {
20
+ const post = (
21
+ message: FileWorkerMessage | ReadyMessage | UndeliverableJobMessage
22
+ ): void => {
20
23
  self.postMessage(message);
21
24
  };
22
25
 
23
- self.onmessage = async (event: MessageEvent<FileWorkerRequest>) => {
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,
@@ -229,11 +229,73 @@ async function gitPull(path: string): Promise<void> {
229
229
  /**
230
230
  * Run collection update command (best effort).
231
231
  */
232
- async function runUpdateCmd(path: string, cmd: string): Promise<void> {
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
- await Bun.$`sh -c ${cmd}`.cwd(path).quiet().nothrow();
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(collection.path, collection.updateCmd);
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 gap-4">
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>