@gmickel/gno 2.9.0 → 2.9.2

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 (35) hide show
  1. package/README.md +1 -1
  2. package/browser-extension/artifacts/{gno-browser-clipper-v2.9.0.zip → gno-browser-clipper-v2.9.2.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v2.9.2.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +2 -1
  6. package/spec/cli.md +3 -0
  7. package/spec/output-schemas/collection-list.schema.json +5 -0
  8. package/src/cli/commands/daemon.ts +9 -0
  9. package/src/cli/detach.ts +42 -5
  10. package/src/cli/program.ts +11 -2
  11. package/src/config/types.ts +3 -0
  12. package/src/core/project-profile-apply-state.ts +3 -0
  13. package/src/core/resident-shutdown-handler.ts +23 -0
  14. package/src/core/sighup-disposition.ts +66 -0
  15. package/src/core/time-slice.ts +34 -0
  16. package/src/ingestion/graph-reconciliation.ts +18 -4
  17. package/src/ingestion/sync.ts +70 -4
  18. package/src/serve/parent-lifeline.ts +44 -0
  19. package/src/serve/resident-runtime.ts +24 -1
  20. package/src/serve/resident-shutdown.ts +4 -0
  21. package/src/serve/server.ts +22 -1
  22. package/src/serve/watch-reconciliation-shared.ts +12 -1
  23. package/src/serve/watch-reconciliation.ts +136 -1
  24. package/src/serve/watch-service-events.ts +43 -9
  25. package/src/serve/watch-service-flush-generation.ts +77 -3
  26. package/src/serve/watch-service-hosts.ts +12 -2
  27. package/src/serve/watch-service-lifecycle.ts +8 -0
  28. package/src/serve/watch-service-run-flush.ts +13 -2
  29. package/src/serve/watch-service.ts +37 -4
  30. package/src/sessions/import-child.ts +79 -11
  31. package/src/store/migrations/035-vector-length-index.ts +30 -0
  32. package/src/store/migrations/index.ts +2 -0
  33. package/src/store/sqlite/adapter.ts +4 -1
  34. package/src/store/vector/freshness.ts +13 -6
  35. package/browser-extension/artifacts/gno-browser-clipper-v2.9.0.zip.sha256 +0 -1
package/README.md CHANGED
@@ -139,7 +139,7 @@ See the [guide](docs/COMPILED-CONTEXT.md).
139
139
 
140
140
  <!-- public-truth:current-version -->
141
141
 
142
- > Current source version: **v2.9.0**. See [CHANGELOG.md](./CHANGELOG.md).
142
+ > Current source version: **v2.9.2**. See [CHANGELOG.md](./CHANGELOG.md).
143
143
 
144
144
  <!-- /public-truth -->
145
145
 
@@ -0,0 +1 @@
1
+ 7073229377bafdffb9fb4834e22fe4531e68afcf615c19c1bb021c205997375a gno-browser-clipper-v2.9.2.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "2.9.0"
24
+ "version": "2.9.2"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "2.9.0",
3
+ "version": "2.9.2",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -90,6 +90,7 @@
90
90
  "test:watcher:filesystem": "bun test test/serve/watch-service-filesystem.test.ts test/serve/resident-runtime.test.ts",
91
91
  "bench:watcher-reconciliation": "bun scripts/watcher-reconciliation-benchmark.ts",
92
92
  "bench:session-import": "bun scripts/session-import-benchmark.ts",
93
+ "soak": "bun scripts/soak/index.ts",
93
94
  "smoke:watcher-reconciliation": "bun scripts/watcher-reconciliation-smoke.ts",
94
95
  "bench:code-embeddings": "bun scripts/code-embedding-benchmark.ts",
95
96
  "bench:code-embeddings:write": "bun scripts/code-embedding-benchmark.ts --write",
package/spec/cli.md CHANGED
@@ -1079,6 +1079,9 @@ gno embed [collection] [--collection <name>] [--force] [--model <uri>] [--batch-
1079
1079
  ]
1080
1080
  ````
1081
1081
 
1082
+ `updateCmdTimeoutMs` (milliseconds) appears when the collection sets a limit
1083
+ for `updateCmd`; without it the command is stopped after 10 minutes.
1084
+
1082
1085
  **Exit Codes:**
1083
1086
 
1084
1087
  - 0: Success
@@ -36,6 +36,11 @@
36
36
  "type": "string",
37
37
  "description": "Optional shell command to run before indexing"
38
38
  },
