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
5 changes: 5 additions & 0 deletions desktop/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ impl App {
});
let desktop_wrapper = DesktopWrapper::new(rand::rng().random(), Arc::new(resource_storage), dirs::app_autosave_documents_dir(), wgpu_context.clone(), wake);

let completion_render_sender = start_render_sender.clone();
DesktopWrapper::set_completion_notifier(move || {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
let _ = completion_render_sender.try_send(());
});

Self {
render_state: None,
wgpu_context,
Expand Down
4 changes: 4 additions & 0 deletions desktop/wrapper/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ impl DesktopWrapper {
executor.execute()
}

pub fn set_completion_notifier(notifier: impl Fn() + Send + Sync + 'static) {
graphite_editor::node_graph_executor::set_completion_notifier(Arc::new(notifier));
}

pub async fn execute_node_graph() -> NodeGraphExecutionResult {
let result = graphite_editor::node_graph_executor::run_node_graph().await;
match result {
Expand Down
16 changes: 15 additions & 1 deletion editor/src/node_graph_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ pub struct NodeGraphExecutor {
runtime_io: NodeRuntimeIO,
current_execution_id: u64,
futures: VecDeque<(u64, ExecutionContext)>,
/// The most recently consumed plain render execution, kept so a runtime-replayed response with the same id
/// (sent after an async source completion) finds its context again.
last_execution_context: Option<(u64, ExecutionContext)>,
node_graph_hash: u64,
/// Full path from the root document network to the node currently being inspected by the Data panel, or empty if nothing is selected.
/// The last element is the inspect target itself; preceding elements identify the nested subnetwork the node lives in,
Expand Down Expand Up @@ -108,6 +111,7 @@ impl NodeGraphExecutor {
let node_executor = Self {
futures: Default::default(),
runtime_io: NodeRuntimeIO::with_channels(request_sender, response_receiver),
last_execution_context: None,
node_graph_hash: 0,
current_execution_id: 0,
previous_node_to_inspect: Vec::new(),
Expand Down Expand Up @@ -377,9 +381,19 @@ impl NodeGraphExecutor {

let execution_context = if self.futures.front().is_some_and(|&(queued_execution_id, _)| queued_execution_id == execution_id) {
let (_, execution_context) = self.futures.pop_front().expect("front was just matched");
self.last_execution_context = Some((execution_id, execution_context.clone()));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
execution_context
} else {
panic!("InvalidGenerationId")
// A runtime-replayed response re-uses an already consumed id; only plain renders may re-apply.
match &self.last_execution_context {
Some((last_execution_id, execution_context)) if *last_execution_id == execution_id => {
if execution_context.export_config.is_some() || execution_context.measure_fill.is_some() {
continue;
}
execution_context.clone()
}
_ => panic!("InvalidGenerationId"),
}
};

// TODO: Eventually remove this document upgrade code
Expand Down
81 changes: 78 additions & 3 deletions editor/src/node_graph_executor/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use graphene_std::ops::ConvertAsync;
use graphene_std::platform_application_io::canvas_utils::{Canvas, CanvasSurface, CanvasSurfaceHandle};
use graphene_std::raster_types::Raster;
use graphene_std::renderer::{Render, RenderParams, RenderSvgSegmentList, SvgRender, SvgSegment};
use graphene_std::runtime::{DynGraphRuntime, DynSpawner, GraphRuntime, NoopSpawner, RuntimeHandle};
use graphene_std::runtime::{DynGraphRuntime, DynNotifier, DynSpawner, GraphRuntime, RuntimeHandle, SourceFuture, Spawner, poll_once};
use graphene_std::transform::RenderQuality;
use graphene_std::vector::Vector;
use graphene_std::vector::style::RenderMode;
Expand All @@ -41,8 +41,9 @@ pub struct NodeRuntime {
editor_preferences: EditorPreferences,
old_graph: Option<NodeNetwork>,
update_thumbnails: bool,
#[expect(dead_code, reason = "read once the host wires a notifier onto the runtime")]
graph_runtime: Arc<DynGraphRuntime>,
/// The last plain render request, replayed when an async source completion marks the graph dirty.
last_render: Option<ExecutionRequest>,

editor_api: Arc<PlatformEditorApi>,
resources: ResourceRegistry,
Expand Down Expand Up @@ -122,9 +123,67 @@ impl NodeGraphUpdateSender for InternalNodeGraphUpdateSender {
// TODO: Replace with `core::cell::LazyCell` (<https://doc.rust-lang.org/core/cell/struct.LazyCell.html>) or similar
pub static NODE_RUNTIME: once_cell::sync::Lazy<Mutex<Option<NodeRuntime>>> = once_cell::sync::Lazy::new(|| Mutex::new(None));

#[cfg(not(target_family = "wasm"))]
pub struct TokioSpawner(Option<tokio::runtime::Runtime>);

#[cfg(not(target_family = "wasm"))]
impl TokioSpawner {
pub fn new() -> Self {
Self(Some(tokio::runtime::Runtime::new().expect("Failed to start the async source runtime")))
Comment thread
TrueDoctor marked this conversation as resolved.
}
}

#[cfg(not(target_family = "wasm"))]
impl Default for TokioSpawner {
fn default() -> Self {
Self::new()
}
}

#[cfg(not(target_family = "wasm"))]
impl Spawner for TokioSpawner {
fn spawn(&self, mut task: SourceFuture) -> bool {
let runtime = self.0.as_ref().expect("runtime lives until drop");
let _guard = runtime.enter();
if poll_once(&mut task) {

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: The TokioSpawner now duplicates identical poll_once + runtime.enter + runtime.spawn logic in both the editor (editor/src/node_graph_executor/runtime.rs) and the CLI (node-graph/graphene-cli/src/main.rs). Since these do the same thing, consider extracting a shared host spawner (e.g. in graphene_std runtime) so the inline-poll behavior stays in one place and can't drift between the two hosts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/node_graph_executor/runtime.rs, line 148:

<comment>The TokioSpawner now duplicates identical `poll_once` + `runtime.enter` + `runtime.spawn` logic in both the editor (editor/src/node_graph_executor/runtime.rs) and the CLI (node-graph/graphene-cli/src/main.rs). Since these do the same thing, consider extracting a shared host spawner (e.g. in graphene_std runtime) so the inline-poll behavior stays in one place and can't drift between the two hosts.</comment>

<file context>
@@ -142,8 +142,14 @@ impl Default for TokioSpawner {
+	fn spawn(&self, mut task: SourceFuture) -> bool {
+		let runtime = self.0.as_ref().expect("runtime lives until drop");
+		let _guard = runtime.enter();
+		if poll_once(&mut task) {
+			return true;
+		}
</file context>

return true;
}
runtime.spawn(task);
false
}
}

/// Dropping a tokio runtime blocks on its tasks, which panics inside an async context; the tests drop
/// [`NodeRuntime`] from one, so shut down in the background instead.
#[cfg(not(target_family = "wasm"))]
impl Drop for TokioSpawner {
fn drop(&mut self) {
if let Some(runtime) = self.0.take() {
runtime.shutdown_background();
}
}
}

#[cfg(target_family = "wasm")]
pub struct WasmSpawner;

#[cfg(target_family = "wasm")]
impl Spawner for WasmSpawner {
fn spawn(&self, mut task: SourceFuture) -> bool {
if poll_once(&mut task) {
return true;
}
wasm_bindgen_futures::spawn_local(task);
false
}
}

impl NodeRuntime {
pub fn new(receiver: Receiver<GraphRuntimeRequest>, sender: Sender<NodeGraphUpdate>) -> Self {
let spawner: Box<DynSpawner> = Box::new(NoopSpawner);
#[cfg(not(target_family = "wasm"))]
let spawner: Box<DynSpawner> = Box::new(TokioSpawner::new());
#[cfg(target_family = "wasm")]
let spawner: Box<DynSpawner> = Box::new(WasmSpawner);
let graph_runtime: Arc<DynGraphRuntime> = Arc::new(GraphRuntime::new(spawner));
let mut executor = DynamicExecutor::default();
executor.set_runtime(Arc::clone(&graph_runtime));
Expand All @@ -138,6 +197,7 @@ impl NodeRuntime {
resources: ResourceRegistry::default(),
update_thumbnails: true,
graph_runtime: Arc::clone(&graph_runtime),
last_render: None,

editor_api: PlatformEditorApi {
editor_preferences: Box::new(EditorPreferences::default()),
Expand Down Expand Up @@ -188,6 +248,10 @@ impl NodeRuntime {
}

let for_export = execution_request.render_config.for_export;
if !for_export {
self.last_render = Some(execution_request.clone());
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}

execution = Some(request);

// If we get an export request we always execute it immedeatly otherwise it could get deduplicated
Expand All @@ -207,6 +271,10 @@ impl NodeRuntime {
eyedropper.render_config.pointer = execution.render_config.pointer;
}

if self.executor.take_dirty() && execution.is_none() {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
execution = self.last_render.clone().map(GraphRuntimeRequest::ExecutionRequest);
}

let requests = [preferences, graph, eyedropper, execution].into_iter().flatten();

for request in requests {
Expand Down Expand Up @@ -582,6 +650,13 @@ pub(crate) fn replace_application_io(application_io: PlatformApplicationIo) {
}
}

pub fn set_completion_notifier(notifier: Arc<DynNotifier>) {
let node_runtime = NODE_RUNTIME.lock();
if let Some(node_runtime) = &*node_runtime {
node_runtime.graph_runtime.set_notifier(notifier);
}
}

impl NodeRuntime {
pub(crate) fn replace_application_io(&mut self, application_io: PlatformApplicationIo) {
self.editor_api = PlatformEditorApi {
Expand Down
34 changes: 23 additions & 11 deletions node-graph/graphene-cli/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,28 @@ use interpreted_executor::dynamic_executor::DynamicExecutor;
use std::error::Error;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::mpsc::Receiver;
use std::time::Duration;

fn execute_to_final(executor: &DynamicExecutor, render_config: RenderConfig) -> Result<TaggedValue, Box<dyn Error>> {
match executor.execute(render_config)? {
GPoll::Final(value) => Ok(value),
GPoll::Fallback(boxed) => {
let (value, error) = *boxed;
log::warn!("Node graph evaluation reported an error alongside its fallback output: {error:?}");
Ok(value)
const SOURCE_COMPLETION_TIMEOUT: Duration = Duration::from_secs(30);

fn execute_until_final(executor: &DynamicExecutor, render_config: RenderConfig, completion: &Receiver<()>) -> Result<TaggedValue, Box<dyn Error>> {
loop {
while completion.try_recv().is_ok() {}
match executor.execute(render_config)? {
GPoll::Final(value) => return Ok(value),
GPoll::Fallback(boxed) => {
let (value, error) = *boxed;
log::warn!("Node graph evaluation reported an error alongside its fallback output: {error:?}");
return Ok(value);
}
GPoll::Partial(_) | GPoll::Pending => {
completion
.recv_timeout(SOURCE_COMPLETION_TIMEOUT)
.map_err(|_| format!("Timed out after {}s waiting for async sources to complete", SOURCE_COMPLETION_TIMEOUT.as_secs()))?;
}
GPoll::Error(error) => return Err(format!("Node graph evaluation failed: {error:?}").into()),
}
GPoll::Partial(_) | GPoll::Pending => Err("Node graph evaluation did not complete".into()),
GPoll::Error(error) => Err(format!("Node graph evaluation failed: {error:?}").into()),
}
}

Expand Down Expand Up @@ -52,6 +62,7 @@ pub fn export_document(
scale: f64,
(width, height): (Option<u32>, Option<u32>),
transparent: bool,
completion: &Receiver<()>,
) -> Result<(), Box<dyn Error>> {
// Determine export format based on file type
let export_format = match file_type {
Expand All @@ -73,7 +84,7 @@ pub fn export_document(
}

// Execute the graph
let result = execute_to_final(executor, render_config)?;
let result = execute_until_final(executor, render_config, completion)?;

// Handle the result based on output type
match result {
Expand Down Expand Up @@ -172,6 +183,7 @@ pub fn export_gif(
scale: f64,
(width, height): (Option<u32>, Option<u32>),
animation: AnimationParams,
completion: &Receiver<()>,
) -> Result<(), Box<dyn Error>> {
use image::codecs::gif::{GifEncoder, Repeat};
use image::{Frame, RgbaImage};
Expand Down Expand Up @@ -211,7 +223,7 @@ pub fn export_gif(
}

// Execute the graph for this frame
let result = execute_to_final(executor, render_config)?;
let result = execute_until_final(executor, render_config, completion)?;

// Extract RGBA data from result
let (data, img_width, img_height) = match result {
Expand Down
41 changes: 37 additions & 4 deletions node-graph/graphene-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use graph_craft::graphene_compiler::Compiler;
use graph_craft::proto::ProtoNetwork;
use graph_craft::util::load_network;
use graphene_std::application_io::{ApplicationIo, NodeGraphUpdateMessage, NodeGraphUpdateSender};
use graphene_std::runtime::{DynGraphRuntime, DynSpawner, GraphRuntime, NoopSpawner, RuntimeHandle};
use graphene_std::runtime::{DynGraphRuntime, DynSpawner, GraphRuntime, RuntimeHandle, SourceFuture, Spawner, poll_once};
use interpreted_executor::dynamic_executor::DynamicExecutor;
use interpreted_executor::util::wrap_network_in_scope;
use std::error::Error;
Expand All @@ -28,6 +28,35 @@ impl NodeGraphUpdateSender for UpdateLogger {
}
}

struct TokioSpawner(Option<tokio::runtime::Runtime>);

impl TokioSpawner {
fn new() -> Result<Self, std::io::Error> {
Ok(Self(Some(tokio::runtime::Runtime::new()?)))
}
}

impl Spawner for TokioSpawner {
fn spawn(&self, mut task: SourceFuture) -> bool {
let runtime = self.0.as_ref().expect("runtime lives until drop");
let _guard = runtime.enter();
if poll_once(&mut task) {
return true;
}
runtime.spawn(task);
false
}
}

/// Dropping a tokio runtime blocks on its tasks, which panics inside the async main; shut down in the background instead.
impl Drop for TokioSpawner {
fn drop(&mut self) {
if let Some(runtime) = self.0.take() {
runtime.shutdown_background();
}
}
}

#[derive(Debug, Parser)]
#[clap(name = "graphene-cli", version)]
pub struct App {
Expand Down Expand Up @@ -179,7 +208,11 @@ fn main() -> Result<(), Box<dyn Error>> {
let preferences = EditorPreferences {
max_render_region_size: EditorPreferences::default().max_render_region_size,
};
let graph_runtime: Arc<DynGraphRuntime> = Arc::new(GraphRuntime::new(Box::new(NoopSpawner) as Box<DynSpawner>));
let graph_runtime: Arc<DynGraphRuntime> = Arc::new(GraphRuntime::new(Box::new(TokioSpawner::new()?) as Box<DynSpawner>));
let (completion_sender, completion_receiver) = std::sync::mpsc::channel();
graph_runtime.set_notifier(Arc::new(move || {
let _ = completion_sender.send(());
}));
let editor_api = Arc::new(PlatformEditorApi {
application_io: Some(application_io_for_api),
node_graph_message_sender: Box::new(UpdateLogger {}),
Expand Down Expand Up @@ -226,9 +259,9 @@ fn main() -> Result<(), Box<dyn Error>> {
// Perform export based on file type
if file_type == export::FileType::Gif {
let animation = export::AnimationParams::new(fps, frames, duration);
export::export_gif(&executor, wgpu_executor_ref.clone(), output, scale, (width, height), animation)?;
export::export_gif(&executor, wgpu_executor_ref.clone(), output, scale, (width, height), animation, &completion_receiver)?;
} else {
export::export_document(&executor, wgpu_executor_ref.clone(), output, file_type, scale, (width, height), transparent)?;
export::export_document(&executor, wgpu_executor_ref.clone(), output, file_type, scale, (width, height), transparent, &completion_receiver)?;
}
}
_ => unreachable!("All other commands should be handled before this match statement is run"),
Expand Down
4 changes: 3 additions & 1 deletion node-graph/interpreted-executor/src/dynamic_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,9 @@ mod test {
struct InertSpawner;

impl Spawner for InertSpawner {
fn spawn(&self, _task: SourceFuture) {}
fn spawn(&self, _task: SourceFuture) -> bool {
false
}
}

#[test]
Expand Down
Loading
Loading