dflockd-client 1.8.3 → 1.9.0

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/client.js CHANGED
@@ -21,6 +21,33 @@ var AuthError = class extends LockError {
21
21
  this.name = "AuthError";
22
22
  }
23
23
  };
24
+ function validateKey(key) {
25
+ if (key === "") {
26
+ throw new LockError("key must not be empty");
27
+ }
28
+ if (/[\0\n\r]/.test(key)) {
29
+ throw new LockError(
30
+ "key must not contain NUL, newline, or carriage return characters"
31
+ );
32
+ }
33
+ }
34
+ function validateAuth(auth) {
35
+ if (/[\0\n\r]/.test(auth)) {
36
+ throw new LockError(
37
+ "auth token must not contain NUL, newline, or carriage return characters"
38
+ );
39
+ }
40
+ }
41
+ function validateToken(token) {
42
+ if (token === "") {
43
+ throw new LockError("token must not be empty");
44
+ }
45
+ if (/[\0\n\r]/.test(token)) {
46
+ throw new LockError(
47
+ "token must not contain NUL, newline, or carriage return characters"
48
+ );
49
+ }
50
+ }
24
51
  function encodeLines(...lines) {
25
52
  return Buffer.from(lines.map((l) => l + "\n").join(""), "utf-8");
26
53
  }
@@ -32,7 +59,13 @@ function writeAll(sock, data) {
32
59
  });
33
60
  });
34
61
  }
62
+ function parseLease(value, fallback = 30) {
63
+ if (value == null || value === "") return fallback;
64
+ const n = Number(value);
65
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
66
+ }
35
67
  var _readlineBuf = /* @__PURE__ */ new WeakMap();
68
+ var MAX_LINE_LENGTH = 1024 * 1024;
36
69
  function readline(sock) {
37
70
  return new Promise((resolve, reject) => {
38
71
  let buf = _readlineBuf.get(sock) ?? "";
@@ -51,6 +84,10 @@ function readline(sock) {
51
84
  const line = buf.slice(0, idx).replace(/\r$/, "");
52
85
  _readlineBuf.set(sock, buf.slice(idx + 1));
53
86
  resolve(line);
87
+ } else if (buf.length > MAX_LINE_LENGTH) {
88
+ cleanup();
89
+ _readlineBuf.delete(sock);
90
+ reject(new LockError("server response exceeded maximum line length"));
54
91
  }
55
92
  };
56
93
  const onError = (err) => {
@@ -63,38 +100,78 @@ function readline(sock) {
63
100
  _readlineBuf.delete(sock);
64
101
  reject(new LockError("server closed connection"));
65
102
  };
103
+ const onEnd = () => {
104
+ cleanup();
105
+ _readlineBuf.delete(sock);
106
+ reject(new LockError("server closed connection"));
107
+ };
66
108
  const cleanup = () => {
67
109
  sock.removeListener("data", onData);
68
110
  sock.removeListener("error", onError);
69
111
  sock.removeListener("close", onClose);
112
+ sock.removeListener("end", onEnd);
70
113
  };
71
114
  sock.on("data", onData);
72
115
  sock.on("error", onError);
73
116
  sock.on("close", onClose);
117
+ sock.on("end", onEnd);
74
118
  });
75
119
  }
