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.
@@ -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). Orphaned grandchildren
162
- // are reaped by the OS as the host exits.
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");
@@ -566,6 +566,8 @@ export async function launchLSP(command, args = [], options = {}) {
566
566
  stdout: proc.stdout,
567
567
  stderr: proc.stderr,
568
568
  pid: proc.pid ?? 0,
569
+ command: spawnCommand,
570
+ args,
569
571
  };
570
572
  }
571
573
  /**
@@ -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: ["ast-grep"],
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
- fs.writeFile(pending.cachePath, json, "utf-8", (writeErr) => {
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
- fs.writeFileSync(pending.cachePath, JSON.stringify(pending.data), "utf-8");
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.