@jitsusama/agentic-harness.core 0.6.1 → 0.6.3
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,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An advisory lock around one file's read-modify-write, for async
|
|
3
|
+
* callers.
|
|
4
|
+
*
|
|
5
|
+
* A read-modify-write that two writers run at once loses one of them:
|
|
6
|
+
* both read the same document and the second write lays its version
|
|
7
|
+
* over the first. Two layers stop that. Within a process, writers of
|
|
8
|
+
* one path queue behind each other, so parallel tool calls in one
|
|
9
|
+
* session never contend. Across processes, a lock file created with
|
|
10
|
+
* `wx` holds the holder's pid, and the next writer waits for it.
|
|
11
|
+
*
|
|
12
|
+
* The synchronous counterpart for quest READMEs is in
|
|
13
|
+
* `quest/io.ts`; its callers cannot await, so it spins instead.
|
|
14
|
+
*
|
|
15
|
+
* A lock is taken over when its holder has gone or it is older than
|
|
16
|
+
* any real write takes, since a crashed session must not wedge the
|
|
17
|
+
* file for good and a pid can be reused. Taking over renames the lock
|
|
18
|
+
* aside rather than deleting it, and checks the file it moved is the
|
|
19
|
+
* one it judged stale: between that judgement and the move, another
|
|
20
|
+
* writer may have taken over first and made a fresh one, and deleting
|
|
21
|
+
* by name would delete theirs. Release checks the same way, so a
|
|
22
|
+
* holder that was taken over from does not delete its successor's.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Run `fn` while holding the lock for `path`, queued behind any other
|
|
26
|
+
* writer of the same path in this process and locked against every
|
|
27
|
+
* other process. The lock file is `${path}.lock`, so its directory has
|
|
28
|
+
* to exist.
|
|
29
|
+
*/
|
|
30
|
+
export declare function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An advisory lock around one file's read-modify-write, for async
|
|
3
|
+
* callers.
|
|
4
|
+
*
|
|
5
|
+
* A read-modify-write that two writers run at once loses one of them:
|
|
6
|
+
* both read the same document and the second write lays its version
|
|
7
|
+
* over the first. Two layers stop that. Within a process, writers of
|
|
8
|
+
* one path queue behind each other, so parallel tool calls in one
|
|
9
|
+
* session never contend. Across processes, a lock file created with
|
|
10
|
+
* `wx` holds the holder's pid, and the next writer waits for it.
|
|
11
|
+
*
|
|
12
|
+
* The synchronous counterpart for quest READMEs is in
|
|
13
|
+
* `quest/io.ts`; its callers cannot await, so it spins instead.
|
|
14
|
+
*
|
|
15
|
+
* A lock is taken over when its holder has gone or it is older than
|
|
16
|
+
* any real write takes, since a crashed session must not wedge the
|
|
17
|
+
* file for good and a pid can be reused. Taking over renames the lock
|
|
18
|
+
* aside rather than deleting it, and checks the file it moved is the
|
|
19
|
+
* one it judged stale: between that judgement and the move, another
|
|
20
|
+
* writer may have taken over first and made a fresh one, and deleting
|
|
21
|
+
* by name would delete theirs. Release checks the same way, so a
|
|
22
|
+
* holder that was taken over from does not delete its successor's.
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { link, open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
26
|
+
/** How long a writer waits for a live holder before saying so. */
|
|
27
|
+
const WAIT_MS = 10_000;
|
|
28
|
+
/** Pause between attempts at the lock. */
|
|
29
|
+
const RETRY_MS = 20;
|
|
30
|
+
/**
|
|
31
|
+
* A lock older than this is taken over whoever holds it. A write under
|
|
32
|
+
* it is a read and a rename of a small file, milliseconds, so anything
|
|
33
|
+
* this old is a holder that stopped rather than one still writing.
|
|
34
|
+
*/
|
|
35
|
+
const STALE_MS = 30_000;
|
|
36
|
+
const queues = new Map();
|
|
37
|
+
/**
|
|
38
|
+
* Run `fn` while holding the lock for `path`, queued behind any other
|
|
39
|
+
* writer of the same path in this process and locked against every
|
|
40
|
+
* other process. The lock file is `${path}.lock`, so its directory has
|
|
41
|
+
* to exist.
|
|
42
|
+
*/
|
|
43
|
+
export async function withFileLock(path, fn) {
|
|
44
|
+
const before = queues.get(path) ?? Promise.resolve();
|
|
45
|
+
const turn = before.then(() => locked(path, fn), () => locked(path, fn));
|
|
46
|
+
// The queue only orders writers; it never carries a failure from one
|
|
47
|
+
// to the next, so the entry it holds settles either way.
|
|
48
|
+
const settled = turn.then(() => undefined, () => undefined);
|
|
49
|
+
queues.set(path, settled);
|
|
50
|
+
try {
|
|
51
|
+
return await turn;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
if (queues.get(path) === settled)
|
|
55
|
+
queues.delete(path);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function locked(path, fn) {
|
|
59
|
+
const lockPath = `${path}.lock`;
|
|
60
|
+
const held = await acquire(lockPath);
|
|
61
|
+
try {
|
|
62
|
+
return await fn();
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await release(lockPath, held);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function acquire(lockPath) {
|
|
69
|
+
const deadline = Date.now() + WAIT_MS;
|
|
70
|
+
while (true) {
|
|
71
|
+
try {
|
|
72
|
+
const handle = await open(lockPath, "wx");
|
|
73
|
+
try {
|
|
74
|
+
await handle.writeFile(String(process.pid), "utf8");
|
|
75
|
+
return (await handle.stat()).ino;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await handle.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (!hasCode(error, "EEXIST"))
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
if (await takeOverIfStale(lockPath))
|
|
86
|
+
continue;
|
|
87
|
+
if (Date.now() >= deadline) {
|
|
88
|
+
const holder = await holderOf(lockPath);
|
|
89
|
+
throw new Error(`${lockPath} has been held for over ${WAIT_MS / 1000}s by ${holder === undefined ? "a process that did not say which" : `pid ${holder}`}, which is still running. Nothing was written. If that process is not writing, delete the lock file and try again.`);
|
|
90
|
+
}
|
|
91
|
+
await new Promise((done) => setTimeout(done, RETRY_MS));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function takeOverIfStale(lockPath) {
|
|
95
|
+
let judged;
|
|
96
|
+
try {
|
|
97
|
+
judged = await stat(lockPath);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
// Released between the failed create and here: try again.
|
|
101
|
+
if (hasCode(error, "ENOENT"))
|
|
102
|
+
return true;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const holder = await holderOf(lockPath);
|
|
106
|
+
const old = Date.now() - judged.mtimeMs > STALE_MS;
|
|
107
|
+
// A holder that has not written its pid yet is one mid-create, not
|
|
108
|
+
// one that has gone, so only age can make its lock stale.
|
|
109
|
+
const gone = holder !== undefined && !isAlive(holder);
|
|
110
|
+
if (!old && !gone)
|
|
111
|
+
return false;
|
|
112
|
+
const aside = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
|
|
113
|
+
try {
|
|
114
|
+
await rename(lockPath, aside);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// Somebody else took it over first.
|
|
118
|
+
if (hasCode(error, "ENOENT"))
|
|
119
|
+
return true;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
const moved = await stat(aside);
|
|
123
|
+
if (moved.ino !== judged.ino) {
|
|
124
|
+
// Between the judgement and the move another writer took over and
|
|
125
|
+
// made a fresh lock, and this moved theirs. Put it back, unless a
|
|
126
|
+
// third has already made one, in which case theirs stands and the
|
|
127
|
+
// one moved is left for its holder's release to find missing.
|
|
128
|
+
try {
|
|
129
|
+
await link(aside, lockPath);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (!hasCode(error, "EEXIST"))
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
await unlink(aside);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
async function release(lockPath, ino) {
|
|
140
|
+
try {
|
|
141
|
+
if ((await stat(lockPath)).ino === ino)
|
|
142
|
+
await unlink(lockPath);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
// Taken over and released by somebody else already: nothing of
|
|
146
|
+
// this holder's is left to remove.
|
|
147
|
+
if (!hasCode(error, "ENOENT"))
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function holderOf(lockPath) {
|
|
152
|
+
try {
|
|
153
|
+
const pid = Number.parseInt(await readFile(lockPath, "utf8"), 10);
|
|
154
|
+
return Number.isFinite(pid) && pid > 0 ? pid : undefined;
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
// Gone since the create failed, which the next attempt handles.
|
|
158
|
+
if (hasCode(error, "ENOENT"))
|
|
159
|
+
return undefined;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function isAlive(pid) {
|
|
164
|
+
// This process never waits on its own lock, since its writers queue
|
|
165
|
+
// first, so a lock naming it is one a previous process with the same
|
|
166
|
+
// pid left behind.
|
|
167
|
+
if (pid === process.pid)
|
|
168
|
+
return false;
|
|
169
|
+
try {
|
|
170
|
+
process.kill(pid, 0);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
// EPERM is a process that exists and belongs to somebody else.
|
|
175
|
+
return hasCode(error, "EPERM");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function hasCode(error, code) {
|
|
179
|
+
return (typeof error === "object" &&
|
|
180
|
+
error !== null &&
|
|
181
|
+
"code" in error &&
|
|
182
|
+
error.code === code);
|
|
183
|
+
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* that bypasses it can still write through. We rely on
|
|
27
27
|
* every README write going through `withQuestLock`.
|
|
28
28
|
*/
|
|
29
|
-
import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
|
|
29
|
+
import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
|
|
30
30
|
import { dirname, join } from "node:path";
|
|
31
31
|
/** Lock-acquire retry budget per call. */
|
|
32
32
|
const LOCK_TIMEOUT_MS = 5000;
|
|
@@ -102,30 +102,52 @@ function isProcessAlive(pid) {
|
|
|
102
102
|
return err.code === "EPERM";
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Take the lock over if its holder has gone or it is too old to be a
|
|
107
|
+
* write in progress.
|
|
108
|
+
*
|
|
109
|
+
* A lock with no readable record is judged by its file's age alone: a
|
|
110
|
+
* holder creates the lock and then writes its record, so for a moment
|
|
111
|
+
* every live lock is empty, and treating that as abandoned took locks
|
|
112
|
+
* from their holders. Taking over moves the lock aside and checks the
|
|
113
|
+
* move took the one judged stale; deleting by name could delete a lock
|
|
114
|
+
* another writer made after taking over first.
|
|
115
|
+
*/
|
|
105
116
|
function tryStealStaleLock(lockPath, now) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
catch {
|
|
113
|
-
// Another process beat us to the cleanup; that's fine.
|
|
114
|
-
}
|
|
117
|
+
let judged;
|
|
118
|
+
try {
|
|
119
|
+
judged = statSync(lockPath);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Released since the failed create; the next attempt takes it.
|
|
115
123
|
return true;
|
|
116
124
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
125
|
+
const record = readLockRecord(lockPath);
|
|
126
|
+
const stale = record
|
|
127
|
+
? now - record.startedAt >= STALE_LOCK_MS || !isProcessAlive(record.pid)
|
|
128
|
+
: now - judged.mtimeMs >= STALE_LOCK_MS;
|
|
129
|
+
if (!stale)
|
|
119
130
|
return false;
|
|
131
|
+
const aside = `${lockPath}.stale-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
120
132
|
try {
|
|
121
|
-
|
|
122
|
-
return true;
|
|
133
|
+
renameSync(lockPath, aside);
|
|
123
134
|
}
|
|
124
135
|
catch {
|
|
125
|
-
//
|
|
126
|
-
// next loop iteration retry the acquire.
|
|
136
|
+
// Somebody else took it over first; the next attempt sees theirs.
|
|
127
137
|
return false;
|
|
128
138
|
}
|
|
139
|
+
if (statSync(aside).ino !== judged.ino) {
|
|
140
|
+
// Moved a fresh lock another writer made after taking over first.
|
|
141
|
+
// Put it back, unless a third already made one, which then stands.
|
|
142
|
+
try {
|
|
143
|
+
linkSync(aside, lockPath);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// EEXIST: the newer lock stands, as above.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
unlinkSync(aside);
|
|
150
|
+
return true;
|
|
129
151
|
}
|
|
130
152
|
function acquireLock(lockPath) {
|
|
131
153
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
@@ -139,7 +161,7 @@ function acquireLock(lockPath) {
|
|
|
139
161
|
};
|
|
140
162
|
writeSync(fd, JSON.stringify(payload));
|
|
141
163
|
fsyncSync(fd);
|
|
142
|
-
return fd;
|
|
164
|
+
return { fd, ino: fstatSync(fd).ino };
|
|
143
165
|
}
|
|
144
166
|
catch (err) {
|
|
145
167
|
const code = err.code;
|
|
@@ -165,15 +187,18 @@ function acquireLock(lockPath) {
|
|
|
165
187
|
}
|
|
166
188
|
}
|
|
167
189
|
}
|
|
168
|
-
function releaseLock(lockPath,
|
|
190
|
+
function releaseLock(lockPath, held) {
|
|
169
191
|
try {
|
|
170
|
-
closeSync(fd);
|
|
192
|
+
closeSync(held.fd);
|
|
171
193
|
}
|
|
172
194
|
catch {
|
|
173
195
|
// Already closed; cleanup of the path below still runs.
|
|
174
196
|
}
|
|
175
197
|
try {
|
|
176
|
-
|
|
198
|
+
// Only this holder's own lock: one taken over from it belongs to
|
|
199
|
+
// whoever made the new one.
|
|
200
|
+
if (statSync(lockPath).ino === held.ino)
|
|
201
|
+
unlinkSync(lockPath);
|
|
177
202
|
}
|
|
178
203
|
catch {
|
|
179
204
|
// Lock file was stolen out from under us or never
|
|
@@ -203,12 +228,12 @@ export function withQuestLock(questDir, fn) {
|
|
|
203
228
|
// Stat failed; the acquire loop handles it.
|
|
204
229
|
}
|
|
205
230
|
}
|
|
206
|
-
const
|
|
231
|
+
const held = acquireLock(lockPath);
|
|
207
232
|
try {
|
|
208
233
|
return fn();
|
|
209
234
|
}
|
|
210
235
|
finally {
|
|
211
|
-
releaseLock(lockPath,
|
|
236
|
+
releaseLock(lockPath, held);
|
|
212
237
|
}
|
|
213
238
|
}
|
|
214
239
|
/**
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* file. When nothing resolves, it fails with a clear message
|
|
12
12
|
* rather than a cryptic one.
|
|
13
13
|
*
|
|
14
|
+
* A server nobody has called for `idleMs` is stopped and dropped
|
|
15
|
+
* from the pool, and the next call for its root starts a fresh
|
|
16
|
+
* one: a TypeScript server holds hundreds of megabytes, and a long
|
|
17
|
+
* session touches many roots it never returns to. A call in
|
|
18
|
+
* flight keeps its servers alive however long it takes.
|
|
19
|
+
*
|
|
14
20
|
* The live server pool lives in this closure's memory for the
|
|
15
21
|
* life of the process that constructs it. A stateless-per-call
|
|
16
22
|
* CLI adapter that wants warm servers across invocations needs
|
|
@@ -30,6 +36,8 @@ export interface StandaloneBackendOptions {
|
|
|
30
36
|
readonly servers?: Readonly<Record<string, ServerConfig>>;
|
|
31
37
|
/** Environment used for PATH resolution. Defaults to process.env. */
|
|
32
38
|
readonly env?: NodeJS.ProcessEnv;
|
|
39
|
+
/** How long a server may sit unused before it is stopped. */
|
|
40
|
+
readonly idleMs?: number;
|
|
33
41
|
}
|
|
34
42
|
/** A standalone backend with visibility into its live pool. */
|
|
35
43
|
export interface StandaloneBackend extends LspBackend {
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* file. When nothing resolves, it fails with a clear message
|
|
12
12
|
* rather than a cryptic one.
|
|
13
13
|
*
|
|
14
|
+
* A server nobody has called for `idleMs` is stopped and dropped
|
|
15
|
+
* from the pool, and the next call for its root starts a fresh
|
|
16
|
+
* one: a TypeScript server holds hundreds of megabytes, and a long
|
|
17
|
+
* session touches many roots it never returns to. A call in
|
|
18
|
+
* flight keeps its servers alive however long it takes.
|
|
19
|
+
*
|
|
14
20
|
* The live server pool lives in this closure's memory for the
|
|
15
21
|
* life of the process that constructs it. A stateless-per-call
|
|
16
22
|
* CLI adapter that wants warm servers across invocations needs
|
|
@@ -28,20 +34,56 @@ export class MissingServerError extends Error {
|
|
|
28
34
|
this.name = "MissingServerError";
|
|
29
35
|
}
|
|
30
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Default idle window. Long enough that a burst of work on one
|
|
39
|
+
* project keeps its server warm, short enough that a project left
|
|
40
|
+
* behind gives its memory back within the hour.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_IDLE_MS = 10 * 60_000;
|
|
31
43
|
/** Construct a standalone backend over the given (or default) server map. */
|
|
32
44
|
export function createStandaloneBackend(options = {}) {
|
|
33
45
|
const servers = options.servers ?? DEFAULT_SERVERS;
|
|
34
46
|
const env = options.env ?? process.env;
|
|
47
|
+
const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;
|
|
35
48
|
const pool = new Map();
|
|
49
|
+
// Calls in flight per pool key, and the stop timer armed when a
|
|
50
|
+
// key's last call finishes.
|
|
51
|
+
const inFlight = new Map();
|
|
52
|
+
const idleTimers = new Map();
|
|
36
53
|
const poolKey = (name, root) => `${name}|${root}`;
|
|
54
|
+
const claim = (key) => {
|
|
55
|
+
inFlight.set(key, (inFlight.get(key) ?? 0) + 1);
|
|
56
|
+
clearTimeout(idleTimers.get(key));
|
|
57
|
+
idleTimers.delete(key);
|
|
58
|
+
};
|
|
59
|
+
const release = (key) => {
|
|
60
|
+
const left = (inFlight.get(key) ?? 1) - 1;
|
|
61
|
+
if (left > 0) {
|
|
62
|
+
inFlight.set(key, left);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
inFlight.delete(key);
|
|
66
|
+
const timer = setTimeout(() => stopIdle(key), idleMs);
|
|
67
|
+
// An idle server must never be what keeps the process alive.
|
|
68
|
+
timer.unref();
|
|
69
|
+
idleTimers.set(key, timer);
|
|
70
|
+
};
|
|
71
|
+
const stopIdle = (key) => {
|
|
72
|
+
idleTimers.delete(key);
|
|
73
|
+
if (inFlight.has(key))
|
|
74
|
+
return;
|
|
75
|
+
const started = pool.get(key);
|
|
76
|
+
pool.delete(key);
|
|
77
|
+
void started?.then((server) => server.dispose(), () => { });
|
|
78
|
+
};
|
|
37
79
|
const instanceFor = (server, root, binary) => {
|
|
38
80
|
const key = poolKey(server.name, root);
|
|
39
81
|
const existing = pool.get(key);
|
|
40
82
|
if (existing)
|
|
41
|
-
return existing;
|
|
83
|
+
return { key, started: existing };
|
|
42
84
|
const started = StandaloneServer.start(server, root, binary);
|
|
43
85
|
pool.set(key, started);
|
|
44
|
-
return started;
|
|
86
|
+
return { key, started };
|
|
45
87
|
};
|
|
46
88
|
// Resolve a server's effective command and args for a root. A
|
|
47
89
|
// server with a `resolve` hook picks its binary per project (the
|
|
@@ -65,7 +107,22 @@ export function createStandaloneBackend(options = {}) {
|
|
|
65
107
|
binary,
|
|
66
108
|
};
|
|
67
109
|
};
|
|
68
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Run fn against the servers for a file, holding each one's pool
|
|
112
|
+
* key claimed from before it resolves until fn settles, so no
|
|
113
|
+
* server is stopped under a call that is using it.
|
|
114
|
+
*/
|
|
115
|
+
const withInstances = async (filePath, typeOnly, fn) => {
|
|
116
|
+
const claimed = [];
|
|
117
|
+
try {
|
|
118
|
+
return await fn(await resolveInstances(filePath, typeOnly, claimed));
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
for (const key of claimed)
|
|
122
|
+
release(key);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const resolveInstances = async (filePath, typeOnly, claimed) => {
|
|
69
126
|
const candidates = serversForFile(filePath, servers).filter((server) => !typeOnly || !server.isLinter);
|
|
70
127
|
const instances = [];
|
|
71
128
|
const reasons = [];
|
|
@@ -80,7 +137,10 @@ export function createStandaloneBackend(options = {}) {
|
|
|
80
137
|
reasons.push(eff.reason);
|
|
81
138
|
continue;
|
|
82
139
|
}
|
|
83
|
-
|
|
140
|
+
const { key, started } = instanceFor(eff.config, root, eff.binary);
|
|
141
|
+
claim(key);
|
|
142
|
+
claimed.push(key);
|
|
143
|
+
instances.push(await started);
|
|
84
144
|
if (typeOnly)
|
|
85
145
|
break;
|
|
86
146
|
}
|
|
@@ -95,40 +155,46 @@ export function createStandaloneBackend(options = {}) {
|
|
|
95
155
|
return {
|
|
96
156
|
name: "standalone",
|
|
97
157
|
async diagnostics(path) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
158
|
+
return withInstances(path, false, async (instances) => {
|
|
159
|
+
const results = await Promise.all(instances.map((s) => s.diagnose(path)));
|
|
160
|
+
return results.flat();
|
|
161
|
+
});
|
|
101
162
|
},
|
|
102
163
|
async definition(target) {
|
|
103
|
-
|
|
104
|
-
return server.definition(target);
|
|
164
|
+
return withInstances(target.path, true, ([server]) => server.definition(target));
|
|
105
165
|
},
|
|
106
166
|
async references(target) {
|
|
107
|
-
|
|
108
|
-
return server.references(target);
|
|
167
|
+
return withInstances(target.path, true, ([server]) => server.references(target));
|
|
109
168
|
},
|
|
110
169
|
async hover(target) {
|
|
111
|
-
|
|
112
|
-
return server.hover(target);
|
|
170
|
+
return withInstances(target.path, true, ([server]) => server.hover(target));
|
|
113
171
|
},
|
|
114
172
|
async documentSymbols(path) {
|
|
115
|
-
|
|
116
|
-
return server.documentSymbols(path);
|
|
173
|
+
return withInstances(path, true, ([server]) => server.documentSymbols(path));
|
|
117
174
|
},
|
|
118
175
|
async workspaceSymbols(query) {
|
|
119
176
|
// Workspace symbols carry no file, so they search every
|
|
120
|
-
// server already running; nothing is spawned on demand
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
177
|
+
// server already running; nothing is spawned on demand, and
|
|
178
|
+
// a server stopped for idleness is not searched until
|
|
179
|
+
// something file-bound starts it again.
|
|
180
|
+
const keys = [...pool.keys()];
|
|
181
|
+
for (const key of keys)
|
|
182
|
+
claim(key);
|
|
183
|
+
try {
|
|
184
|
+
const live = await Promise.all(keys.map((key) => pool.get(key)));
|
|
185
|
+
const results = await Promise.all(live.map((server) => server?.workspaceSymbols(query) ?? []));
|
|
186
|
+
return results.flat();
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
for (const key of keys)
|
|
190
|
+
release(key);
|
|
191
|
+
}
|
|
124
192
|
},
|
|
125
193
|
async rename(target, newName) {
|
|
126
|
-
|
|
127
|
-
return server.rename(target, newName);
|
|
194
|
+
return withInstances(target.path, true, ([server]) => server.rename(target, newName));
|
|
128
195
|
},
|
|
129
196
|
async codeActions(path, range) {
|
|
130
|
-
|
|
131
|
-
return server.codeActions(path, range);
|
|
197
|
+
return withInstances(path, true, ([server]) => server.codeActions(path, range));
|
|
132
198
|
},
|
|
133
199
|
syncDocument(path, text) {
|
|
134
200
|
for (const started of pool.values()) {
|
|
@@ -142,6 +208,9 @@ export function createStandaloneBackend(options = {}) {
|
|
|
142
208
|
return pool.size;
|
|
143
209
|
},
|
|
144
210
|
async dispose() {
|
|
211
|
+
for (const timer of idleTimers.values())
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
idleTimers.clear();
|
|
145
214
|
const started = [...pool.values()];
|
|
146
215
|
pool.clear();
|
|
147
216
|
await Promise.all(started.map((s) => s.then((server) => server.dispose())));
|
package/dist/review/ask/store.js
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* council needs that council still to be there, or nothing can say
|
|
12
12
|
* what it consolidated.
|
|
13
13
|
*/
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
14
15
|
import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
15
16
|
import { join } from "node:path";
|
|
17
|
+
import { withFileLock } from "../../internal/file-lock.js";
|
|
16
18
|
import { changeKey } from "../keys.js";
|
|
17
19
|
/** Runs on disk, one file per change. */
|
|
18
20
|
export function createRunStore(root) {
|
|
@@ -71,15 +73,29 @@ export function createRunStore(root) {
|
|
|
71
73
|
// whose whole premise is that the session may not survive it,
|
|
72
74
|
// so the window stopped being theoretical. A rename within one
|
|
73
75
|
// directory is atomic: a reader sees the old ledger or the new
|
|
74
|
-
// one.
|
|
75
|
-
|
|
76
|
+
// one. Named per write, not per process: two writes in one
|
|
77
|
+
// process sharing a name tore the file, the shorter write landing
|
|
78
|
+
// over the start of the longer.
|
|
79
|
+
const pending = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
76
80
|
await writeFile(pending, JSON.stringify(ledger, null, 2), "utf8");
|
|
77
81
|
await rename(pending, path);
|
|
78
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Read, change and write one change's ledger as a single step. Every
|
|
85
|
+
* write is a whole ledger built from the one read before it, so two
|
|
86
|
+
* of these at once, from parallel tool calls or two sessions on one
|
|
87
|
+
* change, would each lay its version over the other's and drop a
|
|
88
|
+
* round without an error.
|
|
89
|
+
*/
|
|
90
|
+
async function mutate(change, next) {
|
|
91
|
+
await mkdir(root, { recursive: true });
|
|
92
|
+
await withFileLock(join(root, fileFor(change)), async () => {
|
|
93
|
+
await write(change, next(await read(change)));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
79
96
|
return {
|
|
80
97
|
async record(change, run) {
|
|
81
|
-
|
|
82
|
-
await write(change, { runs: [...ledger.runs, run] });
|
|
98
|
+
await mutate(change, (ledger) => ({ runs: [...ledger.runs, run] }));
|
|
83
99
|
},
|
|
84
100
|
async list(change) {
|
|
85
101
|
return (await read(change)).runs;
|
|
@@ -176,24 +192,26 @@ export function createRunStore(root) {
|
|
|
176
192
|
return (await read(change)).runs.find((r) => r.id === runId);
|
|
177
193
|
},
|
|
178
194
|
async keep(change, run) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
195
|
+
await mutate(change, (ledger) => {
|
|
196
|
+
const at = ledger.runs.findIndex((held) => held.id === run.id);
|
|
197
|
+
return {
|
|
198
|
+
runs: at === -1
|
|
199
|
+
? [...ledger.runs, run]
|
|
200
|
+
: ledger.runs.map((held, index) => (index === at ? run : held)),
|
|
201
|
+
};
|
|
185
202
|
});
|
|
186
203
|
},
|
|
187
204
|
async replace(change, run) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
205
|
+
await mutate(change, (ledger) => {
|
|
206
|
+
const at = ledger.runs.findIndex((held) => held.id === run.id);
|
|
207
|
+
if (at === -1) {
|
|
208
|
+
// Adding it silently would make a retry look like it
|
|
209
|
+
// patched something when it invented a round instead.
|
|
210
|
+
throw new Error(`No run "${run.id}" is held against this change, so there is nothing to replace. Record it first, or check the id.`);
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
runs: ledger.runs.map((held, index) => (index === at ? run : held)),
|
|
214
|
+
};
|
|
197
215
|
});
|
|
198
216
|
},
|
|
199
217
|
};
|
package/package.json
CHANGED