@gmickel/gno 2.9.2 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.9.2.zip → gno-browser-clipper-v2.10.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.10.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/spec/cli.md +70 -2
- package/src/app/bun-runtime.ts +41 -0
- package/src/cli/commands/daemon.ts +3 -0
- package/src/cli/commands/doctor.ts +61 -0
- package/src/cli/commands/mcp/config-editors.ts +56 -8
- package/src/cli/commands/mcp/install.ts +159 -24
- package/src/cli/commands/mcp/launch-path.ts +87 -0
- package/src/cli/commands/mcp/paths.ts +30 -6
- package/src/cli/commands/mcp/repair-entry.ts +160 -0
- package/src/cli/commands/mcp/status.ts +55 -15
- package/src/cli/commands/mcp.ts +4 -0
- package/src/cli/commands/serve.ts +3 -0
- package/src/cli/commands/status.ts +24 -0
- package/src/cli/detach.ts +10 -0
- package/src/cli/program.ts +8 -3
- package/src/cli/runtime-health.ts +328 -0
- package/src/core/network-boundary-inventory.ts +16 -0
- package/src/ingestion/file-processor.ts +118 -17
- package/src/ingestion/prepare-worker.ts +20 -2
- package/src/serve/public/components/BootstrapStatus.tsx +3 -3
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/status.ts +2 -1
- package/browser-extension/artifacts/gno-browser-clipper-v2.9.2.zip.sha256 +0 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which Bun the GNO processes on this machine run on (fn-213).
|
|
3
|
+
*
|
|
4
|
+
* Two read-only checks for `gno doctor` and `gno status`:
|
|
5
|
+
* - client registrations whose interpreter or entrypoint is missing, below
|
|
6
|
+
* the supported Bun, or a different Bun than the one running the check;
|
|
7
|
+
* - running residents (`serve`, `daemon`) on a Bun below the floor or older
|
|
8
|
+
* than the one running the check, including residents started by another
|
|
9
|
+
* GNO version, whose runtime is read from their `/api/status`.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here starts a registered command or signals a process; an
|
|
12
|
+
* interpreter is only asked for `--version`, under a short timeout.
|
|
13
|
+
*
|
|
14
|
+
* @module src/cli/runtime-health
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// node:fs realpathSync: synchronous link resolution; Bun has no realpath API.
|
|
18
|
+
import { realpathSync } from "node:fs";
|
|
19
|
+
// node:path basename: no Bun path utilities.
|
|
20
|
+
import { basename } from "node:path";
|
|
21
|
+
|
|
22
|
+
import type { McpScope, McpTarget } from "./commands/mcp/paths";
|
|
23
|
+
|
|
24
|
+
import { BUN_FLOOR, bunMeetsFloor } from "../app/bun-runtime";
|
|
25
|
+
import { VERSION } from "../app/constants";
|
|
26
|
+
import { getServersKey } from "./commands/mcp/config";
|
|
27
|
+
import { resolveMcpConfigLocation } from "./commands/mcp/config-discovery";
|
|
28
|
+
import {
|
|
29
|
+
getJsoncServerEntry,
|
|
30
|
+
getTomlServerEntry,
|
|
31
|
+
getYamlServerEntry,
|
|
32
|
+
} from "./commands/mcp/config-editors";
|
|
33
|
+
import { getTargetDisplayName, resolveAllMcpPaths } from "./commands/mcp/paths";
|
|
34
|
+
import { readExistingRegistration } from "./commands/mcp/repair-entry";
|
|
35
|
+
import {
|
|
36
|
+
type DetachKind,
|
|
37
|
+
isProcessAlive,
|
|
38
|
+
readPidFile,
|
|
39
|
+
resolveProcessPaths,
|
|
40
|
+
} from "./detach";
|
|
41
|
+
|
|
42
|
+
export type RuntimeIssueSeverity = "error" | "warn" | "info";
|
|
43
|
+
|
|
44
|
+
export interface RuntimeIssue {
|
|
45
|
+
kind: "registration" | "resident";
|
|
46
|
+
severity: RuntimeIssueSeverity;
|
|
47
|
+
/** What it is about: a client registration or a resident process. */
|
|
48
|
+
subject: string;
|
|
49
|
+
message: string;
|
|
50
|
+
/** Command or steps that repair it. */
|
|
51
|
+
repair: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How long an interpreter may take to answer `--version`. */
|
|
55
|
+
const VERSION_PROBE_TIMEOUT_MS = 2000;
|
|
56
|
+
/** How long a resident may take to answer `/api/status`. */
|
|
57
|
+
const RESIDENT_PROBE_TIMEOUT_MS = 2000;
|
|
58
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
|
|
59
|
+
/** A Bun version in a version manager's install path. */
|
|
60
|
+
const VERSION_IN_PATH =
|
|
61
|
+
/[\\/](?:installs|tools|Cellar)[\\/]bun[\\/](\d+\.\d+\.\d+[^\\/]*)[\\/]/;
|
|
62
|
+
|
|
63
|
+
const realpathOrNull = (path: string): string | null => {
|
|
64
|
+
try {
|
|
65
|
+
return realpathSync(path);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The Bun version of an interpreter: from its install path, else `--version`. */
|
|
72
|
+
async function interpreterVersion(path: string): Promise<string | null> {
|
|
73
|
+
const resolved = realpathOrNull(path) ?? path;
|
|
74
|
+
const fromPath = VERSION_IN_PATH.exec(resolved)?.[1];
|
|
75
|
+
if (fromPath) {
|
|
76
|
+
return fromPath;
|
|
77
|
+
}
|
|
78
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
79
|
+
try {
|
|
80
|
+
const probe = Bun.spawn([path, "--version"], {
|
|
81
|
+
stdin: "ignore",
|
|
82
|
+
stdout: "pipe",
|
|
83
|
+
stderr: "ignore",
|
|
84
|
+
});
|
|
85
|
+
// Bound the read, not only the process: a child of the probe can hold
|
|
86
|
+
// stdout open after the probe itself is killed.
|
|
87
|
+
const timedOut = new Promise<null>((resolve) => {
|
|
88
|
+
timer = setTimeout(() => resolve(null), VERSION_PROBE_TIMEOUT_MS);
|
|
89
|
+
});
|
|
90
|
+
const output = await Promise.race([
|
|
91
|
+
new Response(probe.stdout).text(),
|
|
92
|
+
timedOut,
|
|
93
|
+
]);
|
|
94
|
+
if (output === null) {
|
|
95
|
+
probe.kill(9);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const version = output.trim();
|
|
99
|
+
return SEMVER.test(version) ? version : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const isGnoExecutable = (command: string): boolean =>
|
|
108
|
+
["gno", "gno.exe", "gno.cmd"].includes(basename(command).toLowerCase());
|
|
109
|
+
|
|
110
|
+
async function readRegistrationEntry(
|
|
111
|
+
configPath: string,
|
|
112
|
+
format: string
|
|
113
|
+
): Promise<unknown> {
|
|
114
|
+
const file = Bun.file(configPath);
|
|
115
|
+
if (!(await file.exists())) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
const content = await file.text();
|
|
119
|
+
if (!content.trim()) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const parsed =
|
|
123
|
+
format === "codex_toml"
|
|
124
|
+
? getTomlServerEntry(content, configPath)
|
|
125
|
+
: format === "yaml_standard"
|
|
126
|
+
? getYamlServerEntry(
|
|
127
|
+
content,
|
|
128
|
+
configPath,
|
|
129
|
+
getServersKey("yaml_standard")
|
|
130
|
+
)
|
|
131
|
+
: getJsoncServerEntry(
|
|
132
|
+
content,
|
|
133
|
+
configPath,
|
|
134
|
+
getServersKey(format as Parameters<typeof getServersKey>[0])
|
|
135
|
+
);
|
|
136
|
+
return parsed.exists ? parsed.entry : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const repairCommand = (target: McpTarget, scope: McpScope): string =>
|
|
140
|
+
`gno mcp install --target ${target} --scope ${scope} --force`;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Registrations whose launch is broken or on another Bun. Unreadable or
|
|
144
|
+
* malformed configs are left to `gno mcp status`.
|
|
145
|
+
*/
|
|
146
|
+
export async function checkRegistrationRuntimes(
|
|
147
|
+
overrides: { cwd?: string; homeDir?: string } = {}
|
|
148
|
+
): Promise<RuntimeIssue[]> {
|
|
149
|
+
const issues: RuntimeIssue[] = [];
|
|
150
|
+
const runningBun = realpathOrNull(process.execPath);
|
|
151
|
+
const versions = new Map<string, Promise<string | null>>();
|
|
152
|
+
for (const { target, scope, paths } of resolveAllMcpPaths(
|
|
153
|
+
"all",
|
|
154
|
+
"all",
|
|
155
|
+
overrides
|
|
156
|
+
)) {
|
|
157
|
+
let entry: unknown;
|
|
158
|
+
let configPath = paths.configPath;
|
|
159
|
+
try {
|
|
160
|
+
configPath = await resolveMcpConfigLocation(paths);
|
|
161
|
+
entry = await readRegistrationEntry(configPath, paths.configFormat);
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const registration =
|
|
166
|
+
entry === undefined
|
|
167
|
+
? null
|
|
168
|
+
: readExistingRegistration(entry, paths.configFormat);
|
|
169
|
+
if (
|
|
170
|
+
!registration ||
|
|
171
|
+
registration.extra.enabled === false ||
|
|
172
|
+
registration.extra.disabled === true
|
|
173
|
+
) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const { command, args } = registration;
|
|
177
|
+
const viaBun = args[0] === "run" && args.length >= 2;
|
|
178
|
+
if (!viaBun && !isGnoExecutable(command)) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const subject = `${getTargetDisplayName(target)} (${scope}) ${configPath}`;
|
|
182
|
+
const repair = repairCommand(target, scope);
|
|
183
|
+
const launchFiles = viaBun ? [command, args[1] ?? ""] : [command];
|
|
184
|
+
const missing = launchFiles.filter((path) => !realpathOrNull(path));
|
|
185
|
+
if (missing.length > 0) {
|
|
186
|
+
issues.push({
|
|
187
|
+
kind: "registration",
|
|
188
|
+
severity: "error",
|
|
189
|
+
subject,
|
|
190
|
+
message: `launches ${missing.join(" and ")}, which no longer exists`,
|
|
191
|
+
repair,
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!viaBun) {
|
|
196
|
+
// The gno launcher finds Bun on PATH when it starts.
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const resolved = realpathOrNull(command) ?? command;
|
|
200
|
+
if (!versions.has(resolved)) {
|
|
201
|
+
versions.set(resolved, interpreterVersion(command));
|
|
202
|
+
}
|
|
203
|
+
const version = await versions.get(resolved);
|
|
204
|
+
if (version && !bunMeetsFloor(version)) {
|
|
205
|
+
issues.push({
|
|
206
|
+
kind: "registration",
|
|
207
|
+
severity: "error",
|
|
208
|
+
subject,
|
|
209
|
+
message: `runs Bun ${version} (${command}), older than the supported minimum ${BUN_FLOOR}`,
|
|
210
|
+
repair,
|
|
211
|
+
});
|
|
212
|
+
} else if (resolved !== runningBun) {
|
|
213
|
+
issues.push({
|
|
214
|
+
kind: "registration",
|
|
215
|
+
severity: "info",
|
|
216
|
+
subject,
|
|
217
|
+
message: `runs ${version ? `Bun ${version}` : "a Bun of unknown version"} (${command}), not this Bun ${Bun.version}`,
|
|
218
|
+
repair,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return issues;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The Bun a resident reports through `/api/status`, if it is GNO. */
|
|
226
|
+
async function residentBunVersion(port: number): Promise<string | null> {
|
|
227
|
+
try {
|
|
228
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/status`, {
|
|
229
|
+
signal: AbortSignal.timeout(RESIDENT_PROBE_TIMEOUT_MS),
|
|
230
|
+
});
|
|
231
|
+
if (!response.ok) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const body = (await response.json()) as {
|
|
235
|
+
indexName?: unknown;
|
|
236
|
+
bootstrap?: { runtime?: { kind?: unknown; currentVersion?: unknown } };
|
|
237
|
+
};
|
|
238
|
+
const runtime = body?.bootstrap?.runtime;
|
|
239
|
+
// Only a GNO status payload counts; anything else on the port is ignored.
|
|
240
|
+
if (
|
|
241
|
+
typeof body?.indexName !== "string" ||
|
|
242
|
+
runtime?.kind !== "bun" ||
|
|
243
|
+
typeof runtime.currentVersion !== "string" ||
|
|
244
|
+
!SEMVER.test(runtime.currentVersion)
|
|
245
|
+
) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return runtime.currentVersion;
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function restartSteps(
|
|
255
|
+
kind: DetachKind,
|
|
256
|
+
pid: number,
|
|
257
|
+
sameVersion: boolean
|
|
258
|
+
): string {
|
|
259
|
+
if (sameVersion) {
|
|
260
|
+
return `restart it: gno ${kind} --stop, then start it again (for example gno ${kind} --detach)`;
|
|
261
|
+
}
|
|
262
|
+
const stop =
|
|
263
|
+
process.platform === "win32" ? `taskkill /PID ${pid}` : `kill ${pid}`;
|
|
264
|
+
return `it was started by another GNO version, so gno ${kind} --stop leaves it alone; stop it with ${stop}, then start it again (for example gno ${kind} --detach)`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Running residents on an unsupported or older Bun. A resident started by
|
|
269
|
+
* another GNO version is read through its HTTP status; it is never signalled.
|
|
270
|
+
*/
|
|
271
|
+
export async function checkResidentRuntimes(): Promise<RuntimeIssue[]> {
|
|
272
|
+
const issues: RuntimeIssue[] = [];
|
|
273
|
+
for (const kind of ["serve", "daemon"] as const) {
|
|
274
|
+
const { pidFile } = resolveProcessPaths(kind);
|
|
275
|
+
let payload: Awaited<ReturnType<typeof readPidFile>>;
|
|
276
|
+
try {
|
|
277
|
+
payload = await readPidFile(pidFile);
|
|
278
|
+
} catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (!payload || payload.cmd !== kind || !isProcessAlive(payload.pid)) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const sameVersion = payload.version === VERSION;
|
|
285
|
+
let version = payload.bun_version ?? null;
|
|
286
|
+
if (!version && typeof payload.port === "number") {
|
|
287
|
+
version = await residentBunVersion(payload.port);
|
|
288
|
+
}
|
|
289
|
+
const subject = `gno ${kind} (pid ${payload.pid}, GNO ${payload.version})`;
|
|
290
|
+
const repair = restartSteps(kind, payload.pid, sameVersion);
|
|
291
|
+
if (!version) {
|
|
292
|
+
if (!sameVersion) {
|
|
293
|
+
issues.push({
|
|
294
|
+
kind: "resident",
|
|
295
|
+
severity: "info",
|
|
296
|
+
subject,
|
|
297
|
+
message:
|
|
298
|
+
"is running from another GNO version; its Bun version is unknown",
|
|
299
|
+
repair,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (!bunMeetsFloor(version)) {
|
|
305
|
+
issues.push({
|
|
306
|
+
kind: "resident",
|
|
307
|
+
severity: "error",
|
|
308
|
+
subject,
|
|
309
|
+
message: `runs Bun ${version}, older than the supported minimum ${BUN_FLOOR}`,
|
|
310
|
+
repair,
|
|
311
|
+
});
|
|
312
|
+
} else if (Bun.semver.order(version, Bun.version) < 0) {
|
|
313
|
+
issues.push({
|
|
314
|
+
kind: "resident",
|
|
315
|
+
severity: "warn",
|
|
316
|
+
subject,
|
|
317
|
+
message: `runs Bun ${version}, older than this Bun ${Bun.version}`,
|
|
318
|
+
repair,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return issues;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** One line per issue for terminal output. */
|
|
326
|
+
export function formatRuntimeIssue(issue: RuntimeIssue): string {
|
|
327
|
+
return `${issue.subject}: ${issue.message}. Repair: ${issue.repair}`;
|
|
328
|
+
}
|
|
@@ -61,6 +61,22 @@ export const NETWORK_BOUNDARY_INVENTORY = [
|
|
|
61
61
|
action: null,
|
|
62
62
|
enforcement: "no_collection_data",
|
|
63
63
|
},
|
|
64
|
+
{
|
|
65
|
+
id: "registration-bun-version-probe",
|
|
66
|
+
key: "src/cli/runtime-health.ts::child_process#1",
|
|
67
|
+
path: "src/cli/runtime-health.ts",
|
|
68
|
+
primitive: "child_process",
|
|
69
|
+
action: null,
|
|
70
|
+
enforcement: "no_collection_data",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "resident-bun-version-probe",
|
|
74
|
+
key: "src/cli/runtime-health.ts::fetch#1",
|
|
75
|
+
path: "src/cli/runtime-health.ts",
|
|
76
|
+
primitive: "fetch",
|
|
77
|
+
action: null,
|
|
78
|
+
enforcement: "no_collection_data",
|
|
79
|
+
},
|
|
64
80
|
{
|
|
65
81
|
id: "terminal-pager",
|
|
66
82
|
key: "src/cli/pager.ts::child_process#1",
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
* If no backend can start, the file fails closed with
|
|
20
20
|
* ISOLATION_UNAVAILABLE; it is never prepared unbounded.
|
|
21
21
|
*
|
|
22
|
+
* A message that cannot be deserialized on the way to or from the processor
|
|
23
|
+
* (fn-213) fails the file with PROCESSOR_MESSAGE_FAILED and replaces the
|
|
24
|
+
* processor, and one stderr line names the direction, backend, file and
|
|
25
|
+
* step. Bun 1.4.0 and later report it as a `messageerror` event on the
|
|
26
|
+
* receiving side of a worker; over the child's IPC channel Bun closes the
|
|
27
|
+
* channel on both ends instead, so the child backend cannot tell which
|
|
28
|
+
* direction failed.
|
|
29
|
+
*
|
|
22
30
|
* One processor is reused across files (starting one costs about a third of
|
|
23
31
|
* a second of module loading). It is replaced after an overrun, after a
|
|
24
32
|
* file that leaves memory above half the budget, and after a short idle
|
|
@@ -78,8 +86,26 @@ export interface ReadyMessage {
|
|
|
78
86
|
type: "ready";
|
|
79
87
|
}
|
|
80
88
|
|
|
89
|
+
/** Posted by the worker when a job it was sent could not be deserialized. */
|
|
90
|
+
export interface UndeliverableJobMessage {
|
|
91
|
+
type: "undeliverable-job";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Which message could not be delivered; the child backend cannot tell. */
|
|
95
|
+
export type DeliveryDirection = "job" | "result" | "unknown";
|
|
96
|
+
|
|
97
|
+
/** Why a processor stopped working. */
|
|
98
|
+
interface ProcessorFailure {
|
|
99
|
+
reason: string;
|
|
100
|
+
/** Set when a message could not be delivered. */
|
|
101
|
+
delivery?: DeliveryDirection;
|
|
102
|
+
}
|
|
103
|
+
|
|
81
104
|
export type FileProcessorBackendKind = "worker" | "child";
|
|
82
105
|
|
|
106
|
+
/** Error code of a file whose message to or from the processor was lost. */
|
|
107
|
+
export const PROCESSOR_MESSAGE_FAILED = "PROCESSOR_MESSAGE_FAILED";
|
|
108
|
+
|
|
83
109
|
/** Error codes of a file stopped at its budget. */
|
|
84
110
|
export const BUDGET_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
85
111
|
"TIMEOUT",
|
|
@@ -149,7 +175,7 @@ interface Processor {
|
|
|
149
175
|
/** Settles once the processor has loaded and can take a file. */
|
|
150
176
|
readonly ready: Promise<void>;
|
|
151
177
|
/** Settles, with a reason, if the processor dies or errors. */
|
|
152
|
-
readonly failed: Promise<
|
|
178
|
+
readonly failed: Promise<ProcessorFailure>;
|
|
153
179
|
/** Send one file; its messages go to `onMessage`. */
|
|
154
180
|
run(
|
|
155
181
|
request: PrepareFileRequest,
|
|
@@ -164,6 +190,8 @@ interface Processor {
|
|
|
164
190
|
/** Child process id (child backend), for shutdown and tests. */
|
|
165
191
|
readonly pid: number | null;
|
|
166
192
|
stop(): void;
|
|
193
|
+
/** Tests only: raise the event Bun raises for an undeliverable message. */
|
|
194
|
+
simulateUndeliverable?(direction: "job" | "result"): void;
|
|
167
195
|
}
|
|
168
196
|
|
|
169
197
|
const phaseName = (index: number): string => PREPARE_PHASES[index] ?? "startup";
|
|
@@ -176,25 +204,38 @@ function startWorker(): Processor {
|
|
|
176
204
|
const phaseSlot = new Int32Array(new SharedArrayBuffer(4));
|
|
177
205
|
let onMessage: ((message: FileWorkerMessage) => void) | null = null;
|
|
178
206
|
let markReady: () => void = () => undefined;
|
|
179
|
-
let markFailed: (
|
|
207
|
+
let markFailed: (failure: ProcessorFailure) => void = () => undefined;
|
|
180
208
|
let dead = false;
|
|
181
209
|
const ready = new Promise<void>((resolve) => {
|
|
182
210
|
markReady = resolve;
|
|
183
211
|
});
|
|
184
|
-
const failed = new Promise<
|
|
185
|
-
markFailed = (
|
|
212
|
+
const failed = new Promise<ProcessorFailure>((resolve) => {
|
|
213
|
+
markFailed = (failure) => {
|
|
186
214
|
dead = true;
|
|
187
|
-
resolve(
|
|
215
|
+
resolve(failure);
|
|
188
216
|
};
|
|
189
217
|
});
|
|
190
218
|
worker.onmessage = (
|
|
191
|
-
event: MessageEvent<
|
|
219
|
+
event: MessageEvent<
|
|
220
|
+
FileWorkerMessage | ReadyMessage | UndeliverableJobMessage
|
|
221
|
+
>
|
|
192
222
|
) => {
|
|
193
223
|
if (event.data.type === "ready") markReady();
|
|
194
|
-
else
|
|
224
|
+
else if (event.data.type === "undeliverable-job") {
|
|
225
|
+
markFailed({
|
|
226
|
+
reason: "the file worker could not deserialize its job",
|
|
227
|
+
delivery: "job",
|
|
228
|
+
});
|
|
229
|
+
} else onMessage?.(event.data);
|
|
230
|
+
};
|
|
231
|
+
worker.onmessageerror = () => {
|
|
232
|
+
markFailed({
|
|
233
|
+
reason: "a message from the file worker could not be deserialized",
|
|
234
|
+
delivery: "result",
|
|
235
|
+
});
|
|
195
236
|
};
|
|
196
237
|
worker.onerror = (event: ErrorEvent) => {
|
|
197
|
-
markFailed(event.message || "file worker failed");
|
|
238
|
+
markFailed({ reason: event.message || "file worker failed" });
|
|
198
239
|
};
|
|
199
240
|
return {
|
|
200
241
|
kind: "worker",
|
|
@@ -212,7 +253,12 @@ function startWorker(): Processor {
|
|
|
212
253
|
pid: null,
|
|
213
254
|
stop() {
|
|
214
255
|
worker.terminate();
|
|
215
|
-
markFailed("file processor was stopped");
|
|
256
|
+
markFailed({ reason: "file processor was stopped" });
|
|
257
|
+
},
|
|
258
|
+
simulateUndeliverable(direction) {
|
|
259
|
+
if (direction === "result") {
|
|
260
|
+
worker.dispatchEvent(new MessageEvent("messageerror"));
|
|
261
|
+
} else worker.postMessage({ simulateUndeliverable: true });
|
|
216
262
|
},
|
|
217
263
|
};
|
|
218
264
|
}
|
|
@@ -267,12 +313,21 @@ function startChild(): Processor {
|
|
|
267
313
|
child.unref();
|
|
268
314
|
liveChildren.add(child);
|
|
269
315
|
ensureShutdownHooks();
|
|
270
|
-
const failed = child.exited.then((code) => {
|
|
316
|
+
const failed = child.exited.then((code): ProcessorFailure => {
|
|
271
317
|
exited = true;
|
|
272
318
|
liveChildren.delete(child);
|
|
273
|
-
return
|
|
274
|
-
|
|
275
|
-
|
|
319
|
+
if (stopping) return { reason: "file processor was stopped" };
|
|
320
|
+
// Bun closes the channel on both ends when either side receives a
|
|
321
|
+
// message it cannot deserialize. The child exits cleanly only once its
|
|
322
|
+
// channel is gone (file-child.ts), and a crash never exits cleanly; the
|
|
323
|
+
// exit can be reported before Bun's own disconnect callback.
|
|
324
|
+
if (code === 0) {
|
|
325
|
+
return {
|
|
326
|
+
reason: "the IPC channel to the file processor child closed",
|
|
327
|
+
delivery: "unknown",
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return { reason: `file processor child exited (${code})` };
|
|
276
331
|
});
|
|
277
332
|
return {
|
|
278
333
|
kind: "child",
|
|
@@ -367,6 +422,19 @@ export function activeFileProcessorPid(): number | null {
|
|
|
367
422
|
return processor?.pid ?? null;
|
|
368
423
|
}
|
|
369
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Tests only: make the running worker processor raise the event Bun raises
|
|
427
|
+
* when a message cannot be deserialized (a real payload cannot be made to
|
|
428
|
+
* fail from JavaScript). False when no worker processor is running.
|
|
429
|
+
*/
|
|
430
|
+
export function simulateUndeliverableMessage(
|
|
431
|
+
direction: "job" | "result"
|
|
432
|
+
): boolean {
|
|
433
|
+
if (!processor?.simulateUndeliverable) return false;
|
|
434
|
+
processor.simulateUndeliverable(direction);
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
|
|
370
438
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
371
439
|
// Processor lifecycle
|
|
372
440
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -429,11 +497,30 @@ const awaitReady = (active: Processor): Promise<string | null> => {
|
|
|
429
497
|
});
|
|
430
498
|
return Promise.race([
|
|
431
499
|
active.ready.then(() => null),
|
|
432
|
-
active.failed,
|
|
500
|
+
active.failed.then((failure) => failure.reason),
|
|
433
501
|
timedOut,
|
|
434
502
|
]).finally(() => clearTimeout(timer));
|
|
435
503
|
};
|
|
436
504
|
|
|
505
|
+
/** Failure of a file whose message to or from the processor was lost. */
|
|
506
|
+
function undeliverableFailure(
|
|
507
|
+
request: PrepareFileRequest,
|
|
508
|
+
kind: FileProcessorBackendKind,
|
|
509
|
+
phase: string,
|
|
510
|
+
failure: ProcessorFailure
|
|
511
|
+
): PrepareFailure {
|
|
512
|
+
const direction = failure.delivery ?? "unknown";
|
|
513
|
+
const file = `${request.input.collection}/${request.input.relativePath}`;
|
|
514
|
+
console.error(
|
|
515
|
+
`gno: file processor message lost (direction ${direction}, backend ${kind}) for ${file} during ${phase}: ${failure.reason}; the processor was replaced`
|
|
516
|
+
);
|
|
517
|
+
return {
|
|
518
|
+
code: PROCESSOR_MESSAGE_FAILED,
|
|
519
|
+
message: `The file processor could not deliver a message during ${phase} (${failure.reason}); the processor was replaced`,
|
|
520
|
+
details: { direction, backend: kind, phase },
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
437
524
|
const runOne = async (
|
|
438
525
|
request: PrepareFileRequest,
|
|
439
526
|
budget: FileBudgetLimits,
|
|
@@ -511,9 +598,23 @@ const runOne = async (
|
|
|
511
598
|
const sampler = setInterval(() => {
|
|
512
599
|
void checkMemory();
|
|
513
600
|
}, MEMORY_SAMPLE_INTERVAL_MS);
|
|
514
|
-
void active.failed.then((
|
|
515
|
-
|
|
516
|
-
|
|
601
|
+
void active.failed.then((failure) => {
|
|
602
|
+
if (settled) return;
|
|
603
|
+
finish(
|
|
604
|
+
{
|
|
605
|
+
ok: false,
|
|
606
|
+
error: failure.delivery
|
|
607
|
+
? undeliverableFailure(
|
|
608
|
+
request,
|
|
609
|
+
active.kind,
|
|
610
|
+
active.phase(),
|
|
611
|
+
failure
|
|
612
|
+
)
|
|
613
|
+
: { code: "INTERNAL", message: failure.reason },
|
|
614
|
+
},
|
|
615
|
+
false
|
|
616
|
+
);
|
|
617
|
+
});
|
|
517
618
|
// A process already over the budget does not start another file.
|
|
518
619
|
const rss = process.memoryUsage.rss();
|
|
519
620
|
if (rss > budget.maxMemoryBytes) {
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
FileWorkerMessage,
|
|
9
9
|
FileWorkerRequest,
|
|
10
10
|
ReadyMessage,
|
|
11
|
+
UndeliverableJobMessage,
|
|
11
12
|
} from "./file-processor";
|
|
12
13
|
|
|
13
14
|
import { defaultChunker } from "./chunker";
|
|
@@ -16,14 +17,31 @@ import { PREPARE_PHASES, prepareFile } from "./prepare-file";
|
|
|
16
17
|
|
|
17
18
|
declare const self: Worker;
|
|
18
19
|
|
|
19
|
-
const post = (
|
|
20
|
+
const post = (
|
|
21
|
+
message: FileWorkerMessage | ReadyMessage | UndeliverableJobMessage
|
|
22
|
+
): void => {
|
|
20
23
|
self.postMessage(message);
|
|
21
24
|
};
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
// A job that cannot be deserialized never reaches onmessage (Bun 1.4.0+
|
|
27
|
+
// raises messageerror instead); tell the parent so the file fails at once.
|
|
28
|
+
// A listener, not `onmessageerror`: Bun's worker scope never calls that
|
|
29
|
+
// property.
|
|
30
|
+
self.addEventListener("messageerror", () => {
|
|
31
|
+
post({ type: "undeliverable-job" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
self.onmessage = async (
|
|
35
|
+
event: MessageEvent<FileWorkerRequest | { simulateUndeliverable: true }>
|
|
36
|
+
) => {
|
|
24
37
|
// Only the parent that created this worker may drive it; a Bun Worker sees
|
|
25
38
|
// its parent's messages with an empty origin.
|
|
26
39
|
if (event.origin !== "") return;
|
|
40
|
+
if ("simulateUndeliverable" in event.data) {
|
|
41
|
+
// Tests only (simulateUndeliverableMessage): raise the real event.
|
|
42
|
+
self.dispatchEvent(new MessageEvent("messageerror"));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
27
45
|
const { request, phaseSlot } = event.data;
|
|
28
46
|
const outcome = await prepareFile(request, {
|
|
29
47
|
convert: convertForPreparation,
|
|
@@ -65,8 +65,8 @@ export function BootstrapStatus({
|
|
|
65
65
|
|
|
66
66
|
return (
|
|
67
67
|
<section className="space-y-4">
|
|
68
|
-
<div className="flex items-start justify-between
|
|
69
|
-
<div>
|
|
68
|
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
69
|
+
<div className="min-w-0">
|
|
70
70
|
<div className="mb-2 flex items-center gap-2">
|
|
71
71
|
<PackageIcon className="size-4 text-primary" />
|
|
72
72
|
<h2 className="font-semibold text-2xl">Bootstrap & Storage</h2>
|
|
@@ -77,7 +77,7 @@ export function BootstrapStatus({
|
|
|
77
77
|
</p>
|
|
78
78
|
</div>
|
|
79
79
|
{missingModels > 0 && !bootstrap.models.downloading && (
|
|
80
|
-
<Button onClick={onDownloadModels} size="sm">
|
|
80
|
+
<Button className="self-start" onClick={onDownloadModels} size="sm">
|
|
81
81
|
<DownloadIcon className="mr-2 size-4" />
|
|
82
82
|
Download missing models
|
|
83
83
|
</Button>
|