Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/en/dev/engine/buildlauncher.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ The command freezes `GOWORK` and `GOFLAGS` for the invocation and verifies the
module selection and graph metadata before and after both Go builds. The built
bridge and payload are content-digested.

Runtime assets are resolved by the same local-manifest, source-checkout, and
pinned-release/cache policy used by the packaging service. Set
`SPX_RUNTIME_OFFLINE=1` to prevent network access.
An explicit `SPX_RUNTIME_LOCAL_MANIFEST` or `SPX_RUNTIME_ASSET_DIR` takes
priority and fails closed. Otherwise, the command uses the verified release
cache and download first. When the selected SPX module is a source checkout,
an unpublished runtime or failed acquisition falls back to the exact-version
Engine and PCK in the first `GOPATH/bin`. Both files must be regular,
non-symlink files and are hashed again before packaging. If they are missing,
run `make dev` in the SPX checkout; `buildlauncher` never starts a Godot build.
Set `SPX_RUNTIME_OFFLINE=1` to skip network access and use cache/local fallback.
9 changes: 7 additions & 2 deletions docs/zh/dev/engine/buildlauncher.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,10 @@ module 和 local replacement,不依赖本地 XGo driver。
命令固定本次调用的 `GOWORK` 和 `GOFLAGS`,并在 bridge 与 launcher 构建前后
校验 module 选择和 graph 元数据;bridge 与 payload 通过内容摘要绑定。

runtime asset 遵循 packaging service 的 local manifest、source checkout、
pinned release/cache 顺序。设置 `SPX_RUNTIME_OFFLINE=1` 可禁止联网。
显式设置的 `SPX_RUNTIME_LOCAL_MANIFEST` 或 `SPX_RUNTIME_ASSET_DIR` 优先,且
校验失败不会静默回退。其他情况先使用经过校验的 release cache/下载;仅当
当前 Go graph 选中 SPX 源码 checkout 时,runtime 尚未发布或下载失败才回退到
首个 `GOPATH/bin` 中版本名完全一致的 Engine 与 PCK。两者必须是普通非符号
链接文件,打包前会重新计算摘要。文件不存在时应在 SPX checkout 执行
`make dev`;`buildlauncher` 不会隐式启动 Godot 编译。设置
`SPX_RUNTIME_OFFLINE=1` 后不访问网络,只尝试 cache 和本地回退。
100 changes: 67 additions & 33 deletions internal/launchpack/runtime_assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ type runtimeAssetDependencies struct {
fetch runtimebundle.FetchFunc
cacheRoot func() string
manifestPin func(release.RuntimeLock) (release.RuntimeManifestPin, error)
goBin func(context.Context, Config, []string) (string, error)
}

func defaultRuntimeAssetDependencies() runtimeAssetDependencies {
return runtimeAssetDependencies{
fetch: fetchRuntimeURL,
cacheRoot: runtimebundle.DefaultCacheRoot,
manifestPin: release.RuntimeManifestPinForLock,
goBin: resolveGoBin,
}
}

Expand All @@ -59,9 +61,10 @@ type runtimeAssetSource struct {
}

type localRuntimeSource struct {
manifest release.LocalRuntimeManifest
directory string
bytes []byte
manifest release.LocalRuntimeManifest
bytes []byte
enginePath string
packPath string
}

