[3.14] gh-132775: Fix Interpreter.call() __main__ Visibility (gh-135638)

As noted in the new tests, there are a few situations we must carefully accommodate
for functions that get pickled during interp.call().  We do so by running the script
from the main interpreter's __main__ module in a hidden module in the other
interpreter.  That hidden module is used as the function __globals__.

(cherry picked from commit 269e19e0a7, AKA gh-135595)

Co-authored-by: Eric Snow <ericsnowcurrently@gmail.com>
This commit is contained in:
Miss Islington (bot) 2025-06-17 22:24:08 +02:00 committed by GitHub
parent 8ec4186b25
commit 2c29ee835a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 419 additions and 246 deletions

View file

@ -1356,6 +1356,187 @@ def {funcname}():
with self.assertRaises(interpreters.NotShareableError):
interp.call(defs.spam_returns_arg, arg)
def test_func_in___main___hidden(self):
# When a top-level function that uses global variables is called
# through Interpreter.call(), it will be pickled, sent over,
# and unpickled. That requires that it be found in the other
# interpreter's __main__ module. However, the original script
# that defined the function is only run in the main interpreter,
# so pickle.loads() would normally fail.
#
# We work around this by running the script in the other
# interpreter. However, this is a one-off solution for the sake
# of unpickling, so we avoid modifying that interpreter's
# __main__ module by running the script in a hidden module.
#
# In this test we verify that the function runs with the hidden
# module as its __globals__ when called in the other interpreter,
# and that the interpreter's __main__ module is unaffected.
text = dedent("""
eggs = True
def spam(*, explicit=False):
if explicit:
import __main__
ns = __main__.__dict__
else:
# For now we have to have a LOAD_GLOBAL in the
# function in order for globals() to actually return
# spam.__globals__. Maybe it doesn't go through pickle?
# XXX We will fix this later.
spam
ns = globals()
func = ns.get('spam')
return [
id(ns),
ns.get('__name__'),
ns.get('__file__'),
id(func),
None if func is None else repr(func),
ns.get('eggs'),
ns.get('ham'),
]
if __name__ == "__main__":
from concurrent import interpreters
interp = interpreters.create()
ham = True
print([
[
spam(explicit=True),
spam(),
],
[
interp.call(spam, explicit=True),
interp.call(spam),
],
])
""")
with os_helper.temp_dir() as tempdir:
filename = script_helper.make_script(tempdir, 'my-script', text)
res = script_helper.assert_python_ok(filename)
stdout = res.out.decode('utf-8').strip()
local, remote = eval(stdout)
# In the main interpreter.
main, unpickled = local
nsid, _, _, funcid, func, _, _ = main
self.assertEqual(main, [
nsid,
'__main__',
filename,
funcid,
func,
True,
True,
])
self.assertIsNot(func, None)
self.assertRegex(func, '^<function spam at 0x.*>$')
self.assertEqual(unpickled, main)
# In the subinterpreter.
main, unpickled = remote
nsid1, _, _, funcid1, _, _, _ = main
self.assertEqual(main, [
nsid1,
'__main__',
None,
funcid1,
None,
None,
None,
])
nsid2, _, _, funcid2, func, _, _ = unpickled
self.assertEqual(unpickled, [
nsid2,
'<fake __main__>',
filename,
funcid2,
func,
True,
None,
])
self.assertIsNot(func, None)
self.assertRegex(func, '^<function spam at 0x.*>$')
self.assertNotEqual(nsid2, nsid1)
self.assertNotEqual(funcid2, funcid1)
def test_func_in___main___uses_globals(self):
# See the note in test_func_in___main___hidden about pickle
# and the __main__ module.
#
# Additionally, the solution to that problem must provide
# for global variables on which a pickled function might rely.
#
# To check that, we run a script that has two global functions
# and a global variable in the __main__ module. One of the
# functions sets the global variable and the other returns
# the value.
#
# The script calls those functions multiple times in another
# interpreter, to verify the following:
#
# * the global variable is properly initialized
# * the global variable retains state between calls
# * the setter modifies that persistent variable
# * the getter uses the variable
# * the calls in the other interpreter do not modify
# the main interpreter
# * those calls don't modify the interpreter's __main__ module
# * the functions and variable do not actually show up in the
# other interpreter's __main__ module
text = dedent("""
count = 0
def inc(x=1):
global count
count += x
def get_count():
return count
if __name__ == "__main__":
counts = []
results = [count, counts]
from concurrent import interpreters
interp = interpreters.create()
val = interp.call(get_count)
counts.append(val)
interp.call(inc)
val = interp.call(get_count)
counts.append(val)
interp.call(inc, 3)
val = interp.call(get_count)
counts.append(val)
results.append(count)
modified = {name: interp.call(eval, f'{name!r} in vars()')
for name in ('count', 'inc', 'get_count')}
results.append(modified)
print(results)
""")
with os_helper.temp_dir() as tempdir:
filename = script_helper.make_script(tempdir, 'my-script', text)
res = script_helper.assert_python_ok(filename)
stdout = res.out.decode('utf-8').strip()
before, counts, after, modified = eval(stdout)
self.assertEqual(modified, {
'count': False,
'inc': False,
'get_count': False,
})
self.assertEqual(before, 0)
self.assertEqual(after, 0)
self.assertEqual(counts, [0, 1, 4])
def test_raises(self):
interp = interpreters.create()
with self.assertRaises(ExecutionFailed):