feat: sync ETag concurrency + fix chunk-transfer hang + sync overlay + auto-VACUUM

Sync optimistic concurrency (ETag/If-Match)
- webdav GET captures the response ETag; PUT sends it back as If-Match so
  the server rejects (412) our write when another device changed the file
  between our pull and push. A 412 re-runs the whole pull→merge→push
  (bounded to 3) so the other device's changes are folded in instead of
  clobbered. Servers without ETags → empty etag → no If-Match → falls
  back to last-write-wins (no regression). onWebdavResult gained a 4th
  etag arg.

Chunked webdav PUT (big vaults no longer black-screen on sync)
- The whole encrypted snapshot base64'd into a single cmd://webdav/put URL
  blew past WebView2's cap → black screen once the vault grew (20MB of
  attachments). PUT bodies now stream through the file/chunk transport and
  commit via a new webdav/put-commit (reads the accumulated buffer).

Chunk-transfer hang fix (root cause of the stuck "Preparing…" sync)
- All chunked transfers (saveFile/writeFile/webdav PUT) share one
  reqId-keyed resolver. A resolved chunk's stale 30s timeout would later
  delete the CURRENT chunk's resolver and fire the wrong res(), leaving
  that chunk's await pending forever. Extracted a single _streamChunks()
  helper whose ack CLEARS the pending timeout, so resolvers stay strictly
  one-at-a-time. Also fixed _webdavCall referencing the Bridge-local cmd()
  from module scope (latent ReferenceError).

Sync busy overlay
- syncStatus() now drives the global busy overlay too, so a running sync
  blocks stray clicks (e.g. the auto-backup "Choose…" picker) and reads
  like the manual backup. The account-mismatch confirm hideBusy()s first
  so it's visible above the overlay.

Auto-VACUUM (reclaim space after deleting large attachments)
- SQLite never shrinks the file on DELETE, so deleting big attachments
  left vault.db bloated (35MB for 11 tiny entries). DB.CompactIfBloated
  VACUUMs when >20% of pages are free AND >~2MB is reclaimable — called on
  startup and after each attachment delete. A healthy small vault pays
  nothing. (Verified: 35MB → 695KB after the deletes.)

