Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
261 lines
20 KiB
JavaScript
261 lines
20 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import {reserveNativeSessionWorkspace,prepareNativeSessionWorkspace,markNativeSessionWorkspace} from './native-session-workspace.mjs';
|
|
import crypto from 'node:crypto';
|
|
import {cancelServerJobProcess} from './server-job-process.mjs';
|
|
import {materializeResources,captureResourceBundle} from './native-resources.mjs';
|
|
import {persistNativePreferences,persistNativeStartup} from './native-preferences.mjs';
|
|
import {startNativeExtensionProxy} from './native-extension-proxy.mjs';
|
|
|
|
const sessions=new Map();
|
|
let startingSessions=0;
|
|
const hash=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
|
|
function write(file,value){const tmp=file+'.tmp';fs.writeFileSync(tmp,JSON.stringify(value));fs.renameSync(tmp,file);}
|
|
function read(file,limit=2*1024*1024){const info=fs.lstatSync(file);if(!info.isFile()||info.size>limit)throw new Error('SESSION_RESULT_INVALID');return JSON.parse(fs.readFileSync(file,'utf8'));}
|
|
async function close(entry){
|
|
if(entry.closed)return;
|
|
entry.closed=true;clearInterval(entry.monitor);sessions.delete(entry.id);
|
|
await entry.extensionProxy?.close();
|
|
await cancelServerJobProcess(entry.process,async()=>{try{if(!entry.lease.persistent)fs.rmSync(entry.workspace,{recursive:true,force:true});}finally{entry.lease.release();}},{graceMs:500});
|
|
}
|
|
function envelope(data,validate){
|
|
if(data.length<12||data.subarray(0,4).toString()!=='BWF1')throw new Error('SESSION_ENVELOPE');
|
|
const metadata=data.readUInt32LE(4),length=data.readUInt32LE(8);
|
|
if(metadata>256*1024||length>512*1024*1024||12+metadata+length!==data.length)throw new Error('SESSION_ENVELOPE_LENGTH');
|
|
const request=JSON.parse(data.subarray(12,12+metadata).toString());validate(request);
|
|
const bytes=data.subarray(12+metadata);
|
|
if(hash(bytes)!==request.sourceBlendSha256||bytes.length!==request.sourceBlendByteLength)throw new Error('SESSION_SOURCE_HASH');
|
|
return {request,bytes};
|
|
}
|
|
function userFile(entry,relative){
|
|
if(typeof relative!=='string'||Buffer.byteLength(relative)>2048||relative.includes('\\')||relative.includes('\0')||relative.startsWith('/')||relative.split('/').some(p=>!p||p==='.'||p==='..'||p.length>255))throw new Error('SESSION_FILE_PATH');
|
|
let target=path.join(entry.workspace,'resources','user');
|
|
for(const segment of relative.split('/')){target=path.join(target,segment);if(fs.existsSync(target)&&fs.lstatSync(target).isSymbolicLink())throw new Error('SESSION_FILE_SYMLINK');}
|
|
return target;
|
|
}
|
|
function userFiles(entry){
|
|
const root=path.join(entry.workspace,'resources','user'),files=[];
|
|
const visit=folder=>{if(!fs.existsSync(folder))return;for(const item of fs.readdirSync(folder,{withFileTypes:true})){
|
|
const absolute=path.join(folder,item.name),info=fs.lstatSync(absolute);
|
|
if(info.isSymbolicLink())throw new Error('SESSION_FILE_SYMLINK');
|
|
if(info.isDirectory())visit(absolute);
|
|
else if(info.isFile()){if(files.length>=4096)throw new Error('SESSION_FILE_COUNT');files.push({path:path.relative(root,absolute).split(path.sep).join('/'),bytes:info.size,modified:info.mtimeMs});}
|
|
}};visit(root);return files;
|
|
}
|
|
function events(items,width=1280,height=900){
|
|
if(!Array.isArray(items)||items.length>64)throw new Error('SESSION_INPUT_BUDGET');
|
|
for(const e of items){
|
|
if(!e||!['move','button','key','wheel','release','pen'].includes(e.type))throw new Error('SESSION_INPUT_TYPE');
|
|
if(['move','button','pen'].includes(e.type)&&(!Number.isInteger(e.x)||!Number.isInteger(e.y)||e.x<0||e.x>=width||e.y<0||e.y>=height))throw new Error('SESSION_POINTER_INVALID');
|
|
if(e.type==='button'&&(![1,2,3].includes(e.button)||typeof e.down!=='boolean'))throw new Error('SESSION_BUTTON_INVALID');
|
|
if(e.type==='key'&&(typeof e.code!=='string'||!/^[A-Za-z0-9]{1,20}$/.test(e.code)||typeof e.down!=='boolean'))throw new Error('SESSION_KEY_INVALID');
|
|
if(e.type==='key'&&e.key!==undefined&&(typeof e.key!=='string'||Buffer.byteLength(e.key)>64||e.key.includes('\0')))throw new Error('SESSION_LOGICAL_KEY_INVALID');
|
|
if(e.type==='key'&&e.locks!==undefined&&(!e.locks||typeof e.locks.caps!=='boolean'||typeof e.locks.num!=='boolean'))throw new Error('SESSION_KEY_LOCKS_INVALID');
|
|
if(e.type==='wheel'&&(!Number.isInteger(e.delta)||Math.abs(e.delta)>5))throw new Error('SESSION_WHEEL_INVALID');
|
|
if(e.type==='wheel'&&e.horizontal!==undefined&&typeof e.horizontal!=='boolean')throw new Error('SESSION_WHEEL_AXIS_INVALID');
|
|
if(e.type==='pen'&&(!['move','press','release'].includes(e.action)||![1,2,3].includes(e.button)||typeof e.eraser!=='boolean'||!Number.isFinite(e.pressure)||e.pressure<0||e.pressure>1||![e.tiltX,e.tiltY].every(value=>Number.isFinite(value)&&value>=-1&&value<=1)))throw new Error('SESSION_TABLET_INVALID');
|
|
}
|
|
return items;
|
|
}
|
|
export async function handleNativeSession(req,res,options,services){
|
|
const {localRequest,collect,json,fail,validate,launch,workspaceUsage}=services;
|
|
const url=new URL(req.url??'/','http://localhost');if(url.pathname!=='/api/blender-session')return false;
|
|
let reserved=false;
|
|
try{
|
|
localRequest(req);const action=req.headers['x-blender-session-action'];
|
|
if(action==='start'){
|
|
if(req.method!=='POST')fail('SESSION_METHOD',405);
|
|
if(sessions.size+startingSessions>=2)fail('SESSION_CAPACITY',429);
|
|
startingSessions++;reserved=true;
|
|
const {request,bytes}=envelope(await collect(req,512*1024*1024+256*1024+12),validate);
|
|
if(!['PYTHON','DESKTOP_OPERATOR'].includes(request.kind))fail('SESSION_KIND_INVALID');
|
|
const lease=reserveNativeSessionWorkspace(request,workspaceUsage),{workspace}=lease;let processHandle,extensionProxy;
|
|
try{
|
|
prepareNativeSessionWorkspace(lease);
|
|
for(const dir of ['commands','responses','events'])fs.mkdirSync(path.join(workspace,dir));
|
|
write(path.join(workspace,'request.json'),request);fs.writeFileSync(path.join(workspace,'source.blend'),bytes);
|
|
if(lease.retained)write(path.join(workspace,'resources.json'),captureResourceBundle(workspace,true)??{files:[]});else materializeResources(request.resources,workspace);
|
|
fs.mkdirSync(path.join(workspace,'resources','user'),{recursive:true});
|
|
if(!fs.existsSync(path.join(workspace,'resources.json')))write(path.join(workspace,'resources.json'),{files:[]});
|
|
extensionProxy=await startNativeExtensionProxy(workspace);
|
|
markNativeSessionWorkspace(lease);processHandle=launch(workspace,request,options,true,lease);
|
|
}catch(error){await extensionProxy?.close();if(!lease.persistent)fs.rmSync(workspace,{recursive:true,force:true});lease.release();throw error;}
|
|
const entry={id:crypto.randomUUID(),token:crypto.randomBytes(32).toString('hex'),request,workspace,lease,process:processHandle,extensionProxy,
|
|
sequence:0,pending:null,eventSequence:0,lastInput:0,lastUse:Date.now(),started:Date.now(),closed:false,stderr:''};
|
|
const preferencesFile=path.join(workspace,'config','userpref.blend');
|
|
if(fs.existsSync(preferencesFile)){const stat=fs.lstatSync(preferencesFile);entry.preferencePublished=`${stat.mtimeMs}:${stat.size}`;}
|
|
const startupFile=path.join(workspace,'config','startup.blend');
|
|
if(fs.existsSync(startupFile)){const stat=fs.lstatSync(startupFile);entry.startupPublished=`${stat.mtimeMs}:${stat.size}`;}
|
|
sessions.set(entry.id,entry);
|
|
processHandle.child.on('error',error=>{entry.stderr=String(error);});
|
|
processHandle.child.stderr.on('data',chunk=>{entry.stderr=(entry.stderr+chunk.toString()).slice(-8192);});
|
|
processHandle.child.stdout.on('data',chunk=>{entry.stderr=(entry.stderr+chunk.toString()).slice(-8192);});
|
|
processHandle.completion.then(()=>{if(!entry.closed)entry.exitError=`SESSION_PROCESS_EXITED (${processHandle.child.exitCode ?? processHandle.child.signalCode}): ${entry.stderr}`;});
|
|
entry.monitor=setInterval(()=>{
|
|
if(entry.closed)return;
|
|
try{
|
|
if(Date.now()-entry.lastUse>5*60*1000||Date.now()-entry.started>(entry.request.desktop?.preserveLayout?12:2)*60*60*1000||entry.exitError)throw new Error('SESSION_EXPIRED');
|
|
workspaceUsage(workspace);
|
|
if(entry.request.desktop?.preserveLayout){
|
|
const startup=path.join(workspace,'config','startup.blend');
|
|
if(fs.existsSync(startup)){
|
|
const stat=fs.lstatSync(startup),stamp=`${stat.mtimeMs}:${stat.size}`;
|
|
const ready=path.join(workspace,'startup-ready.json');
|
|
if(stamp===entry.startupCandidate&&stamp!==entry.startupPublished&&fs.existsSync(ready)&&read(ready).sha256===hash(fs.readFileSync(startup))){
|
|
try{persistNativeStartup(workspace);entry.preferenceError=null;}catch(error){entry.preferenceError=String(error.message);}
|
|
entry.startupPublished=stamp;
|
|
}
|
|
entry.startupCandidate=stamp;
|
|
}
|
|
const file=path.join(workspace,'config','userpref.blend');
|
|
if(fs.existsSync(file)){
|
|
const stat=fs.lstatSync(file),stamp=`${stat.mtimeMs}:${stat.size}`;
|
|
if(stamp===entry.preferenceCandidate&&stamp!==entry.preferencePublished&&stat.isFile()&&stat.size<=16*1024*1024){
|
|
try{persistNativePreferences(workspace,{id:'config/userpref.blend',sha256:hash(fs.readFileSync(file))});entry.preferenceError=null;}
|
|
catch(error){entry.preferenceError=String(error.message);}
|
|
entry.preferencePublished=stamp;
|
|
}
|
|
entry.preferenceCandidate=stamp;
|
|
}
|
|
}
|
|
if(entry.pending&&Date.now()-entry.pending.started>120000&&!fs.existsSync(path.join(workspace,'responses',`${String(entry.pending.sequence).padStart(8,'0')}.json`)))throw new Error('SESSION_COMMAND_TIMEOUT');
|
|
}catch(error){entry.exitError=String(error.message);void close(entry);}
|
|
},1000);entry.monitor.unref();
|
|
json(res,202,{id:entry.id,token:entry.token,status:'STARTING',desktopProtocolVersion:2,sourceRevision:request.sourceRevision,sourceBlendSha256:request.sourceBlendSha256,width:request.desktop?.width??1280,height:request.desktop?.height??900});return true;
|
|
}
|
|
const body=req.method==='POST'&&action!=='upload'?JSON.parse((await collect(req,256*1024)).toString()):{};
|
|
const id=body.id??url.searchParams.get('id'),token=body.token??req.headers['x-blender-session-token'];
|
|
const entry=sessions.get(id);if(!entry||entry.token!==token||entry.closed)fail('SESSION_NOT_FOUND_OR_EXPIRED',404);
|
|
entry.lastUse=Date.now();
|
|
if(action==='close'){
|
|
if(req.method!=='POST')fail('SESSION_METHOD',405);await close(entry);json(res,200,{status:'CLOSED'});return true;
|
|
}
|
|
if(entry.exitError)fail(entry.exitError,503);
|
|
if(action==='upload'){
|
|
if(req.method!=='POST'||entry.pending)fail('SESSION_BUSY',409);
|
|
const relative=decodeURIComponent(req.headers['x-blender-file-path']??''),target=userFile(entry,relative);
|
|
if(fs.existsSync(target))fail('SESSION_FILE_EXISTS',409);
|
|
const bytes=await collect(req,256*1024*1024);
|
|
if(workspaceUsage(entry.workspace).bytes+bytes.length>1024*1024*1024)fail('SESSION_WORKSPACE_BYTE_BUDGET');
|
|
fs.mkdirSync(path.dirname(target),{recursive:true});fs.writeFileSync(target,bytes,{flag:'wx'});
|
|
json(res,200,{status:'UPLOADED',path:relative,bytes:bytes.length});return true;
|
|
}
|
|
if(action==='files'){
|
|
if(req.method!=='GET')fail('SESSION_METHOD',405);
|
|
const statusFile=path.join(entry.workspace,'desktop-state.json');
|
|
const directory=path.join(entry.workspace,'browser-links'),links=[];
|
|
if(fs.existsSync(directory))for(const name of fs.readdirSync(directory).filter(name=>/^[a-f0-9]{32}\.json$/.test(name)).slice(0,32)){
|
|
const item=read(path.join(directory,name),16384),target=new URL(item.url);
|
|
if(['http:','https:'].includes(target.protocol)&&target.hostname)links.push({id:name.slice(0,-5),url:target.href});
|
|
}
|
|
json(res,200,{files:userFiles(entry),links,network:entry.extensionProxy?.status,...(fs.existsSync(statusFile)?{status:read(statusFile)}:{}),...(entry.preferenceError?{preferenceError:entry.preferenceError}:{})});return true;
|
|
}
|
|
if(action==='link-ack'){
|
|
if(req.method!=='POST'||!/^[a-f0-9]{32}$/.test(body.linkId??''))fail('SESSION_LINK_ID');
|
|
fs.rmSync(path.join(entry.workspace,'browser-links',body.linkId+'.json'),{force:true});json(res,200,{status:'ACKNOWLEDGED'});return true;
|
|
}
|
|
if(action==='download'){
|
|
if(req.method!=='GET')fail('SESSION_METHOD',405);
|
|
const file=userFile(entry,url.searchParams.get('file')),before=fs.lstatSync(file);
|
|
if(!before.isFile()||before.size>256*1024*1024)fail('SESSION_DOWNLOAD_BUDGET');
|
|
const bytes=fs.readFileSync(file),after=fs.lstatSync(file);
|
|
if(before.mtimeMs!==after.mtimeMs||bytes.length!==before.size)fail('SESSION_FILE_CHANGING_RETRY',409);
|
|
res.setHeader('Content-Type','application/octet-stream');res.setHeader('Cache-Control','no-store');res.setHeader('Content-Length',bytes.length);res.end(bytes);return true;
|
|
}
|
|
if(action==='screen'){
|
|
if(req.method!=='GET')fail('SESSION_METHOD',405);
|
|
const readyFile=path.join(entry.workspace,'session-ready.json');
|
|
if(fs.existsSync(readyFile)){const state=read(readyFile);if(state.status==='FAILED')fail(state.error??'SESSION_DESKTOP_FAILED',503);}
|
|
const file=path.join(entry.workspace,'screen.png');
|
|
if(!fs.existsSync(file)){if(Date.now()-entry.started>45000)fail('SESSION_SCREEN_TIMEOUT',503);res.statusCode=204;res.end();return true;}
|
|
const size=fs.lstatSync(file);if(!size.isFile()||size.size>24*1024*1024)fail('SESSION_SCREEN_INVALID');
|
|
const metadata=path.join(entry.workspace,'screen-meta.json');if(fs.existsSync(metadata)&&read(metadata).cursorEmbedded)res.setHeader('X-Blender-Cursor','embedded');
|
|
const etag=`"${size.mtimeMs}-${size.size}"`;res.setHeader('ETag',etag);
|
|
if(req.headers['if-none-match']===etag){res.statusCode=304;res.end();return true;}
|
|
const bytes=fs.readFileSync(file);res.setHeader('Content-Type','image/png');res.setHeader('Cache-Control','no-store');res.setHeader('Content-Length',bytes.length);res.end(bytes);return true;
|
|
}
|
|
if(action==='events'){
|
|
if(req.method!=='POST')fail('SESSION_METHOD',405);
|
|
if(entry.pending)fail('SESSION_COMMIT_PENDING',409);
|
|
if(Date.now()-entry.lastInput<8)fail('SESSION_INPUT_RATE',429);entry.lastInput=Date.now();
|
|
const batch=events(body.events,entry.request.desktop?.width??1280,entry.request.desktop?.height??900);
|
|
if(fs.readdirSync(path.join(entry.workspace,'events')).length>128)fail('SESSION_INPUT_BACKLOG',429);
|
|
write(path.join(entry.workspace,'events',`${String(++entry.eventSequence).padStart(12,'0')}.json`),batch);json(res,200,{status:'QUEUED'});return true;
|
|
}
|
|
if(action==='command'){
|
|
if(req.method!=='POST'||entry.pending)fail('SESSION_BUSY',409);
|
|
const command=body.command;
|
|
if(!command||!['read-main','python','undo','redo','invoke','commit','cancel-modal','preferences-save','editor','clipboard','clipboard-read','file-browser','download-project','open-blend'].includes(command.kind))fail('SESSION_COMMAND_INVALID');
|
|
if(command.kind==='clipboard'&&(typeof command.text!=='string'||Buffer.byteLength(command.text)>65536||command.text.includes('\0')))fail('SESSION_CLIPBOARD_BUDGET');
|
|
if(command.kind==='file-browser'&&command.path!=='')userFile(entry,command.path);
|
|
if(command.kind==='file-browser'&&command.filename!==undefined&&(typeof command.filename!=='string'||command.filename.includes('/')||command.filename.includes('\\')||command.filename.includes('\0')||command.filename.length>255))fail('SESSION_FILENAME_INVALID');
|
|
if(command.kind==='open-blend'){
|
|
if(!entry.request.desktop?.preserveLayout)fail('SESSION_FULL_WORKSPACE_REQUIRED');
|
|
const file=userFile(entry,command.path);if(!file.toLowerCase().endsWith('.blend')||!fs.lstatSync(file).isFile())fail('SESSION_BLEND_REQUIRED');
|
|
}
|
|
if(command.kind==='editor'){
|
|
const request={...entry.request,context:{...entry.request.context,areaType:command.areaType}};
|
|
if(typeof command.areaType!=='string')fail('SESSION_EDITOR_REQUIRED');
|
|
const {requestSha256,...unsigned}=request;
|
|
request.requestSha256=hash(Buffer.from(services.canonical(unsigned)));validate(request);
|
|
}
|
|
if(command.kind==='python'&&(typeof command.source!=='string'||Buffer.byteLength(command.source)>65536||command.source.includes('\0')))fail('SESSION_PYTHON_BUDGET');
|
|
if(command.kind==='invoke'){
|
|
const request={...entry.request,kind:'DESKTOP_OPERATOR',operator:{id:command.operator,properties:command.properties??{}}};
|
|
const {requestSha256,...unsigned}=request;
|
|
// Reuse the native operator validator using a freshly computed canonical request binding.
|
|
request.requestSha256=hash(Buffer.from(services.canonical(unsigned)));validate(request);
|
|
}
|
|
if(!fs.existsSync(path.join(entry.workspace,'session-ready.json'))||read(path.join(entry.workspace,'session-ready.json')).status!=='READY')fail('SESSION_NOT_READY',409);
|
|
const sequence=++entry.sequence;
|
|
entry.pending={sequence,kind:command.kind,started:Date.now(),response:null};
|
|
write(path.join(entry.workspace,'commands',`${String(sequence).padStart(8,'0')}.json`),{...command,sequence,afterInput:entry.eventSequence});
|
|
json(res,202,{sequence,status:'RUNNING'});return true;
|
|
}
|
|
if(action==='ack'){
|
|
if(req.method!=='POST'||!entry.pending||entry.pending.sequence!==body.sequence||!entry.pending.response)fail('SESSION_ACK_INVALID',409);
|
|
for(const item of entry.pending.response.artifacts??[])fs.rmSync(path.join(entry.workspace,item.id),{force:true});
|
|
fs.rmSync(path.join(entry.workspace,'responses',`${String(body.sequence).padStart(8,'0')}.json`),{force:true});entry.pending=null;
|
|
json(res,200,{status:'READY'});return true;
|
|
}
|
|
if(action==='artifact'){
|
|
if(req.method!=='GET')fail('SESSION_METHOD',405);
|
|
const item=entry.pending?.response?.artifacts?.find(a=>a.id===url.searchParams.get('artifact'));
|
|
if(!item)fail('SESSION_ARTIFACT_NOT_FOUND',404);
|
|
const file=path.join(entry.workspace,item.id),stat=fs.lstatSync(file);if(!stat.isFile()||stat.size!==item.bytes)fail('SESSION_ARTIFACT_CHANGED');
|
|
res.setHeader('Content-Type',item.mime);res.setHeader('Content-Length',item.bytes);res.setHeader('Cache-Control','no-store');
|
|
fs.createReadStream(file).on('error',()=>res.destroy()).pipe(res);return true;
|
|
}
|
|
if(action!=='read'||req.method!=='GET')fail('SESSION_ACTION_INVALID');
|
|
const readyFile=path.join(entry.workspace,'session-ready.json');
|
|
const ready=fs.existsSync(readyFile)?read(readyFile):{status:'STARTING'};
|
|
if(ready.status==='READY'&&!fs.existsSync(path.join(entry.workspace,'screen.png'))&&Date.now()-entry.started>45000){
|
|
fail('SESSION_SCREEN_HELPER_NOT_READY',503);
|
|
}
|
|
if(entry.pending){
|
|
const file=path.join(entry.workspace,'responses',`${String(entry.pending.sequence).padStart(8,'0')}.json`);
|
|
if(fs.existsSync(file)&&!entry.pending.response){
|
|
const result=read(file);
|
|
if(result.sequence!==entry.pending.sequence||result.sessionId!==entry.request.jobId||result.requestSha256!==entry.request.requestSha256)fail('SESSION_RESPONSE_BINDING');
|
|
let bytes=0;const ids=new Set();
|
|
for(const item of result.artifacts??[]){
|
|
if(!/^(result|resources|preferences|project)-[0-9]{8}\.(blend|zip)$/.test(item.id)||ids.has(item.id))fail('SESSION_ARTIFACT_INVALID');ids.add(item.id);
|
|
const p=path.join(entry.workspace,item.id),stat=fs.lstatSync(p);
|
|
if(!stat.isFile()||stat.size!==item.bytes||(bytes+=stat.size)>512*1024*1024||hash(fs.readFileSync(p))!==item.sha256)fail('SESSION_ARTIFACT_BINDING');
|
|
}
|
|
if(entry.pending.kind==='preferences-save'&&result.status==='SUCCEEDED'){
|
|
const preference=result.artifacts?.find(a=>a.id===`preferences-${String(entry.pending.sequence).padStart(8,'0')}.blend`&&a.mime==='application/x-blender-preferences');
|
|
if(!preference)fail('PREFERENCES_ARTIFACT_MISSING');
|
|
try{persistNativePreferences(entry.workspace,preference);}
|
|
catch(error){result.status='FAILED';result.error=String(error.message);result.output='';}
|
|
}
|
|
if(['SUCCEEDED','PYTHON_ERROR'].includes(result.status)&&result.artifacts?.some(a=>a.mime==='application/x-blender'))result.resources=captureResourceBundle(entry.workspace);
|
|
entry.pending.response=result;
|
|
}
|
|
json(res,200,{...ready,command:entry.pending.response??{sequence:entry.pending.sequence,status:'RUNNING'}});return true;
|
|
}
|
|
json(res,200,ready);return true;
|
|
}catch(error){json(res,error.statusCode??500,{status:'FAILED',error:String(error.message??error)});return true;}
|
|
finally{if(reserved)startingSessions--;}
|
|
}
|