fix: recover deferred workspace reconnects
This commit is contained in:
parent
377fd889c5
commit
0fc152f623
|
|
@ -7331,6 +7331,9 @@
|
||||||
flushAuthored: flushAuthoredOutbox,
|
flushAuthored: flushAuthoredOutbox,
|
||||||
flushNotificationReads: flushNotificationReadOutbox,
|
flushNotificationReads: flushNotificationReadOutbox,
|
||||||
});
|
});
|
||||||
|
const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500);
|
||||||
|
window.addEventListener('online', reconnectAfterOnline);
|
||||||
|
workspaceLifecycle.replayOnline(reconnectAfterOnline);
|
||||||
async function setOfflineWorkEnabled(enabled) {
|
async function setOfflineWorkEnabled(enabled) {
|
||||||
keepWorkOffline.checked = enabled;
|
keepWorkOffline.checked = enabled;
|
||||||
offlineWorkStore.setEnabled(enabled);
|
offlineWorkStore.setEnabled(enabled);
|
||||||
|
|
@ -7388,9 +7391,6 @@
|
||||||
updateDeliveryReceiptControls();
|
updateDeliveryReceiptControls();
|
||||||
if (!navigator.onLine) await showOfflineStatus();
|
if (!navigator.onLine) await showOfflineStatus();
|
||||||
window.addEventListener('offline', showOfflineStatus);
|
window.addEventListener('offline', showOfflineStatus);
|
||||||
const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500);
|
|
||||||
window.addEventListener('online', reconnectAfterOnline);
|
|
||||||
workspaceLifecycle.replayOnline(reconnectAfterOnline);
|
|
||||||
|
|
||||||
qs('#refresh').addEventListener('click', load);
|
qs('#refresh').addEventListener('click', load);
|
||||||
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
|
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ function createReconnectOutboxes({
|
||||||
flushAuthored,
|
flushAuthored,
|
||||||
flushNotificationReads,
|
flushNotificationReads,
|
||||||
}) {
|
}) {
|
||||||
return async function reconnectOutboxes() {
|
let activeReconnect = null;
|
||||||
|
let followUpRequested = false;
|
||||||
|
|
||||||
|
async function reconnectOnce() {
|
||||||
const snapshot = await refresh();
|
const snapshot = await refresh();
|
||||||
const freshness = snapshot?.freshness?.sections?.context;
|
const freshness = snapshot?.freshness?.sections?.context;
|
||||||
const identityFresh = snapshot?.context && !snapshot.context.error &&
|
const identityFresh = snapshot?.context && !snapshot.context.error &&
|
||||||
|
|
@ -19,6 +22,22 @@ function createReconnectOutboxes({
|
||||||
flushNotificationReads(),
|
flushNotificationReads(),
|
||||||
]);
|
]);
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return function reconnectOutboxes() {
|
||||||
|
if (activeReconnect) {
|
||||||
|
followUpRequested = true;
|
||||||
|
return activeReconnect;
|
||||||
|
}
|
||||||
|
activeReconnect = (async () => {
|
||||||
|
let result = false;
|
||||||
|
do {
|
||||||
|
followUpRequested = false;
|
||||||
|
result = await reconnectOnce();
|
||||||
|
} while (followUpRequested);
|
||||||
|
return result;
|
||||||
|
})().finally(() => { activeReconnect = null; });
|
||||||
|
return activeReconnect;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,14 @@ async function loadWorkspace({ document, window = null, createLoader = createFea
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window?.removeEventListener('online', captureOnline);
|
window?.removeEventListener('online', captureOnline);
|
||||||
|
if (window?.location?.reload) {
|
||||||
|
if (cameOnline) window.location.reload();
|
||||||
|
else window.addEventListener('online', () => window.location.reload(), { once: true });
|
||||||
|
}
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent =
|
status.textContent = window
|
||||||
'Workspace could not load. Check your connection, then reload to retry.';
|
? 'Workspace could not load. Reconnect to retry automatically, or reload now.'
|
||||||
|
: 'Workspace could not load. Check your connection, then reload to retry.';
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,19 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||||
if fake.created_issues:
|
if fake.created_issues:
|
||||||
break
|
break
|
||||||
page.wait_for_timeout(250)
|
page.wait_for_timeout(250)
|
||||||
assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}]
|
assert fake.created_issues == [
|
||||||
|
{"title": TITLE, "body": BODY, "assignee": "timmy"}
|
||||||
|
], (
|
||||||
|
f"url={page.url} ready={page.evaluate('document.readyState')} "
|
||||||
|
f"status={page.locator('#my-work-action-status').inner_text()!r} "
|
||||||
|
f"errors={browser_errors[-5:]!r} responses={failed_responses[-10:]!r} "
|
||||||
|
f"requests={fake.requests[-20:]!r}"
|
||||||
|
)
|
||||||
|
# Deferred workspace startup can report its already-issued offline request
|
||||||
|
# after the pre-reconnect clear; retain every non-offline browser error.
|
||||||
|
browser_errors[:] = [
|
||||||
|
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
|
||||||
|
]
|
||||||
|
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
durable_completion = indexed_issue_records(page)
|
durable_completion = indexed_issue_records(page)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,36 @@ reconnect().then(result => process.stdout.write(JSON.stringify({{result, calls}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_reconnect_requests_queue_one_follow_up_flush():
|
||||||
|
result = run_node(
|
||||||
|
f"""
|
||||||
|
const createReconnectOutboxes = require({json.dumps(str(RECONNECT))});
|
||||||
|
(async () => {{
|
||||||
|
let releaseFirst; let issueCalls=0; let active=0; let maxActive=0;
|
||||||
|
const reconnect = createReconnectOutboxes({{
|
||||||
|
refresh: async () => ({{context:{{user:{{login:'timmy'}}}}}}),
|
||||||
|
restoreIdentity: () => {{}},
|
||||||
|
flushIssue: async () => {{
|
||||||
|
issueCalls++; active++; maxActive=Math.max(maxActive,active);
|
||||||
|
if (issueCalls === 1) await new Promise(resolve => {{ releaseFirst=resolve; }});
|
||||||
|
active--;
|
||||||
|
}},
|
||||||
|
flushAuthored: async () => {{}},
|
||||||
|
flushNotificationReads: async () => {{}},
|
||||||
|
}});
|
||||||
|
const first=reconnect();
|
||||||
|
while (!releaseFirst) await new Promise(resolve=>setTimeout(resolve,0));
|
||||||
|
const second=reconnect();
|
||||||
|
releaseFirst();
|
||||||
|
await Promise.all([first,second]);
|
||||||
|
process.stdout.write(JSON.stringify({{issueCalls,maxActive}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"issueCalls": 2, "maxActive": 1}
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_uses_reconnect_flush_after_live_identity_refresh():
|
def test_dashboard_uses_reconnect_flush_after_live_identity_refresh():
|
||||||
index = (ROOT / "frontend" / "index.html").read_text()
|
index = (ROOT / "frontend" / "index.html").read_text()
|
||||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||||
|
|
@ -50,3 +80,12 @@ def test_dashboard_uses_reconnect_flush_after_live_identity_refresh():
|
||||||
assert "restoreIdentity: login => { activeFlushLogin = login; confirmedOwnerLogin = login; }" in dashboard
|
assert "restoreIdentity: login => { activeFlushLogin = login; confirmedOwnerLogin = login; }" in dashboard
|
||||||
assert "window.addEventListener('online', reconnectAfterOnline);" in dashboard
|
assert "window.addEventListener('online', reconnectAfterOnline);" in dashboard
|
||||||
assert "setTimeout(reconnectOutboxes, 500)" in dashboard
|
assert "setTimeout(reconnectOutboxes, 500)" in dashboard
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_registers_reconnect_before_offline_startup_can_wait():
|
||||||
|
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||||
|
|
||||||
|
listener = dashboard.index("window.addEventListener('online', reconnectAfterOnline);")
|
||||||
|
offline_startup = dashboard.index("await updateOfflineWorkControls();")
|
||||||
|
|
||||||
|
assert listener < offline_startup
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,52 @@ console.log(JSON.stringify({message,status:status.textContent}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_bootstrap_reloads_after_a_failed_offline_load_reconnects():
|
||||||
|
result = run_bootstrap("""
|
||||||
|
const status={textContent:''}; const listeners={}; let reloads=0;
|
||||||
|
const document={querySelector(selector) {
|
||||||
|
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||||
|
if (selector === '#my-work-action-status') return status;
|
||||||
|
return null;
|
||||||
|
}};
|
||||||
|
const window={
|
||||||
|
location:{reload(){reloads++;}},
|
||||||
|
addEventListener(name,callback) { listeners[name]=callback; },
|
||||||
|
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||||
|
};
|
||||||
|
const createLoader=()=>({load:async()=>{throw new Error('offline');}});
|
||||||
|
try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
|
||||||
|
const waiting=Boolean(listeners.online);
|
||||||
|
if (listeners.online) listeners.online();
|
||||||
|
console.log(JSON.stringify({waiting,reloads,status:status.textContent}));
|
||||||
|
""")
|
||||||
|
assert result == {
|
||||||
|
"waiting": True,
|
||||||
|
"reloads": 1,
|
||||||
|
"status": "Workspace could not load. Reconnect to retry automatically, or reload now.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_bootstrap_reloads_when_reconnect_arrives_before_load_failure():
|
||||||
|
result = run_bootstrap("""
|
||||||
|
const status={textContent:''}; const listeners={}; let reloads=0;
|
||||||
|
const document={querySelector(selector) {
|
||||||
|
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||||
|
if (selector === '#my-work-action-status') return status;
|
||||||
|
return null;
|
||||||
|
}};
|
||||||
|
const window={
|
||||||
|
location:{reload(){reloads++;}},
|
||||||
|
addEventListener(name,callback) { listeners[name]=callback; },
|
||||||
|
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||||
|
};
|
||||||
|
const createLoader=()=>({load:async()=>{listeners.online(); throw new Error('offline request');}});
|
||||||
|
try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
|
||||||
|
console.log(JSON.stringify({reloads,waiting:Boolean(listeners.online)}));
|
||||||
|
""")
|
||||||
|
assert result == {"reloads": 1, "waiting": False}
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers():
|
def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers():
|
||||||
result = run_bootstrap("""
|
result = run_bootstrap("""
|
||||||
const status={textContent:''}; const listeners={};
|
const status={textContent:''}; const listeners={};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user