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