Skip to content

Commit 3fac56b

Browse files
authored
Merge pull request #36521 from openedx/timmc/cj-dl-more
Restructure codejail darklaunch to better capture errors, fix globals pollution.
2 parents af40ac0 + bf2f8c3 commit 3fac56b

2 files changed

Lines changed: 173 additions & 44 deletions

File tree

xmodule/capa/safe_exec/safe_exec.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ def safe_exec(
159159
raise SafeExecException(emsg)
160160
return
161161

162+
cacheable = True # unless we get an unexpected error
163+
162164
# Create the complete code we'll run.
163165
code_prolog = CODE_PROLOG % random_seed
164166

@@ -178,6 +180,11 @@ def safe_exec(
178180

179181
else:
180182

183+
# Create a copy so the originals are not modified as part of this call.
184+
# This has to happen before local exec is run, since globals are modified
185+
# as a side effect.
186+
darklaunch_globals = copy.deepcopy(globals_dict)
187+
181188
# Decide which code executor to use.
182189
if unsafely:
183190
exec_fn = codejail_not_safe_exec
@@ -196,19 +203,23 @@ def safe_exec(
196203
limit_overrides_context=limit_overrides_context,
197204
slug=slug,
198205
)
199-
except SafeExecException as e:
206+
except BaseException as e:
200207
# Saving SafeExecException e in exception to be used later.
201208
exception = e
202209
emsg = str(e)
210+
if not isinstance(exception, SafeExecException):
211+
# Something unexpected happened, so don't cache this evaluation.
212+
# (We may decide to cache these in the future as well; this is just
213+
# preserving existing behavior during a refactor of error handling.)
214+
cacheable = False
203215
else:
216+
exception = None
204217
emsg = None
205218

206219
# Run the code in both the remote codejail service as well as the local codejail
207220
# when in darklaunch mode.
208221
if is_codejail_in_darklaunch():
209222
try:
210-
# Create a copy so the originals are not modified as part of this call.
211-
darklaunch_globals = copy.deepcopy(globals_dict)
212223
data = {
213224
"code": code_prolog + LAZY_IMPORTS + code,
214225
"globals_dict": darklaunch_globals,
@@ -219,26 +230,38 @@ def safe_exec(
219230
"extra_files": extra_files,
220231
}
221232
with function_trace('safe_exec.remote_exec_darklaunch'):
222-
remote_emsg, _remote_exception = get_remote_exec(data)
233+
remote_emsg, _ = get_remote_exec(data)
234+
except BaseException as e: # pragma: no cover # pylint: disable=broad-except
235+
# Swallow all exceptions and log it in monitoring so that dark launch doesn't cause issues during
236+
# deploy.
237+
remote_emsg = None
238+
remote_exception = e
239+
else:
240+
remote_emsg = None
241+
remote_exception = None
242+
243+
try:
223244
log.info(
224-
f"Remote execution in darklaunch mode produces: {darklaunch_globals} or exception: {remote_emsg}"
245+
f"Remote execution in darklaunch mode produces globals={darklaunch_globals!r}, "
246+
f"emsg={remote_emsg!r}, exception={remote_exception!r}"
225247
)
226-
log.info(f"Local execution in darklaunch mode produces: {globals_dict} or exception: {emsg}")
248+
local_exc_unexpected = None if isinstance(exception, SafeExecException) else exception
249+
log.info(
250+
f"Local execution in darklaunch mode produces globals={globals_dict!r}, "
251+
f"emsg={emsg!r}, exception={local_exc_unexpected!r}")
227252
set_custom_attribute('dark_launch_emsg_match', remote_emsg == emsg)
228253
set_custom_attribute('remote_emsg_exists', remote_emsg is not None)
229254
set_custom_attribute('local_emsg_exists', emsg is not None)
230-
except Exception as e: # pragma: no cover # pylint: disable=broad-except
231-
# Swallows all exceptions and logs it in monitoring so that dark launch doesn't cause issues during
232-
# deploy.
233-
log.exception("Error occurred while trying to remote exec in dark launch mode.")
255+
except BaseException as e: # pragma: no cover # pylint: disable=broad-except
256+
log.exception("Error occurred while trying to report codejail darklauch data.")
234257
record_exception()
235258

236259
# Put the result back in the cache. This is complicated by the fact that
237260
# the globals dict might not be entirely serializable.
238-
if cache:
261+
if cache and cacheable:
239262
cleaned_results = json_safe(globals_dict)
240263
cache.set(key, (emsg, cleaned_results))
241264

242265
# If an exception happened, raise it now.
243-
if emsg:
266+
if exception:
244267
raise exception

xmodule/capa/safe_exec/tests/test_safe_exec.py

Lines changed: 138 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Test safe_exec.py"""
22

33

4+
import copy
45
import hashlib
56
import os
67
import os.path
78
import textwrap
89
import unittest
9-
from unittest.mock import patch
10+
from unittest.mock import call, patch
1011

1112
import pytest
1213
import random2 as random
@@ -132,62 +133,167 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
132133
"""
133134
@patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec')
134135
@patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec')
135-
def test_default_code_execution(self, local_exec, remote_exec):
136+
def test_default_code_execution(self, mock_local_exec, mock_remote_exec):
136137

137138
# Test default only runs local exec.
138139
g = {}
139140
safe_exec('a=1', g)
140-
assert local_exec.called
141-
assert not remote_exec.called
141+
assert mock_local_exec.called
142+
assert not mock_remote_exec.called
142143

143144
@override_settings(ENABLE_CODEJAIL_REST_SERVICE=True)
144145
@patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec')
145146
@patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec')
146-
def test_code_execution_only_codejail_service(self, local_exec, remote_exec):
147+
def test_code_execution_only_codejail_service(self, mock_local_exec, mock_remote_exec):
147148
# Set return values to empty values to indicate no error.
148-
remote_exec.return_value = (None, None)
149+
mock_remote_exec.return_value = (None, None)
149150
# Test with only the service enabled.
150151
g = {}
151152
safe_exec('a=1', g)
152-
assert not local_exec.called
153-
assert remote_exec.called
153+
assert not mock_local_exec.called
154+
assert mock_remote_exec.called
154155

155156
@override_settings(ENABLE_CODEJAIL_DARKLAUNCH=True)
156157
@patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec')
157158
@patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec')
158-
def test_code_execution_darklaunch(self, local_exec, remote_exec):
159-
# Set return values to empty values to indicate no error.
160-
remote_exec.return_value = (None, None)
161-
g = {}
159+
def test_code_execution_darklaunch_misconfig(self, mock_local_exec, mock_remote_exec):
160+
"""Test that darklaunch doesn't run when remote service is generally enabled."""
161+
mock_remote_exec.return_value = (None, None)
162162

163-
# Verify that incorrect config runs only remote and not both.
164163
with override_settings(ENABLE_CODEJAIL_REST_SERVICE=True):
165-
safe_exec('a=1', g)
166-
assert not local_exec.called
167-
assert remote_exec.called
164+
safe_exec('a=1', {})
165+
166+
assert not mock_local_exec.called
167+
assert mock_remote_exec.called
168168

169-
local_exec.reset_mock()
170-
remote_exec.reset_mock()
169+
@override_settings(ENABLE_CODEJAIL_DARKLAUNCH=True)
170+
def run_dark_launch(
171+
self, globals_dict, local, remote,
172+
expect_attr_calls, expect_log_info_calls, expect_globals_contains,
173+
):
174+
"""
175+
Run a darklaunch scenario with mocked out local and remote execution.
171176
172-
# Set up side effects to mimic the real behavior of modifying the globals_dict.
173-
def local_side_effect(*args, **kwargs):
174-
test_globals = args[1]
175-
test_globals['test'] = 'local_test'
177+
Asserts set_custom_attribute and log.info calls and (partial) contents
178+
of globals dict.
176179
177-
def remote_side_effect(*args, **kwargs):
178-
test_globals = args[0]['globals_dict']
179-
test_globals['test'] = 'remote_test'
180+
Return value is a dictionary of:
180181
181-
local_exec.side_effect = local_side_effect
182-
remote_exec.side_effect = remote_side_effect
182+
- 'raised': Exception that safe_exec raised, or None.
183+
"""
183184

184185
assert is_codejail_in_darklaunch()
185-
safe_exec('a=1', g)
186186

187-
assert local_exec.called
188-
assert remote_exec.called
189-
# Verify that the local/default behavior currently wins out.
190-
assert g['test'] == 'local_test'
187+
with (
188+
patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec') as mock_local_exec,
189+
patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec') as mock_remote_exec,
190+
patch('xmodule.capa.safe_exec.safe_exec.set_custom_attribute') as mock_set_custom_attribute,
191+
patch('xmodule.capa.safe_exec.safe_exec.log.info') as mock_log_info,
192+
):
193+
mock_local_exec.side_effect = local
194+
mock_remote_exec.side_effect = remote
195+
196+
try:
197+
safe_exec("<IGNORED BY MOCKS>", globals_dict)
198+
except BaseException as e:
199+
safe_exec_e = e
200+
else:
201+
safe_exec_e = None
202+
203+
# Always want both sides to be called
204+
assert mock_local_exec.called
205+
assert mock_remote_exec.called
206+
207+
mock_set_custom_attribute.assert_has_calls(expect_attr_calls, any_order=True)
208+
mock_log_info.assert_has_calls(expect_log_info_calls, any_order=True)
209+
210+
for (k, v) in expect_globals_contains.items():
211+
assert globals_dict[k] == v
212+
213+
return {'raised': safe_exec_e}
214+
215+
def test_separate_globals(self):
216+
"""Test that local and remote globals are isolated from each other's side effects."""
217+
# Both will attempt to read and write the 'overwrite' key.
218+
globals_dict = {'overwrite': 'original'}
219+
220+
local_globals = None
221+
remote_globals = None
222+
223+
def local_exec(code, globals_dict, **kwargs):
224+
# Preserve what local exec saw
225+
nonlocal local_globals
226+
local_globals = copy.deepcopy(globals_dict)
227+
228+
globals_dict['overwrite'] = 'mock local'
229+
230+
def remote_exec(data):
231+
# Preserve what remote exec saw
232+
nonlocal remote_globals
233+
remote_globals = copy.deepcopy(data['globals_dict'])
234+
235+
data['globals_dict']['overwrite'] = 'mock remote'
236+
return (None, None)
237+
238+
results = self.run_dark_launch(
239+
globals_dict=globals_dict, local=local_exec, remote=remote_exec,
240+
expect_attr_calls=[
241+
call('local_emsg_exists', False),
242+
call('remote_emsg_exists', False),
243+
call('dark_launch_emsg_match', True),
244+
],
245+
expect_log_info_calls=[
246+
call(
247+
"Local execution in darklaunch mode produces globals={'overwrite': 'mock local'}, "
248+
"emsg=None, exception=None"
249+
),
250+
call(
251+
"Remote execution in darklaunch mode produces globals={'overwrite': 'mock remote'}, "
252+
"emsg=None, exception=None"
253+
),
254+
],
255+
# Should only see behavior of local exec
256+
expect_globals_contains={'overwrite': 'mock local'},
257+
)
258+
assert results['raised'] is None
259+
260+
# Both arms should have only seen the original globals object, untouched
261+
# by the other arm.
262+
assert local_globals == {'overwrite': 'original'}
263+
assert remote_globals == {'overwrite': 'original'}
264+
265+
def test_remote_runs_even_if_local_raises(self):
266+
"""Test that remote exec runs even if local raises."""
267+
def local_exec(code, globals_dict, **kwargs):
268+
# Raise something other than a SafeExecException.
269+
raise BaseException("unexpected")
270+
271+
def remote_exec(data):
272+
return (None, None)
273+
274+
results = self.run_dark_launch(
275+
globals_dict={}, local=local_exec, remote=remote_exec,
276+
expect_attr_calls=[
277+
call('local_emsg_exists', True),
278+
call('remote_emsg_exists', False),
279+
call('dark_launch_emsg_match', False),
280+
],
281+
expect_log_info_calls=[
282+
call(
283+
"Remote execution in darklaunch mode produces globals={}, "
284+
"emsg=None, exception=None"
285+
),
286+
call(
287+
"Local execution in darklaunch mode produces globals={}, "
288+
"emsg='unexpected', exception=BaseException('unexpected')"
289+
),
290+
],
291+
expect_globals_contains={},
292+
)
293+
294+
# Unexpected errors from local safe_exec propagate up.
295+
assert isinstance(results['raised'], BaseException)
296+
assert 'unexpected' in repr(results['raised'])
191297

192298

193299
class TestLimitConfiguration(unittest.TestCase):

0 commit comments

Comments
 (0)