Free-Threaded Prolog — janus-swi under Python 3.14t
STATUS: PRODUCTION (graduated 2026-07-18, session 242). This is not a proof-of-concept. The patched fork is the installed, default, fail-fast Prolog query transport of orchestrata-ultimate:
uv syncbuilds and installs it fromscripts/_janus_patched([tool.uv.sources]),CodebaseToolsuses it as THE transport and raises if janus is missing or the GIL is on (no fallback, user ruling), the release wheel is hosted on GitLab (janus-swi-ft 1.5.3-orchestrata.1, installable anywhere viainstall_janus_ft.sh --from-gitlab), and the full 26,859-test authoritative suite runs green through it. Reverting to stock janus is FORBIDDEN.
Python: 3.14.6 free-threading build (python3.14t, Py_GIL_DISABLED, GIL genuinely off — no PYTHON_GIL=0)
SWI-Prolog: 10.x (libswipl.so.10)
janus-swi: 1.5.3, vendored + patched at scripts/_janus_patched/
Goal
Run the governance / tabling Prolog paths under Python 3.14t with the GIL
genuinely off (not forced via PYTHON_GIL=0) and zero crashes, so the
project keeps its free-threaded parallelism instead of falling back to a
process-wide GIL every time janus_swi is imported.
An undeclared C extension re-enables the process-wide GIL the moment it is imported under a free-threaded interpreter. Stock janus-swi 1.5.3 does exactly that, and — once the GIL is off — additionally crashes on the tabling restraint path. Both had to be fixed at the C level in the extension itself.
The crash, visually
Before the individual bugs, the shape of the worst one (bug 2). Under free-threading there is no GIL to implicitly attach a thread state, so when Prolog calls back into Python on a thread that released its state, the Python allocator has no per-thread heap to work with:
The other two bugs are the quieter siblings of the same theme — state the GIL used to serialize implicitly (the module hashtable, the deferred-decref queue) suddenly shared without a lock. The sections below give each one its root cause, fix, and reproducer.
Three distinct bugs, all in the free-threaded (Py_GIL_DISABLED) paths
1. GIL re-enabled on import (module never declared FT-safe)
Single-phase PyModule_Create + PyUnstable_Module_SetGIL did not suppress
CPython's process-wide GIL re-enable. The fix is multi-phase init in
mod_swipl.c: PyInit__swipl returns PyModuleDef_Init(&swipl_module) with a
Py_mod_exec slot and, critically:
static PyModuleDef_Slot swipl_module_slots[] = {
{Py_mod_exec, swipl_module_exec},
{Py_mod_multiple_interpreters, Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED},
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
{0, NULL}
};
Now import janus_swi leaves the GIL off with no env var.
Build trap.
uv/setuptools build isolation compiles a GIL-re-enabling.so— the isolated build backend picks a non-free-threaded interpreter, soPy_GIL_DISABLEDis undefined and the multi-phase declaration compiles out. The package MUST be built--no-build-isolationagainst this project's interpreter. This is wired inpyproject.toml([tool.uv] no-build-isolation-package = ["janus-swi"]).
2. Missing PyThreadState on SWI→Python callbacks (deterministic SEGV)
The prior patch replaced PyGILState_Ensure() with a bare mutex lock in the
free-threaded py_gil_ensure. But PyGILState_Ensure() is not a no-op under
free-threading — it attaches a PyThreadState (and thus this thread's mimalloc
heap). swipl_next_solution runs PL_next_solution inside
Py_BEGIN_ALLOW_THREADS, which detaches the tstate; when SWI then calls back
into Python (py_call → py_eval → PyObject_GetAttrString → PyUnicode_FromString → _PyObject_MiMalloc) the thread has no tstate → NULL per-thread heap →
deterministic crash on the undefined-answer / restraint tabling path
(ownership_chain/3, max_answers(20), cyclic graph). AddressSanitizer
pinpointed _PyObject_MiMalloc dereferencing a NULL heap.
Fix (janus.c): the free-threaded py_gil_ensure/py_gil_release attach and
detach a real tstate exactly as the GIL build does for foreign-called threads:
static int py_gil_ensure(py_gil_state *state)
{ if ( !py_init() ) return FALSE;
state->gil = PyGILState_Ensure(); /* attaches tstate + mimalloc heap */
if ( delayed ) delayed_decref(NULL);
return TRUE; }
static void py_gil_release(py_gil_state state)
{ if ( delayed ) delayed_decref(NULL);
PyGILState_Release(state.gil); }
MyPy_DECREF gates on the real PyGILState_Check(). The prior global
janus_mutex was removed here (non-recursive → would deadlock the re-entrant
Prolog↔Python paths). Deterministic reproducer: 8/8 crash → 0/N.
3. Unprotected shared C state once the GIL was gone (rare race)
Removing the GIL exposed process-global C state the GIL used to serialize. An audit found two structural offenders:
py_module_table(custom lock-free hashtable): concurrent register/lookup/rehash from many SWI engine threads → torn chains / rehash use-after-free. This is why the governance suite (many modules consulted) crashed ~1/20 while a single-module reproducer never did. Fix: a recursivepthreadmutex (janus_data_lock) around every table access —get_py_modulecallspy_register_module, so both take it recursively.delayeddecref queue: an ABA hole in the Treiber-stack drain once multiple threads drain concurrently (a freed node address is reused → spurious CAS success → double free). Fix: a mutex-guarded drain/push underPy_GIL_DISABLED; the GIL build keeps the original single-drainer CAS.
Lazy-initialized singletons (enum_constructor, Fraction/Term ctors, the
janus module ref, pred/user, the hex borrowed ref) are hardened under the
same lock (SGLOCK/SGUNLOCK), plus an explicit Py_XINCREF(hex).
The -O0-masked-bug lesson
Bug #2 is timing-fragile: an unoptimised (-O0 / ASAN) build happens to leave a
usable tstate attached and does not crash, so an -O0 "validation" is a
false green. The bug only reliably reproduces under the interpreter's real
-O3 flags — which is exactly what a normal uv sync build uses. Always
validate the release (-O3) binary, i.e. the one uv sync actually installs,
never a hand-built debug .so.
Reproducible build
The fork is vendored (source only — scripts/_janus_patched/.gitignore excludes
build/, *.so, *.egg-info) and wired as a path source:
# pyproject.toml
[tool.uv]
no-build-isolation-package = ["janus-swi"]
[tool.uv.sources]
janus-swi = { path = "scripts/_janus_patched" }
setuptools/wheel are in the dev dependency group so the no-isolation build
has its backend. uv sync --dev builds the extension against this project's
free-threaded interpreter and installs it.
Local rebuild gotcha. setuptools does incremental builds: a stale
scripts/_janus_patched/build/from an earlier compile can relink a pre-patch object file and silently produce a crashing.so. A fresh clone is clean (the dir is git-ignored); when rebuilding in place after editing the C,rm -rf scripts/_janus_patched/buildfirst, oruv sync --reinstall-package janus-swi.
Verify
# GIL genuinely off, no env var:
env -u PYTHON_GIL .venv/bin/python -c \
"import sys, janus_swi; print('GIL enabled:', sys._is_gil_enabled())" # -> False
Scope: the whole hot path is now GIL-off (msgpack + grpcio RESOLVED)
This work makes janus GIL-safe: importing janus_swi (and the
prolog_adapter / governance Prolog path) leaves the GIL off, and the tabling
crash is gone. The two other hot-path C extensions that used to re-enable the
process-wide GIL have both since been fixed (this section supersedes the
earlier "msgpack is the next domino" note — see docs/free-threaded-prolog.md
in-repo for the authoritative version):
msgpack— versions<1.2.1built from sdist without a free-threading declaration;pyproject.tomlpinsmsgpack>=1.2.1, which declares FT-safe.grpcio— upstream ships no cp314t wheels and the plain sdist build re-enables the GIL on import; the project vendors an FT rebuild (scripts/build_ft_grpcio.py,# cython: freethreading_compatible).
Regression guard: tests/conftest.py imports both grpc and msgpack and
fails the suite if sys._is_gil_enabled() goes True — so a dependency
regression cannot silently re-enable the GIL.
GIL-flip census & the full extension fleet (session 251, 2026-07-21)
A full census (import every C-extension package in the venv in a fresh
python3.14t subprocess, check sys._is_gil_enabled()) found exactly three
remaining GIL-flippers: tree_sitter, tokenizers, httptools. They were
the root cause of order-dependent batch test failures: a co-batched test
importing one of them permanently enabled the GIL for that pytest
subprocess, and every janus-transport test dispatched after it failed its
free-threading assertion — passing solo, failing in batches.
Resolution, per offender:
- tree-sitter stack — FIXED (vendored FT wheels).
scripts/build_ft_tree_sitter.py(mirrorsbuild_ft_grpcio.py) builds cp314t wheels fortree-sitter0.26.0 + grammarspython/c/cpp/typescriptintovendor/wheels/, wired via[tool.uv.sources]and installed by the dev group. Upstream defects it patches: core 0.26.0 explicitly declaresPy_MOD_GIL_USED(flipped toNOT_USED); grammar sdists retag wheelscp310-abi3even on FT builds, hard-definePy_LIMITED_API(which doesn't exist on 3.14t), lack thePy_mod_gilslot (older templates), and are incomplete (missingscanner.c/src/tree_sitter/*.h— upstream's own wheels are CI-built from git so nobody noticed). All fetched files and sdists are sha256-pinned.scripts/code-review.py's deep-AST import checker now runs LIVE (its tree-sitter branch had also never worked — it searched for bare"."tokens the grammar never produces; rewritten as a whole-tree walk forrelative_importnodes). - tiktoken — FIXED (upstream).
>=0.13.0ships real cp314t wheels; now a dev dependency, somemory.token_counteruses real BPE counts instead of the silent word-count fallback. - httptools — AVOIDED (h11 pin). No FT build; uvicorn's
http="auto"imports it inside any process that starts a server. Every uvicorn launch site (gatewayhttp_surface, kernelcli_main,ha_controller_cli,graph_browser,ast_daemon) now pinshttp="h11"— a production correctness fix: the gateway/kernel run janus and must keep the GIL off. - tokenizers (via
sentence_transformers) — QUARANTINED. Rust/PyO3, no FT wheels as of 0.23.1.GIL_UNSAFE_MODULESincore/threading/test_executor.pygivestests/semantic_graph/ test_embedders.pyits own exclusive pytest subprocess so it can poison nobody. Runtime import stays lazy (only the local-embedder path loads it).
Verify the stack any time: python scripts/build_ft_tree_sitter.py --verify
(parses all five languages and asserts the GIL is still off).
Approaches explored and abandoned
Two throwaway exploration scripts lived under scripts/ during this work. Both
are recorded here and then deleted — their scratch code (path hacks,
sub-standard imports, no tests) does not belong in the tree, but the findings
do.
-
MQI subprocess (
scripts/poc_mqi_free_threaded.py) — REJECTED. Explored running Prolog out-of-process via SWI's Machine Query Interface (swiplserver/PrologMQI), so the GIL would be irrelevant (Prolog runs in a separate OS process). It worked functionally, but it was the wrong architecture: it adds a subprocess hop and a socket protocol to every query, abandons the in-processjanus/prolog_adapterintegration the whole codebase is built on, and depended on a vendoredswiplservercopy that was never committed. The in-process janus C patch (this document) fixes the actual root cause instead of routing around it, so MQI was dropped. -
Ad-hoc parallelism benchmark (
scripts/test_prolog_parallel.py) — SUPERSEDED. A quick sequential-vs-parallel harness overPrologGate(8 threads × 500 iters × 4 ops ≈ 4000 governance queries) used to confirm that janus already parallelizes natively (per-call per-thread engines, GIL released insidePL_next_solution). Its finding is real and is captured by the committed, standards-compliantscripts/benchmark_prolog_clips.pyandtests/orchestrator/test_prolog_worker_parallel.py, which replace it. The loose script (misnamedtest_,sys.pathhacks) was deleted.
Verification performed
import janus_swi→ GIL stays off with no env var.- Deterministic reproducer (cyclic
ownership_chain/3,max_answers(20)): 8/8 crash before → 0/N after, on the release (-O3)uv-built binary. - Full governance + tabling +
prolog_worker_parallel+prolog_adaptersuites under GIL-off, many consecutive runs, zero segfaults.
Release packaging & universal installer (session 242, 2026-07-18)
The patched fork is now a distributable release, not just an in-repo build:
scripts/build_janus_ft.py— builds a versioned wheel + sdist fromscripts/_janus_patchedintodist-janus/with.sha256sidecars. Artifact:janus_swi-1.5.3+orchestrata.1-cp314-cp314t-linux_x86_64.whl(the+orchestrata.1local segment distinguishes the fork from stock 1.5.3).scripts/install_janus_ft.sh— installs on any Linux machine: dependency checks with apt/dnf/pacman hints (swipl + embedded-engine headers, C toolchain, and a genuinely free-threaded Python detected viaPy_GIL_DISABLED),--from-gitlab,--wheeland--from-sourcemodes, and a mandatory live verification gate:sys._is_gil_enabled() == False, a real tabled query (fib(20) = 6765), and an 8-thread x 20-query parallel smoke. Proven end-to-end into throwaway 3.14t venvs: 160/160 queries OK, all modes.- GitLab-hosted release (
--from-gitlab, added 2026-07-18) — the wheel, sdist, and.sha256sidecars are published to the GitLab generic package registry ofgitlab.com/MSamy/orchestrata-ultimate-architecture(project81437762), packagejanus-swi-ft, version1.5.3-orchestrata.1.--from-gitlabdownloads the wheel + checksum, dies on sha256 mismatch, then installs and verifies like--wheel(needscurl;GITLAB_TOKENwhile the project is private; overrides viaJANUS_FT_GITLAB_HOST/JANUS_FT_GITLAB_PROJECT_ID/JANUS_FT_VERSION). Verified 2026-07-18: hosted wheel byte-identical to the local release (sha2562de49a71…), and a full--from-gitlabend-to-end into a fresh venv passed GIL-off + tabled query + 160/160 parallel smoke. - Canonical repo doc:
docs/free-threaded-prolog.md— authoritative patch inventory from a real diff vs upstream 1.5.2 (onlyjanus.c+133/-13 andmod_swipl.c+38/-6 carry logic: recursive-mutex module table, mutex-guarded deferred-decref queue replacing an ABA-vulnerable Treiber stack, dualpy_gil_ensurewithPyGILState_Ensurein the FT branch, PEP 489 multi-phase init withPy_MOD_GIL_NOT_USED), build pipeline, release/install process, verification protocol, never-revert doctrine.
CodebaseTools janus transport wiring (session 242)
The patched janus is now the default query transport for the Prolog
codebase-intelligence layer (src/orchestrata_ultimate/tools/codebase_tools.py
over codebase_db.pl):
- Measured: ~791x — 1.491 ms median warm janus query vs 1,179 ms subprocess-swipl round trip, on the full 696-module / 7,892-symbol KB.
- Fail-fast, no fallback (user ruling, kernel-required doctrine): the
constructor RAISES if
janus_swifails to import or the GIL is enabled — a missing/degraded free-threaded janus means the environment is broken, never a reason to silently degrade.transport="subprocess"is explicit opt-in only. reindex()internally stays on subprocess:library(http/json)cannot load under janus (PL_release_streamABI gap; the rules file guards thatuse_modulewithcatch/3).- Facts switching is atomic via
load_facts/1(retract_facts+consultabolish_all_tables) under a process-global lock; transport parity is test-enforced (deep-equal results across 20+ queries) and a 4-thread smoke proves concurrent in-process queries are clean.
- Benchmark context:
reports/prolog-codeintel-benchmark.md— Prolog loses to warm in-memory Python recursion on raw algorithmics but wins end-to-end via persisted facts + this in-process transport.
Still open: registering CodebaseTools in the volk ToolRegistry
(which volks, and the production reindex() trigger) — tracked in the
orchestrata session backlog.