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
+39 -6
View File
@@ -1101,7 +1101,8 @@ begin
// - PUT ok → status=200/201/204, body=''
// - test → status=200..399 means reachable, body=''
// 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
var LMethod := ACmd;
var LReqId := GetParam('reqId');
@@ -1109,6 +1110,26 @@ begin
var LUser := GetParam('user');
var LPwd := GetParam('pwd');
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(
procedure
var
@@ -1120,10 +1141,12 @@ begin
LBodyB64: string;
LStatus: Integer;
LErr: string;
LEtag: string;
begin
LStatus := 0;
LBodyB64 := '';
LErr := '';
LEtag := '';
try
LHttp := System.Net.HttpClient.THTTPClient.Create;
try
@@ -1141,6 +1164,7 @@ begin
try
LResp := LHttp.Get(LUrl, LBodyStream);
LStatus := LResp.StatusCode;
LEtag := LResp.HeaderValue['ETag'];
if (LStatus >= 200) and (LStatus < 300) and (LBodyStream.Size > 0) then
begin
SetLength(LBytes, LBodyStream.Size);
@@ -1158,8 +1182,13 @@ begin
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
LReqStream := TBytesStream.Create(LBytes);
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;
LEtag := LResp.HeaderValue['ETag'];
finally
LReqStream.Free;
end;
@@ -1182,7 +1211,7 @@ begin
TThread.Queue(nil,
procedure
var
LEscReq, LEscPayload: string;
LEscReq, LEscPayload, LEscEtag: string;
begin
LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
// GET success path → ship body base64. Otherwise the field
@@ -1196,12 +1225,16 @@ begin
LEscPayload := StringReplace(LEscPayload, '"', '\"', [rfReplaceAll]);
LEscPayload := StringReplace(LEscPayload, #13, '', [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(
'if(window.Bridge&&Bridge.onWebdavResult)' +
'Bridge.onWebdavResult("' + LEscReq + '",' +
IntToStr(LStatus) + ',"' + LEscPayload + '")');
LogLine(Format('%s %s → %d (%d bytes payload)',
[LMethod, LUrl, LStatus, Length(LEscPayload)]));
IntToStr(LStatus) + ',"' + LEscPayload + '","' + LEscEtag + '")');
LogLine(Format('%s %s → %d (%d bytes payload, etag=%s)',
[LMethod, LUrl, LStatus, Length(LEscPayload), LEtag]));
end);
end).Start;
end