refactor: share async invoke bridge across native platforms

This commit is contained in:
Levi committed 2026-09-06 11:59:11 +08:00
1 parent d12122ea86
commit 607d1729e6
17 files changed
+464 -222

No files matched your search

-123
View File
@@ -1,123 +0,0 @@
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
}
+10 -34
View File
@@ -20,29 +20,15 @@ import "C"
import (
"encoding/json"
"fmt"
"sync"
"unsafe"
nativebridge "github.com/GopeedLab/gopeed/bind/native"
"github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
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
@@ -56,11 +42,13 @@ func Start(cfg *C.char) (int, *C.char) {
if err != nil {
return 0, C.CString(err.Error())
}
nativebridge.ResumeInvokes()
return realPort, nil
}
//export Stop
func Stop() {
nativebridge.PauseInvokesAndWait()
rest.Stop()
}
@@ -96,16 +84,6 @@ func apiServerResult(state *model.APIServerState, err error) string {
return string(data)
}
//export Invoke
func Invoke(method *C.char, path *C.char, query *C.char, body *C.char) *C.char {
return C.CString(rest.Invoke(
C.GoString(method),
C.GoString(path),
C.GoString(query),
C.GoString(body),
))
}
//export InvokeAsync
func InvokeAsync(
method *C.char,
@@ -118,12 +96,6 @@ func InvokeAsync(
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
@@ -138,9 +110,13 @@ func InvokeAsync(
C.CString(payload),
)
}
if !getInvokeExecutor().submit(invokeTask{request: request, complete: complete}) {
complete("", fmt.Errorf("invoke executor is closed"))
}
nativebridge.InvokeAsync(
C.GoString(method),
C.GoString(path),
C.GoString(query),
C.GoString(body),
complete,
)
}
//export SubscribeTaskEvents
+22 -3
View File
@@ -5,6 +5,7 @@ import "C"
import (
"encoding/json"
nativebridge "github.com/GopeedLab/gopeed/bind/native"
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
"github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model"
@@ -18,10 +19,15 @@ func Start(cfg string) (int, error) {
config.ProductionMode = true
config.NativeMode = true
applyWebViewProvider(&config)
return rest.Start(&config)
port, err := rest.Start(&config)
if err == nil {
nativebridge.ResumeInvokes()
}
return port, err
}
func Stop() {
nativebridge.PauseInvokesAndWait()
rest.Stop()
}
@@ -50,8 +56,21 @@ func apiServerResult(state *model.APIServerState, err error) string {
return string(data)
}
func Invoke(method, path, query, body string) string {
return rest.Invoke(method, path, query, body)
type InvokeResultListener interface {
OnResult(requestID int64, success bool, payload string)
}
func InvokeAsync(method, path, query, body string, requestID int64, listener InvokeResultListener) {
if listener == nil {
return
}
nativebridge.InvokeAsync(method, path, query, body, func(result string, err error) {
if err != nil {
listener.OnResult(requestID, false, err.Error())
return
}
listener.OnResult(requestID, true, result)
})
}
type TaskEventListener interface {
+202
View File
@@ -0,0 +1,202 @@
// Package nativebridge contains transport-neutral behavior shared by the
// Desktop C FFI and gomobile bridge adapters.
package nativebridge
import (
"fmt"
"runtime"
"sync"
"github.com/GopeedLab/gopeed/pkg/rest"
)
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)
}
// InvokeAsync queues an in-process API request and completes it through the
// supplied callback. Desktop and mobile adapters share this executor.
func InvokeAsync(method, path, query, body string, callback func(result string, err error)) {
if callback == nil {
return
}
task := invokeTask{
request: invokeRequest{
method: method,
path: path,
query: query,
body: body,
},
complete: callback,
}
if !getInvokeExecutor().submit(task) {
completeCallback(callback, "", fmt.Errorf("native bridge is stopping"))
}
}
// ResumeInvokes enables request submission after the native runtime starts.
func ResumeInvokes() {
getInvokeExecutor().resume()
}
// PauseInvokesAndWait prevents new submissions and waits for every queued or
// running request to finish before a native runtime is stopped.
func PauseInvokesAndWait() {
getInvokeExecutor().pauseAndWait()
}
var (
invokeExecutorOnce sync.Once
sharedInvoker *invokeExecutor
)
func getInvokeExecutor() *invokeExecutor {
invokeExecutorOnce.Do(func() {
sharedInvoker = newDefaultInvokeExecutor(func(request invokeRequest) string {
return rest.Dispatch(request.method, request.path, request.query, request.body)
})
})
return sharedInvoker
}
// invokeExecutor keeps native API work concurrent without rejecting requests
// when every worker is busy. Additional requests wait in FIFO order and are
// completed through their callback.
type invokeExecutor struct {
mu sync.Mutex
ready *sync.Cond
queue []invokeTask
closed bool
accepting bool
pending int
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, accepting: true}
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 || !e.accepting {
return false
}
e.queue = append(e.queue, task)
e.pending++
e.ready.Signal()
return true
}
func (e *invokeExecutor) resume() {
e.mu.Lock()
e.accepting = true
e.mu.Unlock()
}
func (e *invokeExecutor) pauseAndWait() {
e.mu.Lock()
e.accepting = false
for e.pending > 0 {
e.ready.Wait()
}
e.mu.Unlock()
}
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)
completeCallback(task.complete, result, err)
e.mu.Lock()
e.pending--
if e.pending == 0 {
e.ready.Broadcast()
}
e.mu.Unlock()
}
}
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
}
func completeCallback(callback func(string, error), result string, err error) {
defer func() {
_ = recover()
}()
callback(result, err)
}
@@ -1,6 +1,7 @@
package main
package nativebridge
import (
"runtime"
"strconv"
"sync"
"sync/atomic"
@@ -100,3 +101,96 @@ func TestInvokeExecutorConvertsPanicsToErrors(t *testing.T) {
}
executor.close()
}
func TestInvokeExecutorSurvivesCallbackPanic(t *testing.T) {
executor := newInvokeExecutor(1, func(request invokeRequest) string {
return request.path
})
executor.submit(invokeTask{complete: func(string, error) {
panic("callback failed")
}})
completed := make(chan string, 1)
executor.submit(invokeTask{
request: invokeRequest{path: "next"},
complete: func(result string, _ error) {
completed <- result
},
})
select {
case result := <-completed:
if result != "next" {
t.Fatalf("unexpected result: %q", result)
}
case <-time.After(time.Second):
t.Fatal("worker stopped after callback panic")
}
executor.close()
}
func TestInvokeExecutorPausesAndDrains(t *testing.T) {
release := make(chan struct{})
started := make(chan struct{})
executor := newInvokeExecutor(1, func(request invokeRequest) string {
if request.path == "running" {
close(started)
<-release
}
return request.path
})
completed := make(chan struct{})
if !executor.submit(invokeTask{
request: invokeRequest{path: "running"},
complete: func(string, error) {
close(completed)
},
}) {
t.Fatal("initial task was rejected")
}
<-started
drained := make(chan struct{})
go func() {
executor.pauseAndWait()
close(drained)
}()
deadline := time.Now().Add(time.Second)
for {
executor.mu.Lock()
paused := !executor.accepting
executor.mu.Unlock()
if paused {
break
}
if time.Now().After(deadline) {
t.Fatal("executor did not pause")
}
runtime.Gosched()
}
select {
case <-drained:
t.Fatal("pause returned before the running request completed")
default:
}
if executor.submit(invokeTask{}) {
t.Fatal("paused executor accepted a new request")
}
close(release)
<-completed
select {
case <-drained:
case <-time.After(time.Second):
t.Fatal("pause did not return after pending requests drained")
}
executor.resume()
resumed := make(chan struct{})
if !executor.submit(invokeTask{complete: func(string, error) {
close(resumed)
}}) {
t.Fatal("resumed executor rejected a request")
}
<-resumed
executor.close()
}