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; +});