From 404d9f3047711d900e04573548745b869946f240 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 25 Aug 2026 14:10:22 -0700 Subject: [PATCH] Fix corrupted font resource loading in older documents --- .../document/document_message_handler.rs | 2 +- .../resource/resource_message_handler.rs | 101 +++++- .../messages/portfolio/document_migration.rs | 294 ++++++++++++------ .../resource_storage_message_handler.rs | 5 + .../src/application_io/resource/opfs.rs | 3 + .../nodes/gstd/src/platform_application_io.rs | 15 +- 6 files changed, 305 insertions(+), 115 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 1b4d80c568..5c6ce8c754 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -299,7 +299,7 @@ impl MessageHandler> for DocumentMes graph_operation_message_handler.process_message(message, responses, context); } DocumentMessage::Resource(message) => { - let context = ResourceMessageContext { document_id, fonts }; + let context = ResourceMessageContext { document_id, fonts, resource_storage }; self.resources.process_message(message, responses, context); } DocumentMessage::AlignSelectedLayers { axis, aggregate } => { diff --git a/editor/src/messages/portfolio/document/resource/resource_message_handler.rs b/editor/src/messages/portfolio/document/resource/resource_message_handler.rs index 7a45060149..ce1bf94b37 100644 --- a/editor/src/messages/portfolio/document/resource/resource_message_handler.rs +++ b/editor/src/messages/portfolio/document/resource/resource_message_handler.rs @@ -12,6 +12,7 @@ use url::Url; pub struct ResourceMessageContext<'a> { pub document_id: DocumentId, pub fonts: &'a FontsMessageHandler, + pub resource_storage: &'a ResourceStorageMessageHandler, } #[derive(Debug, Clone, PartialEq, Default, serde::Serialize, ExtractField)] @@ -25,7 +26,7 @@ pub struct ResourceMessageHandler { #[message_handler_data] impl MessageHandler> for ResourceMessageHandler { fn process_message(&mut self, message: ResourceMessage, responses: &mut VecDeque, context: ResourceMessageContext) { - let ResourceMessageContext { document_id, fonts } = context; + let ResourceMessageContext { document_id, fonts, resource_storage } = context; match message { ResourceMessage::StoreEmbedded { resource_id, data } => { @@ -48,8 +49,16 @@ impl MessageHandler> for ResourceMes responses.add(ResourceMessage::Resolve { resource_id }); } ResourceMessage::ResolveAll => { - let unresolved_ids: Vec = self.registry.unresolved().map(|info| info.id).collect(); - for id in unresolved_ids { + // A resource keeps its hash when storage evicts its data, so only a fetchable source can repair it + let refetchable = self + .registry + .resolved() + .filter(|info| info.hash.is_some_and(|hash| !resource_storage.contains(hash))) + .filter(|info| info.sources.iter().any(|source| matches!(source, DataSource::Url(_) | DataSource::Font { .. }))) + .map(|info| info.id); + let ids: Vec = self.registry.unresolved().map(|info| info.id).chain(refetchable).collect(); + + for id in ids { if self.pending_resolves.contains(&id) { continue; } @@ -65,7 +74,9 @@ impl MessageHandler> for ResourceMes log::error!("Resolve for {resource_id}: no registry entry"); return; }; - if info.hash.is_some() { + // This hash names the very data that is missing, so it cannot stand in for fetching that data + let data_missing = info.hash.is_some_and(|hash| !resource_storage.contains(hash)); + if info.hash.is_some() && !data_missing { log::warn!("Resource {resource_id} already resolved"); return; } @@ -78,7 +89,7 @@ impl MessageHandler> for ResourceMes .sources .iter() .map(|source| match source { - DataSource::Font { family, style } => { + DataSource::Font { family, style } if !data_missing => { let font = match style { Some(style) => Font::new(family.clone(), style.clone()), None => Font::new_with_default_style(family.clone()), @@ -214,16 +225,20 @@ impl ResourceMessageHandler { .resolved() .filter(|info| info.sources.contains(&DataSource::Embedded)) .filter_map(|info| { - if let Some(hash) = info.hash { - let resource = resources_load_handle.load(*hash); - Some(async move { resource.await.map(|resource| (*hash, resource)) }) - } else { - None - } + let (id, hash) = (info.id, *info.hash?); + let resource = resources_load_handle.load(hash); + Some(async move { (id, hash, resource.await) }) }) .collect::>(); - self.embedded = EmbeddedResources::from_iter(futures::future::join_all(embedded).await.into_iter().flatten()); + let loaded = futures::future::join_all(embedded).await; + + // Saving without these bytes writes a document whose registry claims to carry them + for (id, hash, _) in loaded.iter().filter(|(_, _, resource)| resource.is_none()) { + log::error!("Resource {id} ({hash}) is marked as embedded but its data is missing from storage, so the saved document will not contain it"); + } + + self.embedded = EmbeddedResources::from_iter(loaded.into_iter().filter_map(|(_, hash, resource)| resource.map(|resource| (hash, resource)))); } pub fn collect_garbage(&mut self, used: &[ResourceId]) { @@ -297,3 +312,65 @@ impl<'de> serde::Deserialize<'de> for ResourceMessageHandler { deserializer.deserialize_map(EmbeddedResourcesVisitor { human_readable }) } } + +#[cfg(test)] +mod tests { + use super::*; + use graph_craft::application_io::resource::ResourceStorage; + + /// Storage can lose a resource's data while the document keeps the hash naming it, which leaves the graph + /// pointing at bytes that are gone. Only sources that can be fetched again are worth re-resolving. + #[test] + fn resolve_all_refetches_resources_whose_data_is_missing() { + let mut handler = ResourceMessageHandler::default(); + let storage = ResourceStorageMessageHandler::default(); + + // Present: its bytes are in storage, so it is already usable + let present = ResourceId::new(); + let present_hash = storage.resources_mut().store(b"stored font bytes"); + handler.registry.resolve(&present, present_hash); + handler.registry.push_source_back(&present, DataSource::Embedded); + handler.registry.push_source_back( + &present, + DataSource::Font { + family: "Lato".into(), + style: Some("Regular (400)".into()), + }, + ); + + // Recoverable: its bytes are gone, but the font it came from can be downloaded again + let recoverable = ResourceId::new(); + handler.registry.resolve(&recoverable, ResourceHash::from(b"evicted font bytes".as_slice())); + handler.registry.push_source_back(&recoverable, DataSource::Embedded); + handler.registry.push_source_back( + &recoverable, + DataSource::Font { + family: "Lato".into(), + style: Some("Black (900)".into()), + }, + ); + + // Unrecoverable: its bytes are gone and nothing records where to fetch them from + let unrecoverable = ResourceId::new(); + handler.registry.resolve(&unrecoverable, ResourceHash::from(b"evicted image bytes".as_slice())); + handler.registry.push_source_back(&unrecoverable, DataSource::Embedded); + + let mut responses = VecDeque::new(); + let fonts = FontsMessageHandler::default(); + handler.process_message( + ResourceMessage::ResolveAll, + &mut responses, + ResourceMessageContext { + document_id: DocumentId(0), + fonts: &fonts, + resource_storage: &storage, + }, + ); + + let resolve_requested = |id: ResourceId| responses.contains(&Message::from(ResourceMessage::Resolve { resource_id: id })); + + assert!(resolve_requested(recoverable), "a missing resource with a font source should be fetched again"); + assert!(!resolve_requested(present), "a resource whose data is in storage should be left alone"); + assert!(!resolve_requested(unrecoverable), "a missing resource with no fetchable source has nowhere to fetch from"); + } +} diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index d93ab6e615..14a0196ecb 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1979,79 +1979,38 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], } } - // Upgrade Text node to include line height and character spacing, which were previously hardcoded to 1, from https://github.com/GraphiteEditor/Graphite/pull/2016 - if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode")) && inputs_count == 8 { + // Every Text node era before alignment only appended inputs, so each is a prefix of the 11-input layout. + // Alignment (#2920) is the exception: it landed at index 9 and pushed Per-Glyph Instances out to 10. + if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode")) && (4..=10).contains(&inputs_count) { let mut template: NodeTemplate = legacy_text_node_template()?; document.network_interface.replace_implementation(node_id, network_path, &mut template); let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut template)?; - document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), old_inputs[0].clone(), network_path); - document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[1].clone(), network_path); - document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 2), old_inputs[2].clone(), network_path); - document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 3), old_inputs[3].clone(), network_path); - document.network_interface.set_input( - &InputConnector::node_at_index(*node_id, 4), - if inputs_count == 6 { - old_inputs[4].clone() - } else { - NodeInput::value(TaggedValue::F64(TypesettingConfig::default().line_height_ratio), false) - }, - network_path, - ); - document.network_interface.set_input( - &InputConnector::node_at_index(*node_id, 5), - if inputs_count == 6 { - old_inputs[5].clone() - } else { - NodeInput::value(TaggedValue::F64(TypesettingConfig::default().letter_spacing), false) - }, - network_path, - ); - document.network_interface.set_input( - &InputConnector::node_at_index(*node_id, 6), - if inputs_count >= 7 { - old_inputs[6].clone() - } else { - NodeInput::value(TaggedValue::F64(TypesettingConfig::default().max_width.unwrap_or_default()), false) - }, - network_path, - ); - document.network_interface.set_input( - &InputConnector::node_at_index(*node_id, 7), - if inputs_count >= 8 { - old_inputs[7].clone() - } else { - NodeInput::value(TaggedValue::F64(TypesettingConfig::default().max_width.unwrap_or_default()), false) - }, - network_path, - ); - document.network_interface.set_input( - &InputConnector::node_at_index(*node_id, 8), - if inputs_count >= 9 { - old_inputs[8].clone() - } else { - NodeInput::value(TaggedValue::F64(TypesettingConfig::default().letter_tilt), false) - }, - network_path, - ); - document.network_interface.set_input( - &InputConnector::node_at_index(*node_id, 9), - if inputs_count >= 10 { - old_inputs[9].clone() - } else { - NodeInput::value(TaggedValue::TextAlign(TextAlign::default()), false) - }, - network_path, - ); - document.network_interface.set_input( - &InputConnector::node_at_index(*node_id, 10), - if inputs_count >= 11 { - old_inputs[10].clone() - } else { - NodeInput::value(TaggedValue::Bool(false), false) - }, - network_path, - ); + // Line height and character spacing were hardcoded to 1 in the era before they became inputs + let hardcoded_to_one = || NodeInput::value(TaggedValue::F64(1.), false); + // Zero is how an absent `Option` maximum reads to the split below + let unset_maximum = || NodeInput::value(TaggedValue::F64(0.), false); + + let upgraded_inputs = [ + old_inputs[0].clone(), + old_inputs[1].clone(), + old_inputs[2].clone(), + old_inputs[3].clone(), + old_inputs.get(4).cloned().unwrap_or_else(hardcoded_to_one), + old_inputs.get(5).cloned().unwrap_or_else(hardcoded_to_one), + old_inputs.get(6).cloned().unwrap_or_else(unset_maximum), + old_inputs.get(7).cloned().unwrap_or_else(unset_maximum), + old_inputs + .get(8) + .cloned() + .unwrap_or_else(|| NodeInput::value(TaggedValue::F64(TypesettingConfig::default().letter_tilt), false)), + NodeInput::value(TaggedValue::TextAlign(TextAlign::default()), false), + old_inputs.get(9).cloned().unwrap_or_else(|| NodeInput::value(TaggedValue::Bool(false), false)), + ]; + for (index, input) in upgraded_inputs.into_iter().enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input, network_path); + } + inputs_count = 11 } @@ -2068,31 +2027,26 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document.network_interface.set_input(&InputConnector::node_at_index(*node_id, i), old_inputs[i].clone(), network_path); } + // The old `Option` maximum becomes a bool plus a value, with zero standing in for the absent option. + // A wired maximum has no value to read, so it keeps its connection and counts as present. + let split_maximum = |input: &NodeInput| match input.as_value() { + Some(&TaggedValue::F64(maximum)) => (maximum != 0., NodeInput::value(TaggedValue::F64(if maximum == 0. { 100. } else { maximum }), false)), + _ => (true, input.clone()), + }; + // Max Width - let Some(&TaggedValue::F64(old_max_width)) = old_inputs[6].as_value() else { return None }; - document.network_interface.set_input( - &InputConnector::node_at_index(*node_id, 6), - NodeInput::value(TaggedValue::Bool(old_max_width != 0.), false), - network_path, - ); - document.network_interface.set_input( - &InputConnector::node_at_index(*node_id, 7), - NodeInput::value(TaggedValue::F64(if old_max_width == 0. { 100. } else { old_max_width }), false), - network_path, - ); + let (has_max_width, max_width) = split_maximum(&old_inputs[6]); + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, 6), NodeInput::value(TaggedValue::Bool(has_max_width), false), network_path); + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 7), max_width, network_path); // Max Height - let Some(&TaggedValue::F64(old_max_height)) = old_inputs[7].as_value() else { return None }; - document.network_interface.set_input( - &InputConnector::node_at_index(*node_id, 8), - NodeInput::value(TaggedValue::Bool(old_max_height != 0.), false), - network_path, - ); - document.network_interface.set_input( - &InputConnector::node_at_index(*node_id, 9), - NodeInput::value(TaggedValue::F64(if old_max_height == 0. { 100. } else { old_max_height }), false), - network_path, - ); + let (has_max_height, max_height) = split_maximum(&old_inputs[7]); + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, 8), NodeInput::value(TaggedValue::Bool(has_max_height), false), network_path); + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 9), max_height, network_path); // Copy over old inputs #[allow(clippy::needless_range_loop)] @@ -2441,6 +2395,39 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document.network_interface.add_import(TaggedValue::U32(0), false, 1, "Loop Level", "TODO", &node_path); } + // Drop the placeholder primary input the "Read Vector" node used to carry, since it reads its value from the context + if reference == DefinitionIdentifier::ProtoNode(graphene_std::context::read_vector::IDENTIFIER) && inputs_count > 0 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + } + + // The "Dot Product" node gained a "Normalize" toggle, which older nodes predate by always taking the raw dot product + if reference == DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::dot_product::IDENTIFIER) && inputs_count == 2 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + + for (index, input) in old_inputs.iter().take(2).enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path); + } + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, 2), NodeInput::value(TaggedValue::Bool(false), false), network_path); + } + + // The "Query JSON" node succeeded "JSON Get", whose object lookups always returned their strings unquoted + if reference == DefinitionIdentifier::ProtoNode(graphene_std::text_nodes::json::query_json::IDENTIFIER) && inputs_count == 2 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + + for (index, input) in old_inputs.iter().take(2).enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path); + } + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, 2), NodeInput::value(TaggedValue::Bool(true), false), network_path); + } + // Upgrade the "Animation" node to add the "Rate" input if reference == DefinitionIdentifier::ProtoNode(graphene_std::animation::animation_time::IDENTIFIER) && inputs_count < 2 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); @@ -2883,19 +2870,39 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], /// definition by its old reference name, swaps it to a still-supported implementation, and preserves the user's inputs. /// After this runs, the node's reference resolves cleanly so the rest of `migrate_node` proceeds normally. fn migrate_removed_catalog_definitions(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document: &mut DocumentMessageHandler) -> Option<()> { - // Collapse the legacy "Sample Polyline" wrapper network into the standalone `sample_polyline` proto node. - // The proto node now computes per-bezpath segment lengths inline, so the wrapper's separate `subpath_segment_lengths` - // and `Memoize` nodes are no longer needed. The 7 user-facing inputs are positionally identical between the - // old wrapper and the new proto node. - if let Some(DefinitionIdentifier::Network(name)) = document.network_interface.reference(node_id, network_path) - && name == "Sample Polyline" - && node.inputs.len() == 7 + // Collapse the legacy "Sample Points" and "Sample Polyline" wrapper networks into the standalone `sample_polyline` + // proto node, which now computes per-bezpath segment lengths inline instead of through the wrapper's helper nodes. + // The oldest documents lose their stored reference on load, so the wrapper is recognized by the nodes it encloses. + let wrapper_inputs = match &node.implementation { + DocumentNodeImplementation::Network(inner) => { + let helpers = [graphene_std::ops::passthrough::IDENTIFIER, graphene_std::memo::memoize::IDENTIFIER]; + let mut sample_nodes = 0; + let only_helpers = inner.nodes.values().all(|inner_node| match &inner_node.implementation { + DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::vector::sample_polyline::IDENTIFIER => { + sample_nodes += 1; + true + } + DocumentNodeImplementation::ProtoNode(identifier) => helpers.contains(identifier), + _ => false, + }); + (only_helpers && sample_nodes == 1).then_some(node.inputs.len()) + } + _ => None, + }; + if let Some(wrapper_inputs) = wrapper_inputs + && (wrapper_inputs == 5 || wrapper_inputs == 7) { let mut node_template = resolve_proto_node_type(graphene_std::vector::sample_polyline::IDENTIFIER)?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; - for (index, input) in old_inputs.iter().take(7).enumerate() { - document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path); + + // The 5-input era predates the separation/quantity choice, so its lone spacing distance becomes the separation + let upgraded_inputs: Vec<(usize, NodeInput)> = match wrapper_inputs { + 5 => [0, 2, 4, 5, 6].into_iter().zip(old_inputs.iter().cloned()).collect(), + _ => old_inputs.iter().take(7).cloned().enumerate().collect(), + }; + for (index, input) in upgraded_inputs { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input, network_path); } } @@ -2984,6 +2991,93 @@ mod tests { } } + // The Text node produced geometry until it became a string source, so every shape it ever had must reach the + // current one and gain the converter that turns its string back into geometry + #[test] + fn every_legacy_text_shape_gains_its_geometry_converter() { + use crate::messages::portfolio::document::utility_types::network_interface::NodeTemplate; + use graphene_std::NodeParameter; + use graphene_std::text::Font; + + // Each era only appended to the one before it, so the shorter shapes are prefixes of this longest pre-alignment one + let legacy_inputs = [ + NodeInput::scope("editor-api"), + NodeInput::value(TaggedValue::String("Lorem".into()), false), + NodeInput::value(TaggedValue::Font(Font::new("Lato".to_string(), "Regular (400)".to_string())), false), + NodeInput::value(TaggedValue::F64(48.), false), + NodeInput::value(TaggedValue::F64(1.5), false), + NodeInput::value(TaggedValue::F64(2.), false), + NodeInput::value(TaggedValue::F64(0.), false), + NodeInput::value(TaggedValue::F64(0.), false), + NodeInput::value(TaggedValue::F64(10.), false), + NodeInput::value(TaggedValue::Bool(false), false), + ]; + + for shape in [4, 6, 8, 9, 10] { + let (text_id, consumer_id) = (NodeId(1), NodeId(2)); + let mut document = DocumentMessageHandler::default(); + document.network_interface.insert_node( + text_id, + NodeTemplate { + implementation: NodeTemplateImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode")), + inputs: legacy_inputs[..shape].to_vec(), + ..Default::default() + }, + &[], + ); + document.network_interface.insert_node( + consumer_id, + NodeTemplate { + inputs: vec![NodeInput::value(TaggedValue::None, false)], + ..Default::default() + }, + &[], + ); + document.network_interface.set_input(&InputConnector::node_at_index(consumer_id, 0), NodeInput::node(text_id, 0), &[]); + + document_migration_upgrades(&mut document, false); + + let network = document.network_interface.document_network(); + let text_node = network.nodes.get(&text_id).expect("the upgraded text node should keep its ID"); + assert_eq!(text_node.inputs.len(), 12, "a {shape}-input text node should reach the current shape"); + + // The converter is a new node, so it is found by identity rather than by ID + let converter = network + .nodes + .iter() + .find(|(_, node)| matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::text::text_to_vector::IDENTIFIER)) + .map(|(converter_id, _)| *converter_id) + .unwrap_or_else(|| panic!("a {shape}-input text node should gain a string converter")); + assert_eq!( + network.nodes[&consumer_id].inputs.first(), + Some(&NodeInput::node(converter, 0)), + "the converter should be spliced onto the wire leaving a {shape}-input text node" + ); + + let input_value = |index: usize| text_node.inputs.get(index).and_then(|input| input.as_value()).cloned(); + assert_eq!(input_value(graphene_std::text::text::SizeInput::INDEX), Some(TaggedValue::F64(48.)), "shape {shape} lost its size"); + if shape >= 6 { + assert_eq!( + input_value(graphene_std::text::text::LineHeightInput::INDEX), + Some(TaggedValue::F64(1.5)), + "shape {shape} lost its line height" + ); + assert_eq!( + input_value(graphene_std::text::text::LetterSpacingInput::INDEX), + Some(TaggedValue::F64(2.)), + "shape {shape} lost its letter spacing" + ); + } + if shape >= 9 { + assert_eq!( + input_value(graphene_std::text::text::LetterTiltInput::INDEX), + Some(TaggedValue::F64(10.)), + "shape {shape} lost its letter tilt" + ); + } + } + } + #[test] fn test_no_duplicate_node_replacements() { let mut hashmap = HashMap::::new(); diff --git a/editor/src/messages/resource_storage/resource_storage_message_handler.rs b/editor/src/messages/resource_storage/resource_storage_message_handler.rs index 26001fb948..0f5dc35516 100644 --- a/editor/src/messages/resource_storage/resource_storage_message_handler.rs +++ b/editor/src/messages/resource_storage/resource_storage_message_handler.rs @@ -48,6 +48,11 @@ impl ResourceStorageMessageHandler { inner: self.storage.clone().expect("Resource storage not initialized"), } } + + /// Whether the resource's data is held in storage, assuming it is until storage is initialized. + pub fn contains(&self, hash: &ResourceHash) -> bool { + self.storage.as_ref().is_none_or(|storage| storage.contains(hash)) + } } impl std::fmt::Debug for ResourceStorageMessageHandler { diff --git a/node-graph/graph-craft/src/application_io/resource/opfs.rs b/node-graph/graph-craft/src/application_io/resource/opfs.rs index 261a172b64..e023db78b5 100644 --- a/node-graph/graph-craft/src/application_io/resource/opfs.rs +++ b/node-graph/graph-craft/src/application_io/resource/opfs.rs @@ -153,6 +153,9 @@ async fn drain_queue(inner: Arc>) { Mutation::Write { hash, bytes } => { if let Err(error) = write_file(&directory, &hash, &bytes).await { log::error!("OPFS write for {hash} failed: {error:?}"); + + // Nothing reached disk, so leaving the hash listed would claim a file that later sessions cannot read + inner.lock().unwrap().on_disk.remove(&hash); } } Mutation::Delete { hash } => { diff --git a/node-graph/nodes/gstd/src/platform_application_io.rs b/node-graph/nodes/gstd/src/platform_application_io.rs index 8b71b4f7c5..13ad491d0e 100644 --- a/node-graph/nodes/gstd/src/platform_application_io.rs +++ b/node-graph/nodes/gstd/src/platform_application_io.rs @@ -269,8 +269,19 @@ pub async fn resource<'a: 'n>( hash: Item, ) -> Item { let hash = hash.into_element(); - let application_io = editor_api.into_element().application_io.as_ref().expect("ApplicationIo must be available when using resources"); - let resource = application_io.load_resource(hash).await.unwrap_or_else(|| panic!("Resource {hash} not found")); + let placeholder = || -> Item { Item::new_from_element(Resource::empty()) }; + + let Some(application_io) = editor_api.into_element().application_io.as_ref() else { + log::error!("Resource {hash} is unavailable because the platform's application IO is missing"); + return placeholder(); + }; + + // Stored bytes go missing when the browser evicts its storage or a write is interrupted + let Some(resource) = application_io.load_resource(hash).await else { + log::error!("Resource {hash} was not found in storage"); + return placeholder(); + }; + Item::new_from_element(resource) }