Skip to content

Commit 357ede0

Browse files
fix: replace in-memory _jobs with file-based store — fixes empty terminal with gunicorn multi-worker (--workers 4)
1 parent f5d01e4 commit 357ede0

1 file changed

Lines changed: 79 additions & 27 deletions

File tree

panel/routes/modules.py

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,60 @@ def translate_install_cmd(cmd):
3939
return os_cmd(cmd)
4040

4141
def req(): return 'user' in session
42-
_jobs = {}
42+
43+
# ── Job store: file-based so all gunicorn workers share state ──────────────
44+
# In-memory dict fails with --workers > 1 because each worker has its own
45+
# memory space. A small JSON file per job in /tmp/vortex_jobs/ is shared
46+
# across all workers via the filesystem.
47+
_JOBS_DIR = '/tmp/vortex_jobs'
48+
os.makedirs(_JOBS_DIR, exist_ok=True)
49+
_jobs_lock = threading.Lock()
50+
51+
def _job_path(job_id):
52+
return os.path.join(_JOBS_DIR, f'{job_id}.json')
53+
54+
def _job_get(job_id):
55+
try:
56+
with open(_job_path(job_id)) as f:
57+
return json.load(f)
58+
except: return None
59+
60+
def _job_set(job_id, data):
61+
with _jobs_lock:
62+
try:
63+
with open(_job_path(job_id), 'w') as f:
64+
json.dump(data, f)
65+
except: pass
66+
67+
def _job_append_line(job_id, line):
68+
with _jobs_lock:
69+
try:
70+
path = _job_path(job_id)
71+
with open(path) as f: data = json.load(f)
72+
data['lines'].append(line)
73+
with open(path, 'w') as f: json.dump(data, f)
74+
except: pass
75+
76+
def _job_finish(job_id, success, installed, inst_ver=''):
77+
with _jobs_lock:
78+
try:
79+
path = _job_path(job_id)
80+
with open(path) as f: data = json.load(f)
81+
data.update({'done':True,'success':success,'installed':installed,
82+
'installedVer':inst_ver,'status':'done'})
83+
with open(path, 'w') as f: json.dump(data, f)
84+
except: pass
85+
86+
def _job_create(job_id, initial_installed=False):
87+
_job_set(job_id, {'status':'running','lines':[],'done':False,
88+
'success':False,'installed':initial_installed,'installedVer':''})
89+
90+
# Legacy compatibility — keep _jobs as a shim for any direct access
91+
class _JobsShim:
92+
def get(self, job_id, default=None): return _job_get(job_id) or default
93+
def __setitem__(self, job_id, val): _job_set(job_id, val)
94+
def __getitem__(self, job_id): return _job_get(job_id)
95+
_jobs = _JobsShim()
4396

4497
def sh(c, t=10):
4598
try:
@@ -903,22 +956,22 @@ def rc_get(key):
903956
if not cmd: return jsonify({'ok':False, 'error':'No install command defined'}), 400
904957

905958
job_id = str(uuid.uuid4())[:8]
906-
_jobs[job_id] = {'status':'running', 'lines':[], 'done':False, 'success':False, 'installed':False}
959+
_job_create(job_id, initial_installed=False)
907960

