dsh-plugin-codegraph-tree-sitter 0.6.2 → 0.6.4

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/lib/index.d.ts CHANGED
@@ -67,6 +67,14 @@ export declare const DEFAULT_INDEXER_ID = "codegraph-tree-sitter";
67
67
  * Directory segments never descended into by default. Includes this package's own `.codegraph`
68
68
  * output — without it, a watcher would treat its own `writeGraph()` as an in-scope change and
69
69
  * rebuild forever.
70
+ *
71
+ * `target` is here for a reason that cost real file descriptors to learn: this plugin is developed
72
+ * in its own repository, and `crates/indexer-rust/target/` holds thousands of cargo artefacts
73
+ * across ~1000 directories. Without the exclusion the walk descends into every one of them, and on
74
+ * macOS each watched file pins a descriptor — a single workspace accumulated 9.7k open handles,
75
+ * which is what makes a host's `spawn()` fail with `EBADF` long before any real limit is reached.
76
+ * `.scratch` and `vendor` cover the same shape of accident for working copies and dependency
77
+ * checkouts.
70
78
  */
71
79
  export declare const DEFAULT_EXCLUDE: string[];
72
80
  /** Default ceiling on a single file's size before it is skipped. */
package/lib/index.js CHANGED
@@ -71,8 +71,26 @@ export const DEFAULT_INDEXER_ID = 'codegraph-tree-sitter';
71
71
  * Directory segments never descended into by default. Includes this package's own `.codegraph`
72
72
  * output — without it, a watcher would treat its own `writeGraph()` as an in-scope change and
73
73
  * rebuild forever.
74
+ *
75
+ * `target` is here for a reason that cost real file descriptors to learn: this plugin is developed
76
+ * in its own repository, and `crates/indexer-rust/target/` holds thousands of cargo artefacts
77
+ * across ~1000 directories. Without the exclusion the walk descends into every one of them, and on
78
+ * macOS each watched file pins a descriptor — a single workspace accumulated 9.7k open handles,
79
+ * which is what makes a host's `spawn()` fail with `EBADF` long before any real limit is reached.
80
+ * `.scratch` and `vendor` cover the same shape of accident for working copies and dependency
81
+ * checkouts.
74
82
  */
75
- export const DEFAULT_EXCLUDE = ['node_modules', 'dist', 'build', 'coverage', '.git', '.codegraph'];
83
+ export const DEFAULT_EXCLUDE = [
84
+ 'node_modules',
85
+ 'dist',
86
+ 'build',
87
+ 'coverage',
88
+ '.git',
89
+ '.codegraph',
90
+ 'target',
91
+ '.scratch',
92
+ 'vendor',
93
+ ];
76
94
  /** Default ceiling on a single file's size before it is skipped. */
77
95
  export const DEFAULT_MAX_FILE_BYTES = 2_000_000;
78
96
  /** Default ceiling on how many files one run indexes. */
package/lib/schema.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * naive "delete then recreate" leaves open between `CREATE TABLE` and the first `COMMIT`.
11
11
  * @module dsh-plugin-codegraph-tree-sitter/schema
12
12
  */
13
- import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
13
+ import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
14
14
  import { basename, dirname, join } from 'node:path';
15
15
  import { DatabaseSync } from 'node:sqlite';
16
16
  import { randomUUID } from 'node:crypto';
@@ -52,6 +52,121 @@ async function removeGraphFiles(path) {
52
52
  await rm(`${path}-wal`, { force: true });
53
53
  await rm(`${path}-shm`, { force: true });
54
54
  }
