pi-lens 3.8.67 → 3.8.68
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/CHANGELOG.md +19 -0
- package/README.md +2 -0
- package/dist/clients/deps/ast-grep-napi.js +20 -1
- package/dist/clients/deps/pi-tui.js +4 -1
- package/dist/clients/deps/typebox.js +5 -2
- package/dist/clients/deps/web-tree-sitter.js +22 -1
- package/dist/clients/instance-reaper.js +418 -0
- package/dist/clients/instance-registry.js +238 -0
- package/dist/clients/lsp/client.js +76 -2
- package/dist/clients/lsp/launch.js +2 -0
- package/dist/clients/lsp/server-strategies.js +61 -0
- package/dist/clients/lsp/server.js +8 -1
- package/dist/clients/review-graph/builder.js +20 -2
- package/dist/clients/runtime-session.js +89 -8
- package/dist/clients/runtime-turn.js +9 -0
- package/dist/clients/session-lifecycle.js +252 -0
- package/dist/clients/sgconfig.js +31 -1
- package/dist/clients/slow-fs.js +137 -0
- package/dist/clients/source-filter.js +18 -5
- package/dist/clients/subagent-mode.js +53 -0
- package/dist/index.js +60587 -1636
- package/package.json +3 -2
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process instance registry (#449 slice 1).
|
|
3
|
+
*
|
|
4
|
+
* Observability substrate for multi-agent LSP resource sharing. Records, in
|
|
5
|
+
* a single machine-global file (`~/.pi-lens/instances.json`), every live
|
|
6
|
+
* pi-lens process: its pid, project root, live LSP child servers, RSS, and a
|
|
7
|
+
* heartbeat timestamp. Later slices (cross-process budget, same-root warm
|
|
8
|
+
* attach) build on this; slice 1 is purely observational — it changes no
|
|
9
|
+
* dispatch/LSP behavior, it only records state and reaps stale entries /
|
|
10
|
+
* orphaned LSP children (#472).
|
|
11
|
+
*
|
|
12
|
+
* File shape: `{ instances: InstanceEntry[] }`. Missing or corrupt file is
|
|
13
|
+
* treated as `{ instances: [] }` — this module must never throw on a read.
|
|
14
|
+
*
|
|
15
|
+
* Concurrency: every write is read-modify-write-whole-file with an atomic
|
|
16
|
+
* tmp+rename (same pattern as clients/review-graph/builder.ts). Two
|
|
17
|
+
* processes racing a write is a KNOWN, ACCEPTED race for slice 1
|
|
18
|
+
* (last-writer-wins) — a lost update here only means a stale/missing
|
|
19
|
+
* observability entry, never data corruption (the tmp+rename guarantees the
|
|
20
|
+
* file itself is always valid JSON). A future slice can add file locking or
|
|
21
|
+
* per-pid shard files if this proves too lossy in practice.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import { getGlobalPiLensDir } from "./file-utils.js";
|
|
26
|
+
import { normalizeFilePath } from "./path-utils.js";
|
|
27
|
+
function registryPath() {
|
|
28
|
+
return path.join(getGlobalPiLensDir(), "instances.json");
|
|
29
|
+
}
|
|
30
|
+
// --- Kill switch (lazy, memoized — house style per clients/runtime-config.ts) ---
|
|
31
|
+
let _enabledCache;
|
|
32
|
+
/**
|
|
33
|
+
* `PI_LENS_INSTANCE_REGISTRY=0` disables the registry entirely: every
|
|
34
|
+
* exported function in this module becomes a no-op (including the reaper
|
|
35
|
+
* sweep in clients/instance-reaper.ts, which checks this too).
|
|
36
|
+
*/
|
|
37
|
+
export function isInstanceRegistryEnabled() {
|
|
38
|
+
if (_enabledCache !== undefined)
|
|
39
|
+
return _enabledCache;
|
|
40
|
+
_enabledCache = process.env.PI_LENS_INSTANCE_REGISTRY !== "0";
|
|
41
|
+
return _enabledCache;
|
|
42
|
+
}
|
|
43
|
+
/** Test-only: clear the memoized kill-switch read. */
|
|
44
|
+
export function _resetInstanceRegistryEnabledForTests() {
|
|
45
|
+
_enabledCache = undefined;
|
|
46
|
+
}
|
|
47
|
+
// --- Read ---
|
|
48
|
+
function readRegistrySync() {
|
|
49
|
+
try {
|
|
50
|
+
const raw = fs.readFileSync(registryPath(), "utf-8");
|
|
51
|
+
const parsed = JSON.parse(raw);
|
|
52
|
+
if (parsed && Array.isArray(parsed.instances)) {
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
return { instances: [] };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Missing file, corrupt JSON, or wrong shape — treat as empty, never throw.
|
|
59
|
+
return { instances: [] };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function readRegistryAsync() {
|
|
63
|
+
try {
|
|
64
|
+
const raw = await fs.promises.readFile(registryPath(), "utf-8");
|
|
65
|
+
const parsed = JSON.parse(raw);
|
|
66
|
+
if (parsed && Array.isArray(parsed.instances)) {
|
|
67
|
+
return parsed;
|
|
68
|
+
}
|
|
69
|
+
return { instances: [] };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { instances: [] };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Read-only snapshot of the whole registry (used by the reaper). */
|
|
76
|
+
export async function readInstanceRegistry() {
|
|
77
|
+
const file = await readRegistryAsync();
|
|
78
|
+
return file.instances;
|
|
79
|
+
}
|
|
80
|
+
// --- Write (atomic tmp + rename, same pattern as review-graph/builder.ts) ---
|
|
81
|
+
async function writeRegistryAsync(file) {
|
|
82
|
+
const dir = getGlobalPiLensDir();
|
|
83
|
+
const target = registryPath();
|
|
84
|
+
const tmpPath = `${target}.tmp-${process.pid}`;
|
|
85
|
+
try {
|
|
86
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
87
|
+
await fs.promises.writeFile(tmpPath, JSON.stringify(file), "utf-8");
|
|
88
|
+
await fs.promises.rename(tmpPath, target);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Best-effort observability substrate — a failed write just means this
|
|
92
|
+
// update is lost, never a thrown error for the caller.
|
|
93
|
+
try {
|
|
94
|
+
await fs.promises.rm(tmpPath, { force: true });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// ignore
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function writeRegistrySync(file) {
|
|
102
|
+
const dir = getGlobalPiLensDir();
|
|
103
|
+
const target = registryPath();
|
|
104
|
+
const tmpPath = `${target}.tmp-${process.pid}`;
|
|
105
|
+
try {
|
|
106
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
107
|
+
fs.writeFileSync(tmpPath, JSON.stringify(file), "utf-8");
|
|
108
|
+
fs.renameSync(tmpPath, target);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
try {
|
|
112
|
+
fs.rmSync(tmpPath, { force: true });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// ignore
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// --- Mutations (all read-modify-write whole file) ---
|
|
120
|
+
/** Create/overwrite this process's entry. */
|
|
121
|
+
export async function registerInstance(projectRoot) {
|
|
122
|
+
if (!isInstanceRegistryEnabled())
|
|
123
|
+
return;
|
|
124
|
+
const pid = process.pid;
|
|
125
|
+
const normalizedRoot = normalizeFilePath(projectRoot);
|
|
126
|
+
const file = await readRegistryAsync();
|
|
127
|
+
const now = new Date().toISOString();
|
|
128
|
+
const others = file.instances.filter((entry) => entry.pid !== pid);
|
|
129
|
+
const existing = file.instances.find((entry) => entry.pid === pid);
|
|
130
|
+
others.push({
|
|
131
|
+
pid,
|
|
132
|
+
startedAt: existing?.startedAt ?? now,
|
|
133
|
+
projectRoot: normalizedRoot,
|
|
134
|
+
lspChildren: existing?.lspChildren ?? [],
|
|
135
|
+
lspChildCount: existing?.lspChildren?.length ?? 0,
|
|
136
|
+
rssBytes: process.memoryUsage().rss,
|
|
137
|
+
heartbeatAt: now,
|
|
138
|
+
});
|
|
139
|
+
await writeRegistryAsync({ instances: others });
|
|
140
|
+
}
|
|
141
|
+
/** Update this process's heartbeat/rss. Cheap — safe to call every turn end. */
|
|
142
|
+
export async function updateHeartbeat(patch = {}) {
|
|
143
|
+
if (!isInstanceRegistryEnabled())
|
|
144
|
+
return;
|
|
145
|
+
const pid = process.pid;
|
|
146
|
+
const file = await readRegistryAsync();
|
|
147
|
+
const idx = file.instances.findIndex((entry) => entry.pid === pid);
|
|
148
|
+
if (idx === -1) {
|
|
149
|
+
// No prior registerInstance in this run (e.g. registry file was reaped
|
|
150
|
+
// out from under us, or heartbeat fired before session_start finished) —
|
|
151
|
+
// nothing to update against; skip rather than fabricate a projectRoot.
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const now = new Date().toISOString();
|
|
155
|
+
const current = file.instances[idx];
|
|
156
|
+
file.instances[idx] = {
|
|
157
|
+
...current,
|
|
158
|
+
rssBytes: patch.rssBytes ?? process.memoryUsage().rss,
|
|
159
|
+
lspChildCount: current.lspChildren.length,
|
|
160
|
+
heartbeatAt: now,
|
|
161
|
+
};
|
|
162
|
+
await writeRegistryAsync(file);
|
|
163
|
+
}
|
|
164
|
+
/** Append/replace (by pid) an LSP child under this process's entry. */
|
|
165
|
+
export async function recordLspChild(entry) {
|
|
166
|
+
if (!isInstanceRegistryEnabled())
|
|
167
|
+
return;
|
|
168
|
+
const pid = process.pid;
|
|
169
|
+
const file = await readRegistryAsync();
|
|
170
|
+
const idx = file.instances.findIndex((inst) => inst.pid === pid);
|
|
171
|
+
const now = new Date().toISOString();
|
|
172
|
+
const childEntry = {
|
|
173
|
+
pid: entry.pid,
|
|
174
|
+
serverId: entry.serverId,
|
|
175
|
+
command: entry.command,
|
|
176
|
+
marker: entry.marker,
|
|
177
|
+
spawnedAt: now,
|
|
178
|
+
};
|
|
179
|
+
if (idx === -1) {
|
|
180
|
+
// registerInstance hasn't run yet in this process (or was reaped) —
|
|
181
|
+
// synthesize a minimal entry so the child is still tracked.
|
|
182
|
+
file.instances.push({
|
|
183
|
+
pid,
|
|
184
|
+
startedAt: now,
|
|
185
|
+
projectRoot: normalizeFilePath(process.cwd()),
|
|
186
|
+
lspChildren: [childEntry],
|
|
187
|
+
lspChildCount: 1,
|
|
188
|
+
rssBytes: process.memoryUsage().rss,
|
|
189
|
+
heartbeatAt: now,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
const current = file.instances[idx];
|
|
194
|
+
const filtered = current.lspChildren.filter((child) => child.pid !== entry.pid);
|
|
195
|
+
filtered.push(childEntry);
|
|
196
|
+
file.instances[idx] = {
|
|
197
|
+
...current,
|
|
198
|
+
lspChildren: filtered,
|
|
199
|
+
lspChildCount: filtered.length,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
await writeRegistryAsync(file);
|
|
203
|
+
}
|
|
204
|
+
/** Remove an LSP child (by pid) from this process's entry. */
|
|
205
|
+
export async function removeLspChild(pid) {
|
|
206
|
+
if (!isInstanceRegistryEnabled())
|
|
207
|
+
return;
|
|
208
|
+
const selfPid = process.pid;
|
|
209
|
+
const file = await readRegistryAsync();
|
|
210
|
+
const idx = file.instances.findIndex((inst) => inst.pid === selfPid);
|
|
211
|
+
if (idx === -1)
|
|
212
|
+
return;
|
|
213
|
+
const current = file.instances[idx];
|
|
214
|
+
const filtered = current.lspChildren.filter((child) => child.pid !== pid);
|
|
215
|
+
if (filtered.length === current.lspChildren.length)
|
|
216
|
+
return; // nothing removed
|
|
217
|
+
file.instances[idx] = {
|
|
218
|
+
...current,
|
|
219
|
+
lspChildren: filtered,
|
|
220
|
+
lspChildCount: filtered.length,
|
|
221
|
+
};
|
|
222
|
+
await writeRegistryAsync(file);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Remove this process's entry entirely. SYNC fs only — safe to call from
|
|
226
|
+
* `session_shutdown` (#234: no child spawns permitted at teardown; this
|
|
227
|
+
* function spawns nothing).
|
|
228
|
+
*/
|
|
229
|
+
export function deregisterInstance() {
|
|
230
|
+
if (!isInstanceRegistryEnabled())
|
|
231
|
+
return;
|
|
232
|
+
const pid = process.pid;
|
|
233
|
+
const file = readRegistrySync();
|
|
234
|
+
const remaining = file.instances.filter((entry) => entry.pid !== pid);
|
|
235
|
+
if (remaining.length === file.instances.length)
|
|
236
|
+
return; // nothing to remove
|
|
237
|
+
writeRegistrySync({ instances: remaining });
|
|
238
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
11
11
|
import { EventEmitter } from "node:events";
|
|
12
12
|
import { access, readFile } from "node:fs/promises";
|
|
13
|
+
import * as os from "node:os";
|
|
13
14
|
import { pathToFileURL } from "node:url";
|
|
14
15
|
import { withTimeout } from "../deadline-utils.js";
|
|
15
16
|
import { logLatency } from "../latency-logger.js";
|
|
@@ -18,6 +19,7 @@ import { logLatency } from "../latency-logger.js";
|
|
|
18
19
|
import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
|
|
19
20
|
import { getAmbientAbortSignal } from "../safe-spawn.js";
|
|
20
21
|
import { applyWorkspaceEdit } from "./edits.js";
|
|
22
|
+
import { recordLspChild, removeLspChild } from "../instance-registry.js";
|
|
21
23
|
import { normalizeMapKey, uriToPath } from "./path-utils.js";
|
|
22
24
|
import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
|
|
23
25
|
import { getStrategy } from "./server-strategies.js";
|
|
@@ -27,6 +29,32 @@ import { WatchedFilesQueue } from "./watch-queue.js";
|
|
|
27
29
|
// diagnose the clean-file affirmative-signal question (#240): which servers
|
|
28
30
|
// publish an empty-with-version set on a clean scan vs go silent.
|
|
29
31
|
const PUB_DEBUG = Boolean(process.env.PILENS_PUB_DEBUG);
|
|
32
|
+
/**
|
|
33
|
+
* #472/#449: extract a per-spawn-unique "marker" from an LSP server's resolved
|
|
34
|
+
* args, for the instance registry's command-line re-identification fallback
|
|
35
|
+
* (used when a recorded child's pid is dead/recycled but its process tree
|
|
36
|
+
* grandchild — e.g. ast-grep's native exe behind a dead node wrapper — is
|
|
37
|
+
* still alive under a different pid).
|
|
38
|
+
*
|
|
39
|
+
* Generalized, NOT ast-grep-specific (uniformity requirement — no per-server
|
|
40
|
+
* special casing): the value immediately following a `--config`/`-c` flag, if
|
|
41
|
+
* that value looks like a path under a temp directory (`os.tmpdir()`). This
|
|
42
|
+
* covers ast-grep's `lsp --config <tmp sgconfig path>` (clients/sgconfig.ts)
|
|
43
|
+
* today, and any other server later launched with a temp-file `--config`/`-c`
|
|
44
|
+
* argument, without new server-specific code.
|
|
45
|
+
*/
|
|
46
|
+
function extractSpawnMarker(args) {
|
|
47
|
+
const tmpDir = os.tmpdir();
|
|
48
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
49
|
+
const flag = args[i];
|
|
50
|
+
if (flag === "--config" || flag === "-c") {
|
|
51
|
+
const value = args[i + 1];
|
|
52
|
+
if (value?.startsWith(tmpDir))
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
30
58
|
// --- Constants ---
|
|
31
59
|
const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000); // 15s — npx downloads are handled by ensureTool, not here
|
|
32
60
|
/**
|
|
@@ -158,8 +186,24 @@ export async function killProcessTree(proc, pid, options = {}) {
|
|
|
158
186
|
// Host process is exiting (loop already closing): never spawn a child here —
|
|
159
187
|
// the spawn's uv_async_send on the closing loop-wakeup handle hard-aborts
|
|
160
188
|
// (src\win\async.c). Kill the direct child via the handle we already hold
|
|
161
|
-
// (TerminateProcess; synchronous, no async handle).
|
|
162
|
-
//
|
|
189
|
+
// (TerminateProcess; synchronous, no async handle).
|
|
190
|
+
//
|
|
191
|
+
// #472 CORRECTION of a prior false claim here ("orphaned grandchildren are
|
|
192
|
+
// reaped by the OS as the host exits"): Windows does NOT kill children when
|
|
193
|
+
// a parent dies. For shell/.cmd-wrapped servers the direct child is
|
|
194
|
+
// cmd.exe, so this path only ever kills the wrapper — the actual server
|
|
195
|
+
// (its grandchild) survives by design whenever it doesn't independently
|
|
196
|
+
// exit. It relies entirely on best-effort backstops instead: (1) the
|
|
197
|
+
// server observing stdin EOF once the wrapper's pipes close, (2) LSP
|
|
198
|
+
// `initialize.processId: process.pid` (some servers self-watchdog on that
|
|
199
|
+
// pid dying — typescript-language-server does, ast-grep's native binary
|
|
200
|
+
// does not, an upstream spec violation), and (3) the #449/#472
|
|
201
|
+
// cross-process instance registry's orphan reaper, which is the only
|
|
202
|
+
// mechanism that works regardless of why a pipe write-end stayed open
|
|
203
|
+
// (e.g. Windows handle-inheritance capture by a long-lived process). This
|
|
204
|
+
// is why registering every LSP child at spawn matters uniformly — do NOT
|
|
205
|
+
// weaken this direct-child-only kill to try to chase grandchildren here;
|
|
206
|
+
// spawning taskkill in this branch is exactly the libuv hazard above.
|
|
163
207
|
if (options.processExiting) {
|
|
164
208
|
try {
|
|
165
209
|
proc.kill();
|
|
@@ -751,6 +795,14 @@ export async function clientShutdown(state, options = {}) {
|
|
|
751
795
|
}
|
|
752
796
|
disposeClientConnection(state);
|
|
753
797
|
const pid = state.lspProcess.pid;
|
|
798
|
+
// #449/#472: deregister this LSP child from the instance registry. Fire-
|
|
799
|
+
// and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
|
|
800
|
+
// including the `processExiting` path where the event loop is closing
|
|
801
|
+
// (#234 forbids spawning here, but a plain fs write/rename is fine; even
|
|
802
|
+
// so, we don't await it to keep this teardown path as fast as before).
|
|
803
|
+
void removeLspChild(pid).catch(() => {
|
|
804
|
+
// best-effort — a stale entry is caught dead-pid by the reaper later
|
|
805
|
+
});
|
|
754
806
|
// On Windows, killing the direct child first can orphan grandchildren before
|
|
755
807
|
// taskkill can traverse the tree. Kill the full tree first and wait briefly.
|
|
756
808
|
await killProcessTree(state.lspProcess.process, pid, options);
|
|
@@ -883,6 +935,21 @@ async function resolveCodeActionBestEffort(state, action) {
|
|
|
883
935
|
export async function createLSPClient(options) {
|
|
884
936
|
installCrashGuard();
|
|
885
937
|
const { serverId, process: lspProcess, root, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, } = options;
|
|
938
|
+
// #449/#472: register this LSP child in the cross-process instance registry
|
|
939
|
+
// as soon as we have a live pid — BEFORE `initialize` completes, not after.
|
|
940
|
+
// Registering early means a child that dies/hangs during initialize (the
|
|
941
|
+
// catch block below kills it) is still deregistered by that same path via
|
|
942
|
+
// removeLspChild, and a process that crashes mid-initialize is still
|
|
943
|
+
// visible to the orphan reaper rather than silently untracked. Fire-and-
|
|
944
|
+
// forget: registry I/O must never block or fail LSP startup.
|
|
945
|
+
void recordLspChild({
|
|
946
|
+
pid: lspProcess.pid,
|
|
947
|
+
serverId,
|
|
948
|
+
command: lspProcess.command,
|
|
949
|
+
marker: extractSpawnMarker(lspProcess.args),
|
|
950
|
+
}).catch(() => {
|
|
951
|
+
// best-effort observability — never fail LSP startup over this
|
|
952
|
+
});
|
|
886
953
|
const startupState = {
|
|
887
954
|
exitCode: null,
|
|
888
955
|
exitSignal: null,
|
|
@@ -1009,6 +1076,13 @@ export async function createLSPClient(options) {
|
|
|
1009
1076
|
// SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees.
|
|
1010
1077
|
const pid = lspProcess.pid;
|
|
1011
1078
|
void killProcessTree(lspProcess.process, pid);
|
|
1079
|
+
// A child registered above (recordLspChild) but never reaching a healthy
|
|
1080
|
+
// createLSPClient return must still be deregistered here — otherwise the
|
|
1081
|
+
// registry keeps a stale entry for a process we just killed.
|
|
1082
|
+
void removeLspChild(pid).catch(() => {
|
|
1083
|
+
// best-effort — a stale registry entry is harmless (the reaper's
|
|
1084
|
+
// liveness check will find it dead on the next sweep regardless)
|
|
1085
|
+
});
|
|
1012
1086
|
setTimeout(() => {
|
|
1013
1087
|
if (!lspProcess.process.killed && process.platform !== "win32") {
|
|
1014
1088
|
lspProcess.process.kill("SIGKILL");
|
|
@@ -6,6 +6,67 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Env var overrides (PI_LENS_LSP_*) always take precedence over strategy values.
|
|
8
8
|
*/
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
const _require = createRequire(import.meta.url);
|
|
11
|
+
/**
|
|
12
|
+
* Platform/arch → `@ast-grep/cli-<platform>-<arch>[-msvc|-gnu]` native
|
|
13
|
+
* package name, mirroring @ast-grep/cli's own `optionalDependencies` matrix
|
|
14
|
+
* (checked directly against node_modules/@ast-grep/cli/package.json). Each
|
|
15
|
+
* package ships the native `ast-grep`/`ast-grep.exe` binary at its package
|
|
16
|
+
* root (no `bin/` subdir) — see node_modules/@ast-grep/cli-win32-x64-msvc/.
|
|
17
|
+
*/
|
|
18
|
+
function astGrepNativePackageName(platform, arch) {
|
|
19
|
+
switch (platform) {
|
|
20
|
+
case "win32":
|
|
21
|
+
if (arch === "x64")
|
|
22
|
+
return "@ast-grep/cli-win32-x64-msvc";
|
|
23
|
+
if (arch === "arm64")
|
|
24
|
+
return "@ast-grep/cli-win32-arm64-msvc";
|
|
25
|
+
if (arch === "ia32")
|
|
26
|
+
return "@ast-grep/cli-win32-ia32-msvc";
|
|
27
|
+
return undefined;
|
|
28
|
+
case "darwin":
|
|
29
|
+
if (arch === "arm64")
|
|
30
|
+
return "@ast-grep/cli-darwin-arm64";
|
|
31
|
+
if (arch === "x64")
|
|
32
|
+
return "@ast-grep/cli-darwin-x64";
|
|
33
|
+
return undefined;
|
|
34
|
+
case "linux":
|
|
35
|
+
if (arch === "x64")
|
|
36
|
+
return "@ast-grep/cli-linux-x64-gnu";
|
|
37
|
+
if (arch === "arm64")
|
|
38
|
+
return "@ast-grep/cli-linux-arm64-gnu";
|
|
39
|
+
return undefined;
|
|
40
|
+
default:
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve ast-grep's platform-native exe DIRECTLY, skipping the node-bin
|
|
46
|
+
* wrapper (`ast-grep.cmd`/shim → node → cli.js → spawn native exe). One less
|
|
47
|
+
* orphanable process layer (#472): a wrapper's direct child is the node/cmd
|
|
48
|
+
* process, so on abnormal exit the actual ast-grep binary is a grandchild the
|
|
49
|
+
* #234 teardown path never reaches — resolving straight to the native exe
|
|
50
|
+
* means the LSP's direct child IS the real server.
|
|
51
|
+
*
|
|
52
|
+
* `require.resolve` is wrapped in try/catch (ESM-safe via createRequire, same
|
|
53
|
+
* pattern as clients/deps/ast-grep-napi.ts) — returns undefined so the caller
|
|
54
|
+
* falls back to the existing wrapper-based resolution when the platform
|
|
55
|
+
* package isn't installed (it's an optionalDependency; native builds can be
|
|
56
|
+
* absent on unsupported platforms/arches or a partial install).
|
|
57
|
+
*/
|
|
58
|
+
export function resolveAstGrepNativeExe(platform = process.platform, arch = process.arch) {
|
|
59
|
+
const pkgName = astGrepNativePackageName(platform, arch);
|
|
60
|
+
if (!pkgName)
|
|
61
|
+
return undefined;
|
|
62
|
+
const binaryName = platform === "win32" ? "ast-grep.exe" : "ast-grep";
|
|
63
|
+
try {
|
|
64
|
+
return _require.resolve(`${pkgName}/${binaryName}`);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
9
70
|
export const SERVER_DIAGNOSTIC_STRATEGIES = {
|
|
10
71
|
typescript: {
|
|
11
72
|
seedFirstPush: true,
|
|
@@ -19,6 +19,7 @@ import { resolveOpengrepConfig } from "../opengrep-config.js";
|
|
|
19
19
|
import { resolveZizmorGitHubToken } from "../zizmor-config.js";
|
|
20
20
|
import { logLatency } from "../latency-logger.js";
|
|
21
21
|
import { findLocalSgconfig, resolveBaselineSgconfig } from "../sgconfig.js";
|
|
22
|
+
import { resolveAstGrepNativeExe } from "./server-strategies.js";
|
|
22
23
|
import { isCommandAvailableAsync, safeSpawnAsync } from "../safe-spawn.js";
|
|
23
24
|
import { launchLSP } from "./launch.js";
|
|
24
25
|
import { createLombokJdtlsArgs } from "./lombok.js";
|
|
@@ -1896,8 +1897,14 @@ export const AstGrepServer = {
|
|
|
1896
1897
|
if (baseline)
|
|
1897
1898
|
args = ["lsp", "--config", baseline];
|
|
1898
1899
|
}
|
|
1900
|
+
// #472: prefer the platform-native exe directly (one less orphanable
|
|
1901
|
+
// node-bin-wrapper layer). Prepended as the first candidate; falls back
|
|
1902
|
+
// to the existing "ast-grep" PATH/global-bin resolution when the
|
|
1903
|
+
// optional native package isn't installed for this platform/arch.
|
|
1904
|
+
const nativeExe = resolveAstGrepNativeExe();
|
|
1905
|
+
const candidates = nativeExe ? [nativeExe, "ast-grep"] : ["ast-grep"];
|
|
1899
1906
|
return resolveAndLaunch({
|
|
1900
|
-
candidates
|
|
1907
|
+
candidates,
|
|
1901
1908
|
args,
|
|
1902
1909
|
cwd: root,
|
|
1903
1910
|
managedToolId: "ast-grep",
|
|
@@ -584,10 +584,24 @@ function writePending(key) {
|
|
|
584
584
|
console.error("[review-graph] cache dir creation failed:", mkdirErr.message);
|
|
585
585
|
return;
|
|
586
586
|
}
|
|
587
|
-
|
|
587
|
+
// Write-to-temp + rename so the snapshot lands atomically: a reader
|
|
588
|
+
// (another process's blind load, or the tier-2 disk load in tests) must
|
|
589
|
+
// never see a created-but-partially-written file — that parses as
|
|
590
|
+
// corrupt and silently forces a full rebuild. rename() replaces the
|
|
591
|
+
// destination atomically on both POSIX and Windows (libuv uses
|
|
592
|
+
// MOVEFILE_REPLACE_EXISTING).
|
|
593
|
+
const tmpPath = `${pending.cachePath}.tmp-${process.pid}`;
|
|
594
|
+
fs.writeFile(tmpPath, json, "utf-8", (writeErr) => {
|
|
588
595
|
if (writeErr) {
|
|
589
596
|
console.error("[review-graph] cache write failed:", writeErr.message);
|
|
597
|
+
return;
|
|
590
598
|
}
|
|
599
|
+
fs.rename(tmpPath, pending.cachePath, (renameErr) => {
|
|
600
|
+
if (renameErr) {
|
|
601
|
+
console.error("[review-graph] cache rename failed:", renameErr.message);
|
|
602
|
+
fs.rm(tmpPath, { force: true }, () => { });
|
|
603
|
+
}
|
|
604
|
+
});
|
|
591
605
|
});
|
|
592
606
|
});
|
|
593
607
|
}
|
|
@@ -603,7 +617,11 @@ function ensurePersistExitHook() {
|
|
|
603
617
|
for (const [, pending] of _pendingPersist) {
|
|
604
618
|
try {
|
|
605
619
|
fs.mkdirSync(pending.cacheDir, { recursive: true });
|
|
606
|
-
|
|
620
|
+
// Same atomic tmp+rename as writePending: even at teardown a crash
|
|
621
|
+
// mid-write must not leave a truncated snapshot for the next start.
|
|
622
|
+
const tmpPath = `${pending.cachePath}.tmp-${process.pid}`;
|
|
623
|
+
fs.writeFileSync(tmpPath, JSON.stringify(pending.data), "utf-8");
|
|
624
|
+
fs.renameSync(tmpPath, pending.cachePath);
|
|
607
625
|
}
|
|
608
626
|
catch {
|
|
609
627
|
// Teardown is best-effort; a missed persist just re-confirms next start.
|