diff --git a/.gitea/workflows/index-artifact.yml b/.gitea/workflows/index-artifact.yml index 6b6c97d..c07a9c8 100644 --- a/.gitea/workflows/index-artifact.yml +++ b/.gitea/workflows/index-artifact.yml @@ -42,7 +42,10 @@ jobs: runs-on: ubuntu-latest container: # CGO is not needed: the project uses the pure-Go modernc sqlite - # driver, and neither command imports the Wails app. + # driver, and neither command imports the Wails app — which is a + # claim with a test behind it now (cmd/indexbuild/deps_test.go), + # because the v3 migration quietly broke it and this job was where + # that surfaced. image: golang:1.25 # This host path must exist on the runner and be listed verbatim in # act_runner's container.valid_volumes. It holds explore-staging/ diff --git a/CLAUDE.md b/CLAUDE.md index 54fd289..8933725 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1701,6 +1701,21 @@ frontend would receive (`backend/queue/emit_test.go` is the model). `events.Deliver` is the same call returning an error instead of dropping, and has one legitimate caller — `/__test/emit`. +**Naming the Wails application costs cgo, so exactly two files may.** +v3's `application` package is GTK/WebKit bindings on Linux, and +`cmd/indexbuild` / `cmd/indexexport` are built in a plain `golang` +container with `CGO_ENABLED=0` — the index workflow says so and it is +the one job that must not fail, since it owns the ~205 GB checkpoint. +So the single `app.Event.Emit` lives in `backend/events/runtime_wails.go` +under `//go:build !indexbuild` (with `runtime_indexbuild.go` returning +`ErrNoRuntime`, which is what the app itself returns before Run), and +`explore`'s `ServiceStartup` — the only other thing in that dependency +tree naming `application` — sits in `backend/explore/servicestartup.go` +under the same tag. `TestIndexToolsDoNotImportWails` walks the dependency +graph with `go list -deps -tags indexbuild` and is what keeps it that +way; a `ServiceStartup` hook added to a package the index tools import +is the way this comes back. + ## Code Generation Two generators run via `go generate ./...` (or `make generate`): diff --git a/backend/events/emit.go b/backend/events/emit.go index 6c0d5be..13accd4 100644 --- a/backend/events/emit.go +++ b/backend/events/emit.go @@ -4,8 +4,6 @@ import ( "context" "errors" "log/slog" - - "github.com/wailsapp/wails/v3/pkg/application" ) // ErrNoRuntime is returned by Deliver when there is neither a test Sink @@ -82,16 +80,7 @@ func Deliver(ctx context.Context, name string, data ...any) error { } } - // application.Get() returns nil when no app is running — under test, - // before Run, and after shutdown. It does not terminate the - // process, which is what the v2 probe of the private "events" - // context key existed to avoid. - app := application.Get() - if app == nil { - return ErrNoRuntime - } - - app.Event.Emit(name, data...) - - return nil + // emitRuntime is the only place the Wails application is touched, + // and it is behind a build tag: see runtime_wails.go. + return emitRuntime(name, data...) } diff --git a/backend/events/noemit_test.go b/backend/events/noemit_test.go index 2498765..00cf646 100644 --- a/backend/events/noemit_test.go +++ b/backend/events/noemit_test.go @@ -8,9 +8,12 @@ import ( ) // allowedEmitters are the only files permitted to call the Wails -// runtime's event emitter directly. +// runtime's event emitter directly. There is one call and it lives in +// runtime_wails.go rather than emit.go because naming the application +// package is what forces cgo, and cmd/indexbuild builds this package +// without it. var allowedEmitters = map[string]bool{ - filepath.Join("backend", "events", "emit.go"): true, + filepath.Join("backend", "events", "runtime_wails.go"): true, } // TestNoDirectRuntimeEmits fails if anything outside backend/events diff --git a/backend/events/runtime_indexbuild.go b/backend/events/runtime_indexbuild.go new file mode 100644 index 0000000..cd92e37 --- /dev/null +++ b/backend/events/runtime_indexbuild.go @@ -0,0 +1,18 @@ +//go:build indexbuild + +package events + +// emitRuntime is the index tools' half of the split described in +// runtime_wails.go: under the indexbuild tag there is no Wails +// application to emit through, and importing the one that would be +// there costs cgo and a GTK/WebKit toolchain the index build container +// deliberately does not have. +// +// Returning ErrNoRuntime is the same answer the app gives before Run +// and after shutdown, so Emit's callers need no second code path: an +// event emitted by cmd/indexbuild is logged and dropped. A test that +// wants to observe one installs a Sink with WithSink, which Deliver +// consults first and which works under either tag. +func emitRuntime(_ string, _ ...any) error { + return ErrNoRuntime +} diff --git a/backend/events/runtime_wails.go b/backend/events/runtime_wails.go new file mode 100644 index 0000000..5c02596 --- /dev/null +++ b/backend/events/runtime_wails.go @@ -0,0 +1,32 @@ +//go:build !indexbuild + +package events + +import ( + "github.com/wailsapp/wails/v3/pkg/application" +) + +// emitRuntime pushes an event through the running Wails application. +// +// It is the app's half of a two-file split, and the split exists for a +// build constraint rather than a design one: v3's application package +// is cgo on Linux, so anything importing it needs GTK and WebKit +// headers to compile. cmd/indexbuild and cmd/indexexport reach this +// package through backend/explore and are built in a plain golang +// container with CGO_ENABLED=0 (.gitea/workflows/index-artifact.yml), +// where that is not available and not wanted. See +// runtime_indexbuild.go for the other half. +func emitRuntime(name string, data ...any) error { + // application.Get() returns nil when no app is running — under + // test, before Run, and after shutdown. It does not terminate the + // process, which is what the v2 probe of the private "events" + // context key existed to avoid. + app := application.Get() + if app == nil { + return ErrNoRuntime + } + + app.Event.Emit(name, data...) + + return nil +} diff --git a/backend/explore/explore.go b/backend/explore/explore.go index 544f181..d0ef82f 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -9,7 +9,6 @@ import ( "sync" "time" - "github.com/wailsapp/wails/v3/pkg/application" "golang.org/x/sync/singleflight" "yellowjacket/backend/database" @@ -144,19 +143,9 @@ func (e *Service) CAALimiter() *RateLimiter { return e.caaLimiter } -// ServiceStartup is v3's service lifecycle hook: it runs once the -// runtime exists, and ctx is cancelled when the app shuts down. It -// replaces v2's SetContext, which had to be called by hand from -// OnStartup and was exported, so it was also bound to the frontend. -func (e *Service) ServiceStartup( - ctx context.Context, - _ application.ServiceOptions, -) error { - e.ctx = ctx - e.index.SetContext(ctx) - - return nil -} +// The v3 service lifecycle hook that fills e.ctx lives in +// servicestartup.go, behind a build tag: naming +// application.ServiceOptions is what would drag cgo into cmd/indexbuild. // StartIndexBuild kicks off the background search index build. // Call this after the library scan completes so the indexer doesn't diff --git a/backend/explore/servicestartup.go b/backend/explore/servicestartup.go new file mode 100644 index 0000000..bbc0b1c --- /dev/null +++ b/backend/explore/servicestartup.go @@ -0,0 +1,30 @@ +//go:build !indexbuild + +package explore + +import ( + "context" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// ServiceStartup is v3's service lifecycle hook: it runs once the +// runtime exists, and ctx is cancelled when the app shuts down. It +// replaces v2's SetContext, which had to be called by hand from +// OnStartup and was exported, so it was also bound to the frontend. +// +// It is the one thing in this package that names the Wails application, +// and it is behind a build tag for the reason +// backend/events/runtime_wails.go states: cmd/indexbuild imports this +// package and is built without cgo, GTK or WebKit. Nothing under the +// indexbuild tag runs a Wails app, so the hook — and the context it +// installs — is simply absent there. +func (e *Service) ServiceStartup( + ctx context.Context, + _ application.ServiceOptions, +) error { + e.ctx = ctx + e.index.SetContext(ctx) + + return nil +} diff --git a/cmd/indexbuild/deps_test.go b/cmd/indexbuild/deps_test.go new file mode 100644 index 0000000..4690649 --- /dev/null +++ b/cmd/indexbuild/deps_test.go @@ -0,0 +1,63 @@ +//go:build indexbuild + +package main_test + +import ( + "os/exec" + "strings" + "testing" +) + +// wailsApp is the package that costs cgo: on Linux it is GTK and +// WebKit bindings, which the index build container does not have. +const wailsApp = "github.com/wailsapp/wails/v3/pkg/application" + +// TestIndexToolsDoNotImportWails fails if cmd/indexbuild or +// cmd/indexexport reach the Wails application package. +// +// .gitea/workflows/index-artifact.yml builds both in a plain golang +// image with CGO_ENABLED=0, on the stated grounds that neither imports +// the app. That stopped being true when the v3 migration put +// application.Get() in backend/events and a ServiceStartup hook in +// backend/explore, and the workflow only found out at the point where +// it is most expensive to find out — the job that owns the 205 GB +// checkpoint. Both are behind the indexbuild tag now +// (backend/events/runtime_wails.go), and this is what keeps them there. +// +// It shells out to `go list` because the constraint is about the +// *linked* dependency graph under a particular tag, which no import of +// this package can observe. +func TestIndexToolsDoNotImportWails(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("go"); err != nil { + t.Skip("no go toolchain on PATH") + } + + for _, pkg := range []string{ + "yellowjacket/cmd/indexbuild", + "yellowjacket/cmd/indexexport", + } { + t.Run(pkg, func(t *testing.T) { + t.Parallel() + + out, err := exec.Command( + "go", "list", "-deps", "-tags", "indexbuild", pkg, + ).Output() + if err != nil { + t.Fatalf("go list %s: %v", pkg, err) + } + + for _, dep := range strings.Split(string(out), "\n") { + if strings.TrimSpace(dep) == wailsApp { + t.Errorf( + "%s imports %s, so it needs cgo and a GTK/WebKit "+ + "toolchain; put the dependency behind the "+ + "indexbuild tag", + pkg, wailsApp, + ) + } + } + }) + } +}