// acquireRuntimeAssets obtains one verified Engine/PCK pair.
Expand All @@ -73,6 +76,9 @@ func acquireRuntimeAssetsWith(ctx context.Context, cfg Config, streams IO, lock
if ctx == nil {
return Assets{}, errors.New("launchpack: nil context")
}
if err := ctx.Err(); err != nil {
return Assets{}, err
}
if dependencies.fetch == nil || dependencies.cacheRoot == nil || dependencies.manifestPin == nil {
return Assets{}, errors.New("launchpack: incomplete runtime acquisition dependencies")
}
Expand All @@ -95,17 +101,40 @@ func acquireRuntimeAssetsWith(ctx context.Context, cfg Config, streams IO, lock
}
offline = offline || cfg.RuntimeOffline

if local, found, err := findLocalRuntimeManifest(cfg, env, lock, spec); err != nil {
if local, found, err := findExplicitLocalRuntimeManifest(env, lock, spec); err != nil {
return Assets{}, err
} else if found {
return materializeLocalRuntime(ctx, cacheRoot, lock, spec, local)
}

source, err := resolvePublishedRuntime(ctx, cacheRoot, lock, spec, env, offline, dependencies)
_, assetDirSet, duplicate := environmentValue(env, runtimeAssetDirEnv)
if duplicate {
return Assets{}, fmt.Errorf("launchpack: duplicate %s", runtimeAssetDirEnv)
}
pin, err := dependencies.manifestPin(lock)
if err != nil {
publishedErr := fmt.Errorf("launchpack: resolve runtime manifest pin: %w", err)
if !assetDirSet && cfg.Source.SourceMode && errors.Is(err, release.ErrRuntimeManifestPinNotFound) {
return acquireSourceRuntime(ctx, cfg, env, cacheRoot, lock, spec, dependencies, publishedErr)
}
return Assets{}, publishedErr
}
if err := pin.ValidateForLock(lock); err != nil {
return Assets{}, err
}

source, err := resolvePublishedRuntime(ctx, cacheRoot, lock, spec, pin, env, offline, dependencies)
if err == nil {
var assets Assets
assets, err = materializePublishedRuntime(ctx, cacheRoot, lock, spec, source, offline)
if err == nil {
return assets, nil
}
}
if assetDirSet || !cfg.Source.SourceMode {
return Assets{}, err
}
return materializePublishedRuntime(ctx, cacheRoot, lock, spec, source, offline)
return acquireSourceRuntime(ctx, cfg, env, cacheRoot, lock, spec, dependencies, err)
}

func resolveRuntimeCacheRoot(env []string, defaultRoot func() string) (string, error) {
Expand Down Expand Up @@ -179,37 +208,34 @@ func runtimeEnvironment(cfg Config, base []string) []string {
return env
}

func findLocalRuntimeManifest(cfg Config, env []string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) {
path, explicit, duplicate := environmentValue(env, runtimeLocalManifestEnv)
func findExplicitLocalRuntimeManifest(env []string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) {
path, found, duplicate := environmentValue(env, runtimeLocalManifestEnv)
if duplicate {
return localRuntimeSource{}, false, fmt.Errorf("launchpack: duplicate %s", runtimeLocalManifestEnv)
}
if !explicit {
if _, assetDirSet, duplicate := environmentValue(env, runtimeAssetDirEnv); duplicate {
return localRuntimeSource{}, false, fmt.Errorf("launchpack: duplicate %s", runtimeAssetDirEnv)
} else if assetDirSet {
return localRuntimeSource{}, false, nil
}
if cfg.RuntimeSourceRoot == "" {
if !found {
return localRuntimeSource{}, false, nil
}
return readLocalRuntimeManifest(path, lock, spec, true)
}

func findSourceLocalRuntimeManifest(root string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) {
path, err := release.LocalRuntimeManifestPath(root, lock, spec.GOOS, spec.GOARCH)
if err != nil {
return localRuntimeSource{}, false, err
}
if info, err := os.Lstat(path); err != nil {
if os.IsNotExist(err) {
return localRuntimeSource{}, false, nil
}
candidate, pathErr := release.LocalRuntimeManifestPath(cfg.RuntimeSourceRoot, lock, spec.GOOS, spec.GOARCH)
if pathErr != nil {
return localRuntimeSource{}, false, pathErr
}
if info, err := os.Lstat(candidate); err == nil {
if !isRegularNonSymlink(info) {
return localRuntimeSource{}, false, fmt.Errorf("launchpack: discovered local runtime manifest is not a regular non-symlink file: %s", candidate)
}
path = candidate
explicit = true
} else if !os.IsNotExist(err) {
return localRuntimeSource{}, false, fmt.Errorf("launchpack: inspect local runtime manifest: %w", err)
}
}
if !explicit {
return localRuntimeSource{}, false, nil
return localRuntimeSource{}, false, fmt.Errorf("launchpack: inspect local runtime manifest: %w", err)
} else if !isRegularNonSymlink(info) {
return localRuntimeSource{}, false, fmt.Errorf("launchpack: discovered local runtime manifest is not a regular non-symlink file: %s", path)
}
return readLocalRuntimeManifest(path, lock, spec, false)
}

func readLocalRuntimeManifest(path string, lock release.RuntimeLock, spec release.HostRuntimeSpec, strict bool) (localRuntimeSource, bool, error) {
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return localRuntimeSource{}, false, fmt.Errorf("launchpack: %s must be an absolute clean path", runtimeLocalManifestEnv)
}
Expand All @@ -228,12 +254,20 @@ func findLocalRuntimeManifest(cfg Config, env []string, lock release.RuntimeLock
if err != nil {
return localRuntimeSource{}, false, err
}
if err := manifest.ValidateForLock(lock, spec.GOOS, spec.GOARCH); err != nil {
validate := manifest.ValidateForVersion
if strict {
validate = manifest.ValidateForLock
}
if err := validate(lock, spec.GOOS, spec.GOARCH); err != nil {
return localRuntimeSource{}, false, err
}
directory := filepath.Dir(path)
if err := manifest.VerifyFiles(directory); err != nil {
return localRuntimeSource{}, false, err
}
return localRuntimeSource{manifest: manifest, directory: directory, bytes: data}, true, nil
return localRuntimeSource{
manifest: manifest, bytes: data,
enginePath: filepath.Join(directory, manifest.Engine.Name),
packPath: filepath.Join(directory, manifest.Pack.Name),
}, true, nil
}
9 changes: 1 addition & 8 deletions internal/launchpack/runtime_fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,7 @@ import (

var runtimeHTTPClient = &http.Client{Timeout: 30 * time.Minute}

func resolvePublishedRuntime(ctx context.Context, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, env []string, offline bool, dependencies runtimeAssetDependencies) (runtimeAssetSource, error) {
pin, err := dependencies.manifestPin(lock)
if err != nil {
return runtimeAssetSource{}, fmt.Errorf("launchpack: resolve runtime manifest pin: %w", err)
}
if err := pin.ValidateForLock(lock); err != nil {
return runtimeAssetSource{}, err
}
func resolvePublishedRuntime(ctx context.Context, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, pin release.RuntimeManifestPin, env []string, offline bool, dependencies runtimeAssetDependencies) (runtimeAssetSource, error) {
assetDir, assetDirSet, duplicate := environmentValue(env, runtimeAssetDirEnv)
if duplicate {
return runtimeAssetSource{}, fmt.Errorf("launchpack: duplicate %s", runtimeAssetDirEnv)
Expand Down
14 changes: 10 additions & 4 deletions internal/launchpack/runtime_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/goplus/spx/v3/internal/release"
Expand Down Expand Up @@ -73,6 +72,7 @@ func TestAcquireRuntimeAssetsPrefersExplicitLocalManifest(t *testing.T) {
}

cfg.RuntimeManifestPath = ""
cfg.Source.SourceMode = true
assets, err = acquireRuntimeAssetsWith(context.Background(), cfg, IO{Env: []string{"SPX_RUNTIME_OFFLINE=1"}}, lock, runtimeAssetDependencies{
fetch: func(context.Context, string, io.Writer) error { return errors.New("network must not be used") },
cacheRoot: func() string { return filepath.Join(root, "cache-source") }, manifestPin: release.RuntimeManifestPinForLock,
Expand Down Expand Up @@ -110,7 +110,13 @@ func TestAcquireRuntimeAssetsRejectsInvalidExplicitManifestWithoutFetch(t *testi
return errors.New("invalid local manifest must not fetch")
}
cacheRoot := t.TempDir()
_, err := acquireRuntimeAssetsWith(context.Background(), Config{RuntimeCacheRoot: cacheRoot, RuntimeManifestPath: manifestPath}, IO{Env: []string{runtimeCacheEnv + "=" + cacheRoot}}, release.DefaultRuntimeLock(), localRuntimeTestDependencies(cacheRoot, fetch))
dependencies := localRuntimeTestDependencies(cacheRoot, fetch)
dependencies.goBin = func(context.Context, Config, []string) (string, error) {
calls++
return t.TempDir(), nil
}
cfg := Config{RuntimeCacheRoot: cacheRoot, RuntimeManifestPath: manifestPath, Source: SourceIdentity{SourceMode: true}}
_, err := acquireRuntimeAssetsWith(context.Background(), cfg, IO{Env: []string{runtimeCacheEnv + "=" + cacheRoot}}, release.DefaultRuntimeLock(), dependencies)
if err == nil {
t.Fatal("invalid explicit local manifest was accepted")
}
Expand Down Expand Up @@ -207,7 +213,7 @@ func TestAcquireRuntimeAssetsAutoDiscoveredManifestIdentityMismatchFailsClosed(t
calls++
return errors.New("auto-discovered local manifest must not fetch")
}
cfg := Config{RuntimeSourceRoot: sourceRoot, RuntimeCacheRoot: cacheRoot}
cfg := Config{RuntimeSourceRoot: sourceRoot, RuntimeCacheRoot: cacheRoot, Source: SourceIdentity{SourceMode: true}}
env := IO{Env: []string{}}
dependencies := localRuntimeTestDependencies(cacheRoot, fetch)
assets, err := acquireRuntimeAssetsWith(context.Background(), cfg, env, lock, dependencies)
Expand All @@ -216,7 +222,7 @@ func TestAcquireRuntimeAssetsAutoDiscoveredManifestIdentityMismatchFailsClosed(t
}
assets.Cleanup()

manifest.LockSHA256 = strings.Repeat("0", 64)
manifest.RuntimeVersion = "9.9.9"
data, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
Expand Down
4 changes: 2 additions & 2 deletions internal/launchpack/runtime_materialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ func materializePublishedRuntime(ctx context.Context, cacheRoot string, lock rel
func materializeLocalRuntime(ctx context.Context, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, source localRuntimeSource) (Assets, error) {
return materializeRuntimeBundle(ctx, cacheRoot, lock, spec, runtimeMaterializationInput{
manifestSHA256: digestBytes(source.bytes),
enginePath: filepath.Join(source.directory, source.manifest.Engine.Name),
packPath: filepath.Join(source.directory, source.manifest.Pack.Name),
enginePath: source.enginePath,
packPath: source.packPath,
engineSize: source.manifest.Engine.Size,
engineSHA256: source.manifest.Engine.SHA256,
packSize: source.manifest.Pack.Size,
Expand Down
108 changes: 108 additions & 0 deletions internal/launchpack/runtime_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package launchpack

import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"

"github.com/goplus/spx/v3/internal/release"
)

func acquireSourceRuntime(ctx context.Context, cfg Config, env []string, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, dependencies runtimeAssetDependencies, publishedErr error) (Assets, error) {
if err := ctx.Err(); err != nil {
return Assets{}, err
}
root := cfg.RuntimeSourceRoot
if root == "" {
return Assets{}, sourceRuntimeError(root, publishedErr, errors.New("SPX source root is unavailable"))
}
local, found, err := findSourceLocalRuntimeManifest(root, lock, spec)
if err != nil {
return Assets{}, sourceRuntimeError(root, publishedErr, err)
}
if found {
assets, err := materializeLocalRuntime(ctx, cacheRoot, lock, spec, local)
if err != nil {
return Assets{}, sourceRuntimeError(root, publishedErr, err)
}
return assets, nil
}
if dependencies.goBin == nil {
return Assets{}, sourceRuntimeError(root, publishedErr, errors.New("Go-bin resolver is unavailable"))
}
bin, err := dependencies.goBin(ctx, cfg, env)
if err != nil {
return Assets{}, sourceRuntimeError(root, publishedErr, err)
}
enginePath := filepath.Join(bin, spec.RuntimeName)
packPath := filepath.Join(bin, spec.PackName)
manifest, err := release.NewLocalRuntimeManifest(lock, spec.GOOS, spec.GOARCH, enginePath, packPath)
if err != nil {
return Assets{}, sourceRuntimeError(root, publishedErr, fmt.Errorf("inspect %s: %w", bin, err))
}
data, err := manifest.JSON()
if err != nil {
return Assets{}, sourceRuntimeError(root, publishedErr, err)
}
local = localRuntimeSource{manifest: manifest, bytes: data, enginePath: enginePath, packPath: packPath}
assets, err := materializeLocalRuntime(ctx, cacheRoot, lock, spec, local)
if err != nil {
return Assets{}, sourceRuntimeError(root, publishedErr, err)
}
return assets, nil
}

func sourceRuntimeError(root string, publishedErr, localErr error) error {
hint := "run make dev in the SPX source checkout"
if root != "" {
hint = "run make dev in " + root
}
return fmt.Errorf("launchpack: published runtime unavailable: %w; local runtime unavailable: %w; %s", publishedErr, localErr, hint)
}

func resolveGoBin(ctx context.Context, cfg Config, env []string) (string, error) {
if cfg.GoCommand == "" {
return "", errors.New("Go command is unavailable")
}
command := exec.CommandContext(ctx, cfg.GoCommand, "env", "GOPATH")
command.Dir = cfg.WorkDir
command.Env = hostGoEnv(cfg, env)
var stderr bytes.Buffer
command.Stderr = &stderr
output, err := command.Output()
if err != nil {
if message := strings.TrimSpace(stderr.String()); message != "" {
return "", fmt.Errorf("resolve Go bin: %w: %s", err, message)
}
return "", fmt.Errorf("resolve Go bin: %w", err)
}
paths := filepath.SplitList(strings.TrimSpace(string(output)))
if len(paths) == 0 || paths[0] == "" {
return "", errors.New("Go environment has no GOPATH")
}
bin := filepath.Join(paths[0], "bin")
if !filepath.IsAbs(bin) || filepath.Clean(bin) != bin {
return "", fmt.Errorf("Go bin must be an absolute clean path: %q", bin)
}
return bin, nil
}
Loading
Loading