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.
- package/bin/git-watchtower.js +65 -39
- package/package.json +1 -1
- package/src/server/coordinator.js +83 -9
package/bin/git-watchtower.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
3108
|
-
|
|
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 (
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
worker.
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
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
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
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
|
|
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
|
-
|
|
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
|
@@ -68,13 +68,18 @@ function isProcessAlive(pid) {
|
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* Read the lock file.
|
|
71
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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))
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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,
|