diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 94b02ad..4072703 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -26,6 +26,7 @@ reverse. - "Put on the server" in the document menu turns a local plan into a shared one and puts the link in the address bar - A service endpoint `/api/v1/info` says which build is running — for deploys and monitoring - One `remote` command drives the server: deploy, log, service state, and a database backup that is read back before it is kept +- Fix: folding a node in a shared document could ask whose version should win — the editor was arguing with itself, because the change feed hands your own change back to you ## 2026-08-25 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index bef8bcc..3834d22 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6314,7 +6314,69 @@ gleichnamiges daneben. Erkannt an der id (`live:…`), nicht am laufenden Feed: Auch ein Server-Dokument, das gerade nicht das aktive ist, liegt bereits dort. -## D77 — Backend-Deploy: JDK im Home, systemd-User-Unit, Proxy in der `.htaccess` +**Nachtrag 9 — der Client stritt mit sich selbst: der Feed liefert die eigene +Änderung zurück (2026-08-26).** Gemeldet mit zwei Browsern am selben Dokument: +Einen Knoten zuklappen, und es kommt „Someone changed the same lines. Whose +version should win?" — mit der Vermutung, die Änderung des anderen zähle wieder +als eigene und die beiden spielten Ping-Pong. Die Vermutung war richtig, nur +braucht es den zweiten Browser dafür nicht. + +**Der Server schickt jedem die Änderungen ALLER, die eigenen eingeschlossen.** +Das ist keine Nachlässigkeit, sondern die Bauform des Feeds: Er beantwortet +„was ist seit Version N geschehen", und wer da mitgeschrieben hat, steht nicht +in der Frage. Wacht er im Moment des eigenen Sendens auf, kommt die eigene +Änderung also zurück, **bevor die Antwort darauf da ist**. Die Schattenkopie +steht dann noch auf dem Stand davor — der Client hält die eigene Änderung für +fremd, sieht sie sich mit dem eigenen (aus seiner Sicht ungesendeten) Text +überschneiden und stellt die Frage, die für genau diesen Fall gebaut ist +(Nachtrag 7: „der Konflikt entsteht beim Tippen"). Die Erkennung hatte recht; +falsch war nur, wen sie für den anderen hielt. + +**Das Falten macht es sichtbar, verursacht es aber nicht.** Umklappen schreibt +eine Faltmarke in den Text (D38-Nachtrag 2), also eine gewöhnliche +Textänderung — die Geste ist nur die kürzeste, die eine ganze Zeile ändert und +dabei keine Sekunde Tippen kostet. + +**Nachgemessen statt vermutet, und das war der eigentliche Aufwand.** Auf +localhost liegen die beiden Antworten **7 ms** auseinander, und die PATCH-Antwort +gewinnt — der Fehler tritt dort nie auf. Erst als die PATCH-Antwort im Client um +500 ms verzögert wurde (eine Reihenfolge, die übers Netz jederzeit auftritt), +stand er in der Spur: `PATCH an 200` · `FEED an 200` · `KONFLIKT-BANNER`, drei +Zeilen, fünf Millisekunden. Ohne das Erzwingen hätte die Prüfung „geht doch" +gemeldet. + +**Behoben, wo die Regel hingehört: `feedAction` in `live.js`.** Sie entscheidet +ohnehin, ob eine Feed-Antwort angewendet werden darf; jetzt lautet die dritte +Bedingung „nicht, solange ein eigenes Diff unterwegs ist". Verloren geht +dadurch nichts — was zwischen unserer Basis und der neuen Version liegt, steht +in `opsSinceBase` der Antwort, und der nächste Abruf setzt auf der dann +aktuellen Version auf. Dass die Regel im Modul steht, ist der Punkt: Sie hat +eine Zusicherung und eine Gegenprobe (Sperre entfernt ⇒ genau die zwei neuen +Tests fallen, sonst nichts). Genau diese Lehre steht seit D54-Nachtrag 3 im +Haus, und dieser Fehler wäre ihr Beispiel gewesen. + +**Die Sperre gehört an ZWEI Stellen, gegen zwei verschiedene Fälle.** In +`feedAction` für die Antwort, die eintrifft, während wir senden — und in der +Feed-Schleife dafür, dass währenddessen gar nicht erst gefragt wird. Ohne die +zweite fragte die Schleife sofort wieder, bekäme sofort dieselbe Antwort, +ließe sie wieder aus und drehte eine enge Runde über das Netz, bis das Senden +durch ist. + +**Dabei gefunden: `pushLive()` las seine Basis erst NACH dem Warten.** `const +alt = liveState.shadow` stand hinter dem `await` und nahm damit an, dass sich +dazwischen nichts ändert. Genau die Annahme brach der Feed: Er zog die +Schattenkopie schon nach, und die eigene Änderung wäre ein zweites Mal +daraufgerechnet worden — Textverderb ohne Fehlermeldung, hinter dem Banner +verborgen. Die Basis wird jetzt **vor** dem Warten festgehalten. Die Sperre +oben verhindert den Fall zwar auch, aber eine Rechnung, die nur wegen einer +Sperre anderswo stimmt, schreibt man nicht auf. + +**Nachgemessen** im Browser gegen ein lokales Backend, mit erzwungener +Reihenfolge: Falten in A erzeugt **kein** Banner mehr (Feed-Antwort ausgelassen, +danach mit der neuen Version neu aufgesetzt); eine echte fremde Änderung aus B +kommt weiterhin an; und der **echte** Konflikt wird weiterhin erkannt — A hält +ungesendeten Text auf Zeile 1, B ändert dieselbe Zeile, das Banner erscheint, +„Fremde übernehmen" setzt B's Fassung. 525 Tests. Das Frontend geht seit D16 per rsync auf die stabile Instanz. Das Backend braucht mehr als Dateien: eine Java-Laufzeit, einen dauerhaft laufenden Dienst und einen Weg von außen nach innen. Die Zielumgebung ist vermessen diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 9d0004c..f5538f2 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -129,6 +129,17 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der **Konflikt entsteht beim Tippen**, nicht erst beim Senden — der Server kennt den ungesendeten Text nicht. Fremde Änderungen werden bewusst **nicht** undo-fähig eingespielt. + **Der Feed liefert die EIGENE Änderung zurück** (D76-Nachtrag 9): Er + beantwortet „was ist seit Version N geschehen", und wer mitgeschrieben hat, + steht nicht in der Frage. Wacht er im Moment des eigenen Sendens auf, hielte + der Client sich selbst für den anderen und fragte, wessen Fassung gelten soll. + Deshalb `busy` an **zwei** Stellen: in `feedAction()` (Antwort auslassen) und + in der `runFeed`-Schleife (gar nicht erst fragen — sonst dreht sie eine enge + Runde übers Netz). `pushLive()` hält seine Basis **vor** dem `await` fest; + hinterher aus `liveState` gelesen nähme sie an, dass sich währenddessen + nichts ändert. Auf localhost liegen PATCH- und Feed-Antwort **7 ms** + auseinander und die PATCH-Antwort gewinnt — wer das prüfen will, muss die + PATCH-Antwort im Client verzögern, sonst meldet die Messung „geht doch". - Zustand wird im `localStorage` gehalten (Server-Dokumente ausgenommen): `werkbaum-lang` (Sprache), `werkbaum-docs` (JSON-Array der Dokumente `[{id,name,text}]`), `werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des diff --git a/frontend/src/app.js b/frontend/src/app.js index d9e9d45..ba1ec99 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -4581,6 +4581,13 @@ async function pushLive(){ liveState.busy = true; const seq = nextSeq(); + /* Die Basis, gegen die `ops` gerechnet sind — VOR dem Warten festgehalten. + Sie hinterher aus `liveState` zu lesen hieße anzunehmen, dass sich + dazwischen nichts ändert; genau diese Annahme ist gebrochen, sobald der + Feed dazwischenfunkt (D76-Nachtrag 9). Der Feed lässt sich jetzt aus, + solange wir senden — aber eine Rechnung, die nur wegen einer Sperre + anderswo stimmt, schreibt man nicht auf. */ + const basis = liveState.shadow; try{ const body = { baseVersion: liveState.version, @@ -4601,13 +4608,12 @@ async function pushLive(){ Rechnung hat der Server auch gemacht; wir kommen deshalb auf denselben Text, ohne ihn abholen zu müssen. */ const foreign = (result.opsSinceBase || []); - const alt = liveState.shadow; const meine = foreign.length ? live.rebaseOps(ops, foreign) : ops; if(meine == null){ await reloadLive(); return; } /* kann nicht sein - dann lieber neu */ liveState.shadow = live.applyOps( - foreign.length ? live.applyOps(alt, foreign) : alt, meine); + foreign.length ? live.applyOps(basis, foreign) : basis, meine); liveState.version = result.version; - if(foreign.length) applyForeign(alt, foreign, liveState.shadow, liveState.version); + if(foreign.length) applyForeign(basis, foreign, liveState.shadow, liveState.version); }catch(err){ handlePushError(err); }finally{ @@ -4651,7 +4657,13 @@ async function reloadLive(){ Abruf den Rückstand. */ async function runFeed(){ while(liveState){ - if(document.hidden || liveConflict){ await sleep(500); continue; } + /* `busy` gehört hierher UND in `feedAction`, und zwar gegen zwei + verschiedene Fälle: Hier wird gar nicht erst gefragt, solange ein + eigenes Diff unterwegs ist — sonst antwortete der Server sofort mit + unserer eigenen Änderung, die Antwort würde ausgelassen, und die + Schleife fragte in einer engen Runde erneut. Dort greift der Fall, dass + das Senden BEGINNT, während die Anfrage schon offen steht. */ + if(document.hidden || liveConflict || liveState.busy){ await sleep(500); continue; } const ctl = new AbortController(); liveState.feedAbort = ctl; try{ @@ -4681,7 +4693,7 @@ document.addEventListener('visibilitychange', () => { }); function applyFeed(feed){ - const what = live.feedAction(feed, liveState.version); + const what = live.feedAction(feed, liveState.version, liveState.busy); if(what === 'skip') return; if(what === 'replace'){ /* Volltext: die Basis ist verdichtet, ein Diff gibt es nicht mehr. */ diff --git a/frontend/src/live.js b/frontend/src/live.js index 494fc24..286a342 100644 --- a/frontend/src/live.js +++ b/frontend/src/live.js @@ -271,10 +271,23 @@ export function lineColToCaret(ls, line, col){ Client dieselben Operationen doppelt an — der Fall tritt ein, wenn Feed und 409-Antwort beide dasselbe fremde Diff liefern. + **Solange ein eigenes Diff unterwegs ist, sagt der Feed nichts Neues.** Der + Server schickt jedem die Änderungen ALLER, die eigenen eingeschlossen; wacht + der Feed also im Moment des eigenen Sendens auf, kommt die eigene Änderung + zurück, bevor die Antwort darauf da ist. Die Schattenkopie steht dann noch + auf dem Stand davor — der Client hält die eigene Änderung für fremd, sieht + sie sich mit dem eigenen (aus Sicht der Schattenkopie ungesendeten) Text + überschneiden und fragt, wessen Fassung gelten soll. Er streitet mit sich + selbst. Verloren geht durch das Auslassen nichts: Was zwischen unserer Basis + und der neuen Version liegt, steht in `opsSinceBase` der Antwort, und der + nächste Feed-Abruf setzt auf der dann aktuellen Version auf. Siehe D76- + Nachtrag 9. + 'apply' – Operationen anwenden 'replace' – Volltext übernehmen (Basis verdichtet oder Erstkontakt) - 'skip' – nichts tun (schon gesehen oder passt nicht auf unseren Stand) */ -export function feedAction(feed, shadowVersion){ + 'skip' – nichts tun (schon gesehen, passt nicht, oder wir senden gerade) */ +export function feedAction(feed, shadowVersion, pushInFlight){ + if(pushInFlight) return 'skip'; if(!feed || typeof feed.currentVersion !== 'number') return 'skip'; if(feed.currentVersion <= shadowVersion) return 'skip'; if(typeof feed.content === 'string' && feed.fromVersion == null) return 'replace'; diff --git a/frontend/tests/live.test.js b/frontend/tests/live.test.js index 4e7cb49..187d6a7 100644 --- a/frontend/tests/live.test.js +++ b/frontend/tests/live.test.js @@ -217,3 +217,23 @@ describe('Basis-Adresse des Backends', () => { expect(serverBase('javascript:alert(1)', null, null)).toBe(null); }); }) + +describe('Feed-Antwort anwenden oder nicht', () => { + it('waehrend ein eigenes Diff unterwegs ist, wird nichts angewendet', () => { + // Der Server schickt jedem die Aenderungen ALLER — die eigenen + // eingeschlossen. Wacht der Feed im Moment des eigenen Sendens auf, kaeme + // die eigene Aenderung zurueck, bevor die Antwort darauf da ist: Die + // Schattenkopie steht noch auf dem Stand davor, der Client haelt die + // eigene Aenderung fuer fremd und fragt, wessen Fassung gelten soll. + // Er streitet mit sich selbst (D76-Nachtrag 9). + const eigene = {fromVersion: 4, currentVersion: 5, + ops: [{op: 'replace', index: 1, count: 1, lines: [' - [ ] > Eins (M)']}]}; + expect(feedAction(eigene, 4, true)).toBe('skip'); + expect(feedAction(eigene, 4, false)).toBe('apply'); + }); + + it('auch ein Volltext wird waehrend des Sendens ausgelassen', () => { + // Sonst risse er den gerade getippten, noch nicht gesendeten Text weg. + expect(feedAction({fromVersion: null, currentVersion: 87, content: 'x'}, 4, true)).toBe('skip'); + }); +});