Skip to content

POC: Long running napi#12802

Draft
estib-vega wants to merge 4 commits intomasterfrom
long-running-napi
Draft

POC: Long running napi#12802
estib-vega wants to merge 4 commits intomasterfrom
long-running-napi

Conversation

@estib-vega
Copy link
Contributor

napi nap nap

This is a proof of concept of starting an long running task in a non-blocking way from JS, having the task run and propagate updated from Rust back to JS through a callback, and all while being cancellable.

This should work for things like rust-side-events and other nice things, like streaming AI updates and such

Byron and others added 2 commits March 12, 2026 16:06
While it's using `gix::Progress`, it could also use anything else for that.
There might also be alternatives that offer checks for cancellation as part of the progress,
but I found it better to make this explcit.

Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
That's particularly interesting for the Progress visualisation
and instantiation, as our 'renderer' would be a forwarder of
sorts to pass the progress state on to the frontend.

Co-authored-by: Sebastian Thiel <sebastian.thiel@icloud.com>
Copilot AI review requested due to automatic review settings March 12, 2026 15:23
@vercel
Copy link

vercel bot commented Mar 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
gitbutler-web Skipped Skipped Mar 12, 2026 4:53pm

Request Review

@github-actions github-actions bot added the rust Pull requests that update Rust code label Mar 12, 2026
@estib-vega estib-vega marked this pull request as draft March 12, 2026 15:24
@estib-vega estib-vega changed the title Long running napi POC: Long running napi Mar 12, 2026
Building a POC on top of byron’s POC:
- Export a function from Rust to Node that takes in a duration in ms and
a callback.
- The function returns immediately, doesn’t block.
- The callback get’s event payloads from the Rust side.
- A normal abort signal can be used to abort the process at anytime.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a proof-of-concept for starting long-running Rust work from Node.js via NAPI in a non-blocking way, streaming progress events back to JS, and supporting cancellation.

Changes:

  • Introduces a but-api::poc module with long-running worker helpers plus NAPI bindings to stream progress/cancellation/done events.
  • Adds a but-testing CLI subcommand to run the PoC with progress rendering + interrupt handling.
  • Updates @gitbutler/but-sdk generated bindings and a TS test harness for start/cancel via TSFN.

Reviewed changes

Copilot reviewed 8 out of 11 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
packages/but-sdk/tsconfig.json Disables isolatedModules to accommodate generated typings/usage.
packages/but-sdk/src/test.ts Adds a JS/TS harness to start the long-running task, receive events, and cancel via AbortController.
packages/but-sdk/src/generated/index.js Exposes new native exports (longRunningStartTsfn, longRunningCancelTsfn, LongRunningEventKind).
packages/but-sdk/src/generated/index.d.ts Adds TS declarations for long-running task APIs and event payloads.
crates/but-testing/src/main.rs Wires a new LongRunning subcommand to the PoC command implementation.
crates/but-testing/src/command/poc.rs Implements CLI PoC runner with prodash progress rendering and interrupt handling.
crates/but-testing/src/command/mod.rs Exposes the new poc module.
crates/but-testing/src/args.rs Defines the LongRunning subcommand and duration parsing.
crates/but-testing/Cargo.toml Adds dependencies for the PoC runner (including but-api, humantime, prodash) and enables gix/interrupt.
crates/but-api/src/lib.rs Adds the poc module, worker implementation, NAPI TSFN streaming API, and tests.
Cargo.lock Locks new/updated Rust dependencies introduced by the PoC.

You can also share your feedback on Copilot code review. Take the survey.

Hook up the creation of long-lasting tasks in the FE
Comment on lines +10 to +45
export function useTasks(queryClient: QueryClient) {
const tasksQuery = useQuery({
queryKey: LONG_RUNNING_TASKS_QUERY_KEY,
queryFn: async (): Promise<LongRunningTaskSnapshot[]> => {
return await window.lite.listLongRunningTasks();
},
initialData: [],
});

useEffect(() => {
// This unsubscribes on unmount
return window.lite.onLongRunningTaskEvent((event) => {
queryClient.setQueryData(
LONG_RUNNING_TASKS_QUERY_KEY,
(currentTasks: LongRunningTaskSnapshot[] = []) => {
const hasTask = currentTasks.some((task) => task.taskId === event.taskId);
if (!hasTask) {
return sortTasksByIdDesc([event, ...currentTasks]);
}

return sortTasksByIdDesc(
currentTasks.map((task) => {
if (task.taskId !== event.taskId) {
return task;
}

return event;
})
);
}
);
});
}, [queryClient]);

return tasksQuery;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is how a subscription would work in a query world.
We fetch the initial data on mount, and subscribe to its events as well.
That way, we always have the latest information as its pushed from the main thread (and in turn from Rust).

On unmount, we unsubscribe but we don't stop the process.
That option is still open if we wanted to, but not enabled by default

Comment on lines +11 to +13
const activeTaskIds = new Set<number>();
const tasks = new Map<number, LongRunningTaskSnapshot>();
const listeners = new Set<LongRunningTaskListener>();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We keep track of the tasks, active or not, and subscribers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This also means that the processes are persisted across UI reloads.
Only explicitly cancelling the operations will terminate them.


let rx = long_running_non_blocking_thread(
Duration::from_millis(u64::from(duration_ms)),
gix::progress::Discard,
Copy link
Collaborator

Choose a reason for hiding this comment

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

In this 'optimal' Rust version, there is effectively 3 channels:

  • progress
  • interrupt checks
  • return values

To handle progress, you'd have to do something like this which is effectively:

  • create a progress::Tree to hold the hierarchial progress. This looks like : https://asciinema.org/a/346619
  • create a thread that polls the status of the progress and sends it to the frontend. This would basically just send the entire state over and the UI can render it.

Here one can also use other progress crates, for instance to only support a single progress value, but this progress was specifically built to support complex layered operations that have multiple progresses on their own, i.e. gix clone uses it and you know what I mean.
Oh, before I forget, this Progress trait can also be used to send progress messages, so it's pretty powerful and good to show the user that something is happening.

My recommendation is to use it, but… it's also a UX question and maybe nobody needs more than one. If you were to run a clone though, it quickly becomes hard to map this to a single progress that also has some temporal resolution to it.

CC @OliverJAsh .

return;
}

if (event.kind === LongRunningEventKind.Cancelled) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

If I read this correctly, here you'd have to set should_interrupt to true.
After all, the frontend has to have a mechanism to set that boolean so the thread which polls it knows it should stop.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yikes, GitHub you got me. I thought I am looking at lib.rs, but I am not.
Anyway, I don't seem to be able to find the spot where this variable is set.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The variable event is one of the arguments passed into this callback. So this comes originally from the rust side --> napified --> ends up here.

If I read this correctly, here you'd have to set should_interrupt to true.

That's correct. In this implementation, the way we stop it from the JS side is through the cancelLongRunningTask function bellow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants