git-watchtower 1.12.2 → 1.12.3

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.
@@ -101,7 +101,7 @@ const store = new Store();
101
101
 
102
102
  // Web dashboard server
103
103
  const { WebDashboardServer } = require('../src/server/web');
104
- const { Coordinator, Worker, generateProjectId, getActiveCoordinator, writeLock, removeLock } = require('../src/server/coordinator');
104
+ const { Coordinator, Worker, generateProjectId, getActiveCoordinator, tryAcquireLock, finalizeLock, removeLock, removeSocket } = require('../src/server/coordinator');
105
105
 
106
106
  const PROJECT_ROOT = process.cwd();
107
107
 
@@ -3104,44 +3104,64 @@ async function startWebDashboard(openBrowser) {
3104
3104
  if (url) webDashboard.setRepoWebUrl(url);
3105
3105
  }).catch(() => {});
3106
3106
 
3107
- // Check if a coordinator is already running
3108
- const existing = getActiveCoordinator();
3107
+ // Atomically try to claim the coordinator role. If another live instance
3108
+ // already owns the lock, connect as a worker instead. This prevents a
3109
+ // TOCTOU race where two instances both pass a "no coordinator" check and
3110
+ // then clobber each other's socket in Coordinator.start().
3111
+ const lockResult = tryAcquireLock(process.pid);
3109
3112
 
3110
- if (existing) {
3111
- // Connect as a worker to the existing coordinator
3112
- try {
3113
- worker = new Worker({
3114
- id: projectId,
3115
- projectPath: PROJECT_ROOT,
3116
- projectName: path.basename(PROJECT_ROOT),
3117
- socketPath: existing.socketPath,
3118
- });
3119
- worker.onCommand = (action, payload) => handleWebAction(action, payload);
3120
- await worker.connect();
3121
- addLog(`Joined web dashboard at ${localhostUrl(existing.port)} (tab)`, 'success');
3122
-
3123
- // Push state periodically
3124
- webStateInterval = setInterval(() => {
3125
- if (worker && worker.isConnected()) {
3126
- worker.pushState(webDashboard.getSerializableState());
3127
- } else {
3128
- clearInterval(webStateInterval);
3129
- webStateInterval = null;
3130
- }
3131
- }, 500);
3113
+ if (!lockResult.acquired) {
3114
+ const existing = lockResult.existing || getActiveCoordinator();
3115
+ if (existing) {
3116
+ try {
3117
+ worker = new Worker({
3118
+ id: projectId,
3119
+ projectPath: PROJECT_ROOT,
3120
+ projectName: path.basename(PROJECT_ROOT),
3121
+ socketPath: existing.socketPath,
3122
+ });
3123
+ worker.onCommand = (action, payload) => handleWebAction(action, payload);
3124
+ await worker.connect();
3125
+ addLog(`Joined web dashboard at ${localhostUrl(existing.port)} (tab)`, 'success');
3126
+
3127
+ // Push state periodically
3128
+ webStateInterval = setInterval(() => {
3129
+ if (worker && worker.isConnected()) {
3130
+ worker.pushState(webDashboard.getSerializableState());
3131
+ } else {
3132
+ clearInterval(webStateInterval);
3133
+ webStateInterval = null;
3134
+ }
3135
+ }, 500);
3132
3136
 
3133
- // Don't start our own server — piggyback on the coordinator's.
3134
- // Don't open browser either — the existing tab will show this project automatically.
3135
- WEB_PORT = existing.port;
3136
- render();
3137
- return;
3138
- } catch (err) {
3139
- // Couldn't connect become coordinator instead
3140
- worker = null;
3137
+ // Don't start our own server — piggyback on the coordinator's.
3138
+ // Don't open browser either — the existing tab will show this project automatically.
3139
+ WEB_PORT = existing.port;
3140
+ render();
3141
+ return;
3142
+ } catch (err) {
3143
+ // Another coordinator owns the lock but we can't talk to it. Do NOT
3144
+ // take over (that would unlink the live coordinator's socket). Run
3145
+ // without a web dashboard for this instance.
3146
+ worker = null;
3147
+ addLog(`Could not join web dashboard at ${localhostUrl(existing.port)}: ${err.message}`, 'error');
3148
+ webDashboard = null;
3149
+ render();
3150
+ return;
3151
+ }
3141
3152
  }
3153
+ // Lock exists but we couldn't claim it and couldn't read the owner.
3154
+ // Bail out rather than race a concurrent startup.
3155
+ addLog('Web dashboard unavailable: could not acquire coordinator lock', 'error');
3156
+ webDashboard = null;
3157
+ render();
3158
+ return;
3142
3159
  }
3143
3160
 
3144
- // We are the coordinator
3161
+ // We hold the lock — it is now safe to remove any leftover socket and
3162
+ // start listening. The lock file contains a placeholder pid-only entry
3163
+ // until finalizeLock() writes the real port/socketPath after a successful
3164
+ // bind.
3145
3165
  try {
3146
3166
  coordinator = new Coordinator();
3147
3167
  coordinator.onProjectsChanged = (projects) => {
@@ -3155,7 +3175,12 @@ async function startWebDashboard(openBrowser) {
3155
3175
  await coordinator.start();
3156
3176
  coordinator.registerLocal(projectId, PROJECT_ROOT, path.basename(PROJECT_ROOT), webDashboard.getSerializableState());
3157
3177
 
3158
- // Update coordinator with our latest state periodically
3178
+ const { port } = await webDashboard.start();
3179
+ WEB_PORT = port;
3180
+ finalizeLock(process.pid, port, coordinator.socketPath);
3181
+
3182
+ // Update coordinator with our latest state periodically. Started only
3183
+ // after a successful bind so a failed start doesn't leak an interval.
3159
3184
  webStateInterval = setInterval(() => {
3160
3185
  if (coordinator && webDashboard) {
3161
3186
  coordinator.updateLocal(projectId, webDashboard.getSerializableState());
@@ -3165,15 +3190,16 @@ async function startWebDashboard(openBrowser) {
3165
3190
  }
3166
3191
  }, 500);
3167
3192
 
3168
- const { port } = await webDashboard.start();
3169
- WEB_PORT = port;
3170
- writeLock(process.pid, port, coordinator.socketPath);
3171
-
3172
3193
  addLog(`Web dashboard: ${localhostUrl(port)}`, 'success');
3173
3194
  if (openBrowser) openInBrowser(localhostUrl(port));
3174
3195
  render();
3175
3196
  } catch (err) {
3176
3197
  addLog(`Web dashboard failed: ${err.message}`, 'error');
3198
+ if (coordinator) {
3199
+ try { coordinator.stop(); } catch (_) { /* ignore */ }
3200
+ }
3201
+ removeLock();
3202
+ removeSocket();
3177
3203
  webDashboard = null;
3178
3204
  coordinator = null;
3179
3205
  render();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.12.2",
3
+ "version": "1.12.3",
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": {
@@ -68,13 +68,18 @@ function isProcessAlive(pid) {
68
68
 
69
69
  /**
70
70
  * Read the lock file.
71
- * @returns {{ pid: number, port: number, socketPath: string } | null}
71
+ *
72
+ * A lock may be a placeholder (pid only, no port/socketPath) while a new
73
+ * coordinator is still binding its socket. Callers that need a connectable
74
+ * coordinator should use getActiveCoordinator(), which rejects placeholders.
75
+ *
76
+ * @returns {{ pid: number, port?: number, socketPath?: string, pending?: boolean } | null}
72
77
  */
73
78
  function readLock() {
74
79
  try {
75
80
  if (!fs.existsSync(LOCK_FILE)) return null;
76
81
  const data = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8'));
77
- if (!data || !data.pid || !data.port) return null;
82
+ if (!data || !data.pid) return null;
78
83
  return data;
79
84
  } catch (e) {
80
85
  return null;
@@ -92,6 +97,66 @@ function writeLock(pid, port, socketPath) {
92
97
  fs.writeFileSync(LOCK_FILE, JSON.stringify({ pid, port, socketPath }, null, 2) + '\n', 'utf8');
93
98
  }
94
99
 
100
+ /**
101
+ * Atomically reserve the coordinator lock.
102
+ *
103
+ * Uses `fs.openSync(..., 'wx')` to create the lock file exclusively, so two
104
+ * instances racing to become coordinator cannot both succeed. A placeholder
105
+ * entry ({ pid, pending: true }) is written immediately so that any process
106
+ * reading the lock while we bind our socket still sees a valid owning PID.
107
+ *
108
+ * If the lock already exists but the owning process is dead, the stale lock
109
+ * (and socket) are cleaned up and the acquisition is retried once.
110
+ *
111
+ * @param {number} pid - PID of the acquiring process
112
+ * @returns {{acquired: true} | {acquired: false, existing: {pid: number, port?: number, socketPath?: string, pending?: boolean} | null}}
113
+ */
114
+ function tryAcquireLock(pid) {
115
+ ensureDir();
116
+
117
+ // One retry after stale-lock cleanup; avoids looping if another process
118
+ // keeps recreating the lock faster than we can clean it up.
119
+ for (let attempt = 0; attempt < 2; attempt++) {
120
+ try {
121
+ const fd = fs.openSync(LOCK_FILE, 'wx');
122
+ try {
123
+ fs.writeSync(fd, JSON.stringify({ pid, pending: true }) + '\n');
124
+ } finally {
125
+ fs.closeSync(fd);
126
+ }
127
+ return { acquired: true };
128
+ } catch (err) {
129
+ if (err.code !== 'EEXIST') throw err;
130
+
131
+ // Lock file exists — check if the owner is alive.
132
+ const existing = readLock();
133
+ if (existing && isProcessAlive(existing.pid)) {
134
+ return { acquired: false, existing };
135
+ }
136
+ // Stale or unreadable — clean up and retry the exclusive create.
137
+ removeLock();
138
+ removeSocket();
139
+ }
140
+ }
141
+
142
+ // Another process raced us to re-create the lock. Treat it as active.
143
+ const existing = readLock();
144
+ return { acquired: false, existing: existing || null };
145
+ }
146
+
147
+ /**
148
+ * Replace the placeholder lock with the final port/socket details after the
149
+ * coordinator has successfully bound its IPC socket and the web server has
150
+ * started listening. Caller must already own the lock via tryAcquireLock().
151
+ *
152
+ * @param {number} pid
153
+ * @param {number} port
154
+ * @param {string} socketPath
155
+ */
156
+ function finalizeLock(pid, port, socketPath) {
157
+ writeLock(pid, port, socketPath);
158
+ }
159
+
95
160
  /**
96
161
  * Remove the lock file.
97
162
  */
@@ -107,18 +172,25 @@ function removeSocket() {
107
172
  }
108
173
 
109
174
  /**
110
- * Check if a coordinator is already running.
111
- * Cleans up stale lock if the process is dead.
175
+ * Check if a coordinator is already running and reachable.
176
+ *
177
+ * Returns null for stale locks (cleans them up) and for placeholder locks
178
+ * that haven't finished binding yet — callers shouldn't try to connect to
179
+ * a coordinator that isn't listening.
180
+ *
112
181
  * @returns {{ pid: number, port: number, socketPath: string } | null}
113
182
  */
114
183
  function getActiveCoordinator() {
115
184
  const lock = readLock();
116
185
  if (!lock) return null;
117
- if (isProcessAlive(lock.pid)) return lock;
118
- // Stale lock — clean up
119
- removeLock();
120
- removeSocket();
121
- return null;
186
+ if (!isProcessAlive(lock.pid)) {
187
+ removeLock();
188
+ removeSocket();
189
+ return null;
190
+ }
191
+ // Placeholder (pending) — coordinator is still binding.
192
+ if (!lock.port || !lock.socketPath) return null;
193
+ return /** @type {{pid:number,port:number,socketPath:string}} */ (lock);
122
194
  }
123
195
 
124
196
  // ─── Coordinator (first instance) ────────────────────────────────
@@ -533,6 +605,8 @@ module.exports = {
533
605
  getActiveCoordinator,
534
606
  readLock,
535
607
  writeLock,
608
+ tryAcquireLock,
609
+ finalizeLock,
536
610
  removeLock,
537
611
  removeSocket,
538
612
  isProcessAlive,