@gmickel/gno 2.9.1 → 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.
- package/README.md +1 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v2.9.1.zip → gno-browser-clipper-v2.9.2.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.9.2.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +3 -0
- package/spec/output-schemas/collection-list.schema.json +5 -0
- package/src/cli/commands/daemon.ts +9 -0
- package/src/cli/detach.ts +42 -5
- package/src/cli/program.ts +11 -2
- package/src/config/types.ts +3 -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/graph-reconciliation.ts +18 -4
- package/src/ingestion/sync.ts +70 -4
- package/src/serve/parent-lifeline.ts +44 -0
- 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/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
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.
|
|
142
|
+
> Current source version: **v2.9.2**. See [CHANGELOG.md](./CHANGELOG.md).
|
|
143
143
|
|
|
144
144
|
<!-- /public-truth -->
|
|
145
145
|
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
7073229377bafdffb9fb4834e22fe4531e68afcf615c19c1bb021c205997375a gno-browser-clipper-v2.9.2.zip
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gmickel/gno",
|
|
3
|
-
"version": "2.9.
|
|
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
|
|
947
|
-
*
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
|
|
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
|
|
package/src/cli/program.ts
CHANGED
|
@@ -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
|
-
|
|
5568
|
-
|
|
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.
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
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
|
+
}
|
|
@@ -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))
|
|
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
|
);
|
package/src/serve/server.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { RequestPeerServer } from "./request-locality";
|
|
|
3
3
|
import type { ResidentRuntime } from "./resident-runtime";
|
|
4
4
|
import type { ContextHolder } from "./routes/api";
|
|
5
5
|
|
|
6
|
+
import { markResidentShutdownHandlerInstalled } from "../core/resident-shutdown-handler";
|
|
7
|
+
import { sighupHandlerAllowed } from "../core/sighup-disposition";
|
|
6
8
|
import { getActivePreset } from "../llm/registry";
|
|
7
9
|
import {
|
|
8
10
|
isHttpGatewayLoopbackBind,
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
* @module src/serve/server
|
|
26
28
|
*/
|
|
27
29
|
import { withRemoteHostPathRedaction } from "./host-path-redaction";
|
|
30
|
+
import { watchParentLifeline } from "./parent-lifeline";
|
|
28
31
|
import { PDFJS_ASSET_CACHE_CONTROL } from "./pdfjs-assets";
|
|
29
32
|
// HTML import - Bun handles bundling TSX/CSS automatically via routes
|
|
30
33
|
import homepage from "./public/index.html";
|
|
@@ -401,7 +404,7 @@ export function loopbackHttpOrigin(host: string, port: number): string {
|
|
|
401
404
|
|
|
402
405
|
/**
|
|
403
406
|
* Start the web server.
|
|
404
|
-
* Opens DB once, closes on SIGINT/SIGTERM.
|
|
407
|
+
* Opens DB once, closes on SIGINT/SIGTERM/SIGHUP.
|
|
405
408
|
*/
|
|
406
409
|
export async function startServer(
|
|
407
410
|
options: ServeOptions = {},
|
|
@@ -478,9 +481,22 @@ export async function startServer(
|
|
|
478
481
|
|
|
479
482
|
process.once("SIGINT", shutdown);
|
|
480
483
|
process.once("SIGTERM", shutdown);
|
|
484
|
+
// A closed terminal or logout: shut down cleanly, as on SIGTERM, unless
|
|
485
|
+
// SIGHUP was ignored at start (nohup), which must keep working.
|
|
486
|
+
const handleSighup = sighupHandlerAllowed();
|
|
487
|
+
if (handleSighup) process.once("SIGHUP", shutdown);
|
|
488
|
+
markResidentShutdownHandlerInstalled();
|
|
489
|
+
// A launcher that owns this process (the desktop shell) closes our stdin
|
|
490
|
+
// when it exits or is killed; shut down instead of running orphaned.
|
|
491
|
+
const stopLifeline = watchParentLifeline(() => {
|
|
492
|
+
console.log("\nParent process gone.");
|
|
493
|
+
shutdown();
|
|
494
|
+
});
|
|
481
495
|
const removeShutdownHandlers = (): void => {
|
|
482
496
|
process.off("SIGINT", shutdown);
|
|
483
497
|
process.off("SIGTERM", shutdown);
|
|
498
|
+
if (handleSighup) process.off("SIGHUP", shutdown);
|
|
499
|
+
stopLifeline();
|
|
484
500
|
};
|
|
485
501
|
|
|
486
502
|
// Start server with try/catch for port-in-use etc.
|
|
@@ -1778,6 +1794,11 @@ export async function startServer(
|
|
|
1778
1794
|
await dependencies.waitForShutdown(shutdownController.signal);
|
|
1779
1795
|
} else {
|
|
1780
1796
|
await new Promise<void>((resolve) => {
|
|
1797
|
+
// The lifeline can fire while the listener is still starting.
|
|
1798
|
+
if (shutdownController.signal.aborted) {
|
|
1799
|
+
resolve();
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1781
1802
|
shutdownController.signal.addEventListener("abort", () => resolve(), {
|
|
1782
1803
|
once: true,
|
|
1783
1804
|
});
|
|
@@ -10,8 +10,13 @@
|
|
|
10
10
|
import type { Collection } from "../config/types";
|
|
11
11
|
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
12
12
|
|
|
13
|
+
import { relPathUnderAnyPrefix } from "../ingestion";
|
|
13
14
|
import { WATCHER_ACTIVE_SOURCE_PATH_MAX } from "../store/types";
|
|
14
15
|
import { fallbackClassifyDirtyHints } from "./watch-reconciliation-fallback";
|
|
16
|
+
import {
|
|
17
|
+
fallbackFs,
|
|
18
|
+
inspectNoFollowPresence,
|
|
19
|
+
} from "./watch-reconciliation-fallback-disk";
|
|
15
20
|
import {
|
|
16
21
|
filterEligiblePaths,
|
|
17
22
|
type ClassificationResult,
|
|
@@ -48,6 +53,112 @@ export {
|
|
|
48
53
|
type WatcherEventClassification,
|
|
49
54
|
} from "./watch-reconciliation-shared";
|
|
50
55
|
|
|
56
|
+
type StoreOnlyRemovals =
|
|
57
|
+
| { status: "ok"; paths: string[] }
|
|
58
|
+
| { status: "overflow" }
|
|
59
|
+
| { status: "error"; cause: unknown; stage: "scan" | "store" };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Store-known active paths under the dirty hints that are gone from disk.
|
|
63
|
+
* The snapshot only knows what it scanned: a note indexed from its own exact
|
|
64
|
+
* event after the baseline is in the store but not the snapshot, and when
|
|
65
|
+
* its directory is then moved or replaced (one directory event) the snapshot
|
|
66
|
+
* diff alone would leave it active (fn-209).
|
|
67
|
+
*
|
|
68
|
+
* A path the fresh snapshot lists as a file or symlink is present. Paths in
|
|
69
|
+
* subtrees the snapshot left unproven are left alone. Every other path gets
|
|
70
|
+
* the fallback classifier's no-follow, availability-aware proof: missing or
|
|
71
|
+
* no longer a regular source is a removal; an inconclusive check is a scan
|
|
72
|
+
* error, so the watcher keeps the work and retries rather than infer a
|
|
73
|
+
* delete. The root hint (a watcher event without a filename) uses the
|
|
74
|
+
* bounded whole-collection inventory.
|
|
75
|
+
*/
|
|
76
|
+
async function storeOnlyRemovals(options: {
|
|
77
|
+
store: SqliteAdapter;
|
|
78
|
+
collection: Collection;
|
|
79
|
+
rootAbs: string;
|
|
80
|
+
dirtyHints: readonly string[];
|
|
81
|
+
next: WatcherSnapshot | null;
|
|
82
|
+
sourcePathMax: number;
|
|
83
|
+
snapshotOptions?: WatcherSnapshotOptions;
|
|
84
|
+
}): Promise<StoreOnlyRemovals> {
|
|
85
|
+
const { store, collection, rootAbs, next, sourcePathMax } = options;
|
|
86
|
+
const hints = [...new Set(options.dirtyHints)].sort();
|
|
87
|
+
const storePaths: string[] = [];
|
|
88
|
+
if (hints.includes("")) {
|
|
89
|
+
if (typeof store.listActiveSourcePaths !== "function") {
|
|
90
|
+
return { status: "ok", paths: [] };
|
|
91
|
+
}
|
|
92
|
+
const listed = await store.listActiveSourcePaths(
|
|
93
|
+
collection.name,
|
|
94
|
+
sourcePathMax
|
|
95
|
+
);
|
|
96
|
+
if (!listed.ok) {
|
|
97
|
+
if (listed.error.code === "OVERFLOW") return { status: "overflow" };
|
|
98
|
+
return {
|
|
99
|
+
status: "error",
|
|
100
|
+
cause: new Error(listed.error.message),
|
|
101
|
+
stage: "store",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
storePaths.push(...listed.value);
|
|
105
|
+
} else {
|
|
106
|
+
if (typeof store.listActiveDescendantSourcePaths !== "function") {
|
|
107
|
+
return { status: "ok", paths: [] };
|
|
108
|
+
}
|
|
109
|
+
// A hint under another hint is covered by the outer descendant lookup.
|
|
110
|
+
const roots = hints.filter(
|
|
111
|
+
(hint, index) =>
|
|
112
|
+
!hints.slice(0, index).some((outer) => hint.startsWith(`${outer}/`))
|
|
113
|
+
);
|
|
114
|
+
for (const dir of roots) {
|
|
115
|
+
const listed = await store.listActiveDescendantSourcePaths(
|
|
116
|
+
collection.name,
|
|
117
|
+
dir,
|
|
118
|
+
Math.max(1, sourcePathMax - storePaths.length)
|
|
119
|
+
);
|
|
120
|
+
if (!listed.ok) {
|
|
121
|
+
if (listed.error.code === "OVERFLOW") return { status: "overflow" };
|
|
122
|
+
// Not a directory path the store can scope (e.g. escapes the root).
|
|
123
|
+
if (listed.error.code === "INVALID_INPUT") continue;
|
|
124
|
+
return {
|
|
125
|
+
status: "error",
|
|
126
|
+
cause: new Error(listed.error.message),
|
|
127
|
+
stage: "store",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
storePaths.push(...listed.value);
|
|
131
|
+
if (storePaths.length > sourcePathMax) return { status: "overflow" };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const unproven = [...(next?.unprovenSubtrees ?? [])];
|
|
136
|
+
const fs = options.snapshotOptions?.fs ?? fallbackFs();
|
|
137
|
+
const removals: string[] = [];
|
|
138
|
+
for (const path of storePaths) {
|
|
139
|
+
if (relPathUnderAnyPrefix(path, unproven)) continue;
|
|
140
|
+
const slash = path.lastIndexOf("/");
|
|
141
|
+
const parent = slash === -1 ? "" : path.slice(0, slash);
|
|
142
|
+
const kind = next?.directories
|
|
143
|
+
.get(parent)
|
|
144
|
+
?.get(path.slice(slash + 1))?.kind;
|
|
145
|
+
if (kind === "file" || kind === "symlink") continue;
|
|
146
|
+
const presence = await inspectNoFollowPresence(
|
|
147
|
+
rootAbs,
|
|
148
|
+
path,
|
|
149
|
+
fs,
|
|
150
|
+
options.snapshotOptions?.directoryAvailability
|
|
151
|
+
);
|
|
152
|
+
if (presence.status === "error") {
|
|
153
|
+
return { status: "error", cause: presence.cause, stage: "scan" };
|
|
154
|
+
}
|
|
155
|
+
if (presence.status === "missing" || !presence.indexable) {
|
|
156
|
+
removals.push(path);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { status: "ok", paths: removals };
|
|
160
|
+
}
|
|
161
|
+
|
|
51
162
|
/**
|
|
52
163
|
* Snapshot-first classification of dirty hints. On overflow/scan/metadata
|
|
53
164
|
* failure, uses bounded store + disk enumeration without inferring deletes
|
|
@@ -96,10 +207,32 @@ export async function classifyDirtyHints(options: {
|
|
|
96
207
|
snapshotOptions
|
|
97
208
|
);
|
|
98
209
|
if (diff.status === "ok") {
|
|
210
|
+
const storeRemovals = await storeOnlyRemovals({
|
|
211
|
+
store,
|
|
212
|
+
collection,
|
|
213
|
+
rootAbs,
|
|
214
|
+
dirtyHints,
|
|
215
|
+
next: diff.nextSnapshot,
|
|
216
|
+
sourcePathMax,
|
|
217
|
+
snapshotOptions,
|
|
218
|
+
});
|
|
219
|
+
if (storeRemovals.status === "overflow") {
|
|
220
|
+
return { status: "full_reconcile", reason: "budget_overflow" };
|
|
221
|
+
}
|
|
222
|
+
if (storeRemovals.status === "error") {
|
|
223
|
+
return {
|
|
224
|
+
status: "error",
|
|
225
|
+
cause: storeRemovals.cause,
|
|
226
|
+
stage: storeRemovals.stage,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
99
229
|
return {
|
|
100
230
|
status: "ok",
|
|
101
231
|
candidates: filterEligiblePaths(diff.candidates, collection),
|
|
102
|
-
removals: filterEligiblePaths(
|
|
232
|
+
removals: filterEligiblePaths(
|
|
233
|
+
[...new Set([...diff.removals, ...storeRemovals.paths])],
|
|
234
|
+
collection
|
|
235
|
+
),
|
|
103
236
|
nextSnapshot: diff.nextSnapshot,
|
|
104
237
|
usedFallback: false,
|
|
105
238
|
};
|
|
@@ -7,18 +7,65 @@
|
|
|
7
7
|
// node:path — Bun has no path utilities
|
|
8
8
|
import { normalize } from "node:path";
|
|
9
9
|
|
|
10
|
+
import type { CollectionSyncResult } from "../ingestion";
|
|
10
11
|
import type {
|
|
11
12
|
FlushCollectionInput,
|
|
12
13
|
FlushCollectionOutcome,
|
|
13
14
|
} from "./watch-service-flush";
|
|
14
15
|
|
|
15
|
-
import { defaultSyncService } from "../ingestion";
|
|
16
|
+
import { defaultSyncService, isSourceAvailabilitySkip } from "../ingestion";
|
|
16
17
|
import { hasFileLevelSyncError } from "./watch-reconciliation";
|
|
17
18
|
import {
|
|
18
19
|
contentChangedPaths,
|
|
19
20
|
notifyCompletedSync,
|
|
20
21
|
} from "./watch-service-flush-helpers";
|
|
21
22
|
|
|
23
|
+
/** Failure codes from the store side of a sync: transient, worth a retry. */
|
|
24
|
+
const STORE_SIDE_FAILURE_CODES = new Set(["QUERY_FAILED", "STORE_ERROR"]);
|
|
25
|
+
const SOURCE_AVAILABILITY_PREFIX = "SOURCE_AVAILABILITY_";
|
|
26
|
+
|
|
27
|
+
interface ReconcileFailures {
|
|
28
|
+
/** A collection-level failure (no path): the reconcile itself is incomplete. */
|
|
29
|
+
unscoped: boolean;
|
|
30
|
+
/** Every named failed path, from file receipts and walker errors alike. */
|
|
31
|
+
named: string[];
|
|
32
|
+
/** Named paths that failed in the store, not on the file's content. */
|
|
33
|
+
storeSide: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Classify the failures of a completed full-collection sync. */
|
|
37
|
+
function classifyReconcileFailures(
|
|
38
|
+
result: CollectionSyncResult
|
|
39
|
+
): ReconcileFailures {
|
|
40
|
+
const named = new Set<string>();
|
|
41
|
+
const storeSide = new Set<string>();
|
|
42
|
+
let unscoped = false;
|
|
43
|
+
for (const file of result.files ?? []) {
|
|
44
|
+
if (file.status !== "error") continue;
|
|
45
|
+
named.add(file.relPath);
|
|
46
|
+
if (STORE_SIDE_FAILURE_CODES.has(file.errorCode ?? ""))
|
|
47
|
+
storeSide.add(file.relPath);
|
|
48
|
+
}
|
|
49
|
+
// A collection whose source availability cannot be proven fails the same
|
|
50
|
+
// way on every attempt until its config or volume changes (a config edit
|
|
51
|
+
// starts a new reconcile): record it once, like a content failure.
|
|
52
|
+
let unavailable = false;
|
|
53
|
+
// Walker failures (TOO_LARGE, PERMISSION...) appear only here.
|
|
54
|
+
for (const entry of result.errors) {
|
|
55
|
+
if (isSourceAvailabilitySkip(entry.code)) continue;
|
|
56
|
+
if (!entry.relPath) {
|
|
57
|
+
if (entry.code.startsWith(SOURCE_AVAILABILITY_PREFIX)) unavailable = true;
|
|
58
|
+
else unscoped = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
named.add(entry.relPath);
|
|
62
|
+
if (STORE_SIDE_FAILURE_CODES.has(entry.code)) storeSide.add(entry.relPath);
|
|
63
|
+
}
|
|
64
|
+
// A failure count with nothing named is collection-level too.
|
|
65
|
+
if (named.size === 0 && !unavailable) unscoped = true;
|
|
66
|
+
return { unscoped, named: [...named], storeSide: [...storeSide] };
|
|
67
|
+
}
|
|
68
|
+
|
|
22
69
|
/**
|
|
23
70
|
* When collection generation advanced during/before flush, run full
|
|
24
71
|
* syncCollection. Failures leave durable generation work and never advance
|
|
@@ -96,11 +143,38 @@ export async function runGenerationReconcile(
|
|
|
96
143
|
contentChangedPaths(result);
|
|
97
144
|
|
|
98
145
|
if (hasFileLevelSyncError(result)) {
|
|
99
|
-
// Notify completed sync once; keep durable generation work.
|
|
100
|
-
notifyCompletedSync(input, operationPaths, result, ownership);
|
|
101
146
|
const error = new Error(
|
|
102
147
|
"One or more paths failed during watcher generation reconcile"
|
|
103
148
|
);
|
|
149
|
+
const failures = classifyReconcileFailures(result);
|
|
150
|
+
// The walk completed: the collection is reconciled except for the
|
|
151
|
+
// named failed paths (fn-211). Requeuing the whole generation
|
|
152
|
+
// re-walked the collection every backoff step for as long as one file
|
|
153
|
+
// kept failing. Store-side failures are transient and can leave a
|
|
154
|
+
// stale document, so only those paths are retried (exact paths,
|
|
155
|
+
// failure backoff). Content failures (unreadable, corrupt, too
|
|
156
|
+
// large...) are recorded once, as `gno update` and the daemon's
|
|
157
|
+
// initial sync do; the next edit to the file retries it. Keep the
|
|
158
|
+
// whole generation when any failure is collection-level (e.g. the
|
|
159
|
+
// document inventory failed) or this flush no longer owns the
|
|
160
|
+
// collection (its requeue would be dropped).
|
|
161
|
+
if (
|
|
162
|
+
!failures.unscoped &&
|
|
163
|
+
input.getCurrentGeneration() === input.ownerGeneration
|
|
164
|
+
) {
|
|
165
|
+
input.invalidateSnapshot(stillCurrent);
|
|
166
|
+
notifyCompletedSync(input, operationPaths, result, ownership);
|
|
167
|
+
input.onSyncError(failures.named, error);
|
|
168
|
+
if (failures.storeSide.length > 0) {
|
|
169
|
+
input.requeue(failures.storeSide, []);
|
|
170
|
+
return { status: "failed", error };
|
|
171
|
+
}
|
|
172
|
+
completedGeneration = currentGeneration;
|
|
173
|
+
needsWork = input.getCurrentGeneration() !== completedGeneration;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// Notify completed sync once; keep durable generation work.
|
|
177
|
+
notifyCompletedSync(input, operationPaths, result, ownership);
|
|
104
178
|
input.onSyncError([], error);
|
|
105
179
|
input.requeueGeneration();
|
|
106
180
|
return { status: "failed", error };
|
|
@@ -49,7 +49,11 @@ import {
|
|
|
49
49
|
} from "./watch-service-lifecycle";
|
|
50
50
|
import { runOwnedCollectionFlush } from "./watch-service-run-flush";
|
|
51
51
|
import { beginSnapshotInit } from "./watch-service-snapshot";
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
emptyPending,
|
|
54
|
+
pendingHasWork,
|
|
55
|
+
type CollectionPending,
|
|
56
|
+
} from "./watch-service-state";
|
|
53
57
|
|
|
54
58
|
export interface CollectionWatchState {
|
|
55
59
|
expectedCollections: string[];
|
|
@@ -111,6 +115,12 @@ interface CollectionWatchServiceOptions {
|
|
|
111
115
|
snapshotEntryCeiling?: number;
|
|
112
116
|
/** Shared writer lease taken (no wait) around each flush's writes. */
|
|
113
117
|
acquireWriteLease?: () => Promise<(() => Promise<void>) | null>;
|
|
118
|
+
/**
|
|
119
|
+
* Queue a full reconcile of every watched collection at start, so changes
|
|
120
|
+
* made while no resident ran reach the index. Off for the daemon, whose
|
|
121
|
+
* initial sync (unless --no-sync-on-start) covers the same ground.
|
|
122
|
+
*/
|
|
123
|
+
reconcileOnStart?: boolean;
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
export class CollectionWatchService {
|
|
@@ -152,6 +162,7 @@ export class CollectionWatchService {
|
|
|
152
162
|
readonly #snapshotFs: WatcherSnapshotFs | undefined;
|
|
153
163
|
readonly #snapshotEntryCeiling: number | undefined;
|
|
154
164
|
readonly #acquireWriteLease: CollectionWatchServiceOptions["acquireWriteLease"];
|
|
165
|
+
readonly #reconcileOnStart: boolean;
|
|
155
166
|
#nextCollectionGeneration = 0;
|
|
156
167
|
#disposed = false;
|
|
157
168
|
#lastEventAt: string | null = null;
|
|
@@ -176,11 +187,24 @@ export class CollectionWatchService {
|
|
|
176
187
|
this.#snapshotFs = options.snapshotFs;
|
|
177
188
|
this.#snapshotEntryCeiling = options.snapshotEntryCeiling;
|
|
178
189
|
this.#acquireWriteLease = options.acquireWriteLease;
|
|
190
|
+
this.#reconcileOnStart = options.reconcileOnStart ?? false;
|
|
179
191
|
}
|
|
180
192
|
|
|
181
193
|
start(): void {
|
|
182
|
-
if (
|
|
183
|
-
|
|
194
|
+
if (this.#disposed) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.updateCollections(this.#collections);
|
|
198
|
+
if (!this.#reconcileOnStart) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Generation work waits for the snapshot baseline, then runs a full
|
|
202
|
+
// syncCollection through the normal flush path (write lease, failure
|
|
203
|
+
// backoff, source availability). Unwatchable collections queue nothing.
|
|
204
|
+
for (const name of this.#watchers.keys()) {
|
|
205
|
+
const pending = this.#pendingByCollection.get(name) ?? emptyPending();
|
|
206
|
+
pending.generationReconcile = true;
|
|
207
|
+
this.#pendingByCollection.set(name, pending);
|
|
184
208
|
}
|
|
185
209
|
}
|
|
186
210
|
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
* store connection: the archive import lock, receipts and error codes are
|
|
10
10
|
* unchanged.
|
|
11
11
|
*
|
|
12
|
-
* The child reads one request from stdin and writes one JSON result line
|
|
13
|
-
* stdout. The
|
|
12
|
+
* The child reads one request line from stdin and writes one JSON result line
|
|
13
|
+
* to stdout. The parent keeps stdin open for the child's lifetime: EOF means
|
|
14
|
+
* the parent died, and the child exits instead of importing on as an orphan.
|
|
15
|
+
* A resident kills its import children when it shuts down, and every import
|
|
16
|
+
* has a timeout. The request carries the caller's loaded config: the child opens
|
|
14
17
|
* the index without syncing any config into it and imports with exactly the
|
|
15
18
|
* config the server holds. From source the child runs this module; a
|
|
16
19
|
* compiled executable re-runs itself with `IMPORT_CHILD_ENV` set, which the
|
|
@@ -87,12 +90,24 @@ async function importInThisProcess(
|
|
|
87
90
|
}
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
/** Longest a single import may run before its child is killed. */
|
|
94
|
+
export const SESSION_IMPORT_TIMEOUT_MS = 30 * 60_000;
|
|
95
|
+
|
|
96
|
+
/** Import children still running in this process. */
|
|
97
|
+
const liveChildren = new Set<Bun.Subprocess>();
|
|
98
|
+
|
|
99
|
+
/** Kill every running import child (resident shutdown). */
|
|
100
|
+
export function killImportChildren(): void {
|
|
101
|
+
for (const child of liveChildren) child.kill("SIGKILL");
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
/**
|
|
91
105
|
* Import a registered source without blocking the caller's event loop.
|
|
92
106
|
* Throws a `SessionsError` for typed failures and a plain `Error` otherwise.
|
|
93
107
|
*/
|
|
94
108
|
export async function importInChildProcess(
|
|
95
|
-
request: ImportChildRequest
|
|
109
|
+
request: ImportChildRequest,
|
|
110
|
+
options: { timeoutMs?: number } = {}
|
|
96
111
|
): Promise<SessionImportReceipt> {
|
|
97
112
|
// A compiled executable cannot run an external TS entry: it re-runs itself.
|
|
98
113
|
// Shared check: covers the POSIX `/$bunfs/` and Windows `B:/~BUN/` roots.
|
|
@@ -100,15 +115,38 @@ export async function importInChildProcess(
|
|
|
100
115
|
const child = Bun.spawn({
|
|
101
116
|
cmd: compiled ? [process.execPath] : [process.execPath, import.meta.path],
|
|
102
117
|
env: compiled ? { ...process.env, [IMPORT_CHILD_ENV]: "1" } : process.env,
|
|
103
|
-
|
|
118
|
+
// The request line, then held open as the child's lifeline.
|
|
119
|
+
stdin: "pipe",
|
|
104
120
|
stdout: "pipe",
|
|
105
121
|
stderr: "pipe",
|
|
106
122
|
});
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
liveChildren.add(child);
|
|
124
|
+
const timeoutMs = options.timeoutMs ?? SESSION_IMPORT_TIMEOUT_MS;
|
|
125
|
+
let timedOut = false;
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
timedOut = true;
|
|
128
|
+
child.kill("SIGKILL");
|
|
129
|
+
}, timeoutMs);
|
|
130
|
+
let output: [string, string, number];
|
|
131
|
+
try {
|
|
132
|
+
void child.stdin.write(`${JSON.stringify(request)}\n`);
|
|
133
|
+
void child.stdin.flush();
|
|
134
|
+
output = await Promise.all([
|
|
135
|
+
new Response(child.stdout).text(),
|
|
136
|
+
new Response(child.stderr).text(),
|
|
137
|
+
child.exited,
|
|
138
|
+
]);
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
liveChildren.delete(child);
|
|
142
|
+
void Promise.resolve(child.stdin.end()).catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
const [stdout, stderr, exitCode] = output;
|
|
145
|
+
if (timedOut) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`session import timed out after ${Math.round(timeoutMs / 1000)} s`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
112
150
|
const line = stdout.trim().split("\n").at(-1) ?? "";
|
|
113
151
|
let result: ImportChildResult | null = null;
|
|
114
152
|
try {
|
|
@@ -126,15 +164,43 @@ export async function importInChildProcess(
|
|
|
126
164
|
throw new Error(result.message);
|
|
127
165
|
}
|
|
128
166
|
|
|
129
|
-
/**
|
|
167
|
+
/** Read the request line; afterwards stdin is only the lifeline. */
|
|
168
|
+
async function readRequestLine(
|
|
169
|
+
reader: ReadableStreamDefaultReader<Uint8Array>
|
|
170
|
+
): Promise<string> {
|
|
171
|
+
const decoder = new TextDecoder();
|
|
172
|
+
let text = "";
|
|
173
|
+
while (!text.includes("\n")) {
|
|
174
|
+
const chunk = await reader.read();
|
|
175
|
+
if (chunk.done) break;
|
|
176
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
177
|
+
}
|
|
178
|
+
return text.split("\n")[0] ?? "";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Child entry: one request line on stdin, one JSON result line on stdout. */
|
|
130
182
|
export async function runImportChild(): Promise<void> {
|
|
131
183
|
// Nothing this child starts may re-enter child mode.
|
|
132
184
|
delete process.env[IMPORT_CHILD_ENV];
|
|
185
|
+
const reader = Bun.stdin.stream().getReader();
|
|
186
|
+
let finished = false;
|
|
133
187
|
let result: ImportChildResult;
|
|
134
188
|
try {
|
|
135
189
|
const request = JSON.parse(
|
|
136
|
-
await
|
|
190
|
+
await readRequestLine(reader)
|
|
137
191
|
) as ImportChildRequest;
|
|
192
|
+
// The parent holds stdin open until it has our result; EOF before that
|
|
193
|
+
// means it died. Noticed between import steps.
|
|
194
|
+
void (async () => {
|
|
195
|
+
try {
|
|
196
|
+
while (!(await reader.read()).done) {
|
|
197
|
+
// nothing is written after the request
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// a broken pipe is the same as EOF
|
|
201
|
+
}
|
|
202
|
+
if (!finished) process.exit(1);
|
|
203
|
+
})();
|
|
138
204
|
result = { ok: true, receipt: await importInThisProcess(request) };
|
|
139
205
|
} catch (error) {
|
|
140
206
|
result =
|
|
@@ -146,7 +212,9 @@ export async function runImportChild(): Promise<void> {
|
|
|
146
212
|
message: error instanceof Error ? error.message : String(error),
|
|
147
213
|
};
|
|
148
214
|
}
|
|
215
|
+
finished = true;
|
|
149
216
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
217
|
+
void reader.cancel().catch(() => undefined);
|
|
150
218
|
}
|
|
151
219
|
|
|
152
220
|
if (import.meta.main) await runImportChild();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration: index stored vector byte lengths per model.
|
|
3
|
+
*
|
|
4
|
+
* Status checks the stored embedding dimensions for the active model by
|
|
5
|
+
* comparing the smallest and largest vector in its partition. Without an
|
|
6
|
+
* index that is a scan of every vector on each status build (about 0.6 ms
|
|
7
|
+
* per thousand vectors), run on the resident's main thread. With this
|
|
8
|
+
* covering index each bound is a single index seek.
|
|
9
|
+
*
|
|
10
|
+
* @module src/store/migrations/035-vector-length-index
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Database } from "bun:sqlite";
|
|
14
|
+
|
|
15
|
+
import type { Migration } from "./runner";
|
|
16
|
+
|
|
17
|
+
export const migration: Migration = {
|
|
18
|
+
version: 35,
|
|
19
|
+
name: "vector_length_index",
|
|
20
|
+
|
|
21
|
+
up(db: Database): void {
|
|
22
|
+
db.exec(
|
|
23
|
+
"CREATE INDEX IF NOT EXISTS idx_vectors_model_bytes ON content_vectors(model, length(embedding))"
|
|
24
|
+
);
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
down(db: Database): void {
|
|
28
|
+
db.exec("DROP INDEX IF EXISTS idx_vectors_model_bytes");
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -48,6 +48,7 @@ import { migration as m031 } from "./031-runtime-independent-vectors";
|
|
|
48
48
|
import { migration as m032 } from "./032-vector-runtime-callers";
|
|
49
49
|
import { migration as m033 } from "./033-drop-documents-active-index";
|
|
50
50
|
import { migration as m034 } from "./034-collection-link-workspace";
|
|
51
|
+
import { migration as m035 } from "./035-vector-length-index";
|
|
51
52
|
|
|
52
53
|
/** All migrations in order */
|
|
53
54
|
export const migrations = [
|
|
@@ -85,4 +86,5 @@ export const migrations = [
|
|
|
85
86
|
m032,
|
|
86
87
|
m033,
|
|
87
88
|
m034,
|
|
89
|
+
m035,
|
|
88
90
|
];
|
|
@@ -979,7 +979,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
979
979
|
}
|
|
980
980
|
});
|
|
981
981
|
|
|
982
|
-
transaction
|
|
982
|
+
// Read-then-write: take the write lock up front. A deferred transaction
|
|
983
|
+
// whose snapshot goes stale (another process commits meanwhile) fails at
|
|
984
|
+
// once with "database is locked" instead of waiting out the busy timeout.
|
|
985
|
+
transaction.immediate();
|
|
983
986
|
return ok(undefined);
|
|
984
987
|
} catch (cause) {
|
|
985
988
|
return err(
|
|
@@ -35,14 +35,21 @@ export function getStoredEmbeddingDimensions(
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
// Validate the entire model partition: never trust an arbitrary first blob.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
// One aggregate per query so each bound is a seek on idx_vectors_model_bytes
|
|
39
|
+
// (MIN and MAX together would scan every vector), read in one transaction
|
|
40
|
+
// so both bounds see the same snapshot while another process writes.
|
|
41
|
+
const bound = (aggregate: "MIN" | "MAX"): number | null =>
|
|
42
|
+
(
|
|
43
|
+
db
|
|
44
|
+
.prepare(
|
|
45
|
+
`SELECT ${aggregate}(length(embedding)) AS bytes FROM content_vectors WHERE model = ?`
|
|
46
|
+
)
|
|
47
|
+
.get(model) as { bytes: number | null }
|
|
48
|
+
).bytes;
|
|
49
|
+
const [bytes, largest] = db.transaction(() => [bound("MIN"), bound("MAX")])();
|
|
43
50
|
if (
|
|
44
51
|
!bytes ||
|
|
45
|
-
bytes !==
|
|
52
|
+
bytes !== largest ||
|
|
46
53
|
bytes % Float32Array.BYTES_PER_ELEMENT !== 0
|
|
47
54
|
) {
|
|
48
55
|
return undefined;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
f58e51eea9d0b35a61ef55574882c4be8002ed2fdc29966a3cd1bd7c84350e3c gno-browser-clipper-v2.9.1.zip
|