Skip to content

Commit c2b31e5

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 c2b31e5

File tree

14 files changed

+110
-3
lines changed

14 files changed

+110
-3
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 cpu_limit_exceeded(signum, frame):
97+
raise CpuLimitExceeded("Worker exceeded CPU time limit")
98+
99+
100+
signal.signal(signal.SIGINT, 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: 43 additions & 1 deletion
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
/**
@@ -132,6 +135,44 @@ function setTimeoutTopLevelPatch(
132135
return 0;
133136
}
134137

138+
function getSignalClockAddr(Module: Module) {
139+
if (Module.API.version !== '0.28.2') {
140+
throw new PythonWorkersInternalError('Should not happen');
141+
}
142+
// This is the address here:
143+
// https://github.com/python/cpython/blob/main/Python/emscripten_signal.c#L42
144+
//
145+
// Since the symbol isn't exported, we can't access it directly. Instead, we used wasm-objdump and
146+
// searched for the call site to _Py_CheckEmscriptenSignals_Helper(), then read the offset out of
147+
// the assembly code.
148+
//
149+
// TODO: Export this symbol in the next Pyodide release so we can stop using the magic number.
150+
const emscripten_signal_clock_offset = 3171536;
151+
return Module.___memory_base.value + emscripten_signal_clock_offset;
152+
}
153+
154+
function setupRuntimeSignalHandling(Module: Module) {
155+
Module.Py_EmscriptenSignalBuffer = new Uint8Array([2]);
156+
if (Module.API.version === '0.28.2') {
157+
setCpuLimitNearlyExceededCallback(
158+
Module.HEAP8,
159+
getSignalClockAddr(Module),
160+
Module._Py_EMSCRIPTEN_SIGNAL_HANDLING
161+
);
162+
}
163+
}
164+
165+
export function setupRequestSignalHandling(Module: Module) {
166+
// In case the previous request was aborted, make sure that:
167+
// 1. a sigint is waiting in the signal buffer
168+
// 2. signal handling is off
169+
//
170+
// We will turn signal handling on as part of triggering the interrupt, having it on otherwise
171+
// just wastes cycles.
172+
Module.Py_EmscriptenSignalBuffer[0] = 2;
173+
Module.HEAPU32[Module._Py_EMSCRIPTEN_SIGNAL_HANDLING / 4] = 0;
174+
}
175+
135176
export function loadPyodide(
136177
isWorkerd: boolean,
137178
lockfile: PackageLock,
@@ -193,6 +234,7 @@ export function loadPyodide(
193234
}
194235
);
195236
setupPythonSearchPath(pyodide);
237+
setupRuntimeSignalHandling(Module);
196238
return pyodide;
197239
} catch (e) {
198240
// In edgeworker test suite, without this we get the file name and line number of the exception

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+
await 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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ 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+
Worker::Isolate::from(js).setCpuLimitNearlyExceededCallback(
97+
[buf = kj::mv(buf), sig_clock, sig_flag]() mutable {
98+
// Set signal handling clock to fire on the next check.
99+
buf[sig_clock] = 0;
100+
// Set signal handling to on
101+
buf[sig_flag] = 1;
102+
});
103+
}
104+
94105
kj::Array<kj::String> PythonModuleInfo::getPythonFileContents() {
95106
auto builder = kj::Vector<kj::String>(names.size());
96107
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());

src/workerd/io/limit-enforcer.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ class LimitEnforcer {
156156
// Returns a promise that will reject if and when a limit is exceeded that prevents further
157157
// JavaScript execution, such as the CPU or memory limit.
158158
virtual kj::Promise<void> onLimitsExceeded() = 0;
159+
virtual void setCpuLimitNearlyExceededCallback(kj::Function<void(void)>) = 0;
159160

160161
// Throws an exception if a limit has already been exceeded which prevents further JavaScript
161162
// execution, such as the CPU or memory limit.

0 commit comments

Comments
 (0)