diff --git a/.github/workflows/javascript-runtime.yml b/.github/workflows/javascript-runtime.yml new file mode 100644 index 00000000..c8cda2c0 --- /dev/null +++ b/.github/workflows/javascript-runtime.yml @@ -0,0 +1,51 @@ +name: JavaScript runtime compatibility + +on: + pull_request: + paths: + - '.github/workflows/javascript-runtime.yml' + - 'src/platform/marketdata/javascript_runtime.py' + - 'tests/test_marketdata_javascript_runtime.py' + - 'requirements.txt' + +permissions: + contents: read + +jobs: + release-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install PDF system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangocairo-1.0-0 libpangoft2-1.0-0 \ + libcairo2 libcairo-gobject2 libgdk-pixbuf-2.0-0 \ + libffi-dev libfontconfig1 fonts-noto-cjk + - name: Install release dependencies + run: python -m pip install -r requirements.txt + - name: Run backend release tests + run: python -m pytest tests/ -x -q + - name: Run marketdata package tests + run: python -m pytest packages/marketdata/tests -q + + native-contexts: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ['ubuntu-latest', 'ubuntu-24.04-arm'] + runtime: ['py-mini-racer==0.6.0', 'mini-racer==0.14.1'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install isolated runtime dependencies + run: python -m pip install pytest '${{ matrix.runtime }}' + - name: Test serial warmup and concurrent native contexts + run: python -m pytest --noconftest tests/test_marketdata_javascript_runtime.py -q diff --git a/src/platform/marketdata/javascript_runtime.py b/src/platform/marketdata/javascript_runtime.py index 7a6bd7c8..dfeb324a 100644 --- a/src/platform/marketdata/javascript_runtime.py +++ b/src/platform/marketdata/javascript_runtime.py @@ -21,7 +21,24 @@ def warmup_javascript_runtime() -> None: logger.info("MiniRacer 未安装,跳过行情 JavaScript 引擎预热") return # init_mini_racer alone does not exercise lazy first-isolate setup. - with MiniRacer() as context: + # AkShare installs py-mini-racer 0.6.0, whose contexts have neither + # __enter__ nor close(). The maintained mini-racer package provides + # close(); release it explicitly while retaining legacy GC cleanup. + try: + context = MiniRacer() + except RuntimeError as exc: + # The legacy source distribution has no native binary on ARM. + # Optional JS data sources must not prevent the server starting. + if str(exc).startswith("Native library not available at "): + logger.warning("MiniRacer 原生库不可用,跳过行情 JavaScript 引擎预热: %s", exc) + return + raise + try: context.eval("1 + 1") + finally: + close = getattr(context, "close", None) + if callable(close): + close() + del context _ready = True logger.info("行情 JavaScript 引擎预热完成") diff --git a/tests/test_marketdata_javascript_runtime.py b/tests/test_marketdata_javascript_runtime.py index a949892f..bdedd0cc 100644 --- a/tests/test_marketdata_javascript_runtime.py +++ b/tests/test_marketdata_javascript_runtime.py @@ -3,7 +3,7 @@ import subprocess import sys from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock import pytest @@ -17,8 +17,39 @@ def test_warmup_initializes_and_closes_one_context(monkeypatch): javascript_runtime.warmup_javascript_runtime() javascript_runtime.warmup_javascript_runtime() factory.assert_called_once_with() - factory.return_value.__enter__.return_value.eval.assert_called_once_with("1 + 1") - factory.return_value.__exit__.assert_called_once() + factory.return_value.eval.assert_called_once_with("1 + 1") + factory.return_value.close.assert_called_once_with() + factory.return_value.__enter__.assert_not_called() + + +def test_warmup_supports_legacy_context_without_context_manager_or_close(monkeypatch): + context = SimpleNamespace(eval=Mock(return_value=2)) + factory = Mock(return_value=context) + monkeypatch.setitem(sys.modules, "py_mini_racer", SimpleNamespace(MiniRacer=factory)) + monkeypatch.setattr(javascript_runtime, "_ready", False) + javascript_runtime.warmup_javascript_runtime() + javascript_runtime.warmup_javascript_runtime() + factory.assert_called_once_with() + context.eval.assert_called_once_with("1 + 1") + assert javascript_runtime._ready is True + + +def test_warmup_closes_context_after_evaluation_failure(monkeypatch): + context = SimpleNamespace(eval=Mock(side_effect=RuntimeError("evaluation failed")), close=Mock()) + monkeypatch.setitem(sys.modules, "py_mini_racer", SimpleNamespace(MiniRacer=lambda: context)) + monkeypatch.setattr(javascript_runtime, "_ready", False) + with pytest.raises(RuntimeError, match="evaluation failed"): + javascript_runtime.warmup_javascript_runtime() + context.close.assert_called_once_with() + assert javascript_runtime._ready is False + + +def test_warmup_allows_legacy_distribution_without_native_binary(monkeypatch): + factory = Mock(side_effect=RuntimeError('Native library not available at /tmp/libmini_racer.glibc.so')) + monkeypatch.setitem(sys.modules, 'py_mini_racer', SimpleNamespace(MiniRacer=factory)) + monkeypatch.setattr(javascript_runtime, '_ready', False) + javascript_runtime.warmup_javascript_runtime() + assert javascript_runtime._ready is False def test_warmup_failure_is_not_marked_ready(monkeypatch): @@ -41,15 +72,25 @@ def test_fresh_process_warmup_then_concurrent_native_contexts(): pytest.importorskip("py_mini_racer") script = """ from concurrent.futures import ThreadPoolExecutor +import sys from py_mini_racer import MiniRacer -from src.platform.marketdata.javascript_runtime import warmup_javascript_runtime -warmup_javascript_runtime() +from src.platform.marketdata import javascript_runtime +javascript_runtime.warmup_javascript_runtime() +if not javascript_runtime._ready: + sys.exit(77) def evaluate(_): - with MiniRacer() as context: + context = MiniRacer() + try: return context.eval('1 + 1') + finally: + close = getattr(context, 'close', None) + if callable(close): + close() with ThreadPoolExecutor(max_workers=8) as pool: assert list(pool.map(evaluate, range(32))) == [2] * 32 """ for _ in range(3): result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + if result.returncode == 77: + pytest.skip('Installed optional MiniRacer distribution has no native binary on this platform') assert result.returncode == 0, result.stderr[-2000:]