Test activation conflicts and draft-preserving application reload
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
This commit is contained in:
parent
e34ec3fafe
commit
f273a190f0
13 changed files with 1385 additions and 0 deletions
|
|
@ -48,3 +48,30 @@ can already have side effects before that check. No backend writes, authenticati
|
|||
API migrations, atomic/concurrent map updates, accessibility audit or safe reload of
|
||||
unsaved work is modeled. The startup outage reflects this fixture's chosen dependency,
|
||||
not an assertion that every independently released frontend must fail that way.
|
||||
|
||||
## Activation conflicts and safe update reload
|
||||
|
||||
[Review](../../reviews/ActivationConflictsAndReload.md) and
|
||||
[receipt](conflicts-reload-results.json): three deterministic stale-writer HTTP cases
|
||||
and nine reload scenarios in each browser, 21/21 expected observations.
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_BROWSERS_PATH=/tmp/fep-playwright-browsers FEP_CHROMIUM_PATH=/path/to/chrome node conflicts-reload.mjs
|
||||
```
|
||||
|
||||
Uses the same pinned dependencies/browsers as the original release experiment.
|
||||
Default output `/tmp/fep-conflicts-reload.json`; `FEP_RESULT_PATH` overrides it.
|
||||
Incomplete browser progress goes to `/tmp/fep-conflicts-reload-checkpoint.json`.
|
||||
The earlier host, runner and receipts remain unchanged. The new reload-host.js is
|
||||
an authored policy using sessionStorage, explicit user choices and schema checks.
|
||||
|
||||
C01 and R01 deliberately reproduce data loss from unsafe choices. C02 refetches and
|
||||
reapplies a disjoint intent; C03 rejects a conflicting stale rollback. R02–R09 exercise
|
||||
update deferral, preservation, storage refusal, incompatible schema, explicit discard,
|
||||
unknown-outcome gating, edits during preflight and activation after preflight.
|
||||
|
||||
Activation comparison/replacement is atomic only within this single-process model;
|
||||
its revision header and 409 response are not a full HTTP precondition implementation.
|
||||
Storage refusal is injected. Preservation assumes retention is authorized and protects
|
||||
only the tested same-tab reload. Native reload/tab close, crashes, real backend
|
||||
outcome recovery, multiple tabs and automatic schema migration remain out of scope.
|
||||
|
|
|
|||
1099
research/experiments/feature-release/conflicts-reload-results.json
Normal file
1099
research/experiments/feature-release/conflicts-reload-results.json
Normal file
File diff suppressed because it is too large
Load diff
63
research/experiments/feature-release/conflicts-reload.mjs
Normal file
63
research/experiments/feature-release/conflicts-reload.mjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import {chromium,firefox} from 'playwright';
|
||||
import {createServer} from 'node:http';
|
||||
import {once} from 'node:events';
|
||||
import {readFile,writeFile} from 'node:fs/promises';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createHash} from 'node:crypto';
|
||||
import assert from 'node:assert/strict';
|
||||
const dir=fileURLToPath(new URL('.',import.meta.url));
|
||||
const names=['reload-host.js','reports-r1.js','reports-r2.js'];
|
||||
const assets=new Map(await Promise.all(names.map(async n=>['/'+n,await readFile(dir+n)])));
|
||||
let state,trace,held,holdNext;
|
||||
function reset(){state={revision:1,reports:'/reports-r1.js',account:'A1',draftSchema:1};trace=[];held=null;holdNext=false;}
|
||||
reset();
|
||||
const copy=()=>({...state});
|
||||
const json=(res,code,value)=>{res.writeHead(code,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(value));};
|
||||
const server=createServer(async(req,res)=>{
|
||||
const path=new URL(req.url,'http://local').pathname;res.setHeader('Cache-Control','no-store');
|
||||
if(path==='/activation'&&req.method==='GET'){
|
||||
const snapshot=copy();trace.push({event:'read',snapshot});
|
||||
if(holdNext){holdNext=false;held=()=>json(res,200,snapshot);}else json(res,200,snapshot);
|
||||
}else if((path==='/activation'||path==='/unsafe')&&req.method==='PUT'){
|
||||
let body='';for await(const chunk of req)body+=chunk;const next=JSON.parse(body);
|
||||
// Single-process model: no await between revision comparison and replacement.
|
||||
if(path==='/activation'&&req.headers['if-match']!==String(state.revision)){
|
||||
trace.push({event:'conflict',expected:req.headers['if-match'],actual:state.revision});json(res,409,copy());return;
|
||||
}
|
||||
state={...next,revision:state.revision+1};trace.push({event:'commit',path,state:copy()});json(res,200,copy());
|
||||
}else if(path==='/'){
|
||||
const snapshot=copy();trace.push({event:'document',snapshot});res.setHeader('Content-Type','text/html');res.end(`<!doctype html><html lang="en"><title>Safe reload probe</title><body><p id="version"></p><label>Draft<textarea id="draft"></textarea></label><p id="status" role="status"></p><button id="stay">Keep working</button><button id="preserve">Preserve and reload</button><button id="discard">Discard draft and reload</button><script id="startup" type="application/json">${JSON.stringify(snapshot)}</script><script type="importmap">${JSON.stringify({imports:{reports:snapshot.reports}})}</script><script>window.observed={evaluations:[]};</script><script type="module" src="/reload-host.js"></script></body></html>`);
|
||||
}else if(assets.has(path)){res.setHeader('Content-Type','application/javascript');res.end(assets.get(path));}
|
||||
else{res.writeHead(404);res.end('missing');}
|
||||
});
|
||||
server.listen(0,'127.0.0.1');await once(server,'listening');const url=`http://127.0.0.1:${server.address().port}`;
|
||||
const results=[];
|
||||
async function put(value,rev,unsafe=false){const r=await fetch(url+(unsafe?'/unsafe':'/activation'),{method:'PUT',headers:{'Content-Type':'application/json','If-Match':String(rev)},body:JSON.stringify(value)});return {status:r.status,body:await r.json()};}
|
||||
const get=()=>fetch(url+'/activation').then(r=>r.json());
|
||||
async function control(id,run){reset();const r={id,kind:'HTTP-control-plane',status:'pass'};try{r.observation=await run();}catch(e){r.status='fail';r.error=e.message;}r.trace=[...trace];results.push(r);}
|
||||
try{
|
||||
await control('C01-blind-update-loses-neighbor',async()=>{const [a,b]=await Promise.all([get(),get()]);await put({...a,reports:'/reports-r2.js'},a.revision,true);await put({...b,account:'A2'},b.revision,true);assert.equal(state.reports,'/reports-r1.js');assert.equal(state.account,'A2');return {lostReportsUpdate:true};});
|
||||
await control('C02-stale-disjoint-update-refetch',async()=>{const [a,b]=await Promise.all([get(),get()]);assert.equal((await put({...a,reports:'/reports-r2.js'},a.revision)).status,200);assert.equal((await put({...b,account:'A2'},b.revision)).status,409);const current=await get();assert.equal((await put({...current,account:'A2'},current.revision)).status,200);assert.equal(state.reports,'/reports-r2.js');assert.equal(state.account,'A2');return {bothUpdatesRetained:true,explicitRefetchAndReapply:true};});
|
||||
await control('C03-same-feature-needs-decision',async()=>{const [a,b]=await Promise.all([get(),get()]);await put({...a,reports:'/reports-r2.js'},a.revision);const conflict=await put({...b,reports:'/reports-r1.js'},b.revision);assert.equal(conflict.status,409);assert.equal(state.reports,'/reports-r2.js');return {staleRollbackRejected:true,noAutomaticOverwrite:true};});
|
||||
for(const[engine,launcher]of [['chromium',chromium],['firefox',firefox]]){
|
||||
const browser=await launcher.launch({headless:true,...(engine==='chromium'&&process.env.FEP_CHROMIUM_PATH?{executablePath:process.env.FEP_CHROMIUM_PATH}:{})});
|
||||
try{
|
||||
async function scenario(id,run){reset();const p=await browser.newPage();p.setDefaultTimeout(2500);const errors=[];p.on('pageerror',e=>errors.push(e.message));const r={id,engine,browser:browser.version(),status:'pass'};
|
||||
try{await p.goto(url);await p.waitForFunction(()=>window.ready);await p.locator('#draft').fill('unfinished proposal');await put({...state,reports:'/reports-r2.js'},state.revision);r.observation=await run(p);assert.deepEqual(errors,[]);}catch(e){r.status='fail';r.error=e.message;}
|
||||
r.snapshot=await p.evaluate(()=>({draft:document.getElementById('draft').value,status:document.getElementById('status').textContent,version:document.getElementById('version').textContent,readOnly:document.getElementById('draft').readOnly,saved:sessionStorage.getItem('fep-review-draft')}));r.errors=errors;r.trace=[...trace];results.push(r);if(held){held();held=null;}await p.close();await writeFile('/tmp/fep-conflicts-reload-checkpoint.json',JSON.stringify({status:'incomplete',results},null,2)+'\n');}
|
||||
async function reload(p,button){await Promise.all([p.waitForEvent('load'),p.locator(button).click()]);await p.waitForFunction(()=>window.ready);}
|
||||
const draft= p=>p.locator('#draft').inputValue();
|
||||
await scenario('R01-unguarded-reload-loses-draft',async p=>{await p.reload();await p.waitForFunction(()=>window.ready);assert.equal(await draft(p),'');assert.equal(await p.locator('#version').textContent(),'R2');return {draftLost:true,negativeControl:true};});
|
||||
await scenario('R02-defer-keeps-work',async p=>{await p.locator('#stay').click();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {declinedReload:true};});
|
||||
await scenario('R03-preserve-and-restore',async p=>{await reload(p,'#preserve');assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R2');const s=await p.evaluate(()=>JSON.parse(sessionStorage.getItem('fep-review-draft')));assert.equal(s.base,'record-v1');return {restored:true,basePreserved:true};});
|
||||
await scenario('R04-storage-failure-blocks',async p=>{await p.evaluate(()=>window.failStorage=true);await p.locator('#preserve').click();await p.locator('#status').filter({hasText:'storage failed'}).waitFor();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {reloadBlocked:true};});
|
||||
await scenario('R05-schema-mismatch-blocks',async p=>{await put({...state,draftSchema:2},state.revision);await p.locator('#preserve').click();await p.locator('#status').filter({hasText:'incompatible draft schema'}).waitFor();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {reloadBlocked:true};});
|
||||
await scenario('R06-explicit-discard',async p=>{await p.evaluate(()=>sessionStorage.setItem('fep-review-draft',JSON.stringify({schema:1,text:'older saved draft',base:'record-v1'})));await reload(p,'#discard');assert.equal(await draft(p),'');assert.equal(await p.evaluate(()=>sessionStorage.getItem('fep-review-draft')),null);assert.equal(await p.locator('#version').textContent(),'R2');return {explicitlyDiscarded:true};});
|
||||
await scenario('R07-unknown-write-blocks',async p=>{await p.evaluate(()=>window.pendingUnknown=true);await p.locator('#preserve').click();await p.locator('#status').filter({hasText:'unknown operation'}).waitFor();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {uncertainOutcomeNotDiscarded:true};});
|
||||
await scenario('R08-edit-during-preflight',async p=>{holdNext=true;await p.locator('#preserve').click();await untilHeld();await p.locator('#draft').fill('newer proposal while checking');const release=held;held=null;const done=p.waitForEvent('load');release();await done;await p.waitForFunction(()=>window.ready);assert.equal(await draft(p),'newer proposal while checking');return {latestEditPreserved:true};});
|
||||
await scenario('R09-activation-changes-after-check',async p=>{holdNext=true;await p.locator('#preserve').click();await untilHeld();await put({...state,draftSchema:2},state.revision);const release=held;held=null;const done=p.waitForEvent('load');release();await done;await p.waitForFunction(()=>window.ready);assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#draft').getAttribute('readonly'),'');await p.locator('#status').filter({hasText:'Recovery required'}).waitFor();return {startupRevalidation:true,draftRetainedButNotEditable:true};});
|
||||
}finally{await browser.close();}
|
||||
}
|
||||
}finally{if(held)held();await new Promise(resolve=>server.close(resolve));}
|
||||
async function untilHeld(){for(let i=0;i<100;i++){if(held)return;await new Promise(r=>setTimeout(r,10));}throw new Error('preflight barrier not reached');}
|
||||
const files={};for(const f of [...names,'conflicts-reload.mjs','package.json','package-lock.json'])files[f]=createHash('sha256').update(await readFile(dir+f)).digest('hex');const output=process.env.FEP_RESULT_PATH||'/tmp/fep-conflicts-reload.json';const result={date:new Date().toISOString(),node:process.version,files,results,passed:results.filter(x=>x.status==='pass').length,total:results.length};await writeFile(output,JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify({output,passed:result.passed,total:result.total,failures:results.filter(x=>x.status==='fail')},null,2));if(result.passed!==result.total)process.exitCode=1;
|
||||
44
research/experiments/feature-release/reload-host.js
Normal file
44
research/experiments/feature-release/reload-host.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
const input = document.getElementById('draft');
|
||||
const status = document.getElementById('status');
|
||||
const key = 'fep-review-draft';
|
||||
const startup = JSON.parse(document.getElementById('startup').textContent);
|
||||
window.observed = {startup, outcomes: [], evaluations: [], restored: false};
|
||||
window.pendingUnknown = false;
|
||||
window.failStorage = false;
|
||||
let blocked = false;
|
||||
const saved = sessionStorage.getItem(key);
|
||||
if (saved) {
|
||||
const record = JSON.parse(saved);
|
||||
input.value = record.text;
|
||||
if (record.schema !== startup.draftSchema) {
|
||||
blocked = true; input.readOnly = true; status.textContent = 'Recovery required: draft schema mismatch';
|
||||
} else {window.observed.restored = true; status.textContent = 'Draft restored';}
|
||||
}
|
||||
if (!saved) status.textContent = 'Ready';
|
||||
window.feature = await import('reports');
|
||||
document.getElementById('version').textContent = window.feature.version;
|
||||
window.ready = true;
|
||||
window.requestReload = async choice => {
|
||||
if (choice === 'stay') {status.textContent = 'Update deferred'; return false;}
|
||||
if (blocked) {status.textContent = 'Recovery required: draft schema mismatch'; return false;}
|
||||
if (window.pendingUnknown) {status.textContent = 'Resolve unknown operation outcome first'; return false;}
|
||||
const latest = await fetch('/activation').then(r => r.json());
|
||||
// Recheck after asynchronous work; edits made while checking must not be replaced by an old snapshot.
|
||||
if (window.pendingUnknown) {status.textContent = 'Resolve unknown operation outcome first'; return false;}
|
||||
if (choice !== 'discard' && latest.draftSchema !== 1) {
|
||||
status.textContent = 'Update deferred: incompatible draft schema'; return false;
|
||||
}
|
||||
try {
|
||||
if (window.failStorage) throw new Error('injected storage refusal');
|
||||
if (choice === 'discard') sessionStorage.removeItem(key);
|
||||
else {
|
||||
const record = {schema: 1, text: input.value, base: 'record-v1'};
|
||||
sessionStorage.setItem(key, JSON.stringify(record));
|
||||
if (sessionStorage.getItem(key) !== JSON.stringify(record)) throw new Error('write verification failed');
|
||||
}
|
||||
} catch {status.textContent = 'Update deferred: draft storage failed'; return false;}
|
||||
location.reload();return true;
|
||||
};
|
||||
document.getElementById('stay').onclick = () => window.requestReload('stay');
|
||||
document.getElementById('preserve').onclick = () => window.requestReload('preserve');
|
||||
document.getElementById('discard').onclick = () => window.requestReload('discard');
|
||||
Loading…
Add table
Add a link
Reference in a new issue