Skip to content

Commit 4303149

Browse files
committed
Handle CPU limit exceeded in Python workers
If we call `TerminateExecution()`, it will exit Python execution without unwinding the stack or cleaning up the runtime state. This leaves the Python runtime in a permanently messed up state and all further requests will fail. This adds a new `cpuLimitNearlyExceededCallback` to the limit enforcer and hooks it up so that it triggers a SIGINT inside of Python. This can be used to raise a `CpuLimitExceeded` Python error into the runtime. If this error is ignored, then we'll hit the hard limit and be terminated. If we ever do call `TerminateExecution()` on a Python isolate, we should condemn the isolate, but that is left as a TODO. To trigger the SIGINT inside of Python, we have to set two addresses: 1. we set `emscripten_signal_clock` to `0` to make the Python eval breaker check for a signal on the next tick. 2. we set `_Py_EMSCRIPTEN_SIGNAL_HANDLING` to 1 to make Python check the signal clock. We also have to set `Module.Py_EmscriptenSignalBuffer` to a buffer with the number of the signal we wish to trip in it (`SIGINT` aka 2). When we start a request we set `_Py_EMSCRIPTEN_SIGNAL_HANDLING` to 0 to avoid ongoing costs of calling out to JavaScript to check the buffer when no signal is set, and we put a 2 into `Py_EmscriptenSignalBuffer`. The most annoying aspect of this is that the symbol `emscripten_signal_clock` is not exported. For Pyodide 0.28.2, I manually located the address of this symbol and hard coded it. For the next Pyodide, we'll make sure to export it.
1 parent c052767 commit 4303149

File tree

15 files changed

+138
-4
lines changed

15 files changed

+138
-4
lines changed

src/pyodide/internal/introspection.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import signal
12
from inspect import isawaitable, isclass
23
from types import FunctionType
34

@@ -86,3 +87,14 @@ async def wrapper_func(relaxed, inst, prop, *args, **kwargs):
8687
return python_to_rpc(await result)
8788
else:
8889
return python_to_rpc(result)
90+
91+
92+
class CpuLimitExceeded(BaseException):
93+
pass
94+
95+
96+
def raise_cpu_limit_exceeded(signum, frame):
97+
raise CpuLimitExceeded("Python Worker exceeded CPU time limit")
98+
99+
100+
signal.signal(signal.SIGINT, raise_cpu_limit_exceeded)

src/pyodide/internal/metadata.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,6 @@ export const LEGACY_GLOBAL_HANDLERS = !NO_GLOBAL_HANDLERS;
6363
export const LEGACY_VENDOR_PATH = !FORCE_NEW_VENDOR_PATH;
6464
export const LEGACY_INCLUDE_SDK = !EXTERNAL_SDK;
6565
export const CHECK_RNG_STATE = !!COMPATIBILITY_FLAGS.python_check_rng_state;
66+
67+
export const setCpuLimitNearlyExceededCallback =
68+
MetadataReader.setCpuLimitNearlyExceededCallback.bind(MetadataReader);

src/pyodide/internal/python.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import {
1616
getRandomValues,
1717
entropyBeforeRequest,
1818
} from 'pyodide-internal:topLevelEntropy/lib';
19-
import { LEGACY_VENDOR_PATH } from 'pyodide-internal:metadata';
19+
import {
20+
LEGACY_VENDOR_PATH,
21+
setCpuLimitNearlyExceededCallback,
22+
} from 'pyodide-internal:metadata';
2023
import type { PyodideEntrypointHelper } from 'pyodide:python-entrypoint-helper';
2124

