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