908961
def run_job():
909-
_jobs[job_id]['lines'].append(f'[VortexPanel] Installing {mod["name"]} {ver}...')
962+
_job_append_line(job_id, f'[VortexPanel] Installing {mod["name"]} {ver}...')
910963
_final_cmd = translate_install_cmd(cmd)
911964
proc = subprocess.Popen(f'DEBIAN_FRONTEND=noninteractive {_final_cmd} 2>&1',
912965
shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
913966
for line in proc.stdout:
914-
_jobs[job_id]['lines'].append(line.rstrip())
967+
_job_append_line(job_id, line.rstrip())
915968
proc.wait()
916969
installed = is_installed(mod['check'])
917970
inst_ver = get_version(mod['id']) if installed else ''
918-
_jobs[job_id].update({'installed':installed,'installedVer':inst_ver,'done':True,'success':installed,'status':'done'})
919-
_jobs[job_id]['lines'].append(
971+
_job_append_line(job_id,
920972
f'[VortexPanel] {"✓ Installed successfully! Version: "+inst_ver if installed else "⚠ Installation may have failed — check output above."}'
921973
)
974+
_job_finish(job_id, success=installed, installed=installed, inst_ver=inst_ver)
922975
panel_cache.invalidate('modules_list')
923976

924977
threading.Thread(target=run_job, daemon=True).start()
@@ -943,29 +996,27 @@ def uninstall_module(mod_id):
943996
if not cmd: return jsonify({'ok':False, 'error':'No uninstall command defined'}), 400
944997

945998
job_id = str(uuid.uuid4())[:8]
946-
_jobs[job_id] = {'status':'running', 'lines':[], 'done':False, 'success':False, 'installed':True}
999+
_job_create(job_id, initial_installed=True)
9471000

9481001
def run_job():
949-
_jobs[job_id]['lines'].append(f'[VortexPanel] Removing {mod["name"]} {ver}...')
1002+
_job_append_line(job_id, f'[VortexPanel] Removing {mod["name"]} {ver}...')
9501003
proc = subprocess.Popen(f'DEBIAN_FRONTEND=noninteractive {cmd} 2>&1',
9511004
shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
9521005
for line in proc.stdout:
953-
_jobs[job_id]['lines'].append(line.rstrip())
1006+
_job_append_line(job_id, line.rstrip())
9541007
proc.wait()
955-
# For versioned modules (PHP, Python), check version-specific binary
9561008
if ver and mod_id in ('php','python'):
9571009
ver_binary = f'php{ver}' if mod_id=='php' else f'python{ver}'
9581010
still_installed = bool(sh(f'which {ver_binary} 2>/dev/null'))
9591011
else:
9601012
still_installed = is_installed(mod['check'])
9611013
removed = not still_installed
962-
# For PHP: overall 'installed' = any PHP still present
9631014
if mod_id == 'php':
9641015
any_php = is_installed(mod['check'])
965-
_jobs[job_id].update({'installed':any_php,'done':True,'success':removed,'status':'done'})
1016+
_job_finish(job_id, success=removed, installed=any_php)
9661017
else:
967-
_jobs[job_id].update({'installed':still_installed,'done':True,'success':removed,'status':'done'})
968-
_jobs[job_id]['lines'].append(
1018+
_job_finish(job_id, success=removed, installed=still_installed)
1019+
_job_append_line(job_id,
9691020
f'[VortexPanel] {"✓ Removed successfully!" if removed else "⚠ May not be fully removed — check output above."}'
9701021
)
9711022
panel_cache.invalidate('modules_list')
@@ -977,15 +1028,19 @@ def run_job():
9771028
def job_stream(job_id):
9781029
def generate():
9791030
sent = 0
980-
while True:
981-
job = _jobs.get(job_id)
1031+
for _ in range(600): # max 3 minutes (600 × 0.3s)
1032+
job = _job_get(job_id)
9821033
if not job:
9831034
yield f'data: {json.dumps({"error":"Job not found"})}\n\n'; break
984-
while sent < len(job['lines']):
985-
yield f'data: {json.dumps({"line": job["lines"][sent]})}\n\n'
1035+
lines = job.get('lines', [])
1036+
while sent < len(lines):
1037+
yield f'data: {json.dumps({"line": lines[sent]})}\n\n'
9861038
sent += 1
987-
if job['done']:
1039+
if job.get('done'):
9881040
yield f'data: {json.dumps({"done":True,"success":job["success"],"installed":job["installed"],"installedVer":job.get("installedVer","")})}\n\n'
1041+
# Clean up job file after a short delay
1042+
try: os.remove(_job_path(job_id))
1043+
except: pass
9891044
break
9901045
time.sleep(0.3)
9911046
return Response(generate(), mimetype='text/event-stream',
@@ -1846,27 +1901,24 @@ def sh(cmd, t=30):
18461901

18471902
# Run as a streaming job — same system as install/uninstall
18481903
job_id = str(uuid.uuid4())[:8]
1849-
_jobs[job_id] = {'status':'running','lines':[],'done':False,'success':False,'installed':True}
1904+
_job_create(job_id, initial_installed=True)
18501905

18511906
def run_switch():
18521907
mod_name = mod['name'] if mod else mod_id
1853-
_jobs[job_id]['lines'].append(f'[VortexPanel] Switching {mod_name} to version {ver}...')
1908+
_job_append_line(job_id, f'[VortexPanel] Switching {mod_name} to version {ver}...')
18541909
proc = subprocess.Popen(
18551910
f'DEBIAN_FRONTEND=noninteractive {script} 2>&1',
18561911
shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1
18571912
)
18581913
for line in proc.stdout:
1859-
_jobs[job_id]['lines'].append(line.rstrip())
1914+
_job_append_line(job_id, line.rstrip())
18601915
proc.wait()
18611916
success = proc.returncode == 0
18621917
new_ver = sh(ver_check_cmd) if ver_check_cmd else ver
1863-
_jobs[job_id]['lines'].append(
1918+
_job_append_line(job_id,
18641919
f'[VortexPanel] {"✓ Switched to " + new_ver + " successfully!" if success else "⚠ Switch may have failed — check output above."}'
18651920
)
1866-
_jobs[job_id].update({
1867-
'done': True, 'success': success, 'status': 'done',
1868-
'installed': True, 'installedVer': new_ver,
1869-
})
1921+
_job_finish(job_id, success=success, installed=True, inst_ver=new_ver)
18701922
panel_cache.invalidate('modules_list')
18711923

18721924
threading.Thread(target=run_switch, daemon=True).start()

0 commit comments

Comments
 (0)