Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,37 @@ For auto-generated release notes, see [GitHub Releases](https://github.com/DataV

(Empty — next-cycle work lands here.)

## [1.5.0] - 2026-05-13

Pyodide / Cloudflare Python Workers consumers can now import
``synth_panel.ensemble`` without dragging ``ThreadPoolExecutor`` into
the load chain. Boardroom (and any other Workers-style consumer that
adopts ``synthesize_panel``) is unblocked: the ensemble surface is
fully threadpool-free at load time, so transitively-bound ``.submit()``
calls can no longer deadlock the Worker runtime.

### Changed

- **Lazy threading imports across the ensemble load chain (sy-2wa).**
``synth_panel.orchestrator``, ``synth_panel.synthesis``, and
``synth_panel.perturbation`` no longer bind ``ThreadPoolExecutor`` /
``as_completed`` / ``threading`` at module top. The imports are
hoisted into the threaded entry points (``run_panel_parallel``,
``synthesize_panel_mapreduce``, ``generate_panel_variants_parallel``,
``WorkerRegistry.__init__``) so ``from synth_panel.ensemble import
synthesize_panel`` never touches ``concurrent.futures``. Boardroom's
22 s ``asyncio.wait_for`` fallback around ``synthesize_panel`` (PR #11)
can now be removed (or kept as defense-in-depth).

### Added

- **``tests/test_threadpool_lazy_import.py`` (sy-2wa).** CI test that
asserts ``concurrent.futures`` stays out of ``sys.modules`` after a
fresh ``synth_panel.ensemble`` load, runs the same import against a
poisoned ``concurrent.futures`` (any access raises), and pins the
``orchestrator`` / ``synthesis`` / ``perturbation`` module namespaces
as ``ThreadPoolExecutor``-free.

## [1.4.0] - 2026-05-12

OpenRouter cost actuals are now surfaced explicitly alongside the local
Expand Down
6 changes: 3 additions & 3 deletions site/.well-known/mcp/server-card.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
"name": "io.github.DataViking-Tech/synthpanel",
"title": "SynthPanel",
"description": "Run synthetic focus groups using AI personas. 12 MCP tools for single prompts, full panel runs, and v3 branching (adaptive) instruments across any LLM provider (Claude, OpenAI, Gemini, xAI).",
"version": "1.4.0",
"version": "1.5.0",
"websiteUrl": "https://synthpanel.dev",
"repository": {
"url": "https://github.com/DataViking-Tech/SynthPanel",
"source": "github"
},
"serverInfo": {
"name": "synthpanel",
"version": "1.4.0"
"version": "1.5.0"
},
"capabilities": {
"tools": { "listChanged": false },
Expand All @@ -23,7 +23,7 @@
"registryType": "pypi",
"registryBaseUrl": "https://pypi.org",
"identifier": "synthpanel",
"version": "1.4.0",
"version": "1.5.0",
"runtimeHint": "uvx",
"runtimeArguments": [
{ "type": "positional", "value": "synthpanel[mcp]" },
Expand Down
8 changes: 4 additions & 4 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@
"applicationCategory": "DeveloperApplication",
"applicationSubCategory": "Research Tool",
"operatingSystem": "Cross-platform",
"softwareVersion": "1.4.0",
"dateModified": "2026-05-12",
"softwareVersion": "1.5.0",
"dateModified": "2026-05-13",
"license": "https://opensource.org/licenses/MIT",
"codeRepository": "https://github.com/DataViking-Tech/SynthPanel",
"downloadUrl": "https://pypi.org/project/synthpanel/",
Expand Down Expand Up @@ -140,7 +140,7 @@
class="mb-4 inline-flex items-center gap-2 rounded-full border border-emerald-400/30 bg-emerald-400/5 px-3 py-1 text-xs font-medium text-emerald-300"
>
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400"></span>
v1.4.0 — public beta
v1.5.0 — public beta
</p>
<h1
class="bg-gradient-to-br from-white to-slate-400 bg-clip-text font-mono text-5xl font-bold tracking-tight text-transparent sm:text-6xl"
Expand Down Expand Up @@ -705,7 +705,7 @@ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
class="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 py-6 text-xs text-slate-500"
>
<span>
&copy; 2026 DataViking · MIT-licensed · v1.4.0 ·
&copy; 2026 DataViking · MIT-licensed · v1.5.0 ·
<a
href="https://github.com/DataViking-Tech/SynthPanel"
rel="noopener"
Expand Down
2 changes: 1 addition & 1 deletion site/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# synthpanel — Run synthetic focus groups with any LLM

v1.4.0 — public beta
v1.5.0 — public beta

# synthpanel

Expand Down
2 changes: 1 addition & 1 deletion src/synth_panel/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.4.0"
__version__ = "1.5.0"
24 changes: 21 additions & 3 deletions src/synth_panel/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,20 @@

import hashlib
import logging
import threading
import time as _time
import uuid
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Literal
from typing import TYPE_CHECKING, Any, Literal

if TYPE_CHECKING:
# sy-2wa: type-only import; lifted to a guarded block so the real
# ``threading`` module never lands in synth_panel.ensemble's load
# chain. The annotation below is stringified at runtime via
# ``from __future__ import annotations``.
import threading

from synth_panel.attachments import filter_attachments
from synth_panel.attachments.filter import count_strata
Expand Down Expand Up @@ -601,6 +606,12 @@ class WorkerRegistry:
"""

def __init__(self) -> None:
# sy-2wa: lazy threading import keeps synth_panel.ensemble loadable
# under pyodide (CF Python Workers). Bare `threading` is a no-op
# stub there; we still avoid binding it at module level so the
# whole load chain stays threadpool-free until a real run.
import threading

self._lock = threading.RLock()
self._workers: dict[str, Worker] = {}

Expand Down Expand Up @@ -1484,6 +1495,13 @@ def run_panel_parallel(
# always-on prefix caching still applies.
min_stratum_pop = _min_stratum_population(personas, questions)

# sy-2wa: lazy threading + concurrent.futures imports. Bound here so
# `from synth_panel.ensemble import synthesize_panel` never pulls
# ThreadPoolExecutor into the module's namespace under pyodide
# (CF Python Workers), where `.submit()` silently hangs.
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed

registry = WorkerRegistry()
effective_workers = max_workers or len(personas)
sentiment_cache: dict[str, str] = {}
Expand Down
5 changes: 4 additions & 1 deletion src/synth_panel/perturbation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from __future__ import annotations

import logging
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from enum import Enum
from typing import Any
Expand Down Expand Up @@ -328,6 +327,10 @@ def _gen(p: dict[str, Any]) -> VariantSet:
if workers <= 1 or len(personas) <= 1:
return [_gen(p) for p in personas]

# sy-2wa: lazy import keeps `synth_panel.ensemble` load chain
# threadpool-free for pyodide consumers (CF Python Workers).
from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(_gen, p) for p in personas]
return [f.result() for f in futures]
7 changes: 6 additions & 1 deletion src/synth_panel/synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import re
import sys
from collections.abc import Coroutine
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable

Expand Down Expand Up @@ -1361,6 +1360,12 @@ def _run_one_map(idx: int) -> tuple[int, SynthesisResult, dict[str, Any]]:
)
return idx, res, meta

# sy-2wa: lazy concurrent.futures import. Keeps `from synth_panel.ensemble
# import synthesize_panel` ThreadPoolExecutor-free for pyodide consumers
# (CF Python Workers), where `.submit()` silently hangs. Map-reduce
# is opt-in via STRATEGY_MAP_REDUCE and never reached on pyodide.
from concurrent.futures import ThreadPoolExecutor, as_completed

map_results: list[SynthesisResult | None] = [None] * n
map_meta: list[dict[str, Any] | None] = [None] * n
with ThreadPoolExecutor(max_workers=workers) as executor:
Expand Down
151 changes: 151 additions & 0 deletions tests/test_threadpool_lazy_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""sy-2wa: load-chain hygiene for pyodide consumers (CF Python Workers).

Under pyodide, ``concurrent.futures.ThreadPoolExecutor`` exists as a stub
but ``.submit()`` silently hangs. Binding it in the load chain of
``synth_panel.ensemble`` means any downstream code path that touches the
synthpanel namespace can reach a submit() that will deadlock the Worker.

These tests pin the contract:

1. ``from synth_panel.ensemble import synthesize_panel`` must not import
``concurrent.futures`` at any point in the load chain.
2. Under a pyodide-emulating sys.modules patch where importing
``concurrent.futures`` raises, the same import still succeeds — the
ensemble surface is fully threadpool-free at load time.
3. Module namespaces of ``orchestrator``, ``synthesis``, and
``perturbation`` must not bind ``ThreadPoolExecutor`` until the
threaded entry points are actually called.

We use subprocesses for the load-chain assertions because pytest collects
every test in one interpreter — by the time these tests run,
``synth_panel.*`` (and transitively ``concurrent.futures``) are already
in ``sys.modules``. A subprocess gives us a fresh interpreter so the
"never imported" claim is observable rather than inferred.
"""

from __future__ import annotations

import subprocess
import sys
import textwrap


def _run_in_subprocess(script: str) -> subprocess.CompletedProcess[str]:
"""Run ``script`` in a fresh interpreter and return the result.

Uses the same Python executable as the test runner so the editable
``synth_panel`` install is on the path. Stderr is captured so failures
surface in pytest output.
"""
return subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=False,
)


def test_ensemble_load_does_not_import_concurrent_futures() -> None:
"""Acceptance criterion 1 (sy-2wa): ``from synth_panel.ensemble import
synthesize_panel`` must not pull ``concurrent.futures`` into sys.modules.
"""
script = textwrap.dedent(
"""
import sys
from synth_panel.ensemble import synthesize_panel # noqa: F401

if "concurrent.futures" in sys.modules:
print("LEAK: concurrent.futures imported during ensemble load")
sys.exit(1)
if "concurrent" in sys.modules:
print("LEAK: concurrent (parent) imported during ensemble load")
sys.exit(1)
print("OK")
"""
)
result = _run_in_subprocess(script)
assert result.returncode == 0, (
f"synth_panel.ensemble load chain pulled in concurrent.futures.\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
assert "OK" in result.stdout


def test_ensemble_loads_with_poisoned_concurrent_futures() -> None:
"""Acceptance criterion 2 (sy-2wa): emulate pyodide by poisoning
``concurrent.futures`` so any access raises. ``synth_panel.ensemble``
must still load — proving the load chain truly never touches it.
"""
script = textwrap.dedent(
"""
import sys
import types

# Poison: any attribute lookup raises. ``from concurrent.futures
# import ThreadPoolExecutor`` runs __getattr__ on this module after
# the import system resolves the submodule from sys.modules.
poison = types.ModuleType("concurrent.futures")

def _poisoned_attr(name):
raise AssertionError(
f"sy-2wa regression: concurrent.futures.{name} accessed "
"during synth_panel.ensemble load"
)

poison.__getattr__ = _poisoned_attr
parent = types.ModuleType("concurrent")
parent.futures = poison
sys.modules["concurrent"] = parent
sys.modules["concurrent.futures"] = poison

from synth_panel.ensemble import synthesize_panel # noqa: F401
print("OK")
"""
)
result = _run_in_subprocess(script)
assert result.returncode == 0, (
f"synth_panel.ensemble failed to load under poisoned concurrent.futures.\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
assert "OK" in result.stdout


def test_threadpoolexecutor_not_bound_in_module_namespaces() -> None:
"""Acceptance criterion 3 (sy-2wa): the modules that *do* spawn
threadpools (orchestrator, synthesis, perturbation) must not bind
``ThreadPoolExecutor`` / ``as_completed`` at module top, even after
they've been loaded. Catches a regression where someone re-adds the
top-level import "for convenience".
"""
script = textwrap.dedent(
"""
import sys
from synth_panel.ensemble import synthesize_panel # noqa: F401
import synth_panel.orchestrator
import synth_panel.synthesis
import synth_panel.perturbation

leaks = []
for modname in (
"synth_panel.orchestrator",
"synth_panel.synthesis",
"synth_panel.perturbation",
):
mod = sys.modules[modname]
for sym in ("ThreadPoolExecutor", "as_completed"):
if hasattr(mod, sym):
leaks.append(f"{modname}.{sym}")

if leaks:
print("LEAKS:", leaks)
sys.exit(1)
print("OK")
"""
)
result = _run_in_subprocess(script)
assert result.returncode == 0, (
f"Threadpool symbols bound at module top — move imports inside "
f"threaded functions (sy-2wa).\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
assert "OK" in result.stdout
Loading