@wrongstack/persistence 0.305.0 → 0.305.1
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/dist/index.d.ts +1 -0
- package/dist/index.js +108 -0
- package/dist/project-endpoint.d.ts +55 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -239,6 +239,11 @@ var atomicWrite = defaultPrimitives.atomicWrite;
|
|
|
239
239
|
var ensureDir = defaultPrimitives.ensureDir;
|
|
240
240
|
var withFileLock = defaultPrimitives.withFileLock;
|
|
241
241
|
|
|
242
|
+
// src/project-endpoint.ts
|
|
243
|
+
import * as fsPromises from "node:fs/promises";
|
|
244
|
+
import * as net from "node:net";
|
|
245
|
+
import * as path2 from "node:path";
|
|
246
|
+
|
|
242
247
|
// src/socket-path.ts
|
|
243
248
|
function unixSocketPathLimit(platform = process.platform) {
|
|
244
249
|
if (platform === "darwin" || platform === "freebsd" || platform === "openbsd") return 103;
|
|
@@ -259,13 +264,116 @@ function assertUnixSocketPathWithinLimit(socketPath, service, platform = process
|
|
|
259
264
|
`${service} IPC socket path is ${check.byteLength} bytes, over the ${platform} sun_path limit of ${check.maxBytes} usable bytes: ${socketPath}. Set a shorter TMPDIR to relocate WrongStack IPC sockets.`
|
|
260
265
|
);
|
|
261
266
|
}
|
|
267
|
+
|
|
268
|
+
// src/project-endpoint.ts
|
|
269
|
+
var PROBE_TIMEOUT_MS = 500;
|
|
270
|
+
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
271
|
+
var ENDPOINT_DIR_MODE = 448;
|
|
272
|
+
var ENDPOINT_FILE_MODE = 384;
|
|
273
|
+
function isErrno(error) {
|
|
274
|
+
return error instanceof Error;
|
|
275
|
+
}
|
|
276
|
+
function isProjectEndpointLive(endpoint, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
277
|
+
return new Promise((resolve) => {
|
|
278
|
+
let settled = false;
|
|
279
|
+
const finish = (live) => {
|
|
280
|
+
if (settled) return;
|
|
281
|
+
settled = true;
|
|
282
|
+
probe.destroy();
|
|
283
|
+
resolve(live);
|
|
284
|
+
};
|
|
285
|
+
const probe = net.createConnection(endpoint);
|
|
286
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
287
|
+
timer.unref?.();
|
|
288
|
+
probe.once("connect", () => {
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
finish(true);
|
|
291
|
+
});
|
|
292
|
+
probe.once("error", () => {
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
finish(false);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
async function ensureProjectEndpointDirectory(endpoint, service) {
|
|
299
|
+
if (process.platform === "win32") return;
|
|
300
|
+
assertUnixSocketPathWithinLimit(endpoint, service);
|
|
301
|
+
await fsPromises.mkdir(path2.dirname(endpoint), {
|
|
302
|
+
recursive: true,
|
|
303
|
+
mode: ENDPOINT_DIR_MODE
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
function attemptListen(server, endpoint) {
|
|
307
|
+
return new Promise((resolve) => {
|
|
308
|
+
const onError = (error) => {
|
|
309
|
+
server.removeListener("listening", onListening);
|
|
310
|
+
resolve(error);
|
|
311
|
+
};
|
|
312
|
+
const onListening = () => {
|
|
313
|
+
server.removeListener("error", onError);
|
|
314
|
+
resolve(null);
|
|
315
|
+
};
|
|
316
|
+
server.once("error", onError);
|
|
317
|
+
server.once("listening", onListening);
|
|
318
|
+
server.listen(endpoint);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
async function bindProjectEndpoint(options) {
|
|
322
|
+
const { server, endpoint, service } = options;
|
|
323
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
324
|
+
const isWindows = process.platform === "win32";
|
|
325
|
+
try {
|
|
326
|
+
await ensureProjectEndpointDirectory(endpoint, service);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return {
|
|
329
|
+
outcome: "failed",
|
|
330
|
+
error: isErrno(error) ? error : new Error(String(error))
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
let reclaimed = false;
|
|
334
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
335
|
+
const error = await attemptListen(server, endpoint);
|
|
336
|
+
if (!error) {
|
|
337
|
+
if (!isWindows) {
|
|
338
|
+
await fsPromises.chmod(endpoint, ENDPOINT_FILE_MODE).catch(() => {
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return { outcome: "bound", reclaimedStaleEndpoint: reclaimed };
|
|
342
|
+
}
|
|
343
|
+
if (error.code !== "EADDRINUSE") return { outcome: "failed", error };
|
|
344
|
+
if (isWindows) return { outcome: "already-owned" };
|
|
345
|
+
if (await isProjectEndpointLive(endpoint)) return { outcome: "already-owned" };
|
|
346
|
+
try {
|
|
347
|
+
await fsPromises.rm(endpoint, { force: true });
|
|
348
|
+
reclaimed = true;
|
|
349
|
+
} catch (removeError) {
|
|
350
|
+
const code = isErrno(removeError) ? removeError.code : void 0;
|
|
351
|
+
if (code !== "ENOENT") {
|
|
352
|
+
return {
|
|
353
|
+
outcome: "failed",
|
|
354
|
+
error: new Error(
|
|
355
|
+
`${service} could not reclaim its stale IPC endpoint at ${endpoint}: ${isErrno(removeError) ? removeError.message : String(removeError)}`
|
|
356
|
+
)
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
outcome: "failed",
|
|
363
|
+
error: new Error(
|
|
364
|
+
`${service} could not bind its IPC endpoint at ${endpoint} after ${maxAttempts} attempts: the endpoint is in use but no daemon answers on it. Remove the stale socket and retry.`
|
|
365
|
+
)
|
|
366
|
+
};
|
|
367
|
+
}
|
|
262
368
|
export {
|
|
263
369
|
PersistenceFsError,
|
|
264
370
|
assertUnixSocketPathWithinLimit,
|
|
265
371
|
atomicWrite,
|
|
372
|
+
bindProjectEndpoint,
|
|
266
373
|
checkUnixSocketPath,
|
|
267
374
|
createPersistencePrimitives,
|
|
268
375
|
ensureDir,
|
|
376
|
+
isProjectEndpointLive,
|
|
269
377
|
unixSocketPathLimit,
|
|
270
378
|
withFileLock
|
|
271
379
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as net from 'node:net';
|
|
2
|
+
export type BindProjectEndpointOutcome =
|
|
3
|
+
/** This process owns the endpoint. It is listening. */
|
|
4
|
+
{
|
|
5
|
+
readonly outcome: 'bound';
|
|
6
|
+
readonly reclaimedStaleEndpoint: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A live daemon already owns this endpoint. Not an error: the caller lost a
|
|
10
|
+
* startup race it did not need to win, and must exit 0 without touching
|
|
11
|
+
* project state. Clients will reach the winner.
|
|
12
|
+
*/
|
|
13
|
+
| {
|
|
14
|
+
readonly outcome: 'already-owned';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The endpoint could not be bound and could not be reclaimed. The caller
|
|
18
|
+
* should report `error` and exit non-zero. This is the only outcome that
|
|
19
|
+
* warrants operator attention.
|
|
20
|
+
*/
|
|
21
|
+
| {
|
|
22
|
+
readonly outcome: 'failed';
|
|
23
|
+
readonly error: Error;
|
|
24
|
+
};
|
|
25
|
+
export interface BindProjectEndpointOptions {
|
|
26
|
+
/** A server that has NOT yet been asked to listen. */
|
|
27
|
+
readonly server: net.Server;
|
|
28
|
+
/** Endpoint to bind: a socket path, or a `\\.\pipe\...` name on Windows. */
|
|
29
|
+
readonly endpoint: string;
|
|
30
|
+
/** Service name, used only in error text (`kanban`, `sage`, ...). */
|
|
31
|
+
readonly service: string;
|
|
32
|
+
/** Override the reclaim attempt budget. Defaults to 3. */
|
|
33
|
+
readonly maxAttempts?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* True when something is accepting connections at `endpoint` right now.
|
|
37
|
+
*
|
|
38
|
+
* This is the only safe way to tell a live owner from a stale socket file, and
|
|
39
|
+
* the answer decides whether reclaiming is allowed. Any failure — refused,
|
|
40
|
+
* missing, timed out — reads as "not live": a daemon that cannot be reached
|
|
41
|
+
* within the probe window cannot be serving clients either, and leaving the
|
|
42
|
+
* project permanently unstartable is the worse outcome.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isProjectEndpointLive(endpoint: string, timeoutMs?: number): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Bind `endpoint`, reclaiming it from a dead owner when — and only when — a
|
|
47
|
+
* liveness probe proves no owner is there.
|
|
48
|
+
*
|
|
49
|
+
* On success the socket is chmod'ed to `0600`. That is belt-and-braces next to
|
|
50
|
+
* the `0700` directory, and it is best-effort: a filesystem that rejects the
|
|
51
|
+
* chmod has not invalidated the directory boundary, so a failure here is not
|
|
52
|
+
* worth refusing to start over.
|
|
53
|
+
*/
|
|
54
|
+
export declare function bindProjectEndpoint(options: BindProjectEndpointOptions): Promise<BindProjectEndpointOutcome>;
|
|
55
|
+
//# sourceMappingURL=project-endpoint.d.ts.map
|