39
+ "updateCmdTimeoutMs": {
40
+ "type": "integer",
41
+ "minimum": 1,
42
+ "description": "Optional limit for updateCmd in milliseconds; gno stops the command (and anything it started) after this long. Absent means the 10 minute default."
43
+ },
39
44
  "languageHint": {
40
45
  "type": "string",
41
46
  "description": "BCP-47 language hint for the collection"
@@ -10,6 +10,8 @@ import {
10
10
  EGRESS_DENIED_MESSAGE,
11
11
  EgressDeniedError,
12
12
  } from "../../core/egress-enforcement";
13
+ import { markResidentShutdownHandlerInstalled } from "../../core/resident-shutdown-handler";
14
+ import { sighupHandlerAllowed } from "../../core/sighup-disposition";
13
15
  import {
14
16
  DEFAULT_HTTP_GATEWAY_PORT,
15
17
  isHttpGatewayLoopbackBind,
@@ -164,6 +166,7 @@ function createSignalPromise(
164
166
  signal?.removeEventListener("abort", onAbort);
165
167
  process.off("SIGINT", onSigint);
166
168
  process.off("SIGTERM", onSigterm);
169
+ process.off("SIGHUP", onSighup);
167
170
  if (message && !quiet) {
168
171
  logger.log(message);
169
172
  }
@@ -174,10 +177,16 @@ function createSignalPromise(
174
177
  const onSigint = (): void => complete("Received SIGINT. Shutting down...");
175
178
  const onSigterm = (): void =>
176
179
  complete("Received SIGTERM. Shutting down...");
180
+ // A closed terminal or logout: shut down cleanly, as on SIGTERM, unless
181
+ // SIGHUP was ignored at start (nohup), which must keep working.
182
+ const handleSighup = sighupHandlerAllowed();
183
+ const onSighup = (): void => complete("Received SIGHUP. Shutting down...");
177
184
 
178
185
  signal?.addEventListener("abort", onAbort, { once: true });
179
186
  process.once("SIGINT", onSigint);
180
187
  process.once("SIGTERM", onSigterm);
188
+ if (handleSighup) process.once("SIGHUP", onSighup);
189
+ markResidentShutdownHandlerInstalled();
181
190
  });
182
191
  }
183
192
 
package/src/cli/detach.ts CHANGED
@@ -510,6 +510,17 @@ function acquireStartLock(pidFile: string, kind: DetachKind): string {
510
510
  );
511
511
  }
512
512
 