Rebuild: BuildAssets + F9 (UMainForm + PM.Database + PM.Handler.Attachments).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-04 00:02:25 +01:00
parent b367d031b5
commit ac909f4f09
6 changed files with 195 additions and 57 deletions
+8 -2
View File
@@ -499,8 +499,14 @@ device, never transmitted.
restore attachments ; both sides have it = compare `updated_at`, restore attachments ; both sides have it = compare `updated_at`,
PUT if remote newer. PUT if remote newer.
7. `loadEntries()` + `buildSyncSnapshot()` for the post-merge state. 7. `loadEntries()` + `buildSyncSnapshot()` for the post-merge state.
8. `webdav/put` push the merged snapshot. 8. `webdav/put` push the merged snapshot, with `If-Match: <etag>` where
9. Toast `X added · Y updated · Z deleted`. the etag was captured from the step-2 pull (optimistic concurrency).
A **412** means another device changed the file between our pull and
push → re-run the whole pull→merge→push (`runSyncNow(_attempt+1)`,
bounded to 3) so their changes are folded in instead of clobbered.
Servers without ETag support return an empty etag → no If-Match sent
→ falls back to last-write-wins (same as before).
9. Toast `pulled X new · Y updated · Z deleted · pushed N entries`.
Sensitive actions (export, change master pw, recovery code…) still Sensitive actions (export, change master pw, recovery code…) still
require master pw via `askReauth` — sync never substitutes. require master pw via `askReauth` — sync never substitutes.
@@ -431,6 +431,11 @@ begin
DB.Unlock; DB.Unlock;
end; end;
// Attachments are the big rows; deleting one leaves the file bloated
// until VACUUM'd. Compact now (no-op unless the free ratio is high) so
// disk space is reclaimed immediately, not just on the next launch.
DB.CompactIfBloated;
LogAudit(LUserId, 'delete_attachment', GetClientIP(ARequest)); LogAudit(LUserId, 'delete_attachment', GetClientIP(ARequest));
TJSONHelper.SendOK(AResponse, 'Deleted'); TJSONHelper.SendOK(AResponse, 'Deleted');
end; end;
+39
View File
@@ -33,6 +33,11 @@ type
destructor Destroy; override; destructor Destroy; override;
procedure Lock; procedure Lock;
procedure Unlock; procedure Unlock;
// VACUUM the file when a large fraction of its pages are free (SQLite
// never shrinks on DELETE — deleting big attachments leaves the file
// bloated). No-op when the free ratio is low, so the common case pays
// nothing. Caller must NOT hold the lock or be in a transaction.
procedure CompactIfBloated;
property Connection: TFDConnection read FConn; property Connection: TFDConnection read FConn;
property DBPath: string read FDBPath; property DBPath: string read FDBPath;
end; end;
@@ -61,6 +66,10 @@ begin
CreateSchema; CreateSchema;
ApplyMigrations; ApplyMigrations;
CleanupExpired; CleanupExpired;
// Reclaim space left behind by previously-deleted large rows
// (attachments especially). Only actually rewrites the file when it's
// meaningfully bloated, so a healthy small vault opens instantly.
CompactIfBloated;
end; end;
destructor TPMDatabase.Destroy; destructor TPMDatabase.Destroy;
@@ -70,6 +79,36 @@ begin
inherited; inherited;
end; end;
procedure TPMDatabase.CompactIfBloated;
var
LQ: TFDQuery;
LFree, LTotal: Int64;
begin
FLock.Enter;
try
LFree := 0; LTotal := 0;
LQ := TFDQuery.Create(nil);
try
LQ.Connection := FConn;
LQ.SQL.Text := 'PRAGMA freelist_count';
LQ.Open; if not LQ.IsEmpty then LFree := LQ.Fields[0].AsLargeInt;
LQ.Close;
LQ.SQL.Text := 'PRAGMA page_count';
LQ.Open; if not LQ.IsEmpty then LTotal := LQ.Fields[0].AsLargeInt;
LQ.Close;
finally
LQ.Free;
end;
// Rewrite only when >20% of the pages are free AND there's at least a
// few MB to reclaim (avoids churning a tiny vault). VACUUM can't run
// inside a transaction, so this must be called outside one.
if (LTotal > 0) and (LFree * 5 > LTotal) and (LFree > 512) then
FConn.ExecSQL('VACUUM');
finally
FLock.Leave;
end;
end;
procedure TPMDatabase.Lock; procedure TPMDatabase.Lock;
begin begin
FLock.Enter; FLock.Enter;
+39 -6
View File
@@ -1101,7 +1101,8 @@ begin
// - PUT ok → status=200/201/204, body='' // - PUT ok → status=200/201/204, body=''
// - test → status=200..399 means reachable, body='' // - test → status=200..399 means reachable, body=''
// Network errors → status=0, body=exception message. // Network errors → status=0, body=exception message.
else if (ACmd = 'webdav/get') or (ACmd = 'webdav/put') or (ACmd = 'webdav/test') then else if (ACmd = 'webdav/get') or (ACmd = 'webdav/put') or (ACmd = 'webdav/test')
or (ACmd = 'webdav/put-commit') then
begin begin
var LMethod := ACmd; var LMethod := ACmd;
var LReqId := GetParam('reqId'); var LReqId := GetParam('reqId');
@@ -1109,6 +1110,26 @@ begin
var LUser := GetParam('user'); var LUser := GetParam('user');
var LPwd := GetParam('pwd'); var LPwd := GetParam('pwd');
var LData := GetParam('data'); var LData := GetParam('data');
// put-commit: the (large) body arrived in chunks via file/chunk,
// accumulated in FFileSaveChunks keyed by reqId. Pull it out and treat
// the rest as a normal PUT.
if ACmd = 'webdav/put-commit' then
begin
var LSB: TStringBuilder;
if FFileSaveChunks.TryGetValue(LReqId, LSB) then
begin
LData := LSB.ToString;
LSB.Free;
FFileSaveChunks.Remove(LReqId);
end
else
LData := '';
LMethod := 'webdav/put';
end;
// Optimistic concurrency: JS passes the ETag it saw at pull time; we
// send it as If-Match on the push so the server rejects (412) the
// write when another device changed the file in between.
var LIfMatch := GetParam('ifmatch');
TThread.CreateAnonymousThread( TThread.CreateAnonymousThread(
procedure procedure
var var
@@ -1120,10 +1141,12 @@ begin
LBodyB64: string; LBodyB64: string;
LStatus: Integer; LStatus: Integer;
LErr: string; LErr: string;
LEtag: string;
begin begin
LStatus := 0; LStatus := 0;
LBodyB64 := ''; LBodyB64 := '';
LErr := ''; LErr := '';
LEtag := '';
try try
LHttp := System.Net.HttpClient.THTTPClient.Create; LHttp := System.Net.HttpClient.THTTPClient.Create;
try try
@@ -1141,6 +1164,7 @@ begin
try try
LResp := LHttp.Get(LUrl, LBodyStream); LResp := LHttp.Get(LUrl, LBodyStream);
LStatus := LResp.StatusCode; LStatus := LResp.StatusCode;
LEtag := LResp.HeaderValue['ETag'];
if (LStatus >= 200) and (LStatus < 300) and (LBodyStream.Size > 0) then if (LStatus >= 200) and (LStatus < 300) and (LBodyStream.Size > 0) then
begin begin
SetLength(LBytes, LBodyStream.Size); SetLength(LBytes, LBodyStream.Size);
@@ -1158,8 +1182,13 @@ begin
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData); LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
LReqStream := TBytesStream.Create(LBytes); LReqStream := TBytesStream.Create(LBytes);
try try
LResp := LHttp.Put(LUrl, LReqStream); if LIfMatch <> '' then
LResp := LHttp.Put(LUrl, LReqStream, nil,
[System.Net.URLClient.TNetHeader.Create('If-Match', LIfMatch)])
else
LResp := LHttp.Put(LUrl, LReqStream);
LStatus := LResp.StatusCode; LStatus := LResp.StatusCode;
LEtag := LResp.HeaderValue['ETag'];
finally finally
LReqStream.Free; LReqStream.Free;
end; end;
@@ -1182,7 +1211,7 @@ begin
TThread.Queue(nil, TThread.Queue(nil,
procedure procedure
var var
LEscReq, LEscPayload: string; LEscReq, LEscPayload, LEscEtag: string;
begin begin
LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]); LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
// GET success path → ship body base64. Otherwise the field // GET success path → ship body base64. Otherwise the field
@@ -1196,12 +1225,16 @@ begin
LEscPayload := StringReplace(LEscPayload, '"', '\"', [rfReplaceAll]); LEscPayload := StringReplace(LEscPayload, '"', '\"', [rfReplaceAll]);
LEscPayload := StringReplace(LEscPayload, #13, '', [rfReplaceAll]); LEscPayload := StringReplace(LEscPayload, #13, '', [rfReplaceAll]);
LEscPayload := StringReplace(LEscPayload, #10, '\n', [rfReplaceAll]); LEscPayload := StringReplace(LEscPayload, #10, '\n', [rfReplaceAll]);
LEscEtag := StringReplace(LEtag, '\', '\\', [rfReplaceAll]);
LEscEtag := StringReplace(LEscEtag, '"', '\"', [rfReplaceAll]);
LEscEtag := StringReplace(LEscEtag, #13, '', [rfReplaceAll]);
LEscEtag := StringReplace(LEscEtag, #10, '', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onWebdavResult)' + 'if(window.Bridge&&Bridge.onWebdavResult)' +
'Bridge.onWebdavResult("' + LEscReq + '",' + 'Bridge.onWebdavResult("' + LEscReq + '",' +
IntToStr(LStatus) + ',"' + LEscPayload + '")'); IntToStr(LStatus) + ',"' + LEscPayload + '","' + LEscEtag + '")');
LogLine(Format('%s %s → %d (%d bytes payload)', LogLine(Format('%s %s → %d (%d bytes payload, etag=%s)',
[LMethod, LUrl, LStatus, Length(LEscPayload)])); [LMethod, LUrl, LStatus, Length(LEscPayload), LEtag]));
end); end);
end).Start; end).Start;
end end
Binary file not shown.
+104 -49
View File
@@ -16,6 +16,36 @@ const API = (location.pathname.indexOf('/password-manager/') === 0)
const prefResolvers = {}; const prefResolvers = {};
const fileSaveResolvers = {}; const fileSaveResolvers = {};
const fileChunkResolvers = {}; const fileChunkResolvers = {};
// Stream a base64 string to Delphi in URL-sized pieces via cmd://file/chunk,
// each acked (onFileChunkAck) before the next is sent. Returns true when all
// pieces are buffered server-side, false on a stalled chunk. The ack CLEARS
// the pending timeout — without that, a resolved chunk's stale 30s timeout
// would later delete the CURRENT chunk's resolver and hang the transfer
// forever (only the last location.href navigation "wins" per event loop, so
// resolvers must be strictly 1-at-a-time and their timers torn down).
async function _streamChunks(reqId, b64, onProgress) {
const CHUNK = 1000000;
const total = Math.ceil(b64.length / CHUNK);
let done = 0;
for (let off = 0; off < b64.length; off += CHUNK) {
const piece = b64.slice(off, off + CHUNK);
const ok = await new Promise(res => {
const timer = setTimeout(() => {
if (fileChunkResolvers[reqId]) {
delete fileChunkResolvers[reqId];
res(false);
}
}, 30000);
fileChunkResolvers[reqId] = { resolve: res, timer };
window.location.href = 'cmd://file/chunk?reqId=' + encodeURIComponent(reqId) +
'&data=' + encodeURIComponent(piece);
});
if (!ok) return false;
done++;
if (typeof onProgress === 'function') onProgress(Math.round(done / total * 100));
}
return true;
}
let versionResolver = null; let versionResolver = null;
let launchModeResolver = null; let launchModeResolver = null;
const folderPickResolvers = {}; const folderPickResolvers = {};
@@ -241,26 +271,8 @@ const Bridge = (() => {
}); });
} }
return (async () => { return (async () => {
const totalChunks = Math.ceil(b64.length / CHUNK); const ok = await _streamChunks(reqId, b64, onProgress);
let done = 0; if (!ok) return { ok: false, error: 'chunk transfer failed' };
for (let off = 0; off < b64.length; off += CHUNK) {
const piece = b64.slice(off, off + CHUNK);
const ackOk = await new Promise(res => {
fileChunkResolvers[reqId] = res;
cmd('cmd://file/chunk?reqId=' + encodeURIComponent(reqId) +
'&data=' + encodeURIComponent(piece));
setTimeout(() => {
if (fileChunkResolvers[reqId]) {
delete fileChunkResolvers[reqId];
res(false);
}
}, 30000);
});
if (!ackOk) return { ok: false, error: 'chunk transfer failed' };
done++;
if (typeof onProgress === 'function')
onProgress(Math.round(done / totalChunks * 100));
}
return await new Promise(resolve => { return await new Promise(resolve => {
fileSaveResolvers[reqId] = resolve; fileSaveResolvers[reqId] = resolve;
cmd('cmd://file/save-commit?reqId=' + encodeURIComponent(reqId) + cmd('cmd://file/save-commit?reqId=' + encodeURIComponent(reqId) +
@@ -276,7 +288,11 @@ const Bridge = (() => {
}, },
onFileChunkAck(reqId) { onFileChunkAck(reqId) {
const r = fileChunkResolvers[reqId]; const r = fileChunkResolvers[reqId];
if (r) { delete fileChunkResolvers[reqId]; r(true); } if (r) {
delete fileChunkResolvers[reqId];
if (r.timer) clearTimeout(r.timer);
r.resolve(true);
}
}, },
onFileSaveResult(reqId, ok, path, err) { onFileSaveResult(reqId, ok, path, err) {
const r = fileSaveResolvers[reqId]; const r = fileSaveResolvers[reqId];
@@ -332,26 +348,8 @@ const Bridge = (() => {
}); });
} }
return (async () => { return (async () => {
const totalChunks = Math.ceil(b64.length / CHUNK); const ok = await _streamChunks(reqId, b64, onProgress);
let done = 0; if (!ok) return { ok: false, error: 'chunk transfer failed' };
for (let off = 0; off < b64.length; off += CHUNK) {
const piece = b64.slice(off, off + CHUNK);
const ackOk = await new Promise(res => {
fileChunkResolvers[reqId] = res;
cmd('cmd://file/chunk?reqId=' + encodeURIComponent(reqId) +
'&data=' + encodeURIComponent(piece));
setTimeout(() => {
if (fileChunkResolvers[reqId]) {
delete fileChunkResolvers[reqId];
res(false);
}
}, 30000);
});
if (!ackOk) return { ok: false, error: 'chunk transfer failed' };
done++;
if (typeof onProgress === 'function')
onProgress(Math.round(done / totalChunks * 100));
}
return await new Promise(resolve => { return await new Promise(resolve => {
fileWriteResolvers[reqId] = resolve; fileWriteResolvers[reqId] = resolve;
cmd('cmd://file/write-commit?reqId=' + encodeURIComponent(reqId) + cmd('cmd://file/write-commit?reqId=' + encodeURIComponent(reqId) +
@@ -9889,15 +9887,45 @@ const SYNC_PREFS = {
}; };
let _webdavResolvers = {}; let _webdavResolvers = {};
Bridge.onWebdavResult = function(reqId, status, payload) { Bridge.onWebdavResult = function(reqId, status, payload, etag) {
const r = _webdavResolvers[reqId]; const r = _webdavResolvers[reqId];
if (!r) return; if (!r) return;
delete _webdavResolvers[reqId]; delete _webdavResolvers[reqId];
r({ status: status | 0, payload: payload || '' }); r({ status: status | 0, payload: payload || '', etag: etag || '' });
}; };
function _webdavCall(method, url, user, pwd, dataB64) { // method: 'get'|'put'|'test'. opts.ifMatch → sent as If-Match on a put so
// the server rejects (412) a write when the remote changed since our pull.
function _webdavCall(method, url, user, pwd, dataB64, opts) {
opts = opts || {};
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
const CHUNK = 1000000;
// Large PUT bodies (a big encrypted vault) can't ride in a single
// cmd:// URL — WebView2 caps it and the navigation blanks the page.
// Stream the base64 through the file/chunk transport, then commit via
// webdav/put-commit (which reads the accumulated buffer server-side).
if (method === 'put' && dataB64 && dataB64.length > CHUNK) {
return (async () => {
const ok = await _streamChunks(reqId, dataB64);
if (!ok) return { status: 0, payload: 'chunk transfer failed', etag: '' };
return await new Promise(resolve => {
_webdavResolvers[reqId] = resolve;
let q = 'cmd://webdav/put-commit'
+ '?reqId=' + encodeURIComponent(reqId)
+ '&url=' + encodeURIComponent(url)
+ '&user=' + encodeURIComponent(user || '')
+ '&pwd=' + encodeURIComponent(pwd || '');
if (opts.ifMatch) q += '&ifmatch=' + encodeURIComponent(opts.ifMatch);
window.location.href = q;
setTimeout(() => {
if (_webdavResolvers[reqId]) {
delete _webdavResolvers[reqId];
resolve({ status: 0, payload: 'timeout', etag: '' });
}
}, 60000);
});
})();
}
return new Promise(resolve => { return new Promise(resolve => {
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
_webdavResolvers[reqId] = resolve; _webdavResolvers[reqId] = resolve;
let q = 'cmd://webdav/' + method let q = 'cmd://webdav/' + method
+ '?reqId=' + encodeURIComponent(reqId) + '?reqId=' + encodeURIComponent(reqId)
@@ -9905,11 +9933,12 @@ function _webdavCall(method, url, user, pwd, dataB64) {
+ '&user=' + encodeURIComponent(user || '') + '&user=' + encodeURIComponent(user || '')
+ '&pwd=' + encodeURIComponent(pwd || ''); + '&pwd=' + encodeURIComponent(pwd || '');
if (dataB64) q += '&data=' + encodeURIComponent(dataB64); if (dataB64) q += '&data=' + encodeURIComponent(dataB64);
if (opts.ifMatch) q += '&ifmatch=' + encodeURIComponent(opts.ifMatch);
window.location.href = q; window.location.href = q;
setTimeout(() => { setTimeout(() => {
if (_webdavResolvers[reqId]) { if (_webdavResolvers[reqId]) {
delete _webdavResolvers[reqId]; delete _webdavResolvers[reqId];
resolve({ status: 0, payload: 'timeout' }); resolve({ status: 0, payload: 'timeout', etag: '' });
} }
}, 60000); }, 60000);
}); });
@@ -10245,6 +10274,10 @@ function syncStatus(text) {
if (text) { el.textContent = text; el.style.display = ''; } if (text) { el.textContent = text; el.style.display = ''; }
else { el.textContent = ''; el.style.display = 'none'; } else { el.textContent = ''; el.style.display = 'none'; }
} }
// Also drive the global busy overlay so a running sync blocks stray
// clicks (e.g. the auto-backup "Choose…" picker) and reads the same
// as the manual backup. Empty text = clear.
if (text) showBusy(text); else hideBusy();
// Lock the Sync/Test buttons while a run is in flight so the user // Lock the Sync/Test buttons while a run is in flight so the user
// can't double-click a second concurrent sync. // can't double-click a second concurrent sync.
const active = !!text; const active = !!text;
@@ -10254,14 +10287,15 @@ function syncStatus(text) {
}); });
} }
async function runSyncNow() { async function runSyncNow(_attempt) {
_attempt = _attempt || 0;
const cfg = await loadSyncConfig(); const cfg = await loadSyncConfig();
if (!cfg) return toast('Bridge not available', 'error'); if (!cfg) return toast('Bridge not available', 'error');
if (!cfg.url) return toast('Sync not configured', 'warning'); if (!cfg.url) return toast('Sync not configured', 'warning');
if (!cfg.encPwd) return toast('Set the sync password first', 'warning'); if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
if (!state.cryptoKey) return toast('Vault is locked', 'warning'); if (!state.cryptoKey) return toast('Vault is locked', 'warning');
toast('Syncing…'); if (_attempt === 0) toast('Syncing…');
let merged = { added: 0, updated: 0, deleted: 0 }; let merged = { added: 0, updated: 0, deleted: 0 };
// Fail-fast connectivity: hit the remote FIRST so a dead server / // Fail-fast connectivity: hit the remote FIRST so a dead server /
@@ -10285,6 +10319,10 @@ async function runSyncNow() {
syncStatus(''); syncStatus('');
return toast('Pull failed: HTTP ' + pullResp.status, 'error'); return toast('Pull failed: HTTP ' + pullResp.status, 'error');
} }
// ETag of the version we just pulled — sent back as If-Match on the
// push so the server rejects our write if another device changed the
// file in between (optimistic concurrency, avoids lost updates).
const remoteEtag = pullResp.etag || '';
// Decrypt + cross-account guard BEFORE touching local state, so a // Decrypt + cross-account guard BEFORE touching local state, so a
// wrong sync password or a foreign account aborts cleanly. // wrong sync password or a foreign account aborts cleanly.
@@ -10300,6 +10338,9 @@ async function runSyncNow() {
} }
if (remoteSnap && remoteSnap.username && state.username && if (remoteSnap && remoteSnap.username && state.username &&
remoteSnap.username !== state.username) { remoteSnap.username !== state.username) {
// Drop the busy overlay so this confirm (z-index below it)
// is visible; a later syncStatus() re-shows it if we continue.
hideBusy();
const ok = await confirmDialog({ const ok = await confirmDialog({
title: 'Different account on remote', title: 'Different account on remote',
message: 'The remote snapshot belongs to <b>' + message: 'The remote snapshot belongs to <b>' +
@@ -10369,7 +10410,21 @@ async function runSyncNow() {
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2)); const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
syncStatus('Pushing…'); syncStatus('Pushing…');
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd, const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
bytesToBase64(bodyBytes)); bytesToBase64(bodyBytes),
{ ifMatch: remoteEtag });
// 412 Precondition Failed = the remote changed since our pull
// (another device pushed). Re-run the whole pull→merge→push so we
// fold in their changes instead of clobbering them. Bounded to a
// few tries to avoid a livelock against a device syncing in a hot
// loop.
if (r.status === 412) {
syncStatus('');
if (_attempt < 3) {
toast('Remote changed — re-syncing…');
return await runSyncNow(_attempt + 1);
}
return toast('Sync gave up after repeated remote changes — try again', 'error');
}
if (!(r.status >= 200 && r.status < 300)) { if (!(r.status >= 200 && r.status < 300)) {
syncStatus(''); syncStatus('');
return toast('Push failed: HTTP ' + r.status, 'error'); return toast('Push failed: HTTP ' + r.status, 'error');