Skip to content

Commit a982810

Browse files
committed
fix: resolve empty library and installation hang by fixing state initialization and queue creation
1 parent 34c8bbc commit a982810

File tree

1 file changed

+86
-43
lines changed

1 file changed

+86
-43
lines changed

backend/api_server.py

Lines changed: 86 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,34 @@
7878

7979
@asynccontextmanager
8080
async def lifespan(app: FastAPI):
81-
# Suppress WinError 10054 on Windows asyncio loop
82-
if sys.platform == "win32":
81+
global state
82+
logging.info("Lifespan starting...")
83+
84+
# Ensure loop is set
85+
try:
8386
loop = asyncio.get_running_loop()
87+
if state:
88+
state.loop = loop
89+
# Set exception handler
90+
if sys.platform == "win32":
8491

85-
def custom_exception_handler(loop, context):
86-
if "WinError 10054" in str(context.get("exception", "")):
87-
return
88-
loop.default_exception_handler(context)
92+
def custom_exception_handler(loop, context):
93+
if "WinError 10054" in str(context.get("exception", "")):
94+
return
95+
loop.default_exception_handler(context)
96+
97+
loop.set_exception_handler(custom_exception_handler)
8998

90-
loop.set_exception_handler(custom_exception_handler)
99+
state.start_background_tasks()
100+
logging.info("Background tasks started in lifespan")
101+
except Exception as e:
102+
logging.error(f"Error in lifespan startup: {e}")
91103

92104
yield
105+
logging.info("Lifespan shutting down...")
106+
107+
108+
app = FastAPI(lifespan=lifespan)
93109

94110

95111
app = FastAPI(lifespan=lifespan)
@@ -122,22 +138,48 @@ def __init__(self):
122138

123139
# Config Path
124140
self.config_path = os.path.join(self.app_data_dir, "gui_config.json")
125-
default_config = os.path.join(self.script_dir, "gui_config.json")
126141

127-
# Check if config exists in AppData, if not copy from default (if exists) or create empty
142+
# MIGRATION: Check for legacy config in local folder (next to exe or script)
143+
# If running from EXE, sys.executable is the EXE. If script, __file__ is the script.
144+
current_dir = os.path.dirname(
145+
os.path.abspath(
146+
sys.executable if getattr(sys, "frozen", False) else __file__
147+
)
148+
)
149+
legacy_config = os.path.join(current_dir, "gui_config.json")
150+
151+
# Alternative legacy location: one level up from backend (Resources folder)
152+
resources_dir = os.path.dirname(current_dir)
153+
legacy_config_alt = os.path.join(resources_dir, "gui_config.json")
154+
155+
# Check if config exists in AppData, if not try to migrate or create empty
128156
if not os.path.exists(self.config_path):
129-
if os.path.exists(default_config):
157+
found_legacy = None
158+
if os.path.exists(legacy_config):
159+
found_legacy = legacy_config
160+
elif os.path.exists(legacy_config_alt):
161+
found_legacy = legacy_config_alt
162+
163+
if found_legacy:
130164
try:
131-
with open(default_config, "r") as f:
132-
data = f.read()
133-
with open(self.config_path, "w") as f:
134-
f.write(data)
165+
logging.info(
166+
f"Migrating legacy config from {found_legacy} to {self.config_path}"
167+
)
168+
shutil.copy2(found_legacy, self.config_path)
135169
except Exception as e:
136-
print(f"Failed to copy default config: {e}")
170+
logging.error(f"Failed to migrate legacy config: {e}")
137171
else:
138-
# Create empty default config
139-
with open(self.config_path, "w") as f:
140-
f.write(json.dumps({"servers": []}))
172+
default_config = os.path.join(self.script_dir, "gui_config.json")
173+
if os.path.exists(default_config):
174+
try:
175+
shutil.copy2(default_config, self.config_path)
176+
except Exception as e:
177+
logging.error(f"Failed to copy default config: {e}")
178+
else:
179+
# Create empty default config
180+
with open(self.config_path, "w") as f:
181+
json.dump({"servers": []}, f)
182+
logging.info("Created new empty config in AppData")
141183

142184
self.config_manager = ConfigManager(self.config_path)
143185

@@ -147,22 +189,22 @@ def __init__(self):
147189
self.mods_manager = ModsManager()
148190

