Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
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.
0453062 to
fb7f075
Compare
There was a problem hiding this comment.
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::pocmodule with long-running worker helpers plus NAPI bindings to stream progress/cancellation/done events. - Adds a
but-testingCLI subcommand to run the PoC with progress rendering + interrupt handling. - Updates
@gitbutler/but-sdkgenerated 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
| 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; | ||
| } |
There was a problem hiding this comment.
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
| const activeTaskIds = new Set<number>(); | ||
| const tasks = new Map<number, LongRunningTaskSnapshot>(); | ||
| const listeners = new Set<LongRunningTaskListener>(); |
There was a problem hiding this comment.
We keep track of the tasks, active or not, and subscribers.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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::Treeto 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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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