fix(webui): sp1 second review round, a read or file save in flight at a switch keeps nothing (#1873, lane sp1)
The second review found two paths the first fix round left open.
- The project-file save captures orgEpoch before its POST. When a switch
happened meanwhile, it neither paints nor announces the old file's
outcome on the new organization's page. An opaqueredirect still signs out.
- A live read (reader, get.api), the palette's inbox and tasks reads and
the board poll keep their answer in `last` only while the organization
they were sent for is still open. A later cached render on the new
organization no longer draws the old one's rows.
single-pane test 11 holds acme's file save across a switch to beta and
asserts nothing is announced: red on the previous bus.js ("Saved as commit
cccccccccccc."), green now. The kept-read guard has no browser test.
This commit was merged in pull request #1965.
This commit is contained in:
1 parent
bb2f79f3d9
commit
c7aecbffa9
2 files changed
+41
-6
No files matched your search
@@ -75,13 +75,17 @@
|
|||||||
window.MosaicIcons.succeeded();
|
window.MosaicIcons.succeeded();
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
// #1873 SP1: bumped by each in-place organization switch. A write, or a read that keeps its answer,
|
||||||
|
// captures it before its await and drops the answer when it changed, so nothing the old organization
|
||||||
|
// answered lands on the new page or in its kept answers.
|
||||||
|
let orgEpoch = 0;
|
||||||
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
|
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
|
||||||
function reader(live) {
|
function reader(live) {
|
||||||
const used = [];
|
const used = [];
|
||||||
const get = async (verb, subject) => {
|
const get = async (verb, subject) => {
|
||||||
const k = subject === undefined ? verb : `${verb}:${subject}`;
|
const k = subject === undefined ? verb : `${verb}:${subject}`;
|
||||||
let r;
|
let r;
|
||||||
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
|
if (live) { const epoch = orgEpoch; r = await fetchRead(verb, subject); if (epoch === orgEpoch) last.set(k, r); }
|
||||||
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
||||||
used.push(r.at);
|
used.push(r.at);
|
||||||
if (verb === 'messages') { messageCount = r.unread; return r; }
|
if (verb === 'messages') { messageCount = r.unread; return r; }
|
||||||
@@ -90,7 +94,7 @@
|
|||||||
get.api = async path => {
|
get.api = async path => {
|
||||||
const k = `api:${path}`;
|
const k = `api:${path}`;
|
||||||
let r;
|
let r;
|
||||||
if (live) { r = await fetchApi(path); last.set(k, r); }
|
if (live) { const epoch = orgEpoch; r = await fetchApi(path); if (epoch === orgEpoch) last.set(k, r); }
|
||||||
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
||||||
used.push(r.at);
|
used.push(r.at);
|
||||||
return r;
|
return r;
|
||||||
@@ -345,9 +349,6 @@
|
|||||||
let configRead = Promise.resolve();
|
let configRead = Promise.resolve();
|
||||||
// #1873 SP1: the config read an organization switch started, until the next render has waited for it.
|
// #1873 SP1: the config read an organization switch started, until the next render has waited for it.
|
||||||
let switchRead = null;
|
let switchRead = null;
|
||||||
// #1873 SP1: bumped by each in-place organization switch. A write captures it before its await and
|
|
||||||
// drops its answer when it changed, so nothing the old organization answered lands on the new page.
|
|
||||||
let orgEpoch = 0;
|
|
||||||
let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
|
let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
|
||||||
const REPLIES = 50; // the broker's cap
|
const REPLIES = 50; // the broker's cap
|
||||||
function repliesHtml() {
|
function repliesHtml() {
|
||||||
@@ -1539,6 +1540,7 @@
|
|||||||
if (!s || !saveable(s)) return;
|
if (!s || !saveable(s)) return;
|
||||||
const sent = { project: s.project, path: s.path, base: s.commit, content: s.text }, unknown = 'Outcome unknown: the save may have gone through. Copy your text, then reload the file to check before saving again. Draft kept.';
|
const sent = { project: s.project, path: s.path, base: s.commit, content: s.text }, unknown = 'Outcome unknown: the save may have gone through. Copy your text, then reload the file to check before saving again. Draft kept.';
|
||||||
s.saving = true; s.status = '';
|
s.saving = true; s.status = '';
|
||||||
|
const epoch = orgEpoch;
|
||||||
let res = null, body = null;
|
let res = null, body = null;
|
||||||
try {
|
try {
|
||||||
paintDoc();
|
paintDoc();
|
||||||
@@ -1550,6 +1552,9 @@
|
|||||||
} else if (body?.sent === false) s.status = `${typeof body.message === 'string' && body.message ? body.message : 'Not saved.'} Draft kept.`;
|
} else if (body?.sent === false) s.status = `${typeof body.message === 'string' && body.message ? body.message : 'Not saved.'} Draft kept.`;
|
||||||
else s.status = unknown;
|
else s.status = unknown;
|
||||||
} finally { s.saving = false; } // a throw above must not leave Save off and Reload refused
|
} finally { s.saving = false; } // a throw above must not leave Save off and Reload refused
|
||||||
|
// #1873 SP1: a switch while this was in flight cleared the drafts; the old file's outcome is not
|
||||||
|
// painted or announced on the new organization's page.
|
||||||
|
if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; }
|
||||||
paintDoc();
|
paintDoc();
|
||||||
announce(s.status);
|
announce(s.status);
|
||||||
if (res?.type === 'opaqueredirect') signOut();
|
if (res?.type === 'opaqueredirect') signOut();
|
||||||
@@ -1756,12 +1761,15 @@
|
|||||||
// The board page keeps its own title; the Inbox count still follows the bus.
|
// The board page keeps its own title; the Inbox count still follows the bus.
|
||||||
if (shown !== null) { shown = null; unmountEditor(); $('bus-view').innerHTML = ''; document.title = 'Dashboard · Mosaic Console'; if (navigated) $('main').focus(); }
|
if (shown !== null) { shown = null; unmountEditor(); $('bus-view').innerHTML = ''; document.title = 'Dashboard · Mosaic Console'; if (navigated) $('main').focus(); }
|
||||||
// The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
|
// The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
|
||||||
|
const epoch = orgEpoch;
|
||||||
const [inbox, agents, messages, tasks, queue, business, projects] = await Promise.allSettled([
|
const [inbox, agents, messages, tasks, queue, business, projects] = await Promise.allSettled([
|
||||||
fetchRead('inbox'), fetchRead('agents'), fetchRead('messages'), fetchRead('tasks'), fetchApi('queue'), fetchApi('business'),
|
fetchRead('inbox'), fetchRead('agents'), fetchRead('messages'), fetchRead('tasks'), fetchApi('queue'), fetchApi('business'),
|
||||||
fetch('/api/chat/projects', { cache: 'no-store', redirect: 'manual' }).then(async r => { if (r.type === 'opaqueredirect') { signOut(); throw signedOut(); } const body = await r.json(); if (signInRequired(r, body)) { signOutSso(); throw signedOut(); } if (!r.ok || !Array.isArray(body.rows)) throw new ReadError(body?.error, body?.message, r.status); return body; })
|
fetch('/api/chat/projects', { cache: 'no-store', redirect: 'manual' }).then(async r => { if (r.type === 'opaqueredirect') { signOut(); throw signedOut(); } const body = await r.json(); if (signInRequired(r, body)) { signOutSso(); throw signedOut(); } if (!r.ok || !Array.isArray(body.rows)) throw new ReadError(body?.error, body?.message, r.status); return body; })
|
||||||
]);
|
]);
|
||||||
const failed = [inbox, agents, messages, tasks].filter(s => s.status === 'rejected').map(s => s.reason);
|
const failed = [inbox, agents, messages, tasks].filter(s => s.status === 'rejected').map(s => s.reason);
|
||||||
if (failed.some(err => err.code === 'signed-out')) return;
|
if (failed.some(err => err.code === 'signed-out')) return;
|
||||||
|
// #1873 SP1: an answer read for an organization that is no longer open is not kept.
|
||||||
|
if (epoch !== orgEpoch) return;
|
||||||
if (inbox.status === 'fulfilled') last.set('inbox', inbox.value);
|
if (inbox.status === 'fulfilled') last.set('inbox', inbox.value);
|
||||||
if (agents.status === 'fulfilled') last.set('agents', agents.value);
|
if (agents.status === 'fulfilled') last.set('agents', agents.value);
|
||||||
if (messages.status === 'fulfilled') last.set('messages', messages.value);
|
if (messages.status === 'fulfilled') last.set('messages', messages.value);
|
||||||
@@ -1902,7 +1910,9 @@
|
|||||||
if (d.open) return;
|
if (d.open) return;
|
||||||
pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = '';
|
pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = '';
|
||||||
pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus();
|
pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus();
|
||||||
Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(rs => {
|
const epoch = orgEpoch;
|
||||||
|
Promise.allSettled(['inbox', 'tasks'].map(async v => { const r = await fetchRead(v); if (epoch === orgEpoch) last.set(v, r); })).then(rs => {
|
||||||
|
if (epoch !== orgEpoch) return;
|
||||||
if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); }
|
if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); }
|
||||||
if (d.open) { pkItems(); pkFill(); }
|
if (d.open) { pkItems(); pkFill(); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -285,3 +285,28 @@ test('a restart started on one organization finishes on that organization after
|
|||||||
// beta's page draws nothing of acme's chain.
|
// beta's page draws nothing of acme's chain.
|
||||||
assert.equal(await b.evaluate('!!document.querySelector("[data-agent-restart-status=coder]")'), false);
|
assert.equal(await b.evaluate('!!document.querySelector("[data-agent-restart-status=coder]")'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a file save still in flight at a switch: its outcome is not announced on the next organization', async t => {
|
||||||
|
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
|
||||||
|
// acme's file reads are stubbed; its save POST is held until the test releases it, then answers with a commit.
|
||||||
|
const init = `window.__posts = []; const original = window.fetch;
|
||||||
|
window.fetch = async (url, options = {}) => { const s = String(url); let x;
|
||||||
|
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true, edit: true }; return ${json}; }
|
||||||
|
if (s.startsWith('/api/orgs/acme/project-file?')) { x = { path: 'docs/a.md', commit: 'a'.repeat(40), content: '# A\\n', at: new Date().toISOString() }; return ${json}; }
|
||||||
|
if (options.method === 'POST' && s === '/api/orgs/acme/project-file') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
|
||||||
|
x = { path: 'docs/a.md', commit: 'c'.repeat(40) }; return ${json}; }
|
||||||
|
return original(url, options); };`;
|
||||||
|
const { b, wait } = await setup(t, { path: `/#/orgs/acme/docs/site/${encodeURIComponent('docs/a.md')}`, init });
|
||||||
|
await wait('document.querySelector("#doc-form")');
|
||||||
|
if (!await b.evaluate('!!document.querySelector("#doc-text")')) await b.evaluate('document.querySelector("#doc-mode-source").click(), true');
|
||||||
|
await wait('document.querySelector("#doc-text")?.value === "# A\\n"');
|
||||||
|
await b.evaluate('(() => { const a = document.querySelector("#doc-text"); a.value = "# A\\n\\nmore\\n"; a.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("#doc-form").requestSubmit(); })()');
|
||||||
|
await wait('typeof window.__release === "function"');
|
||||||
|
assert.equal(await b.evaluate('window.__posts[0]?.base'), 'a'.repeat(40));
|
||||||
|
await b.evaluate('location.hash = "#/orgs/beta/organization"');
|
||||||
|
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
|
||||||
|
await b.evaluate('window.__release()');
|
||||||
|
await new Promise(r => setTimeout(r, 700));
|
||||||
|
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/organization');
|
||||||
|
assert.doesNotMatch(await b.evaluate('document.getElementById("announce")?.textContent ?? ""'), /Saved as commit|Draft kept/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user