Skip to content

Commit ec4292f

Browse files
author
Christopher Rowley
committed
Add concurrent Python region tests
1 parent 258439f commit ec4292f

3 files changed

Lines changed: 83 additions & 10 deletions

File tree

‎AGENTS.md‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
# Repository Instructions
22

33
## Running tests
4-
- **Julia tests**: Run from the project root with `julia --project -e 'using Pkg; Pkg.test()'`. Expect a warning about the General registry being unreachable in locked-down environments; PyCall tests are marked broken/skipped. With the currently installed Julia 1.13.0, the functional tests pass but Aqua's persistent-task subprocess check may fail because its `done.log` is not created.
4+
- **Julia tests**: Run from the project root with `julia --project -e 'using Pkg; Pkg.test()'`. Expect a warning about the General registry being unreachable in locked-down environments; PyCall tests are marked broken/skipped. With the currently installed Julia 1.13.1, the functional tests pass but Aqua's persistent-task subprocess check may fail because its `done.log` is not created.
55
- **Python tests**:
66
- Copy `pysrc/juliacall/juliapkg-dev.json` to `pysrc/juliacall/juliapkg.json` before running (do **not** commit this copy).
77
- Execute with `uv run pytest -s --nbval ./pytest` (add `--cov=pysrc` when coverage is needed).
8-
- Sometimes `juliapkg` requires Julia 1.10–1.11. Check `juliaup status` rather than
9-
assuming 1.11.7 is installed: as of 2026-10-10 this environment only has Julia
10-
1.13 builds. JuliaPkg selecting Julia 1.13.1 under uv's downloaded Python 3.10
11-
fails to load `libjulia-codegen` because the Python process has loaded a
12-
`libstdc++.so.6` that lacks `GLIBCXX_3.4.30`.
8+
- Sometimes `juliapkg` requires Julia 1.10–1.11. Juliaup provides Julia 1.11.9
9+
through the `1.11` channel. JuliaPkg selecting Julia 1.13.1 under
10+
uv's downloaded Python 3.10 fails to load `libjulia-codegen` because the Python
11+
process has loaded a `libstdc++.so.6` that lacks `GLIBCXX_3.4.30`.
1312
- `julia --project=docs docs/make.jl` requires a valid Git `origin` so Documenter can infer
1413
source links; it fails during `makedocs` in checkouts without one.
1514

‎pytest/test_all.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,45 @@ def test_parallel_call(yld):
155155
t2 = time() - t0
156156
# executing the tasks should take about 1 second because they happen in parallel
157157
assert 0.9 < t2 < 1.5
158+
159+
160+
def test_concurrent_callback_roundtrips():
161+
"""Python threads can yield in Julia and repeatedly call back into Python."""
162+
from concurrent.futures import ThreadPoolExecutor
163+
from threading import Barrier, get_ident
164+
from juliacall import Main as jl
165+
166+
# Each worker crosses Python -> Julia -> Python -> Julia repeatedly. Checking
167+
# the OS thread in the Python callback catches restoring another worker's
168+
# borrowed thread state, while the start barrier makes state races likely on
169+
# both GIL and free-threaded builds without assuming how Julia schedules them.
170+
jl.jl_eval(
171+
"""
172+
function _callback_roundtrip(callback, python_tid, start)
173+
total = 0
174+
for value in start:(start + 19)
175+
yield()
176+
total += pyconvert(Int, callback(python_tid, value))
177+
end
178+
return total
179+
end
180+
"""
181+
)
182+
183+
def callback(expected_tid, value):
184+
assert get_ident() == expected_tid
185+
return 2 * int(jl.identity(value))
186+
187+
participants = 2
188+
start_barrier = Barrier(participants)
189+
190+
def worker(start):
191+
python_tid = get_ident()
192+
start_barrier.wait()
193+
return jl._callback_roundtrip(callback, python_tid, start)
194+
195+
with ThreadPoolExecutor(participants) as pool:
196+
results = list(pool.map(worker, range(participants)))
197+
198+
for start, total in enumerate(results):
199+
assert total == 2 * sum(range(start, start + 20))

‎test/C.jl‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,28 @@ end
2525
@test PythonCall.C.PyThreadState_GetUnchecked() == C_NULL
2626

2727
# Nested regions reuse the state cached by the owning Julia thread. A break
28-
# must relinquish that cache while yielding and restore it on return.
28+
# must relinquish that cache while yielding, keep the task on the same Julia
29+
# thread, and restore both the cache and the task's original stickiness.
30+
test_task = current_task()
31+
oldsticky = test_task.sticky
2932
@pyregion begin
3033
local state = PythonCall.C.TASK_STATE()
3134
local thread_state = PythonCall.C.THREAD_STATE()
32-
@test thread_state.task === current_task()
35+
local tid = threadid()
36+
@test test_task.sticky
37+
@test thread_state.task === test_task
3338
@test thread_state.task_state === state
34-
@pyregion @test PythonCall.C.current_task_state(current_task()) === state
39+
@pyregion @test PythonCall.C.current_task_state(test_task) === state
3540
@pyregionbreak begin
41+
@test test_task.sticky
3642
@test thread_state.task === nothing
3743
yield()
44+
@test threadid() == tid
3845
end
39-
@test thread_state.task === current_task()
46+
@test thread_state.task === test_task
4047
@test thread_state.task_state === state
4148
end
49+
@test test_task.sticky == oldsticky
4250
@test PythonCall.C.THREAD_STATE().task === nothing
4351

4452
@test @pyregion begin
@@ -51,6 +59,30 @@ end
5159
pyconvert(Int, pyint(3)) == 3
5260
end
5361

62+
# A task waiting in ordinary Julia code must release its Python state so that
63+
# another task on the same Julia thread can use Python. The bounded wait makes
64+
# a missing break fail promptly instead of leaving the test suite deadlocked.
65+
started = Channel{Nothing}(1)
66+
ready = Ref(false)
67+
break_status = Ref(:not_started)
68+
values = Ref((0, 0))
69+
@sync begin
70+
@async @pyregion begin
71+
put!(started, nothing)
72+
first = pyconvert(Int, pybuiltins.sum([1, 2, 3]))
73+
break_status[] = @pyregionbreak timedwait(() -> ready[], 2; pollint=0.001)
74+
second = pyconvert(Int, pybuiltins.sum([4, 5, 6]))
75+
values[] = (first, second)
76+
end
77+
@async begin
78+
take!(started)
79+
@test pyconvert(Int, pybuiltins.sum([7, 8, 9])) == 24
80+
ready[] = true
81+
end
82+
end
83+
@test break_status[] == :ok
84+
@test values[] == (6, 15)
85+
5486
@test_throws ErrorException @pyregion @pyregionbreak error("region exception")
5587
@test PythonCall.C.PyThreadState_GetUnchecked() == C_NULL
5688
@test_throws PyException @pyregion pybuiltins.int("not an integer")

0 commit comments

Comments
 (0)