fix(libraries): load node modules under stable namespaces so pickled values survive restarts#5136
fix(libraries): load node modules under stable namespaces so pickled values survive restarts#5136collindutter wants to merge 14 commits into
Conversation
Library node files were executed under a volatile module name (gtn_dynamic_module_<file>_<hash(str(path))>). Python randomizes the hash of strings per process, so the name changed on every engine restart. Any value pickled with a class from such a module (e.g. an enum used as a parameter default, like SetVariablesFromData's collision_behavior) embedded that volatile name, so unpickling in a fresh process failed with "No module named 'gtn_dynamic_module_..._<hash>'". This surfaced when dragging a saved image onto the canvas, whose embedded flow commands are a plain pickle. Execute node modules under their stable namespace (griptape_nodes.node_libraries.<lib>.<file>) as the canonical module name, registering the synthetic parent packages so the dotted name is importable. The class __module__ is now stable and deterministic, so pickles are portable across restarts from every serialization call site without per-site patching. The stable namespace format is unchanged from the alias it replaces, so existing saved workflows still load, and genuine same-stem file collisions are detected and disambiguated instead of silently clobbering one another.
Loading node modules under a stable namespace stops new images from embedding volatile module names, but images saved by earlier engines already carry pickles referencing names like gtn_dynamic_module_<file>_<hash>, which can never resolve in a later process. Dragging those images onto the canvas still failed. Give the image-metadata extract path an Unpickler that, when a module reference fails to import, asks the LibraryManager to map the volatile name back to the module now loaded under its stable namespace (matching the file stem and requiring the referenced class to exist). When no loaded library satisfies the reference, fail with a message that tells the user which library to install instead of a raw ModuleNotFoundError.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Storing the collision-detection key by round-tripping module.__file__ (with a Path() fallback when unset) could misclassify a hot reload of the same file as a same-stem collision and hand it a spurious disambiguation suffix, silently breaking the stable-name guarantee. Pass the canonicalized load path through to _track_stable_module so the comparison uses the exact value it resolved with. Also stop surfacing raw internal module tokens (gtn_dynamic_module_..., stable namespaces) in the artist-facing extract error; reduce them to the library-relative name and keep the raw token in a debug log.
Supplemental technical deep diveThis is intentionally separate from the PR description. It documents the complete failure chain, why this approach was chosen, the hot-reload concern, and what remains architecturally unresolved. 1. The user-visible failureDragging a generated image onto the canvas produced: Generated images can contain a workflow in PNG metadata: When an image is dragged onto the canvas, the engine:
The failure occurred during step 4. 2. How library modules were loadedNode library files were not imported using normal package names. The engine loaded each file using a synthetic module name: module_name = (
f"gtn_dynamic_module_"
f"{file_path.name.replace('.', '_')}_"
f"{hash(str(file_path))}"
)For example: Using a hash of the full path helped distinguish two files with the same filename in different directories. The problem is that Python intentionally randomizes string hashes for every process: The same file therefore received a different module name after restarting the engine. Within one engine process the name was stable; across processes it was not. 3. Why pickle caredPython pickle does not normally serialize a class implementation. It records where to import the class from: For a When loading the pickle later, Python effectively tried: from gtn_dynamic_module_set_variables_from_data_py_4816193767510271467 import CollisionBehaviorAfter an engine restart, that module name no longer existed. The same file had been loaded under a different hash, producing the reported 4. Why
|
Add hermetic end-to-end coverage driving the stable-namespace behavior through public request paths in fresh subprocesses: - New image round trip: save a PNG through the production WriteFileRequest + image artifact provider metadata injection in one process, extract and deserialize it in another, and assert the embedded pickle carries the stable namespace with no volatile token. - Legacy recovery: a PNG referencing an old gtn_dynamic_module_<hash> name loads through the real extract handler once the stable fixture library is registered. - Public reload: ReloadAllLibrariesRequest hot-swaps edited source under the same stable namespace with no volatile module names left behind. - Sandbox lifecycle: RegisterSandboxNodeFromSourceRequest register/replace/fail/ recover, asserting the stable namespace and that a failed reload leaves the prior module object in sys.modules. - Namespace collision: two libraries whose sanitized names collide get distinct namespaces, survive unloading the winner, and flip cleanly on reverse-order reload with no stale leaf modules. Subprocess environments are isolated via per-test XDG config/data/cache dirs and stripped GTN_/GT_CLOUD_ overrides so nothing reads or writes real user state.
| def find_class(self, module: str, name: str) -> Any: | ||
| try: | ||
| return super().find_class(module, name) | ||
| except ModuleNotFoundError: |
There was a problem hiding this comment.
a computer told me this, so grain of salt:
Because find_class dynamically imports modules and searches for attributes, it can theoretically throw any exception associated with importing code or executing module-level code. For instance, if you override it or use custom objects, it could also result in an UnpicklingError or standard runtime exceptions.
…ndently Pickle recovery for library modules had three gaps: a module that lost a namespace collision was invisible to legacy ambiguity detection (its namespace leaf no longer matches the file stem), qualified nested-class names (protocol 4+) failed the single getattr lookup, and a collision load-order flip left previously pickled references unresolvable. Legacy volatile-name matching now compares against the tracked file stem, class lookup walks dotted qualified names through a shared helper, and a new resolve_collided_stable_class searches every loaded module that could have owned the referenced namespace in some load order, failing safely on ambiguity. The flow-commands unpickler also recovers from AttributeError so a base namespace owned by the other colliding file resolves instead of silently or loudly failing.
Route stable-namespace references through collision-aware resolution before the plain lookup, so a reference both colliding files can satisfy fails with the ambiguity error instead of silently resolving to whichever file currently owns the base namespace. Match legacy volatile tokens against the exact old-format file name (every '.' replaced with '_') instead of a normalized stem, so dotted file names resolve and hyphen/underscore siblings stay distinct. Make a tracked file's namespace sticky across hot reloads: a collision loser no longer claims the base namespace freed by the winner's unload, which would have left its old suffixed module registered and stale.
Generated workflow files decoded their pickled parameter values with plain pickle.loads and force-loaded library modules via hard deferred imports, both keyed to whichever namespace a colliding node file owned at save time. A later process that registers the colliding libraries in a different order could hard-fail the import or silently unpickle the wrong same-named class. Move the collision-aware unpickler out of FlowManager into a shared LibraryAwareUnpickler / loads_with_library_recovery in LibraryManager, emit that loader in generated unique-values code, and guard deferred library imports with try/except ImportError since unpickling now resolves the reference itself. Also rephrase the ambiguity error in terms of node files (a collision can live inside a single library, where 'disable one of the libraries' is impossible advice) and hoist the e2e driver's pervasive lazy imports to module scope, which is safe because the parent test isolates the child environment before the interpreter starts.
Canonicalizing the load path for identity also resolved symlinks before deriving the stable namespace and legacy token, so a node file loaded through a differently named symlink registered under the target's name and orphaned every reference persisted under the symlink's name. Track a StableModuleFile carrying both the canonical path (hot-reload/collision identity, collision suffix) and the logical path (namespace and legacy volatile-token naming). Also reference-count shared namespaces on unload: two library names that sanitize identically and point at the same canonical file share one module, and unloading either library previously ripped it out of sys.modules while the other library still served classes from it.
Loading a library whose sanitized name and canonical file resolve to an already loaded namespace re-executed the file and replaced the module object, stranding the first library's registry on a stale class generation (pickling its values could fail or resolve to the wrong generation). A new co-owner now reuses the existing module; re-execution is reserved for genuine hot reloads by a library that already owns the namespace.
Dragging an image onto the canvas failed with
No module named 'gtn_dynamic_module_set_variables_from_data_py_4816193767510271467'. Library node files were executed under a module name containinghash(str(path)), which Python randomizes per process, so any pickled value referencing a class from such a module could never unpickle after an engine restart. The workflow-save path masked this by patching__module__to a stable alias before pickling, but the image-metadata path had no such patch, and future pickle sites would have carried the same landmine.Rather than add the patch to another call site, this makes the existing stable namespace (
griptape_nodes.node_libraries.<lib>.<file>) the module's canonical name at load time. Classes carry a stable__module__by construction, so every pickle site is portable without per-site class patching. The namespace format remains byte-for-byte identical to the old alias because it is already embedded in saved-workflow pickles. Same-stem file collisions are detected and disambiguated rather than silently clobbering one another.The hashed name was not providing generation identity for hot reload. It existed before hot reload was added, and the reload implementation reused and replaced the same
sys.moduleskey. The stable loader preserves that lifecycle: it installs a fresh module object under the same key, restores the previous module if execution fails, and releases the old module after replacement. Tests verify that live old instances keep their old class generation while the old module and class are collected when their external references disappear.Images saved by earlier engines already contain volatile names, so the extract path also gets an
Unpicklerthat maps a failedgtn_dynamic_module_*reference to a uniquely matching loaded stable module. Recovery requires the requested class to be defined by that module. If multiple libraries match, extraction fails with an artist-readable ambiguity error rather than guessing and risking silent corruption. If nothing matches, the user is told to install or enable the originating library instead of receiving a rawModuleNotFoundError._patch_and_pickle_objectremains only to alignSerializableMixin.module_namefields before workflow serialization. The deeper issue, pickle coupling portable artifacts to Python import paths at all, is tracked in #4475.Note
Beep boop. This PR description was written by AI.