pi-lsp-adapter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,339 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { getProcessRegistryPath } from "../config/paths.js";
5
+ import { delay, isNodeError, isPlainObject } from "../util/helpers.js";
6
+
7
+ export interface LspProcessEntry {
8
+ id: string;
9
+ serverId: string;
10
+ rootDir: string;
11
+ pid: number;
12
+ command: string[];
13
+ cwd: string;
14
+ startedAt: string;
15
+ ownerId: string;
16
+ ownerPid: number;
17
+ }
18
+
19
+ export interface LspPidFile {
20
+ processes: LspProcessEntry[];
21
+ }
22
+
23
+ export interface ProcessProbe {
24
+ isRunning(pid: number): boolean | Promise<boolean>;
25
+ commandMatches(pid: number, command: string[]): boolean | Promise<boolean>;
26
+ terminate(pid: number, signal: NodeJS.Signals): void | Promise<void>;
27
+ }
28
+
29
+ export interface LspProcessRegistryOptions {
30
+ path?: string;
31
+ dir?: string;
32
+ ownerId: string;
33
+ probe?: ProcessProbe;
34
+ terminateGraceMs?: number;
35
+ }
36
+
37
+ export interface CleanupResult {
38
+ terminated: LspProcessEntry[];
39
+ removed: LspProcessEntry[];
40
+ kept: LspProcessEntry[];
41
+ }
42
+
43
+ export class LspProcessRegistry {
44
+ private readonly path: string;
45
+ private readonly dir: string;
46
+ private readonly ownerId: string;
47
+ private readonly probe: ProcessProbe;
48
+ private readonly terminateGraceMs: number;
49
+ private mutationQueue = Promise.resolve();
50
+
51
+ constructor(options: LspProcessRegistryOptions) {
52
+ this.ownerId = options.ownerId;
53
+ this.path = options.path ?? getProcessRegistryPath(options.ownerId);
54
+ this.dir = options.dir ?? dirname(this.path);
55
+ this.probe = options.probe ?? nodeProcessProbe;
56
+ this.terminateGraceMs = options.terminateGraceMs ?? 1500;
57
+ }
58
+
59
+ async list(): Promise<LspProcessEntry[]> {
60
+ return this.readAllEntries();
61
+ }
62
+
63
+ async register(
64
+ entry: Omit<LspProcessEntry, "ownerId" | "ownerPid" | "startedAt"> &
65
+ Partial<Pick<LspProcessEntry, "ownerId" | "ownerPid" | "startedAt">>,
66
+ ): Promise<void> {
67
+ await this.withMutation(async () => {
68
+ const pidFile = await this.readOwnerFile();
69
+ const nextEntry: LspProcessEntry = {
70
+ ...entry,
71
+ ownerId: entry.ownerId ?? this.ownerId,
72
+ ownerPid: entry.ownerPid ?? process.pid,
73
+ startedAt: entry.startedAt ?? new Date().toISOString(),
74
+ };
75
+ pidFile.processes = [...pidFile.processes.filter((item) => !isSameOwnerSlot(item, nextEntry)), nextEntry];
76
+ await this.writeOwnerFile(pidFile);
77
+ });
78
+ }
79
+
80
+ async unregister(id: string, pid?: number): Promise<void> {
81
+ await this.withMutation(async () => {
82
+ const pidFile = await this.readOwnerFile();
83
+ pidFile.processes = pidFile.processes.filter((entry) => {
84
+ if (entry.id !== id) return true;
85
+ if (pid === undefined) return false;
86
+ return entry.pid !== pid;
87
+ });
88
+ await this.writeOwnerFile(pidFile);
89
+ });
90
+ }
91
+
92
+ async cleanupStaleProcesses(): Promise<CleanupResult> {
93
+ const entries = await this.readAllEntries();
94
+ const result: CleanupResult = { terminated: [], removed: [], kept: [] };
95
+
96
+ for (const entry of entries) {
97
+ if (!(await this.probe.isRunning(entry.pid))) {
98
+ result.removed.push(entry);
99
+ continue;
100
+ }
101
+
102
+ if (!(await this.isStaleOwner(entry))) {
103
+ result.kept.push(entry);
104
+ continue;
105
+ }
106
+
107
+ if (!(await this.probe.commandMatches(entry.pid, entry.command))) {
108
+ result.kept.push(entry);
109
+ continue;
110
+ }
111
+
112
+ await this.terminateEntry(entry);
113
+ result.terminated.push(entry);
114
+ }
115
+
116
+ await this.rewriteAllEntries(result.kept);
117
+ return result;
118
+ }
119
+
120
+ async terminateOwnedProcesses(): Promise<CleanupResult> {
121
+ return this.terminateProcesses((entry) => entry.ownerId === this.ownerId);
122
+ }
123
+
124
+ async terminateProcesses(predicate: (entry: LspProcessEntry) => boolean = () => true): Promise<CleanupResult> {
125
+ const entries = await this.readAllEntries();
126
+ const result: CleanupResult = { terminated: [], removed: [], kept: [] };
127
+
128
+ for (const entry of entries) {
129
+ if (!predicate(entry)) {
130
+ result.kept.push(entry);
131
+ continue;
132
+ }
133
+
134
+ if (!(await this.probe.isRunning(entry.pid))) {
135
+ result.removed.push(entry);
136
+ continue;
137
+ }
138
+
139
+ if (!(await this.probe.commandMatches(entry.pid, entry.command))) {
140
+ result.kept.push(entry);
141
+ continue;
142
+ }
143
+
144
+ await this.terminateEntry(entry);
145
+ result.terminated.push(entry);
146
+ }
147
+
148
+ await this.rewriteAllEntries(result.kept);
149
+ return result;
150
+ }
151
+
152
+ private async isStaleOwner(entry: LspProcessEntry): Promise<boolean> {
153
+ return entry.ownerPid === process.pid || !(await this.probe.isRunning(entry.ownerPid));
154
+ }
155
+
156
+ private async terminateEntry(entry: LspProcessEntry): Promise<void> {
157
+ await this.probe.terminate(entry.pid, "SIGTERM");
158
+ await delay(this.terminateGraceMs);
159
+ if (await this.probe.isRunning(entry.pid)) {
160
+ await this.probe.terminate(entry.pid, "SIGKILL");
161
+ }
162
+ }
163
+
164
+ private async withMutation<T>(operation: () => Promise<T>): Promise<T> {
165
+ const run = this.mutationQueue.catch(() => undefined).then(operation);
166
+ this.mutationQueue = run.then(
167
+ () => undefined,
168
+ () => undefined,
169
+ );
170
+ return run;
171
+ }
172
+
173
+ private async readOwnerFile(): Promise<LspPidFile> {
174
+ return this.readFile(this.path);
175
+ }
176
+
177
+ private async readAllEntries(): Promise<LspProcessEntry[]> {
178
+ const files = await this.registryFiles();
179
+ const entries: LspProcessEntry[] = [];
180
+ for (const file of files) {
181
+ entries.push(...(await this.readFile(file)).processes);
182
+ }
183
+ return normalizePidFile({ processes: entries }).processes;
184
+ }
185
+
186
+ private async registryFiles(): Promise<string[]> {
187
+ const files = new Set<string>();
188
+ files.add(this.path);
189
+
190
+ try {
191
+ for (const entry of await readdir(this.dir, { withFileTypes: true })) {
192
+ if (entry.isFile() && entry.name.endsWith(".json")) {
193
+ files.add(join(this.dir, entry.name));
194
+ }
195
+ }
196
+ } catch (error) {
197
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
198
+ }
199
+
200
+ return [...files].sort();
201
+ }
202
+
203
+ private async rewriteAllEntries(entries: LspProcessEntry[]): Promise<void> {
204
+ const grouped = new Map<string, LspProcessEntry[]>();
205
+ for (const entry of entries) {
206
+ const filePath = this.pathForOwner(entry.ownerId);
207
+ grouped.set(filePath, [...(grouped.get(filePath) ?? []), entry]);
208
+ }
209
+
210
+ for (const filePath of await this.registryFiles()) {
211
+ await this.writeFile(filePath, { processes: grouped.get(filePath) ?? [] });
212
+ grouped.delete(filePath);
213
+ }
214
+
215
+ for (const [filePath, processes] of grouped) {
216
+ await this.writeFile(filePath, { processes });
217
+ }
218
+ }
219
+
220
+ private pathForOwner(ownerId: string): string {
221
+ if (ownerId === this.ownerId) return this.path;
222
+ return join(this.dir, `${safePathSegment(ownerId)}.json`);
223
+ }
224
+
225
+ private async readFile(path: string): Promise<LspPidFile> {
226
+ try {
227
+ const raw = await readFile(path, "utf8");
228
+ if (raw.trim() === "") return { processes: [] };
229
+
230
+ const parsed: unknown = JSON.parse(raw);
231
+ if (!isPidFile(parsed)) return { processes: [] };
232
+ return { processes: parsed.processes };
233
+ } catch (error) {
234
+ if (isNodeError(error) && error.code === "ENOENT") {
235
+ return { processes: [] };
236
+ }
237
+ if (error instanceof SyntaxError) {
238
+ return { processes: [] };
239
+ }
240
+ throw error;
241
+ }
242
+ }
243
+
244
+ private async writeOwnerFile(pidFile: LspPidFile): Promise<void> {
245
+ await this.writeFile(this.path, pidFile);
246
+ }
247
+
248
+ private async writeFile(path: string, pidFile: LspPidFile): Promise<void> {
249
+ await mkdir(dirname(path), { recursive: true });
250
+ const normalized = normalizePidFile(pidFile);
251
+ if (normalized.processes.length === 0) {
252
+ await rm(path, { force: true });
253
+ return;
254
+ }
255
+
256
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
257
+ await writeFile(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
258
+ await rename(tempPath, path);
259
+ }
260
+ }
261
+
262
+ export const nodeProcessProbe: ProcessProbe = {
263
+ isRunning(pid: number): boolean {
264
+ try {
265
+ process.kill(pid, 0);
266
+ return true;
267
+ } catch (error) {
268
+ return isNodeError(error) && error.code === "EPERM";
269
+ }
270
+ },
271
+
272
+ async commandMatches(pid: number, command: string[]): Promise<boolean> {
273
+ if (process.platform !== "linux") return true;
274
+ const expected = command[0];
275
+ if (!expected) return false;
276
+
277
+ try {
278
+ const cmdline = await readFile(`/proc/${pid}/cmdline`, "utf8");
279
+ const actualParts = cmdline.split("\0").filter(Boolean);
280
+ const expectedBasename = expected.split("/").at(-1);
281
+ return actualParts.some((part) => part === expected || part.endsWith(`/${expectedBasename}`));
282
+ } catch {
283
+ return false;
284
+ }
285
+ },
286
+
287
+ terminate(pid: number, signal: NodeJS.Signals): void {
288
+ try {
289
+ process.kill(pid, signal);
290
+ } catch (error) {
291
+ if (!isNodeError(error) || error.code !== "ESRCH") throw error;
292
+ }
293
+ },
294
+ };
295
+
296
+ function normalizePidFile(pidFile: LspPidFile): LspPidFile {
297
+ return {
298
+ processes: [...pidFile.processes].sort(
299
+ (left, right) =>
300
+ left.id.localeCompare(right.id) || left.ownerId.localeCompare(right.ownerId) || left.pid - right.pid,
301
+ ),
302
+ };
303
+ }
304
+
305
+ function safePathSegment(value: string): string {
306
+ return value.replace(/[^A-Za-z0-9._-]/gu, "_");
307
+ }
308
+
309
+ function isSameOwnerSlot(
310
+ left: Pick<LspProcessEntry, "id" | "ownerId">,
311
+ right: Pick<LspProcessEntry, "id" | "ownerId">,
312
+ ): boolean {
313
+ return left.id === right.id && left.ownerId === right.ownerId;
314
+ }
315
+
316
+ function isPidFile(value: unknown): value is LspPidFile {
317
+ if (!isPlainObject(value) || !Array.isArray(value.processes)) return false;
318
+ return value.processes.every(isProcessEntry);
319
+ }
320
+
321
+ function isProcessEntry(value: unknown): value is LspProcessEntry {
322
+ return (
323
+ isPlainObject(value) &&
324
+ typeof value.id === "string" &&
325
+ typeof value.serverId === "string" &&
326
+ typeof value.rootDir === "string" &&
327
+ typeof value.pid === "number" &&
328
+ Number.isInteger(value.pid) &&
329
+ value.pid > 0 &&
330
+ Array.isArray(value.command) &&
331
+ value.command.every((entry) => typeof entry === "string") &&
332
+ typeof value.cwd === "string" &&
333
+ typeof value.startedAt === "string" &&
334
+ typeof value.ownerId === "string" &&
335
+ typeof value.ownerPid === "number" &&
336
+ Number.isInteger(value.ownerPid) &&
337
+ value.ownerPid > 0
338
+ );
339
+ }