diff --git a/lib/assets/AI.md b/lib/assets/AI.md index 5d76e6be..9ed0cfb5 100644 --- a/lib/assets/AI.md +++ b/lib/assets/AI.md @@ -37,9 +37,18 @@ set or remove the managed `id` attribute and that accept only canonical positive - Each command in a batched frame is isolated. A failing DOM command is logged and later commands in the same frame still run. -The reconnect path probes `/jaws/.ping` and reloads only after the navigation is -old enough; pagehide closes the active socket and a bfcache pageshow reloads. -Keep reconnect constants and behavior covered by the JavaScript runtime tests. +The reconnect path observes a five-second grace period after a WebSocket +failure. It neither shows the connection-lost indicator nor probes +`/jaws/.ping` before that period elapses. If the old document remains active, +the first probe reloads an old enough page without showing the indicator when +the server is available; otherwise, the indicator appears after the probe +completes and elapsed-time backoff begins. A stalled probe can defer the +indicator until its ten-second timeout, roughly 15 seconds after failure. A +cross-document navigation that leaves the old document active beyond the grace +period can still show the indicator or be superseded by a reconnect-triggered +reload before pagehide invalidates reconnecting state. A bfcache pageshow +reloads. Keep reconnect constants and behavior covered by the JavaScript +runtime tests. ## Browser helpers diff --git a/lib/assets/jaws.js b/lib/assets/jaws.js index 001631b7..c87154b8 100644 --- a/lib/assets/jaws.js +++ b/lib/assets/jaws.js @@ -15,6 +15,8 @@ var jawsIdPrefix = 'Jid.'; var jawsDebug = false; const jawsJidRx = /^[1-9]\d*$/; const jawsMaxJid = '9223372036854775807'; +// Milliseconds after WebSocket failure before reconnect probing begins. +const jawsFailureGracePeriod = 5 * 1000; // Milliseconds; bounds reconnect probes that never receive a network result. const jawsReconnectTimeout = 10 * 1000; // Minimum navigation age for a reconnect-triggered page reload. @@ -345,27 +347,27 @@ function jawsSetValue(elem, str) { } function jawsLost() { + if (!(jaws instanceof Date)) { + return; + } let delay = 1; let innerHTML = 'Server connection lost'; - if (jaws instanceof Date) { - let elapsed = Math.floor((Date.now() - jaws) / 1000); - if (elapsed > 0) { - let units = ' second'; - delay = elapsed; + let elapsed = Math.floor((Date.now() - jaws) / 1000); + if (elapsed > 0) { + delay = Math.min(60, elapsed); + let units = ' second'; + if (elapsed >= 60) { + units = ' minute'; + elapsed = Math.floor(elapsed / 60); if (elapsed >= 60) { - delay = 60; - units = ' minute'; + units = ' hour'; elapsed = Math.floor(elapsed / 60); - if (elapsed >= 60) { - units = ' hour'; - elapsed = Math.floor(elapsed / 60); - } } - if (elapsed > 1) { - units += 's'; - } - innerHTML += ' ' + elapsed + units + ' ago'; } + if (elapsed > 1) { + units += 's'; + } + innerHTML += ' ' + elapsed + units + ' ago'; } innerHTML += '. Trying to reconnect.'; let elem = document.querySelector('[data-jaws-lost]'); @@ -380,6 +382,9 @@ function jawsLost() { } function jawsHandleReconnect(e) { + if (!(jaws instanceof Date)) { + return; + } // Reloading resets the navigation clock, so repeated failures enter the backoff. if (e.currentTarget.status === 204 && performance.now() >= jawsReconnectReloadMinPageAge) { window.location.reload(); @@ -389,6 +394,9 @@ function jawsHandleReconnect(e) { } function jawsReconnect() { + if (!(jaws instanceof Date)) { + return; + } const req = new XMLHttpRequest(); req.open("GET", window.location.protocol + "//" + window.location.host + "/jaws/.ping", true); req.timeout = jawsReconnectTimeout; @@ -399,7 +407,7 @@ function jawsReconnect() { function jawsFailed() { if (jaws instanceof WebSocket) { jaws = new Date(); - jawsReconnect(); + setTimeout(jawsReconnect, jawsFailureGracePeriod); } } @@ -408,8 +416,8 @@ function jawsUnloading() { jaws.removeEventListener('close', jawsFailed); jaws.removeEventListener('error', jawsFailed); jaws.close(); - jaws = null; } + jaws = null; } function jawsElement(html) { diff --git a/lib/assets/js_test.go b/lib/assets/js_test.go index f093c6cb..eb7e82a3 100644 --- a/lib/assets/js_test.go +++ b/lib/assets/js_test.go @@ -2025,6 +2025,7 @@ jawsElement = function(html) { func TestJawsJS_LostUsesDataAttributeHook(t *testing.T) { raw := runJawsJSSnippet(t, lostIndicatorStubs+` setTimeout = function() {}; +jaws = new Date(); jawsLost(); lostIndicatorElem = { innerHTML: "old" }; @@ -2057,6 +2058,181 @@ process.stdout.write(JSON.stringify({ } } +func TestJawsJS_FailureRecoveryWaitsFiveSeconds(t *testing.T) { + raw := runJawsJSSnippet(t, lostIndicatorStubs+` +let now = 100000; +Date.now = function() { return now; }; +global.performance = { now: function() { return 60000; } }; +let reloads = 0; +window.location.reload = function() { reloads++; }; +let reconnects = 0; +jawsReconnect = function() { reconnects++; }; +const timers = []; +let nextTimer = 1; +setTimeout = function(fn, ms) { + const timer = { id: nextTimer++, fn: fn, ms: ms }; + timers.push(timer); + return timer.id; +}; + +function FakeSocket() {} +WebSocket = FakeSocket; +jaws = new FakeSocket(); +jawsFailed(); +jawsFailed(); +jaws = new Date(now); +const afterFailure = { + reconnects: reconnects, + delays: timers.map(function(timer) { return timer.ms; }), + selectors: lostIndicatorSelectors.length, + prepended: lostIndicatorPrepended +}; + +now += 5000; +timers[0].fn(); +const afterGrace = { + reconnects: reconnects, + delays: timers.map(function(timer) { return timer.ms; }), + selectors: lostIndicatorSelectors.length, + prepended: lostIndicatorPrepended, + created: lostIndicatorCreated +}; + +// An available server reloads an old page without flashing the banner first. +jawsHandleReconnect({ currentTarget: { status: 204 } }); +const afterSuccessfulProbe = { + reconnects: reconnects, + delays: timers.map(function(timer) { return timer.ms; }), + selectors: lostIndicatorSelectors.length, + prepended: lostIndicatorPrepended, + reloads: reloads, + bodyScrollTop: document.body.scrollTop, + documentScrollTop: document.documentElement.scrollTop +}; + +// Exercise the failed-probe outcome independently of the stubbed reload above. +reloads = 0; +jawsHandleReconnect({ currentTarget: { status: 0 } }); +const afterFailedProbe = { + reconnects: reconnects, + delays: timers.map(function(timer) { return timer.ms; }), + selectors: lostIndicatorSelectors.length, + prepended: lostIndicatorPrepended, + created: lostIndicatorCreated, + reloads: reloads +}; + +process.stdout.write(JSON.stringify({ + afterFailure: afterFailure, + afterGrace: afterGrace, + afterSuccessfulProbe: afterSuccessfulProbe, + afterFailedProbe: afterFailedProbe +})); +`) + + type state struct { + Reconnects int `json:"reconnects"` + Delays []int `json:"delays"` + Selectors int `json:"selectors"` + Prepended int `json:"prepended"` + Created string `json:"created"` + Reloads int `json:"reloads"` + BodyScroll int `json:"bodyScrollTop"` + DocScroll int `json:"documentScrollTop"` + } + var got struct { + AfterFailure state `json:"afterFailure"` + AfterGrace state `json:"afterGrace"` + AfterSuccessfulProbe state `json:"afterSuccessfulProbe"` + AfterFailedProbe state `json:"afterFailedProbe"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &got); err != nil { + t.Fatalf("failed to parse snippet output %q: %v", raw, err) + } + if want := (state{Delays: []int{5000}}); !reflect.DeepEqual(got.AfterFailure, want) { + t.Fatalf("state after failure = %+v, want %+v", got.AfterFailure, want) + } + if want := (state{Reconnects: 1, Delays: []int{5000}}); !reflect.DeepEqual(got.AfterGrace, want) { + t.Fatalf("state after grace = %+v, want probe without lost indicator %+v", got.AfterGrace, want) + } + if want := (state{Reconnects: 1, Delays: []int{5000}, Reloads: 1, BodyScroll: 10, DocScroll: 10}); !reflect.DeepEqual(got.AfterSuccessfulProbe, want) { + t.Fatalf("state after successful probe = %+v, want reload without indicator %+v", got.AfterSuccessfulProbe, want) + } + if got.AfterFailedProbe.Reconnects != 1 || + !reflect.DeepEqual(got.AfterFailedProbe.Delays, []int{5000, 5000}) || + got.AfterFailedProbe.Selectors != 1 || got.AfterFailedProbe.Prepended != 1 || + got.AfterFailedProbe.Reloads != 0 || + !strings.Contains(got.AfterFailedProbe.Created, "Server connection lost 5 seconds ago") { + t.Fatalf("state after failed probe = %+v, want indicator and delayed retry", got.AfterFailedProbe) + } +} + +func TestJawsJS_PagehideInvalidatesDelayedFailureRecovery(t *testing.T) { + raw := runJawsJSSnippet(t, lostIndicatorStubs+` +global.performance = { now: function() { return 60000; } }; +let reloads = 0; +window.location.reload = function() { reloads++; }; +const timers = []; +let nextTimer = 1; +setTimeout = function(fn, ms) { + const timer = { id: nextTimer++, fn: fn, ms: ms }; + timers.push(timer); + return timer.id; +}; + +const requests = []; +function FakeXHR() { + this.status = 0; + this.listeners = {}; + requests.push(this); +} +FakeXHR.prototype.open = function() {}; +FakeXHR.prototype.addEventListener = function(name, fn) { + this.listeners[name] = fn; +}; +FakeXHR.prototype.send = function() {}; +XMLHttpRequest = FakeXHR; + +function FakeSocket() {} +WebSocket = FakeSocket; +jaws = new FakeSocket(); +jawsFailed(); +const delayedRecovery = timers[0].fn; + +// The committed navigation arrives after the transport failure. +jawsUnloading(); +delayedRecovery(); +jawsHandleReconnect({ currentTarget: { status: 0 } }); +jawsHandleReconnect({ currentTarget: { status: 204 } }); +jawsReconnect(); + +process.stdout.write(JSON.stringify({ + jawsIsNull: jaws === null, + requests: requests.length, + delays: timers.map(function(timer) { return timer.ms; }), + selectors: lostIndicatorSelectors.length, + prepended: lostIndicatorPrepended, + reloads: reloads +})); +`) + + var got struct { + JawsIsNull bool `json:"jawsIsNull"` + Requests int `json:"requests"` + Delays []int `json:"delays"` + Selectors int `json:"selectors"` + Prepended int `json:"prepended"` + Reloads int `json:"reloads"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &got); err != nil { + t.Fatalf("failed to parse snippet output %q: %v", raw, err) + } + if !got.JawsIsNull || got.Requests != 0 || !reflect.DeepEqual(got.Delays, []int{5000}) || + got.Selectors != 0 || got.Prepended != 0 || got.Reloads != 0 { + t.Fatalf("state after pagehide = %+v, want delayed work inert", got) + } +} + func TestJawsJS_ReconnectXHRResultsAreHandledOnce(t *testing.T) { raw := runJawsJSSnippet(t, lostIndicatorStubs+` global.performance = { now: function() { return 60000; } }; @@ -2118,6 +2294,7 @@ FakeXHR.prototype.finish = function(name, status) { XMLHttpRequest = FakeXHR; jawsFailed(); +scheduled[0].fn(); for (let i = 1; i < 5; i++) jawsReconnect(); const pending = { timers: timers, lost: lost, reloaded: reloaded }; @@ -2135,7 +2312,7 @@ results.push({ timers: timers, lost: lost, reloaded: reloaded }); // One-shot completion prevents stale terminal events from changing the result. requests.slice(0, 5).forEach(function(req) { req.finish("timeout", 0); }); -scheduled[0].fn(); +scheduled[1].fn(); process.stdout.write(JSON.stringify({ jawsIsDate: jaws instanceof Date, @@ -2184,8 +2361,8 @@ process.stdout.write(JSON.stringify({ if len(got.Attempts) != 6 || got.Sends != 6 { t.Fatalf("reconnect attempts = %d requests, %d sends; want 6 each", len(got.Attempts), got.Sends) } - if got.Pending != (counts{}) { - t.Fatalf("pending reconnect changed state: %+v", got.Pending) + if got.Pending != (counts{Timers: 1}) { + t.Fatalf("reconnect state before results = %+v, want only the elapsed grace timeout", got.Pending) } for i, attempt := range got.Attempts { if attempt.Method != "GET" || attempt.URL != "http://example.test/jaws/.ping" || !attempt.Async { @@ -2196,11 +2373,11 @@ process.stdout.write(JSON.stringify({ } } wants := []counts{ - {Timers: 1, Lost: 1}, - {Timers: 2, Lost: 2}, - {Timers: 3, Lost: 3}, - {Timers: 4, Lost: 4}, - {Timers: 4, Lost: 4, Reloaded: 1}, + {Timers: 2, Lost: 1}, + {Timers: 3, Lost: 2}, + {Timers: 4, Lost: 3}, + {Timers: 5, Lost: 4}, + {Timers: 5, Lost: 4, Reloaded: 1}, } if !reflect.DeepEqual(got.Results, wants) { t.Fatalf("reconnect results = %+v, want %+v", got.Results, wants) @@ -2217,6 +2394,7 @@ func TestJawsJS_ReconnectReloadUsesNavigationAge(t *testing.T) { raw := runJawsJSSnippet(t, lostIndicatorStubs+` let navigationAge = 59999; global.performance = { now: function() { return navigationAge; } }; +jaws = new Date(); let reloads = 0; window.location.reload = function() { reloads++; }; const delays = [];