File
Blob: src/pyodide/internal/python.ts
| 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 | |
| 5 | import { enterJaegerSpan } from 'pyodide-internal:jaeger'; |
| 6 | import { |
| 7 | adjustSysPath, |
| 8 | mountWorkerFiles, |
| 9 | } from 'pyodide-internal:setupPackages'; |
| 10 | import { |
| 11 | maybeCollectSnapshot, |
| 12 | maybeRestoreSnapshot, |
| 13 | finalizeBootstrap, |
| 14 | isRestoringSnapshot, |
| 15 | type CustomSerializedObjects, |
| 16 | } from 'pyodide-internal:snapshot'; |
| 17 | import { |
| 18 | entropyMountFiles, |
| 19 | entropyAfterRuntimeInit, |
| 20 | entropyBeforeTopLevel, |
| 21 | getRandomValues, |
| 22 | entropyBeforeRequest, |
| 23 | } from 'pyodide-internal:topLevelEntropy/lib'; |
| 24 | import { |
| 25 | LEGACY_VENDOR_PATH, |
| 26 | PROCESS_PTH_FILES, |
| 27 | SHOULD_ABORT_ISOLATE_ON_FATAL_ERROR, |
| 28 | setCpuLimitNearlyExceededCallback, |
| 29 | } from 'pyodide-internal:metadata'; |
| 30 | import { default as FatalReporter } from 'pyodide-internal:fatal-reporter'; |
| 31 | import { default as cloudflareWorkers } from 'cloudflare-internal:workers'; |
| 32 | |
| 33 | import { default as UnsafeEval } from 'internal:unsafe-eval'; |
| 34 | import { |
| 35 | PythonUserError, |
| 36 | PythonWorkersInternalError, |
| 37 | loadPythonMod, |
| 38 | reportError, |
| 39 | setInternalErrorReporter, |
| 40 | unreachable, |
| 41 | } from 'pyodide-internal:util'; |
| 42 | import { loadPackages } from 'pyodide-internal:loadPackage'; |
| 43 | import { default as MetadataReader } from 'pyodide-internal:runtime-generated/metadata'; |
| 44 | import { default as setupPythonSearchPathSource } from 'pyodide-internal:setup_python_search_path.py'; |
| 45 | import { TRANSITIVE_REQUIREMENTS, IS_WORKERD } from 'pyodide-internal:metadata'; |
| 46 | import { getTrustedReadFunc } from 'pyodide-internal:readOnlyFS'; |
| 47 | import { PyodideVersion } from 'pyodide-internal:const'; |
| 48 | import { default as pythonStdlibZip } from 'pyodideRuntime-internal:python_stdlib.zip'; |
| 49 | import { default as pyodideAsmWasm } from 'pyodideRuntime-internal:pyodide.asm.wasm'; |
| 50 | import { instantiateEmscriptenModule } from 'pyodideRuntime-internal:emscriptenSetup'; |
| 51 | import { 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. |
| 56 | setInternalErrorReporter(() => { |
| 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 | */ |
| 66 | function 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 | |
| 86 | type SetupPythonSearchPathMod = { |
| 87 | __dict__: PyDict; |
| 88 | setup_python_search_path: PyCallable; |
| 89 | destroy(): void; |
| 90 | }; |
| 91 | |
| 92 | function 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 | */ |
| 110 | function 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 | |
| 122 | const origSetTimeout = globalThis.setTimeout.bind(this); |
| 123 | |
| 124 | function 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 | |
| 149 | function 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 | |
| 167 | function 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 | |
| 190 | const SIGXCPU = 24; |
| 191 | |
| 192 | export 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 | |
| 206 | function 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 | |
| 227 | export 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 | |
| 302 | export function beforeRequest(Module: Module): void { |
| 303 | entropyBeforeRequest(Module); |
| 304 | Module.setSetTimeout(setTimeout, clearTimeout, setInterval, clearInterval); |
| 305 | } |