ace-mini-1 / scripts /space_server.py
ConnorKapoor's picture
Bound concurrent request uploads before body buffering
35ee39b verified
Raw History Blame Contribute Delete
7.72 kB
"""Single-process public gateway. Backend is loopback-only; uploads are session-owned."""
import asyncio, contextlib, hashlib, hmac, json, os, re, secrets, shutil, subprocess, sys, tempfile, time
from contextlib import asynccontextmanager
from pathlib import Path
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
ROOT=Path(__file__).resolve().parents[1]
PUBLIC=Path(os.environ.get('ACE_PUBLIC_DIR',ROOT/'public'))
KEY=secrets.token_bytes(32);owners={};busy=set();gate=asyncio.Semaphore(1);waiting=0
inflight=0
TTL=7200;MAX_UPLOAD=50*1024*1024
backend_url=os.environ.get('ACE_BACKEND_URL','http://127.0.0.1:9877')
client=None;store=None
def sign(n):return hmac.new(KEY,n.encode(),hashlib.sha256).hexdigest()
def session(req):
token=req.cookies.get('ace_session','');bits=token.split('.')
if len(bits)==2 and re.fullmatch('[a-f0-9]{64}',bits[0]) and hmac.compare_digest(sign(bits[0]),bits[1]):return bits[0],None
n=secrets.token_hex(32);return n,n+'.'+sign(n)
def remove_job(j):
if store is None:return
target=(store/j).resolve()
if target.parent!=store.resolve() or not re.fullmatch('[a-f0-9]{32}',j):raise ValueError('Invalid cleanup target')
if target.exists():shutil.rmtree(target)
owners.pop(j,None)
async def cleanup():
while True:
await asyncio.sleep(60)
for j,entry in list(owners.items()):
if j not in busy and time.time()-entry['created']>TTL:remove_job(j)
@asynccontextmanager
async def lifespan(app):
global client,store
with tempfile.TemporaryDirectory(prefix='ace-public-') as temp:
store=Path(temp)
env={**os.environ,'ACE_PORT':'9877','ACE_STORE':temp,'PYTHONUNBUFFERED':'1'}
proc=subprocess.Popen([sys.executable,str(ROOT/'scripts/casting_app.py')],env=env)
client=httpx.AsyncClient(base_url=backend_url,timeout=420)
task=None
try:
for _ in range(240):
if proc.poll() is not None:raise RuntimeError('Inference backend failed to start')
try:
r=await client.get('/api/model')
if r.status_code==200:break
except httpx.HTTPError:pass
await asyncio.sleep(1)
else:raise RuntimeError('Inference backend startup timed out')
task=asyncio.create_task(cleanup());yield
finally:
if task:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):await task
await client.aclose();proc.terminate()
try:proc.wait(timeout=10)
except subprocess.TimeoutExpired:proc.kill();proc.wait()
app=FastAPI(lifespan=lifespan,docs_url=None,redoc_url=None,openapi_url=None)
@app.middleware('http')
async def identity(req,call_next):
global inflight
limited=req.method=='POST' and req.url.path.startswith('/api/')
if limited and inflight>=4:return JSONResponse({'error':'Service busy; please retry shortly.'},status_code=429)
if limited:inflight+=1
sid,cookie=session(req);req.state.sid=sid
try:response=await call_next(req)
finally:
if limited:inflight-=1
if cookie:response.set_cookie('ace_session',cookie,httponly=True,secure=req.url.scheme=='https',samesite='lax',max_age=TTL)
if req.url.path.startswith('/api'):response.headers['Cache-Control']='no-store'
response.headers['X-Content-Type-Options']='nosniff'
return response
@app.get('/')
async def home():return RedirectResponse('/artifacts/whitepaper/index.html')
@app.get('/app')
async def app_root():return RedirectResponse('/app/')
@app.get('/healthz')
async def health():return {'status':'ready'}
def err(message,code=400):return JSONResponse({'error':message},status_code=code)
def owned(j,sid):return j in owners and owners[j]['owner']==sid and time.time()-owners[j]['created']<=TTL
def forward(r):
# httpx decompresses; do not forward Content-Encoding/Length from upstream.
return Response(r.content,status_code=r.status_code,media_type=r.headers.get('content-type','application/json'))
@app.api_route('/api/{route:path}',methods=['GET','POST'])
async def api(route,req:Request):
global waiting
sid=req.state.sid
if req.method=='GET':
if route=='model':return forward(await client.get('/api/model'))
if route=='recent':return [{'id':j,'name':e['name']} for j,e in owners.items() if owned(j,sid)]
if not re.fullmatch(r'(job|geometry)/[a-f0-9]{32}',route):return err('Not found',404)
if not owned(route.split('/')[-1],sid):return err('Part not found or session expired',404)
return forward(await client.get('/api/'+route))
# Browser requests must originate from this host; no cross-origin upload API.
origin=req.headers.get('origin')
if origin and origin.rstrip('/')!=str(req.base_url).rstrip('/'):return err('Origin not allowed',403)
if route not in ['upload','resume','predict']:return err('Not found',404)
raw=bytearray();limit=MAX_UPLOAD if route=='upload' else 65536
async for chunk in req.stream():
raw.extend(chunk)
if len(raw)>limit:return err('Request exceeds size limit',413)
if route=='upload':
if len(owners)>=24 or sum(owned(j,sid) for j in owners)>=3:return err('Upload limit reached. Temporary parts expire after two hours.',429)
if b'ISO-10303-21' not in raw[:4096].upper():return err('Expected a STEP/STP file')
else:
try:opt=json.loads(raw);j=opt['id']
except (ValueError,KeyError,TypeError):return err('Invalid request')
if not isinstance(j,str) or not owned(j,sid):return err('Part not found or session expired',404)
if route=='resume':return forward(await client.post('/api/resume',content=bytes(raw)))
if waiting>=4:return err('Prediction service is busy. Please retry shortly.',429)
waiting+=1;j=None
try:
async with gate:
headers={'Accept':req.headers.get('accept','application/json')}
if route=='upload':
if len(owners)>=24 or sum(owned(k,sid) for k in owners)>=3:return err('Upload limit reached',429)
name=req.headers.get('x-filename','Uploaded STEP')[:200];headers['X-Filename']=name
r=await client.post('/api/upload',content=bytes(raw),headers=headers)
if r.status_code!=200:return forward(r)
result=r.json();j=result['id'];owners[j]={'owner':sid,'created':time.time(),'name':name};busy.add(j)
# Keep CAD and inference serialized; browser shows its preparing state.
for _ in range(390):
state=await client.get('/api/job/'+j)
if state.json().get('status')!='processing':return forward(state)
await asyncio.sleep(1)
return err('Geometry preparation timed out',504)
j=opt['id']
if not owned(j,sid):return err('Part expired',404)
busy.add(j)
return forward(await client.post('/api/predict',content=bytes(raw),headers=headers))
except httpx.HTTPError:return err('Prediction service unavailable; please retry',503)
finally:
waiting-=1
if j:busy.discard(j)
# Only explicitly packaged assets are served. No repository or job-directory mounts.
app.mount('/app',StaticFiles(directory=PUBLIC/'artifacts/casting_app',html=True),name='app')
app.mount('/artifacts',StaticFiles(directory=PUBLIC/'artifacts'),name='showcase')