feat: make desktop FFI invocation asynchronous

This commit is contained in:
Levi committed 2026-09-06 11:23:27 +08:00
1 parent 8f3cf35162
commit d12122ea86
13 files changed
+574 -141

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
package main
import (
"fmt"
"runtime"
"sync"
)
const (
minInvokeConcurrency = 4
maxInvokeConcurrency = 32
)
type invokeRequest struct {
method string
path string
query string
body string
}
type invokeTask struct {
request invokeRequest
complete func(result string, err error)
}
// invokeExecutor keeps native API work concurrent without ever rejecting a
// request because all workers are busy. submit only appends to the waiting
// queue; callers observe backpressure through the Future completed by the
// callback rather than by blocking the FFI entry point.
type invokeExecutor struct {
mu sync.Mutex
ready *sync.Cond
queue []invokeTask
closed bool
workers sync.WaitGroup
handler func(invokeRequest) string
}
func invokeConcurrency(cpuCount int) int {
concurrency := cpuCount * 2
if concurrency < minInvokeConcurrency {
return minInvokeConcurrency
}
if concurrency > maxInvokeConcurrency {
return maxInvokeConcurrency
}
return concurrency
}
func newInvokeExecutor(workerCount int, handler func(invokeRequest) string) *invokeExecutor {
if workerCount < 1 {
workerCount = 1
}
executor := &invokeExecutor{handler: handler}
executor.ready = sync.NewCond(&executor.mu)
executor.workers.Add(workerCount)
for range workerCount {
go executor.run()
}
return executor
}
func newDefaultInvokeExecutor(handler func(invokeRequest) string) *invokeExecutor {
return newInvokeExecutor(invokeConcurrency(runtime.GOMAXPROCS(0)), handler)
}
func (e *invokeExecutor) submit(task invokeTask) bool {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return false
}
e.queue = append(e.queue, task)
e.ready.Signal()
return true
}
func (e *invokeExecutor) close() {
e.mu.Lock()
e.closed = true
e.ready.Broadcast()
e.mu.Unlock()
e.workers.Wait()
}
func (e *invokeExecutor) run() {
defer e.workers.Done()
for {
task, ok := e.take()
if !ok {
return
}
result, err := e.execute(task.request)
task.complete(result, err)
}
}
func (e *invokeExecutor) take() (invokeTask, bool) {
e.mu.Lock()
defer e.mu.Unlock()
for len(e.queue) == 0 && !e.closed {
e.ready.Wait()
}
if len(e.queue) == 0 {
return invokeTask{}, false
}
task := e.queue[0]
e.queue[0] = invokeTask{}
e.queue = e.queue[1:]
if len(e.queue) == 0 {
e.queue = nil
}
return task, true
}
func (e *invokeExecutor) execute(request invokeRequest) (result string, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("invoke panic: %v", recovered)
}
}()
return e.handler(request), nil
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestInvokeConcurrency(t *testing.T) {
tests := []struct {
cpus int
want int
}{
{cpus: 1, want: 4},
{cpus: 2, want: 4},
{cpus: 4, want: 8},
{cpus: 8, want: 16},
{cpus: 16, want: 32},
{cpus: 64, want: 32},
}
for _, test := range tests {
t.Run(strconv.Itoa(test.cpus), func(t *testing.T) {
if got := invokeConcurrency(test.cpus); got != test.want {
t.Fatalf("invokeConcurrency(%d) = %d, want %d", test.cpus, got, test.want)
}
})
}
}
func TestInvokeExecutorWaitsWithoutRejecting(t *testing.T) {
const (
workerCount = 2
taskCount = 6
)
release := make(chan struct{})
started := make(chan struct{}, taskCount)
var active atomic.Int32
var maxActive atomic.Int32
executor := newInvokeExecutor(workerCount, func(request invokeRequest) string {
current := active.Add(1)
for {
maximum := maxActive.Load()
if current <= maximum || maxActive.CompareAndSwap(maximum, current) {
break
}
}
started <- struct{}{}
<-release
active.Add(-1)
return request.path
})
var completed sync.WaitGroup
completed.Add(taskCount)
for i := range taskCount {
accepted := executor.submit(invokeTask{
request: invokeRequest{path: strconv.Itoa(i)},
complete: func(string, error) {
completed.Done()
},
})
if !accepted {
t.Fatalf("task %d was rejected while all workers were busy", i)
}
}
for range workerCount {
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("workers did not start queued tasks")
}
}
if got := maxActive.Load(); got != workerCount {
t.Fatalf("maximum active tasks = %d, want %d", got, workerCount)
}
close(release)
completed.Wait()
executor.close()
}
func TestInvokeExecutorConvertsPanicsToErrors(t *testing.T) {
executor := newInvokeExecutor(1, func(invokeRequest) string {
panic("boom")
})
completed := make(chan error, 1)
executor.submit(invokeTask{complete: func(_ string, err error) {
completed <- err
}})
select {
case err := <-completed:
if err == nil || err.Error() != "invoke panic: boom" {
t.Fatalf("unexpected error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("panic result was not completed")
}
executor.close()
}
+58
View File
@@ -6,14 +6,21 @@ package main
typedef void (*TaskEventCallback)(char* payload);
typedef void (*InvokeResultCallback)(uint64_t request_id, int success, char* payload);
static void callTaskEventCallback(uintptr_t callback, char* payload) {
((TaskEventCallback)callback)(payload);
}
static void callInvokeResultCallback(uintptr_t callback, uint64_t request_id, int success, char* payload) {
((InvokeResultCallback)callback)(request_id, success, payload);
}
*/
import "C"
import (
"encoding/json"
"fmt"
"sync"
"unsafe"
"github.com/GopeedLab/gopeed/pkg/rest"
@@ -22,6 +29,20 @@ import (
func main() {}
var (
invokeExecutorOnce sync.Once
desktopInvoker *invokeExecutor
)
func getInvokeExecutor() *invokeExecutor {
invokeExecutorOnce.Do(func() {
desktopInvoker = newDefaultInvokeExecutor(func(request invokeRequest) string {
return rest.Invoke(request.method, request.path, request.query, request.body)
})
})
return desktopInvoker
}
//export Start
func Start(cfg *C.char) (int, *C.char) {
var config model.StartConfig
@@ -85,6 +106,43 @@ func Invoke(method *C.char, path *C.char, query *C.char, body *C.char) *C.char {
))
}
//export InvokeAsync
func InvokeAsync(
method *C.char,
path *C.char,
query *C.char,
body *C.char,
requestID C.ulonglong,
callback C.uintptr_t,
) {
if callback == 0 {
return
}
request := invokeRequest{
method: C.GoString(method),
path: C.GoString(path),
query: C.GoString(query),
body: C.GoString(body),
}
complete := func(result string, err error) {
success := C.int(1)
payload := result
if err != nil {
success = 0
payload = err.Error()
}
C.callInvokeResultCallback(
callback,
C.uint64_t(requestID),
success,
C.CString(payload),
)
}
if !getInvokeExecutor().submit(invokeTask{request: request, complete: complete}) {
complete("", fmt.Errorf("invoke executor is closed"))
}
}
//export SubscribeTaskEvents
func SubscribeTaskEvents(mask C.ulonglong, callback C.uintptr_t) {
if mask == 0 || callback == 0 {