@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 CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './atomic-write.js';
2
+ export * from './project-endpoint.js';
2
3
  export * from './socket-path.js';
3
4
  //# sourceMappingURL=index.d.ts.map
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/persistence",
3
- "version": "0.305.0",
3
+ "version": "0.305.1",
4
4
  "license": "MIT",
5
5
  "description": "Dependency-free filesystem persistence primitives shared across WrongStack packages.",
6
6
  "repository": {