-
Notifications
You must be signed in to change notification settings - Fork 882
POC: Long running napi #12802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
POC: Long running napi #12802
Changes from all commits
8bc9055
15d15f9
fb7f075
39f3a0d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,31 @@ | ||
| import type { ProjectForFrontend, RefInfo } from "@gitbutler/but-sdk"; | ||
|
|
||
| export type LongRunningTaskStatus = "running" | "cancelling" | "done" | "cancelled" | "error"; | ||
|
|
||
| export interface LongRunningTaskSnapshot { | ||
| taskId: number; | ||
| durationMs: number; | ||
| step: number; | ||
| status: LongRunningTaskStatus; | ||
| message?: string; | ||
| } | ||
|
|
||
| export interface LiteElectronApi { | ||
| ping(input: string): Promise<string>; | ||
| getVersion(): Promise<string>; | ||
| listProjects(): Promise<ProjectForFrontend[]>; | ||
| headInfo(projectId: string): Promise<RefInfo>; | ||
| listLongRunningTasks(): Promise<LongRunningTaskSnapshot[]>; | ||
| startLongRunningTask(durationMs: number): Promise<number>; | ||
| cancelLongRunningTask(taskId: number): Promise<boolean>; | ||
| onLongRunningTaskEvent(listener: (event: LongRunningTaskSnapshot) => void): () => void; | ||
| } | ||
|
|
||
| export const liteIpcChannels = { | ||
| ping: "lite:ping", | ||
| getVersion: "lite:get-version", | ||
| listProjects: "projects:list", | ||
| headInfo: "workspace:head-info", | ||
| listLongRunningTasks: "long-running:list", | ||
| startLongRunningTask: "long-running:start", | ||
| cancelLongRunningTask: "long-running:cancel", | ||
| longRunningTaskEvent: "long-running:event", | ||
| } as const; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| import { | ||
| longRunningCancelTsfn, | ||
| LongRunningEventKind, | ||
| longRunningStartTsfn, | ||
| } from "@gitbutler/but-sdk"; | ||
| import type { LongRunningTaskSnapshot } from "#electron/ipc"; | ||
|
|
||
| const MAX_DURATION_MS = 600000; | ||
| type LongRunningTaskListener = (event: LongRunningTaskSnapshot) => void; | ||
|
|
||
| const activeTaskIds = new Set<number>(); | ||
| const tasks = new Map<number, LongRunningTaskSnapshot>(); | ||
| const listeners = new Set<LongRunningTaskListener>(); | ||
|
|
||
| /** | ||
| * Starts a non-blocking task in Rust and tracks task snapshots for renderer consumption. | ||
| */ | ||
| export function startLongRunningTask(durationMs: number): number { | ||
| if (!Number.isInteger(durationMs) || durationMs < 1 || durationMs > MAX_DURATION_MS) { | ||
| throw new Error("durationMs must be an integer between 1 and 600000."); | ||
| } | ||
|
|
||
| let taskId = 0; | ||
|
|
||
| taskId = longRunningStartTsfn(durationMs, (err, event) => { | ||
| const currentSnapshot = tasks.get(taskId); | ||
| if (!currentSnapshot) { | ||
| return; | ||
| } | ||
|
|
||
| if (err) { | ||
| activeTaskIds.delete(taskId); | ||
| const nextSnapshot: LongRunningTaskSnapshot = { | ||
| ...currentSnapshot, | ||
| status: "error", | ||
| message: err.message ?? "unknown error", | ||
| }; | ||
| setTaskSnapshot(nextSnapshot); | ||
| return; | ||
| } | ||
|
|
||
| if (!activeTaskIds.has(taskId)) { | ||
| return; | ||
| } | ||
|
|
||
| const snapshotWithStep: LongRunningTaskSnapshot = { | ||
| ...currentSnapshot, | ||
| step: typeof event.step === "number" ? event.step : currentSnapshot.step, | ||
| }; | ||
|
|
||
| if (event.kind === LongRunningEventKind.Progress) { | ||
| setTaskSnapshot({ | ||
| ...snapshotWithStep, | ||
| status: "running", | ||
| message: undefined, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| if (event.kind === LongRunningEventKind.Done) { | ||
| activeTaskIds.delete(taskId); | ||
| setTaskSnapshot({ | ||
| ...snapshotWithStep, | ||
| status: "done", | ||
| message: undefined, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| if (event.kind === LongRunningEventKind.Cancelled) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I read this correctly, here you'd have to set
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yikes, GitHub you got me. I thought I am looking at
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The variable
That's correct. In this implementation, the way we stop it from the JS side is through the |
||
| activeTaskIds.delete(taskId); | ||
| setTaskSnapshot({ | ||
| ...snapshotWithStep, | ||
| status: "cancelled", | ||
| message: undefined, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| activeTaskIds.delete(taskId); | ||
| setTaskSnapshot({ | ||
| ...snapshotWithStep, | ||
| status: "error", | ||
| message: event.message ?? "unknown error", | ||
| }); | ||
| }); | ||
|
|
||
| activeTaskIds.add(taskId); | ||
| const initialSnapshot: LongRunningTaskSnapshot = { | ||
| taskId, | ||
| durationMs, | ||
| step: 0, | ||
| status: "running", | ||
| }; | ||
| setTaskSnapshot(initialSnapshot); | ||
|
|
||
| return taskId; | ||
| } | ||
|
|
||
| /** | ||
| * Requests cancellation for a task and marks it as cancelling until the Rust callback emits a terminal state. | ||
| */ | ||
| export function cancelLongRunningTask(taskId: number): boolean { | ||
| if (!activeTaskIds.has(taskId)) { | ||
| return false; | ||
| } | ||
|
|
||
| const snapshot = tasks.get(taskId); | ||
| if (snapshot) { | ||
| const nextSnapshot: LongRunningTaskSnapshot = { | ||
| ...snapshot, | ||
| status: "cancelling", | ||
| message: undefined, | ||
| }; | ||
| setTaskSnapshot(nextSnapshot); | ||
| } | ||
|
|
||
| const cancelled = longRunningCancelTsfn(taskId); | ||
| if (!cancelled && snapshot) { | ||
| const nextSnapshot: LongRunningTaskSnapshot = { | ||
| ...snapshot, | ||
| status: "error", | ||
| message: "Task could not be cancelled (already finished).", | ||
| }; | ||
| setTaskSnapshot(nextSnapshot); | ||
| } | ||
|
|
||
| return cancelled; | ||
| } | ||
|
|
||
| /** | ||
| * Returns all known task snapshots, including terminal ones, newest first. | ||
| */ | ||
| export function listLongRunningTasks(): LongRunningTaskSnapshot[] { | ||
| return [...tasks.values()].sort((left, right) => right.taskId - left.taskId); | ||
| } | ||
|
|
||
| export function subscribeLongRunningTaskEvents(listener: LongRunningTaskListener): () => void { | ||
| listeners.add(listener); | ||
|
|
||
| return () => { | ||
| listeners.delete(listener); | ||
| }; | ||
| } | ||
|
|
||
| function setTaskSnapshot(snapshot: LongRunningTaskSnapshot): void { | ||
| tasks.set(snapshot.taskId, snapshot); | ||
| emitLongRunningTaskEvent(snapshot); | ||
| } | ||
|
|
||
| function emitLongRunningTaskEvent(event: LongRunningTaskSnapshot): void { | ||
| for (const listener of listeners) { | ||
| listener(event); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,37 @@ | ||
| import { contextBridge, ipcRenderer } from "electron"; | ||
| import type { LiteElectronApi } from "#electron/ipc"; | ||
| import { | ||
| type LiteElectronApi, | ||
| type LongRunningTaskSnapshot, | ||
| } from "#electron/ipc"; | ||
| import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; | ||
| import type { ProjectForFrontend, RefInfo } from "@gitbutler/but-sdk"; | ||
|
|
||
| const api: LiteElectronApi = { | ||
| async ping(input: string): Promise<string> { | ||
| return await ipcRenderer.invoke("lite:ping", input); | ||
| }, | ||
| async getVersion(): Promise<string> { | ||
| return await ipcRenderer.invoke("lite:get-version"); | ||
| }, | ||
| async listProjects(): Promise<ProjectForFrontend[]> { | ||
| return await ipcRenderer.invoke("projects:list"); | ||
| }, | ||
| async headInfo(projectId: string): Promise<RefInfo> { | ||
| return await ipcRenderer.invoke("workspace:head-info", projectId); | ||
| }, | ||
| async listLongRunningTasks(): Promise<LongRunningTaskSnapshot[]> { | ||
| return await ipcRenderer.invoke("long-running:list"); | ||
| }, | ||
| async startLongRunningTask(durationMs: number): Promise<number> { | ||
| return await ipcRenderer.invoke("long-running:start", durationMs); | ||
| }, | ||
| async cancelLongRunningTask(taskId: number): Promise<boolean> { | ||
| return await ipcRenderer.invoke("long-running:cancel", taskId); | ||
| }, | ||
| onLongRunningTaskEvent(listener: (event: LongRunningTaskSnapshot) => void): () => void { | ||
| function eventListener(_event: IpcRendererEvent, taskEvent: LongRunningTaskSnapshot): void { | ||
| listener(taskEvent); | ||
| } | ||
|
|
||
| ipcRenderer.on("long-running:event", eventListener); | ||
|
|
||
| return () => { | ||
| ipcRenderer.removeListener("long-running:event", eventListener); | ||
| }; | ||
| }, | ||
| }; | ||
|
|
||
| contextBridge.exposeInMainWorld("lite", api); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.