2225
/**
@@ -27,7 +30,11 @@ import type { PyodideEntrypointHelper } from 'pyodide:python-entrypoint-helper';
2730
import { default as SetupEmscripten } from 'internal:setup-emscripten';
2831

2932
import { default as UnsafeEval } from 'internal:unsafe-eval';
30-
import { PythonWorkersInternalError, reportError } from 'pyodide-internal:util';
33+
import {
34+
PythonWorkersInternalError,
35+
reportError,
36+
unreachable,
37+
} from 'pyodide-internal:util';
3138
import { loadPackages } from 'pyodide-internal:loadPackage';
3239
import { default as MetadataReader } from 'pyodide-internal:runtime-generated/metadata';
3340
import { TRANSITIVE_REQUIREMENTS } from 'pyodide-internal:metadata';
@@ -132,6 +139,60 @@ function setTimeoutTopLevelPatch(
132139
return 0;
133140
}
134141

142+
function getSignalClockAddr(Module: Module): number {
143+
if (Module.API.version !== '0.28.2') {
144+
throw new PythonWorkersInternalError(
145+
'getSignalClockAddr only supported in 0.28.2'
146+
);
147+
}
148+
// This is the address here:
149+
// https://github.com/python/cpython/blob/main/Python/emscripten_signal.c#L42
150+
//
151+
// Since the symbol isn't exported, we can't access it directly. Instead, we used wasm-objdump and
152+
// searched for the call site to _Py_CheckEmscriptenSignals_Helper(), then read the offset out of
153+
// the assembly code.
154+
//
155+
// TODO: Export this symbol in the next Pyodide release so we can stop using the magic number.
156+
const emscripten_signal_clock_offset = 3171536;
157+
return Module.___memory_base.value + emscripten_signal_clock_offset;
158+
}
159+
160+
function setupRuntimeSignalHandling(Module: Module): void {
161+
Module.Py_EmscriptenSignalBuffer = new Uint8Array(1);
162+
const version = Module.API.version;
163+
if (version === '0.26.0a2') {
164+
return;
165+
}
166+
if (version === '0.28.2') {
167+
// The callback sets signal_clock to 0 and signal_handling to 1. It has to be in C++ because we
168+
// don't hold the isolate lock when we call it. JS code would be:
169+
//
170+
// function callback() { Module.HEAP8[getSignalClockAddr(Module)] = 0;
171+
// Module.HEAP8[Module._Py_EMSCRIPTEN_SIGNAL_HANDLING] = 1;
172+
// }
173+
setCpuLimitNearlyExceededCallback(
174+
Module.HEAP8,
175+
getSignalClockAddr(Module),
176+
Module._Py_EMSCRIPTEN_SIGNAL_HANDLING
177+
);
178+
return;
179+
}
180+
unreachable(version);
181+
}
182+
183+
const SIGINT = 2;
184+
185+
export function setupRequestSignalHandling(Module: Module): void {
186+
// In case the previous request was aborted, make sure that:
187+
// 1. a sigint is waiting in the signal buffer
188+
// 2. signal handling is off
189+
//
190+
// We will turn signal handling on as part of triggering the interrupt, having it on otherwise
191+
// just wastes cycles.
192+
Module.Py_EmscriptenSignalBuffer[0] = SIGINT;
193+
Module.HEAPU32[Module._Py_EMSCRIPTEN_SIGNAL_HANDLING / 4] = 0;
194+
}
195+
135196
export function loadPyodide(
136197
isWorkerd: boolean,
137198
lockfile: PackageLock,
@@ -193,6 +254,7 @@ export function loadPyodide(
193254
}
194255
);
195256
setupPythonSearchPath(pyodide);
257+
setupRuntimeSignalHandling(Module);
196258
return pyodide;
197259
} catch (e) {
198260
// In edgeworker test suite, without this we get the file name and line number of the exception

src/pyodide/internal/util.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,7 @@ export function invalidateCaches(Module: Module): void {
7878
`from importlib import invalidate_caches; invalidate_caches(); del invalidate_caches`
7979
);
8080
}
81+
82+
export function unreachable(msg: never): never {
83+
throw new PythonWorkersInternalError(`Unreachable: ${msg}`);
84+
}

src/pyodide/python-entrypoint-helper.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
// This file is a BUILTIN module that provides the actual implementation for the
33
// python-entrypoint.js USER module.
44

5-
import { beforeRequest, loadPyodide } from 'pyodide-internal:python';
5+
import {
6+
beforeRequest,
7+
loadPyodide,
8+
setupRequestSignalHandling,
9+
} from 'pyodide-internal:python';
610
import { enterJaegerSpan } from 'pyodide-internal:jaeger';
711
import { patchLoadPackage } from 'pyodide-internal:setupPackages';
812
import {
@@ -292,6 +296,8 @@ async function doPyCallHelper(
292296
pyfunc: PyCallable,
293297
args: any[]
294298
): Promise<any> {
299+
const pyodide = await getPyodide();
300+
setupRequestSignalHandling(pyodide._module);
295301
try {
296302
if (pyfunc.callWithOptions) {
297303
return await pyfunc.callWithOptions(

src/pyodide/types/emscripten.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,7 @@ interface Module {
132132
getEmptyTableSlot(): number;
133133
freeTableIndexes: number[];
134134
LD_LIBRARY_PATH: string;
135+
Py_EmscriptenSignalBuffer: Uint8Array;
136+
_Py_EMSCRIPTEN_SIGNAL_HANDLING: number;
137+
___memory_base: WebAssembly.Global<'i32'>;
135138
}

src/pyodide/types/runtime-generated/metadata.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ declare namespace MetadataReader {
3030
const read: (index: number, position: number, buffer: Uint8Array) => number;
3131
const getTransitiveRequirements: () => Set<string>;
3232
const getCompatibilityFlags: () => CompatibilityFlags;
33+
const setCpuLimitNearlyExceededCallback: (
34+
buf: Uint8Array,
35+
sig_clock: number,
36+
sig_flag: number
37+
) => void;
3338
const constructor: {
3439
getBaselineSnapshotImports(): string[];
3540
};

src/workerd/api/pyodide/pyodide.c++

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,19 @@ kj::Array<kj::StringPtr> PyodideMetadataReader::getNames(
9191
return builder.releaseAsArray();
9292
}
9393

94+
void PyodideMetadataReader::setCpuLimitNearlyExceededCallback(
95+
jsg::Lock& js, kj::Array<kj::byte> buf, int sig_clock, int sig_flag) {
96+
// This callback has to be implemented in C++ because we don't hold the isolate lock when we call
97+
// it.
98+
Worker::Isolate::from(js).setCpuLimitNearlyExceededCallback(
99+
[buf = kj::mv(buf), sig_clock, sig_flag]() mutable {
100+
// Set signal handling clock to fire on the next check.
101+
buf[sig_clock] = 0;
102+
// Set signal handling to on
103+
buf[sig_flag] = 1;
104+
});
105+
}
106+
94107
kj::Array<kj::String> PythonModuleInfo::getPythonFileContents() {
95108
auto builder = kj::Vector<kj::String>(names.size());
96109
for (auto i: kj::zeroTo(names.size())) {

src/workerd/api/pyodide/pyodide.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,9 @@ class PyodideMetadataReader: public jsg::Object {
238238

239239
static kj::Array<kj::StringPtr> getBaselineSnapshotImports();
240240

241+
void setCpuLimitNearlyExceededCallback(
242+
jsg::Lock& js, kj::Array<kj::byte> buf, int addr, int addr2);
243+
241244
// Similar to Cloudflare::::getCompatibilityFlags in global-scope.c++, but the key difference is
242245
// that it returns experimental flags even if `experimental` is not enabled. This avoids a gotcha
243246
// where an experimental compat flag is enabled in our C++ code, but not in our JS code.
@@ -266,6 +269,7 @@ class PyodideMetadataReader: public jsg::Object {
266269
JSG_METHOD(getTransitiveRequirements);
267270
JSG_METHOD(getCompatibilityFlags);
268271
JSG_STATIC_METHOD(getBaselineSnapshotImports);
272+
JSG_METHOD(setCpuLimitNearlyExceededCallback);
269273
}
270274

271275
void visitForMemoryInfo(jsg::MemoryTracker& tracker) const {

src/workerd/io/io-context.c++

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ IoContext::IoContext(ThreadContext& thread,
193193

194194
return promise;
195195
};
196+
limitEnforcer->setCpuLimitNearlyExceededCallback(
197+
[this]() { this->worker->getIsolate().cpuLimitNearlyExceeded(); });
196198

197199
// Arrange to abort when limits expire.
198200
abortWhen(makeLimitsPromise());

0 commit comments

Comments
 (0)