Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8cce03c
chore(deps): dépendances backend en extras optionnels, versions rafra…
traoreera Sep 28, 2026
57eec28
fix(security): 3 échappements sandbox confirmés — exécution de code a…
traoreera Sep 28, 2026
589e6e9
docs(security): anonymise le nom d'utilisateur système dans le rapport
traoreera Sep 28, 2026
041eed7
fix(security): execution_mode par défaut passe de legacy à sandboxed
traoreera Sep 28, 2026
51fb834
docs(roadmap): spécification technique V3 — runtime natif distribué
traoreera Sep 28, 2026
6b7aec3
fix(security): 3 échappements sandbox confirmés — exécution de code a…
traoreera Sep 28, 2026
ca6b279
docs(roadmap): spécification technique V3 — runtime natif distribué (…
traoreera Sep 28, 2026
f8a2252
fix(sandbox): logs des plugins sandboxed invisibles (LOG_LEVEL figé +…
traoreera Sep 30, 2026
9bab559
fix(sandbox): _stderr_pump() plus robuste + niveaux de log respectés
traoreera Sep 30, 2026
3c9a1e1
fix(sandbox): 3 bugs supplémentaires trouvés en continuant l'audit du…
traoreera Sep 30, 2026
610f06e
fix(lifecycle): reload après boot — le container partagé n'est plus e…
traoreera Oct 1, 2026
76475e2
fix(scheduler): jobs libérés au unload après le boot + ids namespacés…
traoreera Oct 1, 2026
7046692
fix(lifecycle): ce que le plugin laisse derrière lui au unload/reload…
traoreera Oct 1, 2026
9ff7ff2
fix(runtime): routes de plugin retirées au unload/reload, instances E…
traoreera Oct 1, 2026
bc0819f
fix(sandbox): budget CPU par requête + recyclage du worker, gel du ev…
traoreera Oct 1, 2026
4b2e6ce
test(sandbox): test de recyclage du worker déterministe
traoreera Oct 1, 2026
4865573
chore(release): v2.7.0 sur la 2.6.8 — extras optionnels pour les dépe…
traoreera Oct 1, 2026
f04a73d
fix(sdk): import xcore sans SQLAlchemy — adaptateurs SQL résolus à la…
traoreera Oct 1, 2026
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
113 changes: 113 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Présentation

**xcore v2.5.3** — framework d'orchestration plugin-first construit sur FastAPI.
**xcore v2.7.0** — framework d'orchestration plugin-first construit sur FastAPI.
Charge, isole et gère des plugins modulaires dans un environnement sandboxé.

- **Language** : Python 3.12+
Expand Down Expand Up @@ -240,3 +240,10 @@ Le cache `.venv` est clé sur `poetry.lock` — un changement de dépendances in
- **Branche principale** : `main`. Ne pas confondre avec les branches de feature (`add-ephemeral`, etc.).
- **Seuil de coverage** : `fail_under = 80` dans `pyproject.toml` (`branch = true`). Le CI échoue en dessous. Patcher les imports locaux dans `boot()` au niveau du module source (`xcore.services.container.ServiceContainer`) et non au niveau `xcore`.
- **`asyncio_mode = auto`** dans `pyproject.toml` — pas besoin de `@pytest.mark.asyncio` sur chaque test async.
- **Tâches de fond d'un plugin** — `ctx.spawn_task()`, jamais `asyncio.create_task()` brut : seules les tâches suivies par le kernel sont annulées *et attendues* au unload/reload. Une tâche brute épingle l'ancienne génération du plugin (visible dans les logs : `plugin instance still referenced after unload`).
- **`self._services` d'un plugin est le dict partagé du `ServiceContainer`** (`LifecycleManager._services`) : n'y écrire que des services *exportés* par le plugin. `propagate_services()` ignore ce qui est identique à l'objet injecté (`_injected_services`) — ne jamais réintroduire un `update()` brut des services injectés (proxy de ramasse-miette, wrappers tenant) dans ce dict : ça casse le reload/load de tous les plugins.
- **`get_service()` des services noyau** — `PluginContext.get_service()` sert les services noyau (`PluginRegistry.is_core_service`) depuis `ctx.services` du plugin, pas depuis le registre (qui contient les objets bruts après le boot : le suivi des jobs et l'isolation tenant seraient contournés).
- **Plugins Ephemeral** — chaque instance poolée est un `LifecycleManager(..., pooled=True)` : espace de noms `sys.modules` propre, pas de désinscription du registre à l'unload.
- **Routes de plugin** — monter/retirer via `Xcore._mount_plugin_router` / `_unmount_plugin_router` (suivi des objets route) ; `app.routes` n'a pas de setter et les `_IncludedRouter` de FastAPI ≥ 0.14x n'ont pas de `.path`.
- **Budget CPU du sandbox** — `RLIMIT_CPU` est cumulatif : limite souple réarmée par requête dans `worker.py` (`_arm_cpu_budget`), limite dure = plafond de vie du worker (`max_cpu_lifetime_seconds`), recyclage propre via `RECYCLE_EXIT_CODE`. Ne pas reposer une limite unique au démarrage.
- **Tests d'intégration du reload** — utiliser un vrai `PluginRegistry` + `ServiceContainer` (+ `register_core_service` pour simuler « après boot »), pas des `MagicMock` : les mocks avaient masqué ces bugs.
113 changes: 113 additions & 0 deletions doc/changelog.md

Large diffs are not rendered by default.

27 changes: 25 additions & 2 deletions doc/plugins/trusted-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ class Plugin(TrustedBase):
| `get_service(name)` | Returns a service from the container. Supports literal overloads for IDE typing. |
| `get_service_as(name, type)` | Returns a service cast to a specific type (e.g., `AsyncSQLAdapter`). |
| `call_plugin(name, action, payload)` | IPC helper to call another plugin from within the Trusted environment. |
| `ctx.spawn_task(coro, name=None)` | Creates a background task tracked by the kernel: it is cancelled **and awaited** when the plugin is unloaded or reloaded. Use it instead of a bare `asyncio.create_task()`. |

---

Expand All @@ -156,8 +157,30 @@ entry_point: "src/main.py"
**Fix**: Prefix your service names (e.g., `myplugin_db`) or use the `PluginRegistry` to set them as private.

!!! warning "Synchronous Blocking"
Trusted plugins run in the main event loop. If you perform blocking I/O (like `time.sleep()` or synchronous requests) inside a hook or `handle`, you will freeze the entire application.
**Fix**: Always use `async`/`await` or `run_in_executor`.
Trusted plugins run in the main event loop, in a single thread. Anything your code does *between two `await`s* — CPU work, `time.sleep()`, a synchronous HTTP or database call — freezes the **entire application**, and the Python GIL means threads do not change that for CPU-bound work.
**Fix**: use `async`/`await`; for blocking I/O use `asyncio.to_thread()`; for heavy CPU work use `execution_mode: sandboxed` (one process per plugin, outside the main loop and its GIL).

!!! warning "`timeout_seconds` cannot interrupt synchronous code"
The `resources.timeout_seconds` limit is enforced with `asyncio.wait_for`, which can only act at an `await`. A `handle()` that burns 1 s of CPU without awaiting and has `timeout_seconds: 0.2` still returns normally after 1 s.
Xcore now **reports** it instead: a synchronous step longer than `plugins.loop_block_warn_ms` (default `250`, `0` disables) logs `plugin blocked the event loop` with the plugin and action, rate-limited to one warning per plugin every 10 s, and `status()` exposes `max_loop_block_ms`. The same warning exists for scheduler jobs (`scheduler job blocked the event loop`) and for synchronous hooks that exceed their timeout (their worker thread cannot be interrupted and keeps running).

---

### Reload, Unload and Memory

When a plugin is unloaded or reloaded, the kernel releases what it can track: scheduler jobs, health checks, event/hook subscriptions, services you exported, your router and middlewares, and tasks created with `ctx.spawn_task()`. A plugin instance lives in reference cycles, so Xcore also schedules a garbage collection shortly afterwards (`plugins.gc_after_unload`, default `true`) and checks that the old instance is really gone.

If you see `plugin instance still referenced after unload` in the logs, something outside the plugin context still holds it — typically:

- a task started with a bare `asyncio.create_task()` (use `ctx.spawn_task()`),
- a callback or bound method registered on a global object you imported yourself,
- a service object stored somewhere that outlives the plugin.

!!! note "Scheduler job ids are namespaced"
Jobs you register are stored as `<plugin>:<job_id>` so two plugins can both have a `cleanup` job. Inside your plugin you keep using your own id (`remove_job("cleanup")`).

!!! warning "Reload only affects one worker process"
With several server workers (`uvicorn --workers N`, `xcli manager start --workers N`) each worker process loads its own copy of every plugin. A `POST /plugins/<name>/reload` is handled by **one** worker only; the others keep running the old code until restarted. There is no cross-worker reload broadcast yet: restart the workers (or reload through each of them) to roll out a new plugin version.

---

Expand Down
3 changes: 3 additions & 0 deletions doc/sdk/api/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ from xcore.sdk import (
)
```

!!! note "SQLAlchemy is an optional dependency"
Since 2.7.0 `BaseAsyncRepository` and `BaseSyncRepository` need SQLAlchemy, which is no longer installed with a plain `pip install XCoreRuntime`. Install the extra that matches your database — `XCoreRuntime[postgres]`, `XCoreRuntime[sqlite]` or `XCoreRuntime[db]` (both). Without it, importing these two names raises an `ImportError` that tells you so; the rest of `xcore.sdk` works unchanged.

---

## BaseAsyncRepository
Expand Down
Loading
Loading