Skip to content

Commit bf75d7a

Browse files
authored
Merge pull request #719 from zouyonghe/fix/validator-worker-isolation-default-workers
isolate validator worker installs and raise default concurrency
2 parents 6147ad4 + 05e874b commit bf75d7a

3 files changed

Lines changed: 164 additions & 5 deletions

File tree

.github/workflows/validate-plugin-smoke.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ on:
2525
max_workers:
2626
description: "Maximum concurrent plugin validations"
2727
required: false
28-
default: "8"
28+
default: "16"
2929

3030
jobs:
3131
validate-plugin-smoke:
@@ -52,7 +52,7 @@ jobs:
5252
echo "ASTRBOT_REF=master" >> "$GITHUB_ENV"
5353
echo "PLUGIN_NAME_LIST=" >> "$GITHUB_ENV"
5454
echo "PLUGIN_LIMIT=" >> "$GITHUB_ENV"
55-
echo "MAX_WORKERS=8" >> "$GITHUB_ENV"
55+
echo "MAX_WORKERS=16" >> "$GITHUB_ENV"
5656
echo "SHOULD_VALIDATE=true" >> "$GITHUB_ENV"
5757
echo "VALIDATION_NOTE=Running scheduled full plugin validation." >> "$GITHUB_ENV"
5858

scripts/validate_plugins/run.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
REQUIRED_METADATA_FIELDS = ("name", "desc", "version", "author")
3232
DEFAULT_CLONE_TIMEOUT = 120
33-
DEFAULT_MAX_WORKERS = 8
33+
DEFAULT_MAX_WORKERS = 16
3434
CONFLICT_MARKERS = ("<<<<<<<", "=======", ">>>>>>>")
3535

3636

