Skip to content

Commit c5b2f93

Browse files
committed
fix(sandbox): restore persistent sandboxes asynchronously
1 parent 7cc9ebb commit c5b2f93

6 files changed

Lines changed: 162 additions & 43 deletions

File tree

‎astrbot/core/computer/sandbox_manager.py‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -985,10 +985,7 @@ async def destroy_sandbox(self, session_id: str, sandbox_id: str) -> dict:
985985
finally:
986986
self.session_booter.pop(sandbox_id, None)
987987
self.clear_idle_state(sandbox_id)
988-
if record.get("retention_policy") == "persistent":
989-
self.registry.update_sandbox_status(sandbox_id, SandboxStatus.STOPPED)
990-
else:
991-
self.registry.delete_sandbox(sandbox_id)
988+
self.registry.delete_sandbox(sandbox_id)
992989
self.drop_boot_lock(sandbox_id)
993990
await self.save_registry_async()
994991

@@ -1121,7 +1118,14 @@ async def reconcile_on_startup(self) -> None:
11211118

11221119
await self.save_registry_async()
11231120

1124-
async def restore_persistent_sandboxes(self, context: Context) -> None:
1121+
async def restore_persistent_sandboxes(
1122+
self,
1123+
context: Context,
1124+
*,
1125+
per_sandbox_timeout: float | None = None,
1126+
) -> tuple[int, int]:
1127+
restored = 0
1128+
deleted = 0
11251129
for record in self.registry.list_sandboxes():
11261130
sandbox_id = record["sandbox_id"]
11271131
if not record.get("managed"):
@@ -1134,18 +1138,35 @@ async def restore_persistent_sandboxes(self, context: Context) -> None:
11341138
}:
11351139
continue
11361140
try:
1137-
await self._revive_persistent_booter_if_needed(
1138-
record,
1141+
restore_coro = self._revive_persistent_booter_if_needed(
1142+
record=record,
1143+
sandbox_id=sandbox_id,
1144+
session_id=str(record.get("owner_session_id") or "dashboard"),
1145+
context=context,
1146+
)
1147+
if per_sandbox_timeout is None:
1148+
await restore_coro
1149+
else:
1150+
await asyncio.wait_for(restore_coro, timeout=per_sandbox_timeout)
1151+
restored += 1
1152+
except asyncio.TimeoutError:
1153+
self.session_booter.pop(sandbox_id, None)
1154+
self.clear_idle_state(sandbox_id)
1155+
self.registry.delete_sandbox(sandbox_id)
1156+
self.drop_boot_lock(sandbox_id)
1157+
await self.save_registry_async()
1158+
deleted += 1
1159+
logger.warning(
1160+
"[Computer] Persistent sandbox restore timed out; removed stale record: %s",
11391161
sandbox_id,
1140-
str(record.get("owner_session_id") or "dashboard"),
1141-
context,
11421162
)
11431163
except Exception as exc:
11441164
logger.warning(
11451165
"[Computer] Failed to restore persistent sandbox %s: %s",
11461166
sandbox_id,
11471167
exc,
11481168
)
1169+
return restored, deleted
11491170

11501171
async def cleanup_managed_sandboxes(self) -> None:
11511172
for sandbox_id in list(self.pending_boot_tasks):

‎astrbot/core/core_lifecycle.py‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def __init__(self, log_broker: LogBroker, db: BaseDatabase) -> None:
6161
self.cron_manager: CronJobManager | None = None
6262
self.temp_dir_cleaner: TempDirCleaner | None = None
6363
self._default_chat_provider_warning_emitted = False
64+
self._persistent_restore_task: asyncio.Task | None = None
6465

6566
# 设置代理
6667
proxy_config = self.astrbot_config.get("http_proxy", "")
@@ -254,8 +255,9 @@ async def initialize(self) -> None:
254255
exc_info=True,
255256
)
256257

