fix(live): der Client stritt mit sich selbst (D76-Nachtrag 9)
Gemeldet: zwei Browser am selben Dokument, einen Knoten zuklappen, und es kommt "Someone changed the same lines. Whose version should win?". Der Feed beantwortet "was ist seit Version N geschehen" - und wer da mitgeschrieben hat, steht nicht in der Frage. Er liefert also die EIGENE Aenderung zurueck, und wacht er im Moment des eigenen Sendens auf, kommt sie an, bevor die Antwort darauf da ist. Die Schattenkopie steht dann noch auf dem Stand davor: Der Client haelt die eigene Aenderung fuer fremd, sieht sie sich mit dem eigenen Text ueberschneiden und fragt. Die Erkennung hatte recht, falsch war nur, wen sie fuer den anderen hielt. Der zweite Browser ist dafuer gar nicht noetig; das Falten ist nur die kuerzeste Geste, die eine ganze Zeile aendert. Auf localhost liegen PATCH- und Feed-Antwort 7 ms auseinander und die PATCH-Antwort gewinnt - der Fehler tritt dort nie auf. Reproduziert mit im Client um 500 ms verzoegerter PATCH-Antwort (eine Reihenfolge, die uebers Netz jederzeit auftritt): PATCH an 200 / FEED an 200 / KONFLIKT-BANNER. Behoben in feedAction() (live.js) - dort steht ohnehin, wann eine Feed-Antwort angewendet werden darf; Gegenprobe: Sperre entfernt => genau die zwei neuen Zusicherungen fallen. Dieselbe Sperre gehoert in die runFeed-Schleife, sonst fragt sie sofort wieder und dreht eine enge Runde uebers Netz. Dabei gefunden: pushLive() las seine Basis erst NACH dem await und nahm damit an, dass sich dazwischen nichts aendert - der Feed brach genau die Annahme und haette die eigene Aenderung ein zweites Mal aufgerechnet. Jetzt vorher festgehalten. Nachgemessen: Falten erzeugt kein Banner mehr, fremde Aenderungen kommen weiterhin an, und der ECHTE Konflikt wird weiterhin erkannt (A haelt ungesendeten Text auf Zeile 1, B aendert dieselbe Zeile). 525 Tests.
This commit is contained in:
@@ -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
|
||||
|
||||
+17
-5
@@ -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. */
|
||||
|
||||
+15
-2
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user