fix(sync): stop sync loop and apply remote state once
CI / Build & Test (push) Has been cancelled

- Await restorePersistedTunnels in applySyncedState so applyingSync stays
  true for the full restore. Prevents saveState() during tunnel startup
  from pushing back to autopass and causing a feedback loop.
- Debounce onRemoteUpdate (600ms) so bursts of autopass 'update' events
  trigger a single apply.
- Skip apply when remote state matches current state (canonical fingerprint)
  to avoid redundant restarts and re-pushes.
- Clear remoteUpdateDebounceTimer in cleanup().
This commit is contained in:
Raven Scott
2026-03-15 01:44:46 -04:00
parent 2595a01f65
commit 33ea92cd43
+43 -4
View File
@@ -12,6 +12,7 @@ const STATE_KEY = 'holesail-state';
const IDENTITY_FILENAME = 'autopass-identity.json';
const STATE_FILENAME = 'state.json';
const AUTOPASS_DIRNAME = 'autopass';
const REMOTE_UPDATE_DEBOUNCE_MS = 600;
let storageDir = null;
let holesailManager = null;
@@ -27,6 +28,7 @@ let initPromise = null;
let applyingSync = false;
let weJustPushed = false;
let lastSyncedAt = null;
let remoteUpdateDebounceTimer = null;
function setStoragePath(dir) {
storageDir = dir;
@@ -141,15 +143,45 @@ async function ensureInitialized() {
await initPromise;
}
async function onRemoteUpdate() {
/**
* Canonical string form of state for comparison (sorted keys at every level).
*/
function stateFingerprint(obj) {
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
if (Array.isArray(obj)) return '[' + obj.map(stateFingerprint).join(',') + ']';
const keys = Object.keys(obj).sort();
const parts = keys.map((k) => JSON.stringify(k) + ':' + stateFingerprint(obj[k]));
return '{' + parts.join(',') + '}';
}
function onRemoteUpdate() {
if (weJustPushed) return;
if (!pass || !holesailManager) return;
if (remoteUpdateDebounceTimer) clearTimeout(remoteUpdateDebounceTimer);
remoteUpdateDebounceTimer = setTimeout(() => {
remoteUpdateDebounceTimer = null;
applyRemoteUpdate().catch((e) => {
if (process.stderr) process.stderr.write('[sync-manager] applyRemoteUpdate failed: ' + e.message + '\n');
});
}, REMOTE_UPDATE_DEBOUNCE_MS);
}
async function applyRemoteUpdate() {
if (weJustPushed || !pass || !holesailManager) return;
try {
const entry = await pass.get(STATE_KEY);
if (!entry || entry.value == null) return;
const str = typeof entry.value === 'string' ? entry.value : (entry.value && entry.value.toString ? entry.value.toString() : '');
if (!str) return;
const snapshot = JSON.parse(str);
const statePath = getStateFilePath();
if (statePath && fs.existsSync(statePath)) {
try {
const currentRaw = fs.readFileSync(statePath, 'utf8');
const current = JSON.parse(currentRaw);
if (stateFingerprint(current) === stateFingerprint(snapshot)) return;
} catch (_) {}
}
await applySyncedState(snapshot);
lastSyncedAt = Date.now();
if (emitEvent) emitEvent('syncApplied', {});
@@ -160,7 +192,8 @@ async function onRemoteUpdate() {
/**
* Write state snapshot to state.json and run the same restore flow as backup restore.
* Does not touch certs.
* Does not touch certs. Keeps applyingSync true until all tunnels are restored so
* saveState() calls during restore do not push back to autopass and cause a loop.
*/
async function applySyncedState(snapshot) {
if (!holesailManager || !setTunnelsRestoredPromise || !restorePersistedTunnels) return;
@@ -174,9 +207,11 @@ async function applySyncedState(snapshot) {
fs.writeFileSync(statePath, json, 'utf8');
await holesailManager.cleanup().catch(() => {});
const restored = holesailManager.restorePersistedState();
setTunnelsRestoredPromise(
restorePersistedTunnels(holesailManager, restored).catch((e) => log('Post-sync tunnel restore failed:', e.message))
const restorePromise = restorePersistedTunnels(holesailManager, restored).catch((e) =>
log('Post-sync tunnel restore failed:', e.message)
);
setTunnelsRestoredPromise(restorePromise);
await restorePromise;
} finally {
applyingSync = false;
}
@@ -287,6 +322,10 @@ async function cleanup() {
initPromise = null;
weJustPushed = false;
applyingSync = false;
if (remoteUpdateDebounceTimer) {
clearTimeout(remoteUpdateDebounceTimer);
remoteUpdateDebounceTimer = null;
}
if (pass && pass.off) pass.off('update', onRemoteUpdate);
await closePass();
}