149191
self.active_websockets: List[WebSocket] = []
150-
self.loop = asyncio.get_running_loop()
151-
self.selected_server_id = None
192+
self.loop: Optional[asyncio.AbstractEventLoop] = None
193+
self.selected_server_id: Optional[str] = None
152194

153195
# Log broadcasting control (prevents WS flood from starving the API)
154-
self._log_queue: asyncio.Queue = asyncio.Queue(maxsize=2000)
196+
self._log_queue: Optional[asyncio.Queue] = None
155197
self._log_broadcaster_task: Optional[asyncio.Task] = None
156198

157199
# Tunnel management
158-
self.tunnel_process = None
159-
self.tunnel_address = None
200+
self.tunnel_process: Optional[subprocess.Popen] = None
201+
self.tunnel_address: Optional[str] = None
160202

161203
# Install Progress Tracking
162-
self.install_progress = 0
163-
self.install_status_msg = ""
164-
self.install_error = None
165-
self.installed_server_id = None
204+
self.install_progress: int = 0
205+
self.install_status_msg: str = ""
206+
self.install_error: Optional[str] = None
207+
self.installed_server_id: Optional[str] = None
166208

167209
self.world_size_cache = {}
168210
self.world_size_inflight = set()
@@ -175,11 +217,15 @@ def __init__(self):
175217
self.app_log_history = collections.deque(maxlen=500)
176218

177219
def start_background_tasks(self):
220+
if self._log_queue is None:
221+
self._log_queue = asyncio.Queue(maxsize=2000)
178222
if self._log_broadcaster_task is None:
179223
self._log_broadcaster_task = asyncio.create_task(self._log_broadcaster())
180224

181225
def _enqueue_log_from_loop(self, msg_obj: dict):
182226
"""Must be called from the asyncio loop thread."""
227+
if self._log_queue is None:
228+
return
183229
try:
184230
if self._log_queue.full() and msg_obj.get("level") in ("normal", "info"):
185231
return
@@ -195,10 +241,8 @@ def _enqueue_log_from_loop(self, msg_obj: dict):
195241
return
196242

197243
async def _log_broadcaster(self):
198-
"""Batches log messages and streams them to WebSocket clients.
199-
200-
This avoids scheduling thousands of per-line tasks during server startup.
201-
"""
244+
if self._log_queue is None:
245+
return
202246
while True:
203247
msg = await self._log_queue.get()
204248

@@ -349,10 +393,13 @@ def broadcast_log_sync(self, message, level="normal", server_id=None):
349393
# deque auto-evicts oldest when full — no manual pop needed
350394

351395
# Thread-safe enqueue into the asyncio queue (only non-verbose logs)
352-
try:
353-
self.loop.call_soon_threadsafe(self._enqueue_log_from_loop, msg_obj)
354-
except Exception:
355-
return
396+
if self.loop:
397+
try:
398+
self.loop.call_soon_threadsafe(
399+
self._enqueue_log_from_loop, msg_obj
400+
)
401+
except Exception:
402+
return
356403
except Exception as e:
357404
print(f"Error logging: {e}")
358405

@@ -371,14 +418,8 @@ async def broadcast_log(self, message: dict):
371418
pass
372419

373420

374-
state: Optional[AppState] = None
375-
376-
377-
@app.on_event("startup")
378-
async def startup_event():
379-
global state
380-
state = AppState()
381-
state.start_background_tasks()
421+
state: AppState = AppState()
422+
logging.info(f"Global state initialized. Config path: {state.config_path}")
382423

383424

384425
# --- Models ---
@@ -757,6 +798,7 @@ def install_server(req: InstallRequest):
757798
install_path = os.path.join(req.parent_path, req.folder_name)
758799

759800
def run_install():
801+
logging.info("Installation thread started")
760802
try:
761803
# Helper to send structured progress
762804
def send_progress(pct, msg, **kwargs):
@@ -911,6 +953,7 @@ def forge_progress(p):
911953
)
912954

913955
except Exception as e:
956+
logging.exception(f"Installation failed: {e}")
914957
state.install_error = str(e)
915958
state.broadcast_log_sync(f"Installation failed: {e}", "error")
916959
state.broadcast_log_sync({"type": "progress", "value": 0, "error": str(e)})

0 commit comments

Comments
 (0)