git-watchtower 2.0.0 → 2.0.2

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.
@@ -1753,7 +1753,7 @@ const pollMutex = new Mutex();
1753
1753
  async function pollGitChanges() {
1754
1754
  // Skip if a poll is already in progress (don't queue)
1755
1755
  if (pollMutex.isLocked()) return;
1756
- await pollMutex.acquire();
1756
+ const pollToken = await pollMutex.acquire();
1757
1757
  store.setState({ isPolling: true, pollingStatus: 'fetching' });
1758
1758
 
1759
1759
  // Casino mode: start slot reels spinning (no sound - too annoying)
@@ -2122,7 +2122,7 @@ async function pollGitChanges() {
2122
2122
  }
2123
2123
  } finally {
2124
2124
  store.setState({ isPolling: false });
2125
- pollMutex.release();
2125
+ pollMutex.release(pollToken);
2126
2126
  render();
2127
2127
  }
2128
2128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Terminal-based Git branch monitor with activity sparklines and optional dev server with live reload",
5
5
  "main": "bin/git-watchtower.js",
6
6
  "bin": {
@@ -6,22 +6,33 @@
6
6
  /**
7
7
  * Simple mutex for preventing concurrent operations
8
8
  * Use this to prevent race conditions in polling and server operations
9
+ *
10
+ * Mutual exclusion is enforced by ownership tokens: acquire() returns a
11
+ * unique Symbol, and release() requires the caller to hand that same
12
+ * token back. Previously release() was just a zero-arg "drain the next
13
+ * waiter" operation — so a double-release or a stray release() without
14
+ * a matching acquire() would advance the queue twice, handing the lock
15
+ * to two owners concurrently and silently breaking the invariant the
16
+ * mutex exists to protect. Tokens make that misuse throw instead.
9
17
  */
10
18
  class Mutex {
11
19
  constructor() {
12
- this.locked = false;
20
+ /** @type {symbol | null} */
21
+ this._heldBy = null;
22
+ /** @type {Array<(token: symbol) => void>} */
13
23
  this.queue = [];
14
24
  }
15
25
 
16
26
  /**
17
- * Acquire the lock. Returns a promise that resolves when lock is acquired.
18
- * @returns {Promise<void>}
27
+ * Acquire the lock. Resolves with an opaque token that must be passed
28
+ * back to release(). Hold on to it and don't share it across callers.
29
+ * @returns {Promise<symbol>}
19
30
  */
20
31
  async acquire() {
21
32
  return new Promise((resolve) => {
22
- if (!this.locked) {
23
- this.locked = true;
24
- resolve();
33
+ if (this._heldBy === null) {
34
+ this._heldBy = Symbol('mutex-token');
35
+ resolve(this._heldBy);
25
36
  } else {
26
37
  this.queue.push(resolve);
27
38
  }
@@ -29,14 +40,28 @@ class Mutex {
29
40
  }
30
41
 
31
42
  /**
32
- * Release the lock. If there are waiting acquirers, the next one gets the lock.
43
+ * Release the lock. The token must be the one returned by the
44
+ * corresponding acquire(). Throws for:
45
+ * - release() on an unlocked mutex (release-without-acquire)
46
+ * - release() with a token that doesn't match the current holder
47
+ * (double-release, or release from the wrong caller)
48
+ *
49
+ * @param {symbol} token
33
50
  */
34
- release() {
51
+ release(token) {
52
+ if (this._heldBy === null) {
53
+ throw new Error('Mutex.release(): called on an unlocked mutex');
54
+ }
55
+ if (token !== this._heldBy) {
56
+ throw new Error('Mutex.release(): token does not match the current holder');
57
+ }
35
58
  if (this.queue.length > 0) {
36
59
  const next = this.queue.shift();
37
- next();
60
+ const nextToken = Symbol('mutex-token');
61
+ this._heldBy = nextToken;
62
+ next(nextToken);
38
63
  } else {
39
- this.locked = false;
64
+ this._heldBy = null;
40
65
  }
41
66
  }
42
67
 
@@ -47,11 +72,11 @@ class Mutex {
47
72
  * @returns {Promise<T>}
48
73
  */
49
74
  async withLock(fn) {
50
- await this.acquire();
75
+ const token = await this.acquire();
51
76
  try {
52
77
  return await fn();
53
78
  } finally {
54
- this.release();
79
+ this.release(token);
55
80
  }
56
81
  }
57
82
 
@@ -60,7 +85,7 @@ class Mutex {
60
85
  * @returns {boolean}
61
86
  */
62
87
  isLocked() {
63
- return this.locked;
88
+ return this._heldBy !== null;
64
89
  }
65
90
 
66
91
  /**
@@ -181,17 +206,24 @@ function debounce(fn, delay) {
181
206
 
182
207
  /**
183
208
  * Throttle a function - execute at most once per interval
209
+ *
210
+ * Mirrors debounce() in returning a wrapped function with a `.cancel()`
211
+ * method. If a trailing call has been scheduled (throttle fires on the
212
+ * leading edge and coalesces further calls during the interval into a
213
+ * single trailing call), cancel() drops it. Wire cancel() into shutdown
214
+ * paths so a UI-event-driven throttle can't fire a deferred call after
215
+ * the target module has torn down and leave state partially written.
216
+ *
184
217
  * @template {(...args: any[]) => void} T
185
218
  * @param {T} fn - Function to throttle
186
219
  * @param {number} interval - Minimum interval between calls in milliseconds
187
- * @returns {T}
220
+ * @returns {T & { cancel: () => void }}
188
221
  */
189
222
  function throttle(fn, interval) {
190
223
  let lastCall = 0;
191
224
  let timeoutId = null;
192
225
 
193
- // @ts-ignore - TypeScript can't verify generic function return type
194
- return (...args) => {
226
+ const throttled = (...args) => {
195
227
  const now = Date.now();
196
228
  const timeSinceLastCall = now - lastCall;
197
229
 
@@ -207,6 +239,16 @@ function throttle(fn, interval) {
207
239
  }, interval - timeSinceLastCall);
208
240
  }
209
241
  };
242
+
243
+ throttled.cancel = () => {
244
+ if (timeoutId) {
245
+ clearTimeout(timeoutId);
246
+ timeoutId = null;
247
+ }
248
+ };
249
+
250
+ // @ts-ignore - TypeScript can't verify generic function augmentation
251
+ return throttled;
210
252
  }
211
253
 
212
254
  module.exports = {