513
+ /** Take the start lock if it is free right now; null when someone holds it. */
514
+ function tryAcquireStartLock(pidFile: string): string | null {
515
+ const lockPath = startLockPath(pidFile);
516
+ try {
517
+ closeSync(openSync(lockPath, "wx", 0o600));
518
+ return lockPath;
519
+ } catch {
520
+ return null;
521
+ }
522
+ }
523
+
513
524
  function releaseStartLock(lockPath: string): void {
514
525
  try {
515
526
  unlinkSync(lockPath);
@@ -943,12 +954,35 @@ async function waitForExit(
943
954
  /**
944
955
  * Stop a detached process: SIGTERM → poll → SIGKILL → poll → error.
945
956
  *
946
- * The pid-file is **not** unlinked on success by this helper — we let the
947
- * target's own signal handler clean it up (see `createSignalPromise` in
948
- * `src/cli/commands/daemon.ts`). Callers may unlink the file as a fallback
949
- * when liveness is `false` after the kill sequence; `stopProcess` itself only
950
- * unlinks stale pid-files it discovers on entry.
957
+ * The target's own signal handler normally removes the pid-file. A target
958
+ * killed before that handler existed (SIGTERM during early startup, or the
959
+ * SIGKILL escalation) cannot, so once the exit is confirmed this helper
960
+ * removes the pid-file if it still names the pid it stopped.
961
+ */
962
+ /**
963
+ * Remove the pid-file only if it still names `pid` (never a newer instance's).
964
+ * The check and the unlink run under the start lock, so a concurrent
965
+ * `--detach` cannot write its pid-file in between. If a start holds the lock,
966
+ * the file is left alone: `guardDoubleStart` already treats a dead pid as
967
+ * stale.
951
968
  */
969
+ async function removePidFileIfOwnedBy(
970
+ pidFile: string,
971
+ pid: number
972
+ ): Promise<void> {
973
+ const lockPath = tryAcquireStartLock(pidFile);
974
+ if (!lockPath) return;
975
+ try {
976
+ const current = await readPidFile(pidFile).catch(() => null);
977
+ if (current?.pid !== pid) return;
978
+ await unlink(pidFile).catch(() => {
979
+ /* already gone */
980
+ });
981
+ } finally {
982
+ releaseStartLock(lockPath);
983
+ }
984
+ }
985
+
952
986
  export async function stopProcess(options: StopOptions): Promise<StopOutcome> {
953
987
  const timeoutMs = options.timeoutMs ?? RESIDENT_STOP_GRACE_MS;
954
988
  const pollIntervalMs = options.pollIntervalMs ?? 100;
@@ -1004,6 +1038,7 @@ export async function stopProcess(options: StopOptions): Promise<StopOutcome> {
1004
1038
  sleep
1005
1039
  );
1006
1040
  if (exitedOnSigterm) {
1041
+ await removePidFileIfOwnedBy(options.pidFile, payload.pid);
1007
1042
  return { kind: "stopped", pid: payload.pid, signal: "SIGTERM" };
1008
1043
  }
1009
1044
 
@@ -1011,6 +1046,7 @@ export async function stopProcess(options: StopOptions): Promise<StopOutcome> {
1011
1046
  kill(payload.pid, "SIGKILL");
1012
1047
  } catch (error) {
1013
1048
  if (isErrnoException(error) && error.code === "ESRCH") {
1049
+ await removePidFileIfOwnedBy(options.pidFile, payload.pid);
1014
1050
  return { kind: "stopped", pid: payload.pid, signal: "SIGTERM" };
1015
1051
  }
1016
1052
  throw error;
@@ -1025,6 +1061,7 @@ export async function stopProcess(options: StopOptions): Promise<StopOutcome> {
1025
1061
  sleep
1026
1062
  );
1027
1063
  if (exitedOnSigkill) {
1064
+ await removePidFileIfOwnedBy(options.pidFile, payload.pid);
1028
1065
  return { kind: "stopped", pid: payload.pid, signal: "SIGKILL" };
1029
1066
  }
1030
1067
 
@@ -25,6 +25,7 @@ import {
25
25
  formatFindingsRunStatusLine,
26
26
  readFindingsRunStatus,
27
27
  } from "../core/findings-run-state";
28
+ import { residentShutdownHandlerInstalled } from "../core/resident-shutdown-handler";
28
29
  import { parseAndValidateTagFilter } from "../core/tags";
29
30
  import {
30
31
  formatWriteLeaseBusyJson,
@@ -5564,8 +5565,16 @@ function installPidFileCleanup(pidFile: string): void {
5564
5565
  // Already gone or permission-denied — nothing actionable here.
5565
5566
  }
5566
5567
  };
5567
- process.once("SIGINT", cleanup);
5568
- process.once("SIGTERM", cleanup);
5568
+ // Registering a listener disables the default terminate action. A signal
5569
+ // that arrives before the daemon/server installs its own shutdown handler
5570
+ // (still starting up) would otherwise be swallowed, and `--stop` would wait
5571
+ // out its grace period and SIGKILL. Until that handler exists, exit now.
5572
+ const onSignal = (exitCode: number) => () => {
5573
+ cleanup();
5574
+ if (!residentShutdownHandlerInstalled()) process.exit(exitCode);
5575
+ };
5576
+ process.once("SIGINT", onSignal(130));
5577
+ process.once("SIGTERM", onSignal(143));
5569
5578
  // Also run on a clean (`exit(0)`) path so crashes leave a stale pid-file
5570
5579
  // that `--status` can detect via liveness check, but orderly shutdown
5571
5580
  // (e.g. startServer returned) still cleans up.
@@ -138,6 +138,9 @@ export const CollectionSchema = z.object({
138
138
  /** Optional shell command to run before indexing */
139
139
  updateCmd: z.string().optional(),
140
140
 
141
+ /** Kill `updateCmd` (and anything it started) after this long; default 10 min */
142
+ updateCmdTimeoutMs: z.number().int().positive().optional(),
143
+
141
144
  /** Optional BCP-47 language hint */
142
145
  languageHint: z
143
146
  .string()
@@ -78,6 +78,9 @@ export function applyProjectProfileDesiredState(
78
78
  include: [],
79
79
  exclude: desired.collection.exclude,
80
80
  ...(existing?.updateCmd ? { updateCmd: existing.updateCmd } : {}),
81
+ ...(existing?.updateCmdTimeoutMs
82
+ ? { updateCmdTimeoutMs: existing.updateCmdTimeoutMs }
83
+ : {}),
81
84
  ...(desired.collection.languageHint
82
85
  ? { languageHint: desired.collection.languageHint }
83
86
  : existing?.languageHint
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Whether this process's resident (serve or daemon) has installed its own
3
+ * SIGINT/SIGTERM shutdown handler.
4
+ *
5
+ * A detached resident registers a pid-file cleanup listener before startup,
6
+ * and registering any listener disables the default terminate action. Until
7
+ * the resident's handler exists, that cleanup listener must exit the process
8
+ * itself, or a stop sent during startup is swallowed. Counting listeners
9
+ * cannot tell: the CLI bootstrap and the file processor add their own.
10
+ *
11
+ * @module src/core/resident-shutdown-handler
12
+ */
13
+
14
+ let installed = false;
15
+
16
+ /** Called by serve and the daemon right after registering their handlers. */
17
+ export function markResidentShutdownHandlerInstalled(): void {
18
+ installed = true;
19
+ }
20
+
21
+ export function residentShutdownHandlerInstalled(): boolean {
22
+ return installed;
23
+ }
@@ -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,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,
@@ -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
+ }
@@ -209,6 +209,7 @@ export type ResidentRuntimeDeps = {
209
209
  callbacks?: CollectionWatchCallbacks;
210
210
  syncOptions?: Parameters<typeof withContentTypeRules>[0];
211
211
  acquireWriteLease?: () => Promise<(() => Promise<void>) | null>;
212
+ reconcileOnStart?: boolean;
212
213
  }) => CollectionWatchService;
213
214
  modelManagerFactory?: (config: Config) => ModelManager;
214
215
  };
@@ -392,6 +393,8 @@ export async function startResidentRuntime(
392
393
  },
393
394
  syncOptions: withContentTypeRules({}, initialConfig),
394
395
  acquireWriteLease: leaseFor(`gno ${mode} (watch sync)`),
396
+ // serve has no initial sync of its own; the daemon runs one unless told not to.
397
+ reconcileOnStart: mode === "serve",
395
398
  });
396
399
  watchService.start();
397
400
  // A backlog left by `--no-embed` or an earlier run gets a pass without
@@ -502,8 +505,24 @@ export async function startResidentRuntime(
502
505
  indexName: canonicalizeIndexName(options.index ?? DEFAULT_INDEX_NAME),
503
506
  };
504
507
  let configRefresh: Promise<void> | undefined;
508
+ // Every read request refreshes the config. Re-reading and re-parsing an
509
+ // unchanged file each time kept the main thread busy under load; skip it
510
+ // while the file's identity, size and times match the last good load. Any
511
+ // write, rename, chmod or delete changes the stamp, so a broken file is
512
+ // still reported, never served stale.
513
+ let loadedConfigStamp: string | undefined;
514
+ const configStamp = async (): Promise<string | undefined> => {
515
+ try {
516
+ const stat = await Bun.file(instance.configPath).stat();
517
+ return `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
518
+ } catch {
519
+ return undefined;
520
+ }
521
+ };
505
522
  const refreshConfig = (): Promise<void> => {
506
523
  configRefresh ??= (async () => {
524
+ const stamp = await configStamp();
525
+ if (stamp !== undefined && stamp === loadedConfigStamp) return;
507
526
  const file = await readInstanceConfig(
508
527
  instance,
509
528
  deps.loadConfig ?? loadConfig
@@ -514,7 +533,10 @@ export async function startResidentRuntime(
514
533
  collections: file.collections,
515
534
  contexts: file.contexts,
516
535
  };
517
- if (Bun.deepEquals(next, served)) return;
536
+ if (Bun.deepEquals(next, served)) {
537
+ loadedConfigStamp = stamp;
538
+ return;
539
+ }
518
540
  await adoptServedConfig(
519
541
  {
520
542
  ...instance,
@@ -529,6 +551,7 @@ export async function startResidentRuntime(
529
551
  },
530
552
  next
531
553
  );
554
+ loadedConfigStamp = stamp;
532
555
  })().finally(() => {
533
556
  configRefresh = undefined;
534
557
  });
@@ -16,6 +16,7 @@ import {
16
16
  SHUTDOWN_ABORT_MS,
17
17
  SHUTDOWN_EXIT_MS,
18
18
  } from "../core/shutdown-budget";
19
+ import { killImportChildren } from "../sessions/import-child";
19
20
 
20
21
  interface ResidentShutdownResources {
21
22
  options: ResidentRuntimeOptions;
@@ -78,6 +79,9 @@ export async function disposeResidentResources(
78
79
  (error: unknown) => console.error("Resident cleanup failed:", error)
79
80
  );
80
81
  stopFindings();
82
+ // A session import runs in a child that would outlive this process and hold
83
+ // up the drain below; cutting it short is safe (one transaction per file).
84
+ killImportChildren();
81
85
  const schedulerDrain = observe(() =>
82
86
  scheduler.stop ? scheduler.stop() : scheduler.dispose()
83
87
  );