fix(sync): apply live Autopass updates including intentional empties
CI / Build & Test (push) Successful in 4m26s
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:
+1
-1
@@ -40,7 +40,7 @@ When a linked device goes offline (e.g. browser closed, machine asleep), it rema
|
|||||||
## Behaviour after linking
|
## 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).
|
- **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.
|
- **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)
|
## At link time (pairing)
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ const rdpManager = require('../managers/rdp-manager.js');
|
|||||||
|
|
||||||
httpsProxy.setHostnameResolver((hostname) => holesailManager.getLocalBackend(hostname));
|
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);
|
initStartup(holesailManager, certificateAuthority, httpsProxy, connectProxy);
|
||||||
|
|
||||||
// Scheduled auto-backup: fires every backupIntervalHours when > 0.
|
// Scheduled auto-backup: fires every backupIntervalHours when > 0.
|
||||||
|
|||||||
@@ -308,6 +308,14 @@ function snapshotContentSize(snapshot) {
|
|||||||
(Array.isArray(snapshot.serviceTunnels) ? snapshot.serviceTunnels.length : 0);
|
(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() {
|
async function applyRemoteUpdate() {
|
||||||
if (!pass || !holesailManager || applyingSync) return;
|
if (!pass || !holesailManager || applyingSync) return;
|
||||||
try {
|
try {
|
||||||
@@ -332,17 +340,18 @@ async function applyRemoteUpdate() {
|
|||||||
if (stateFingerprint(current) === stateFingerprint(snapshot)) return;
|
if (stateFingerprint(current) === stateFingerprint(snapshot)) return;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
// Reject stale revisions (cold peer / out-of-order Autopass views) so they cannot delete newer local config.
|
// Autopass already LWW-chose this blob — do not reject on stateRevision (local rev can be
|
||||||
const localRev = current && typeof current.stateRevision === 'number' ? current.stateRevision : readStateRevisionFromDisk();
|
// ahead after metadata pushes or failed replication; skipping would diverge forever).
|
||||||
const remoteRev = typeof snapshot.stateRevision === 'number' ? snapshot.stateRevision : 0;
|
const remoteRev = typeof snapshot.stateRevision === 'number' ? snapshot.stateRevision : 0;
|
||||||
if (localRev > 0 && remoteRev > 0 && remoteRev < localRev) {
|
// Skip only unversioned empty snapshots (cold peer / bad merge). A versioned empty
|
||||||
if (process.stderr) process.stderr.write('[sync-manager] skip stale remote stateRevision ' + remoteRev + ' < local ' + localRev + '\n');
|
// 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;
|
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);
|
await applySyncedState(snapshot);
|
||||||
lastSyncedAt = Date.now();
|
lastSyncedAt = Date.now();
|
||||||
if (emitEvent) emitEvent('syncApplied', {});
|
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).
|
* 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) {
|
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 () => {
|
enqueueSyncOp(async () => {
|
||||||
if (applyingSync || !pass) return;
|
if (applyingSync || !pass) return;
|
||||||
try {
|
try {
|
||||||
@@ -692,6 +710,10 @@ async function getSyncStatus() {
|
|||||||
usesCustomDisplayName: localDisp.length > 0
|
usesCustomDisplayName: localDisp.length > 0
|
||||||
};
|
};
|
||||||
if (pass) {
|
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 {
|
try {
|
||||||
const identity = loadIdentity();
|
const identity = loadIdentity();
|
||||||
out.isMaster = identity && identity.isMaster === true;
|
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).
|
// 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 myId = out.deviceId;
|
||||||
const currentName = out.deviceName;
|
const currentName = out.deviceName;
|
||||||
const currentRuntime = getLocalRuntimeKind();
|
const currentRuntime = getLocalRuntimeKind();
|
||||||
@@ -749,7 +772,8 @@ async function getSyncStatus() {
|
|||||||
const existing = readSyncMasterDeviceIdFromState();
|
const existing = readSyncMasterDeviceIdFromState();
|
||||||
if (existing) toPush.syncMasterDeviceId = existing;
|
if (existing) toPush.syncMasterDeviceId = existing;
|
||||||
}
|
}
|
||||||
toPush.stateRevision = nextStateRevision(toPush);
|
const existingRev = readStateRevisionFromDisk();
|
||||||
|
toPush.stateRevision = existingRev > 0 ? existingRev : nextStateRevision(toPush);
|
||||||
const statePath = getStateFilePath();
|
const statePath = getStateFilePath();
|
||||||
if (statePath) {
|
if (statePath) {
|
||||||
const onDisk = { ...toPush };
|
const onDisk = { ...toPush };
|
||||||
|
|||||||
Reference in New Issue
Block a user