Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions lib/assets/AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 25 additions & 17 deletions lib/assets/jaws.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]');
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -399,7 +407,7 @@ function jawsReconnect() {
function jawsFailed() {
if (jaws instanceof WebSocket) {
jaws = new Date();
jawsReconnect();
setTimeout(jawsReconnect, jawsFailureGracePeriod);
}
}

Expand All @@ -408,8 +416,8 @@ function jawsUnloading() {
jaws.removeEventListener('close', jawsFailed);
jaws.removeEventListener('error', jawsFailed);
jaws.close();
jaws = null;
}
jaws = null;
}

function jawsElement(html) {
Expand Down
194 changes: 186 additions & 8 deletions lib/assets/js_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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; } };
Expand Down Expand Up @@ -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 };

Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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 = [];
Expand Down
Loading