From 4f3539bb753452120fe8f5ef0aa298c3e5458bfb Mon Sep 17 00:00:00 2001 From: Kenearos Date: Tue, 7 Jul 2026 16:18:45 +0200 Subject: [PATCH] fix(sync): pending-Flag nur leeren wenn keine neuere Aenderung anliegt (Datenverlust-Race) _flush() loeschte KEY_PENDING unconditionally nach erfolgreichem PUT. Lief waehrend des PUT ein weiterer push() (neue Aenderung B), wurde deren pending- Status vom ersten Flush geloescht, obwohl B noch nicht auf dem Server ist. Schloss der Nutzer den Tab davor, sah der naechste boot() pending=false und uebernahm den Server-Stand -> B ging verloren. Fix: Generationszaehler _dirty. push() inkrementiert ihn, _flush() merkt sich den Stand vor dem await und loescht pending nur, wenn kein neuerer push() in der Zwischenzeit lief. Neue Tests in sync.test.js decken Race (pending bleibt gesetzt) und Normalfall (pending wird geleert) ab. --- sync.js | 4 +++- sync.test.js | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/sync.js b/sync.js index 9341be3..db59f37 100644 --- a/sync.js +++ b/sync.js @@ -66,12 +66,14 @@ const DataSync = { push() { if (this._applying) return; + this._dirty = (this._dirty || 0) + 1; localStorage.setItem(this.KEY_PENDING, '1'); clearTimeout(this._timer); this._timer = setTimeout(() => this._flush(), 500); }, async _flush() { + const gen = this._dirty; // ponytail: Generationszaehler gegen Verlust-Race bei ueberlappenden Flushes try { const res = await fetch('/api/state', { method: 'PUT', @@ -79,7 +81,7 @@ const DataSync = { body: JSON.stringify(this._local()), }); if (!res.ok) throw new Error('HTTP ' + res.status); - localStorage.removeItem(this.KEY_PENDING); // erst nach Erfolg + if (this._dirty === gen) localStorage.removeItem(this.KEY_PENDING); // nur wenn kein neuerer push() lief this.online = true; } catch (e) { console.error('Sync fehlgeschlagen, Daten bleiben lokal:', e); diff --git a/sync.test.js b/sync.test.js index 1d5d25b..9a100b5 100644 --- a/sync.test.js +++ b/sync.test.js @@ -24,3 +24,56 @@ test('Server hat Daten → adopt-server', () => { test('beide leer → adopt-server (nichts zu tun)', () => { assert.strictEqual(DataSync.decideSync(empty, empty, false), 'adopt-server'); }); + +// --- localStorage/fetch Stubs fuer _flush()-Race-Tests --- +function makeLocalStorage() { + const map = new Map(); + return { + getItem: (k) => (map.has(k) ? map.get(k) : null), + setItem: (k, v) => map.set(k, String(v)), + removeItem: (k) => map.delete(k), + }; +} + +test('einzelner push() + erfolgreicher flush → pending wird geleert', async () => { + global.localStorage = makeLocalStorage(); + global.fetch = async () => ({ ok: true, json: async () => ({}) }); + DataSync._dirty = 0; + DataSync._applying = false; + + DataSync.push(); + clearTimeout(DataSync._timer); // manueller Flush statt Debounce + assert.strictEqual(localStorage.getItem(DataSync.KEY_PENDING), '1'); + + await DataSync._flush(); + + assert.strictEqual(localStorage.getItem(DataSync.KEY_PENDING), null); + + delete global.localStorage; + delete global.fetch; +}); + +test('push() waehrend laufendem flush() → pending bleibt gesetzt (Verlust-Race verhindert)', async () => { + global.localStorage = makeLocalStorage(); + DataSync._dirty = 0; + DataSync._applying = false; + + let resolveFetch; + global.fetch = () => new Promise((resolve) => { resolveFetch = resolve; }); + + DataSync.push(); // dirty=1, pending='1' + clearTimeout(DataSync._timer); + + const flushPromise = DataSync._flush(); // gen erfasst als 1, fetch haengt in flight + + DataSync.push(); // B: dirty=2, pending erneut '1' waehrend flush#1 noch laeuft + clearTimeout(DataSync._timer); + + resolveFetch({ ok: true, json: async () => ({}) }); // flush#1 kommt zurueck + await flushPromise; + + assert.strictEqual(localStorage.getItem(DataSync.KEY_PENDING), '1'); + + delete global.localStorage; + delete global.fetch; +});