git-watchtower 1.12.3 → 1.12.5
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 +120 -44
- package/package.json +1 -1
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, tryAcquireLock, finalizeLock, removeLock, removeSocket } = require('../src/server/coordinator');
|
|
104
|
+
const { Coordinator, Worker, generateProjectId, getActiveCoordinator, tryAcquireLock, finalizeLock, removeLock, removeSocket, isProcessAlive } = require('../src/server/coordinator');
|
|
105
105
|
|
|
106
106
|
const PROJECT_ROOT = process.cwd();
|
|
107
107
|
|
|
@@ -802,7 +802,7 @@ const { ansi, box, truncate, sparkline: uiSparkline, visibleLength, stripAnsi, p
|
|
|
802
802
|
|
|
803
803
|
// Error detection utilities imported from src/utils/errors.js
|
|
804
804
|
const { ErrorHandler, isAuthError, isMergeConflict, isNetworkError } = require('../src/utils/errors');
|
|
805
|
-
const { Mutex } = require('../src/utils/async');
|
|
805
|
+
const { Mutex, sleep } = require('../src/utils/async');
|
|
806
806
|
|
|
807
807
|
// Keyboard handling utilities imported from src/ui/keybindings.js
|
|
808
808
|
const { filterBranches } = require('../src/ui/keybindings');
|
|
@@ -2059,13 +2059,21 @@ async function pollGitChanges() {
|
|
|
2059
2059
|
}
|
|
2060
2060
|
|
|
2061
2061
|
function schedulePoll() {
|
|
2062
|
+
// Bail out if shutdown has started: both here (no new timer) and again
|
|
2063
|
+
// inside the timer callback after each await (the in-flight poll may
|
|
2064
|
+
// have started before shutdown() cleared pollIntervalId, and clearTimeout
|
|
2065
|
+
// on a timer whose callback is already executing is a no-op).
|
|
2066
|
+
if (isShuttingDown) return;
|
|
2062
2067
|
pollIntervalId = setTimeout(async () => {
|
|
2068
|
+
if (isShuttingDown) return;
|
|
2063
2069
|
await pollGitChanges();
|
|
2070
|
+
if (isShuttingDown) return;
|
|
2064
2071
|
schedulePoll();
|
|
2065
2072
|
}, store.get('adaptivePollInterval'));
|
|
2066
2073
|
}
|
|
2067
2074
|
|
|
2068
2075
|
function restartPolling() {
|
|
2076
|
+
if (isShuttingDown) return;
|
|
2069
2077
|
if (pollIntervalId) {
|
|
2070
2078
|
clearTimeout(pollIntervalId);
|
|
2071
2079
|
}
|
|
@@ -3081,6 +3089,50 @@ async function handleWebAction(action, payload) {
|
|
|
3081
3089
|
}
|
|
3082
3090
|
}
|
|
3083
3091
|
|
|
3092
|
+
/**
|
|
3093
|
+
* Maximum attempts to connect to an existing coordinator as a worker
|
|
3094
|
+
* before giving up (or reclaiming the lock if the coordinator is dead).
|
|
3095
|
+
*/
|
|
3096
|
+
const WORKER_CONNECT_MAX_ATTEMPTS = 3;
|
|
3097
|
+
|
|
3098
|
+
/**
|
|
3099
|
+
* Base delay for exponential backoff between worker-connect attempts (ms).
|
|
3100
|
+
* Delays are 200ms, 400ms — total added latency ~600ms in the worst case.
|
|
3101
|
+
*/
|
|
3102
|
+
const WORKER_CONNECT_BASE_DELAY_MS = 200;
|
|
3103
|
+
|
|
3104
|
+
/**
|
|
3105
|
+
* Attempt to connect to an existing coordinator as a worker, with bounded
|
|
3106
|
+
* exponential backoff. Returns the connected Worker on success, or null if
|
|
3107
|
+
* every attempt failed. Between attempts, if the coordinator's process is
|
|
3108
|
+
* no longer alive, we stop retrying so the caller can reclaim the lock.
|
|
3109
|
+
*
|
|
3110
|
+
* @param {{pid: number, port: number, socketPath: string}} existing - Coordinator lock info
|
|
3111
|
+
* @param {string} projectIdArg - Project ID for worker registration
|
|
3112
|
+
* @returns {Promise<Worker|null>}
|
|
3113
|
+
*/
|
|
3114
|
+
async function connectWorkerWithRetry(existing, projectIdArg) {
|
|
3115
|
+
for (let attempt = 1; attempt <= WORKER_CONNECT_MAX_ATTEMPTS; attempt++) {
|
|
3116
|
+
try {
|
|
3117
|
+
const w = new Worker({
|
|
3118
|
+
id: projectIdArg,
|
|
3119
|
+
projectPath: PROJECT_ROOT,
|
|
3120
|
+
projectName: path.basename(PROJECT_ROOT),
|
|
3121
|
+
socketPath: existing.socketPath,
|
|
3122
|
+
});
|
|
3123
|
+
w.onCommand = (action, payload) => handleWebAction(action, payload);
|
|
3124
|
+
await w.connect();
|
|
3125
|
+
return w;
|
|
3126
|
+
} catch (err) {
|
|
3127
|
+
if (attempt >= WORKER_CONNECT_MAX_ATTEMPTS) return null;
|
|
3128
|
+
// Stop early if the coordinator has exited — caller will reclaim.
|
|
3129
|
+
if (!isProcessAlive(existing.pid)) return null;
|
|
3130
|
+
await sleep(WORKER_CONNECT_BASE_DELAY_MS * Math.pow(2, attempt - 1));
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
return null;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3084
3136
|
/**
|
|
3085
3137
|
* Create and start the web dashboard, with coordinator support.
|
|
3086
3138
|
* @param {boolean} openBrowser - Whether to auto-open the browser
|
|
@@ -3108,51 +3160,75 @@ async function startWebDashboard(openBrowser) {
|
|
|
3108
3160
|
// already owns the lock, connect as a worker instead. This prevents a
|
|
3109
3161
|
// TOCTOU race where two instances both pass a "no coordinator" check and
|
|
3110
3162
|
// then clobber each other's socket in Coordinator.start().
|
|
3111
|
-
|
|
3163
|
+
//
|
|
3164
|
+
// The outer loop runs at most twice so we can reclaim the coordinator
|
|
3165
|
+
// role if the existing coordinator dies while we're retrying the worker
|
|
3166
|
+
// handshake (e.g. it crashed just before we attached). Without this, a
|
|
3167
|
+
// transient connect failure (peer not yet accepting, EPIPE, slow fork)
|
|
3168
|
+
// against a coordinator that later crashes would leave us with no web
|
|
3169
|
+
// dashboard even though we could safely take over.
|
|
3170
|
+
let acquired = false;
|
|
3171
|
+
let existing = null;
|
|
3172
|
+
for (let outer = 0; outer < 2 && !acquired; outer++) {
|
|
3173
|
+
const lockResult = tryAcquireLock(process.pid);
|
|
3174
|
+
if (lockResult.acquired) {
|
|
3175
|
+
acquired = true;
|
|
3176
|
+
break;
|
|
3177
|
+
}
|
|
3112
3178
|
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
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);
|
|
3179
|
+
existing = lockResult.existing || getActiveCoordinator();
|
|
3180
|
+
if (!existing) {
|
|
3181
|
+
// Lock exists but we couldn't claim it and couldn't read the owner.
|
|
3182
|
+
// Bail out rather than race a concurrent startup.
|
|
3183
|
+
addLog('Web dashboard unavailable: could not acquire coordinator lock', 'error');
|
|
3184
|
+
webDashboard = null;
|
|
3185
|
+
render();
|
|
3186
|
+
return;
|
|
3187
|
+
}
|
|
3136
3188
|
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3189
|
+
// Try to connect as a worker with bounded retry + exponential backoff.
|
|
3190
|
+
// The coordinator may still be finishing its bind after finalizeLock()
|
|
3191
|
+
// writes the real socket path, or temporarily unresponsive.
|
|
3192
|
+
const connectedWorker = await connectWorkerWithRetry(existing, projectId);
|
|
3193
|
+
if (connectedWorker) {
|
|
3194
|
+
worker = connectedWorker;
|
|
3195
|
+
addLog(`Joined web dashboard at ${localhostUrl(existing.port)} (tab)`, 'success');
|
|
3196
|
+
|
|
3197
|
+
// Push state periodically
|
|
3198
|
+
webStateInterval = setInterval(() => {
|
|
3199
|
+
if (worker && worker.isConnected()) {
|
|
3200
|
+
worker.pushState(webDashboard.getSerializableState());
|
|
3201
|
+
} else {
|
|
3202
|
+
clearInterval(webStateInterval);
|
|
3203
|
+
webStateInterval = null;
|
|
3204
|
+
}
|
|
3205
|
+
}, 500);
|
|
3206
|
+
|
|
3207
|
+
// Don't start our own server — piggyback on the coordinator's.
|
|
3208
|
+
// Don't open browser either — the existing tab will show this project automatically.
|
|
3209
|
+
WEB_PORT = existing.port;
|
|
3210
|
+
render();
|
|
3211
|
+
return;
|
|
3152
3212
|
}
|
|
3153
|
-
|
|
3154
|
-
//
|
|
3155
|
-
|
|
3213
|
+
|
|
3214
|
+
// Every connect attempt failed. If the coordinator process died while
|
|
3215
|
+
// we were retrying, clean up the stale lock/socket and loop once to
|
|
3216
|
+
// claim the coordinator role ourselves. Otherwise abort — do NOT take
|
|
3217
|
+
// over a live coordinator's socket.
|
|
3218
|
+
if (!isProcessAlive(existing.pid)) {
|
|
3219
|
+
removeLock();
|
|
3220
|
+
removeSocket();
|
|
3221
|
+
continue;
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
addLog(`Could not join web dashboard at ${localhostUrl(existing.port)}: coordinator unreachable`, 'error');
|
|
3225
|
+
webDashboard = null;
|
|
3226
|
+
render();
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
if (!acquired) {
|
|
3231
|
+
addLog('Web dashboard unavailable: could not acquire coordinator lock after retry', 'error');
|
|
3156
3232
|
webDashboard = null;
|
|
3157
3233
|
render();
|
|
3158
3234
|
return;
|
package/package.json
CHANGED