257-
await computer_client.sandbox_manager.restore_persistent_sandboxes(
258-
self.star_context
258+
self._persistent_restore_task = asyncio.create_task(
259+
self._restore_persistent_sandboxes_background(),
260+
name="persistent-sandbox-restore",
259261
)
260262

261263
# 根据配置实例化各个 Provider
@@ -292,6 +294,28 @@ async def initialize(self) -> None:
292294

293295
asyncio.create_task(update_llm_metadata())
294296

297+
async def _restore_persistent_sandboxes_background(self) -> None:
298+
try:
299+
restored, deleted = (
300+
await computer_client.sandbox_manager.restore_persistent_sandboxes(
301+
self.star_context,
302+
per_sandbox_timeout=30.0,
303+
)
304+
)
305+
logger.info(
306+
"Persistent sandbox restore finished: restored=%d deleted=%d",
307+
restored,
308+
deleted,
309+
)
310+
except asyncio.CancelledError:
311+
raise
312+
except Exception as e:
313+
logger.warning(
314+
"Persistent sandbox restore failed: %s",
315+
e,
316+
exc_info=True,
317+
)
318+
295319
def _load(self) -> None:
296320
"""加载事件总线和任务并初始化."""
297321
# 创建一个异步任务来执行事件总线的 dispatch() 方法
@@ -384,6 +408,15 @@ async def stop(self) -> None:
384408
if self.cron_manager:
385409
await self.cron_manager.shutdown()
386410

411+
persistent_restore_task = getattr(self, "_persistent_restore_task", None)
412+
if persistent_restore_task is not None:
413+
persistent_restore_task.cancel()
414+
try:
415+
await persistent_restore_task
416+
except asyncio.CancelledError:
417+
pass
418+
self._persistent_restore_task = None
419+
387420
try:
388421
await computer_client.cleanup_managed_sandboxes()
389422
except Exception as e:

‎astrbot/core/initial_loader.py‎

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,33 +25,35 @@ def __init__(self, db: BaseDatabase, log_broker: LogBroker) -> None:
2525

2626
async def start(self) -> None:
2727
core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db)
28-
29-
try:
30-
await core_lifecycle.initialize()
31-
except Exception as e:
32-
logger.critical(traceback.format_exc())
33-
logger.critical(f"😭 初始化 AstrBot 失败:{e} !!!")
34-
return
35-
36-
core_task = core_lifecycle.start()
37-
38-
webui_dir = self.webui_dir
39-
40-
self.dashboard_server = AstrBotDashboard(
41-
core_lifecycle,
42-
self.db,
43-
core_lifecycle.dashboard_shutdown_event,
44-
webui_dir,
45-
)
46-
47-
coro = self.dashboard_server.run()
48-
if coro:
49-
# 启动核心任务和仪表板服务器
50-
task = asyncio.gather(core_task, coro)
51-
else:
52-
task = core_task
5328
try:
54-
await task # 整个AstrBot在这里运行
29+
try:
30+
await core_lifecycle.initialize()
31+
except Exception as e:
32+
logger.critical(traceback.format_exc())
33+
logger.critical(f"😭 初始化 AstrBot 失败:{e} !!!")
34+
return
35+
36+
core_task = core_lifecycle.start()
37+
38+
webui_dir = self.webui_dir
39+
40+
self.dashboard_server = AstrBotDashboard(
41+
core_lifecycle,
42+
self.db,
43+
core_lifecycle.dashboard_shutdown_event,
44+
webui_dir,
45+
)
46+
47+
coro = self.dashboard_server.run()
48+
if coro:
49+
# 启动核心任务和仪表板服务器
50+
task = asyncio.gather(core_task, coro)
51+
else:
52+
task = core_task
53+
try:
54+
await task # 整个AstrBot在这里运行
55+
except asyncio.CancelledError:
56+
logger.info("🌈 正在关闭 AstrBot...")
5557
except asyncio.CancelledError:
5658
logger.info("🌈 正在关闭 AstrBot...")
5759
finally:

‎tests/unit/test_core_lifecycle.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ async def test_subagent_orchestrator_error_is_logged(
261261

262262
class TestAstrBotCoreLifecycleSandboxRestore:
263263
@pytest.mark.asyncio
264-
async def test_initialize_restores_persistent_sandboxes(
264+
async def test_initialize_restores_persistent_sandboxes_in_background(
265265
self, mock_log_broker, mock_db, mock_astrbot_config
266266
):
267267
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
@@ -286,7 +286,13 @@ async def test_initialize_restores_persistent_sandboxes(
286286
mock_pipeline_scheduler = MagicMock(initialize=AsyncMock())
287287
mock_astrbot_updator = MagicMock()
288288
mock_event_bus = MagicMock()
289-
restore_persistent = AsyncMock()
289+
restore_started = asyncio.Event()
290+
restore_finished = asyncio.Event()
291+
292+
async def restore_persistent(_context, **_kwargs):
293+
restore_started.set()
294+
await restore_finished.wait()
295+
return 1, 0
290296

291297
with (
292298
patch("astrbot.core.core_lifecycle.astrbot_config", mock_astrbot_config),
@@ -357,9 +363,14 @@ async def test_initialize_restores_persistent_sandboxes(
357363
restore_persistent,
358364
),
359365
):
360-
await lifecycle.initialize()
361-
362-
restore_persistent.assert_awaited_once_with(mock_star_context)
366+
init_task = asyncio.create_task(lifecycle.initialize())
367+
await asyncio.wait_for(restore_started.wait(), timeout=1)
368+
await asyncio.wait_for(init_task, timeout=1)
369+
assert not restore_finished.is_set()
370+
restore_finished.set()
371+
await asyncio.wait_for(asyncio.sleep(0), timeout=1)
372+
373+
assert restore_started.is_set()
363374

364375

365376
class TestAstrBotCoreLifecycleDefaultChatProviderWarning:

‎tests/unit/test_sandbox_computer_client.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ async def terminate_plugin(plugin):
588588
lifecycle.platform_manager.terminate = platform_terminate
589589
lifecycle.kb_manager.terminate = kb_terminate
590590
lifecycle.plugin_manager._terminate_plugin = terminate_plugin
591+
lifecycle._persistent_restore_task = None
591592

592593
await AstrBotCoreLifecycle.stop(lifecycle)
593594

‎tests/unit/test_sandbox_manager.py‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,24 @@ async def test_destroy_sandbox_cancels_deferred_boot_task(tmp_path):
333333
assert sandbox["sandbox_id"] not in manager.pending_boot_tasks
334334

335335

336+
@pytest.mark.asyncio
337+
async def test_destroy_persistent_sandbox_removes_record(tmp_path):
338+
manager, provider = _manager(tmp_path)
339+
created = await manager.create_sandbox(None, "session-a", "generic", "Named")
340+
manager.update_sandbox_config(
341+
created["sandbox_id"],
342+
idle_timeout=None,
343+
expires_at=None,
344+
retention_policy="persistent",
345+
)
346+
347+
destroyed = await manager.destroy_sandbox("session-a", created["sandbox_id"])
348+
349+
assert destroyed["sandbox_id"] == created["sandbox_id"]
350+
assert manager.registry.get_sandbox(created["sandbox_id"]) is None
351+
assert provider.destroyed[0][1] == created["sandbox_id"]
352+
353+
336354
@pytest.mark.asyncio
337355
async def test_create_sandbox_sets_current_sandbox_after_lease(tmp_path):
338356
manager, _provider = _manager(tmp_path)
@@ -673,7 +691,7 @@ async def test_manager_does_not_revive_destroyed_persistent_sandbox(tmp_path):
673691

674692
await manager.destroy_sandbox("session-a", created["sandbox_id"])
675693

676-
with pytest.raises(RuntimeError, match="has been destroyed"):
694+
with pytest.raises(RuntimeError, match="not found"):
677695
await manager.get_observer_booter_by_id(
678696
created["sandbox_id"],
679697
"dashboard",
@@ -923,6 +941,39 @@ async def test_manager_reconcile_on_startup_keeps_valid_persistent_records(
923941
assert len(provider.created) == 0
924942

925943

944+
@pytest.mark.asyncio
945+
async def test_manager_restore_persistent_sandboxes_times_out_and_deletes_record(
946+
tmp_path,
947+
):
948+
provider = FailingReconnectProvider()
949+
manager, _provider = _manager(tmp_path, provider)
950+
restore_started = asyncio.Event()
951+
952+
async def slow_create_booter(context, session_id, sandbox_id, config):
953+
restore_started.set()
954+
await asyncio.sleep(1)
955+
return await FakeProvider().create_booter(context, session_id, sandbox_id, config)
956+
957+
provider.create_booter = slow_create_booter
958+
manager.registry.upsert_sandbox(
959+
sandbox_id="generic-1",
960+
sandbox_name="Persistent",
961+
provider="generic",
962+
managed=True,
963+
created_by_astrbot=True,
964+
owner_user_id="session-a",
965+
owner_session_id="session-a",
966+
connect_info={"name": "Persistent"},
967+
status="running",
968+
retention_policy="persistent",
969+
)
970+
971+
await manager.restore_persistent_sandboxes(object(), per_sandbox_timeout=0.01)
972+
973+
assert restore_started.is_set()
974+
assert manager.registry.get_sandbox("generic-1") is None
975+
976+
926977
def test_manager_reconcile_on_startup_drops_temporary_records(tmp_path):
927978
manager, _provider = _manager(tmp_path)
928979
manager.registry.upsert_sandbox(

0 commit comments

Comments
 (0)