55
+ /**
56
+ * Report whether the process that claimed a lock file is still running.
57
+ *
58
+ * A timestamp is the wrong staleness test here: an index over a large tree legitimately runs
59
+ * for minutes, so any fixed window either steals a lock that is still held or waits forever on
60
+ * one whose holder crashed. The holder writes its pid after the exclusive create, and a pid the
61
+ * OS no longer knows is garbage. `process.kill(pid, 0)` sends no signal — it only asks whether
62
+ * the process exists; `EPERM` means it does and is simply not ours to signal.
63
+ * @param lockPath - absolute path of the `.graph.lock` file to inspect.
64
+ * @returns true when the recorded holder still exists.
65
+ */
66
+ async function lockHolderIsAlive(lockPath) {
67
+ let raw;
68
+ try {
69
+ raw = await readFile(lockPath, 'utf8');
70
+ }
71
+ catch {
72
+ // Vanished between the failed `wx` and this read: let the caller retry the create.
73
+ return false;
74
+ }
75
+ const pid = Number.parseInt(raw, 10);
76
+ if (!Number.isInteger(pid) || pid <= 0)
77
+ return false;
78
+ try {
79
+ process.kill(pid, 0);
80
+ return true;
81
+ }
82
+ catch (error) {
83
+ // ESRCH: no such process. EPERM: alive, but owned by another user.
84
+ return error.code === 'EPERM';
85
+ }
86
+ }
87
+ /**
88
+ * Serialize graph writes on one workspace, across processes.
89
+ *
90
+ * Two runs used to be allowed to overlap (see writeStatusFile): each built its own
91
+ * temporary file and the last rename won. That is safe for the file, but the loser's
92
+ * status.json can then describe a graph the winner already replaced.
93
+ * @param dir - the `.codegraph` directory, which must already exist.
94
+ * @param work - the write to run while the lock is held.
95
+ */
96
+ async function withGraphLock(dir, work) {
97
+ const lockPath = join(dir, '.graph.lock');
98
+ const ACQUIRE_MS = 10_000;
99
+ const RETRY_MS = 25;
100
+ const started = Date.now();
101
+ let handle = null;
102
+ while (handle === null) {
103
+ try {
104
+ handle = await open(lockPath, 'wx');
105
+ }
106
+ catch (error) {
107
+ if (error.code !== 'EEXIST')
108
+ throw error;
109
+ // A holder that died leaves the file behind; only a dead pid may be stolen, so a
110
+ // live run slower than ACQUIRE_MS is waited out rather than overrun.
111
+ if (!(await lockHolderIsAlive(lockPath))) {
112
+ try {
113
+ await rm(lockPath, { force: true });
114
+ }
115
+ catch {
116
+ // another contender reclaimed it first
117
+ }
118
+ continue;
119
+ }
120
+ if (Date.now() - started > ACQUIRE_MS) {
121
+ throw new Error(`codegraph: another index run holds ${lockPath}`);
122
+ }
123
+ await new Promise((resolve) => setTimeout(resolve, RETRY_MS));
124
+ }
125
+ }
126
+ try {
127
+ // Record the holder so the next contender can tell a live run from a crashed one. An
128
+ // absent or unparseable pid reads as dead, which is also the right answer for a lock
129
+ // left by a process that died between the create and this write.
130
+ await handle.writeFile(String(process.pid));
131
+ return await work();
132
+ }
133
+ finally {
134
+ try {
135
+ await handle.close();
136
+ }
137
+ catch {
138
+ // already closed
139
+ }
140
+ try {
141
+ await rm(lockPath, { force: true });
142
+ }
143
+ catch {
144
+ // best effort
145
+ }
146
+ }
147
+ }
148
+ /**
149
+ * `rename` on Windows fails with EPERM/EBUSY/EACCES while ANY handle holds the target.
150
+ * Those are transient by nature -- an antivirus read, a reader closing its connection --
151
+ * and a single attempt turns one of them into a failed index.
152
+ */
153
+ async function renameWithRetry(from, to) {
154
+ let last = null;
155
+ for (let attempt = 0; attempt <= 3; attempt += 1) {
156
+ try {
157
+ await rename(from, to);
158
+ return;
159
+ }
160
+ catch (error) {
161
+ last = error;
162
+ const retryable = ['EPERM', 'EBUSY', 'EACCES'];
163
+ if (!retryable.includes(error.code ?? '') || attempt === 3)
164
+ break;
165
+ await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
166
+ }
167
+ }
168
+ throw last;
169
+ }
55
170
  /**
56
171
  * Replace the graph at `databasePath` with one indexing run's output.
57
172
  *
@@ -63,6 +178,12 @@ async function removeGraphFiles(path) {
63
178
  * @param input - the run's resolved graph and per-file metadata.
64
179
  */
