@velarscript/desktop 0.10.1
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/LICENSE +201 -0
- package/README.md +247 -0
- package/dist/build.d.ts +53 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +351 -0
- package/dist/build.js.map +1 -0
- package/dist/compiler.d.ts +4 -0
- package/dist/compiler.d.ts.map +1 -0
- package/dist/compiler.js +1967 -0
- package/dist/compiler.js.map +1 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +140 -0
- package/dist/config.js.map +1 -0
- package/dist/host.d.ts +3 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +71 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/package-host.d.ts +3 -0
- package/dist/package-host.d.ts.map +1 -0
- package/dist/package-host.js +20 -0
- package/dist/package-host.js.map +1 -0
- package/dist/test-runtime.d.ts +16 -0
- package/dist/test-runtime.d.ts.map +1 -0
- package/dist/test-runtime.js +475 -0
- package/dist/test-runtime.js.map +1 -0
- package/dist/worker.js +546 -0
- package/native/macos/VelarDesktopHost.swift +1258 -0
- package/native/macos/VelarScript.icns +0 -0
- package/native/macos/VelarTerminalHost.swift +203 -0
- package/native/node/project-transactions.js +157 -0
- package/native/node/worker.js +2220 -0
- package/package.json +63 -0
|
@@ -0,0 +1,2220 @@
|
|
|
1
|
+
import { constants as fsConstants, watch as watchNode } from "node:fs";
|
|
2
|
+
import { access, appendFile, copyFile as copyNodeFile, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { StringDecoder } from "node:string_decoder";
|
|
7
|
+
import {
|
|
8
|
+
createDesktopProjectTransactionOwner,
|
|
9
|
+
desktopProjectChangePage,
|
|
10
|
+
desktopProjectChangeView,
|
|
11
|
+
} from "./project-transactions.js";
|
|
12
|
+
|
|
13
|
+
const MAX_MESSAGE_BYTES = 128 * 1024 * 1024;
|
|
14
|
+
const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
15
|
+
const MAX_LIST_TEXT_UNITS = 2 * 1024 * 1024;
|
|
16
|
+
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
17
|
+
const MAX_HTTP_CHUNKS = 1000000;
|
|
18
|
+
const MAX_HTTP_REDIRECTS = 20;
|
|
19
|
+
const MAX_PATH_UNITS = 4096;
|
|
20
|
+
const MAX_FILE_WATCHERS = 128;
|
|
21
|
+
const MAX_WATCH_PATHS = 4096;
|
|
22
|
+
const MAX_WATCH_TEXT_UNITS = 2 * 1024 * 1024;
|
|
23
|
+
const MAX_LANGUAGE_SERVER_MESSAGE_BYTES = 16 * 1024 * 1024;
|
|
24
|
+
const MAX_LANGUAGE_SERVER_QUEUED_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
const MAX_LANGUAGE_SERVERS = 4;
|
|
26
|
+
const MAX_PROJECT_TASKS = 4;
|
|
27
|
+
const MAX_PROJECT_CHANGE_HANDLES = 16;
|
|
28
|
+
const MAX_PROJECT_CHANGE_QUEUE_ITEMS = 100;
|
|
29
|
+
const MAX_PROJECT_CHANGE_QUEUE_BYTES = 16 * 1024 * 1024;
|
|
30
|
+
const MAX_TERMINALS = 4;
|
|
31
|
+
const MAX_TERMINAL_CHUNK_BYTES = 1024 * 1024;
|
|
32
|
+
const MAX_TERMINAL_QUEUED_BYTES = 4 * 1024 * 1024;
|
|
33
|
+
const TERMINAL_PAUSE_BYTES = 2 * 1024 * 1024;
|
|
34
|
+
const TERMINAL_RESUME_BYTES = 1024 * 1024;
|
|
35
|
+
const WATCH_DEBOUNCE_MS = 20;
|
|
36
|
+
const PROCESS_STOP_CONFIRMATION_TIMEOUT_MS = 5000;
|
|
37
|
+
const PROCESS_EXIT_PIPE_CONFIRMATION_TIMEOUT_MS = 5000;
|
|
38
|
+
const HOST_HANDSHAKE_TIMEOUT_MS = 5000;
|
|
39
|
+
const HOST_PIPE_WRITE_TIMEOUT_MS = 5000;
|
|
40
|
+
const FATAL_DRAIN_TIMEOUT_MS = 8000;
|
|
41
|
+
const processTerminationMarker = Object.freeze({});
|
|
42
|
+
const processRootExitMarker = Object.freeze({});
|
|
43
|
+
const processStopMarker = Object.freeze({});
|
|
44
|
+
const transportCharCodeAt = Object.getOwnPropertyDescriptor(String.prototype, "charCodeAt")?.value;
|
|
45
|
+
const transportReflectApply = Object.getOwnPropertyDescriptor(Reflect, "apply")?.value;
|
|
46
|
+
const [configPath, appDataRoot, launchRoot] = process.argv.slice(2);
|
|
47
|
+
if (!configPath || !appDataRoot || !launchRoot) throw new Error("Velar Desktop worker requires config, app-data, and launch roots");
|
|
48
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
49
|
+
if (config.protocolVersion !== 1 || !config.permissions || typeof config.permissions !== "object") throw new Error("Invalid Velar Desktop worker configuration");
|
|
50
|
+
const fileScopes = new Set(config.permissions.files ?? []);
|
|
51
|
+
const allowedProcesses = new Set(config.permissions.processes ?? []);
|
|
52
|
+
const allowedOrigins = new Set(config.permissions.network ?? []);
|
|
53
|
+
const allowedSecrets = new Set(config.permissions.secrets ?? []);
|
|
54
|
+
const processHandles = new Map();
|
|
55
|
+
let nextProcessHandle = 1;
|
|
56
|
+
const httpHandles = new Map();
|
|
57
|
+
const activeRequests = new Map();
|
|
58
|
+
const fileMutationTails = new Map();
|
|
59
|
+
const fileWatchers = new Map();
|
|
60
|
+
let nextFileWatcherHandle = 1;
|
|
61
|
+
const languageServers = new Map();
|
|
62
|
+
let nextLanguageServerHandle = 1_000_000_000;
|
|
63
|
+
const projectChangeHandles = new Map();
|
|
64
|
+
let nextProjectChangeHandle = 3_000_000_000;
|
|
65
|
+
const terminals = new Map();
|
|
66
|
+
let nextTerminalHandle = 2_000_000_000;
|
|
67
|
+
let nextTextReplacementIdentity = 1;
|
|
68
|
+
let fatalDrainStarted = false;
|
|
69
|
+
let activeOwner = null;
|
|
70
|
+
const roots = [];
|
|
71
|
+
const lexicalRoots = [];
|
|
72
|
+
let appDataCanonicalRoot = null;
|
|
73
|
+
let appDataLexicalRoot = null;
|
|
74
|
+
let projectRoot = null;
|
|
75
|
+
let projectLexicalRoot = null;
|
|
76
|
+
let projectRootUpdate = Promise.resolve();
|
|
77
|
+
let projectTransactionOwner = null;
|
|
78
|
+
let projectTransactionOwnerPending = null;
|
|
79
|
+
let projectTransactionGeneration = 0;
|
|
80
|
+
let languageServerPath = null;
|
|
81
|
+
let projectTaskPath = null;
|
|
82
|
+
let buildEnginePath = null;
|
|
83
|
+
let terminalHostPath = null;
|
|
84
|
+
const terminalGranted = config.permissions.terminal === true;
|
|
85
|
+
|
|
86
|
+
class HttpTransportFailure extends Error {
|
|
87
|
+
constructor(phase) {
|
|
88
|
+
super(phase === "request" ? "HTTP request transport failed" : "HTTP response transport failed");
|
|
89
|
+
this.name = "HttpTransportError";
|
|
90
|
+
this.phase = phase;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// A bundled child that stops answering must fail the one request that waits on
|
|
94
|
+
// it, never leave the host waiting without a bound.
|
|
95
|
+
class HostDeadlineFailure extends Error {
|
|
96
|
+
constructor(message) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.name = "HostDeadlineError";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function boundedHostWait(value, message, timeout) {
|
|
102
|
+
let timer = null;
|
|
103
|
+
const guarded = value.then(
|
|
104
|
+
(result) => { if (timer !== null) clearTimeout(timer); return result; },
|
|
105
|
+
(error) => { if (timer !== null) clearTimeout(timer); throw error; },
|
|
106
|
+
);
|
|
107
|
+
return Promise.race([guarded, new Promise((_, rejectDeadline) => {
|
|
108
|
+
timer = setTimeout(() => rejectDeadline(new HostDeadlineFailure(message)), timeout);
|
|
109
|
+
})]);
|
|
110
|
+
}
|
|
111
|
+
const launchDirectory = await realpath(launchRoot);
|
|
112
|
+
if (config.languageServer !== undefined) {
|
|
113
|
+
if (!config.languageServer || typeof config.languageServer !== "object" || Array.isArray(config.languageServer)
|
|
114
|
+
|| Object.keys(config.languageServer).some(key => key !== "path")
|
|
115
|
+
|| config.languageServer.path !== "host/language-server.js") throw new Error("Invalid bundled language-server configuration");
|
|
116
|
+
const candidate = resolve(dirname(configPath), config.languageServer.path);
|
|
117
|
+
languageServerPath = await realpath(candidate);
|
|
118
|
+
if (!(await stat(languageServerPath)).isFile()) throw new Error("Bundled language server must be an ordinary file");
|
|
119
|
+
}
|
|
120
|
+
if (config.projectTask !== undefined) {
|
|
121
|
+
if (!config.projectTask || typeof config.projectTask !== "object" || Array.isArray(config.projectTask)
|
|
122
|
+
|| Object.keys(config.projectTask).some(key => !["path", "buildEnginePath"].includes(key))
|
|
123
|
+
|| config.projectTask.path !== "host/project-task.js" || config.projectTask.buildEnginePath !== "host/build-engine") {
|
|
124
|
+
throw new Error("Invalid bundled project-task configuration");
|
|
125
|
+
}
|
|
126
|
+
const resourcesRoot = await realpath(dirname(configPath));
|
|
127
|
+
projectTaskPath = await realpath(resolve(dirname(configPath), config.projectTask.path));
|
|
128
|
+
buildEnginePath = await realpath(resolve(dirname(configPath), config.projectTask.buildEnginePath));
|
|
129
|
+
if (!contained(resourcesRoot, projectTaskPath) || !contained(resourcesRoot, buildEnginePath)
|
|
130
|
+
|| !(await stat(projectTaskPath)).isFile() || !(await stat(buildEnginePath)).isFile()) {
|
|
131
|
+
throw new Error("Bundled project task tools must be ordinary files inside Desktop resources");
|
|
132
|
+
}
|
|
133
|
+
await access(buildEnginePath, fsConstants.X_OK);
|
|
134
|
+
}
|
|
135
|
+
if (config.terminalHost !== undefined) {
|
|
136
|
+
if (!config.terminalHost || typeof config.terminalHost !== "object" || Array.isArray(config.terminalHost)
|
|
137
|
+
|| Object.keys(config.terminalHost).some(key => key !== "path")
|
|
138
|
+
|| config.terminalHost.path !== "host/terminal-host") throw new Error("Invalid bundled terminal-host configuration");
|
|
139
|
+
const resourcesRoot = await realpath(dirname(configPath));
|
|
140
|
+
terminalHostPath = await realpath(resolve(dirname(configPath), config.terminalHost.path));
|
|
141
|
+
if (!contained(resourcesRoot, terminalHostPath) || !(await stat(terminalHostPath)).isFile()) {
|
|
142
|
+
throw new Error("Bundled terminal host must be an ordinary file inside Desktop resources");
|
|
143
|
+
}
|
|
144
|
+
await access(terminalHostPath, fsConstants.X_OK);
|
|
145
|
+
}
|
|
146
|
+
if (fileScopes.has("app-data")) {
|
|
147
|
+
const dataRoot = resolve(appDataRoot, "data");
|
|
148
|
+
await mkdir(dataRoot, { recursive: true });
|
|
149
|
+
appDataLexicalRoot = dataRoot;
|
|
150
|
+
appDataCanonicalRoot = await realpath(dataRoot);
|
|
151
|
+
}
|
|
152
|
+
if (fileScopes.has("project")) {
|
|
153
|
+
projectLexicalRoot = resolve(launchRoot);
|
|
154
|
+
projectRoot = launchDirectory;
|
|
155
|
+
}
|
|
156
|
+
rebuildFileRoots();
|
|
157
|
+
|
|
158
|
+
const reader = createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
159
|
+
reader.once("close", () => {
|
|
160
|
+
if (activeOwner !== null) retireOwner(activeOwner);
|
|
161
|
+
retireProjectTransactionOwner(new Error("Desktop capability host closed"));
|
|
162
|
+
for (const task of fileWatchers.values()) releaseFileWatcher(task, new Error("Desktop capability host closed"));
|
|
163
|
+
for (const task of languageServers.values()) releaseLanguageServer(task, new Error("Desktop capability host closed"));
|
|
164
|
+
for (const task of terminals.values()) releaseTerminal(task, new Error("Desktop capability host closed"));
|
|
165
|
+
for (const activity of activeRequests.values()) cancelActivity(activity);
|
|
166
|
+
});
|
|
167
|
+
reader.on("line", async (line) => {
|
|
168
|
+
let id = 0;
|
|
169
|
+
let activity = null;
|
|
170
|
+
try {
|
|
171
|
+
if (Buffer.byteLength(line, "utf8") > MAX_MESSAGE_BYTES) throw new RangeError("Desktop request exceeds its 128 MiB transport bound");
|
|
172
|
+
const request = JSON.parse(line);
|
|
173
|
+
if (request?.hostCommand !== undefined) {
|
|
174
|
+
await handleHostCommand(request);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
id = request.id;
|
|
178
|
+
if (request.protocolVersion !== 1 || !Number.isSafeInteger(id) || id < 1
|
|
179
|
+
|| typeof request.capability !== "string" || typeof request.operation !== "string" || !Array.isArray(request.args)
|
|
180
|
+
|| !validOwner(request.owner) || request.owner !== activeOwner) {
|
|
181
|
+
throw new TypeError("Invalid Desktop worker request");
|
|
182
|
+
}
|
|
183
|
+
if (activeRequests.has(id)) throw new Error("Desktop worker request identity is already active");
|
|
184
|
+
activity = { owner: request.owner, cancelled: false, cancel: null };
|
|
185
|
+
activeRequests.set(id, activity);
|
|
186
|
+
const value = await dispatch(request.capability, request.operation, request.args, request.owner, activity);
|
|
187
|
+
if (activity.cancelled) throw new Error("Desktop host request was cancelled");
|
|
188
|
+
if (request.owner !== activeOwner) throw new Error("Desktop document generation is no longer active");
|
|
189
|
+
respond({ id, ok: true, value });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
respond({ id, ok: false, error: safeError(error) });
|
|
192
|
+
} finally {
|
|
193
|
+
if (activity !== null && activeRequests.get(id) === activity) activeRequests.delete(id);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
async function handleHostCommand(request) {
|
|
198
|
+
if (request.protocolVersion !== 1 || !validOwner(request.owner)) {
|
|
199
|
+
throw new TypeError("Invalid Desktop host command");
|
|
200
|
+
}
|
|
201
|
+
if (request.hostCommand === "owner-activate") {
|
|
202
|
+
if (Object.keys(request).some((key) => !["protocolVersion", "hostCommand", "owner"].includes(key))) throw new TypeError("Invalid Desktop host command");
|
|
203
|
+
if (activeOwner !== null && activeOwner !== request.owner) retireOwner(activeOwner);
|
|
204
|
+
activeOwner = request.owner;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (request.hostCommand === "owner-retire") {
|
|
208
|
+
if (Object.keys(request).some((key) => !["protocolVersion", "hostCommand", "owner"].includes(key))) throw new TypeError("Invalid Desktop host command");
|
|
209
|
+
retireOwner(request.owner);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (request.hostCommand === "request-cancel") {
|
|
213
|
+
if (Object.keys(request).some((key) => !["protocolVersion", "hostCommand", "owner", "requestID"].includes(key))
|
|
214
|
+
|| !Number.isSafeInteger(request.requestID) || request.requestID < 1) throw new TypeError("Invalid Desktop host command");
|
|
215
|
+
const activity = activeRequests.get(request.requestID);
|
|
216
|
+
if (activity && activity.owner === request.owner) cancelActivity(activity);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (request.hostCommand === "project-root-set") {
|
|
220
|
+
if (Object.keys(request).some((key) => !["protocolVersion", "hostCommand", "owner", "commandID", "path"].includes(key))
|
|
221
|
+
|| request.owner !== activeOwner || !Number.isSafeInteger(request.commandID) || request.commandID < 1
|
|
222
|
+
|| typeof request.path !== "string" || !isAbsolute(request.path) || request.path.length === 0
|
|
223
|
+
|| request.path.length > MAX_PATH_UNITS || request.path.includes("\0") || !fileScopes.has("project")) {
|
|
224
|
+
throw new TypeError("Invalid Desktop project-root command");
|
|
225
|
+
}
|
|
226
|
+
const update = projectRootUpdate.then(() => replaceProjectRoot(request.path));
|
|
227
|
+
projectRootUpdate = update.catch(() => {});
|
|
228
|
+
try {
|
|
229
|
+
await update;
|
|
230
|
+
respond({ protocolVersion: 1, hostEvent: "project-root-settled", owner: request.owner, commandID: request.commandID, ok: true });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
respond({ protocolVersion: 1, hostEvent: "project-root-settled", owner: request.owner, commandID: request.commandID, ok: false, error: safeError(error) });
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
throw new TypeError("Unknown Desktop host command");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function cancelActivity(activity) {
|
|
240
|
+
if (activity.cancelled) return;
|
|
241
|
+
activity.cancelled = true;
|
|
242
|
+
try { activity.cancel?.(); } catch {}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function setActivityCancellation(activity, cancel) {
|
|
246
|
+
activity.cancel = cancel;
|
|
247
|
+
if (activity.cancelled) cancel();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function validOwner(value) {
|
|
251
|
+
return typeof value === "string" && /^[0-9a-f]{32}$/u.test(value);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function dispatch(capability, operation, args, owner, activity) {
|
|
255
|
+
await projectRootUpdate;
|
|
256
|
+
if (capability === "fs") return fsOperation(operation, args, owner, activity);
|
|
257
|
+
if (capability === "process") {
|
|
258
|
+
if (operation === "run") return processRun(args, owner, activity);
|
|
259
|
+
if (operation === "start") return processStart(args, owner, activity);
|
|
260
|
+
if (operation === "read") return processRead(args, owner);
|
|
261
|
+
if (operation === "wait") return processWait(args, owner);
|
|
262
|
+
if (operation === "stop") return processStop(args, owner);
|
|
263
|
+
}
|
|
264
|
+
if (capability === "language-server") return languageServerOperation(operation, args, owner, activity);
|
|
265
|
+
if (capability === "project-task") return projectTaskOperation(operation, args, owner, activity);
|
|
266
|
+
if (capability === "project-changes") return projectChangeOperation(operation, args, owner, activity);
|
|
267
|
+
if (capability === "terminal") return terminalOperation(operation, args, owner, activity);
|
|
268
|
+
if (capability === "http") {
|
|
269
|
+
if (operation === "request") return httpRequest(args, owner, activity);
|
|
270
|
+
if (operation === "read") return httpRead(args, owner);
|
|
271
|
+
if (operation === "cancel") return httpCancel(args, owner);
|
|
272
|
+
}
|
|
273
|
+
throw new Error(`Unknown Desktop capability '${capability}.${operation}'`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function rebuildFileRoots() {
|
|
277
|
+
roots.length = 0;
|
|
278
|
+
lexicalRoots.length = 0;
|
|
279
|
+
if (appDataCanonicalRoot !== null && appDataLexicalRoot !== null) {
|
|
280
|
+
roots.push(appDataCanonicalRoot);
|
|
281
|
+
lexicalRoots.push(appDataLexicalRoot);
|
|
282
|
+
}
|
|
283
|
+
if (projectRoot !== null && projectLexicalRoot !== null) {
|
|
284
|
+
roots.push(projectRoot);
|
|
285
|
+
lexicalRoots.push(projectLexicalRoot);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function replaceProjectRoot(path) {
|
|
290
|
+
const canonical = await realpath(path);
|
|
291
|
+
const metadata = await stat(canonical);
|
|
292
|
+
if (!metadata.isDirectory()) throw new TypeError("Desktop project grant must identify a directory");
|
|
293
|
+
for (const task of fileWatchers.values()) releaseFileWatcher(task, new Error("Desktop project grant changed"));
|
|
294
|
+
for (const task of languageServers.values()) releaseLanguageServer(task, new Error("Desktop project grant changed"));
|
|
295
|
+
for (const task of terminals.values()) releaseTerminal(task, new Error("Desktop project grant changed"));
|
|
296
|
+
retireProjectTransactionOwner(new Error("Desktop project grant changed"));
|
|
297
|
+
for (const activity of activeRequests.values()) cancelActivity(activity);
|
|
298
|
+
for (const [handle, task] of processHandles) retainRetiredProcess(handle, task);
|
|
299
|
+
for (const [handle, request] of httpHandles) {
|
|
300
|
+
request.controller.abort(new Error("Desktop project grant changed"));
|
|
301
|
+
void request.reader?.cancel().catch(() => {});
|
|
302
|
+
finishHttp(handle, request);
|
|
303
|
+
}
|
|
304
|
+
projectLexicalRoot = resolve(path);
|
|
305
|
+
projectRoot = canonical;
|
|
306
|
+
rebuildFileRoots();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function closeRetiredProjectTransactionOwner(owner) {
|
|
310
|
+
if (!owner.retired || owner.activeOperations > 0) return;
|
|
311
|
+
owner.close();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function retireProjectTransactionOwner(error) {
|
|
315
|
+
projectTransactionGeneration += 1;
|
|
316
|
+
const current = projectTransactionOwner;
|
|
317
|
+
projectTransactionOwner = null;
|
|
318
|
+
if (current !== null) {
|
|
319
|
+
current.retired = true;
|
|
320
|
+
closeRetiredProjectTransactionOwner(current);
|
|
321
|
+
}
|
|
322
|
+
for (const task of projectChangeHandles.values()) releaseProjectChangeHandle(task, error);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function currentProjectTransactionOwner() {
|
|
326
|
+
if (projectRoot === null || !fileScopes.has("project")) {
|
|
327
|
+
throw new Error("Desktop project changes require the project file grant");
|
|
328
|
+
}
|
|
329
|
+
if (projectTransactionOwner?.root === projectRoot && !projectTransactionOwner.retired) return projectTransactionOwner;
|
|
330
|
+
const generation = projectTransactionGeneration;
|
|
331
|
+
if (projectTransactionOwnerPending?.root !== projectRoot || projectTransactionOwnerPending.generation !== generation) {
|
|
332
|
+
const root = projectRoot;
|
|
333
|
+
const pending = createDesktopProjectTransactionOwner(root, appDataRoot).then((created) => ({
|
|
334
|
+
...created,
|
|
335
|
+
activeOperations: 0,
|
|
336
|
+
retired: false,
|
|
337
|
+
}));
|
|
338
|
+
projectTransactionOwnerPending = { root, generation, pending };
|
|
339
|
+
}
|
|
340
|
+
const pendingOwner = projectTransactionOwnerPending;
|
|
341
|
+
try {
|
|
342
|
+
const created = await pendingOwner.pending;
|
|
343
|
+
if (generation !== projectTransactionGeneration || projectRoot !== pendingOwner.root) {
|
|
344
|
+
created.retired = true;
|
|
345
|
+
closeRetiredProjectTransactionOwner(created);
|
|
346
|
+
throw new Error("Desktop project grant changed while project transactions were opening");
|
|
347
|
+
}
|
|
348
|
+
projectTransactionOwner = created;
|
|
349
|
+
return created;
|
|
350
|
+
} finally {
|
|
351
|
+
if (projectTransactionOwnerPending === pendingOwner) projectTransactionOwnerPending = null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function withProjectTransactionOperation(owner, action) {
|
|
356
|
+
owner.activeOperations += 1;
|
|
357
|
+
try {
|
|
358
|
+
return await action(owner.controller);
|
|
359
|
+
} finally {
|
|
360
|
+
owner.activeOperations -= 1;
|
|
361
|
+
closeRetiredProjectTransactionOwner(owner);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function allocateProjectChangeHandle() {
|
|
366
|
+
let candidate = nextProjectChangeHandle;
|
|
367
|
+
for (let attempts = 0; attempts <= MAX_PROJECT_CHANGE_HANDLES; attempts += 1) {
|
|
368
|
+
if (!projectChangeHandles.has(candidate)) {
|
|
369
|
+
nextProjectChangeHandle = candidate >= 3_000_000_015 ? 3_000_000_000 : candidate + 1;
|
|
370
|
+
return candidate;
|
|
371
|
+
}
|
|
372
|
+
candidate = candidate >= 3_000_000_015 ? 3_000_000_000 : candidate + 1;
|
|
373
|
+
}
|
|
374
|
+
throw new RangeError("Desktop project change handle space is unavailable");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function projectChangeUpdate(task) {
|
|
378
|
+
const changes = [...task.queue.values()];
|
|
379
|
+
const update = { changes, rescan: task.rescan };
|
|
380
|
+
task.queue.clear();
|
|
381
|
+
task.queuedBytes = 0;
|
|
382
|
+
task.rescan = false;
|
|
383
|
+
return update;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function settleProjectChangeSubscription(task) {
|
|
387
|
+
if (task.pending === null || !task.rescan && task.queue.size === 0) return;
|
|
388
|
+
const pending = task.pending;
|
|
389
|
+
task.pending = null;
|
|
390
|
+
pending.activity.cancel = null;
|
|
391
|
+
pending.resolve(projectChangeUpdate(task));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function enqueueProjectChange(task, value) {
|
|
395
|
+
if (task.closed || task.rescan) return;
|
|
396
|
+
try {
|
|
397
|
+
const change = desktopProjectChangeView(value);
|
|
398
|
+
const bytes = Buffer.byteLength(JSON.stringify(change), "utf8");
|
|
399
|
+
const previous = task.queue.get(change.transactionId);
|
|
400
|
+
if (previous !== undefined) task.queuedBytes -= Buffer.byteLength(JSON.stringify(previous), "utf8");
|
|
401
|
+
if (!task.queue.has(change.transactionId) && task.queue.size >= MAX_PROJECT_CHANGE_QUEUE_ITEMS
|
|
402
|
+
|| task.queuedBytes + bytes > MAX_PROJECT_CHANGE_QUEUE_BYTES) {
|
|
403
|
+
task.queue.clear();
|
|
404
|
+
task.queuedBytes = 0;
|
|
405
|
+
task.rescan = true;
|
|
406
|
+
} else {
|
|
407
|
+
task.queue.set(change.transactionId, change);
|
|
408
|
+
task.queuedBytes += bytes;
|
|
409
|
+
}
|
|
410
|
+
} catch {
|
|
411
|
+
task.queue.clear();
|
|
412
|
+
task.queuedBytes = 0;
|
|
413
|
+
task.rescan = true;
|
|
414
|
+
}
|
|
415
|
+
settleProjectChangeSubscription(task);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function releaseProjectChangeHandle(task, error = null) {
|
|
419
|
+
if (task.closed) return false;
|
|
420
|
+
task.closed = true;
|
|
421
|
+
projectChangeHandles.delete(task.handle);
|
|
422
|
+
task.unsubscribe();
|
|
423
|
+
if (task.pending !== null) {
|
|
424
|
+
const pending = task.pending;
|
|
425
|
+
task.pending = null;
|
|
426
|
+
pending.activity.cancel = null;
|
|
427
|
+
if (error === null) pending.resolve(null);
|
|
428
|
+
else pending.reject(error);
|
|
429
|
+
}
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function ownedProjectChangeHandle(value, owner) {
|
|
434
|
+
const handle = integer(value, 3_000_000_000, 3_000_000_015, "ProjectChanges handle");
|
|
435
|
+
const task = projectChangeHandles.get(handle);
|
|
436
|
+
if (!task || task.closed) throw new Error("Desktop ProjectChanges handle is unknown or already released");
|
|
437
|
+
if (task.owner !== owner) throw new Error("Desktop ProjectChanges handle belongs to another document generation");
|
|
438
|
+
return task;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function startProjectChanges(args, owner) {
|
|
442
|
+
if (args.length !== 0) throw new TypeError("projectChanges start arguments are invalid");
|
|
443
|
+
if (projectChangeHandles.size >= MAX_PROJECT_CHANGE_HANDLES) throw new RangeError("Desktop cannot own more than 16 project change handles");
|
|
444
|
+
const transactionOwner = await currentProjectTransactionOwner();
|
|
445
|
+
const handle = allocateProjectChangeHandle();
|
|
446
|
+
const task = {
|
|
447
|
+
handle,
|
|
448
|
+
owner,
|
|
449
|
+
transactionOwner,
|
|
450
|
+
queue: new Map(),
|
|
451
|
+
queuedBytes: 0,
|
|
452
|
+
rescan: false,
|
|
453
|
+
pending: null,
|
|
454
|
+
closed: false,
|
|
455
|
+
unsubscribe: null,
|
|
456
|
+
};
|
|
457
|
+
task.unsubscribe = transactionOwner.controller.subscribe((change) => enqueueProjectChange(task, change));
|
|
458
|
+
projectChangeHandles.set(handle, task);
|
|
459
|
+
return handle;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function listProjectChanges(args, owner) {
|
|
463
|
+
if (args.length !== 2) throw new TypeError("ProjectChanges.list arguments are invalid");
|
|
464
|
+
const task = ownedProjectChangeHandle(args[0], owner);
|
|
465
|
+
const limit = integer(args[1], 1, 100, "ProjectChanges.list limit");
|
|
466
|
+
return desktopProjectChangePage(task.transactionOwner.controller.list({ limit: limit + 1 }), limit);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function getProjectChange(args, owner) {
|
|
470
|
+
if (args.length !== 2) throw new TypeError("ProjectChanges.get arguments are invalid");
|
|
471
|
+
const task = ownedProjectChangeHandle(args[0], owner);
|
|
472
|
+
const change = task.transactionOwner.controller.get(args[1]);
|
|
473
|
+
return change === undefined ? null : desktopProjectChangeView(change);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function subscribeProjectChanges(args, owner, activity) {
|
|
477
|
+
if (args.length !== 1) throw new TypeError("ProjectChanges.subscribe arguments are invalid");
|
|
478
|
+
const task = ownedProjectChangeHandle(args[0], owner);
|
|
479
|
+
if (task.pending !== null) throw new Error("ProjectChanges.subscribe already has an active pull");
|
|
480
|
+
if (task.rescan || task.queue.size > 0) return projectChangeUpdate(task);
|
|
481
|
+
return new Promise((resolveNext, rejectNext) => {
|
|
482
|
+
const pending = { resolve: resolveNext, reject: rejectNext, activity };
|
|
483
|
+
task.pending = pending;
|
|
484
|
+
setActivityCancellation(activity, () => {
|
|
485
|
+
if (task.pending !== pending) return;
|
|
486
|
+
task.pending = null;
|
|
487
|
+
pending.reject(new Error("Desktop project change subscription was cancelled"));
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function mutateProjectChange(args, owner, operation) {
|
|
493
|
+
if (args.length !== 2) throw new TypeError(`ProjectChanges.${operation} arguments are invalid`);
|
|
494
|
+
const task = ownedProjectChangeHandle(args[0], owner);
|
|
495
|
+
const input = { transactionId: args[1] };
|
|
496
|
+
await withProjectTransactionOperation(task.transactionOwner, (controller) => controller[operation](input));
|
|
497
|
+
const change = task.transactionOwner.controller.get(args[1]);
|
|
498
|
+
if (change === undefined) throw new Error(`Project transaction disappeared after ${operation}`);
|
|
499
|
+
return desktopProjectChangeView(change);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function projectChangeOperation(operation, args, owner, activity) {
|
|
503
|
+
if (operation === "start") return startProjectChanges(args, owner);
|
|
504
|
+
if (operation === "list") return listProjectChanges(args, owner);
|
|
505
|
+
if (operation === "get") return getProjectChange(args, owner);
|
|
506
|
+
if (operation === "subscribe") return subscribeProjectChanges(args, owner, activity);
|
|
507
|
+
if (operation === "apply") return mutateProjectChange(args, owner, "apply");
|
|
508
|
+
if (operation === "rollback") return mutateProjectChange(args, owner, "rollback");
|
|
509
|
+
if (operation === "close") {
|
|
510
|
+
if (args.length !== 1) throw new TypeError("ProjectChanges.close arguments are invalid");
|
|
511
|
+
releaseProjectChangeHandle(ownedProjectChangeHandle(args[0], owner));
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
throw new Error(`Unknown project-changes operation '${operation}'`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function allocateFileWatcherHandle() {
|
|
518
|
+
let candidate = nextFileWatcherHandle;
|
|
519
|
+
for (let attempts = 0; attempts <= MAX_FILE_WATCHERS; attempts += 1) {
|
|
520
|
+
if (!fileWatchers.has(candidate)) {
|
|
521
|
+
nextFileWatcherHandle = candidate >= Number.MAX_SAFE_INTEGER ? 1 : candidate + 1;
|
|
522
|
+
return candidate;
|
|
523
|
+
}
|
|
524
|
+
candidate = candidate >= Number.MAX_SAFE_INTEGER ? 1 : candidate + 1;
|
|
525
|
+
}
|
|
526
|
+
throw new RangeError("Desktop file watcher handle space is unavailable");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function releaseFileWatcher(task, error = null) {
|
|
530
|
+
if (task.closed) return false;
|
|
531
|
+
task.closed = true;
|
|
532
|
+
if (task.timer !== null) { clearTimeout(task.timer); task.timer = null; }
|
|
533
|
+
try { task.watcher.close(); } catch {}
|
|
534
|
+
fileWatchers.delete(task.handle);
|
|
535
|
+
if (task.pending !== null) {
|
|
536
|
+
const pending = task.pending;
|
|
537
|
+
task.pending = null;
|
|
538
|
+
pending.activity.cancel = null;
|
|
539
|
+
if (error === null) pending.resolve(null);
|
|
540
|
+
else pending.reject(error);
|
|
541
|
+
}
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function fileWatchBatch(task) {
|
|
546
|
+
if (task.rescan) {
|
|
547
|
+
task.rescan = false;
|
|
548
|
+
task.paths.clear();
|
|
549
|
+
task.units = 0;
|
|
550
|
+
return { paths: [], rescan: true };
|
|
551
|
+
}
|
|
552
|
+
const paths = [...task.paths].sort();
|
|
553
|
+
task.paths.clear();
|
|
554
|
+
task.units = 0;
|
|
555
|
+
return { paths, rescan: false };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function settleFileWatch(task) {
|
|
559
|
+
if (task.pending === null || task.timer !== null || task.failure !== null || !task.rescan && task.paths.size === 0) return;
|
|
560
|
+
task.timer = setTimeout(() => {
|
|
561
|
+
task.timer = null;
|
|
562
|
+
if (task.pending === null || task.closed) return;
|
|
563
|
+
const pending = task.pending;
|
|
564
|
+
task.pending = null;
|
|
565
|
+
pending.activity.cancel = null;
|
|
566
|
+
pending.resolve(fileWatchBatch(task));
|
|
567
|
+
}, WATCH_DEBOUNCE_MS);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function rescanFileWatch(task) {
|
|
571
|
+
task.rescan = true;
|
|
572
|
+
task.paths.clear();
|
|
573
|
+
task.units = 0;
|
|
574
|
+
settleFileWatch(task);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function enqueueFileWatch(task, filename) {
|
|
578
|
+
if (task.closed || task.failure !== null || task.rescan) return;
|
|
579
|
+
let path;
|
|
580
|
+
if (!task.directory) path = task.root;
|
|
581
|
+
else {
|
|
582
|
+
if (typeof filename !== "string" || filename.length === 0 || filename.length > MAX_PATH_UNITS || filename.includes("\0") || isAbsolute(filename)) {
|
|
583
|
+
rescanFileWatch(task);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
path = resolve(task.root, filename);
|
|
587
|
+
if (!contained(task.root, path) || path.length > MAX_PATH_UNITS) {
|
|
588
|
+
rescanFileWatch(task);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (!task.paths.has(path)) {
|
|
593
|
+
if (task.paths.size >= MAX_WATCH_PATHS || task.units + path.length > MAX_WATCH_TEXT_UNITS) {
|
|
594
|
+
rescanFileWatch(task);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
task.paths.add(path);
|
|
598
|
+
task.units += path.length;
|
|
599
|
+
}
|
|
600
|
+
settleFileWatch(task);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function failFileWatch(task, error) {
|
|
604
|
+
if (task.closed || task.failure !== null) return;
|
|
605
|
+
task.failure = error instanceof Error ? error : new Error("Desktop file watcher failed");
|
|
606
|
+
if (task.timer !== null) { clearTimeout(task.timer); task.timer = null; }
|
|
607
|
+
try { task.watcher.close(); } catch {}
|
|
608
|
+
if (task.pending !== null) releaseFileWatcher(task, task.failure);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function startFileWatch(args, owner) {
|
|
612
|
+
if (args.length !== 2 || typeof args[1] !== "boolean") throw new TypeError("watchStart arguments are invalid");
|
|
613
|
+
if (fileWatchers.size >= MAX_FILE_WATCHERS) throw new RangeError("Desktop host cannot own more than 128 file watchers");
|
|
614
|
+
const root = await authorizedExisting(args[0]);
|
|
615
|
+
const metadata = await stat(root);
|
|
616
|
+
const directory = metadata.isDirectory();
|
|
617
|
+
if (!directory && !metadata.isFile()) throw new TypeError("watchFiles requires a file or directory path");
|
|
618
|
+
if (args[1] && !directory) throw new TypeError("recursive watchFiles requires a directory path");
|
|
619
|
+
const handle = allocateFileWatcherHandle();
|
|
620
|
+
const task = { handle, owner, root, directory, watcher: null, paths: new Set(), units: 0, rescan: false, pending: null, timer: null, failure: null, closed: false };
|
|
621
|
+
task.watcher = watchNode(root, { recursive: args[1], encoding: "utf8", persistent: true }, (_event, filename) => enqueueFileWatch(task, filename));
|
|
622
|
+
task.watcher.once("error", error => failFileWatch(task, error));
|
|
623
|
+
fileWatchers.set(handle, task);
|
|
624
|
+
return handle;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function ownedFileWatcher(value, owner) {
|
|
628
|
+
const handle = integer(value, 1, Number.MAX_SAFE_INTEGER, "Desktop file watcher handle");
|
|
629
|
+
const task = fileWatchers.get(handle);
|
|
630
|
+
if (!task) throw new Error("Desktop file watcher handle is unknown or already released");
|
|
631
|
+
if (task.owner !== owner) throw new Error("Desktop file watcher belongs to another document generation");
|
|
632
|
+
return task;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function nextFileWatch(args, owner, activity) {
|
|
636
|
+
if (args.length !== 1) throw new TypeError("watchNext arguments are invalid");
|
|
637
|
+
const task = ownedFileWatcher(args[0], owner);
|
|
638
|
+
if (task.pending !== null) throw new Error("FileWatcher.next already has an active pull");
|
|
639
|
+
if (task.failure !== null) {
|
|
640
|
+
const failure = task.failure;
|
|
641
|
+
releaseFileWatcher(task);
|
|
642
|
+
throw failure;
|
|
643
|
+
}
|
|
644
|
+
if (task.rescan || task.paths.size > 0) return fileWatchBatch(task);
|
|
645
|
+
return new Promise((resolveNext, rejectNext) => {
|
|
646
|
+
const pending = { resolve: resolveNext, reject: rejectNext, activity };
|
|
647
|
+
task.pending = pending;
|
|
648
|
+
setActivityCancellation(activity, () => {
|
|
649
|
+
if (task.pending === pending) releaseFileWatcher(task, new Error("Desktop file watcher pull was cancelled"));
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function closeFileWatchHandle(args, owner) {
|
|
655
|
+
if (args.length !== 1) throw new TypeError("watchClose arguments are invalid");
|
|
656
|
+
const handle = integer(args[0], 1, Number.MAX_SAFE_INTEGER, "Desktop file watcher handle");
|
|
657
|
+
const task = fileWatchers.get(handle);
|
|
658
|
+
if (!task) return false;
|
|
659
|
+
if (task.owner !== owner) throw new Error("Desktop file watcher belongs to another document generation");
|
|
660
|
+
return releaseFileWatcher(task);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function allocateLanguageServerHandle() {
|
|
664
|
+
let candidate = nextLanguageServerHandle;
|
|
665
|
+
for (let attempts = 0; attempts <= MAX_LANGUAGE_SERVERS; attempts += 1) {
|
|
666
|
+
if (!languageServers.has(candidate)) {
|
|
667
|
+
nextLanguageServerHandle = candidate >= 1_000_000_003 ? 1_000_000_000 : candidate + 1;
|
|
668
|
+
return candidate;
|
|
669
|
+
}
|
|
670
|
+
candidate = candidate >= 1_000_000_003 ? 1_000_000_000 : candidate + 1;
|
|
671
|
+
}
|
|
672
|
+
throw new RangeError("Desktop language-server handle space is unavailable");
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function ownedLanguageServer(value, owner) {
|
|
676
|
+
const handle = integer(value, 1, Number.MAX_SAFE_INTEGER, "Desktop language-server handle");
|
|
677
|
+
const task = languageServers.get(handle);
|
|
678
|
+
if (!task) throw new Error("Desktop language-server handle is unknown or already released");
|
|
679
|
+
if (task.owner !== owner) throw new Error("Desktop language server belongs to another document generation");
|
|
680
|
+
return task;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function settleLanguageServerPull(task) {
|
|
684
|
+
if (task.waiter === null) return;
|
|
685
|
+
if (task.queue.length > 0) {
|
|
686
|
+
const waiter = task.waiter;
|
|
687
|
+
task.waiter = null;
|
|
688
|
+
waiter.activity.cancel = null;
|
|
689
|
+
const value = task.queue.shift();
|
|
690
|
+
task.queuedBytes -= Buffer.byteLength(value, "utf8");
|
|
691
|
+
waiter.resolve(value);
|
|
692
|
+
} else if (task.failure || task.settled) {
|
|
693
|
+
const waiter = task.waiter;
|
|
694
|
+
task.waiter = null;
|
|
695
|
+
waiter.activity.cancel = null;
|
|
696
|
+
if (task.failure) waiter.reject(task.failure);
|
|
697
|
+
else waiter.resolve(null);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function failLanguageServer(task, error) {
|
|
702
|
+
if (!task.failure) task.failure = error instanceof Error ? error : new Error("Bundled language server failed");
|
|
703
|
+
settleLanguageServerPull(task);
|
|
704
|
+
signalTree(task.child, "SIGKILL");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function enqueueLanguageServerMessage(task, body) {
|
|
708
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
709
|
+
if (bytes < 1 || bytes > MAX_LANGUAGE_SERVER_MESSAGE_BYTES) {
|
|
710
|
+
failLanguageServer(task, new RangeError("Bundled language-server message exceeds 16 MiB"));
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (task.waiter !== null) {
|
|
714
|
+
const waiter = task.waiter;
|
|
715
|
+
task.waiter = null;
|
|
716
|
+
waiter.activity.cancel = null;
|
|
717
|
+
waiter.resolve(body);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (task.queuedBytes + bytes > MAX_LANGUAGE_SERVER_QUEUED_BYTES) {
|
|
721
|
+
failLanguageServer(task, new RangeError("Bundled language-server queue exceeds 64 MiB"));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
task.queue.push(body);
|
|
725
|
+
task.queuedBytes += bytes;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function parseLanguageServerOutput(task, chunk) {
|
|
729
|
+
task.buffer = Buffer.concat([task.buffer, chunk]);
|
|
730
|
+
while (!task.failure) {
|
|
731
|
+
const boundary = task.buffer.indexOf("\r\n\r\n");
|
|
732
|
+
if (boundary === -1) {
|
|
733
|
+
if (task.buffer.byteLength > 64 * 1024) failLanguageServer(task, new RangeError("Bundled language-server header exceeds 64 KiB"));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (boundary > 64 * 1024) { failLanguageServer(task, new RangeError("Bundled language-server header exceeds 64 KiB")); return; }
|
|
737
|
+
const header = task.buffer.subarray(0, boundary).toString("ascii");
|
|
738
|
+
const match = /(?:^|\r\n)Content-Length:\s*(\d+)/iu.exec(header);
|
|
739
|
+
if (!match) { failLanguageServer(task, new Error("Bundled language server emitted an invalid frame")); return; }
|
|
740
|
+
const size = Number(match[1]);
|
|
741
|
+
if (!Number.isSafeInteger(size) || size < 1 || size > MAX_LANGUAGE_SERVER_MESSAGE_BYTES) {
|
|
742
|
+
failLanguageServer(task, new RangeError("Bundled language-server message exceeds 16 MiB"));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const end = boundary + 4 + size;
|
|
746
|
+
if (task.buffer.byteLength < end) return;
|
|
747
|
+
const body = task.buffer.subarray(boundary + 4, end).toString("utf8");
|
|
748
|
+
task.buffer = task.buffer.subarray(end);
|
|
749
|
+
try { JSON.parse(body); }
|
|
750
|
+
catch { failLanguageServer(task, new Error("Bundled language server emitted invalid JSON")); return; }
|
|
751
|
+
enqueueLanguageServerMessage(task, body);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function startLanguageServer(args, owner, activity) {
|
|
756
|
+
if (args.length !== 0) throw new TypeError("languageServer start arguments are invalid");
|
|
757
|
+
if (languageServerPath === null) throw new Error("This Desktop package does not contain an official language server");
|
|
758
|
+
if (projectRoot === null || !fileScopes.has("project")) throw new Error("Desktop language services require the project file grant");
|
|
759
|
+
if (languageServers.size >= MAX_LANGUAGE_SERVERS) throw new RangeError("Desktop cannot own more than 4 language servers");
|
|
760
|
+
const handle = allocateLanguageServerHandle();
|
|
761
|
+
const environment = Object.create(null);
|
|
762
|
+
for (const name of ["HOME", "LANG", "LC_ALL", "PATH", "TMPDIR"]) {
|
|
763
|
+
if (typeof process.env[name] === "string") environment[name] = process.env[name];
|
|
764
|
+
}
|
|
765
|
+
environment.VELAR_LANGUAGE_SERVER_WORKSPACE_ROOT = projectLexicalRoot ?? projectRoot;
|
|
766
|
+
environment.VELAR_LANGUAGE_SERVER_CANONICAL_ROOT = projectRoot;
|
|
767
|
+
const child = spawn(process.execPath, [languageServerPath], {
|
|
768
|
+
cwd: projectRoot,
|
|
769
|
+
env: environment,
|
|
770
|
+
shell: false,
|
|
771
|
+
windowsHide: true,
|
|
772
|
+
detached: process.platform !== "win32",
|
|
773
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
774
|
+
});
|
|
775
|
+
let resolveClosed;
|
|
776
|
+
const closed = new Promise(resolve => { resolveClosed = resolve; });
|
|
777
|
+
const task = { handle, owner, child, buffer: Buffer.alloc(0), queue: [], queuedBytes: 0, waiter: null, stderr: "", settled: false, failure: null, ownershipPublished: false, closed, resolveClosed };
|
|
778
|
+
languageServers.set(handle, task);
|
|
779
|
+
setActivityCancellation(activity, () => releaseLanguageServer(task, new Error("Desktop language-server start was cancelled")));
|
|
780
|
+
child.stdout.on("data", chunk => parseLanguageServerOutput(task, chunk));
|
|
781
|
+
child.stderr.on("data", chunk => {
|
|
782
|
+
if (task.stderr.length < 64 * 1024) task.stderr += chunk.toString("utf8").slice(0, 64 * 1024 - task.stderr.length);
|
|
783
|
+
});
|
|
784
|
+
child.once("error", error => failLanguageServer(task, error));
|
|
785
|
+
child.once("close", (code, signal) => {
|
|
786
|
+
task.settled = true;
|
|
787
|
+
task.resolveClosed();
|
|
788
|
+
if (!task.failure && code !== 0) task.failure = new Error(`Bundled language server exited with ${code === null ? signal ?? "an unknown signal" : `code ${code}`}${task.stderr ? `: ${task.stderr}` : ""}`);
|
|
789
|
+
settleLanguageServerPull(task);
|
|
790
|
+
if (task.ownershipPublished) respond({ protocolVersion: 1, hostEvent: "language-server-settled", owner, handle });
|
|
791
|
+
});
|
|
792
|
+
try {
|
|
793
|
+
await new Promise((resolveStart, rejectStart) => {
|
|
794
|
+
child.once("spawn", resolveStart);
|
|
795
|
+
child.once("error", rejectStart);
|
|
796
|
+
});
|
|
797
|
+
} catch (error) {
|
|
798
|
+
releaseLanguageServer(task, error instanceof Error ? error : new Error("Bundled language server failed to start"));
|
|
799
|
+
throw error;
|
|
800
|
+
}
|
|
801
|
+
activity.cancel = null;
|
|
802
|
+
task.ownershipPublished = true;
|
|
803
|
+
respond({ protocolVersion: 1, hostEvent: "language-server-owned", owner, handle, pid: child.pid });
|
|
804
|
+
if (activity.cancelled || owner !== activeOwner) {
|
|
805
|
+
releaseLanguageServer(task, new Error("Desktop document generation is no longer active"));
|
|
806
|
+
throw new Error("Desktop document generation is no longer active");
|
|
807
|
+
}
|
|
808
|
+
return handle;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function sendLanguageServer(args, owner) {
|
|
812
|
+
if (args.length !== 2 || typeof args[1] !== "string") throw new TypeError("languageServer send arguments are invalid");
|
|
813
|
+
const task = ownedLanguageServer(args[0], owner);
|
|
814
|
+
if (task.failure) throw task.failure;
|
|
815
|
+
if (task.settled || !task.child.stdin.writable) throw new Error("Bundled language server is closed");
|
|
816
|
+
const bytes = Buffer.byteLength(args[1], "utf8");
|
|
817
|
+
if (bytes < 1 || bytes > MAX_LANGUAGE_SERVER_MESSAGE_BYTES) throw new RangeError("Language-server request exceeds 16 MiB");
|
|
818
|
+
try { JSON.parse(args[1]); }
|
|
819
|
+
catch { throw new TypeError("Language-server request must contain valid JSON"); }
|
|
820
|
+
const frame = `Content-Length: ${bytes}\r\n\r\n${args[1]}`;
|
|
821
|
+
try {
|
|
822
|
+
await boundedHostWait(
|
|
823
|
+
new Promise((resolveWrite, rejectWrite) => task.child.stdin.write(frame, error => error ? rejectWrite(error) : resolveWrite())),
|
|
824
|
+
`Bundled language server did not accept a request within ${HOST_PIPE_WRITE_TIMEOUT_MS} milliseconds`,
|
|
825
|
+
HOST_PIPE_WRITE_TIMEOUT_MS,
|
|
826
|
+
);
|
|
827
|
+
} catch (error) {
|
|
828
|
+
// A half-written frame leaves the protocol stream unusable, so a write that
|
|
829
|
+
// never drains retires the server instead of corrupting later requests.
|
|
830
|
+
if (error instanceof HostDeadlineFailure) failLanguageServer(task, error);
|
|
831
|
+
throw error;
|
|
832
|
+
}
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function nextLanguageServer(args, owner, activity) {
|
|
837
|
+
if (args.length !== 1) throw new TypeError("languageServer next arguments are invalid");
|
|
838
|
+
const task = ownedLanguageServer(args[0], owner);
|
|
839
|
+
if (task.waiter !== null) throw new Error("LanguageServer.next already has an active pull");
|
|
840
|
+
if (task.queue.length > 0) {
|
|
841
|
+
const value = task.queue.shift();
|
|
842
|
+
task.queuedBytes -= Buffer.byteLength(value, "utf8");
|
|
843
|
+
return value;
|
|
844
|
+
}
|
|
845
|
+
if (task.failure) throw task.failure;
|
|
846
|
+
if (task.settled) return null;
|
|
847
|
+
return new Promise((resolveNext, rejectNext) => {
|
|
848
|
+
const waiter = { resolve: resolveNext, reject: rejectNext, activity };
|
|
849
|
+
task.waiter = waiter;
|
|
850
|
+
setActivityCancellation(activity, () => {
|
|
851
|
+
if (task.waiter !== waiter) return;
|
|
852
|
+
task.waiter = null;
|
|
853
|
+
waiter.reject(new Error("Desktop language-server pull was cancelled"));
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function releaseLanguageServer(task, error = null) {
|
|
859
|
+
if (languageServers.get(task.handle) !== task) return false;
|
|
860
|
+
languageServers.delete(task.handle);
|
|
861
|
+
if (task.waiter !== null) {
|
|
862
|
+
const waiter = task.waiter;
|
|
863
|
+
task.waiter = null;
|
|
864
|
+
waiter.activity.cancel = null;
|
|
865
|
+
if (error) waiter.reject(error);
|
|
866
|
+
else waiter.resolve(null);
|
|
867
|
+
}
|
|
868
|
+
try { task.child.stdin.end(); } catch {}
|
|
869
|
+
if (error) signalTree(task.child, "SIGTERM");
|
|
870
|
+
else setTimeout(() => { if (!task.settled) signalTree(task.child, "SIGTERM"); }, 500).unref();
|
|
871
|
+
setTimeout(() => { if (!task.settled) signalTree(task.child, "SIGKILL"); }, 2500).unref();
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function closeLanguageServer(args, owner) {
|
|
876
|
+
if (args.length !== 1) throw new TypeError("languageServer close arguments are invalid");
|
|
877
|
+
const task = ownedLanguageServer(args[0], owner);
|
|
878
|
+
releaseLanguageServer(task);
|
|
879
|
+
let timer = null;
|
|
880
|
+
try {
|
|
881
|
+
const closed = await Promise.race([
|
|
882
|
+
task.closed.then(() => true),
|
|
883
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(false), 5000); }),
|
|
884
|
+
]);
|
|
885
|
+
if (!closed) {
|
|
886
|
+
signalTree(task.child, "SIGKILL");
|
|
887
|
+
throw new Error("Bundled language server did not close within 5000 milliseconds");
|
|
888
|
+
}
|
|
889
|
+
} finally {
|
|
890
|
+
if (timer !== null) clearTimeout(timer);
|
|
891
|
+
}
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function languageServerOperation(operation, args, owner, activity) {
|
|
896
|
+
if (operation === "start") return startLanguageServer(args, owner, activity);
|
|
897
|
+
if (operation === "send") return sendLanguageServer(args, owner);
|
|
898
|
+
if (operation === "next") return nextLanguageServer(args, owner, activity);
|
|
899
|
+
if (operation === "close") return closeLanguageServer(args, owner);
|
|
900
|
+
throw new Error(`Unknown language-server operation '${operation}'`);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function fsOperation(operation, args, owner, activity) {
|
|
904
|
+
if (operation === "watchStart") return startFileWatch(args, owner);
|
|
905
|
+
if (operation === "watchNext") return nextFileWatch(args, owner, activity);
|
|
906
|
+
if (operation === "watchClose") return closeFileWatchHandle(args, owner);
|
|
907
|
+
if (operation === "readText") {
|
|
908
|
+
const [path, maximum = MAX_FILE_BYTES] = args;
|
|
909
|
+
const data = await boundedFile(path, maximum, "readText");
|
|
910
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
911
|
+
}
|
|
912
|
+
if (operation === "exists") {
|
|
913
|
+
try { await authorizedExisting(args[0]); return true; }
|
|
914
|
+
catch (error) { if (error?.code === "ENOENT") return false; throw error; }
|
|
915
|
+
}
|
|
916
|
+
if (operation === "list") {
|
|
917
|
+
const maximum = integer(args[1] ?? 100000, 1, 100000, "list maxItems");
|
|
918
|
+
const names = await readdir(await authorizedExisting(args[0]));
|
|
919
|
+
if (names.length > maximum) throw new RangeError("list result exceeds maxItems");
|
|
920
|
+
let units = 0;
|
|
921
|
+
for (const name of names) {
|
|
922
|
+
units += name.length;
|
|
923
|
+
if (units > MAX_LIST_TEXT_UNITS) throw new RangeError("list result cannot exceed 2 MiB of text");
|
|
924
|
+
}
|
|
925
|
+
return names.sort();
|
|
926
|
+
}
|
|
927
|
+
if (operation === "info") {
|
|
928
|
+
let path;
|
|
929
|
+
try { path = await authorizedEntry(args[0]); }
|
|
930
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
931
|
+
const metadata = await lstat(path);
|
|
932
|
+
return { name: basename(path), kind: metadata.isFile() ? "file" : metadata.isDirectory() ? "directory" : metadata.isSymbolicLink() ? "symlink" : "other", size: metadata.size, modifiedAt: metadata.mtimeMs };
|
|
933
|
+
}
|
|
934
|
+
if (operation === "canonical") return authorizedExisting(args[0]);
|
|
935
|
+
if (operation === "makeDirectory") { await mkdir(await authorizedDirectoryTarget(args[0]), { recursive: true }); return null; }
|
|
936
|
+
if (operation === "createText") {
|
|
937
|
+
const path = await authorizedTarget(args[0]);
|
|
938
|
+
const text = textValue(args[1], "createText text", MAX_FILE_BYTES);
|
|
939
|
+
return withFileMutations([path], async () => {
|
|
940
|
+
try { await writeFile(path, text, {encoding: "utf8", flag: "wx"}); }
|
|
941
|
+
catch (error) {
|
|
942
|
+
if (error?.code === "EEXIST") throw new Error("createText target already exists");
|
|
943
|
+
throw error;
|
|
944
|
+
}
|
|
945
|
+
return null;
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
if (operation === "replaceTextIfMatches") {
|
|
949
|
+
const path = await authorizedExisting(args[0]);
|
|
950
|
+
const expected = Buffer.from(textValue(args[1], "replaceTextIfMatches expected text", MAX_FILE_BYTES), "utf8");
|
|
951
|
+
const replacement = Buffer.from(textValue(args[2], "replaceTextIfMatches replacement text", MAX_FILE_BYTES), "utf8");
|
|
952
|
+
return withFileMutations([path], async () => {
|
|
953
|
+
const metadata = await stat(path);
|
|
954
|
+
if (!metadata.isFile()) throw new TypeError("replaceTextIfMatches requires a file path");
|
|
955
|
+
if (metadata.size > MAX_FILE_BYTES) throw new RangeError("replaceTextIfMatches file exceeds 16 MiB");
|
|
956
|
+
const current = await readFile(path);
|
|
957
|
+
if (current.byteLength > MAX_FILE_BYTES) throw new RangeError("replaceTextIfMatches file exceeds 16 MiB");
|
|
958
|
+
if (!current.equals(expected)) return false;
|
|
959
|
+
await commitTextReplacement(path, replacement, metadata.mode);
|
|
960
|
+
return true;
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
if (operation === "writeText" || operation === "appendText") {
|
|
964
|
+
const path = await authorizedTarget(args[0]);
|
|
965
|
+
const text = textValue(args[1], `${operation} text`, MAX_FILE_BYTES);
|
|
966
|
+
return withFileMutations([path], async () => {
|
|
967
|
+
const existing = await stat(path).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
968
|
+
if (existing && !existing.isFile()) throw new TypeError(`${operation} requires a file path`);
|
|
969
|
+
if (operation === "writeText") await writeFile(path, text, "utf8");
|
|
970
|
+
else {
|
|
971
|
+
if (existing && existing.size + Buffer.byteLength(text, "utf8") > MAX_FILE_BYTES) throw new RangeError("appendText result cannot exceed 16 MiB");
|
|
972
|
+
await appendFile(path, text, "utf8");
|
|
973
|
+
}
|
|
974
|
+
return null;
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
if (operation === "copyFile") {
|
|
978
|
+
const source = await authorizedExisting(args[0]);
|
|
979
|
+
if (!(await stat(source)).isFile()) throw new TypeError("copyFile requires a regular file source");
|
|
980
|
+
const target = await authorizedTarget(args[1]);
|
|
981
|
+
return withFileMutations([target], async () => {
|
|
982
|
+
if (args[2] !== true) await absent(target, "copyFile");
|
|
983
|
+
await copyNodeFile(source, target);
|
|
984
|
+
return null;
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
if (operation === "move") {
|
|
988
|
+
const source = await authorizedEntry(args[0]);
|
|
989
|
+
if (roots.includes(source)) throw new Error("move refuses a granted Desktop file root");
|
|
990
|
+
const target = await authorizedEntry(args[1], true);
|
|
991
|
+
return withFileMutations([source, target], async () => {
|
|
992
|
+
if (args[2] !== true) await absent(target, "move");
|
|
993
|
+
else await rm(target, { force: true, recursive: false });
|
|
994
|
+
await rename(source, target);
|
|
995
|
+
return null;
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
if (operation === "removeFile") {
|
|
999
|
+
const path = await authorizedEntry(args[0]);
|
|
1000
|
+
return withFileMutations([path], async () => {
|
|
1001
|
+
if ((await lstat(path)).isDirectory()) throw new TypeError("removeFile refuses directories");
|
|
1002
|
+
await rm(path, { recursive: false, force: false });
|
|
1003
|
+
return null;
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
throw new Error(`Unknown filesystem operation '${operation}'`);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async function boundedFile(path, maximum, operation) {
|
|
1010
|
+
maximum = integer(maximum, 1, MAX_FILE_BYTES, `${operation} maxBytes`);
|
|
1011
|
+
path = await authorizedExisting(path);
|
|
1012
|
+
const metadata = await stat(path);
|
|
1013
|
+
if (!metadata.isFile()) throw new TypeError(`${operation} requires a file path`);
|
|
1014
|
+
if (metadata.size > maximum) throw new RangeError(`${operation} file exceeds maxBytes`);
|
|
1015
|
+
const data = await readFile(path);
|
|
1016
|
+
if (data.byteLength > maximum) throw new RangeError(`${operation} file exceeds maxBytes`);
|
|
1017
|
+
return data;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function authorizedExisting(input) {
|
|
1021
|
+
const candidate = pathValue(input);
|
|
1022
|
+
// Authorize the lexical path before touching the filesystem so an
|
|
1023
|
+
// out-of-scope missing path cannot be disguised as an ordinary ENOENT.
|
|
1024
|
+
authorizeLexical(candidate);
|
|
1025
|
+
const canonical = await realpath(candidate);
|
|
1026
|
+
authorize(canonical);
|
|
1027
|
+
return canonical;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function authorizedTarget(input) {
|
|
1031
|
+
const candidate = pathValue(input);
|
|
1032
|
+
try { return await authorizedExisting(candidate); }
|
|
1033
|
+
catch (error) {
|
|
1034
|
+
if (error?.code !== "ENOENT") throw error;
|
|
1035
|
+
try {
|
|
1036
|
+
await lstat(candidate);
|
|
1037
|
+
throw new Error("Filesystem write target cannot be a dangling symbolic link");
|
|
1038
|
+
} catch (entryError) {
|
|
1039
|
+
if (entryError?.code !== "ENOENT") throw entryError;
|
|
1040
|
+
}
|
|
1041
|
+
const parent = await realpath(dirname(candidate));
|
|
1042
|
+
authorize(parent);
|
|
1043
|
+
return resolve(parent, basename(candidate));
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async function authorizedEntry(input, allowMissing = false) {
|
|
1048
|
+
const candidate = pathValue(input);
|
|
1049
|
+
authorizeLexical(candidate);
|
|
1050
|
+
for (let index = 0; index < lexicalRoots.length; index += 1) {
|
|
1051
|
+
if (candidate === lexicalRoots[index] || candidate === roots[index]) {
|
|
1052
|
+
if (!allowMissing) await lstat(roots[index]);
|
|
1053
|
+
return roots[index];
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const parent = await realpath(dirname(candidate));
|
|
1057
|
+
authorize(parent);
|
|
1058
|
+
const entry = resolve(parent, basename(candidate));
|
|
1059
|
+
authorize(entry);
|
|
1060
|
+
if (!allowMissing) await lstat(entry);
|
|
1061
|
+
return entry;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function authorizedDirectoryTarget(input) {
|
|
1065
|
+
const candidate = pathValue(input);
|
|
1066
|
+
authorizeLexical(candidate);
|
|
1067
|
+
const missing = [];
|
|
1068
|
+
let probe = candidate;
|
|
1069
|
+
while (true) {
|
|
1070
|
+
try {
|
|
1071
|
+
const metadata = await lstat(probe);
|
|
1072
|
+
if (metadata.isSymbolicLink() && missing.length === 0) throw new TypeError("makeDirectory refuses a symbolic-link target");
|
|
1073
|
+
const canonical = await realpath(probe);
|
|
1074
|
+
authorize(canonical);
|
|
1075
|
+
if (!(await stat(canonical)).isDirectory()) throw new TypeError("makeDirectory requires every existing ancestor to be a directory");
|
|
1076
|
+
let target = canonical;
|
|
1077
|
+
for (let index = missing.length - 1; index >= 0; index -= 1) target = resolve(target, missing[index]);
|
|
1078
|
+
authorize(target);
|
|
1079
|
+
return target;
|
|
1080
|
+
} catch (error) {
|
|
1081
|
+
if (error?.code !== "ENOENT") throw error;
|
|
1082
|
+
const parent = dirname(probe);
|
|
1083
|
+
if (parent === probe) throw error;
|
|
1084
|
+
missing.push(basename(probe));
|
|
1085
|
+
probe = parent;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function pathValue(value) {
|
|
1091
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_PATH_UNITS || value.includes("\0")) throw new TypeError("Filesystem operation requires a bounded path");
|
|
1092
|
+
const base = projectRoot ?? roots[0];
|
|
1093
|
+
if (!base) throw new Error("Desktop application has no granted filesystem scope");
|
|
1094
|
+
return isAbsolute(value) ? resolve(value) : resolve(base, value);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function authorize(path) {
|
|
1098
|
+
if (!roots.some((root) => contained(root, path))) throw new Error("Filesystem path is outside granted Desktop file roots");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function authorizeLexical(path) {
|
|
1102
|
+
if (![...lexicalRoots, ...roots].some((root) => contained(root, path))) throw new Error("Filesystem path is outside granted Desktop file roots");
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function contained(root, target) {
|
|
1106
|
+
const path = relative(root, target);
|
|
1107
|
+
return path === "" || (path !== ".." && !path.startsWith("../") && !isAbsolute(path));
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async function absent(path, operation) {
|
|
1111
|
+
try { await lstat(path); }
|
|
1112
|
+
catch (error) { if (error?.code === "ENOENT") return; throw error; }
|
|
1113
|
+
throw new Error(`${operation} target already exists`);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async function withFileMutations(paths, action) {
|
|
1117
|
+
const identities = [...new Set(paths.map(path => resolve(path)))].sort();
|
|
1118
|
+
const reservations = identities.map(identity => {
|
|
1119
|
+
const previous = fileMutationTails.get(identity) ?? null;
|
|
1120
|
+
let release;
|
|
1121
|
+
const tail = new Promise(resolveTail => { release = resolveTail; });
|
|
1122
|
+
fileMutationTails.set(identity, tail);
|
|
1123
|
+
return {identity, previous, release, tail};
|
|
1124
|
+
});
|
|
1125
|
+
await Promise.all(reservations.map(reservation => reservation.previous));
|
|
1126
|
+
try { return await action(); }
|
|
1127
|
+
finally {
|
|
1128
|
+
for (const reservation of reservations) {
|
|
1129
|
+
reservation.release();
|
|
1130
|
+
if (fileMutationTails.get(reservation.identity) === reservation.tail) fileMutationTails.delete(reservation.identity);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async function commitTextReplacement(path, data, mode) {
|
|
1136
|
+
let temporary = null;
|
|
1137
|
+
for (let attempts = 0; attempts < 1024; attempts += 1) {
|
|
1138
|
+
const identity = nextTextReplacementIdentity;
|
|
1139
|
+
nextTextReplacementIdentity = nextTextReplacementIdentity >= Number.MAX_SAFE_INTEGER ? 1 : nextTextReplacementIdentity + 1;
|
|
1140
|
+
const candidate = resolve(dirname(path), `.velar-replace-${identity}.tmp`);
|
|
1141
|
+
try {
|
|
1142
|
+
await writeFile(candidate, data, {flag: "wx", mode});
|
|
1143
|
+
temporary = candidate;
|
|
1144
|
+
break;
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
if (error?.code !== "EEXIST") throw error;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (temporary === null) throw new Error("replaceTextIfMatches could not allocate a temporary file");
|
|
1150
|
+
try { await rename(temporary, path); }
|
|
1151
|
+
finally { await rm(temporary, {force: true, recursive: false}); }
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function allocateTerminalHandle() {
|
|
1155
|
+
let candidate = nextTerminalHandle;
|
|
1156
|
+
for (let attempts = 0; attempts <= MAX_TERMINALS; attempts += 1) {
|
|
1157
|
+
if (!terminals.has(candidate)) {
|
|
1158
|
+
nextTerminalHandle = candidate >= 2_000_000_003 ? 2_000_000_000 : candidate + 1;
|
|
1159
|
+
return candidate;
|
|
1160
|
+
}
|
|
1161
|
+
candidate = candidate >= 2_000_000_003 ? 2_000_000_000 : candidate + 1;
|
|
1162
|
+
}
|
|
1163
|
+
throw new RangeError("Desktop terminal handle space is unavailable");
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function ownedTerminal(value, owner) {
|
|
1167
|
+
const handle = integer(value, 1, Number.MAX_SAFE_INTEGER, "Desktop terminal handle");
|
|
1168
|
+
const task = terminals.get(handle);
|
|
1169
|
+
if (!task) throw new Error("Desktop terminal handle is unknown or already released");
|
|
1170
|
+
if (task.owner !== owner) throw new Error("Desktop terminal belongs to another document generation");
|
|
1171
|
+
return task;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function terminalFrame(kind, payload = Buffer.alloc(0)) {
|
|
1175
|
+
if (!(payload instanceof Buffer) || payload.byteLength > MAX_TERMINAL_CHUNK_BYTES) throw new RangeError("Terminal frame exceeds 1 MiB");
|
|
1176
|
+
const frame = Buffer.allocUnsafe(5 + payload.byteLength);
|
|
1177
|
+
frame[0] = kind;
|
|
1178
|
+
frame.writeUInt32BE(payload.byteLength, 1);
|
|
1179
|
+
payload.copy(frame, 5);
|
|
1180
|
+
return frame;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
async function writeTerminalFrame(task, frame) {
|
|
1184
|
+
if (task.failure) throw task.failure;
|
|
1185
|
+
if (task.settled || task.closing || !task.child.stdin.writable) throw new Error("Desktop terminal is closed");
|
|
1186
|
+
try {
|
|
1187
|
+
await boundedHostWait(
|
|
1188
|
+
new Promise((resolveWrite, rejectWrite) => {
|
|
1189
|
+
task.child.stdin.write(frame, error => error ? rejectWrite(error) : resolveWrite(null));
|
|
1190
|
+
}),
|
|
1191
|
+
`Bundled terminal host did not accept a frame within ${HOST_PIPE_WRITE_TIMEOUT_MS} milliseconds`,
|
|
1192
|
+
HOST_PIPE_WRITE_TIMEOUT_MS,
|
|
1193
|
+
);
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
// A half-written frame desynchronizes the terminal transport, so a write
|
|
1196
|
+
// that never drains retires the session instead of corrupting it.
|
|
1197
|
+
if (error instanceof HostDeadlineFailure) failTerminal(task, error);
|
|
1198
|
+
throw error;
|
|
1199
|
+
}
|
|
1200
|
+
return null;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function settleTerminalPull(task) {
|
|
1204
|
+
if (task.waiter === null) return;
|
|
1205
|
+
if (task.queue.length > 0) {
|
|
1206
|
+
const waiter = task.waiter;
|
|
1207
|
+
task.waiter = null;
|
|
1208
|
+
waiter.activity.cancel = null;
|
|
1209
|
+
const value = task.queue.shift();
|
|
1210
|
+
task.queuedBytes -= Buffer.byteLength(value, "utf8");
|
|
1211
|
+
if (task.paused && task.queuedBytes <= TERMINAL_RESUME_BYTES) {
|
|
1212
|
+
task.paused = false;
|
|
1213
|
+
task.child.stdout.resume();
|
|
1214
|
+
}
|
|
1215
|
+
waiter.resolve(value);
|
|
1216
|
+
} else if (task.failure || task.settled) {
|
|
1217
|
+
const waiter = task.waiter;
|
|
1218
|
+
task.waiter = null;
|
|
1219
|
+
waiter.activity.cancel = null;
|
|
1220
|
+
if (task.failure) waiter.reject(task.failure);
|
|
1221
|
+
else { task.outputEnded = true; waiter.resolve(null); }
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function enqueueTerminalText(task, value) {
|
|
1226
|
+
if (value.length === 0) return;
|
|
1227
|
+
const bytes = Buffer.byteLength(value, "utf8");
|
|
1228
|
+
if (bytes > MAX_TERMINAL_CHUNK_BYTES || task.queuedBytes + bytes > MAX_TERMINAL_QUEUED_BYTES) {
|
|
1229
|
+
failTerminal(task, new RangeError("Desktop terminal output queue exceeded 4 MiB"));
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
if (task.waiter !== null) {
|
|
1233
|
+
const waiter = task.waiter;
|
|
1234
|
+
task.waiter = null;
|
|
1235
|
+
waiter.activity.cancel = null;
|
|
1236
|
+
waiter.resolve(value);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
task.queue.push(value);
|
|
1240
|
+
task.queuedBytes += bytes;
|
|
1241
|
+
if (!task.paused && task.queuedBytes >= TERMINAL_PAUSE_BYTES) {
|
|
1242
|
+
task.paused = true;
|
|
1243
|
+
task.child.stdout.pause();
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function signalTerminalTree(task, signal) {
|
|
1248
|
+
if (task.shellPid !== null) {
|
|
1249
|
+
try { process.kill(-task.shellPid, signal); }
|
|
1250
|
+
catch { try { process.kill(task.shellPid, signal); } catch {} }
|
|
1251
|
+
}
|
|
1252
|
+
signalTree(task.child, signal);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function failTerminal(task, error) {
|
|
1256
|
+
if (!task.failure) task.failure = error instanceof Error ? error : new Error("Desktop terminal failed");
|
|
1257
|
+
settleTerminalPull(task);
|
|
1258
|
+
signalTerminalTree(task, "SIGKILL");
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
async function terminalMetadata(task) {
|
|
1262
|
+
const stream = task.child.stdio[3];
|
|
1263
|
+
if (!stream) throw new Error("Bundled terminal host ownership channel is unavailable");
|
|
1264
|
+
let text = "";
|
|
1265
|
+
stream.setEncoding("utf8");
|
|
1266
|
+
// The ownership handshake is the one reply the host cannot proceed without,
|
|
1267
|
+
// so it carries its own bound: a terminal host that neither publishes its
|
|
1268
|
+
// shell nor exits fails this open instead of blocking it forever.
|
|
1269
|
+
const metadata = await boundedHostWait(new Promise((resolveMetadata, rejectMetadata) => {
|
|
1270
|
+
stream.on("data", chunk => {
|
|
1271
|
+
text += chunk;
|
|
1272
|
+
if (Buffer.byteLength(text, "utf8") > 256) rejectMetadata(new RangeError("Bundled terminal host metadata exceeds 256 bytes"));
|
|
1273
|
+
});
|
|
1274
|
+
stream.once("error", rejectMetadata);
|
|
1275
|
+
stream.once("end", () => resolveMetadata(text));
|
|
1276
|
+
task.child.once("error", rejectMetadata);
|
|
1277
|
+
task.child.once("close", () => rejectMetadata(new Error("Bundled terminal host closed before publishing shell ownership")));
|
|
1278
|
+
}), `Bundled terminal host did not publish shell ownership within ${HOST_HANDSHAKE_TIMEOUT_MS} milliseconds`, HOST_HANDSHAKE_TIMEOUT_MS);
|
|
1279
|
+
let value;
|
|
1280
|
+
try { value = JSON.parse(metadata); }
|
|
1281
|
+
catch { throw new Error("Bundled terminal host returned invalid ownership metadata"); }
|
|
1282
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
1283
|
+
|| Object.keys(value).some(key => !["protocolVersion", "pid"].includes(key))
|
|
1284
|
+
|| value.protocolVersion !== 1 || !Number.isSafeInteger(value.pid) || value.pid < 1 || value.pid === task.child.pid) {
|
|
1285
|
+
throw new Error("Bundled terminal host returned invalid ownership metadata");
|
|
1286
|
+
}
|
|
1287
|
+
return value.pid;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
async function terminalOpen(args, owner, activity) {
|
|
1291
|
+
if (!terminalGranted) throw new Error("Desktop terminal access requires desktop.permissions.terminal");
|
|
1292
|
+
if (terminalHostPath === null) throw new Error("This Desktop package does not contain the official terminal host");
|
|
1293
|
+
if (projectRoot === null || !fileScopes.has("project")) throw new Error("Desktop terminals require the project file grant");
|
|
1294
|
+
if (args.length !== 1 || !args[0] || typeof args[0] !== "object" || Array.isArray(args[0])
|
|
1295
|
+
|| Object.keys(args[0]).some(key => !["columns", "rows"].includes(key))) throw new TypeError("Terminal options are invalid");
|
|
1296
|
+
const columns = integer(args[0].columns ?? 80, 20, 1000, "Terminal columns");
|
|
1297
|
+
const rows = integer(args[0].rows ?? 24, 5, 1000, "Terminal rows");
|
|
1298
|
+
if (terminals.size >= MAX_TERMINALS) throw new RangeError("Desktop cannot own more than 4 terminals");
|
|
1299
|
+
if (activity.cancelled) throw new Error("Desktop host request was cancelled");
|
|
1300
|
+
const handle = allocateTerminalHandle();
|
|
1301
|
+
const environment = Object.create(null);
|
|
1302
|
+
for (const name of ["HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR"]) {
|
|
1303
|
+
if (typeof process.env[name] === "string") environment[name] = process.env[name];
|
|
1304
|
+
}
|
|
1305
|
+
environment.TERM = "xterm-256color";
|
|
1306
|
+
environment.COLORTERM = "truecolor";
|
|
1307
|
+
const child = spawn(terminalHostPath, [projectLexicalRoot ?? projectRoot, String(columns), String(rows)], {
|
|
1308
|
+
cwd: projectRoot,
|
|
1309
|
+
env: environment,
|
|
1310
|
+
shell: false,
|
|
1311
|
+
windowsHide: true,
|
|
1312
|
+
detached: true,
|
|
1313
|
+
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
1314
|
+
});
|
|
1315
|
+
let resolveResult;
|
|
1316
|
+
let rejectResult;
|
|
1317
|
+
const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; });
|
|
1318
|
+
result.catch(() => {});
|
|
1319
|
+
const task = {
|
|
1320
|
+
handle, owner, child, shellPid: null, queue: [], queuedBytes: 0, waiter: null, paused: false,
|
|
1321
|
+
decoder: new StringDecoder("utf8"), failure: null, settled: false, closing: false, ownershipPublished: false, outputEnded: false,
|
|
1322
|
+
result, resolveResult, rejectResult, stderr: "",
|
|
1323
|
+
};
|
|
1324
|
+
terminals.set(handle, task);
|
|
1325
|
+
setActivityCancellation(activity, () => releaseTerminal(task, new Error("Desktop terminal start was cancelled")));
|
|
1326
|
+
child.stdout.on("data", chunk => enqueueTerminalText(task, task.decoder.write(chunk)));
|
|
1327
|
+
child.stderr.on("data", chunk => {
|
|
1328
|
+
if (task.stderr.length < 64 * 1024) task.stderr += chunk.toString("utf8").slice(0, 64 * 1024 - task.stderr.length);
|
|
1329
|
+
});
|
|
1330
|
+
child.once("error", error => failTerminal(task, error));
|
|
1331
|
+
child.once("close", (code, signal) => {
|
|
1332
|
+
enqueueTerminalText(task, task.decoder.end());
|
|
1333
|
+
task.settled = true;
|
|
1334
|
+
if (task.failure) task.rejectResult(task.failure);
|
|
1335
|
+
else if (code === null) task.rejectResult(new Error(`Bundled terminal host exited with ${signal ?? "an unknown signal"}${task.stderr ? `: ${task.stderr}` : ""}`));
|
|
1336
|
+
else task.resolveResult({code});
|
|
1337
|
+
settleTerminalPull(task);
|
|
1338
|
+
if (task.ownershipPublished) respond({ protocolVersion: 1, hostEvent: "terminal-settled", owner, handle });
|
|
1339
|
+
});
|
|
1340
|
+
try {
|
|
1341
|
+
const ownership = terminalMetadata(task);
|
|
1342
|
+
// A failed spawn rejects both waits; keep the handshake observed so the
|
|
1343
|
+
// losing rejection cannot escalate into a fatal drain of the whole host.
|
|
1344
|
+
void ownership.catch(() => {});
|
|
1345
|
+
await new Promise((resolveStart, rejectStart) => {
|
|
1346
|
+
child.once("spawn", resolveStart);
|
|
1347
|
+
child.once("error", rejectStart);
|
|
1348
|
+
});
|
|
1349
|
+
task.shellPid = await ownership;
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
releaseTerminal(task, error instanceof Error ? error : new Error("Bundled terminal host failed to start"));
|
|
1352
|
+
throw error;
|
|
1353
|
+
}
|
|
1354
|
+
activity.cancel = null;
|
|
1355
|
+
task.ownershipPublished = true;
|
|
1356
|
+
respond({ protocolVersion: 1, hostEvent: "terminal-owned", owner, handle, pids: [child.pid, task.shellPid] });
|
|
1357
|
+
if (activity.cancelled || owner !== activeOwner) {
|
|
1358
|
+
releaseTerminal(task, new Error("Desktop document generation is no longer active"));
|
|
1359
|
+
throw new Error("Desktop document generation is no longer active");
|
|
1360
|
+
}
|
|
1361
|
+
return {handle, pid: task.shellPid};
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function terminalWrite(args, owner) {
|
|
1365
|
+
if (args.length !== 2 || typeof args[1] !== "string" || Buffer.byteLength(args[1], "utf8") < 1
|
|
1366
|
+
|| Buffer.byteLength(args[1], "utf8") > MAX_TERMINAL_CHUNK_BYTES) throw new RangeError("Terminal write requires 1 byte through 1 MiB of text");
|
|
1367
|
+
return writeTerminalFrame(ownedTerminal(args[0], owner), terminalFrame(1, Buffer.from(args[1], "utf8")));
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function terminalResize(args, owner) {
|
|
1371
|
+
if (args.length !== 3) throw new TypeError("Terminal resize arguments are invalid");
|
|
1372
|
+
const columns = integer(args[1], 20, 1000, "Terminal columns");
|
|
1373
|
+
const rows = integer(args[2], 5, 1000, "Terminal rows");
|
|
1374
|
+
const payload = Buffer.allocUnsafe(8);
|
|
1375
|
+
payload.writeUInt32BE(columns, 0);
|
|
1376
|
+
payload.writeUInt32BE(rows, 4);
|
|
1377
|
+
return writeTerminalFrame(ownedTerminal(args[0], owner), terminalFrame(2, payload));
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function terminalNext(args, owner, activity) {
|
|
1381
|
+
if (args.length !== 1) throw new TypeError("Terminal next arguments are invalid");
|
|
1382
|
+
const task = ownedTerminal(args[0], owner);
|
|
1383
|
+
if (task.waiter !== null) throw new Error("TerminalSession.next already has an active pull");
|
|
1384
|
+
if (task.queue.length > 0) {
|
|
1385
|
+
const value = task.queue.shift();
|
|
1386
|
+
task.queuedBytes -= Buffer.byteLength(value, "utf8");
|
|
1387
|
+
if (task.paused && task.queuedBytes <= TERMINAL_RESUME_BYTES) { task.paused = false; task.child.stdout.resume(); }
|
|
1388
|
+
return value;
|
|
1389
|
+
}
|
|
1390
|
+
if (task.failure) throw task.failure;
|
|
1391
|
+
if (task.settled) { task.outputEnded = true; return null; }
|
|
1392
|
+
return new Promise((resolveNext, rejectNext) => {
|
|
1393
|
+
const waiter = {resolve: resolveNext, reject: rejectNext, activity};
|
|
1394
|
+
task.waiter = waiter;
|
|
1395
|
+
setActivityCancellation(activity, () => {
|
|
1396
|
+
if (task.waiter !== waiter) return;
|
|
1397
|
+
task.waiter = null;
|
|
1398
|
+
waiter.reject(new Error("Desktop terminal pull was cancelled"));
|
|
1399
|
+
});
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
async function terminalWait(args, owner) {
|
|
1404
|
+
if (args.length !== 1) throw new TypeError("Terminal wait arguments are invalid");
|
|
1405
|
+
const task = ownedTerminal(args[0], owner);
|
|
1406
|
+
if (!task.outputEnded) throw new Error("Terminal output must be consumed before wait()");
|
|
1407
|
+
const result = await task.result;
|
|
1408
|
+
terminals.delete(task.handle);
|
|
1409
|
+
return result;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function releaseTerminal(task, error = null) {
|
|
1413
|
+
if (terminals.get(task.handle) !== task || task.closing) return false;
|
|
1414
|
+
task.closing = true;
|
|
1415
|
+
if (error && !task.failure) task.failure = error;
|
|
1416
|
+
if (task.waiter !== null) {
|
|
1417
|
+
const waiter = task.waiter;
|
|
1418
|
+
task.waiter = null;
|
|
1419
|
+
waiter.activity.cancel = null;
|
|
1420
|
+
if (error) waiter.reject(error);
|
|
1421
|
+
else if (task.queue.length === 0) waiter.resolve(null);
|
|
1422
|
+
}
|
|
1423
|
+
try { task.child.stdin.end(terminalFrame(3)); } catch {}
|
|
1424
|
+
setTimeout(() => { if (!task.settled) signalTerminalTree(task, "SIGTERM"); }, 500).unref();
|
|
1425
|
+
setTimeout(() => { if (!task.settled) signalTerminalTree(task, "SIGKILL"); }, 2500).unref();
|
|
1426
|
+
if (error) void task.result.finally(() => { if (terminals.get(task.handle) === task) terminals.delete(task.handle); }).catch(() => {});
|
|
1427
|
+
return true;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
async function terminalClose(args, owner) {
|
|
1431
|
+
if (args.length !== 1) throw new TypeError("Terminal close arguments are invalid");
|
|
1432
|
+
const task = ownedTerminal(args[0], owner);
|
|
1433
|
+
releaseTerminal(task);
|
|
1434
|
+
const result = await task.result;
|
|
1435
|
+
terminals.delete(task.handle);
|
|
1436
|
+
return result;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function terminalOperation(operation, args, owner, activity) {
|
|
1440
|
+
if (operation === "open") return terminalOpen(args, owner, activity);
|
|
1441
|
+
if (operation === "write") return terminalWrite(args, owner);
|
|
1442
|
+
if (operation === "resize") return terminalResize(args, owner);
|
|
1443
|
+
if (operation === "next") return terminalNext(args, owner, activity);
|
|
1444
|
+
if (operation === "wait") return terminalWait(args, owner);
|
|
1445
|
+
if (operation === "close") return terminalClose(args, owner);
|
|
1446
|
+
throw new Error(`Unknown terminal operation '${operation}'`);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async function projectTaskStart(args, owner, activity) {
|
|
1450
|
+
if (projectTaskPath === null || buildEnginePath === null) throw new Error("This Desktop package does not contain official project tasks");
|
|
1451
|
+
if (projectRoot === null || !fileScopes.has("project")) throw new Error("Desktop project tasks require the project file grant");
|
|
1452
|
+
if (args.length !== 3) throw new TypeError("Project task start arguments are invalid");
|
|
1453
|
+
const [command, commandArgs, options] = args;
|
|
1454
|
+
if (!["check", "test", "browserTest", "build", "fix", "package", "run"].includes(command)) throw new TypeError("Project task command is invalid");
|
|
1455
|
+
if (!Array.isArray(commandArgs) || commandArgs.length > 1000 || command !== "run" && commandArgs.length > 0) {
|
|
1456
|
+
throw new TypeError("Only a run project task accepts a bounded List<string> of program arguments");
|
|
1457
|
+
}
|
|
1458
|
+
let argumentUnits = 0;
|
|
1459
|
+
for (const item of commandArgs) {
|
|
1460
|
+
if (typeof item !== "string" || item.length > 1024 * 1024 || item.includes("\0")) throw new TypeError("Project task arguments must contain bounded strings");
|
|
1461
|
+
argumentUnits += item.length;
|
|
1462
|
+
if (argumentUnits > 1024 * 1024) throw new RangeError("Project task arguments cannot exceed 1 MiB");
|
|
1463
|
+
}
|
|
1464
|
+
if (!options || typeof options !== "object" || Array.isArray(options)
|
|
1465
|
+
|| Object.keys(options).some(key => !["timeout", "maxOutputBytes"].includes(key))) throw new TypeError("Project task options are invalid");
|
|
1466
|
+
const timeout = integer(options.timeout ?? 120000, 0, 600000, "Project task timeout");
|
|
1467
|
+
const maxOutputBytes = integer(options.maxOutputBytes ?? 4 * 1024 * 1024, 1, 16 * 1024 * 1024, "Project task maxOutputBytes");
|
|
1468
|
+
let activeProjectTasks = 0;
|
|
1469
|
+
for (const task of processHandles.values()) if (task.kind === "project-task") activeProjectTasks += 1;
|
|
1470
|
+
if (activeProjectTasks >= MAX_PROJECT_TASKS) throw new RangeError("Desktop cannot own more than 4 project tasks");
|
|
1471
|
+
if (processHandles.size >= 128) throw new RangeError("Desktop process handle limit reached");
|
|
1472
|
+
if (activity.cancelled) throw new Error("Desktop host request was cancelled");
|
|
1473
|
+
const handle = nextProcessHandle++;
|
|
1474
|
+
const environment = Object.create(null);
|
|
1475
|
+
for (const name of ["HOME", "LANG", "LC_ALL", "TMPDIR"]) if (typeof process.env[name] === "string") environment[name] = process.env[name];
|
|
1476
|
+
environment.ESBUILD_BINARY_PATH = buildEnginePath;
|
|
1477
|
+
if (command === "package") environment.VELAR_DESKTOP_PACKAGE_TEMPLATE_ROOT = await realpath(dirname(configPath));
|
|
1478
|
+
const toolArguments = [projectTaskPath, command, projectLexicalRoot ?? projectRoot];
|
|
1479
|
+
if (command === "run" && commandArgs.length > 0) toolArguments.push("--", ...commandArgs);
|
|
1480
|
+
let task = null;
|
|
1481
|
+
setActivityCancellation(activity, () => { if (task !== null) retainRetiredProcess(handle, task); });
|
|
1482
|
+
task = await launchChild(process.execPath, toolArguments, {
|
|
1483
|
+
cwd: projectRoot,
|
|
1484
|
+
environment,
|
|
1485
|
+
stdin: "",
|
|
1486
|
+
timeout,
|
|
1487
|
+
maxOutputBytes,
|
|
1488
|
+
}, () => respond({ protocolVersion: 1, hostEvent: "process-settled", owner, handle }));
|
|
1489
|
+
task.owner = owner;
|
|
1490
|
+
task.kind = "project-task";
|
|
1491
|
+
processHandles.set(handle, task);
|
|
1492
|
+
respond({ protocolVersion: 1, hostEvent: "process-owned", owner, handle, pid: task.pid });
|
|
1493
|
+
task.result.catch(() => {});
|
|
1494
|
+
if (activity.cancelled || owner !== activeOwner) {
|
|
1495
|
+
retainRetiredProcess(handle, task);
|
|
1496
|
+
throw new Error("Desktop document generation is no longer active");
|
|
1497
|
+
}
|
|
1498
|
+
return {handle, pid: task.pid};
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function projectTaskOperation(operation, args, owner, activity) {
|
|
1502
|
+
if (operation === "start") return projectTaskStart(args, owner, activity);
|
|
1503
|
+
if (operation === "read") return processRead(args, owner, "project-task");
|
|
1504
|
+
if (operation === "wait") return processWait(args, owner, "project-task");
|
|
1505
|
+
if (operation === "stop") return processStop(args, owner, "project-task");
|
|
1506
|
+
throw new Error(`Unknown project-task operation '${operation}'`);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
async function processRun(args, owner, activity) {
|
|
1510
|
+
const started = await processStart(args, owner, activity);
|
|
1511
|
+
const outcome = await processWait([started.handle], owner);
|
|
1512
|
+
if (outcome.retained) {
|
|
1513
|
+
retainRunHandle(started.handle, owner);
|
|
1514
|
+
throw processError(outcome.error);
|
|
1515
|
+
}
|
|
1516
|
+
if (outcome.error) throw processError(outcome.error);
|
|
1517
|
+
return outcome.result;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function retainRunHandle(handle, owner) {
|
|
1521
|
+
const retry = () => {
|
|
1522
|
+
processStop([handle], owner).catch(() => { setTimeout(retry, 1000); });
|
|
1523
|
+
};
|
|
1524
|
+
setTimeout(retry, 0);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
async function processStart(args, owner, activity) {
|
|
1528
|
+
if (processHandles.size >= 128) throw new RangeError("Desktop process handle limit reached");
|
|
1529
|
+
if (activity.cancelled) throw new Error("Desktop host request was cancelled");
|
|
1530
|
+
const handle = nextProcessHandle++;
|
|
1531
|
+
let task = null;
|
|
1532
|
+
setActivityCancellation(activity, () => { if (task !== null) retainRetiredProcess(handle, task); });
|
|
1533
|
+
task = await launchProcess(args, () => respond({ protocolVersion: 1, hostEvent: "process-settled", owner, handle }));
|
|
1534
|
+
task.owner = owner;
|
|
1535
|
+
task.kind = "process";
|
|
1536
|
+
processHandles.set(handle, task);
|
|
1537
|
+
// The native shell becomes the crash-recovery owner before the renderer
|
|
1538
|
+
// receives the public start/run result.
|
|
1539
|
+
respond({ protocolVersion: 1, hostEvent: "process-owned", owner, handle, pid: task.pid });
|
|
1540
|
+
// A caller may start before it waits. Keep rejection observed while the
|
|
1541
|
+
// explicit wait/stop operation still owns delivery and handle release.
|
|
1542
|
+
task.result.catch(() => {});
|
|
1543
|
+
if (activity.cancelled || owner !== activeOwner) {
|
|
1544
|
+
retainRetiredProcess(handle, task);
|
|
1545
|
+
throw new Error("Desktop document generation is no longer active");
|
|
1546
|
+
}
|
|
1547
|
+
return { handle, pid: task.pid };
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
async function processWait(args, owner, kind = "process") {
|
|
1551
|
+
const [handle, task] = ownedProcess(args[0], owner, kind);
|
|
1552
|
+
if (task.reading) throw new Error("Process wait() cannot run while next() is pending");
|
|
1553
|
+
task.waitStarted = true;
|
|
1554
|
+
if (task.waitRetained && !task.settled) signalTree(task.child, "SIGKILL");
|
|
1555
|
+
const outcome = await waitForTask(task);
|
|
1556
|
+
task.waitRetained = outcome.retained;
|
|
1557
|
+
if (!outcome.retained) processHandles.delete(handle);
|
|
1558
|
+
return outcome;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
async function processRead(args, owner, kind = "process") {
|
|
1562
|
+
const [, task] = ownedProcess(args[0], owner, kind);
|
|
1563
|
+
return task.next();
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
async function processStop(args, owner, kind = "process") {
|
|
1567
|
+
const handle = processHandle(args[0]);
|
|
1568
|
+
const task = processHandles.get(handle);
|
|
1569
|
+
if (!task) return { result: null, error: null };
|
|
1570
|
+
if (task.kind !== kind) throw new Error(`Desktop ${kind === "process" ? "process" : "project task"} handle is unknown or already released`);
|
|
1571
|
+
if (task.owner !== owner) throw new Error("Desktop process handle belongs to another document generation");
|
|
1572
|
+
task.stop();
|
|
1573
|
+
const outcome = await waitForTask(task);
|
|
1574
|
+
if (outcome.retained) throw processError(outcome.error);
|
|
1575
|
+
processHandles.delete(handle);
|
|
1576
|
+
return { result: outcome.result, error: outcome.error };
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function ownedProcess(value, owner, kind = "process") {
|
|
1580
|
+
const handle = processHandle(value);
|
|
1581
|
+
const task = processHandles.get(handle);
|
|
1582
|
+
if (!task || task.kind !== kind) throw new Error(`Desktop ${kind === "process" ? "process" : "project task"} handle is unknown or already released`);
|
|
1583
|
+
if (task.owner !== owner) throw new Error("Desktop process handle belongs to another document generation");
|
|
1584
|
+
return [handle, task];
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function retainRetiredProcess(handle, task) {
|
|
1588
|
+
if (task.retirementStarted) return;
|
|
1589
|
+
task.retirementStarted = true;
|
|
1590
|
+
task.stop();
|
|
1591
|
+
const retry = async () => {
|
|
1592
|
+
try {
|
|
1593
|
+
const outcome = await waitForTask(task);
|
|
1594
|
+
if (outcome.retained) throw processError(outcome.error);
|
|
1595
|
+
if (processHandles.get(handle) === task) processHandles.delete(handle);
|
|
1596
|
+
} catch {
|
|
1597
|
+
setTimeout(retry, 1000);
|
|
1598
|
+
}
|
|
1599
|
+
};
|
|
1600
|
+
void retry();
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function retireOwner(owner) {
|
|
1604
|
+
if (activeOwner === owner) activeOwner = null;
|
|
1605
|
+
for (const task of fileWatchers.values()) {
|
|
1606
|
+
if (task.owner === owner) releaseFileWatcher(task, new Error("Desktop document generation retired"));
|
|
1607
|
+
}
|
|
1608
|
+
for (const activity of activeRequests.values()) if (activity.owner === owner) cancelActivity(activity);
|
|
1609
|
+
for (const task of languageServers.values()) {
|
|
1610
|
+
if (task.owner === owner) releaseLanguageServer(task, new Error("Desktop document generation retired"));
|
|
1611
|
+
}
|
|
1612
|
+
for (const task of projectChangeHandles.values()) {
|
|
1613
|
+
if (task.owner === owner) releaseProjectChangeHandle(task, new Error("Desktop document generation retired"));
|
|
1614
|
+
}
|
|
1615
|
+
for (const task of terminals.values()) {
|
|
1616
|
+
if (task.owner === owner) releaseTerminal(task, new Error("Desktop document generation retired"));
|
|
1617
|
+
}
|
|
1618
|
+
for (const [handle, task] of processHandles) {
|
|
1619
|
+
if (task.owner === owner) retainRetiredProcess(handle, task);
|
|
1620
|
+
}
|
|
1621
|
+
for (const [handle, request] of httpHandles) {
|
|
1622
|
+
if (request.owner !== owner) continue;
|
|
1623
|
+
request.controller.abort(new Error("Desktop document generation retired"));
|
|
1624
|
+
void request.reader?.cancel().catch(() => {});
|
|
1625
|
+
finishHttp(handle, request);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
async function fatalDrain() {
|
|
1630
|
+
if (fatalDrainStarted) return;
|
|
1631
|
+
fatalDrainStarted = true;
|
|
1632
|
+
reader.removeAllListeners("line");
|
|
1633
|
+
reader.close();
|
|
1634
|
+
for (const task of fileWatchers.values()) releaseFileWatcher(task, new Error("Desktop capability host failed"));
|
|
1635
|
+
for (const task of languageServers.values()) releaseLanguageServer(task, new Error("Desktop capability host failed"));
|
|
1636
|
+
retireProjectTransactionOwner(new Error("Desktop capability host failed"));
|
|
1637
|
+
for (const task of terminals.values()) releaseTerminal(task, new Error("Desktop capability host failed"));
|
|
1638
|
+
const tasks = Array.from(processHandles.values());
|
|
1639
|
+
for (const task of tasks) task.stop();
|
|
1640
|
+
for (const request of httpHandles.values()) request.controller.abort(new Error("Desktop capability host failed"));
|
|
1641
|
+
let timer = null;
|
|
1642
|
+
try {
|
|
1643
|
+
await Promise.race([
|
|
1644
|
+
Promise.allSettled(tasks.map((task) => task.result)),
|
|
1645
|
+
new Promise((resolveDrain) => { timer = setTimeout(resolveDrain, FATAL_DRAIN_TIMEOUT_MS); }),
|
|
1646
|
+
]);
|
|
1647
|
+
} finally {
|
|
1648
|
+
if (timer !== null) clearTimeout(timer);
|
|
1649
|
+
for (const task of tasks) if (!task.settled) signalTree(task.child, "SIGKILL");
|
|
1650
|
+
process.exit(1);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function processErrorRecord(error) {
|
|
1655
|
+
const name = error instanceof TypeError ? "TypeError" : error instanceof RangeError ? "RangeError" : "Error";
|
|
1656
|
+
let message = error instanceof Error ? error.message : "Desktop process failed";
|
|
1657
|
+
if (typeof message !== "string" || message.length === 0) message = "Desktop process failed";
|
|
1658
|
+
if (message.length > 65536) message = message.slice(0, 65536);
|
|
1659
|
+
return { name, message };
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function processError(value) {
|
|
1663
|
+
if (value.name === "TypeError") return new TypeError(value.message);
|
|
1664
|
+
if (value.name === "RangeError") return new RangeError(value.message);
|
|
1665
|
+
return new Error(value.message);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
function terminalProcessOutcome(task) {
|
|
1669
|
+
return task.result.then(
|
|
1670
|
+
(result) => ({ result, error: null, retained: false }),
|
|
1671
|
+
(failure) => ({ result: null, error: processErrorRecord(failure), retained: false }),
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
async function processConfirmationOutcome(terminal) {
|
|
1676
|
+
let timer = null;
|
|
1677
|
+
const confirmationFailure = new Error(`Process termination could not be confirmed within ${PROCESS_STOP_CONFIRMATION_TIMEOUT_MS} milliseconds`);
|
|
1678
|
+
try {
|
|
1679
|
+
return await Promise.race([
|
|
1680
|
+
terminal,
|
|
1681
|
+
new Promise((resolveOutcome) => {
|
|
1682
|
+
timer = setTimeout(
|
|
1683
|
+
() => resolveOutcome({ result: null, error: processErrorRecord(confirmationFailure), retained: true }),
|
|
1684
|
+
PROCESS_STOP_CONFIRMATION_TIMEOUT_MS,
|
|
1685
|
+
);
|
|
1686
|
+
}),
|
|
1687
|
+
]);
|
|
1688
|
+
} finally {
|
|
1689
|
+
if (timer !== null) clearTimeout(timer);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
async function waitForTask(task) {
|
|
1694
|
+
const terminal = terminalProcessOutcome(task);
|
|
1695
|
+
if (!task.terminationRequested) {
|
|
1696
|
+
const first = await Promise.race([terminal, task.termination.then(() => processTerminationMarker)]);
|
|
1697
|
+
if (first !== processTerminationMarker) return first;
|
|
1698
|
+
}
|
|
1699
|
+
if (task.rootExited && !task.stopping) {
|
|
1700
|
+
const afterExit = await Promise.race([terminal, task.stopRequest.then(() => processStopMarker)]);
|
|
1701
|
+
if (afterExit !== processStopMarker) return afterExit;
|
|
1702
|
+
return await processConfirmationOutcome(terminal);
|
|
1703
|
+
}
|
|
1704
|
+
const confirmation = processConfirmationOutcome(terminal);
|
|
1705
|
+
const first = await Promise.race([
|
|
1706
|
+
terminal,
|
|
1707
|
+
confirmation,
|
|
1708
|
+
task.stopping ? new Promise(() => {}) : task.rootExit.then(() => processRootExitMarker),
|
|
1709
|
+
]);
|
|
1710
|
+
if (first !== processRootExitMarker) return first;
|
|
1711
|
+
if (task.stopping) return await confirmation;
|
|
1712
|
+
const afterExit = await Promise.race([terminal, task.stopRequest.then(() => processStopMarker)]);
|
|
1713
|
+
if (afterExit !== processStopMarker) return afterExit;
|
|
1714
|
+
return await processConfirmationOutcome(terminal);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function processHandle(value) {
|
|
1718
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("Desktop process handle is invalid");
|
|
1719
|
+
return value;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
async function launchProcess(args, settled) {
|
|
1723
|
+
const [command, commandArgs = [], options = {}] = args;
|
|
1724
|
+
if (typeof command !== "string" || !allowedProcesses.has(command) || command !== basename(command)) throw new Error(`Process '${String(command)}' is not granted by desktop.permissions.processes`);
|
|
1725
|
+
if (!Array.isArray(commandArgs) || commandArgs.length > 1000) throw new TypeError("Process args must be a bounded List<string>");
|
|
1726
|
+
let argumentUnits = 0;
|
|
1727
|
+
for (const item of commandArgs) {
|
|
1728
|
+
if (typeof item !== "string" || item.length === 0 || item.includes("\0") || item.length > 1024 * 1024) throw new TypeError("Process args must be a bounded List<string>");
|
|
1729
|
+
argumentUnits += item.length;
|
|
1730
|
+
if (argumentUnits > 1024 * 1024) throw new RangeError("Process arguments cannot exceed 1 MiB");
|
|
1731
|
+
}
|
|
1732
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("Process options must be a record");
|
|
1733
|
+
const allowedOptionFields = new Set(["cwd", "env", "stdin", "timeout", "maxOutputBytes"]);
|
|
1734
|
+
for (const key of Object.keys(options)) if (!allowedOptionFields.has(key)) throw new TypeError(`Process options has unknown field '${key}'`);
|
|
1735
|
+
const timeout = integer(options.timeout ?? 120000, 0, 600000, "Process timeout");
|
|
1736
|
+
const maxOutputBytes = integer(options.maxOutputBytes ?? 4 * 1024 * 1024, 1, 16 * 1024 * 1024, "Process maxOutputBytes");
|
|
1737
|
+
const cwd = options.cwd == null ? projectRoot ?? launchDirectory : await authorizedExisting(options.cwd);
|
|
1738
|
+
const stdin = options.stdin ?? "";
|
|
1739
|
+
textValue(stdin, "Process stdin", 16 * 1024 * 1024);
|
|
1740
|
+
const environment = Object.create(null);
|
|
1741
|
+
for (const name of ["HOME", "LANG", "LC_ALL", "PATH", "SHELL", "TERM", "TMPDIR", "USER"]) if (typeof process.env[name] === "string") environment[name] = process.env[name];
|
|
1742
|
+
if (options.env != null) {
|
|
1743
|
+
if (!Array.isArray(options.env) || options.env.length > 1000) throw new TypeError("Process env must be Map<string, string>");
|
|
1744
|
+
let environmentUnits = 0;
|
|
1745
|
+
for (const pair of options.env) {
|
|
1746
|
+
if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(pair[0]) || pair[0] === "PATH" || typeof pair[1] !== "string" || pair[1].includes("\0")) throw new TypeError("Process env must contain valid string variables and cannot replace PATH");
|
|
1747
|
+
environmentUnits += pair[0].length + pair[1].length;
|
|
1748
|
+
if (environmentUnits > 1024 * 1024) throw new RangeError("Process env cannot exceed 1 MiB");
|
|
1749
|
+
environment[pair[0]] = pair[1];
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
const executable = await resolveExecutable(command, environment.PATH ?? "");
|
|
1753
|
+
return launchChild(executable, commandArgs, {cwd, environment, stdin, timeout, maxOutputBytes}, settled);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
async function launchChild(executable, commandArgs, launchOptions, settled) {
|
|
1757
|
+
const {cwd, environment, stdin, timeout, maxOutputBytes} = launchOptions;
|
|
1758
|
+
const child = spawn(executable, commandArgs, {
|
|
1759
|
+
cwd,
|
|
1760
|
+
env: environment,
|
|
1761
|
+
shell: false,
|
|
1762
|
+
windowsHide: true,
|
|
1763
|
+
detached: process.platform !== "win32",
|
|
1764
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1765
|
+
});
|
|
1766
|
+
let resolveTermination;
|
|
1767
|
+
const termination = new Promise((resolveTerminationRequest) => { resolveTermination = resolveTerminationRequest; });
|
|
1768
|
+
let resolveRootExit;
|
|
1769
|
+
const rootExit = new Promise((resolveRootExitRequest) => { resolveRootExit = resolveRootExitRequest; });
|
|
1770
|
+
let resolveStopRequest;
|
|
1771
|
+
const stopRequest = new Promise((resolveStopRequestValue) => { resolveStopRequest = resolveStopRequestValue; });
|
|
1772
|
+
const task = {
|
|
1773
|
+
child,
|
|
1774
|
+
pid: child.pid ?? 0,
|
|
1775
|
+
settled: false,
|
|
1776
|
+
stopping: false,
|
|
1777
|
+
terminationRequested: false,
|
|
1778
|
+
termination,
|
|
1779
|
+
rootExited: false,
|
|
1780
|
+
rootExit,
|
|
1781
|
+
stopRequest,
|
|
1782
|
+
stdout: [],
|
|
1783
|
+
stderr: [],
|
|
1784
|
+
bytes: 0,
|
|
1785
|
+
outputChunks: 0,
|
|
1786
|
+
outputQueue: [],
|
|
1787
|
+
outputWaiter: null,
|
|
1788
|
+
reading: false,
|
|
1789
|
+
waitStarted: false,
|
|
1790
|
+
waitRetained: false,
|
|
1791
|
+
stdoutDecoder: new StringDecoder("utf8"),
|
|
1792
|
+
stderrDecoder: new StringDecoder("utf8"),
|
|
1793
|
+
failure: null,
|
|
1794
|
+
timer: null,
|
|
1795
|
+
exitTimer: null,
|
|
1796
|
+
result: null,
|
|
1797
|
+
terminate(failure, signal) {
|
|
1798
|
+
if (failure && !task.failure) task.failure = failure;
|
|
1799
|
+
if (!task.terminationRequested) {
|
|
1800
|
+
task.terminationRequested = true;
|
|
1801
|
+
resolveTermination();
|
|
1802
|
+
}
|
|
1803
|
+
if (task.outputWaiter) {
|
|
1804
|
+
const waiter = task.outputWaiter;
|
|
1805
|
+
task.outputWaiter = null;
|
|
1806
|
+
waiter.reject(task.failure ?? new Error("Process output is unavailable after stop()"));
|
|
1807
|
+
}
|
|
1808
|
+
signalTree(child, signal);
|
|
1809
|
+
},
|
|
1810
|
+
stop() {
|
|
1811
|
+
if (task.settled) return;
|
|
1812
|
+
if (task.stopping) {
|
|
1813
|
+
signalTree(child, "SIGKILL");
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
task.stopping = true;
|
|
1817
|
+
resolveStopRequest();
|
|
1818
|
+
if (task.exitTimer) {
|
|
1819
|
+
clearTimeout(task.exitTimer);
|
|
1820
|
+
task.exitTimer = null;
|
|
1821
|
+
}
|
|
1822
|
+
task.terminate(null, "SIGTERM");
|
|
1823
|
+
setTimeout(() => { if (!task.settled) signalTree(child, "SIGKILL"); }, 2000).unref();
|
|
1824
|
+
},
|
|
1825
|
+
async next() {
|
|
1826
|
+
if (task.waitStarted) throw new Error("Process output must be consumed before wait()");
|
|
1827
|
+
if (task.stopping) throw new Error("Process output is unavailable after stop()");
|
|
1828
|
+
if (task.terminationRequested) throw task.failure ?? new Error("Process output is unavailable after termination");
|
|
1829
|
+
if (task.reading) throw new Error("Process.next() allows only one active pull");
|
|
1830
|
+
task.reading = true;
|
|
1831
|
+
try {
|
|
1832
|
+
if (task.outputQueue.length > 0) return task.outputQueue.shift();
|
|
1833
|
+
if (task.settled) {
|
|
1834
|
+
if (task.failure) throw task.failure;
|
|
1835
|
+
return null;
|
|
1836
|
+
}
|
|
1837
|
+
return await new Promise((resolveOutput, rejectOutput) => { task.outputWaiter = { resolve: resolveOutput, reject: rejectOutput }; });
|
|
1838
|
+
} finally {
|
|
1839
|
+
task.reading = false;
|
|
1840
|
+
}
|
|
1841
|
+
},
|
|
1842
|
+
};
|
|
1843
|
+
task.result = new Promise((resolveRun, rejectRun) => {
|
|
1844
|
+
const deliver = (channel, text) => {
|
|
1845
|
+
if (text.length === 0 || task.failure) return;
|
|
1846
|
+
task.outputChunks += 1;
|
|
1847
|
+
if (task.outputChunks > 1000000) {
|
|
1848
|
+
task.terminate(new RangeError("Process output cannot exceed 1000000 chunks"), "SIGKILL");
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
const value = Object.freeze({ channel, text });
|
|
1852
|
+
if (task.outputWaiter) {
|
|
1853
|
+
const waiter = task.outputWaiter;
|
|
1854
|
+
task.outputWaiter = null;
|
|
1855
|
+
waiter.resolve(value);
|
|
1856
|
+
} else task.outputQueue.push(value);
|
|
1857
|
+
};
|
|
1858
|
+
const collect = (target, decoder, channel, chunk) => {
|
|
1859
|
+
if (task.failure) return;
|
|
1860
|
+
task.bytes += chunk.byteLength;
|
|
1861
|
+
if (task.bytes > maxOutputBytes) {
|
|
1862
|
+
task.terminate(new RangeError("Process output exceeded maxOutputBytes"), "SIGKILL");
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
const copy = Buffer.from(chunk);
|
|
1866
|
+
target.push(copy);
|
|
1867
|
+
deliver(channel, decoder.write(copy));
|
|
1868
|
+
};
|
|
1869
|
+
child.stdout.on("data", (chunk) => collect(task.stdout, task.stdoutDecoder, "stdout", chunk));
|
|
1870
|
+
child.stderr.on("data", (chunk) => collect(task.stderr, task.stderrDecoder, "stderr", chunk));
|
|
1871
|
+
child.once("error", (error) => { task.terminate(error, "SIGKILL"); });
|
|
1872
|
+
child.once("exit", () => {
|
|
1873
|
+
task.rootExited = true;
|
|
1874
|
+
resolveRootExit();
|
|
1875
|
+
if (!task.stopping) {
|
|
1876
|
+
signalTree(child, "SIGTERM");
|
|
1877
|
+
setTimeout(() => { if (!task.settled) signalTree(child, "SIGKILL"); }, 2000).unref();
|
|
1878
|
+
task.exitTimer = setTimeout(() => {
|
|
1879
|
+
if (task.settled) return;
|
|
1880
|
+
if (!task.failure) task.failure = new Error(`Process output streams did not close within ${PROCESS_EXIT_PIPE_CONFIRMATION_TIMEOUT_MS} milliseconds after process exit`);
|
|
1881
|
+
child.stdout.destroy();
|
|
1882
|
+
child.stderr.destroy();
|
|
1883
|
+
}, PROCESS_EXIT_PIPE_CONFIRMATION_TIMEOUT_MS);
|
|
1884
|
+
task.exitTimer.unref();
|
|
1885
|
+
}
|
|
1886
|
+
});
|
|
1887
|
+
child.once("close", (code, signal) => {
|
|
1888
|
+
task.settled = true;
|
|
1889
|
+
if (task.timer) clearTimeout(task.timer);
|
|
1890
|
+
if (task.exitTimer) clearTimeout(task.exitTimer);
|
|
1891
|
+
deliver("stdout", task.stdoutDecoder.end());
|
|
1892
|
+
deliver("stderr", task.stderrDecoder.end());
|
|
1893
|
+
if (task.outputWaiter) {
|
|
1894
|
+
const waiter = task.outputWaiter;
|
|
1895
|
+
task.outputWaiter = null;
|
|
1896
|
+
if (task.outputQueue.length > 0) waiter.resolve(task.outputQueue.shift());
|
|
1897
|
+
else if (task.failure) waiter.reject(task.failure);
|
|
1898
|
+
else waiter.resolve(null);
|
|
1899
|
+
}
|
|
1900
|
+
if (task.failure) rejectRun(task.failure);
|
|
1901
|
+
else resolveRun({ code, signal, stdout: Buffer.concat(task.stdout).toString("utf8"), stderr: Buffer.concat(task.stderr).toString("utf8") });
|
|
1902
|
+
settled();
|
|
1903
|
+
});
|
|
1904
|
+
});
|
|
1905
|
+
task.timer = timeout === 0 ? null : setTimeout(() => {
|
|
1906
|
+
if (task.settled) return;
|
|
1907
|
+
task.terminate(new Error(`Process timed out after ${timeout} milliseconds`), "SIGKILL");
|
|
1908
|
+
}, timeout);
|
|
1909
|
+
child.stdin.end(stdin);
|
|
1910
|
+
return task;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
function signalTree(child, signal) {
|
|
1914
|
+
if (!child.pid) return;
|
|
1915
|
+
if (process.platform === "win32") {
|
|
1916
|
+
try { child.kill(signal); } catch {}
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
try { process.kill(-child.pid, signal); }
|
|
1920
|
+
catch { try { child.kill(signal); } catch {} }
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
async function resolveExecutable(name, pathValue) {
|
|
1924
|
+
for (const directory of pathValue.split(process.platform === "win32" ? ";" : ":")) {
|
|
1925
|
+
if (!directory || !isAbsolute(directory)) continue;
|
|
1926
|
+
const candidate = resolve(directory, name);
|
|
1927
|
+
try {
|
|
1928
|
+
await access(candidate, fsConstants.X_OK);
|
|
1929
|
+
const canonical = await realpath(candidate);
|
|
1930
|
+
if (roots.some((root) => contained(root, canonical))) throw new Error(`Process '${name}' resolves inside a writable Desktop file root`);
|
|
1931
|
+
return canonical;
|
|
1932
|
+
} catch (error) {
|
|
1933
|
+
if (error instanceof Error && error.message.includes("writable Desktop file root")) throw error;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
throw new Error(`Granted process '${name}' was not found on the trusted executable path`);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
async function httpRequest(args, owner, activity) {
|
|
1940
|
+
if (activity.cancelled) throw new Error("Desktop host request was cancelled");
|
|
1941
|
+
const [handleValue, methodValue, urlValue, options = {}] = args;
|
|
1942
|
+
const handle = httpHandle(handleValue);
|
|
1943
|
+
if (httpHandles.has(handle)) throw new Error("Desktop HTTP handle is already active");
|
|
1944
|
+
if (httpHandles.size >= 128) throw new RangeError("Desktop HTTP handle limit reached");
|
|
1945
|
+
if (typeof methodValue !== "string") throw new TypeError("HTTP method must be text");
|
|
1946
|
+
const method = methodValue.toUpperCase();
|
|
1947
|
+
if (method.length === 0 || method.length > 32 || !/^[!#$%&'*+.^_\x60|~0-9A-Z-]+$/u.test(method) || ["CONNECT", "TRACE", "TRACK"].includes(method)) {
|
|
1948
|
+
throw new TypeError("HTTP method is invalid or forbidden");
|
|
1949
|
+
}
|
|
1950
|
+
const url = httpUrl(urlValue);
|
|
1951
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("HTTP options must be a record");
|
|
1952
|
+
const allowedOptionFields = new Set(["headers", "secretHeaders", "body", "timeout", "maxBytes"]);
|
|
1953
|
+
for (const key of Object.keys(options)) if (!allowedOptionFields.has(key)) throw new TypeError(`HTTP options has an unknown field '${key}'`);
|
|
1954
|
+
const timeout = integer(options.timeout ?? 120000, 0, 600000, "HTTP timeout");
|
|
1955
|
+
const maxBytes = integer(options.maxBytes ?? 16 * 1024 * 1024, 1, MAX_RESPONSE_BYTES, "HTTP maxBytes");
|
|
1956
|
+
const headers = new Headers();
|
|
1957
|
+
if (options.headers != null) {
|
|
1958
|
+
if (!Array.isArray(options.headers) || options.headers.length > 100) throw new TypeError("HTTP headers must be Map<string, string>");
|
|
1959
|
+
for (const pair of options.headers) {
|
|
1960
|
+
if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string" || typeof pair[1] !== "string" || /[\r\n]/u.test(pair[1])) throw new TypeError("HTTP headers are invalid");
|
|
1961
|
+
headers.append(pair[0], pair[1]);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
const secretHeaderNames = applySecretHeaders(options.secretHeaders, headers);
|
|
1965
|
+
let body = options.body ?? null;
|
|
1966
|
+
if (body !== null && typeof body !== "string") throw new TypeError("Desktop HTTP body must be validated text");
|
|
1967
|
+
if (body !== null && Buffer.byteLength(body, "utf8") > MAX_FILE_BYTES) throw new RangeError("HTTP body cannot exceed 16 MiB");
|
|
1968
|
+
if ((method === "GET" || method === "HEAD") && body !== null) throw new TypeError(`${method} requests cannot have a body`);
|
|
1969
|
+
const controller = new AbortController();
|
|
1970
|
+
setActivityCancellation(activity, () => controller.abort(new Error("Desktop host request was cancelled")));
|
|
1971
|
+
const request = {
|
|
1972
|
+
owner,
|
|
1973
|
+
controller,
|
|
1974
|
+
reader: null,
|
|
1975
|
+
decoder: new TextDecoder("utf-8", { fatal: true }),
|
|
1976
|
+
maxBytes,
|
|
1977
|
+
bytes: 0,
|
|
1978
|
+
chunks: 0,
|
|
1979
|
+
declaredTooLarge: false,
|
|
1980
|
+
timer: timeout === 0 ? null : setTimeout(() => controller.abort(new Error("HTTP request timed out")), timeout),
|
|
1981
|
+
};
|
|
1982
|
+
httpHandles.set(handle, request);
|
|
1983
|
+
if (activity.cancelled) controller.abort(new Error("Desktop host request was cancelled"));
|
|
1984
|
+
let response = null;
|
|
1985
|
+
try {
|
|
1986
|
+
response = await fetchAuthorized(url, method, headers, secretHeaderNames, body, controller.signal);
|
|
1987
|
+
if (owner !== activeOwner) throw new Error("Desktop document generation is no longer active");
|
|
1988
|
+
const ok = response.ok;
|
|
1989
|
+
const status = response.status;
|
|
1990
|
+
const statusText = response.statusText;
|
|
1991
|
+
const responseUrl = response.url;
|
|
1992
|
+
if (typeof ok !== "boolean" || !Number.isInteger(status) || status < 100 || status > 599
|
|
1993
|
+
|| ok !== (status >= 200 && status <= 299)) {
|
|
1994
|
+
throw new TypeError("Fetch returned invalid HTTP response metadata");
|
|
1995
|
+
}
|
|
1996
|
+
if (typeof statusText !== "string") throw new TypeError("HTTP response status text must be text");
|
|
1997
|
+
if (statusText.length > 65536) throw new RangeError("HTTP response status text cannot exceed 64 KiB");
|
|
1998
|
+
if (typeof responseUrl !== "string") throw new TypeError("HTTP response URL must be text");
|
|
1999
|
+
if (responseUrl.length > 2 * 1024 * 1024) throw new RangeError("HTTP response URLs cannot exceed 2 MiB");
|
|
2000
|
+
const responseHeaders = [...response.headers.entries()];
|
|
2001
|
+
let headerUnits = 0;
|
|
2002
|
+
if (responseHeaders.length > 100) throw new RangeError("HTTP response headers cannot exceed 100 fields");
|
|
2003
|
+
for (const [name, value] of responseHeaders) {
|
|
2004
|
+
headerUnits += name.length + value.length;
|
|
2005
|
+
if (headerUnits > 65536) throw new RangeError("HTTP response headers cannot exceed 64 KiB");
|
|
2006
|
+
}
|
|
2007
|
+
const hasBody = response.body !== null;
|
|
2008
|
+
const declaredLength = transportDeclaredLength(response.headers.get("content-length"));
|
|
2009
|
+
request.declaredTooLarge = hasBody && declaredLength !== null && declaredLength > maxBytes;
|
|
2010
|
+
request.reader = response.body?.getReader() ?? null;
|
|
2011
|
+
if (!hasBody) finishHttp(handle, request);
|
|
2012
|
+
return { ok, status, statusText, url: responseUrl, headers: responseHeaders, body: hasBody };
|
|
2013
|
+
} catch (error) {
|
|
2014
|
+
try { await response?.body?.cancel(error); } catch {}
|
|
2015
|
+
finishHttp(handle, request);
|
|
2016
|
+
throw error;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
function applySecretHeaders(value, headers) {
|
|
2021
|
+
if (value == null) return new Set();
|
|
2022
|
+
if (!Array.isArray(value) || value.length > 16) throw new TypeError("HTTP secretHeaders must be a List with at most 16 entries");
|
|
2023
|
+
const forbidden = new Set(["connection", "content-length", "cookie", "cookie2", "host", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
|
|
2024
|
+
const names = new Set();
|
|
2025
|
+
for (const item of value) {
|
|
2026
|
+
if (!item || typeof item !== "object" || Array.isArray(item)
|
|
2027
|
+
|| Object.keys(item).some((key) => !["name", "environment", "prefix"].includes(key))
|
|
2028
|
+
|| typeof item.name !== "string" || !/^[!#$%&'*+.^_|~0-9A-Za-z-]+$/u.test(item.name)
|
|
2029
|
+
|| typeof item.environment !== "string" || !/^[A-Z_][A-Z0-9_]{0,127}$/u.test(item.environment)
|
|
2030
|
+
|| typeof item.prefix !== "string" || item.prefix.length > 256 || /[\r\n]/u.test(item.prefix)) {
|
|
2031
|
+
throw new TypeError("HTTP secret header descriptor is invalid");
|
|
2032
|
+
}
|
|
2033
|
+
const lower = item.name.toLowerCase();
|
|
2034
|
+
if (forbidden.has(lower)) throw new TypeError("HTTP secret header name is transport-controlled");
|
|
2035
|
+
if (!allowedSecrets.has(item.environment)) throw new Error(`Secret '${item.environment}' is not granted by desktop.permissions.secrets`);
|
|
2036
|
+
if (headers.has(item.name) || names.has(lower)) throw new TypeError("HTTP secret header conflicts with another header");
|
|
2037
|
+
const secret = process.env[item.environment];
|
|
2038
|
+
if (secret === undefined) throw new Error(`Granted Desktop secret '${item.environment}' is unavailable`);
|
|
2039
|
+
const headerValue = item.prefix + secret;
|
|
2040
|
+
if (/\r|\n/u.test(headerValue) || Buffer.byteLength(headerValue, "utf8") > 65536) throw new RangeError("HTTP secret header value is outside the supported bounds");
|
|
2041
|
+
headers.set(item.name, headerValue);
|
|
2042
|
+
names.add(lower);
|
|
2043
|
+
}
|
|
2044
|
+
if ([...headers].length > 100) throw new RangeError("HTTP headers cannot exceed 100 fields");
|
|
2045
|
+
let units = 0;
|
|
2046
|
+
for (const [name, item] of headers) {
|
|
2047
|
+
units += name.length + item.length;
|
|
2048
|
+
if (units > 65536) throw new RangeError("HTTP headers cannot exceed 64 KiB");
|
|
2049
|
+
}
|
|
2050
|
+
return names;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
async function fetchAuthorized(initialUrl, initialMethod, initialHeaders, secretHeaderNames, initialBody, signal) {
|
|
2054
|
+
let url = initialUrl;
|
|
2055
|
+
let method = initialMethod;
|
|
2056
|
+
let headers = initialHeaders;
|
|
2057
|
+
let body = initialBody;
|
|
2058
|
+
for (let redirects = 0; ; redirects += 1) {
|
|
2059
|
+
authorizeOrigin(url);
|
|
2060
|
+
let response;
|
|
2061
|
+
try { response = await fetch(url, { method, headers, body, signal, redirect: "manual" }); }
|
|
2062
|
+
catch (error) {
|
|
2063
|
+
if (signal.aborted) throw error;
|
|
2064
|
+
throw new HttpTransportFailure("request");
|
|
2065
|
+
}
|
|
2066
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) return response;
|
|
2067
|
+
const location = response.headers.get("location");
|
|
2068
|
+
if (location === null) return response;
|
|
2069
|
+
if (redirects >= MAX_HTTP_REDIRECTS) {
|
|
2070
|
+
try { await response.body?.cancel(); } catch {}
|
|
2071
|
+
throw new Error(`HTTP redirect limit of ${MAX_HTTP_REDIRECTS} was exceeded`);
|
|
2072
|
+
}
|
|
2073
|
+
let nextUrl;
|
|
2074
|
+
try {
|
|
2075
|
+
nextUrl = httpUrl(new URL(location, url).href);
|
|
2076
|
+
authorizeOrigin(nextUrl);
|
|
2077
|
+
} finally {
|
|
2078
|
+
try { await response.body?.cancel(); } catch {}
|
|
2079
|
+
}
|
|
2080
|
+
if (nextUrl.origin !== url.origin) {
|
|
2081
|
+
headers = new Headers(headers);
|
|
2082
|
+
for (const name of ["authorization", "proxy-authorization", "cookie", "cookie2", ...secretHeaderNames]) headers.delete(name);
|
|
2083
|
+
}
|
|
2084
|
+
if (response.status === 303 && method !== "HEAD" || (response.status === 301 || response.status === 302) && method === "POST") {
|
|
2085
|
+
method = "GET";
|
|
2086
|
+
body = null;
|
|
2087
|
+
headers = new Headers(headers);
|
|
2088
|
+
headers.delete("content-encoding");
|
|
2089
|
+
headers.delete("content-language");
|
|
2090
|
+
headers.delete("content-location");
|
|
2091
|
+
headers.delete("content-type");
|
|
2092
|
+
}
|
|
2093
|
+
url = nextUrl;
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
function httpUrl(value) {
|
|
2098
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 2 * 1024 * 1024) throw new TypeError("HTTP URL must be bounded text");
|
|
2099
|
+
let url;
|
|
2100
|
+
try { url = new URL(value); }
|
|
2101
|
+
catch { throw new TypeError("HTTP URL must be absolute"); }
|
|
2102
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new TypeError("HTTP URL must use http or https");
|
|
2103
|
+
if (url.username || url.password) throw new TypeError("HTTP URL credentials are not allowed");
|
|
2104
|
+
return url;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function authorizeOrigin(url) {
|
|
2108
|
+
if (!allowedOrigins.has(url.origin)) throw new Error(`Network origin '${url.origin}' is not granted by desktop.permissions.network`);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
async function httpRead(args, owner) {
|
|
2112
|
+
const handle = httpHandle(args[0]);
|
|
2113
|
+
const request = httpHandles.get(handle);
|
|
2114
|
+
if (!request) throw new Error("Desktop HTTP handle is unknown or already released");
|
|
2115
|
+
if (request.owner !== owner) throw new Error("Desktop HTTP handle belongs to another document generation");
|
|
2116
|
+
try {
|
|
2117
|
+
if (request.declaredTooLarge) throw new RangeError("HTTP response exceeds maxBytes");
|
|
2118
|
+
if (!request.reader) {
|
|
2119
|
+
const tail = request.decoder.decode();
|
|
2120
|
+
finishHttp(handle, request);
|
|
2121
|
+
return { done: true, text: tail };
|
|
2122
|
+
}
|
|
2123
|
+
let result;
|
|
2124
|
+
try { result = await request.reader.read(); }
|
|
2125
|
+
catch (error) {
|
|
2126
|
+
if (request.controller.signal.aborted) throw error;
|
|
2127
|
+
throw new HttpTransportFailure("response");
|
|
2128
|
+
}
|
|
2129
|
+
if (owner !== activeOwner) throw new Error("Desktop document generation is no longer active");
|
|
2130
|
+
if (result.done) {
|
|
2131
|
+
const tail = request.decoder.decode();
|
|
2132
|
+
finishHttp(handle, request);
|
|
2133
|
+
return { done: true, text: tail };
|
|
2134
|
+
}
|
|
2135
|
+
if (!(result.value instanceof Uint8Array)) throw new TypeError("HTTP response yielded a non-byte chunk");
|
|
2136
|
+
request.bytes += result.value.byteLength;
|
|
2137
|
+
request.chunks += 1;
|
|
2138
|
+
if (request.bytes > request.maxBytes) throw new RangeError("HTTP response exceeds maxBytes");
|
|
2139
|
+
if (request.chunks > MAX_HTTP_CHUNKS) throw new RangeError("HTTP responses cannot exceed 1000000 chunks");
|
|
2140
|
+
return { done: false, text: request.decoder.decode(result.value, { stream: true }) };
|
|
2141
|
+
} catch (error) {
|
|
2142
|
+
try { await request.reader?.cancel(error); } catch {}
|
|
2143
|
+
finishHttp(handle, request);
|
|
2144
|
+
throw error;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
async function httpCancel(args, owner) {
|
|
2149
|
+
const handle = httpHandle(args[0]);
|
|
2150
|
+
const request = httpHandles.get(handle);
|
|
2151
|
+
if (!request) return null;
|
|
2152
|
+
if (request.owner !== owner) throw new Error("Desktop HTTP handle belongs to another document generation");
|
|
2153
|
+
request.controller.abort(new Error("HTTP request cancelled"));
|
|
2154
|
+
try { await request.reader?.cancel(); } catch {}
|
|
2155
|
+
finishHttp(handle, request);
|
|
2156
|
+
return null;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function httpHandle(value) {
|
|
2160
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("Desktop HTTP handle is invalid");
|
|
2161
|
+
return value;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
function finishHttp(handle, expected) {
|
|
2165
|
+
const request = httpHandles.get(handle);
|
|
2166
|
+
if (!request || request !== expected) return;
|
|
2167
|
+
if (request.timer) clearTimeout(request.timer);
|
|
2168
|
+
httpHandles.delete(handle);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
function integer(value, minimum, maximum, name) {
|
|
2172
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new RangeError(`${name} must be an integer from ${minimum} through ${maximum}`);
|
|
2173
|
+
return value;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
function transportDeclaredLength(value) {
|
|
2177
|
+
if (typeof value !== "string" || value.length === 0) return null;
|
|
2178
|
+
if (typeof transportCharCodeAt !== "function" || typeof transportReflectApply !== "function") {
|
|
2179
|
+
throw new TypeError("The host transport sizing intrinsics are unavailable");
|
|
2180
|
+
}
|
|
2181
|
+
let length = 0;
|
|
2182
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
2183
|
+
const unit = transportReflectApply(transportCharCodeAt, value, [index]);
|
|
2184
|
+
if (unit < 48 || unit > 57) return null;
|
|
2185
|
+
if (length > 900719925474099) return 1 / 0;
|
|
2186
|
+
length = length * 10 + unit - 48;
|
|
2187
|
+
if (length > 9007199254740991) return 1 / 0;
|
|
2188
|
+
}
|
|
2189
|
+
return length;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
function textValue(value, name, maxBytes) {
|
|
2193
|
+
if (typeof value !== "string" || value.includes("\0") || Buffer.byteLength(value, "utf8") > maxBytes) throw new RangeError(`${name} is outside the supported bounds`);
|
|
2194
|
+
return value;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function safeError(error) {
|
|
2198
|
+
if (error instanceof HttpTransportFailure) {
|
|
2199
|
+
return { kind: "http-transport", message: error.message, phase: error.phase };
|
|
2200
|
+
}
|
|
2201
|
+
if (error instanceof Error && typeof error.message === "string") return error.message.slice(0, 4096);
|
|
2202
|
+
return "Desktop capability failed";
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function respond(value) {
|
|
2206
|
+
const line = JSON.stringify(value);
|
|
2207
|
+
if (Buffer.byteLength(line, "utf8") > MAX_RESPONSE_BYTES + MAX_MESSAGE_BYTES) {
|
|
2208
|
+
process.stdout.write(`${JSON.stringify({ id: value.id, ok: false, error: "Desktop response exceeds its transport bound" })}\n`);
|
|
2209
|
+
} else {
|
|
2210
|
+
process.stdout.write(`${line}\n`);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
process.once("exit", () => {
|
|
2215
|
+
for (const task of processHandles.values()) signalTree(task.child, "SIGKILL");
|
|
2216
|
+
for (const task of languageServers.values()) signalTree(task.child, "SIGKILL");
|
|
2217
|
+
for (const task of terminals.values()) signalTerminalTree(task, "SIGKILL");
|
|
2218
|
+
});
|
|
2219
|
+
process.on("uncaughtException", () => { void fatalDrain(); });
|
|
2220
|
+
process.on("unhandledRejection", () => { void fatalDrain(); });
|