diff --git a/apis/backup.go b/apis/backup.go index a100e56b..9070afde 100644 --- a/apis/backup.go +++ b/apis/backup.go @@ -12,6 +12,7 @@ import ( "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/rest" + "github.com/pocketbase/pocketbase/tools/routine" "github.com/pocketbase/pocketbase/tools/types" "github.com/spf13/cast" ) @@ -172,7 +173,7 @@ func (api *backupApi) restore(c echo.Context) error { return NewBadRequestError("Missing or invalid backup file.", err) } - go func() { + routine.FireAndForget(func() { // wait max 15 minutes to fetch the backup ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() @@ -183,7 +184,7 @@ func (api *backupApi) restore(c echo.Context) error { if err := api.app.RestoreBackup(ctx, key); err != nil { api.app.Logger().Error("Failed to restore backup", "key", key, "error", err.Error()) } - }() + }) return c.NoContent(http.StatusNoContent) } diff --git a/core/base.go b/core/base.go index 8c201bc7..d810eafa 100644 --- a/core/base.go +++ b/core/base.go @@ -1278,7 +1278,7 @@ func (app *BaseApp) initLogger() error { }, }) - go func() { + routine.FireAndForget(func() { ctx := context.Background() for { @@ -1289,7 +1289,7 @@ func (app *BaseApp) initLogger() error { handler.WriteAll(ctx) } } - }() + }) app.logger = slog.New(handler) diff --git a/daos/view.go b/daos/view.go index f7b53d7c..0dfe9d6e 100644 --- a/daos/view.go +++ b/daos/view.go @@ -260,6 +260,12 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField, } for _, col := range p.columns { + // note: it should be safe to use the already parsed alias as there + // is no valid SQL where * column can be aliased to something else + if col.alias == "*" { + return nil, errors.New("wildcard columns (*) are not supported - manually type the collection field names you want the view query to have") + } + colLower := strings.ToLower(col.original) // numeric aggregations @@ -326,10 +332,6 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField, continue } - if fieldName == "*" { - return nil, errors.New("dynamic column names are not supported") - } - // find the first field by name (case insensitive) var field *schema.SchemaField for _, f := range collection.Schema.Fields() { diff --git a/daos/view_test.go b/daos/view_test.go index 18205525..8ac6f472 100644 --- a/daos/view_test.go +++ b/daos/view_test.go @@ -243,6 +243,12 @@ func TestCreateViewSchema(t *testing.T) { true, nil, }, + { + "wrapped query with wildcard column", + "select * from (select 1 as id)", + true, + nil, + }, { "query with comments", ` diff --git a/plugins/jsvm/jsvm.go b/plugins/jsvm/jsvm.go index d1d04dcc..91558024 100644 --- a/plugins/jsvm/jsvm.go +++ b/plugins/jsvm/jsvm.go @@ -32,6 +32,7 @@ import ( "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" "github.com/pocketbase/pocketbase/plugins/jsvm/internal/types/generated" + "github.com/pocketbase/pocketbase/tools/routine" "github.com/pocketbase/pocketbase/tools/template" ) @@ -400,7 +401,7 @@ func (p *plugin) watchHooks() error { }) // start listening for events. - go func() { + routine.FireAndForget(func() { defer stopDebounceTimer() for { @@ -430,7 +431,7 @@ func (p *plugin) watchHooks() error { color.Red("Watch error:", err) } } - }() + }) // add directories to watch // diff --git a/pocketbase.go b/pocketbase.go index 9b68c254..fbdee59c 100644 --- a/pocketbase.go +++ b/pocketbase.go @@ -12,6 +12,7 @@ import ( "github.com/pocketbase/pocketbase/cmd" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/routine" "github.com/spf13/cobra" ) @@ -156,21 +157,21 @@ func (pb *PocketBase) Execute() error { done := make(chan bool, 1) // listen for interrupt signal to gracefully shutdown the application - go func() { + routine.FireAndForget(func() { sigch := make(chan os.Signal, 1) signal.Notify(sigch, os.Interrupt, syscall.SIGTERM) <-sigch done <- true - }() + }) // execute the root command - go func() { + routine.FireAndForget(func() { // note: leave to the commands to decide whether to print their error pb.RootCmd.Execute() done <- true - }() + }) <-done diff --git a/tools/cron/cron.go b/tools/cron/cron.go index 39d3627c..53477e28 100644 --- a/tools/cron/cron.go +++ b/tools/cron/cron.go @@ -13,6 +13,8 @@ import ( "fmt" "sync" "time" + + "github.com/pocketbase/pocketbase/tools/routine" ) type job struct { @@ -170,7 +172,7 @@ func (c *Cron) Start() { c.runDue(time.Now()) // run after each tick - go func() { + routine.FireAndForget(func() { for { select { case <-c.tickerDone: @@ -179,7 +181,7 @@ func (c *Cron) Start() { c.runDue(t) } } - }() + }) }) c.Unlock() } diff --git a/tools/filesystem/internal/s3lite/s3lite.go b/tools/filesystem/internal/s3lite/s3lite.go index 926d7133..4288aec4 100644 --- a/tools/filesystem/internal/s3lite/s3lite.go +++ b/tools/filesystem/internal/s3lite/s3lite.go @@ -85,6 +85,7 @@ import ( s3managerv2 "github.com/aws/aws-sdk-go-v2/feature/s3/manager" s3v2 "github.com/aws/aws-sdk-go-v2/service/s3" typesv2 "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/pocketbase/pocketbase/tools/routine" "github.com/aws/smithy-go" "gocloud.dev/blob" @@ -362,7 +363,7 @@ func (w *writer) Upload(r io.Reader) error { // error uploading to S3. func (w *writer) open(r io.Reader, closePipeOnError bool) { // This goroutine will keep running until Close, unless there's an error. - go func() { + routine.FireAndForget(func() { defer close(w.donec) if r == nil { @@ -379,7 +380,7 @@ func (w *writer) open(r io.Reader, closePipeOnError bool) { } w.err = err } - }() + }) } // Close completes the writer and closes it. Any error occurring during write diff --git a/tools/routine/routine.go b/tools/routine/routine.go index a18f8559..148980ac 100644 --- a/tools/routine/routine.go +++ b/tools/routine/routine.go @@ -1,6 +1,7 @@ package routine import ( + "fmt" "log" "runtime/debug" "sync" @@ -30,3 +31,17 @@ func FireAndForget(f func(), wg ...*sync.WaitGroup) { f() }() } + +// SafeWrap wraps the provided function with auto panic recover handling +// and returns any eventual panic as regular error. +func SafeWrap(f func() error) func() error { + return func() (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("[SafeWrap] recovered from panic: %v", r) + } + }() + + return f() + } +} diff --git a/tools/routine/routine_test.go b/tools/routine/routine_test.go index dcb6ace6..1032a883 100644 --- a/tools/routine/routine_test.go +++ b/tools/routine/routine_test.go @@ -1,6 +1,7 @@ package routine_test import ( + "strings" "sync" "testing" @@ -12,7 +13,7 @@ func TestFireAndForget(t *testing.T) { fn := func() { called = true - panic("test") + panic("test_recover") } wg := &sync.WaitGroup{} @@ -22,6 +23,29 @@ func TestFireAndForget(t *testing.T) { wg.Wait() if !called { - t.Error("Expected fn to be called.") + t.Fatal("Expected fn to be called.") + } +} + +func TestSafeWrap(t *testing.T) { + called := false + + fn := func() error { + called = true + panic("test_recover") + } + + err := routine.SafeWrap(fn)() + + if !called { + t.Fatal("Expected fn to be called.") + } + + if err == nil { + t.Fatal("Expected fn panic to be converted to error") + } + + if !strings.Contains(err.Error(), "test_recover") { + t.Fatal("Expected the returned error to contain the recovered panic value") } } diff --git a/tools/search/provider.go b/tools/search/provider.go index 8ce21733..89e7e04c 100644 --- a/tools/search/provider.go +++ b/tools/search/provider.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/tools/routine" "golang.org/x/sync/errgroup" ) @@ -268,8 +269,8 @@ func (s *Provider) Exec(items any) (*Result, error) { // execute the 2 queries concurrently errg := new(errgroup.Group) errg.SetLimit(2) - errg.Go(countExec) - errg.Go(modelsExec) + errg.Go(routine.SafeWrap(countExec)) + errg.Go(routine.SafeWrap(modelsExec)) if err := errg.Wait(); err != nil { return nil, err }