Skip to content
File

Blob: src/pyodide/internal/python.ts

typescript306 lines
1// Copyright (c) 2026 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5import { enterJaegerSpan } from 'pyodide-internal:jaeger';
6import {
7 adjustSysPath,
8 mountWorkerFiles,
9} from 'pyodide-internal:setupPackages';
10import {
11 maybeCollectSnapshot,
12 maybeRestoreSnapshot,
13 finalizeBootstrap,
14 isRestoringSnapshot,
15 type CustomSerializedObjects,
16} from 'pyodide-internal:snapshot';
17import {
18 entropyMountFiles,
19 entropyAfterRuntimeInit,
20 entropyBeforeTopLevel,
21 getRandomValues,
22 entropyBeforeRequest,
23} from 'pyodide-internal:topLevelEntropy/lib';
24import {
25 LEGACY_VENDOR_PATH,
26 PROCESS_PTH_FILES,
27 SHOULD_ABORT_ISOLATE_ON_FATAL_ERROR,
28 setCpuLimitNearlyExceededCallback,
29} from 'pyodide-internal:metadata';
30import { default as FatalReporter } from 'pyodide-internal:fatal-reporter';
31import { default as cloudflareWorkers } from 'cloudflare-internal:workers';
32 
33import { default as UnsafeEval } from 'internal:unsafe-eval';
34import {
35 PythonUserError,
36 PythonWorkersInternalError,
37 loadPythonMod,
38 reportError,
39 setInternalErrorReporter,
40 unreachable,
41} from 'pyodide-internal:util';
42import { loadPackages } from 'pyodide-internal:loadPackage';
43import { default as MetadataReader } from 'pyodide-internal:runtime-generated/metadata';
44import { default as setupPythonSearchPathSource } from 'pyodide-internal:setup_python_search_path.py';
45import { TRANSITIVE_REQUIREMENTS, IS_WORKERD } from 'pyodide-internal:metadata';
46import { getTrustedReadFunc } from 'pyodide-internal:readOnlyFS';
47import { PyodideVersion } from 'pyodide-internal:const';
48import { default as pythonStdlibZip } from 'pyodideRuntime-internal:python_stdlib.zip';
49import { default as pyodideAsmWasm } from 'pyodideRuntime-internal:pyodide.asm.wasm';
50import { instantiateEmscriptenModule } from 'pyodideRuntime-internal:emscriptenSetup';
51import { createImportProxy } from 'pyodide-internal:serializeJsModule';
52 
53// Wire the PythonWorkersInternalError constructor's reporter to the C++ FatalReporter module.
54// See util.ts for why this indirection is needed (pool bundling constraints).
55// TODO: Remove once the Python pool is gone and util.ts can import FatalReporter directly.
56setInternalErrorReporter(() => {
57 FatalReporter.reportPythonWorkersInternalError();
58});
59 
60/**
61 * After running `instantiateEmscriptenModule` but before calling into any C
62 * APIs, we call this function. If `MEMORY` is defined, then we will have passed
63 * `noInitialRun: true` and so the C runtime is in an incoherent state until we
64 * restore the linear memory from the snapshot.
65 */
66function prepareWasmLinearMemory(
67 Module: Module,
68 customSerializedObjects: CustomSerializedObjects
69): void {
70 maybeRestoreSnapshot(Module);
71 // entropyAfterRuntimeInit adjusts JS state ==> always needs to be called.
72 entropyAfterRuntimeInit(Module);
73 if (!isRestoringSnapshot()) {
74 // The effects of these are purely in Python state so they only need to be run
75 // if we didn't restore a snapshot.
76 entropyBeforeTopLevel(Module);
77 // Note that setupPythonSearchPath runs after adjustSysPath and rearranges where
78 // the /session/metadata path is added.
79 adjustSysPath(Module);
80 }
81 if (Module.API.version !== PyodideVersion.V0_26_0a2) {
82 finalizeBootstrap(Module, customSerializedObjects);
83 }
84}
85 
86type SetupPythonSearchPathMod = {
87 __dict__: PyDict;
88 setup_python_search_path: PyCallable;
89 destroy(): void;
90};
91 
92function setupPythonSearchPath(pyodide: Pyodide): void {
93 const mod = loadPythonMod(
94 pyodide,
95 'setup_python_search_path',
96 setupPythonSearchPathSource
97 ) as SetupPythonSearchPathMod;
98 mod.setup_python_search_path.callKwargs({
99 LEGACY_VENDOR_PATH,
100 PROCESS_PTH_FILES,
101 });
102 mod.destroy();
103}
104 
105/**
106 * Verifies that the Pyodide version in our compat flag matches our actual version. This is to
107 * prevent us accidentally releasing a Pyodide bundle built against a different version than one
108 * we expect.
109 */
110function validatePyodideVersion(pyodide: Pyodide): void {
111 const expectedPyodideVersion = MetadataReader.getPyodideVersion();
112 if (expectedPyodideVersion == 'dev') {
113 return;
114 }
115 if (pyodide.version !== expectedPyodideVersion) {
116 throw new PythonWorkersInternalError(
117 `Pyodide version mismatch, expected '${expectedPyodideVersion}'`
118 );
119 }
120}
121 
122const origSetTimeout = globalThis.setTimeout.bind(this);
123 
124function makeSetTimeout(Module: Module): typeof setTimeout {
125 return function setTimeoutTopLevelPatch(
126 handler: () => void,
127 timeout: number | undefined
128 ): number {
129 // Redirect top level setTimeout(cb, 0) to queueMicrotask().
130 // If we don't know how to handle it, call normal setTimeout() to force failure.
131 if (typeof handler === 'string') {
132 return origSetTimeout(handler, timeout);
133 }
134 function wrappedHandler(): void {
135 // In case an Exceeded CPU occurred just as Python was exiting, there may be one waiting that
136 // will interrupt the wrong task. Clear signals before entering the task.
137 // This is covered by cpu-limit-exceeded.ew-test "async_trip" test.
138 clearSignals(Module);
139 handler();
140 }
141 if (timeout) {
142 return origSetTimeout(wrappedHandler, timeout);
143 }
144 queueMicrotask(wrappedHandler);
145 return 0;
146 } as typeof setTimeout;
147}
148 
149function getSignalClockAddr(Module: Module): number {
150 if (Module.API.version !== PyodideVersion.V0_28_2) {
151 throw new PythonWorkersInternalError(
152 'getSignalClockAddr only supported in 0.28.2'
153 );
154 }
155 // This is the address here:
156 // https://github.com/python/cpython/blob/main/Python/emscripten_signal.c#L42
157 //
158 // Since the symbol isn't exported, we can't access it directly. Instead, we used wasm-objdump and
159 // searched for the call site to _Py_CheckEmscriptenSignals_Helper(), then read the offset out of
160 // the assembly code.
161 //
162 // TODO: Export this symbol in the next Pyodide release so we can stop using the magic number.
163 const emscripten_signal_clock_offset = 3171536;
164 return Module.___memory_base.value + emscripten_signal_clock_offset;
165}
166 
167function setupRuntimeSignalHandling(Module: Module): void {
168 Module.Py_EmscriptenSignalBuffer = new Uint8Array(1);
169 const version = Module.API.version;
170 if (version === PyodideVersion.V0_26_0a2) {
171 return;
172 }
173 if (version === PyodideVersion.V0_28_2) {
174 // The callback sets signal_clock to 0 and signal_handling to 1. It has to be in C++ because we
175 // don't hold the isolate lock when we call it. JS code would be:
176 //
177 // function callback() { Module.HEAP8[getSignalClockAddr(Module)] = 0;
178 // Module.HEAP8[Module._Py_EMSCRIPTEN_SIGNAL_HANDLING] = 1;
179 // }
180 setCpuLimitNearlyExceededCallback(
181 Module.HEAP8,
182 getSignalClockAddr(Module),
183 Module._Py_EMSCRIPTEN_SIGNAL_HANDLING
184 );
185 return;
186 }
187 unreachable(version);
188}
189 
190const SIGXCPU = 24;
191 
192export function clearSignals(Module: Module): void {
193 if (Module.API.version === PyodideVersion.V0_28_2) {
194 // In case the previous request was aborted, make sure that:
195 // 1. a sigint is waiting in the signal buffer
196 // 2. signal handling is off
197 //
198 // We will turn signal handling on as part of triggering the interrupt, having it on otherwise
199 // just wastes cycles.
200 Module.Py_EmscriptenSignalBuffer[0] = SIGXCPU;
201 Module.HEAPU32[getSignalClockAddr(Module) / 4] = 1;
202 Module.HEAPU32[Module._Py_EMSCRIPTEN_SIGNAL_HANDLING / 4] = 0;
203 }
204}
205 
206function compileModuleFromReadOnlyFS(
207 Module: Module,
208 path: string
209): WebAssembly.Module {
210 const { node } = Module.FS.lookupPath(path);
211 // Get the trusted read function from our private Map, not from the node
212 // or filesystem object (which could have been tampered with by user code)
213 const trustedRead = getTrustedReadFunc(node);
214 if (!trustedRead) {
215 throw new PythonUserError(
216 'Can only load shared libraries from read only file systems.'
217 );
218 }
219 const stat = node.node_ops.getattr(node);
220 const buffer = new Uint8Array(stat.size);
221 // Create a minimal stream object and read using trusted read function
222 const stream = { node, position: 0 };
223 trustedRead(stream, buffer, 0, stat.size, 0);
224 return UnsafeEval.newWasmModule(buffer);
225}
226 
227export async function loadPyodide(
228 isWorkerd: boolean,
229 lockfile: PackageLock,
230 customSerializedObjects: CustomSerializedObjects
231): Promise<Pyodide> {
232 try {
233 const Module = await enterJaegerSpan('instantiate_emscripten', () =>
234 instantiateEmscriptenModule(IS_WORKERD, pythonStdlibZip, pyodideAsmWasm)
235 );
236 Module.compileModuleFromReadOnlyFS = compileModuleFromReadOnlyFS;
237 if (Module.API.version === PyodideVersion.V0_28_2) {
238 Module.API.config.jsglobals = createImportProxy(
239 'global this',
240 globalThis
241 );
242 } else {
243 Module.API.config.jsglobals = globalThis;
244 }
245 if (isWorkerd) {
246 Module.API.config.resolveLockFilePromise!(lockfile);
247 }
248 Module.setGetRandomValues(getRandomValues);
249 Module.setSetTimeout(
250 makeSetTimeout(Module),
251 clearTimeout,
252 setInterval,
253 clearInterval
254 );
255 
256 entropyMountFiles(Module);
257 enterJaegerSpan('load_packages', () => {
258 // NB. loadPackages adds the packages to the `VIRTUALIZED_DIR` global which then gets used in
259 // preloadDynamicLibs.
260 loadPackages(Module, TRANSITIVE_REQUIREMENTS);
261 });
262 
263 enterJaegerSpan('prepare_wasm_linear_memory', () => {
264 prepareWasmLinearMemory(Module, customSerializedObjects);
265 });
266 
267 maybeCollectSnapshot(Module, customSerializedObjects);
268 // Mount worker files after doing snapshot upload so we ensure that data from the files is never
269 // present in snapshot memory.
270 mountWorkerFiles(Module);
271 
272 if (Module.API.version === PyodideVersion.V0_26_0a2) {
273 // Finish setting up Pyodide's ffi so we can use the nice Python interface
274 // In newer versions we already did this in prepareWasmLinearMemory.
275 finalizeBootstrap(Module, customSerializedObjects);
276 }
277 const pyodide = Module.API.public_api;
278 
279 validatePyodideVersion(pyodide);
280 setupPythonSearchPath(pyodide);
281 setupRuntimeSignalHandling(Module);
282 Module.API.on_fatal = (error: unknown): void => {
283 try {
284 FatalReporter.reportFatal(String(error));
285 } catch (_e) {
286 FatalReporter.reportFatal('Internal error reporting fatal error');
287 }
288 if (SHOULD_ABORT_ISOLATE_ON_FATAL_ERROR) {
289 cloudflareWorkers.abortIsolate(
290 `Python worker fatal error: ${String(error)}`
291 );
292 }
293 };
294 return pyodide;
295 } catch (e) {
296 // In edgeworker test suite, without this we get the file name and line number of the exception
297 // but no traceback. This gives us a full traceback.
298 reportError(e as Error);
299 }
300}
301 
302export function beforeRequest(Module: Module): void {
303 entropyBeforeRequest(Module);
304 Module.setSetTimeout(setTimeout, clearTimeout, setInterval, clearInterval);
305}