File
Blob: src/pyodide/internal/snapshot.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 { default as ArtifactBundler } from 'pyodide-internal:artifacts'; |
| 7 | import { default as UnsafeEval } from 'internal:unsafe-eval'; |
| 8 | import { default as DiskCache } from 'pyodide-internal:disk_cache'; |
| 9 | import { type FilePath, VIRTUALIZED_DIR } from 'pyodide-internal:setupPackages'; |
| 10 | import { default as EmbeddedPackagesTarReader } from 'pyodide-internal:packages_tar_reader'; |
| 11 | import { |
| 12 | SHOULD_SNAPSHOT_TO_DISK, |
| 13 | IS_CREATING_BASELINE_SNAPSHOT, |
| 14 | MEMORY_SNAPSHOT_READER, |
| 15 | REQUIREMENTS, |
| 16 | IS_CREATING_SNAPSHOT, |
| 17 | IS_EW_VALIDATING, |
| 18 | IS_DYNAMIC_WORKER, |
| 19 | IS_DEDICATED_SNAPSHOT_ENABLED, |
| 20 | COMPATIBILITY_FLAGS, |
| 21 | type CompatibilityFlags, |
| 22 | IS_SECOND_VALIDATION_PHASE, |
| 23 | } from 'pyodide-internal:metadata'; |
| 24 | import { |
| 25 | invalidateCaches, |
| 26 | PythonWorkersInternalError, |
| 27 | PythonUserError, |
| 28 | simpleRunPython, |
| 29 | unreachable, |
| 30 | } from 'pyodide-internal:util'; |
| 31 | import { default as MetadataReader } from 'pyodide-internal:runtime-generated/metadata'; |
| 32 | import type { PyodideEntrypointHelper } from 'pyodide:python-entrypoint-helper'; |
| 33 | import { entropyAfterSnapshot } from 'pyodide-internal:topLevelEntropy/lib'; |
| 34 | import { |
| 35 | deserializeJsModule, |
| 36 | maybeSerializeJsModule, |
| 37 | type SerializedJsModule, |
| 38 | } from 'pyodide-internal:serializeJsModule'; |
| 39 | import { PyodideVersion } from 'pyodide-internal:const'; |
| 40 | |
| 41 | // A handle is the pointer into the linear memory returned by dlopen. Multiple dlopens will return |
| 42 | // multiple pointers. |
| 43 | type DsoHandles = { |
| 44 | [name: string]: { handles: number[] }; |
| 45 | }; |
| 46 | |
| 47 | // This is the info about Dsos that we record in getMemoryPatched. Namely the load order and where |
| 48 | // their metadata is allocated. It would be natural to also have dsoHandles in here, but we don't |
| 49 | // need a global variable to calculate dsoHandles since it is calculable from the information that |
| 50 | // Emscripten stores in Module.LDSO (see recordDsoHandles). |
| 51 | type DsoLoadInfo = { |
| 52 | readonly loadOrder: string[]; |
| 53 | readonly soMemoryBases: { [name: string]: number }; |
| 54 | readonly soTableBases?: { [name: string]: number }; |
| 55 | }; |
| 56 | |
| 57 | // This is the old wire format, where "settings" is mixed with the DsoHandles information and |
| 58 | // DsoLoadInfo is not present because we used to preload all dynamic libraries in a standard order. |
| 59 | // The "version" field is never present in the old wire format, but at runtime accessing it will |
| 60 | // give undefined. We need to include it here so that we can use the version field as the |
| 61 | // descriminator for `OldSnapshotMeta | SnapshotMeta`. |
| 62 | type OldSnapshotMeta = DsoHandles & { |
| 63 | readonly settings?: { readonly baselineSnapshot?: boolean }; |
| 64 | readonly version?: undefined; |
| 65 | }; |
| 66 | |
| 67 | type LoadedSnapshotSettings = { |
| 68 | readonly snapshotType: ArtifactBundler.SnapshotType; |
| 69 | readonly compatFlags: CompatibilityFlags; |
| 70 | }; |
| 71 | |
| 72 | type SnapshotSettings = { |
| 73 | readonly baselineSnapshot?: boolean; |
| 74 | } & Partial<LoadedSnapshotSettings>; |
| 75 | |
| 76 | // The new wire format, with additional information about the hiwire state, the order that dsos were |
| 77 | // loaded in, and their memory bases. We also moved settings out of the dsoHandles. |
| 78 | type SnapshotMeta = { |
| 79 | // We just store importedModulesList to help with testing and introspection |
| 80 | readonly importedModulesList: ReadonlyArray<string> | undefined; |
| 81 | readonly hiwire: SnapshotConfig | undefined; |
| 82 | readonly dsoHandles: DsoHandles; |
| 83 | readonly settings: SnapshotSettings; |
| 84 | readonly version: 1; |
| 85 | readonly jsModuleNames?: ReadonlyArray<string>; |
| 86 | } & DsoLoadInfo; |
| 87 | |
| 88 | // MEMORY_SNAPSHOT_READER has type SnapshotReader | undefined |
| 89 | type SnapshotReader = { |
| 90 | readMemorySnapshot: (offset: number, buf: Uint32Array | Uint8Array) => void; |
| 91 | getMemorySnapshotSize: () => number; |
| 92 | disposeMemorySnapshot: () => void; |
| 93 | }; |
| 94 | |
| 95 | // Extra info that isn't stored in the snapshot but is calculated at runtime: snapshotSize and |
| 96 | // snapshotOffset would be awkward to store in the snapshot. snapshotReader is equal to |
| 97 | // MEMORY_SNAPSHOT_READER, but typescript knows it is defined. |
| 98 | type LoadedSnapshotExtras = { |
| 99 | snapshotSize: number; |
| 100 | snapshotOffset: number; |
| 101 | snapshotReader: SnapshotReader; |
| 102 | }; |
| 103 | |
| 104 | type LoadedSnapshotMeta = SnapshotMeta & |
| 105 | LoadedSnapshotExtras & { readonly settings: LoadedSnapshotSettings }; |
| 106 | |
| 107 | /** |
| 108 | * Constants |
| 109 | */ |
| 110 | // "\x00snp" |
| 111 | const SNAPSHOT_MAGIC = 0x706e7300; |
| 112 | const CREATE_SNAPSHOT_VERSION = 2; |
| 113 | const HEADER_SIZE = 4 * 4; |
| 114 | |
| 115 | /** |
| 116 | * Global variables for the memory snapshot. |
| 117 | */ |
| 118 | const LOADED_SNAPSHOT_META: LoadedSnapshotMeta | undefined = decodeSnapshot( |
| 119 | MEMORY_SNAPSHOT_READER |
| 120 | ); |
| 121 | let JS_MODULES: Record<string, any>; |
| 122 | |
| 123 | export async function fillSnapshotJsModules( |
| 124 | doAnImport: (mod: string) => Promise<any> |
| 125 | ): Promise<void> { |
| 126 | JS_MODULES = await importJsModulesFromSnapshot( |
| 127 | doAnImport, |
| 128 | LOADED_SNAPSHOT_META?.jsModuleNames |
| 129 | ); |
| 130 | } |
| 131 | const CREATED_SNAPSHOT_META: Required<DsoLoadInfo> = { |
| 132 | soMemoryBases: {}, |
| 133 | soTableBases: {}, |
| 134 | loadOrder: [], |
| 135 | }; |
| 136 | if (LOADED_SNAPSHOT_META) { |
| 137 | // Make sure we include the soMemoryBases and loadOrder from the baseline snapshot when we are |
| 138 | // generating stacked snapshots. |
| 139 | Object.assign( |
| 140 | CREATED_SNAPSHOT_META.soMemoryBases, |
| 141 | LOADED_SNAPSHOT_META.soMemoryBases |
| 142 | ); |
| 143 | Object.assign( |
| 144 | CREATED_SNAPSHOT_META.soTableBases, |
| 145 | LOADED_SNAPSHOT_META.soTableBases |
| 146 | ); |
| 147 | CREATED_SNAPSHOT_META.loadOrder.push(...LOADED_SNAPSHOT_META.loadOrder); |
| 148 | } |
| 149 | export const LOADED_SNAPSHOT_TYPE = LOADED_SNAPSHOT_META?.settings.snapshotType; |
| 150 | |
| 151 | /** |
| 152 | * Preload a dynamic library. |
| 153 | * |
| 154 | * Emscripten would usually figure out all of these details for us |
| 155 | * automatically. These defaults work for shared libs that are configured as |
| 156 | * standard Python extensions. This naive approach will not work for libraries |
| 157 | * like scipy, shapely, geos... |
| 158 | * TODO(someday) fix this. |
| 159 | */ |
| 160 | function loadDynlib( |
| 161 | Module: Module, |
| 162 | path: string, |
| 163 | wasmModuleData: Uint8Array |
| 164 | ): void { |
| 165 | const wasmModule = UnsafeEval.newWasmModule(wasmModuleData); |
| 166 | const dso = Module.newDSO(path, undefined, 'loading'); |
| 167 | // even though these are used via dlopen, we are allocating them in an arena |
| 168 | // outside the heap and the memory cannot be reclaimed. So I don't think it |
| 169 | // would help us to allow them to be dealloc'd. |
| 170 | dso.refcount = Infinity; |
| 171 | // Hopefully they are used with dlopen |
| 172 | dso.global = false; |
| 173 | const options = {}; |
| 174 | // Passing this empty object as dylibLocalScope fixes symbol lookup in dependent shared libraries |
| 175 | // that are not loaded globally, thus fixing one of our problems with the upstream shift away from |
| 176 | // RLTD_GLOBAL. Emscripten should probably be updated so that if dylibLocalScope is undefined it |
| 177 | // will give the dynamic library a new empty loading scope. |
| 178 | const dylibLocalScope = {}; |
| 179 | dso.exports = Module.loadWebAssemblyModule( |
| 180 | wasmModule, |
| 181 | options, |
| 182 | path, |
| 183 | dylibLocalScope |
| 184 | ); |
| 185 | // "handles" are dlopen handles. There will be one entry in the `handles` list |
| 186 | // for each dlopen handle that has not been dlclosed. We need to keep track of |
| 187 | // these across |
| 188 | const { handles } = LOADED_SNAPSHOT_META?.dsoHandles[path] || { handles: [] }; |
| 189 | for (const handle of handles) { |
| 190 | Module.LDSO.loadedLibsByHandle[handle] = dso; |
| 191 | } |
| 192 | Module.LDSO.loadedLibsByName[path.split('/').at(-1)!] = dso; |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * This function is used to ensure the order in which we load SO_FILES stays the same. It is only |
| 197 | * used for 0.26.0a2, later we look at SNAPSHOT_META.loadOrder to decide what order to load libs. |
| 198 | * |
| 199 | * The sort always puts _lzma.so and _ssl.so first, because these SO_FILES are loaded in the |
| 200 | * baseline snapshot, and if we want to generate a package snapshot while a baseline snapshot is |
| 201 | * loaded we need them to be first. The rest of the files are sorted alphabetically. |
| 202 | * |
| 203 | * The `filePaths` list is of the form [["folder", "file.so"], ["file.so"]], so each element in it |
| 204 | * is effectively a file path. |
| 205 | */ |
| 206 | function sortSoFiles(filePaths: FilePath[]): FilePath[] { |
| 207 | let result = []; |
| 208 | let hasLzma = false; |
| 209 | let hasSsl = false; |
| 210 | const lzmaFile = '_lzma.so'; |
| 211 | const sslFile = '_ssl.so'; |
| 212 | for (const path of filePaths) { |
| 213 | if (path.length == 1 && path[0] == lzmaFile) { |
| 214 | hasLzma = true; |
| 215 | } else if (path.length == 1 && path[0] == sslFile) { |
| 216 | hasSsl = true; |
| 217 | } else { |
| 218 | result.push(path); |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | // JS might handle sorting lists of lists fine, but I'd rather be explicit here and make it compare |
| 223 | // strings. |
| 224 | result = result |
| 225 | .map((x) => x.join('/')) |
| 226 | .sort() |
| 227 | .map((x) => x.split('/')); |
| 228 | if (hasSsl) { |
| 229 | result.unshift([sslFile]); |
| 230 | } |
| 231 | if (hasLzma) { |
| 232 | result.unshift([lzmaFile]); |
| 233 | } |
| 234 | |
| 235 | return result; |
| 236 | } |
| 237 | |
| 238 | /** |
| 239 | * Used to ensure that the memoryBase of the dynamic library is stable when restoring snapshots. |
| 240 | * If the dynamic library was loaded in a memory snapshot, |
| 241 | */ |
| 242 | function getMemoryPatched( |
| 243 | Module: Module, |
| 244 | libPath: string, |
| 245 | size: number |
| 246 | ): number { |
| 247 | if (Module.API.version === PyodideVersion.V0_26_0a2) { |
| 248 | return Module.getMemory(size); |
| 249 | } |
| 250 | // Sometimes the module is loaded once by path and once by name, in either order. I'm not really |
| 251 | // sure why. But let's check if we snapshoted a load of the library by name. |
| 252 | const libName = libPath.split('/').at(-1)!; |
| 253 | // 1. Is it loaded in the snapshot? Replay the memory base. |
| 254 | { |
| 255 | const { soMemoryBases, soTableBases } = LOADED_SNAPSHOT_META ?? {}; |
| 256 | // If we loaded this library before taking the snapshot, we already allocated the memory and the |
| 257 | // allocator remembers because its state is in the linear memory. We just have to look it up. |
| 258 | const tableBase = Module.wasmTable.length; |
| 259 | const expectedTableBase = |
| 260 | soTableBases?.[libPath] ?? soTableBases?.[libName]; |
| 261 | if (expectedTableBase && tableBase !== expectedTableBase) { |
| 262 | // If this happens, we will segfault if we ever try to use this dynamic library. |
| 263 | // Save ourselves some debugging pain by crashing early. |
| 264 | throw new PythonWorkersInternalError( |
| 265 | `Error loading ${libName}: Expected table base ${expectedTableBase} but got table base ${tableBase}` |
| 266 | ); |
| 267 | } |
| 268 | const memoryBase = soMemoryBases?.[libPath] ?? soMemoryBases?.[libName]; |
| 269 | if (memoryBase) { |
| 270 | return memoryBase; |
| 271 | } |
| 272 | } |
| 273 | // 2. It's not loaded in the snapshot. Record |
| 274 | { |
| 275 | const { loadOrder, soMemoryBases, soTableBases } = CREATED_SNAPSHOT_META; |
| 276 | // Okay, we didn't load this before so we need to allocate new memory for it. Also record what we |
| 277 | // did in case someone makes a snapshot from this run. |
| 278 | loadOrder.push(libPath); |
| 279 | const memoryBase = Module.getMemory(size); |
| 280 | // Track both by full path and by name. That gives us a chance to resolve conflicts in name by the |
| 281 | // full path. |
| 282 | soMemoryBases[libPath] = memoryBase; |
| 283 | soMemoryBases[libName] = memoryBase; |
| 284 | soTableBases[libPath] = Module.wasmTable.length; |
| 285 | soTableBases[libName] = Module.wasmTable.length; |
| 286 | return memoryBase; |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | function loadDynlibFromTarFs( |
| 291 | Module: Module, |
| 292 | base: string, |
| 293 | node: TarFSInfo | undefined, |
| 294 | soFile: string[] |
| 295 | ): void { |
| 296 | for (const part of soFile) { |
| 297 | node = node?.children?.get(part); |
| 298 | } |
| 299 | if (!node?.contentsOffset) { |
| 300 | node = VIRTUALIZED_DIR.getDynlibRoot(); |
| 301 | for (const part of soFile) { |
| 302 | node = node?.children?.get(part); |
| 303 | } |
| 304 | } |
| 305 | if (!node?.contentsOffset) { |
| 306 | throw Error(`fs node could not be found for ${soFile.join('/')}`); |
| 307 | } |
| 308 | const { contentsOffset, size } = node; |
| 309 | if (contentsOffset === undefined) { |
| 310 | throw Error(`contentsOffset not defined for ${soFile.join('/')}`); |
| 311 | } |
| 312 | const wasmModuleData = new Uint8Array(size); |
| 313 | (node.reader ?? EmbeddedPackagesTarReader).read( |
| 314 | contentsOffset, |
| 315 | wasmModuleData |
| 316 | ); |
| 317 | const path = base + soFile.join('/'); |
| 318 | loadDynlib(Module, path, wasmModuleData); |
| 319 | } |
| 320 | |
| 321 | function loadDynlibFromVendor( |
| 322 | Module: Module, |
| 323 | soFile: string[], |
| 324 | userBundleNames: string[] |
| 325 | ): void { |
| 326 | const path = soFile.slice(3).join('/'); |
| 327 | const index = userBundleNames.indexOf(path); |
| 328 | if (index == -1) { |
| 329 | throw new PythonWorkersInternalError( |
| 330 | `Could not find ${path} in user bundle, which is required by the snapshot.` |
| 331 | ); |
| 332 | } |
| 333 | // The MetadataReader holds the user bundle's contents. |
| 334 | const buffer = new Uint8Array(MetadataReader.getSizes()[index]!); |
| 335 | MetadataReader.read(index, 0, buffer); |
| 336 | loadDynlib(Module, path, buffer); |
| 337 | } |
| 338 | |
| 339 | /** |
| 340 | * Preloading for the legacy 0.26 version. This loads all dynamic libraries |
| 341 | * visible in the site-packages directory. They are loaded before the runtime is |
| 342 | * initialized outside of the heap, using the same mechanism for DT_NEEDED libs |
| 343 | * (i.e., the libs that are loaded before the program starts because you passed |
| 344 | * them as linker args). |
| 345 | */ |
| 346 | function preloadDynamicLibs026(Module: Module): void { |
| 347 | const sitePackages = Module.FS.sessionSitePackages + '/'; |
| 348 | const sitePackagesRoot = VIRTUALIZED_DIR.getSitePackagesRoot(); |
| 349 | const loadedBaselineSnapshot = |
| 350 | LOADED_SNAPSHOT_META?.settings?.baselineSnapshot; |
| 351 | let SO_FILES_TO_LOAD: string[][]; |
| 352 | if (IS_CREATING_BASELINE_SNAPSHOT || loadedBaselineSnapshot) { |
| 353 | SO_FILES_TO_LOAD = [['_lzma.so'], ['_ssl.so']]; |
| 354 | } else { |
| 355 | SO_FILES_TO_LOAD = sortSoFiles(VIRTUALIZED_DIR.getSoFilesToLoad()); |
| 356 | } |
| 357 | for (const soFile of SO_FILES_TO_LOAD) { |
| 358 | loadDynlibFromTarFs(Module, sitePackages, sitePackagesRoot, soFile); |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | /** |
| 363 | * If we're restoring from a snapshot, we need to preload dynamic libraries so that any function |
| 364 | * pointers that point into the dylib symbols work correctly. |
| 365 | * Load the dynamic libraries in loadOrder. Mostly logic dealing with paths. |
| 366 | */ |
| 367 | function preloadDynamicLibsMain(Module: Module, loadOrder: string[]): void { |
| 368 | const sitePackages = Module.FS.sessionSitePackages + '/'; |
| 369 | const sitePackagesRoot = VIRTUALIZED_DIR.getSitePackagesRoot(); |
| 370 | const dynlibRoot = VIRTUALIZED_DIR.getDynlibRoot(); |
| 371 | const dynlibPath = '/usr/lib/'; |
| 372 | const userBundleNames = MetadataReader.getNames(); |
| 373 | for (let path of loadOrder) { |
| 374 | let root = sitePackagesRoot; |
| 375 | let base = ''; |
| 376 | if (path.startsWith(sitePackages)) { |
| 377 | path = path.slice(sitePackages.length); |
| 378 | base = sitePackages; |
| 379 | } else if (path.startsWith(dynlibPath)) { |
| 380 | path = path.slice(dynlibPath.length); |
| 381 | root = dynlibRoot; |
| 382 | base = dynlibPath; |
| 383 | } |
| 384 | |
| 385 | const pathSplit = Module.PATH.normalizeArray(path.split('/'), true); |
| 386 | if (pathSplit[0] == '') { |
| 387 | // This is a file path beginning with `/`, like /session/metadata/vendor/pkg/lib.so. So we |
| 388 | // are loading the vendored package's dynlibs here. |
| 389 | // |
| 390 | // TODO(EW-9508): support .so's in user bundle outside vendor dir. |
| 391 | loadDynlibFromVendor(Module, pathSplit, userBundleNames); |
| 392 | } else { |
| 393 | // This is a file path relative to the site-packages directory, like pkg/lib.so. So we are |
| 394 | // loading the built-in package's dynlibs here. |
| 395 | loadDynlibFromTarFs(Module, base, root, pathSplit); |
| 396 | } |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | function preloadDynamicLibs(Module: Module): void { |
| 401 | if (Module.API.version === PyodideVersion.V0_26_0a2) { |
| 402 | // In 0.26.0a2 we need to preload dynamic libraries even if we aren't restoring a snapshot. |
| 403 | preloadDynamicLibs026(Module); |
| 404 | return; |
| 405 | } |
| 406 | // |
| 407 | const loadOrder = LOADED_SNAPSHOT_META?.loadOrder; |
| 408 | if (!loadOrder) { |
| 409 | // In newer versions we only need to do the preloading if there is a snapshot to restore. |
| 410 | return; |
| 411 | } |
| 412 | // In Pyodide 0.28 we switched from using top level EM_JS to initialize the CountArgs function |
| 413 | // pointer to using an initializer to work around a regression in Emscripten 4.0.3 and 4.0.4. We |
| 414 | // could drop this patch because we are now on Emscripten 4.0.9. |
| 415 | // https://github.com/pyodide/pyodide/blob/main/cpython/patches/0008-Fix-Emscripten-call-trampoline-compatibility-with-Em.patch |
| 416 | // |
| 417 | // Unfortunately, this initializer allocates a function table slot and is called before dynamic |
| 418 | // loading when taking the snapshot but after when restoring the snapshot. Thus, when restoring a |
| 419 | // snapshot, before loading dynamic libraries we reserve a function pointer for the |
| 420 | // CountArgsPointer and after loading dynamic libraries, we put it in the free list so it will be |
| 421 | // used at the right moment. |
| 422 | const PyEMCountArgsPtr = Module.getEmptyTableSlot(); |
| 423 | preloadDynamicLibsMain(Module, loadOrder); |
| 424 | Module.freeTableIndexes.push(PyEMCountArgsPtr); |
| 425 | } |
| 426 | |
| 427 | /** |
| 428 | * This records which dynamic libraries have open handles (handed out by dlopen, |
| 429 | * not yet dlclosed). We'll need to track this information so that we don't |
| 430 | * crash if we dlsym the handle after restoring from the snapshot |
| 431 | */ |
| 432 | function recordDsoHandles(Module: Module): DsoHandles { |
| 433 | const dylinkInfo: DsoHandles = {}; |
| 434 | for (const [h, { name }] of Object.entries(Module.LDSO.loadedLibsByHandle)) { |
| 435 | const handle = Number(h); |
| 436 | if (handle === 0) { |
| 437 | continue; |
| 438 | } |
| 439 | dylinkInfo[name] ??= { handles: [] }; |
| 440 | dylinkInfo[name].handles.push(handle); |
| 441 | } |
| 442 | return dylinkInfo; |
| 443 | } |
| 444 | |
| 445 | /** |
| 446 | * Python modules do a lot of work the first time they are imported. The memory |
| 447 | * snapshot will save more time the more of this work is included. However, we |
| 448 | * can't snapshot the JS runtime state so we have no ffi. Thus some imports from |
| 449 | * user code will fail. |
| 450 | * |
| 451 | * If we are doing a baseline snapshot, just import everything from |
| 452 | * baselineSnapshotImports. These will all succeed. |
| 453 | * |
| 454 | * If doing a more dedicated "package" snap shot, also try to import each |
| 455 | * user import that is importing non-vendored modules. |
| 456 | * |
| 457 | * All of this is being done in the __main__ global scope, so be careful not to |
| 458 | * pollute it with extra included-by-default names (user code is executed in its |
| 459 | * own separate module scope though so it's not _that_ important). |
| 460 | * |
| 461 | * This function returns a list of modules that have been imported. |
| 462 | */ |
| 463 | function memorySnapshotDoImports(Module: Module): string[] { |
| 464 | const baselineSnapshotImports = |
| 465 | MetadataReader.constructor.getBaselineSnapshotImports(); |
| 466 | const toImport = baselineSnapshotImports.join(','); |
| 467 | const toDelete = Array.from( |
| 468 | new Set(baselineSnapshotImports.map((x) => x.split('.', 1)[0])) |
| 469 | ).join(','); |
| 470 | |
| 471 | simpleRunPython(Module, `import ${toImport}`); |
| 472 | simpleRunPython(Module, 'sysconfig.get_config_vars()'); |
| 473 | // Delete to avoid polluting globals |
| 474 | simpleRunPython(Module, `del ${toDelete}`); |
| 475 | if (IS_CREATING_BASELINE_SNAPSHOT) { |
| 476 | // We've done all the imports for the baseline snapshot. |
| 477 | return []; |
| 478 | } |
| 479 | if (REQUIREMENTS.length == 0) { |
| 480 | // Don't attempt to scan for package imports if the Worker has specified no package |
| 481 | // requirements, as this means their code isn't going to be importing any modules that we need |
| 482 | // to include in a snapshot. |
| 483 | return []; |
| 484 | } |
| 485 | |
| 486 | // The `importedModules` list will contain all modules that have been imported, including local |
| 487 | // modules, the usual `js` and other stdlib modules. We want to filter out local imports, so we |
| 488 | // grab them and put them into a set for fast filtering. |
| 489 | const importedModules: string[] = MetadataReader.getPackageSnapshotImports( |
| 490 | Module.API.version |
| 491 | ); |
| 492 | const deduplicatedModules = [...new Set(importedModules)]; |
| 493 | |
| 494 | // Import the modules list so they are included in the snapshot. |
| 495 | if (deduplicatedModules.length > 0) { |
| 496 | simpleRunPython(Module, 'import ' + deduplicatedModules.join(',')); |
| 497 | } |
| 498 | |
| 499 | return deduplicatedModules; |
| 500 | } |
| 501 | |
| 502 | function describeValue(val: any): string { |
| 503 | try { |
| 504 | const out = []; |
| 505 | |
| 506 | const type = typeof val; |
| 507 | const isObject = type === 'object'; |
| 508 | out.push(`Value: ${val}`); |
| 509 | out.push(`Type: ${type}`); |
| 510 | try { |
| 511 | const constructorName = val?.constructor?.name; // eslint-disable-line |
| 512 | if (constructorName) { |
| 513 | out.push(`Constructor name: ${constructorName}`); |
| 514 | } |
| 515 | } catch { |
| 516 | // Ignore errors when getting keys |
| 517 | } |
| 518 | |
| 519 | if (val && isObject) { |
| 520 | try { |
| 521 | out.push( |
| 522 | `Keys: ${Object.keys(val as Record<string, unknown>) |
| 523 | .slice(0, 10) |
| 524 | .join(', ')}` |
| 525 | ); |
| 526 | } catch { |
| 527 | // Ignore errors when getting keys |
| 528 | } |
| 529 | } |
| 530 | |
| 531 | try { |
| 532 | if (val && isObject && typeof (val as Error).stack === 'string') { |
| 533 | out.push(`Stack:\n${(val as Error).stack}`); |
| 534 | } |
| 535 | } catch { |
| 536 | // Ignore errors when getting stack |
| 537 | } |
| 538 | |
| 539 | try { |
| 540 | out.push(`toStringTag: ${Object.prototype.toString.call(val)}`); |
| 541 | } catch { |
| 542 | // Ignore errors when getting object type |
| 543 | } |
| 544 | |
| 545 | try { |
| 546 | const contents = JSON.stringify(val); |
| 547 | if (contents?.length > 100) { |
| 548 | out.push(`Contents: ${contents.slice(0, 100)}...`); |
| 549 | } else if (contents) { |
| 550 | out.push(`Contents: ${contents}`); |
| 551 | } |
| 552 | } catch { |
| 553 | // Ignore JSON stringify errors |
| 554 | } |
| 555 | |
| 556 | return out.join('\n'); |
| 557 | } catch (err) { |
| 558 | return `Error describing value: ${err}`; |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | /** |
| 563 | * When we create a dedicated memory snapshot, we capture all the globals in the user's top-level |
| 564 | * scope. If those globals refer to JS objects, then we may fail to serialise them. This function |
| 565 | * creates a user error to inform the user of this issue. |
| 566 | * |
| 567 | * It's important that we give the user as much information about this as possible, so they can |
| 568 | * understand what they need to change in order to resolve the problem on their end. |
| 569 | */ |
| 570 | function createUnserializableObjectError(obj: any): PythonUserError { |
| 571 | // TODO: Create docs for this and link them here. |
| 572 | const error = `Can't serialize top-level variable. |
| 573 | Please review any global variables you or your imported modules create at the top-level of your code, and consider deleting them. |
| 574 | |
| 575 | Description of the value: |
| 576 | ${describeValue(obj)} |
| 577 | `; |
| 578 | return new PythonUserError(error); |
| 579 | } |
| 580 | |
| 581 | async function importJsModulesFromSnapshot( |
| 582 | doAnImport: (mod: string) => Promise<any>, |
| 583 | jsModuleNames: ReadonlyArray<string> | undefined |
| 584 | ): Promise<Record<string, any>> { |
| 585 | if (jsModuleNames === undefined) { |
| 586 | return {}; |
| 587 | } |
| 588 | async function doImport(x: string): Promise<any> { |
| 589 | if (x === 'global this') { |
| 590 | return globalThis; |
| 591 | } |
| 592 | return await doAnImport(x); |
| 593 | } |
| 594 | return Object.fromEntries( |
| 595 | await Promise.all( |
| 596 | jsModuleNames.map( |
| 597 | async (x): Promise<[string, any]> => [x, await doImport(x)] |
| 598 | ) |
| 599 | ) |
| 600 | ); |
| 601 | } |
| 602 | |
| 603 | type CustomSerialized = |
| 604 | | { pyodide_entrypoint_helper: true } |
| 605 | | { cloudflare_compat_flags: true } |
| 606 | | SerializedJsModule; |
| 607 | /** |
| 608 | * Global objects that need a custom serializer |
| 609 | */ |
| 610 | export type CustomSerializedObjects = { |
| 611 | pyodide_entrypoint_helper: PyodideEntrypointHelper; |
| 612 | cloudflare_compat_flags: CompatibilityFlags; |
| 613 | }; |
| 614 | |
| 615 | function getHiwireSerializer( |
| 616 | globalObj: CustomSerializedObjects, |
| 617 | modules: Set<string> |
| 618 | ): (obj: any) => CustomSerialized { |
| 619 | return function serializer(obj: any): CustomSerialized { |
| 620 | if (obj === globalObj.pyodide_entrypoint_helper) { |
| 621 | return { pyodide_entrypoint_helper: true }; |
| 622 | } else if (obj === globalObj.cloudflare_compat_flags) { |
| 623 | return { cloudflare_compat_flags: true }; |
| 624 | } |
| 625 | const serializedModule = maybeSerializeJsModule(obj, modules); |
| 626 | if (serializedModule) { |
| 627 | return serializedModule; |
| 628 | } |
| 629 | throw createUnserializableObjectError(obj); |
| 630 | }; |
| 631 | } |
| 632 | |
| 633 | function getHiwireDeserializer( |
| 634 | globalObj: CustomSerializedObjects |
| 635 | ): (obj: CustomSerialized) => any { |
| 636 | return function deserializer(obj) { |
| 637 | if ('pyodide_entrypoint_helper' in obj) { |
| 638 | return globalObj.pyodide_entrypoint_helper; |
| 639 | } else if ('cloudflare_compat_flags' in obj) { |
| 640 | return globalObj.cloudflare_compat_flags; |
| 641 | } |
| 642 | if ('jsModule' in obj) { |
| 643 | return deserializeJsModule(obj, JS_MODULES); |
| 644 | } |
| 645 | unreachable(obj, `Can't deserialize ${obj}`); |
| 646 | }; |
| 647 | } |
| 648 | |
| 649 | /** |
| 650 | * Create memory snapshot by importing SNAPSHOT_IMPORTS to ensure these packages |
| 651 | * are initialized in the linear memory snapshot and then saving a copy of the |
| 652 | * linear memory into MEMORY. |
| 653 | */ |
| 654 | function makeLinearMemorySnapshot( |
| 655 | Module: Module, |
| 656 | importedModulesList: string[], |
| 657 | customSerializedObjects: CustomSerializedObjects, |
| 658 | snapshotType: ArtifactBundler.SnapshotType |
| 659 | ): Uint8Array { |
| 660 | const dsoHandles = recordDsoHandles(Module); |
| 661 | let hiwire: SnapshotConfig | undefined; |
| 662 | const jsModuleNames: Set<string> = new Set(); |
| 663 | if (Module.API.version !== PyodideVersion.V0_26_0a2) { |
| 664 | hiwire = Module.API.serializeHiwireState( |
| 665 | getHiwireSerializer(customSerializedObjects, jsModuleNames) |
| 666 | ); |
| 667 | } |
| 668 | const settings: SnapshotSettings = { |
| 669 | baselineSnapshot: IS_CREATING_BASELINE_SNAPSHOT, |
| 670 | snapshotType, |
| 671 | compatFlags: COMPATIBILITY_FLAGS, |
| 672 | }; |
| 673 | return encodeSnapshot(Module.HEAP8, { |
| 674 | version: 1, |
| 675 | dsoHandles, |
| 676 | hiwire, |
| 677 | importedModulesList, |
| 678 | jsModuleNames: Array.from(jsModuleNames), |
| 679 | settings, |
| 680 | ...CREATED_SNAPSHOT_META, |
| 681 | }); |
| 682 | } |
| 683 | |
| 684 | /** |
| 685 | * Encode heap and dsoJSON into the memory snapshot artifact that we'll upload |
| 686 | */ |
| 687 | function encodeSnapshot(heap: Uint8Array, meta: SnapshotMeta): Uint8Array { |
| 688 | const json = JSON.stringify(meta); |
| 689 | let snapshotOffset = HEADER_SIZE + 2 * json.length; |
| 690 | // align to 8 bytes |
| 691 | snapshotOffset = Math.ceil(snapshotOffset / 8) * 8; |
| 692 | const toUpload = new Uint8Array(snapshotOffset + heap.length); |
| 693 | const encoder = new TextEncoder(); |
| 694 | const { written: jsonByteLength } = encoder.encodeInto( |
| 695 | json, |
| 696 | toUpload.subarray(HEADER_SIZE) |
| 697 | ); |
| 698 | const header = new Uint32Array(toUpload.buffer); |
| 699 | header[0] = SNAPSHOT_MAGIC; |
| 700 | header[1] = CREATE_SNAPSHOT_VERSION; |
| 701 | header[2] = snapshotOffset; |
| 702 | header[3] = jsonByteLength; |
| 703 | toUpload.subarray(snapshotOffset).set(heap); |
| 704 | return toUpload; |
| 705 | } |
| 706 | |
| 707 | /** |
| 708 | * Decode heap and dsoJSON from the memory snapshot reader |
| 709 | */ |
| 710 | function decodeSnapshot( |
| 711 | reader: SnapshotReader | undefined |
| 712 | ): LoadedSnapshotMeta | undefined { |
| 713 | if (!reader) { |
| 714 | return undefined; |
| 715 | } |
| 716 | if (reader.getMemorySnapshotSize() === 0) { |
| 717 | throw new PythonWorkersInternalError( |
| 718 | `SnapshotReader returned memory snapshot size of 0` |
| 719 | ); |
| 720 | } |
| 721 | const header = new Uint32Array(4); |
| 722 | reader.readMemorySnapshot(0, header); |
| 723 | if (header[0] !== SNAPSHOT_MAGIC) { |
| 724 | throw new PythonWorkersInternalError( |
| 725 | `Invalid magic number ${header[0]}, expected ${SNAPSHOT_MAGIC}` |
| 726 | ); |
| 727 | } |
| 728 | // buf[1] is SNAPSHOT_VERSION (unused currently) |
| 729 | const snapshotOffset = header[2]!; |
| 730 | const jsonByteLength = header[3]!; |
| 731 | |
| 732 | const snapshotSize = reader.getMemorySnapshotSize() - snapshotOffset; |
| 733 | const jsonBuf = new Uint8Array(jsonByteLength); |
| 734 | const offset = header.byteLength; // the json starts after the header |
| 735 | reader.readMemorySnapshot(offset, jsonBuf); |
| 736 | const json = new TextDecoder().decode(jsonBuf); |
| 737 | const meta = JSON.parse(json) as OldSnapshotMeta | SnapshotMeta; |
| 738 | const extras: LoadedSnapshotExtras = { |
| 739 | snapshotSize, |
| 740 | snapshotOffset, |
| 741 | snapshotReader: reader, |
| 742 | }; |
| 743 | if (!meta?.version) { |
| 744 | return { |
| 745 | version: 1, |
| 746 | importedModulesList: undefined, |
| 747 | dsoHandles: meta, |
| 748 | hiwire: undefined, |
| 749 | loadOrder: [], |
| 750 | soMemoryBases: {}, |
| 751 | settings: { |
| 752 | snapshotType: meta.settings?.baselineSnapshot ? 'baseline' : 'package', |
| 753 | compatFlags: {}, |
| 754 | ...meta.settings, |
| 755 | }, |
| 756 | jsModuleNames: [], |
| 757 | ...extras, |
| 758 | }; |
| 759 | } |
| 760 | return { |
| 761 | ...meta, |
| 762 | ...extras, |
| 763 | settings: { |
| 764 | ...meta.settings, |
| 765 | snapshotType: |
| 766 | meta.settings.snapshotType ?? |
| 767 | (meta.settings.baselineSnapshot ? 'baseline' : 'package'), |
| 768 | compatFlags: meta.settings.compatFlags ?? {}, |
| 769 | }, |
| 770 | }; |
| 771 | } |
| 772 | |
| 773 | export function isRestoringSnapshot(): boolean { |
| 774 | return !!LOADED_SNAPSHOT_META; |
| 775 | } |
| 776 | |
| 777 | function checkSnapshotType(snapshotType: string): void { |
| 778 | if (SHOULD_SNAPSHOT_TO_DISK) { |
| 779 | return; |
| 780 | } |
| 781 | // Dynamic workers don't yet support dedicated snapshots, so they will receive a baseline |
| 782 | // snapshot from GCS even when the dedicated snapshot compat flag is enabled. Skip the |
| 783 | // snapshot type validation in this case. |
| 784 | if (IS_DYNAMIC_WORKER) { |
| 785 | return; |
| 786 | } |
| 787 | if ( |
| 788 | !IS_EW_VALIDATING && |
| 789 | snapshotType === 'dedicated' && |
| 790 | !IS_DEDICATED_SNAPSHOT_ENABLED |
| 791 | ) { |
| 792 | throw new PythonWorkersInternalError( |
| 793 | 'Received dedicated snapshot but compat flag for dedicated snapshots is not enabled' |
| 794 | ); |
| 795 | } |
| 796 | |
| 797 | if ( |
| 798 | !IS_EW_VALIDATING && |
| 799 | snapshotType !== 'dedicated' && |
| 800 | IS_DEDICATED_SNAPSHOT_ENABLED |
| 801 | ) { |
| 802 | throw new PythonWorkersInternalError( |
| 803 | 'Received non-dedicated snapshot but compat flag for dedicated snapshots is enabled' |
| 804 | ); |
| 805 | } |
| 806 | |
| 807 | // If we have a snapshot in the bundle and the dedicated snapshot flag is enabled, then we |
| 808 | // should verify that the snapshot in the bundle is a dedicated snapshot. If it is not |
| 809 | // we should fail with an error. |
| 810 | if (snapshotType !== 'dedicated' && IS_SECOND_VALIDATION_PHASE) { |
| 811 | throw new PythonWorkersInternalError( |
| 812 | 'The second validation phase should receive a dedicated snapshot, got ' + |
| 813 | snapshotType |
| 814 | ); |
| 815 | } |
| 816 | } |
| 817 | |
| 818 | export function maybeRestoreSnapshot(Module: Module): void { |
| 819 | Module.noInitialRun = isRestoringSnapshot(); |
| 820 | Module.getMemoryPatched = getMemoryPatched; |
| 821 | // Make sure memory is large enough |
| 822 | Module.growMemory(LOADED_SNAPSHOT_META?.snapshotSize ?? 0); |
| 823 | enterJaegerSpan('preload_dynamic_libs', () => { |
| 824 | preloadDynamicLibs(Module); |
| 825 | }); |
| 826 | // TODO: Remove the jaeger span here |
| 827 | enterJaegerSpan('remove_run_dependency', () => { |
| 828 | Module.removeRunDependency('dynlibs'); |
| 829 | }); |
| 830 | if (!LOADED_SNAPSHOT_META) { |
| 831 | return; |
| 832 | } |
| 833 | const { snapshotSize, snapshotOffset, snapshotReader, settings } = |
| 834 | LOADED_SNAPSHOT_META; |
| 835 | checkSnapshotType(settings.snapshotType); |
| 836 | |
| 837 | Module.growMemory(snapshotSize); |
| 838 | snapshotReader.readMemorySnapshot(snapshotOffset, Module.HEAP8); |
| 839 | snapshotReader.disposeMemorySnapshot(); |
| 840 | // Invalidate caches if we have a snapshot because the contents of site-packages |
| 841 | // may have changed. |
| 842 | invalidateCaches(Module); |
| 843 | } |
| 844 | |
| 845 | function collectSnapshot( |
| 846 | Module: Module, |
| 847 | importedModulesList: string[], |
| 848 | customSerializedObjects: CustomSerializedObjects, |
| 849 | snapshotType: ArtifactBundler.SnapshotType |
| 850 | ): void { |
| 851 | if (!IS_EW_VALIDATING && !SHOULD_SNAPSHOT_TO_DISK) { |
| 852 | throw new PythonWorkersInternalError( |
| 853 | "Attempted to collect snapshot outside of context where it's supported." |
| 854 | ); |
| 855 | } |
| 856 | const snapshot = makeLinearMemorySnapshot( |
| 857 | Module, |
| 858 | importedModulesList, |
| 859 | customSerializedObjects, |
| 860 | snapshotType |
| 861 | ); |
| 862 | entropyAfterSnapshot(Module); |
| 863 | if (IS_EW_VALIDATING) { |
| 864 | ArtifactBundler.storeMemorySnapshot({ |
| 865 | snapshot, |
| 866 | importedModulesList, |
| 867 | snapshotType, |
| 868 | }); |
| 869 | } else if (SHOULD_SNAPSHOT_TO_DISK) { |
| 870 | DiskCache.putSnapshot('snapshot.bin', snapshot); |
| 871 | } else { |
| 872 | throw new PythonWorkersInternalError('Unreachable'); |
| 873 | } |
| 874 | } |
| 875 | |
| 876 | /** |
| 877 | * Collects a dedicated snapshot. This is only called after the top-level of the worker has been |
| 878 | * run. |
| 879 | */ |
| 880 | export function maybeCollectDedicatedSnapshot( |
| 881 | Module: Module, |
| 882 | customSerializedObjects: CustomSerializedObjects | null |
| 883 | ): void { |
| 884 | if (!IS_CREATING_SNAPSHOT) { |
| 885 | return; |
| 886 | } |
| 887 | |
| 888 | if (!IS_DEDICATED_SNAPSHOT_ENABLED) { |
| 889 | return; |
| 890 | } |
| 891 | |
| 892 | if (Module.API.version == PyodideVersion.V0_26_0a2) { |
| 893 | // 0.26.0a2 does not support serialisation of the hiwire state, so it cannot support dedicated |
| 894 | // snapshots. |
| 895 | throw new PythonWorkersInternalError( |
| 896 | 'Dedicated snapshot is not supported for Python runtime version 0.26.0a2' |
| 897 | ); |
| 898 | } |
| 899 | |
| 900 | if (!customSerializedObjects) { |
| 901 | throw new PythonWorkersInternalError( |
| 902 | 'customSerializedObjects is required for dedicated snapshot' |
| 903 | ); |
| 904 | } |
| 905 | collectSnapshot(Module, [], customSerializedObjects, 'dedicated'); |
| 906 | } |
| 907 | |
| 908 | /** |
| 909 | * Collects either a baseline or package snapshot. This is called prior to running the top-level |
| 910 | * of the worker and crucially before the worker files are mounted. |
| 911 | * |
| 912 | * Dedicated snapshots are collected in `maybeCollectDedicatedSnapshot`. |
| 913 | */ |
| 914 | export function maybeCollectSnapshot( |
| 915 | Module: Module, |
| 916 | customSerializedObjects: CustomSerializedObjects |
| 917 | ): void { |
| 918 | // In order to surface any problems that occur in `memorySnapshotDoImports` to |
| 919 | // users in local development, always call it even if we aren't actually |
| 920 | const importedModulesList = memorySnapshotDoImports(Module); |
| 921 | if (!IS_CREATING_SNAPSHOT) { |
| 922 | return; |
| 923 | } |
| 924 | |
| 925 | if (IS_DEDICATED_SNAPSHOT_ENABLED) { |
| 926 | // We are not interested in collecting a baseline/package snapshot here if this feature flag |
| 927 | // is enabled. |
| 928 | return; |
| 929 | } |
| 930 | |
| 931 | collectSnapshot( |
| 932 | Module, |
| 933 | importedModulesList, |
| 934 | customSerializedObjects, |
| 935 | IS_CREATING_BASELINE_SNAPSHOT ? 'baseline' : 'package' |
| 936 | ); |
| 937 | } |
| 938 | |
| 939 | export function finalizeBootstrap( |
| 940 | Module: Module, |
| 941 | customSerializedObjects: CustomSerializedObjects |
| 942 | ): void { |
| 943 | Module.API.config._makeSnapshot = |
| 944 | IS_CREATING_SNAPSHOT && |
| 945 | Module.API.version !== PyodideVersion.V0_26_0a2 && |
| 946 | Module.API.version !== PyodideVersion.V0_28_2; |
| 947 | enterJaegerSpan('finalize_bootstrap', () => { |
| 948 | Module.API.finalizeBootstrap( |
| 949 | LOADED_SNAPSHOT_META?.hiwire, |
| 950 | getHiwireDeserializer(customSerializedObjects) |
| 951 | ); |
| 952 | }); |
| 953 | // finalizeBootstrap overrides LD_LIBRARY_PATH. Restore it. |
| 954 | simpleRunPython( |
| 955 | Module, |
| 956 | `import os; os.environ["LD_LIBRARY_PATH"] += ":/session/metadata/python_modules/lib/"; del os` |
| 957 | ); |
| 958 | if (IS_CREATING_SNAPSHOT) { |
| 959 | return; |
| 960 | } |
| 961 | Module.API.public_api.registerJsModule('_cf_internal_snapshot_info', { |
| 962 | loadedSnapshot: !!LOADED_SNAPSHOT_META, |
| 963 | loadedBaselineSnapshot: LOADED_SNAPSHOT_META?.settings.baselineSnapshot, |
| 964 | importedModulesList: LOADED_SNAPSHOT_META?.importedModulesList, |
| 965 | }); |
| 966 | } |