65
180
  export async function writeGraph(databasePath, input) {
181
+ // The lock file lives beside the database, so its directory must exist before the lock can
182
+ // be created — the first index of a workspace has no `.codegraph/` yet.
183
+ await mkdir(dirname(databasePath), { recursive: true });
184
+ return withGraphLock(dirname(databasePath), () => writeGraphUnlocked(databasePath, input));
185
+ }
186
+ async function writeGraphUnlocked(databasePath, input) {
66
187
  await mkdir(dirname(databasePath), { recursive: true });
67
188
  const tempPath = `${databasePath}.tmp-${process.pid}-${randomUUID()}`;
68
189
  // Defensive: a previous run that crashed mid-write may have left this exact temp name behind.
@@ -119,7 +240,15 @@ export async function writeGraph(databasePath, input) {
119
240
  // before the rename so the new file never inherits a stale WAL/SHM pair.
120
241
  await rm(`${databasePath}-wal`, { force: true });
121
242
  await rm(`${databasePath}-shm`, { force: true });
122
- await rename(tempPath, databasePath);
243
+ try {
244
+ await renameWithRetry(tempPath, databasePath);
245
+ }
246
+ catch (error) {
247
+ // The rename sat outside the build's cleanup, so a failed replace left a finished
248
+ // multi-megabyte temp behind. Reproduced before this change.
249
+ await removeGraphFiles(tempPath);
250
+ throw error;
251
+ }
123
252
  // rename() only moves the main file; the temp name's own sidecars (should the driver have left any)
124
253
  // never travel with it and would otherwise linger under the temp name forever.
125
254
  await removeGraphFiles(tempPath);
@@ -149,7 +278,7 @@ export async function writeGraph(databasePath, input) {
149
278
  export async function writeJsonAtomically(target, value) {
150
279
  const temp = `${target}.tmp-${process.pid}-${randomUUID()}`;
151
280
  await writeFile(temp, JSON.stringify(value));
152
- await rename(temp, target);
281
+ await renameWithRetry(temp, target);
153
282
  }
154
283
  /**
155
284
  * Write the prompt-time status sidecar beside the graph, replacing it atomically.
package/lib/walk.js CHANGED
@@ -171,9 +171,12 @@ async function prepareCandidate(candidate, config, cache, signal) {
171
171
  */
172
172
  async function extractWithJs(file, signal) {
173
173
  const parser = await createParser(file.spec);
174
+ // Declared outside the `try` so the `finally` can free it: a `const` inside the `try` is not
175
+ // in scope there, and the tree is where a parse keeps its memory.
176
+ let tree = null;
174
177
  try {
175
178
  signal?.throwIfAborted();
176
- const tree = parser.parse(file.text);
179
+ tree = parser.parse(file.text);
177
180
  /* v8 ignore next 2 -- createParser() always assigns a language before returning; parse() returns
178
181
  * null only when the parser has none. */
179
182
  if (tree === null)
@@ -181,6 +184,13 @@ async function extractWithJs(file, signal) {
181
184
  return extractFile(tree, file.spec);
182
185
  }
183
186
  finally {
187
+ // The memory a parse holds lives in the tree, not the parser: a 362 KB source keeps tens of
188
+ // megabytes of AST. Deleting only the parser leaked it for the life of the process -- measured
189
+ // on a 477-file workspace: RSS +103 MB per index run, +1.1 GB across 11 runs before the host ran
190
+ // out of memory. With this line the same loop is flat at 95 MB.
191
+ /* v8 ignore next -- the same impossible null as the check above: parse() returns null only
192
+ * when the parser has no language, which createParser() never returns. */
193
+ tree?.delete();
184
194
  parser.delete();
185
195
  }
186
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-codegraph-tree-sitter",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Self-built code-graph indexer for DeepSeek Harness — parses a workspace with web-tree-sitter and writes schema version 4 to .codegraph/codegraph.db, the same on-disk format the codegraph CLI writes, so no external indexer is required",
5
5
  "keywords": [
6
6
  "dsh",
@@ -51,7 +51,7 @@
51
51
  "@deepseek-ai/schemastery": "^3.18.1",
52
52
  "tree-sitter-wasms": "^0.1.13",
53
53
  "web-tree-sitter": "^0.25.10",
54
- "dsh-plugin-codegraph-service": "^0.6.2"
54
+ "dsh-plugin-codegraph-service": "^0.6.4"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@deepseek-ai/cordis": ">=4.0.1",
@@ -59,11 +59,11 @@
59
59
  "@deepseek-ai/dsh-invariants": ">=0.1.0-rc.6"
60
60
  },
61
61
  "optionalDependencies": {
62
- "dsh-plugin-codegraph-indexer-darwin-arm64": "0.6.2",
63
- "dsh-plugin-codegraph-indexer-darwin-x64": "0.6.2",
64
- "dsh-plugin-codegraph-indexer-linux-x64-gnu": "0.6.2",
65
- "dsh-plugin-codegraph-indexer-linux-arm64-gnu": "0.6.2",
66
- "dsh-plugin-codegraph-indexer-win32-x64-msvc": "0.6.2"
62
+ "dsh-plugin-codegraph-indexer-darwin-arm64": "0.6.4",
63
+ "dsh-plugin-codegraph-indexer-darwin-x64": "0.6.4",
64
+ "dsh-plugin-codegraph-indexer-linux-x64-gnu": "0.6.4",
65
+ "dsh-plugin-codegraph-indexer-linux-arm64-gnu": "0.6.4",
66
+ "dsh-plugin-codegraph-indexer-win32-x64-msvc": "0.6.4"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@deepseek-ai/cordis": "^4.0.1",