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.
This commit is contained in:
Kenearos 2026-07-07 16:18:45 +02:00
parent 49a3803aa7
commit 4f3539bb75
2 changed files with 56 additions and 1 deletions

View file

@ -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);

View file

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