Skip to content

Commit 189e937

Browse files
committed
fix(boxlite): recover boxes after bridge restart
1 parent 6a95ea1 commit 189e937

3 files changed

Lines changed: 147 additions & 31 deletions

File tree

packages/boxlite/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sandbank.dev/boxlite",
3-
"version": "0.6.0",
3+
"version": "0.6.1",
44
"description": "BoxLite bare-metal sandbox adapter for Sandbank",
55
"license": "MIT",
66
"type": "module",

packages/boxlite/src/local-client.ts

Lines changed: 99 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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> {

packages/boxlite/test/local-client.test.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,15 @@ describe('BoxLiteLocalClient', () => {
304304
const cmd = proc.sentCommands.find(c => c.action === 'destroy')
305305
expect(cmd!.box_id).toBe('box_123')
306306
})
307+
308+
it('should pass force to destroy action', async () => {
309+
const { client, proc } = await createReadyClient()
310+
proc.autoRespond({})
311+
312+
await client.deleteBox('box_123', true)
313+
314+
expect(proc.sentCommands.at(-1)).toMatchObject({ action: 'destroy', box_id: 'box_123', force: true })
315+
})
307316
})
308317

309318
describe('startBox', () => {
@@ -594,13 +603,49 @@ describe('BoxLiteLocalClient', () => {
594603
})
595604

596605
describe('embedded bridge lifecycle', () => {
597-
it('refreshes the handle after stop and before start', () => {
606+
it('reacquires box handles from the runtime after bridge restart', () => {
598607
const source = readFileSync(join(import.meta.dirname, '../src/local-client.ts'), 'utf8')
599608

600609
expect(source).toContain('async def _refresh_box_handle(self, box_id, started=None):')
601-
expect(source).toContain('await self._refresh_box_handle(box_id, False)')
610+
expect(source).toContain('await self._ensure_runtime()')
611+
expect(source).toContain('fresh = await rt.get(box_id)')
612+
expect(source).toContain('async def _get_box_async(self, box_id):')
613+
expect(source).toContain('return await self._refresh_box_handle(box_id)')
614+
expect(source).toContain('box = await self._get_box_async(box_id)')
615+
expect(source).not.toContain('async def get(self, box_id):\n box = self._boxes.get(box_id)')
616+
})
617+
618+
it('uses fresh handles for stopped box lifecycle operations', () => {
619+
const source = readFileSync(join(import.meta.dirname, '../src/local-client.ts'), 'utf8')
620+
621+
expect(source).toContain('def _box_status(self, box):')
622+
expect(source).toContain('status_attr = getattr(value, "status", None)')
623+
expect(source).toContain('info = info_fn()')
624+
expect(source).toContain('return self._normalize_status(state)')
625+
expect(source).toContain('async def _get_fresh_box(self, box_id):')
626+
expect(source).toContain('inner = await self._get_fresh_box(box_id)')
627+
expect(source).toContain('fresh = await self._refresh_box_handle(box_id, False)')
602628
expect(source).toContain('raise RuntimeError(f"Box has no start method: {box_id}")')
603629
expect(source).toContain('old_sb._started = True')
604630
})
631+
632+
it('does not destroy boxes during graceful bridge shutdown', () => {
633+
const source = readFileSync(join(import.meta.dirname, '../src/local-client.ts'), 'utf8')
634+
635+
expect(source).toContain('async def cleanup(self, graceful=True):')
636+
expect(source).toContain('self._boxes.clear()')
637+
expect(source).toContain('self._simple_boxes.clear()')
638+
expect(source).toContain('await bridge.cleanup()')
639+
})
640+
641+
it('removes reacquired boxes through the runtime on destroy', () => {
642+
const source = readFileSync(join(import.meta.dirname, '../src/local-client.ts'), 'utf8')
643+
644+
expect(source).toContain('async def destroy(self, box_id, force=False):')
645+
expect(source).toContain('await self._ensure_runtime()')
646+
expect(source).toContain('for method_name in ("remove", "destroy", "delete"):')
647+
expect(source).toContain('await fn(box_id, force=force)')
648+
expect(source).toContain('await bridge.destroy(cmd["box_id"], cmd.get("force", False))')
649+
})
605650
})
606651
})

0 commit comments

Comments
 (0)