@@ -161,12 +161,10 @@ class Bridge:
161161 }
162162
163163 async def get(self, box_id):
164- box = self._boxes.get(box_id)
165- if box is None:
166- raise ValueError(f"Box not found: {box_id}")
164+ box = await self._get_box_async(box_id)
167165 return {
168166 "id": box_id,
169- "status": str(getattr( box, "status", "running") ),
167+ "status": self._box_status( box),
170168 "image": getattr(box, "image", "unknown"),
171169 "cpu": getattr(box, "cpu", 1),
172170 "memory_mb": getattr(box, "memory_mb", 512),
@@ -179,7 +177,7 @@ class Bridge:
179177 for box_id, box in self._boxes.items():
180178 results.append({
181179 "id": box_id,
182- "status": str(getattr( box, "status", "running") ),
180+ "status": self._box_status( box),
183181 "image": getattr(box, "image", "unknown"),
184182 "cpu": getattr(box, "cpu", 1),
185183 "memory_mb": getattr(box, "memory_mb", 512),
@@ -189,9 +187,7 @@ class Bridge:
189187 return results
190188
191189 async def exec_cmd(self, box_id, cmd, **kwargs):
192- box = self._boxes.get(box_id)
193- if box is None:
194- raise ValueError(f"Box not found: {box_id}")
190+ box = await self._get_box_async(box_id)
195191
196192 result = None
197193 errors = []
@@ -276,10 +272,28 @@ class Bridge:
276272 "exit_code": exit_code,
277273 }
278274
279- async def destroy(self, box_id):
275+ async def destroy(self, box_id, force=False ):
280276 box = self._boxes.pop(box_id, None)
281277 sb = self._simple_boxes.pop(box_id, None)
282278
279+ await self._ensure_runtime()
280+ rt = getattr(self, "_boxlite_rt", None)
281+ if rt is not None:
282+ for method_name in ("remove", "destroy", "delete"):
283+ fn = getattr(rt, method_name, None)
284+ if fn is None:
285+ continue
286+ try:
287+ try:
288+ await fn(box_id, force=force)
289+ except TypeError:
290+ await fn(box_id)
291+ return
292+ except Exception:
293+ if box is None and sb is None:
294+ raise
295+ break
296+
283297 if sb is not None:
284298 try:
285299 await sb.__aexit__(None, None, None)
@@ -302,13 +316,20 @@ class Bridge:
302316 await box.stop()
303317
304318 async def _refresh_box_handle(self, box_id, started=None):
319+ await self._ensure_runtime()
305320 rt = getattr(self, "_boxlite_rt", None)
306321 if rt is None:
307- return self._boxes.get(box_id)
322+ box = self._boxes.get(box_id)
323+ if box is None:
324+ raise ValueError(f"Box not found: {box_id}")
325+ return box
308326
309- fresh = await rt.get(box_id)
327+ try:
328+ fresh = await rt.get(box_id)
329+ except Exception as exc:
330+ raise ValueError(f"Box not found: {box_id}") from exc
310331 if fresh is None:
311- raise RuntimeError (f"Cannot get fresh handle for box {box_id}")
332+ raise ValueError (f"Box not found: {box_id}")
312333
313334 old_sb = self._simple_boxes.get(box_id)
314335 old_box = self._boxes.get(box_id)
@@ -323,18 +344,12 @@ class Bridge:
323344 return fresh
324345
325346 async def stop(self, box_id):
326- box = self._boxes.get(box_id)
327- if box is None:
328- raise ValueError(f"Box not found: {box_id}")
347+ box = await self._get_box_async(box_id)
329348 if hasattr(box, "stop"):
330349 await box.stop()
331350 await self._refresh_box_handle(box_id, False)
332351
333352 async def start(self, box_id):
334- box = self._boxes.get(box_id)
335- if box is None:
336- raise ValueError(f"Box not found: {box_id}")
337-
338353 fresh = await self._refresh_box_handle(box_id, False)
339354 if fresh and hasattr(fresh, "start"):
340355 await fresh.start()
@@ -347,6 +362,43 @@ class Bridge:
347362 if old_sb and hasattr(old_sb, "_started"):
348363 old_sb._started = True
349364
365+ def _normalize_status(self, value):
366+ status_attr = getattr(value, "status", None)
367+ if status_attr is not None and status_attr is not value:
368+ return self._normalize_status(status_attr)
369+ if isinstance(value, dict) and value.get("status") is not None:
370+ return self._normalize_status(value.get("status"))
371+
372+ text = str(value or "").lower()
373+ for state in ("configured", "stopping", "stopped", "paused", "running"):
374+ if state in text:
375+ return state
376+ return text or "unknown"
377+
378+ def _box_status(self, box):
379+ status = getattr(box, "status", None)
380+ if status is not None:
381+ return self._normalize_status(status)
382+
383+ inner = getattr(box, "_box", box)
384+ info_fn = getattr(inner, "info", None)
385+ if callable(info_fn):
386+ try:
387+ info = info_fn()
388+ state = getattr(info, "state", None)
389+ if state is not None:
390+ return self._normalize_status(state)
391+ except Exception:
392+ pass
393+
394+ return "unknown"
395+
396+ async def _get_box_async(self, box_id):
397+ box = self._boxes.get(box_id)
398+ if box is not None:
399+ return box
400+ return await self._refresh_box_handle(box_id)
401+
350402 def _get_box(self, box_id):
351403 box = self._boxes.get(box_id)
352404 if box is None:
@@ -355,11 +407,22 @@ class Bridge:
355407 inner = getattr(box, "_box", box)
356408 return inner
357409
410+ async def _get_fresh_box(self, box_id):
411+ try:
412+ fresh = await self._refresh_box_handle(box_id)
413+ if fresh is not None:
414+ return getattr(fresh, "_box", fresh)
415+ except Exception:
416+ if self._boxes.get(box_id) is None:
417+ raise
418+
419+ return self._get_box(box_id)
420+
358421 async def create_snapshot(self, box_id, name):
359422 # boxlite snapshot uses fork_qcow2 (rename + COW child). If QEMU is
360423 # running, its FD still points to the renamed inode, so post-snapshot
361424 # writes corrupt the snapshot file. Must stop → snapshot → restart.
362- inner = self._get_box (box_id)
425+ inner = await self._get_fresh_box (box_id)
363426 await inner.stop()
364427
365428 rt = getattr(self, "_boxlite_rt", None)
@@ -401,7 +464,7 @@ class Bridge:
401464 async def restore_snapshot(self, box_id, name):
402465 # Same stop → fresh handle pattern as create_snapshot.
403466 # stop() also invalidates the LiteBox handle (cancels shutdown_token).
404- inner = self._get_box (box_id)
467+ inner = await self._get_fresh_box (box_id)
405468 await inner.stop()
406469
407470 rt = getattr(self, "_boxlite_rt", None)
@@ -430,7 +493,7 @@ class Bridge:
430493 self._boxes[box_id] = fresh
431494
432495 async def list_snapshots(self, box_id):
433- inner = self._get_box (box_id)
496+ inner = await self._get_fresh_box (box_id)
434497 snap_handle = getattr(inner, "snapshot", None)
435498 if snap_handle is None:
436499 raise RuntimeError("Box does not support snapshots")
@@ -446,14 +509,14 @@ class Bridge:
446509 } for s in snapshots]
447510
448511 async def delete_snapshot(self, box_id, name):
449- inner = self._get_box (box_id)
512+ inner = await self._get_fresh_box (box_id)
450513 snap_handle = getattr(inner, "snapshot", None)
451514 if snap_handle is None:
452515 raise RuntimeError("Box does not support snapshots")
453516 await snap_handle.remove(name)
454517
455518 async def clone_box(self, box_id, name=None):
456- inner = self._get_box (box_id)
519+ inner = await self._get_fresh_box (box_id)
457520 from boxlite import CloneOptions
458521 cloned = await inner.clone_box(options=CloneOptions(), name=name)
459522 cloned_id = cloned.id
@@ -469,7 +532,15 @@ class Bridge:
469532 "created_at": "",
470533 }
471534
472- async def cleanup(self):
535+ async def cleanup(self, graceful=True):
536+ if graceful:
537+ # Process/bridge shutdown must not destroy tenant boxes. Running and
538+ # stopped boxes can be reacquired from disk by _get_box_async after
539+ # the next bridge starts.
540+ self._boxes.clear()
541+ self._simple_boxes.clear()
542+ return
543+
473544 for box_id in list(self._boxes.keys()):
474545 try:
475546 await self.destroy(box_id)
@@ -512,7 +583,7 @@ async def main():
512583 elif action == "exec":
513584 result = await bridge.exec_cmd(cmd["box_id"], cmd["cmd"])
514585 elif action == "destroy":
515- await bridge.destroy(cmd["box_id"])
586+ await bridge.destroy(cmd["box_id"], cmd.get("force", False) )
516587 result = {}
517588 elif action == "start":
518589 await bridge.start(cmd["box_id"])
@@ -721,8 +792,8 @@ export function createBoxLiteLocalClient(config: BoxLiteLocalConfig): BoxLiteCli
721792 return send < BoxLiteBox [ ] > ( { action : 'list' } )
722793 } ,
723794
724- async deleteBox ( boxId : string ) : Promise < void > {
725- await send ( { action : 'destroy' , box_id : boxId } )
795+ async deleteBox ( boxId : string , force ?: boolean ) : Promise < void > {
796+ await send ( { action : 'destroy' , box_id : boxId , force : force ?? false } )
726797 } ,
727798
728799 async startBox ( boxId : string ) : Promise < void > {
0 commit comments