76
- async function connect2(host, port, tlsOptions, auth) {
120
+ async function connect2(host, port, tlsOptions, auth, connectTimeoutMs) {
77
121
  const sock = await new Promise((resolve, reject) => {
122
+ let timer = null;
123
+ let settled = false;
124
+ const connectEvent = tlsOptions ? "secureConnect" : "connect";
125
+ const onConnect = () => {
126
+ if (settled) return;
127
+ settled = true;
128
+ if (timer) clearTimeout(timer);
129
+ s.removeListener("error", onError);
130
+ resolve(s);
131
+ };
132
+ const onError = (err) => {
133
+ if (settled) return;
134
+ settled = true;
135
+ if (timer) clearTimeout(timer);
136
+ s.removeListener(connectEvent, onConnect);
137
+ s.destroy();
138
+ reject(err);
139
+ };
140
+ let s;
78
141
  if (tlsOptions) {
79
- const s = tls.connect({ host, port, ...tlsOptions }, () => {
80
- s.removeListener("error", reject);
81
- resolve(s);
82
- });
83
- s.on("error", reject);
142
+ s = tls.connect({ ...tlsOptions, host, port }, onConnect);
84
143
  } else {
85
- const s = net.createConnection({ host, port }, () => {
86
- s.removeListener("error", reject);
87
- resolve(s);
88
- });
89
- s.on("error", reject);
144
+ s = net.createConnection({ host, port }, onConnect);
145
+ }
146
+ s.on("error", onError);
147
+ if (connectTimeoutMs != null && connectTimeoutMs > 0) {
148
+ timer = setTimeout(() => {
149
+ if (settled) return;
150
+ settled = true;
151
+ s.removeListener("error", onError);
152
+ s.removeListener(connectEvent, onConnect);
153
+ s.destroy();
154
+ reject(
155
+ new LockError(
156
+ `connect timed out after ${connectTimeoutMs}ms to ${host}:${port}`
157
+ )
158
+ );
159
+ }, connectTimeoutMs);
90
160
  }
91
161
  });
92
162
  sock.setNoDelay(true);
93
163
  sock.on("error", () => {
94
164
  });
95
165
  if (auth != null && auth !== "") {
96
- await writeAll(sock, encodeLines("auth", "_", auth));
97
- const resp = await readline(sock);
166
+ validateAuth(auth);
167
+ let resp;
168
+ try {
169
+ await writeAll(sock, encodeLines("auth", "_", auth));
170
+ resp = await readline(sock);
171
+ } catch (err) {
172
+ sock.destroy();
173
+ throw err;
174
+ }
98
175
  if (resp === "ok") {
99
176
  return sock;
100
177
  }
@@ -122,9 +199,19 @@ function crc32(buf) {
122
199
  return (crc ^ 4294967295) >>> 0;
123
200
  }
124
201
  function stableHashShard(key, numServers) {
125
- return (crc32(Buffer.from(key, "utf-8")) >>> 0) % numServers;
202
+ if (numServers <= 0) {
203
+ throw new LockError("numServers must be greater than 0");
204
+ }
205
+ return crc32(Buffer.from(key, "utf-8")) % numServers;
126
206
  }
127
207
  async function acquire(sock, key, acquireTimeoutS, leaseTtlS) {
208
+ validateKey(key);
209
+ if (!Number.isFinite(acquireTimeoutS) || acquireTimeoutS < 0) {
210
+ throw new LockError("acquireTimeoutS must be a finite number >= 0");
211
+ }
212
+ if (leaseTtlS != null && (!Number.isFinite(leaseTtlS) || leaseTtlS <= 0)) {
213
+ throw new LockError("leaseTtlS must be a finite number > 0");
214
+ }
128
215
  const arg = leaseTtlS == null ? String(acquireTimeoutS) : `${acquireTimeoutS} ${leaseTtlS}`;
129
216
  await writeAll(sock, encodeLines("l", key, arg));
130
217
  const resp = await readline(sock);
@@ -136,30 +223,44 @@ async function acquire(sock, key, acquireTimeoutS, leaseTtlS) {
136
223
  }
137
224
  const parts = resp.split(" ");
138
225
  const token = parts[1];
139
- const lease = parts.length >= 3 ? parseInt(parts[2], 10) : 30;
226
+ if (!token) {
227
+ throw new LockError(`acquire: server returned no token: '${resp}'`);
228
+ }
229
+ const lease = parseLease(parts[2]);
140
230
  return { token, lease };
141
231
  }
142
232
  async function renew(sock, key, token, leaseTtlS) {
233
+ validateKey(key);
234
+ validateToken(token);
235
+ if (leaseTtlS != null && (!Number.isFinite(leaseTtlS) || leaseTtlS <= 0)) {
236
+ throw new LockError("leaseTtlS must be a finite number > 0");
237
+ }
143
238
  const arg = leaseTtlS == null ? token : `${token} ${leaseTtlS}`;
144
239
  await writeAll(sock, encodeLines("n", key, arg));
145
240
  const resp = await readline(sock);
146
241
  if (resp !== "ok" && !resp.startsWith("ok ")) {
147
242
  throw new LockError(`renew failed: '${resp}'`);
148
243
  }
149
- const parts = resp.split(" ");
150
- if (parts.length >= 2 && /^\d+$/.test(parts[1])) {
151
- return parseInt(parts[1], 10);
244
+ if (resp === "ok") {
245
+ return leaseTtlS ?? 30;
152
246
  }
153
- throw new LockError(`renew: malformed response: '${resp}'`);
247
+ return parseLease(resp.split(" ")[1]);
154
248
  }
155
249
  async function enqueue(sock, key, leaseTtlS) {
250
+ validateKey(key);
251
+ if (leaseTtlS != null && (!Number.isFinite(leaseTtlS) || leaseTtlS <= 0)) {
252
+ throw new LockError("leaseTtlS must be a finite number > 0");
253
+ }
156
254
  const arg = leaseTtlS == null ? "" : String(leaseTtlS);
157
255
  await writeAll(sock, encodeLines("e", key, arg));
158
256
  const resp = await readline(sock);
159
257
  if (resp.startsWith("acquired ")) {
160
258
  const parts = resp.split(" ");
161
259
  const token = parts[1];
162
- const lease = parts.length >= 3 ? parseInt(parts[2], 10) : 30;
260
+ if (!token) {
261
+ throw new LockError(`enqueue: server returned no token: '${resp}'`);
262
+ }
263
+ const lease = parseLease(parts[2]);
163
264
  return { status: "acquired", token, lease };
164
265
  }
165
266
  if (resp === "queued") {
@@ -168,6 +269,10 @@ async function enqueue(sock, key, leaseTtlS) {
168
269
  throw new LockError(`enqueue failed: '${resp}'`);
169
270
  }
170
271
  async function waitForLock(sock, key, waitTimeoutS) {
272
+ validateKey(key);
273
+ if (!Number.isFinite(waitTimeoutS) || waitTimeoutS < 0) {
274
+ throw new LockError("waitTimeoutS must be a finite number >= 0");
275
+ }
171
276
  await writeAll(sock, encodeLines("w", key, String(waitTimeoutS)));
172
277
  const resp = await readline(sock);
173
278
  if (resp === "timeout") {
@@ -178,10 +283,15 @@ async function waitForLock(sock, key, waitTimeoutS) {
178
283
  }
179
284
  const parts = resp.split(" ");
180
285
  const token = parts[1];
181
- const lease = parts.length >= 3 ? parseInt(parts[2], 10) : 30;
286
+ if (!token) {
287
+ throw new LockError(`wait: server returned no token: '${resp}'`);
288
+ }
289
+ const lease = parseLease(parts[2]);
182
290
  return { token, lease };
183
291
  }
184
292
  async function release(sock, key, token) {
293
+ validateKey(key);
294
+ validateToken(token);
185
295
  await writeAll(sock, encodeLines("r", key, token));
186
296
  const resp = await readline(sock);
187
297
  if (resp !== "ok") {
@@ -189,6 +299,16 @@ async function release(sock, key, token) {
189
299
  }
190
300
  }
191
301
  async function semAcquire(sock, key, acquireTimeoutS, limit, leaseTtlS) {
302
+ validateKey(key);
303
+ if (!Number.isFinite(acquireTimeoutS) || acquireTimeoutS < 0) {
304
+ throw new LockError("acquireTimeoutS must be a finite number >= 0");
305
+ }
306
+ if (!Number.isInteger(limit) || limit < 1) {
307
+ throw new LockError("limit must be an integer >= 1");
308
+ }
309
+ if (leaseTtlS != null && (!Number.isFinite(leaseTtlS) || leaseTtlS <= 0)) {
310
+ throw new LockError("leaseTtlS must be a finite number > 0");
311
+ }
192
312
  const arg = leaseTtlS == null ? `${acquireTimeoutS} ${limit}` : `${acquireTimeoutS} ${limit} ${leaseTtlS}`;
193
313
  await writeAll(sock, encodeLines("sl", key, arg));
194
314
  const resp = await readline(sock);
@@ -200,30 +320,47 @@ async function semAcquire(sock, key, acquireTimeoutS, limit, leaseTtlS) {
200
320
  }
201
321
  const parts = resp.split(" ");
202
322
  const token = parts[1];
203
- const lease = parts.length >= 3 ? parseInt(parts[2], 10) : 30;
323
+ if (!token) {
324
+ throw new LockError(`sem_acquire: server returned no token: '${resp}'`);
325
+ }
326
+ const lease = parseLease(parts[2]);
204
327
  return { token, lease };
205
328
  }
206
329
  async function semRenew(sock, key, token, leaseTtlS) {
330
+ validateKey(key);
331
+ validateToken(token);
332
+ if (leaseTtlS != null && (!Number.isFinite(leaseTtlS) || leaseTtlS <= 0)) {
333
+ throw new LockError("leaseTtlS must be a finite number > 0");
334
+ }
207
335
  const arg = leaseTtlS == null ? token : `${token} ${leaseTtlS}`;
208
336
  await writeAll(sock, encodeLines("sn", key, arg));
209
337
  const resp = await readline(sock);
210
338
  if (resp !== "ok" && !resp.startsWith("ok ")) {
211
339
  throw new LockError(`sem_renew failed: '${resp}'`);
212
340
  }
213
- const parts = resp.split(" ");
214
- if (parts.length >= 2 && /^\d+$/.test(parts[1])) {
215
- return parseInt(parts[1], 10);
341
+ if (resp === "ok") {
342
+ return leaseTtlS ?? 30;
216
343
  }
217
- throw new LockError(`sem_renew: malformed response: '${resp}'`);
344
+ return parseLease(resp.split(" ")[1]);
218
345
  }
219
346
  async function semEnqueue(sock, key, limit, leaseTtlS) {
347
+ validateKey(key);
348
+ if (!Number.isInteger(limit) || limit < 1) {
349
+ throw new LockError("limit must be an integer >= 1");
350
+ }
351
+ if (leaseTtlS != null && (!Number.isFinite(leaseTtlS) || leaseTtlS <= 0)) {
352
+ throw new LockError("leaseTtlS must be a finite number > 0");
353
+ }
220
354
  const arg = leaseTtlS == null ? String(limit) : `${limit} ${leaseTtlS}`;
221
355
  await writeAll(sock, encodeLines("se", key, arg));
222
356
  const resp = await readline(sock);
223
357
  if (resp.startsWith("acquired ")) {
224
358
  const parts = resp.split(" ");
225
359
  const token = parts[1];
226
- const lease = parts.length >= 3 ? parseInt(parts[2], 10) : 30;
360
+ if (!token) {
361
+ throw new LockError(`sem_enqueue: server returned no token: '${resp}'`);
362
+ }
363
+ const lease = parseLease(parts[2]);
227
364
  return { status: "acquired", token, lease };
228
365
  }
229
366
  if (resp === "queued") {
@@ -232,6 +369,10 @@ async function semEnqueue(sock, key, limit, leaseTtlS) {
232
369
  throw new LockError(`sem_enqueue failed: '${resp}'`);
233
370
  }
234
371
  async function semWaitForLock(sock, key, waitTimeoutS) {
372
+ validateKey(key);
373
+ if (!Number.isFinite(waitTimeoutS) || waitTimeoutS < 0) {
374
+ throw new LockError("waitTimeoutS must be a finite number >= 0");
375
+ }
235
376
  await writeAll(sock, encodeLines("sw", key, String(waitTimeoutS)));
236
377
  const resp = await readline(sock);
237
378
  if (resp === "timeout") {
@@ -242,10 +383,15 @@ async function semWaitForLock(sock, key, waitTimeoutS) {
242
383
  }
243
384
  const parts = resp.split(" ");
244
385
  const token = parts[1];
245
- const lease = parts.length >= 3 ? parseInt(parts[2], 10) : 30;
386
+ if (!token) {
387
+ throw new LockError(`sem_wait: server returned no token: '${resp}'`);
388
+ }
389
+ const lease = parseLease(parts[2]);
246
390
  return { token, lease };
247
391
  }
248
392
  async function semRelease(sock, key, token) {
393
+ validateKey(key);
394
+ validateToken(token);
249
395
  await writeAll(sock, encodeLines("sr", key, token));
250
396
  const resp = await readline(sock);
251
397
  if (resp !== "ok") {
@@ -268,14 +414,14 @@ async function statsProto(sock) {
268
414
  async function stats(options) {
269
415
  const host = options?.host ?? DEFAULT_HOST;
270
416
  const port = options?.port ?? DEFAULT_PORT;
271
- const sock = await connect2(host, port, options?.tls, options?.auth);
417
+ const sock = await connect2(host, port, options?.tls, options?.auth, options?.connectTimeoutMs);
272
418
  try {
273
419
  return await statsProto(sock);
274
420
  } finally {
275
421
  sock.destroy();
276
422
  }
277
423
  }
278
- var DistributedLock = class {
424
+ var DistributedPrimitive = class {
279
425
  key;
280
426
  acquireTimeoutS;
281
427
  leaseTtlS;
@@ -285,18 +431,30 @@ var DistributedLock = class {
285
431
  tls;
286
432
  auth;
287
433
  onLockLost;
434
+ connectTimeoutMs;
435
+ socketTimeoutMs;
288
436
  token = null;
289
437
  lease = 0;
290
438
  sock = null;
291
439
  renewTimer = null;
440
+ renewInFlight = null;
292
441
  closed = false;
293
442
  constructor(opts) {
443
+ validateKey(opts.key);
294
444
  this.key = opts.key;
295
445
  this.acquireTimeoutS = opts.acquireTimeoutS ?? 10;
296
446
  this.leaseTtlS = opts.leaseTtlS;
297
447
  this.tls = opts.tls;
298
448
  this.auth = opts.auth;
299
449
  this.onLockLost = opts.onLockLost;
450
+ this.connectTimeoutMs = opts.connectTimeoutMs;
451
+ this.socketTimeoutMs = opts.socketTimeoutMs;
452
+ if (!Number.isFinite(this.acquireTimeoutS) || this.acquireTimeoutS < 0) {
453
+ throw new LockError("acquireTimeoutS must be a finite number >= 0");
454
+ }
455
+ if (this.leaseTtlS != null && (!Number.isFinite(this.leaseTtlS) || this.leaseTtlS <= 0)) {
456
+ throw new LockError("leaseTtlS must be a finite number > 0");
457
+ }
300
458
  if (opts.servers) {
301
459
  if (opts.servers.length === 0) {
302
460
  throw new LockError("servers list must not be empty");
@@ -306,7 +464,36 @@ var DistributedLock = class {
306
464
  this.servers = [[opts.host ?? DEFAULT_HOST, opts.port ?? DEFAULT_PORT]];
307
465
  }
308
466
  this.shardingStrategy = opts.shardingStrategy ?? stableHashShard;
309
- this.renewRatio = opts.renewRatio ?? 0.5;
467
+ const renewRatio = opts.renewRatio ?? 0.5;
468
+ if (!Number.isFinite(renewRatio) || renewRatio <= 0 || renewRatio >= 1) {
469
+ throw new LockError("renewRatio must be a finite number between 0 and 1 (exclusive)");
470
+ }
471
+ this.renewRatio = renewRatio;
472
+ }
473
+ // -- public API --
474
+ async openConnection() {
475
+ const [host, port] = this.pickServer();
476
+ const sock = await connect2(host, port, this.tls, this.auth, this.connectTimeoutMs);
477
+ if (this.socketTimeoutMs != null && this.socketTimeoutMs > 0) {
478
+ sock.on("timeout", () => {
479
+ sock.destroy(new LockError("socket idle timeout"));
480
+ });
481
+ sock.setTimeout(this.socketTimeoutMs);
482
+ }
483
+ return sock;
484
+ }
485
+ /**
486
+ * Suspend or restore the socket idle timeout. Only has effect when
487
+ * `socketTimeoutMs` was set at construction time and a listener was
488
+ * registered in `openConnection`.
489
+ */
490
+ suspendSocketTimeout(sock) {
491
+ sock.setTimeout(0);
492
+ }
493
+ restoreSocketTimeout(sock) {
494
+ if (this.socketTimeoutMs != null && this.socketTimeoutMs > 0) {
495
+ sock.setTimeout(this.socketTimeoutMs);
496
+ }
310
497
  }
311
498
  pickServer() {
312
499
  const idx = this.shardingStrategy(this.key, this.servers.length);
@@ -318,26 +505,26 @@ var DistributedLock = class {
318
505
  return this.servers[idx];
319
506
  }
320
507
  /**
321
- * Acquire the lock. Returns `true` on success, `false` on timeout.
322
- * @param opts.force - If `true`, silently close any existing connection before acquiring. Defaults to `false`, which throws if already connected.
508
+ * Acquire the lock / semaphore slot.
509
+ * Returns `true` on success, `false` on timeout.
510
+ * @param opts.force - If `true`, silently close any existing connection
511
+ * before acquiring. Defaults to `false`, which throws if already connected.
323
512
  */
324
513
  async acquire(opts) {
325
514
  if (this.sock && !this.closed) {
326
515
  if (!opts?.force) {
327
- throw new LockError("already connected; call release() or close() first, or pass { force: true }");
516
+ throw new LockError(
517
+ "already connected; call release() or close() first, or pass { force: true }"
518
+ );
328
519
  }
329
520
  this.close();
330
521
  }
331
522
  this.closed = false;
332
- const [host, port] = this.pickServer();
333
- this.sock = await connect2(host, port, this.tls, this.auth);
523
+ this.sock = await this.openConnection();
334
524
  try {
335
- const result = await acquire(
336
- this.sock,
337
- this.key,
338
- this.acquireTimeoutS,
339
- this.leaseTtlS
340
- );
525
+ this.suspendSocketTimeout(this.sock);
526
+ const result = await this.doAcquire(this.sock);
527
+ this.restoreSocketTimeout(this.sock);
341
528
  this.token = result.token;
342
529
  this.lease = result.lease;
343
530
  } catch (err) {
@@ -348,12 +535,33 @@ var DistributedLock = class {
348
535
  this.startRenew();
349
536
  return true;
350
537
  }
351
- /** Release the lock and close the connection. */
538
+ /**
539
+ * Release the lock / semaphore slot and close the connection.
540
+ *
541
+ * Throws `LockError` if the instance is already closed (e.g. after a
542
+ * previous `release()` or `close()` call).
543
+ *
544
+ * The server-side release itself is best-effort: if the underlying
545
+ * connection is already dead the protocol-level release error is silently
546
+ * ignored so that the method doesn't throw on transient network failures.
547
+ */
352
548
  async release() {
549
+ if (this.closed) {
550
+ throw new LockError("not connected; nothing to release");
551
+ }
552
+ const tokenToRelease = this.token;
553
+ const sockToRelease = this.sock;
353
554
  try {
354
555
  this.stopRenew();
355
- if (this.sock && this.token) {
356
- await release(this.sock, this.key, this.token);
556
+ if (this.renewInFlight) {
557
+ await this.renewInFlight;
558
+ this.stopRenew();
559
+ }
560
+ if (sockToRelease != null && tokenToRelease != null) {
561
+ try {
562
+ await this.doRelease(sockToRelease, tokenToRelease);
563
+ } catch {
564
+ }
357
565
  }
358
566
  } finally {
359
567
  this.close();
@@ -361,23 +569,27 @@ var DistributedLock = class {
361
569
  }
362
570
  /**
363
571
  * Two-phase step 1: connect and join the FIFO queue.
364
- * Returns `"acquired"` (fast-path, lock is already held) or `"queued"`.
572
+ * Returns `"acquired"` (fast-path) or `"queued"`.
365
573
  * If acquired immediately, the renew loop starts automatically.
366
- * @param opts.force - If `true`, silently close any existing connection before enqueuing. Defaults to `false`, which throws if already connected.
574
+ * @param opts.force - If `true`, silently close any existing connection
575
+ * before enqueuing. Defaults to `false`, which throws if already connected.
367
576
  */
368
577
  async enqueue(opts) {
369
578
  if (this.sock && !this.closed) {
370
579
  if (!opts?.force) {
371
- throw new LockError("already connected; call release() or close() first, or pass { force: true }");
580
+ throw new LockError(
581
+ "already connected; call release() or close() first, or pass { force: true }"
582
+ );
372
583
  }
373
584
  this.close();
374
585
  }
375
586
  this.closed = false;
376
- const [host, port] = this.pickServer();
377
- this.sock = await connect2(host, port, this.tls, this.auth);
587
+ this.sock = await this.openConnection();
378
588
  try {
379
- const result = await enqueue(this.sock, this.key, this.leaseTtlS);
589
+ this.suspendSocketTimeout(this.sock);
590
+ const result = await this.doEnqueue(this.sock);
380
591
  if (result.status === "acquired") {
592
+ this.restoreSocketTimeout(this.sock);
381
593
  this.token = result.token;
382
594
  this.lease = result.lease ?? 0;
383
595
  this.startRenew();
@@ -389,7 +601,7 @@ var DistributedLock = class {
389
601
  }
390
602
  }
391
603
  /**
392
- * Two-phase step 2: block until the lock is granted.
604
+ * Two-phase step 2: block until the lock / slot is granted.
393
605
  * Returns `true` if granted, `false` on timeout.
394
606
  * If already acquired during `enqueue()`, returns `true` immediately.
395
607
  */
@@ -397,12 +609,17 @@ var DistributedLock = class {
397
609
  if (this.token !== null) {
398
610
  return true;
399
611
  }
612
+ if (this.closed) {
613
+ throw new LockError("connection closed; call enqueue() again");
614
+ }
400
615
  if (!this.sock) {
401
616
  throw new LockError("not connected; call enqueue() first");
402
617
  }
403
618
  const timeout = timeoutS ?? this.acquireTimeoutS;
404
619
  try {
405
- const result = await waitForLock(this.sock, this.key, timeout);
620
+ this.suspendSocketTimeout(this.sock);
621
+ const result = await this.doWait(this.sock, timeout);
622
+ this.restoreSocketTimeout(this.sock);
406
623
  this.token = result.token;
407
624
  this.lease = result.lease;
408
625
  } catch (err) {
@@ -414,24 +631,28 @@ var DistributedLock = class {
414
631
  return true;
415
632
  }
416
633
  /**
417
- * Run `fn` while holding the lock, then release automatically.
634
+ * Run `fn` while holding the lock / slot, then release automatically.
418
635
  *
419
- * ```ts
420
- * const lock = new DistributedLock({ key: "my-resource" });
421
- * await lock.withLock(async () => {
422
- * // critical section
423
- * });
424
- * ```
636
+ * If `fn()` throws, its error is always preserved — a concurrent
637
+ * release failure will not mask it.
425
638
  */
426
639
  async withLock(fn) {
427
640
  const ok = await this.acquire();
428
641
  if (!ok) {
429
642
  throw new AcquireTimeoutError(this.key);
430
643
  }
644
+ let threw = false;
431
645
  try {
432
646
  return await fn();
647
+ } catch (err) {
648
+ threw = true;
649
+ throw err;
433
650
  } finally {
434
- await this.release();
651
+ try {
652
+ await this.release();
653
+ } catch (releaseErr) {
654
+ if (!threw) throw releaseErr;
655
+ }
435
656
  }
436
657
  }
437
658
  /** Close the underlying socket (idempotent). */
@@ -439,29 +660,47 @@ var DistributedLock = class {
439
660
  if (this.closed) return;
440
661
  this.closed = true;
441
662
  this.stopRenew();
663
+ this.renewInFlight = null;
442
664
  if (this.sock) {
443
665
  this.sock.destroy();
444
666
  this.sock = null;
445
667
  }
446
668
  this.token = null;
669
+ this.lease = 0;
447
670
  }
448
671
  // -- internals --
449
672
  startRenew() {
673
+ this.stopRenew();
450
674
  const loop = async () => {
451
675
  const savedToken = this.token;
452
- if (!this.sock || !savedToken) return;
676
+ const sock = this.sock;
677
+ if (!sock || !savedToken) return;
453
678
  const start = Date.now();
454
- try {
455
- this.lease = await renew(this.sock, this.key, savedToken, this.leaseTtlS);
456
- } catch {
457
- if (this.token === savedToken) {
458
- this.token = null;
459
- if (this.onLockLost) {
460
- this.onLockLost(this.key, savedToken);
679
+ const p = (async () => {
680
+ try {
681
+ this.lease = await this.doRenew(sock, savedToken);
682
+ } catch {
683
+ if (this.token === savedToken) {
684
+ this.token = null;
685
+ if (this.onLockLost) {
686
+ try {
687
+ const result = this.onLockLost(this.key, savedToken);
688
+ if (result instanceof Promise) {
689
+ result.catch(() => {
690
+ });
691
+ }
692
+ } catch {
693
+ }
694
+ }
695
+ this.close();
461
696
  }
697
+ return;
462
698
  }
463
- return;
464
- }
699
+ })();
700
+ this.renewInFlight = p;
701
+ await p;
702
+ this.renewInFlight = null;
703
+ if (this.closed || this.token !== savedToken) return;
465
704
  const elapsed = Date.now() - start;
466
705
  const interval2 = Math.max(1, this.lease * this.renewRatio) * 1e3;
467
706
  this.renewTimer = setTimeout(loop, Math.max(0, interval2 - elapsed));
@@ -478,210 +717,55 @@ var DistributedLock = class {
478
717
  }
479
718
  }
480
719
  };
481
- var DistributedSemaphore = class {
482
- key;
483
- limit;
484
- acquireTimeoutS;
485
- leaseTtlS;
486
- servers;
487
- shardingStrategy;
488
- renewRatio;
489
- tls;
490
- auth;
491
- onLockLost;
492
- token = null;
493
- lease = 0;
494
- sock = null;
495
- renewTimer = null;
496
- closed = false;
720
+ var DistributedLock = class extends DistributedPrimitive {
497
721
  constructor(opts) {
498
- this.key = opts.key;
499
- this.limit = opts.limit;
500
- this.acquireTimeoutS = opts.acquireTimeoutS ?? 10;
501
- this.leaseTtlS = opts.leaseTtlS;
502
- this.tls = opts.tls;
503
- this.auth = opts.auth;
504
- this.onLockLost = opts.onLockLost;
505
- if (opts.servers) {
506
- if (opts.servers.length === 0) {
507
- throw new LockError("servers list must not be empty");
508
- }
509
- this.servers = [...opts.servers];
510
- } else {
511
- this.servers = [[opts.host ?? DEFAULT_HOST, opts.port ?? DEFAULT_PORT]];
512
- }
513
- this.shardingStrategy = opts.shardingStrategy ?? stableHashShard;
514
- this.renewRatio = opts.renewRatio ?? 0.5;
722
+ super(opts);
515
723
  }
516
- pickServer() {
517
- const idx = this.shardingStrategy(this.key, this.servers.length);
518
- if (!Number.isInteger(idx) || idx < 0 || idx >= this.servers.length) {
519
- throw new LockError(
520
- `shardingStrategy returned invalid index ${idx} for ${this.servers.length} server(s)`
521
- );
522
- }
523
- return this.servers[idx];
724
+ doAcquire(sock) {
725
+ return acquire(sock, this.key, this.acquireTimeoutS, this.leaseTtlS);
524
726
  }
525
- /**
526
- * Acquire a semaphore slot. Returns `true` on success, `false` on timeout.
527
- * @param opts.force - If `true`, silently close any existing connection before acquiring. Defaults to `false`, which throws if already connected.
528
- */
529
- async acquire(opts) {
530
- if (this.sock && !this.closed) {
531
- if (!opts?.force) {
532
- throw new LockError("already connected; call release() or close() first, or pass { force: true }");
533
- }
534
- this.close();
535
- }
536
- this.closed = false;
537
- const [host, port] = this.pickServer();
538
- this.sock = await connect2(host, port, this.tls, this.auth);
539
- try {
540
- const result = await semAcquire(
541
- this.sock,
542
- this.key,
543
- this.acquireTimeoutS,
544
- this.limit,
545
- this.leaseTtlS
546
- );
547
- this.token = result.token;
548
- this.lease = result.lease;
549
- } catch (err) {
550
- this.close();
551
- if (err instanceof AcquireTimeoutError) return false;
552
- throw err;
553
- }
554
- this.startRenew();
555
- return true;
727
+ doEnqueue(sock) {
728
+ return enqueue(sock, this.key, this.leaseTtlS);
556
729
  }
557
- /** Release the semaphore slot and close the connection. */
558
- async release() {
559
- try {
560
- this.stopRenew();
561
- if (this.sock && this.token) {
562
- await semRelease(this.sock, this.key, this.token);
563
- }
564
- } finally {
565
- this.close();
566
- }
730
+ doWait(sock, timeoutS) {
731
+ return waitForLock(sock, this.key, timeoutS);
567
732
  }
568
- /**
569
- * Two-phase step 1: connect and join the FIFO queue.
570
- * Returns `"acquired"` (fast-path, slot granted immediately) or `"queued"`.
571
- * If acquired immediately, the renew loop starts automatically.
572
- * @param opts.force - If `true`, silently close any existing connection before enqueuing. Defaults to `false`, which throws if already connected.
573
- */
574
- async enqueue(opts) {
575
- if (this.sock && !this.closed) {
576
- if (!opts?.force) {
577
- throw new LockError("already connected; call release() or close() first, or pass { force: true }");
578
- }
579
- this.close();
580
- }
581
- this.closed = false;
582
- const [host, port] = this.pickServer();
583
- this.sock = await connect2(host, port, this.tls, this.auth);
584
- try {
585
- const result = await semEnqueue(this.sock, this.key, this.limit, this.leaseTtlS);
586
- if (result.status === "acquired") {
587
- this.token = result.token;
588
- this.lease = result.lease ?? 0;
589
- this.startRenew();
590
- }
591
- return result.status;
592
- } catch (err) {
593
- this.close();
594
- throw err;
595
- }
733
+ doRelease(sock, token) {
734
+ return release(sock, this.key, token);
596
735
  }
597
- /**
598
- * Two-phase step 2: block until a semaphore slot is granted.
599
- * Returns `true` if granted, `false` on timeout.
600
- * If already acquired during `enqueue()`, returns `true` immediately.
601
- */
602
- async wait(timeoutS) {
603
- if (this.token !== null) {
604
- return true;
605
- }
606
- if (!this.sock) {
607
- throw new LockError("not connected; call enqueue() first");
608
- }
609
- const timeout = timeoutS ?? this.acquireTimeoutS;
610
- try {
611
- const result = await semWaitForLock(this.sock, this.key, timeout);
612
- this.token = result.token;
613
- this.lease = result.lease;
614
- } catch (err) {
615
- this.close();
616
- if (err instanceof AcquireTimeoutError) return false;
617
- throw err;
618
- }
619
- this.startRenew();
620
- return true;
736
+ doRenew(sock, token) {
737
+ return renew(sock, this.key, token, this.leaseTtlS);
621
738
  }
622
- /**
623
- * Run `fn` while holding a semaphore slot, then release automatically.
624
- *
625
- * ```ts
626
- * const sem = new DistributedSemaphore({ key: "my-resource", limit: 5 });
627
- * await sem.withLock(async () => {
628
- * // critical section (up to 5 concurrent holders)
629
- * });
630
- * ```
631
- */
632
- async withLock(fn) {
633
- const ok = await this.acquire();
634
- if (!ok) {
635
- throw new AcquireTimeoutError(this.key);
636
- }
637
- try {
638
- return await fn();
639
- } finally {
640
- await this.release();
739
+ };
740
+ var DistributedSemaphore = class extends DistributedPrimitive {
741
+ limit;
742
+ constructor(opts) {
743
+ super(opts);
744
+ if (!Number.isInteger(opts.limit) || opts.limit < 1) {
745
+ throw new LockError("limit must be an integer >= 1");
641
746
  }
747
+ this.limit = opts.limit;
642
748
  }
643
- /** Close the underlying socket (idempotent). */
644
- close() {
645
- if (this.closed) return;
646
- this.closed = true;
647
- this.stopRenew();
648
- if (this.sock) {
649
- this.sock.destroy();
650
- this.sock = null;
651
- }
652
- this.token = null;
749
+ doAcquire(sock) {
750
+ return semAcquire(
751
+ sock,
752
+ this.key,
753
+ this.acquireTimeoutS,
754
+ this.limit,
755
+ this.leaseTtlS
756
+ );
653
757
  }
654
- // -- internals --
655
- startRenew() {
656
- const loop = async () => {
657
- const savedToken = this.token;
658
- if (!this.sock || !savedToken) return;
659
- const start = Date.now();
660
- try {
661
- this.lease = await semRenew(this.sock, this.key, savedToken, this.leaseTtlS);
662
- } catch {
663
- if (this.token === savedToken) {
664
- this.token = null;
665
- if (this.onLockLost) {
666
- this.onLockLost(this.key, savedToken);
667
- }
668
- }
669
- return;
670
- }
671
- const elapsed = Date.now() - start;
672
- const interval2 = Math.max(1, this.lease * this.renewRatio) * 1e3;
673
- this.renewTimer = setTimeout(loop, Math.max(0, interval2 - elapsed));
674
- this.renewTimer.unref();
675
- };
676
- const interval = Math.max(1, this.lease * this.renewRatio) * 1e3;
677
- this.renewTimer = setTimeout(loop, interval);
678
- this.renewTimer.unref();
758
+ doEnqueue(sock) {
759
+ return semEnqueue(sock, this.key, this.limit, this.leaseTtlS);
679
760
  }
680
- stopRenew() {
681
- if (this.renewTimer != null) {
682
- clearTimeout(this.renewTimer);
683
- this.renewTimer = null;
684
- }
761
+ doWait(sock, timeoutS) {
762
+ return semWaitForLock(sock, this.key, timeoutS);
763
+ }
764
+ doRelease(sock, token) {
765
+ return semRelease(sock, this.key, token);
766
+ }
767
+ doRenew(sock, token) {
768
+ return semRenew(sock, this.key, token, this.leaseTtlS);
685
769
  }
686
770
  };
687
771
  export {