|
| 1 | +"""v1.108.71 — telemetry worker hardening (PRD F-T01 / F-T02). |
| 2 | +
|
| 3 | +The community-meter sender used to spawn a daemon thread at module import, |
| 4 | +unconditionally, regardless of the share_savings opt-out. That is an import-time |
| 5 | +background-service side effect (the shape package scanners flag) and it ran even |
| 6 | +for opted-out users. The worker is now started lazily on the first share, behind |
| 7 | +the existing opt-out gate, and the endpoint is overridable via env. |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +import subprocess |
| 13 | +import sys |
| 14 | +import threading |
| 15 | + |
| 16 | +import pytest |
| 17 | + |
| 18 | +from src.jcodemunch_mcp.storage import token_tracker as tt |
| 19 | + |
| 20 | + |
| 21 | +_WORKER_NAME = "jcodemunch-telemetry" |
| 22 | + |
| 23 | + |
| 24 | +def _worker_threads(): |
| 25 | + return [t for t in threading.enumerate() if t.name == _WORKER_NAME] |
| 26 | + |
| 27 | + |
| 28 | +class TestNoImportTimeThread: |
| 29 | + def test_fresh_import_starts_no_worker_thread(self): |
| 30 | + """A clean `import` must not spawn the telemetry thread (run in a fresh |
| 31 | + interpreter so prior in-process tests can't have started it).""" |
| 32 | + code = ( |
| 33 | + "import threading\n" |
| 34 | + "import jcodemunch_mcp.storage.token_tracker as t\n" |
| 35 | + "live = [x for x in threading.enumerate() if x.name == 'jcodemunch-telemetry']\n" |
| 36 | + "assert not live, 'worker thread started at import'\n" |
| 37 | + "assert t._telemetry_worker_started is False, 'started flag set at import'\n" |
| 38 | + "print('OK')\n" |
| 39 | + ) |
| 40 | + res = subprocess.run( |
| 41 | + [sys.executable, "-c", code], capture_output=True, text=True, timeout=60 |
| 42 | + ) |
| 43 | + assert res.returncode == 0, f"stderr={res.stderr}\nstdout={res.stdout}" |
| 44 | + assert "OK" in res.stdout |
| 45 | + |
| 46 | + |
| 47 | +class TestLazyStart: |
| 48 | + def test_ensure_starts_one_worker_and_is_idempotent(self): |
| 49 | + before = len(_worker_threads()) |
| 50 | + tt._ensure_telemetry_worker() |
| 51 | + after_first = _worker_threads() |
| 52 | + assert len(after_first) == before + 1 or before >= 1, "worker did not start" |
| 53 | + assert tt._telemetry_worker_started is True |
| 54 | + # Idempotent: a second call must not spawn another thread. |
| 55 | + tt._ensure_telemetry_worker() |
| 56 | + assert len(_worker_threads()) == len(after_first) |
| 57 | + |
| 58 | + |
| 59 | +class TestOptOutNeverStartsWorker: |
| 60 | + def test_opted_out_flush_does_not_touch_the_worker(self, monkeypatch, tmp_path): |
| 61 | + """With share_savings disabled, flush must not even reach the worker |
| 62 | + starter (the enqueue is gated upstream).""" |
| 63 | + called = {"ensure": False} |
| 64 | + monkeypatch.setattr( |
| 65 | + tt, "_ensure_telemetry_worker", |
| 66 | + lambda: called.__setitem__("ensure", True), |
| 67 | + ) |
| 68 | + monkeypatch.setattr( |
| 69 | + tt._config, "get", |
| 70 | + lambda key, default=None, repo=None: False if key == "share_savings" else default, |
| 71 | + ) |
| 72 | + st = tt._State() |
| 73 | + st.add(1000, str(tmp_path)) |
| 74 | + st.flush() |
| 75 | + assert called["ensure"] is False, "worker was started despite opt-out" |
| 76 | + |
| 77 | + |
| 78 | +class TestTelemetryUrlOverride: |
| 79 | + def test_default_when_env_unset(self, monkeypatch): |
| 80 | + monkeypatch.delenv("JCODEMUNCH_TELEMETRY_URL", raising=False) |
| 81 | + assert tt._telemetry_url() == tt._TELEMETRY_URL |
| 82 | + |
| 83 | + def test_env_override_wins(self, monkeypatch): |
| 84 | + monkeypatch.setenv("JCODEMUNCH_TELEMETRY_URL", "https://example.test/meter") |
| 85 | + assert tt._telemetry_url() == "https://example.test/meter" |
| 86 | + |
| 87 | + def test_empty_env_falls_back_to_default(self, monkeypatch): |
| 88 | + monkeypatch.setenv("JCODEMUNCH_TELEMETRY_URL", "") |
| 89 | + assert tt._telemetry_url() == tt._TELEMETRY_URL |
0 commit comments