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