Merge pull request #359 from saphid/feat/framey-store-request-recovery

Recover Framey store jobs after lost responses
This commit is contained in:
Alex Southwell authored and GitHub committed 2026-10-27 02:01:07 +00:00
commit 64938443c8
6 files changed
+163 -23

No files matched your search

+10 -7
View File
@@ -203,12 +203,14 @@ the managed service is stopped; it does not restart the service.
Install, Update, and Remove show the current job stage in the headset panel.
The store verifies each downloaded archive and the headset applies it with a
rollback if Decky cannot start. The panel remembers the current job ID for this
browser session, so **Check result** can resume polling after a navigation or
temporary connection error. After a polling error, Install, Update, and Remove
rollback if Decky cannot start. The panel saves a random request ID before
submitting a change. The server uses that ID as the job ID and returns the
same job for a repeated, identical request while its result is retained;
reusing the ID for a different change is rejected. **Check result** can resume
polling even if the POST response is lost. After a connection error, Install, Update, and Remove
stay disabled while the saved job's result is unknown; use **Check result**
before starting another change.
If Chromium denies session storage, the open panel keeps the accepted job ID in
If Chromium denies session storage, the open panel keeps the request ID in
memory and **Check result** still works after a connection error. A page reload
loses that memory fallback; wait for the old job to finish and refresh installed
status before starting another change.
@@ -216,9 +218,10 @@ Refresh the installed list before starting another
operation after an error. The headset panel and desktop app send the hash of
the version you saw; if the official store changes that version before the job
starts, Frame Control stops before downloading or changing the headset. Refresh
the store and review the new version before retrying. If Frame Control restarts, it cannot recover the old
job result. The panel clears that job ID and asks you to refresh the installed
list before making another change.
the store and review the new version before retrying. Job results live in server
memory for one hour after completion. If Frame Control restarts, or the request
never reached it, the panel cannot recover the result. It clears that ID and
asks you to refresh the installed list before making another change.
An open plugin panel closes before its own Update or Remove starts, so its old
frontend cannot call the changing backend. Open panel is unavailable while a
+9 -2
View File
@@ -107,9 +107,16 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertEqual((catalogue[0]['version'], catalogue[0]['sha256']), ('1.0.0', HASHES[0]))
status, _, _ = api('POST', '/api/decky/plugin', {'action':'install','id':999})
self.assertEqual(status, 400) # Third-party code needs explicit acknowledgement.
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':999,'acknowledged':True}))
request = {'action':'install','id':999,'acknowledged':True,
'requestId':'0123456789abcdef' * 2}
started = ok('POST', '/api/decky/plugin', request)
self.assertEqual(ok('POST', '/api/decky/plugin', request)['job'], started['job'])
status, conflict, _ = api('POST', '/api/decky/plugin', {**request, 'id':998})
self.assertEqual(status, 409)
self.assertIn('already used', conflict['error'])
job = finished(started)
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('POST', '/api/decky/plugin', request)['job'], started['job'])
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
receipt = HASHES[0]
+42 -1
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import {webcrypto} from 'node:crypto';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
@@ -17,6 +18,7 @@ const document = {getElementById(id) {if(!elements.has(id)) elements.set(id,new
createElement() {return new Element();}, hidden:false};
const session = new Map();
const ctx = vm.createContext({document, location:{hash:'',pathname:'/'},
crypto:webcrypto,
sessionStorage:{getItem:key=>session.get(key)||'',setItem:(key,value)=>session.set(key,value),removeItem:key=>session.delete(key)},
history:{replaceState(){}}, confirm:()=>true,
window:{addEventListener(){}}, setInterval(){}, setTimeout(){},
@@ -61,6 +63,45 @@ assert.equal(el('deckyJobProgress').value,100);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(session.has('frameyPluginJob'),false);
// If the server accepts the POST but its response is lost, the panel already
// has the ID needed to find the job. It must not offer a second install.
let lostJobId, lostPosts=0;
ctx.mockApi=async (path,body) => {
if(path==='/api/decky/plugin'){
lostPosts++; lostJobId=body.requestId;
assert.match(lostJobId,/^[0-9a-f]{32}$/);
throw Error('POST response lost');
}
if(path.startsWith('/api/job')){
assert.equal(new URL(path,'http://localhost').searchParams.get('id'),lostJobId);
return {done:true,message:'Recovered install'};
}
if(path==='/api/decky')return {managed:true,running:true,plugins:[]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
assert.equal(JSON.parse(session.get('frameyPluginJob')).job,lostJobId);
assert.equal(el('deckyJobCheck').hidden,false);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
assert.equal(lostPosts,1);
await vm.runInContext('checkDeckyJob()',ctx);
assert.equal(el('deckyJobStatus').textContent,'Recovered install');
assert.equal(session.has('frameyPluginJob'),false);
// A definite validation rejection never reserved a job on the server.
ctx.mockApi=async path => {
if(path==='/api/decky/plugin'){
const error=Error('Invalid reviewed store version');error.status=400;throw error;
}
if(path==='/api/decky')return {managed:true,running:true,plugins:[]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"bad"})',ctx);
assert.equal(session.has('frameyPluginJob'),false);
assert.equal(el('deckyJobCheck').hidden,true);
// A stopped Decky service must still offer recovery by removing its owned plugin.
ctx.mockApi=async path => path==='/api/decky' ?
{managed:true,running:false,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]} :
@@ -149,7 +190,7 @@ ctx.mockApi=async path => {
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('checkDeckyJob()',ctx);
assert.match(el('deckyJobStatus').textContent,/app restarted and lost this job result/);
assert.match(el('deckyJobStatus').textContent,/server has no record of this request/);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(session.has('frameyPluginJob'),false);
assert.equal(el('deckyStoreList').children[0].children[1].disabled,false);
+65
View File
@@ -0,0 +1,65 @@
"""Store requests can reconnect when the headset panel loses the POST response."""
import sandbox # noqa: F401
import sys
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import server
class StoreJobs(unittest.TestCase):
def test_same_request_id_runs_once_and_conflicting_reuse_is_rejected(self):
request_id = 'a' * 32
started = threading.Event()
finish = threading.Event()
calls = []
def change(_ssh, action, plugin_id, report, reviewed_hash=None):
calls.append((action, plugin_id, reviewed_hash))
started.set()
self.assertTrue(finish.wait(5))
report('Installed', 100)
return {'message': 'Installed once'}
body = {'action': 'install', 'id': 2, 'sha256': 'b' * 64,
'acknowledged': True, 'requestId': request_id}
try:
with patch.object(server.frame_decky_store, 'change', side_effect=change):
first = server.decky_plugin(body)
self.assertTrue(started.wait(5))
self.assertEqual(server.decky_plugin(body), first)
for changed in ({'action': 'update'}, {'id': 3}, {'sha256': 'c' * 64}):
with self.subTest(changed=changed), self.assertRaises(server.Failure) as error:
server.decky_plugin({**body, **changed})
self.assertEqual(error.exception.status, 409)
self.assertEqual(calls, [('install', 2, 'b' * 64)])
self.assertNotIn('_request', server.job_status('id=' + request_id))
finish.set()
for _ in range(100):
if server.job_status('id=' + request_id)['done']:
break
time.sleep(0.01)
self.assertEqual(server.job_status('id=' + request_id)['message'], 'Installed once')
self.assertEqual(server.decky_plugin(body), first)
self.assertEqual(len(calls), 1)
finally:
finish.set()
with server._jobs_lock:
server._jobs.pop(request_id, None)
def test_invalid_request_id_does_not_start_work(self):
with patch.object(server.frame_decky_store, 'change') as change:
for request_id in (1, '', 'A' * 32, 'f' * 31, 'f' * 33, '../' * 11):
with self.subTest(request_id=request_id), self.assertRaises(server.Failure) as error:
server.decky_plugin({'action': 'install', 'id': 2,
'acknowledged': True, 'requestId': request_id})
self.assertEqual(error.exception.status, 400)
change.assert_not_called()
if __name__ == '__main__':
unittest.main()
+12 -7
View File
@@ -72,7 +72,8 @@ const byId=id=>document.getElementById(id);
let latestStatus=null, activeExtension=null, recordBusy=false;
async function api(path,body){const response=await fetch(path,{method:body?'POST':'GET',
headers:{'X-Frame-UI':key,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
const data=await response.json();if(!response.ok)throw Error(data.error||'Frame request failed');return data;}
const data=await response.json();if(!response.ok){const error=Error(data.error||'Frame request failed');
error.status=response.status;throw error;}return data;}
async function refresh(){try{const status=latestStatus=await api('/api/status');
byId('battery').textContent=status.battery?.percent==null?'Unavailable':`${status.battery.percent}%`;
const free=status.disk?.home?.free;
@@ -107,9 +108,8 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
}catch(error){list.textContent=error.message;}}
function compatibilityText(plugin){const c=plugin.compatibility;return c ? `${c.status === 'na' ? 'N/A' : c.status}: ${c.reason}${c.verification?.length ? ' Verified: '+c.verification.join(', ')+'.' : ''}` : 'Unverified on Frame';}
// Some Chromium profiles deny sessionStorage. Keep the accepted job ID in
// memory so this open panel can still reconnect instead of offering a second
// install while the first one may be running.
// Some Chromium profiles deny sessionStorage. Keep the reserved request ID in
// memory so this open panel can still reconnect if the POST response is lost.
let deckyPendingJob=null;
function savedDeckyJob(){
if(deckyPendingJob)return deckyPendingJob;
@@ -124,6 +124,8 @@ function clearDeckyJob(){
try{sessionStorage.removeItem('frameyPluginJob');}catch(_){}
}
function deckyJobPending(){return !!savedDeckyJob();}
function newDeckyRequestId(){return Array.from(crypto.getRandomValues(new Uint8Array(16)),
byte=>byte.toString(16).padStart(2,'0')).join('');}
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
deckyStatusRequest=0,
activeFrameyId=null, activeFrameyReceipt=null, frameySession=null, deckyBusy=false;
@@ -200,7 +202,7 @@ async function followDeckyJob(job,action,name){
catch(error){
if(/no such job/i.test(error.message)){
clearDeckyJob();
throw Error('The app restarted and lost this job result. Refresh installed plugins to see what changed.');
throw Error('The server has no record of this request. It may not have arrived, or the app may have restarted. Refresh installed plugins before trying again.');
}
throw error;
}
@@ -224,14 +226,17 @@ async function deckyChange(action,plugin){if(deckyBusy)return;
if(panelClosed)closeFrameyPanel();
deckyJobView(`${action} ${plugin.name}`,
panelClosed?'Starting… Old plugin panel closed until the change finishes.':'Starting…',0);
const requestId=newDeckyRequestId();
rememberDeckyJob({job:requestId,action,name:plugin.name});
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true,requestId});
if(!started.job)throw Error('The server did not return a job ID');
rememberDeckyJob({job:started.job,action,name:plugin.name});
await followDeckyJob(started.job,action,plugin.name);
clearDeckyJob();
if(panelNote)byId('deckyJobStatus').textContent+=panelNote;
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}${panelNote}`,null,true);}
}catch(error){if(error.status>=400&&error.status<500)clearDeckyJob();
deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}${panelNote}`,null,true);}
finally{deckyBusy=false;await refreshDecky();}
}
async function checkDeckyJob(){const saved=savedDeckyJob();
+25 -6
View File
@@ -345,11 +345,16 @@ def backfill_art(apps=(), titles=(), meant=None):
return True
def _new_job_locked(label, **fields):
def _prune_jobs_locked():
now = time.time()
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
del _jobs[j]
job = secrets.token_hex(8)
def _new_job_locked(label, job_id=None, **fields):
_prune_jobs_locked()
now = time.time()
job = job_id or secrets.token_hex(8)
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now, **fields}
return job
@@ -376,10 +381,18 @@ def _run_job(job, label, work, progress):
_jobs[job].update(fields, done=True, time=time.time())
def start_job(label, work, progress=False):
def start_job(label, work, progress=False, *, request_id=None, request=None):
"""Run work() in the background. It returns a dict with a "message"."""
with _jobs_lock:
job = _new_job_locked(label)
if request_id is not None:
_prune_jobs_locked()
existing = _jobs.get(request_id)
if existing:
if existing.get('_request') != request:
raise Failure('This plugin request ID was already used for a different change', 409)
return {"message": f"{label}…", "job": request_id}
job = _new_job_locked(label, job_id=request_id,
**({'_request': request} if request_id is not None else {}))
busy_thread(_run_job, job, label, work, progress).start()
return {"message": f"{label}…", "job": job}
@@ -462,7 +475,7 @@ def queue_action(body):
def job_status(query):
with _jobs_lock:
job = _jobs.get((parse_qs(query).get("id") or [""])[0])
snapshot = job and {k: v for k, v in job.items() if k != "time"}
snapshot = job and {k: v for k, v in job.items() if k not in ("time", "_request")}
if not snapshot:
raise Failure("no such job (the app may have restarted)", 404)
return snapshot
@@ -572,9 +585,15 @@ def decky_plugin(body):
if body.get('acknowledged') is not True:
raise Failure('Confirm that store plugins run code as your Frame user', 400)
reviewed_hash = body.get('sha256')
request_id = body.get('requestId')
if request_id is not None and (not isinstance(request_id, str) or
not re.fullmatch(r'[0-9a-f]{32}', request_id)):
raise Failure('Invalid plugin request ID', 400)
device = LINK.active_device()['id'] if LINK else FRAME
return start_job('Decky plugin ' + action,
lambda report: frame_decky_store.change(ssh, action, plugin_id, report,
reviewed_hash=reviewed_hash), progress=True)
reviewed_hash=reviewed_hash), progress=True,
request_id=request_id, request=(device, action, plugin_id, reviewed_hash))
def framey_method_action(body):