@@ -282,6 +282,38 @@ def build_worker_sys_path(*, astrbot_root: Path, astrbot_path: Path) -> list[str
282282
return [str(astrbot_root.resolve()), str(astrbot_path.resolve())]
283283

284284

285+
def normalize_path_for_comparison(path: str | os.PathLike[str]) -> str:
286+
path_str = os.fspath(path)
287+
return os.path.normcase(os.path.realpath(os.path.abspath(os.path.expanduser(path_str))))
288+
289+
290+
def configure_worker_install_target(*, temp_root: Path) -> Path:
291+
"""Configure process-global install/import state for a validator worker.
292+
293+
This mutates ``os.environ`` and ``sys.path`` for the lifetime of the worker
294+
process so plugin dependency installs stay isolated under ``temp_root``.
295+
"""
296+
297+
site_packages = (temp_root / "site-packages").resolve()
298+
site_packages.mkdir(parents=True, exist_ok=True)
299+
site_packages_str = str(site_packages)
300+
site_packages_key = normalize_path_for_comparison(site_packages_str)
301+
302+
os.environ["PIP_TARGET"] = site_packages_str
303+
existing_pythonpath = [
304+
entry
305+
for entry in os.environ.get("PYTHONPATH", "").split(os.pathsep)
306+
if entry and normalize_path_for_comparison(entry) != site_packages_key
307+
]
308+
os.environ["PYTHONPATH"] = os.pathsep.join([site_packages_str, *existing_pythonpath])
309+
310+
sys.path[:] = [
311+
entry for entry in sys.path if normalize_path_for_comparison(entry) != site_packages_key
312+
]
313+
sys.path.insert(0, site_packages_str)
314+
return site_packages
315+
316+
285317
def build_report(results: list[dict]) -> dict:
286318
passed = sum(1 for result in results if result.get("severity") == "pass")
287319
warned = sum(1 for result in results if result.get("severity") == "warn")
@@ -700,6 +732,8 @@ async def run_worker_load_check(plugin_dir_name: str, normalized_repo_url: str)
700732
def run_worker(args: argparse.Namespace) -> int:
701733
temp_root = Path(tempfile.mkdtemp(prefix="astrbot-plugin-worker-"))
702734
try:
735+
configure_worker_install_target(temp_root=temp_root)
736+
703737
astrbot_root = temp_root / "astrbot-root"
704738
plugin_store = astrbot_root / "data" / "plugins"
705739
plugin_config = astrbot_root / "data" / "config"

tests/test_validate_plugins.py

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,12 +386,12 @@ def test_load_plugins_index_rejects_non_dict_values(self):
386386

387387

388388
class ValidationProgressTests(unittest.TestCase):
389-
def test_build_parser_defaults_max_workers_to_eight(self):
389+
def test_build_parser_defaults_max_workers_to_sixteen(self):
390390
module = load_validator_module()
391391

392392
args = module.build_parser().parse_args(["--astrbot-path", "/tmp/AstrBot"])
393393

394-
self.assertEqual(args.max_workers, 8)
394+
self.assertEqual(args.max_workers, 16)
395395

396396
def test_build_parser_rejects_non_positive_worker_and_timeout_values(self):
397397
module = load_validator_module()
@@ -748,6 +748,131 @@ async def load(self, specified_dir_name: str):
748748
self.assertEqual(result["message"], "{'reason': 'boom'}")
749749

750750

751+
class RunWorkerIsolationTests(unittest.TestCase):
752+
def _assert_worker_isolated(self, *, temp_root: Path, args, observed: dict) -> None:
753+
self.assertIn("astrbot_root", observed)
754+
astrbot_root = Path(observed["astrbot_root"]).resolve()
755+
shared_astrbot_path = Path(args.astrbot_path).resolve()
756+
757+
self.assertNotEqual(astrbot_root, shared_astrbot_path)
758+
self.assertTrue(
759+
astrbot_root.is_relative_to(temp_root.resolve()),
760+
f"worker astrbot_root {astrbot_root} should be under {temp_root}",
761+
)
762+
763+
current = astrbot_root
764+
found_worker_prefix = False
765+
while True:
766+
if current.name.startswith("astrbot-plugin-worker-"):
767+
found_worker_prefix = True
768+
break
769+
if current.parent == current:
770+
break
771+
current = current.parent
772+
773+
self.assertTrue(found_worker_prefix)
774+
self.assertTrue((astrbot_root / "data" / "plugins").is_dir())
775+
self.assertTrue((astrbot_root / "data" / "config").is_dir())
776+
777+
def test_configure_worker_install_target_deduplicates_process_paths(self):
778+
module = load_validator_module()
779+
original_sys_path = list(sys.path)
780+
781+
with tempfile.TemporaryDirectory() as tmp_dir:
782+
temp_root = Path(tmp_dir)
783+
site_packages = (temp_root / "site-packages").resolve()
784+
site_packages_alias = os.path.join(str(site_packages.parent), ".", site_packages.name)
785+
extra_path = (temp_root / "extra-path").resolve()
786+
extra_path.mkdir()
787+
observed = {}
788+
789+
sys.path[:0] = [str(site_packages), site_packages_alias]
790+
with mock.patch.dict(
791+
os.environ,
792+
{"PYTHONPATH": os.pathsep.join([site_packages_alias, str(extra_path)])},
793+
clear=True,
794+
):
795+
module.configure_worker_install_target(temp_root=temp_root)
796+
module.configure_worker_install_target(temp_root=temp_root)
797+
798+
observed["pip_target"] = os.environ["PIP_TARGET"]
799+
observed["pythonpath_entries"] = os.environ["PYTHONPATH"].split(os.pathsep)
800+
observed["resolved_pythonpath_count"] = sum(
801+
1
802+
for entry in observed["pythonpath_entries"]
803+
if Path(entry).resolve() == site_packages
804+
)
805+
observed["resolved_sys_path_count"] = sum(
806+
1 for entry in sys.path if Path(entry).resolve() == site_packages
807+
)
808+
809+
self.assertEqual(observed["pip_target"], str(site_packages))
810+
self.assertEqual(observed["resolved_pythonpath_count"], 1)
811+
self.assertEqual(observed["resolved_sys_path_count"], 1)
812+
self.assertIn(str(extra_path), observed["pythonpath_entries"])
813+
814+
sys.path[:] = original_sys_path
815+
816+
def test_run_worker_isolates_plugin_installs_under_temp_root(self):
817+
module = load_validator_module()
818+
observed = {}
819+
original_sys_path = list(sys.path)
820+
821+
async def fake_run_worker_load_check(plugin_dir_name: str, normalized_repo_url: str):
822+
observed["plugin_dir_name"] = plugin_dir_name
823+
observed["normalized_repo_url"] = normalized_repo_url
824+
observed["astrbot_root"] = os.environ.get("ASTRBOT_ROOT")
825+
observed["pip_target"] = os.environ.get("PIP_TARGET")
826+
observed["sys_path"] = list(sys.path)
827+
return module.build_result(
828+
plugin=plugin_dir_name,
829+
repo=normalized_repo_url,
830+
normalized_repo_url=normalized_repo_url,
831+
ok=True,
832+
stage="load",
833+
message="plugin loaded successfully",
834+
plugin_dir_name=plugin_dir_name,
835+
)
836+
837+
with tempfile.TemporaryDirectory() as tmp_dir:
838+
worker_temp_root = Path(tmp_dir) / "astrbot-plugin-worker-test-root"
839+
worker_temp_root.mkdir()
840+
plugin_source_dir = Path(tmp_dir) / "plugin-src"
841+
plugin_source_dir.mkdir()
842+
(plugin_source_dir / "main.py").write_text("print('hello')\n", encoding="utf-8")
843+
args = module.argparse.Namespace(
844+
astrbot_path="/tmp/AstrBot",
845+
plugin_source_dir=str(plugin_source_dir),
846+
plugin_dir_name="demo_plugin",
847+
normalized_repo_url="https://github.com/example/demo-plugin",
848+
)
849+
850+
with mock.patch.dict(os.environ, {}, clear=True):
851+
with mock.patch.object(module, "run_worker_load_check", side_effect=fake_run_worker_load_check):
852+
with mock.patch.object(module.tempfile, "mkdtemp", return_value=str(worker_temp_root)):
853+
with mock.patch.object(module.shutil, "rmtree") as rmtree_mock:
854+
exit_code = module.run_worker(args)
855+
856+
self._assert_worker_isolated(temp_root=worker_temp_root, args=args, observed=observed)
857+
rmtree_mock.assert_called_once_with(worker_temp_root, ignore_errors=True)
858+
859+
sys.path[:] = original_sys_path
860+
861+
self.assertEqual(exit_code, 0)
862+
self.assertEqual(observed["plugin_dir_name"], "demo_plugin")
863+
self.assertEqual(
864+
observed["normalized_repo_url"],
865+
"https://github.com/example/demo-plugin",
866+
)
867+
self.assertIsNotNone(observed["astrbot_root"])
868+
self.assertIsNotNone(observed["pip_target"])
869+
self.assertEqual(
870+
Path(observed["pip_target"]).resolve(),
871+
(Path(observed["astrbot_root"]).parent / "site-packages").resolve(),
872+
)
873+
self.assertIn(observed["pip_target"], observed["sys_path"])
874+
875+
751876
class ReportBuilderTests(unittest.TestCase):
752877
def test_build_report_counts_passed_warned_and_failed_results(self):
753878
module = load_validator_module()

0 commit comments

Comments
 (0)