Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion editor/src/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ impl Dispatcher {
s
}

#[cfg(test)]
pub fn with_executor(executor: crate::node_graph_executor::NodeGraphExecutor) -> Self {
let mut s = Self::default();
let mut s = Self::new(Arc::new(graph_craft::application_io::resource::HashMapResourceStorage::new()), None);
s.message_handlers.portfolio_message_handler = PortfolioMessageHandler::with_executor(executor);
s
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::messages::portfolio::{document::resource::utility_types::EmbeddedReso
use crate::messages::prelude::*;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use graph_craft::application_io::resource::{DataSource, LoadResource, Resource, ResourceHash, ResourceId, ResourceRegistry};
use graph_craft::application_io::resource::{DataSource, LoadResource, Resource, ResourceHash, ResourceId, ResourceRegistry, ResourceStorage};
use graphene_std::text::Font;
use std::sync::Arc;
use url::Url;
Expand Down Expand Up @@ -49,15 +49,8 @@ impl MessageHandler<ResourceMessage, ResourceMessageContext<'_>> for ResourceMes
responses.add(ResourceMessage::Resolve { resource_id });
}
ResourceMessage::ResolveAll => {
// 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<ResourceId> = self.registry.unresolved().map(|info| info.id).chain(refetchable).collect();

let storage = resource_storage.resources_mut();
let ids: Vec<ResourceId> = self.registry.ids().filter(|id| !self.registry.hash(id).is_some_and(|hash| storage.contains(&hash))).collect();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: ResolveAll now re-resolves every resolved resource whose bytes are missing, including Embedded-only resources that have no fetchable source. For those, Resolve's DataSource::Embedded => continue can never recover the data, so each document load produces a wasted resolve that always exhausts sources and logs "all sources exhausted". The previous code deliberately filtered to sources that can be refetched (URL/Font); restore that so unrecoverable embedded resources aren't re-resolved.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/messages/portfolio/document/resource/resource_message_handler.rs, line 53:

<comment>`ResolveAll` now re-resolves every resolved resource whose bytes are missing, including Embedded-only resources that have no fetchable source. For those, `Resolve`'s `DataSource::Embedded => continue` can never recover the data, so each document load produces a wasted resolve that always exhausts sources and logs "all sources exhausted". The previous code deliberately filtered to sources that can be refetched (URL/Font); restore that so unrecoverable embedded resources aren't re-resolved.</comment>

<file context>
@@ -49,15 +49,8 @@ impl MessageHandler<ResourceMessage, ResourceMessageContext<'_>> for ResourceMes
-				let ids: Vec<ResourceId> = self.registry.unresolved().map(|info| info.id).chain(refetchable).collect();
-
+				let storage = resource_storage.resources_mut();
+				let ids: Vec<ResourceId> = self.registry.ids().filter(|id| !self.registry.hash(id).is_some_and(|hash| storage.contains(&hash))).collect();
 				for id in ids {
 					if self.pending_resolves.contains(&id) {
</file context>

for id in ids {
if self.pending_resolves.contains(&id) {
continue;
Expand All @@ -74,9 +67,7 @@ impl MessageHandler<ResourceMessage, ResourceMessageContext<'_>> for ResourceMes
log::error!("Resolve for {resource_id}: no registry entry");
return;
};
// 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 {
if info.hash.is_some_and(|hash| resource_storage.resources_mut().contains(hash)) {
log::warn!("Resource {resource_id} already resolved");
return;
}
Expand All @@ -89,7 +80,7 @@ impl MessageHandler<ResourceMessage, ResourceMessageContext<'_>> for ResourceMes
.sources
.iter()
.map(|source| match source {
DataSource::Font { family, style } if !data_missing => {
DataSource::Font { family, style } => {
let font = match style {
Some(style) => Font::new(family.clone(), style.clone()),
None => Font::new_with_default_style(family.clone()),
Comment on lines +83 to 86

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the registry hash is missing from storage but FontsMessageHandler still remembers that font, this branch treats the cached hash as a successful resolution without restoring any bytes. Check the cached hash with resource storage before returning it; otherwise let the font source fall through to download.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/messages/portfolio/document/resource/resource_message_handler.rs, line 83:

<comment>When the registry hash is missing from storage but `FontsMessageHandler` still remembers that font, this branch treats the cached hash as a successful resolution without restoring any bytes. Check the cached hash with resource storage before returning it; otherwise let the font source fall through to download.</comment>

<file context>
@@ -89,7 +80,7 @@ impl MessageHandler<ResourceMessage, ResourceMessageContext<'_>> for ResourceMes
 					.iter()
 					.map(|source| match source {
-						DataSource::Font { family, style } if !data_missing => {
+						DataSource::Font { family, style } => {
 							let font = match style {
 								Some(style) => Font::new(family.clone(), style.clone()),
</file context>

Expand Down Expand Up @@ -225,20 +216,16 @@ impl ResourceMessageHandler {
.resolved()
.filter(|info| info.sources.contains(&DataSource::Embedded))
.filter_map(|info| {
let (id, hash) = (info.id, *info.hash?);
let resource = resources_load_handle.load(hash);
Some(async move { (id, hash, resource.await) })
if let Some(hash) = info.hash {
let resource = resources_load_handle.load(*hash);
Some(async move { resource.await.map(|resource| (*hash, resource)) })
} else {
None
}
})
.collect::<Vec<_>>();

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))));
self.embedded = EmbeddedResources::from_iter(futures::future::join_all(embedded).await.into_iter().flatten());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an embedded resource is missing from storage, flatten() silently drops its bytes while the registry still claims they are embedded, so saving produces an incomplete document. Keep the missing-resource diagnostic or abort the save instead of silently dropping the entry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/messages/portfolio/document/resource/resource_message_handler.rs, line 228:

<comment>When an embedded resource is missing from storage, `flatten()` silently drops its bytes while the registry still claims they are embedded, so saving produces an incomplete document. Keep the missing-resource diagnostic or abort the save instead of silently dropping the entry.</comment>

<file context>
@@ -225,20 +216,16 @@ impl ResourceMessageHandler {
-		}
-
-		self.embedded = EmbeddedResources::from_iter(loaded.into_iter().filter_map(|(_, hash, resource)| resource.map(|resource| (hash, resource))));
+		self.embedded = EmbeddedResources::from_iter(futures::future::join_all(embedded).await.into_iter().flatten());
 	}
 
</file context>

}

pub fn collect_garbage(&mut self, used: &[ResourceId]) {
Expand Down Expand Up @@ -318,45 +305,28 @@ 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() {
fn resolve_all_refetches_resources_whose_bytes_are_missing() {
let mut handler = ResourceMessageHandler::default();
let storage = ResourceStorageMessageHandler::default();
let fonts = FontsMessageHandler::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()),
},
);
let font = |style: &str| DataSource::Font {
family: "Lato".into(),
style: Some(style.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()),
},
);
let cached = ResourceId::from(1);
let cached_hash = storage.resources_mut().store(b"stored font bytes");
handler.registry.resolve(&cached, cached_hash);
handler.registry.push_source_back(&cached, font("Regular (400)"));

// 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 evicted = ResourceId::from(2);
let evicted_hash = ResourceHash::from(b"evicted font bytes".as_slice());
handler.registry.resolve(&evicted, evicted_hash);
handler.registry.push_source_back(&evicted, font("Black (900)"));

let mut responses = VecDeque::new();
let fonts = FontsMessageHandler::default();
handler.process_message(
ResourceMessage::ResolveAll,
&mut responses,
Expand All @@ -368,9 +338,8 @@ mod tests {
);

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");
assert!(resolve_requested(evicted), "a resource whose bytes are gone should be resolved again");
assert!(!resolve_requested(cached), "a resource whose bytes are in storage should be left alone");
assert_eq!(handler.registry.hash(&evicted), Some(evicted_hash), "the hash keeps naming the content");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,6 @@ 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 {
Expand Down
3 changes: 0 additions & 3 deletions node-graph/graph-craft/src/application_io/resource/opfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,6 @@ async fn drain_queue(inner: Arc<Mutex<Inner>>) {
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a newly stored hash's background OPFS write fails, retaining on_disk prevents later store calls from retrying it, leaving the resource only in cache and risking its loss after reload. Clear the marker or schedule a retry when the write fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node-graph/graph-craft/src/application_io/resource/opfs.rs, line 158:

<comment>When a newly stored hash's background OPFS write fails, retaining `on_disk` prevents later `store` calls from retrying it, leaving the resource only in `cache` and risking its loss after reload. Clear the marker or schedule a retry when the write fails.</comment>

<file context>
@@ -153,9 +153,6 @@ async fn drain_queue(inner: Arc<Mutex<Inner>>) {
-					inner.lock().unwrap().on_disk.remove(&hash);
 				}
 			}
 			Mutation::Delete { hash } => {
</file context>

}
}
Mutation::Delete { hash } => {
Expand Down
15 changes: 2 additions & 13 deletions node-graph/nodes/gstd/src/platform_application_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,19 +269,8 @@ pub async fn resource<'a: 'n>(
hash: Item<ResourceHash>,
) -> Item<Resource> {
let hash = hash.into_element();
let placeholder = || -> Item<Resource> { 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();
};

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"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The resource node now panics when the platform application IO is absent or a resource's data is missing, instead of returning the previous graceful empty-Resource fallback. A missing resource is exactly the case this PR targets (older documents with corrupted/evicted font data), and on the WASM target a panic! aborts the whole application rather than degrading the single resource. Restore graceful handling: log the error and return Resource::empty() as before.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node-graph/nodes/gstd/src/platform_application_io.rs, line 273:

<comment>The `resource` node now panics when the platform application IO is absent or a resource's data is missing, instead of returning the previous graceful empty-`Resource` fallback. A missing resource is exactly the case this PR targets (older documents with corrupted/evicted font data), and on the WASM target a `panic!` aborts the whole application rather than degrading the single resource. Restore graceful handling: log the error and return `Resource::empty()` as before.</comment>

<file context>
@@ -269,19 +269,8 @@ pub async fn resource<'a: 'n>(
-	};
-
+	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"));
 	Item::new_from_element(resource)
 }
</file context>

Item::new_from_element(resource)
}

Expand Down
Loading