fix(sync): apply live Autopass updates including intentional empties
CI / Build & Test (push) Successful in 4m26s

Stop dropping peer deletes when the synced snapshot is empty (e.g. last
vhost removed with syncServers off). Trust Autopass LWW instead of
rejecting lower stateRevision, avoid bumping revision on device-name
status pushes, and retry push after sync warmup if Autopass was not open yet.
This commit is contained in:
Raven Scott
2026-07-26 00:55:14 -04:00
parent db640644bf
commit 5a5a9bee9c
3 changed files with 39 additions and 11 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ When a linked device goes offline (e.g. browser closed, machine asleep), it rema
## Behaviour after linking
- **Push:** Any change on a device (add/remove virtual host, edit settings, etc.) is saved to disk and pushed to the sync group. Other linked devices receive the update and apply it (only changed tunnels are started or restarted).
- **Pull:** When an update is received from another device, the native host diffs the synced state against the current state and applies only the changes: tunnels that were removed are stopped, new or changed tunnels are started or restarted, and unchanged tunnels keep running. The merged state is written to `state.json`. The dashboard refreshes when it receives the `syncApplied` event. **All** remote changes are applied (adds, edits, and deletions). The only exception: if the incoming snapshot is **empty** (no servers, virtual hosts, or service tunnels) and this device already has content, the update is skipped so that a bad merge or cold peer cannot overwrite real state with empty.
- **Pull:** When an update is received from another device, the native host diffs the synced state against the current state and applies only the changes: tunnels that were removed are stopped, new or changed tunnels are started or restarted, and unchanged tunnels keep running. The merged state is written to `state.json`. The dashboard refreshes when it receives the `syncApplied` event. **All** remote changes are applied (adds, edits, and deletions), including clearing the last synced tunnel. The only exception: if the incoming snapshot is **empty and unversioned** (`stateRevision` missing/0) while this device already has synced content, the update is skipped so a cold peer or bad merge cannot wipe real state.
- **Persistence:** The link is stored in `holesail-browser-storage/autopass-identity.json`. After closing the browser or restarting the native host, both devices remain linked and continue syncing when the host runs.
## At link time (pairing)
+4
View File
@@ -55,6 +55,10 @@ const rdpManager = require('../managers/rdp-manager.js');
httpsProxy.setHostnameResolver((hostname) => holesailManager.getLocalBackend(hostname));
void syncManager.warmupSyncAtStartup().catch((err) => {
debugLog('sync warmup at startup failed (continuing):', err && err.message);
});
initStartup(holesailManager, certificateAuthority, httpsProxy, connectProxy);
// Scheduled auto-backup: fires every backupIntervalHours when > 0.
+34 -10
View File
@@ -308,6 +308,14 @@ function snapshotContentSize(snapshot) {
(Array.isArray(snapshot.serviceTunnels) ? snapshot.serviceTunnels.length : 0);
}
/** Count tunnels that participate in sync (servers only when syncServers is on). */
function syncedContentSize(snapshot, syncServers) {
if (!snapshot || typeof snapshot !== 'object') return 0;
return (syncServers && Array.isArray(snapshot.servers) ? snapshot.servers.length : 0) +
(Array.isArray(snapshot.virtualHosts) ? snapshot.virtualHosts.length : 0) +
(Array.isArray(snapshot.serviceTunnels) ? snapshot.serviceTunnels.length : 0);
}
async function applyRemoteUpdate() {
if (!pass || !holesailManager || applyingSync) return;
try {
@@ -332,17 +340,18 @@ async function applyRemoteUpdate() {
if (stateFingerprint(current) === stateFingerprint(snapshot)) return;
} catch (_) {}
}
// Reject stale revisions (cold peer / out-of-order Autopass views) so they cannot delete newer local config.
const localRev = current && typeof current.stateRevision === 'number' ? current.stateRevision : readStateRevisionFromDisk();
// Autopass already LWW-chose this blob — do not reject on stateRevision (local rev can be
// ahead after metadata pushes or failed replication; skipping would diverge forever).
const remoteRev = typeof snapshot.stateRevision === 'number' ? snapshot.stateRevision : 0;
if (localRev > 0 && remoteRev > 0 && remoteRev < localRev) {
if (process.stderr) process.stderr.write('[sync-manager] skip stale remote stateRevision ' + remoteRev + ' < local ' + localRev + '\n');
// Skip only unversioned empty snapshots (cold peer / bad merge). A versioned empty
// snapshot is an intentional clear (e.g. delete last virtual host with syncServers off).
const syncServers = !!(holesailManager.getSettings && holesailManager.getSettings().syncServers);
const currentSize = syncedContentSize(current || holesailManager.getStateSnapshot(), syncServers);
const snapshotSize = syncedContentSize(snapshot, syncServers);
if (snapshotSize === 0 && currentSize > 0 && !(remoteRev > 0)) {
if (process.stderr) process.stderr.write('[sync-manager] skip unversioned empty remote snapshot\n');
return;
}
// Only skip when the snapshot is empty and we have content (avoids empty overwriting after a bad merge or cold peer).
const currentSize = current ? snapshotContentSize(current) : snapshotContentSize(holesailManager.getStateSnapshot());
const snapshotSize = snapshotContentSize(snapshot);
if (snapshotSize === 0 && currentSize > 0) return;
await applySyncedState(snapshot);
lastSyncedAt = Date.now();
if (emitEvent) emitEvent('syncApplied', {});
@@ -583,7 +592,16 @@ async function applySyncedState(snapshot) {
* Called by holesail-manager after each saveState(). Merge this device's hostname into state, write to disk, then push to autopass (if linked and not applying).
*/
function onStateSaved(snapshot) {
if (applyingSync || !pass) return;
if (applyingSync) return;
if (!pass) {
// Host may save before Autopass is reopened after restart — warm up then push.
if (loadIdentity()) {
void ensureInitialized().then(() => {
if (!applyingSync && pass) onStateSaved(snapshot);
}).catch(() => {});
}
return;
}
enqueueSyncOp(async () => {
if (applyingSync || !pass) return;
try {
@@ -692,6 +710,10 @@ async function getSyncStatus() {
usesCustomDisplayName: localDisp.length > 0
};
if (pass) {
// Opportunistic pull so dashboard polls keep peers converging if Autopass 'update' is quiet.
void enqueueSyncOp(() => applyRemoteUpdate()).catch((e) => {
if (process.stderr) process.stderr.write('[sync-manager] applyRemoteUpdate (status poll) failed: ' + e.message + '\n');
});
try {
const identity = loadIdentity();
out.isMaster = identity && identity.isMaster === true;
@@ -728,6 +750,7 @@ async function getSyncStatus() {
}];
}
// Publish display name / runtime from live in-memory state (never rewrite a stale disk snapshot).
// Do not bump stateRevision — metadata-only pushes must not race ahead of tunnel edits.
const myId = out.deviceId;
const currentName = out.deviceName;
const currentRuntime = getLocalRuntimeKind();
@@ -749,7 +772,8 @@ async function getSyncStatus() {
const existing = readSyncMasterDeviceIdFromState();
if (existing) toPush.syncMasterDeviceId = existing;
}
toPush.stateRevision = nextStateRevision(toPush);
const existingRev = readStateRevisionFromDisk();
toPush.stateRevision = existingRev > 0 ? existingRev : nextStateRevision(toPush);
const statePath = getStateFilePath();
if (statePath) {
const onDisk = { ...toPush };