git-watchtower 2.3.16 → 2.3.18

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.
@@ -878,7 +878,7 @@ const { parseDiffStats, stash: gitStash, stashPop: gitStashPop } = require('../s
878
878
 
879
879
  // Server process command parsing and static server utilities
880
880
  const { parseCommand } = require('../src/server/process');
881
- const { getMimeType, injectLiveReload, resolveStaticPath } = require('../src/server/static');
881
+ const { getMimeType, injectLiveReload, resolveStaticPath, broadcastReload } = require('../src/server/static');
882
882
 
883
883
  // State (non-store globals)
884
884
  let previousBranchStates = new Map(); // branch name -> commit hash
@@ -2299,9 +2299,13 @@ function restartPolling() {
2299
2299
 
2300
2300
  function notifyClients() {
2301
2301
  if (NO_SERVER) return; // No clients in no-server mode
2302
- clients.forEach(client => client.write('data: reload\n\n'));
2303
- if (clients.size > 0) {
2304
- addLog(`Reloading ${clients.size} browser(s)`, 'info');
2302
+ if (clients.size === 0) return;
2303
+ const { delivered, dropped } = broadcastReload(clients);
2304
+ if (delivered > 0) {
2305
+ addLog(`Reloading ${delivered} browser(s)`, 'info');
2306
+ }
2307
+ if (dropped > 0) {
2308
+ addLog(`Dropped ${dropped} dead live-reload client(s)`, 'warning');
2305
2309
  }
2306
2310
  }
2307
2311
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.3.16",
3
+ "version": "2.3.18",
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": {
@@ -108,6 +108,24 @@ function ensureDir() {
108
108
 
109
109
  /**
110
110
  * Check if a process with the given PID is alive.
111
+ *
112
+ * `process.kill(pid, 0)` is the standard "is this PID alive?" probe. It
113
+ * sends signal 0 (no-op) and surfaces the kernel's answer via errno:
114
+ *
115
+ * - resolves cleanly → process exists and we can signal it
116
+ * - throws ESRCH → no such process; safe to call dead
117
+ * - throws EPERM → process exists but is owned by another
118
+ * user or in a different cgroup. STILL
119
+ * alive — we just can't signal it.
120
+ *
121
+ * Treating EPERM as "dead" was a real bug for the coordinator lock: if
122
+ * a coordinator's PID was reused by another local user's process after
123
+ * a crash, a peer instance would read the lock, see EPERM, decide the
124
+ * coordinator was dead, unlink the lock, and try to take over while
125
+ * the original (or reused) PID was still running. Mirroring the same
126
+ * EPERM-aware check used by src/utils/monitor-lock.js prevents that
127
+ * cleanup-then-clobber race.
128
+ *
111
129
  * @param {number} pid
112
130
  * @returns {boolean}
113
131
  */
@@ -116,7 +134,8 @@ function isProcessAlive(pid) {
116
134
  process.kill(pid, 0);
117
135
  return true;
118
136
  } catch (e) {
119
- return false;
137
+ // ESRCH = no such process; EPERM = exists but owned by another user.
138
+ return e.code === 'EPERM';
120
139
  }
121
140
  }
122
141
 
@@ -125,10 +125,50 @@ function resolveStaticPath(candidate, realStaticDir) {
125
125
  return { status: 'ok', path: realPath };
126
126
  }
127
127
 
128
+ /**
129
+ * Broadcast an SSE frame to every live-reload client, isolating per-client
130
+ * write failures so one dead socket can't abort the whole iteration.
131
+ *
132
+ * Previously this was a one-liner in bin/git-watchtower.js:
133
+ * clients.forEach(c => c.write('data: reload\n\n'));
134
+ * If any client's underlying socket had been destroyed (browser tab
135
+ * closed, network reset, proxy hangup) without 'close' firing yet, the
136
+ * synchronous write threw `ERR_STREAM_DESTROYED` and aborted the
137
+ * forEach mid-iteration — every later client in the Set never received
138
+ * the reload event. Wrapping each write in try/catch and removing the
139
+ * failed client from the Set keeps the broadcast atomic-per-client and
140
+ * also prunes the dead entry so the next call doesn't trip on it again.
141
+ *
142
+ * @param {Set<{write: function, end?: function}>} clients - SSE response objects
143
+ * @param {string} [frame='data: reload\n\n'] - Pre-formatted SSE frame
144
+ * @returns {{delivered: number, dropped: number}}
145
+ */
146
+ function broadcastReload(clients, frame) {
147
+ const message = frame || 'data: reload\n\n';
148
+ let delivered = 0;
149
+ let dropped = 0;
150
+ // Iterate a snapshot — Set.delete during forEach is safe in V8, but
151
+ // copying makes the contract explicit and survives any future
152
+ // iterator-protocol changes.
153
+ for (const client of Array.from(clients)) {
154
+ try {
155
+ client.write(message);
156
+ delivered++;
157
+ } catch (e) {
158
+ // Dead socket. Drop it and keep going so subsequent clients still
159
+ // see the broadcast.
160
+ clients.delete(client);
161
+ dropped++;
162
+ }
163
+ }
164
+ return { delivered, dropped };
165
+ }
166
+
128
167
  module.exports = {
129
168
  MIME_TYPES,
130
169
  getMimeType,
131
170
  LIVE_RELOAD_SCRIPT,
132
171
  injectLiveReload,
133
172
  resolveStaticPath,
173
+ broadcastReload,
134
174
  };