Skip to content

Commit 6f3d45e

Browse files
authored
Force propertynames(::Py) to occur on the main thread (#677)
* Force propertynames(::Py) to occur on the main thread * Refactor, document, make more robust and remove a lock * Make setup_onfixedthread robust against errors * Make on_main_thread test more reliable --------- Co-authored-by: Christopher Doris <github.com/cjdoris>
1 parent e6dba51 commit 6f3d45e

File tree

3 files changed

+115
-16
lines changed

3 files changed

+115
-16
lines changed

src/C/context.jl

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,79 @@ function _atpyexit()
2929
return
3030
end
3131

32+
33+
function setup_onfixedthread()
34+
channel_input = Channel(1)
35+
channel_output = Channel(1)
36+
islaunched = Ref(false) # use Ref to avoid closure boxing of variable
37+
function launch_worker(tid)
38+
islaunched[] && error("Cannot launch more than once: call setup_onfixedthread again if need be.")
39+
islaunched[] = true
40+
worker_task = Task() do
41+
while true
42+
f = take!(channel_input)
43+
ret = try
44+
Some(invokelatest(f))
45+
# invokelatest is necessary for development and interactive use.
46+
# Otherwise, only a method f defined in a world prior to the call of
47+
# launch_worker would work.
48+
catch e
49+
e, catch_backtrace()
50+
end
51+
put!(channel_output, ret)
52+
end
53+
end
54+
# code adapted from set_task_tid! in StableTasks.jl, itself taken from Dagger.jl
55+
worker_task.sticky = true
56+
for _ in 1:100
57+
# try to fix the task id to tid, retrying up to 100 times
58+
ret = ccall(:jl_set_task_tid, Cint, (Any, Cint), worker_task, tid-1)
59+
if ret == 1
60+
break # success
61+
elseif ret == 0
62+
yield()
63+
else
64+
error("Unexpected retcode from jl_set_task_tid: $ret")
65+
end
66+
end
67+
if Threads.threadid(worker_task) != tid
68+
error("Failed setting the thread ID to $tid.")
69+
end
70+
schedule(worker_task)
71+
end
72+
function onfixedthread(f)
73+
put!(channel_input, f)
74+
ret = take!(channel_output)
75+
if ret isa Tuple
76+
e, backtrace = ret
77+
printstyled(stderr, "ERROR: "; color=:red, bold=true)
78+
showerror(stderr, e)
79+
Base.show_backtrace(stderr, backtrace)
80+
println(stderr)
81+
throw(e) # the stacktrace of the actual error is printed above
82+
else
83+
something(ret)
84+
end
85+
end
86+
launch_worker, onfixedthread
87+
end
88+
89+
# launch_on_main_thread is used in init_context(), after which on_main_thread becomes usable
90+
const launch_on_main_thread, on_main_thread = setup_onfixedthread()
91+
92+
"""
93+
on_main_thread(f)
94+
95+
Execute `f()` on the main thread.
96+
97+
!!! warning
98+
The value returned by `on_main_thread(f)` cannot be type-inferred by the compiler:
99+
if necessary, use explicit type annotations such as `on_main_thread(f)::T`, where `T` is
100+
the expected return type.
101+
"""
102+
on_main_thread
103+
104+
32105
function init_context()
33106

34107
CTX.is_embedded = hasproperty(Base.Main, :__PythonCall_libptr)
@@ -240,6 +313,8 @@ function init_context()
240313
"Only Python 3.10+ is supported, this is Python $(CTX.version) at $(CTX.exe_path===missing ? "unknown location" : CTX.exe_path).",
241314
)
242315

316+
launch_on_main_thread(Threads.threadid()) # makes on_main_thread usable
317+
243318
@debug "Initialized PythonCall.jl" CTX.is_embedded CTX.is_initialized CTX.exe_path CTX.lib_path CTX.lib_ptr CTX.pyprogname CTX.pyhome CTX.version
244319

245320
return

src/Core/Py.jl

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -282,25 +282,29 @@ Base.setproperty!(x::Py, k::Symbol, v) = pysetattr(x, string(k), v)
282282
Base.setproperty!(x::Py, k::String, v) = pysetattr(x, k, v)
283283

284284
function Base.propertynames(x::Py, private::Bool = false)
285-
# this follows the logic of rlcompleter.py
286-
function classmembers(c)
287-
r = pydir(c)
288-
if pyhasattr(c, "__bases__")
289-
for b in c.__bases__
290-
r = pyiadd(r, classmembers(b))
285+
properties = C.on_main_thread() do
286+
# this follows the logic of rlcompleter.py
287+
function classmembers(c)
288+
r = pydir(c)
289+
if pyhasattr(c, "__bases__")
290+
for b in c.__bases__
291+
r = pyiadd(r, classmembers(b))
292+
end
291293
end
294+
return r
292295
end
293-
return r
294-
end
295-
words = pyset(pydir(x))
296-
words.discard("__builtins__")
297-
if pyhasattr(x, "__class__")
298-
words.add("__class__")
299-
words.update(classmembers(x.__class__))
300-
end
301-
words = map(pystr_asstring, words)
296+
297+
words = pyset(pydir(x::Py))
298+
words.discard("__builtins__")
299+
if pyhasattr(x, "__class__")
300+
words.add("__class__")
301+
words.update(classmembers(x.__class__))
302+
end
303+
map(pystr_asstring, words)
304+
end::Vector{String} # explicit type since on_main_thread() is type-unstable
305+
302306
# private || filter!(w->!startswith(w, "_"), words)
303-
map(Symbol, words)
307+
map(Symbol, properties)
304308
end
305309

306310
Base.Bool(x::Py) = pytruth(x)

test/Core.jl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,3 +830,23 @@ end
830830
@test !isdir(tname)
831831
end
832832
end
833+
834+
@testitem "propertynames" begin
835+
x = pyint(7)
836+
task = Threads.@spawn propertynames(x)
837+
properties = propertynames(x)
838+
@test :__init__ in properties
839+
prop_task = fetch(task)
840+
@test properties == prop_task
841+
end
842+
843+
@testitem "on_main_thread" begin
844+
refid = PythonCall.C.on_main_thread() do; Threads.threadid(); end
845+
tasks = [Threads.@spawn(PythonCall.C.on_main_thread() do; Threads.threadid(); end) for _ in 1:20]
846+
@test all(t -> fetch(t) == refid, tasks)
847+
@test_throws DivideError redirect_stderr(devnull) do
848+
PythonCall.C.on_main_thread() do
849+
throw(DivideError())
850+
end
851+
end
852+
end

0 commit comments

Comments
 (0)