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
6 changes: 6 additions & 0 deletions .github/workflows/wefto-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ jobs:
node-version-file: magpie/.node-version
package-manager-cache: false

- name: Resolve the macOS temporary directory
if: runner.os == 'macOS'
run: |
canonical_temp="$(cd "${TMPDIR:-/tmp}" && pwd -P)"
printf 'TMPDIR=%s\nTMP=%s\nTEMP=%s\n' "$canonical_temp" "$canonical_temp" "$canonical_temp" >> "$GITHUB_ENV"

- name: Configure the Windows race compiler
if: runner.os == 'Windows'
shell: pwsh
Expand Down
2 changes: 2 additions & 0 deletions magpie/docs/product-regression.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ Windows 在 PowerShell 中可用 `npm.cmd`,不要求改变脚本执行策略

三个独立原生 job 分别使用 Ubuntu 24.04 x64、Windows 2025 x64、macOS 15 arm64。每个 job 从 `go.mod`、`.node-version`、`package-lock.json` 准备工具链、Go modules 和 Playwright 浏览器;Windows 显式检查 MinGW-w64 gcc,Linux 在准备阶段安装浏览器系统库。当前不持久化依赖缓存,避免混用不同平台或镜像版本的缓存。

macOS 在运行入口检查和产品回归前,将系统临时目录解析为真实路径并固定 `TMPDIR`、`TMP`、`TEMP`。系统常见的 `/var` → `/private/var` 别名因此不会混入测试布局;产品原有的符号链接/重解析点路径保护仍生效。

准备完成后运行 `npm run test:entry`,再调用同一条完整入口 `npm test -- --output=dist/regression/ci`;构建、27 包 race、27 包 vet、Chromium 和 WebKit 任一步失败都会阻断该 job。最终还检查 summary 的 `passed`、`complete: true`、干净 tracked 工作树和本次 GitHub 候选 SHA;不使用诊断模式或允许失败的矩阵项。

每个平台始终尝试上传 `magpie/dist/regression/` 为 `wefto-regression-<平台>-<运行次数>` artifact,保留 14 天,包含本次二进制、summary、日志和截图。若在依赖准备阶段即失败,还没有回归目录,错误可从 Actions 步骤日志查看。工作流只获得仓库内容读取权限,不发布 Release,不需要模型、供应商或宿主凭证。
Expand Down
4 changes: 4 additions & 0 deletions magpie/docs/subsystems/wefto-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

[`routercontrol`](../../internal/routercontrol/control.go) implements background start, status, stop, restart and explicit Windows per-user autostart. `state/router.lock/instance.json` records exact executable, PID, process stamp, instance identity and address. Control validates those against the authenticated instance; another executable owning the layout is not adopted. Only recognized exited/reused-process locks may be archived.

Executable comparisons resolve both existing absolute paths before comparing them. This recognizes Windows 8.3 aliases and macOS symlinked temporary roots returned by OS process queries. A missing or unreadable path does not establish identity; a distinct hard-link path remains another installation. PID creation stamps and authenticated instance checks still apply.

Native Windows supports external process identity and per-user autostart. Linux provides external PID identity for regression runners. Other platforms currently identify only the current foreground process: an external process remains unknown, and its lock must not be archived or its process controlled. The cleanup regression exercises that refusal on those platforms rather than spawning a child that cannot be identified.

The ordinary Windows development entry is [`scripts/start-project.ps1`](../../../scripts/start-project.ps1), also called by `start.cmd`. It uses `magpie/dist/wefto.exe` and ordinary Windows roots, and reports the actual management URL. `-CheckOnly -NoBrowser` is read-only. Its check accepts a whole fresh or legacy layout, never a mixture. Upgrades stop the original executable before switching builds; see the [upgrade guide](../wefto-upgrade.md).

## Management and model requests
Expand Down
31 changes: 31 additions & 0 deletions magpie/internal/routercontrol/cleanup_test.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,47 @@
package routercontrol

import (
"bytes"
"encoding/json"
"github.com/yetone/magpie/internal/dirs"
"github.com/yetone/magpie/internal/proc"
"os"
"path/filepath"
"runtime"
"testing"
"time"
)

func TestStartupFailureCleanupOwnsExactChild(t *testing.T) {
if runtime.GOOS != "windows" && runtime.GOOS != "linux" {
t.Run("unsupported-process-identity-retains-unknown-lock", func(t *testing.T) {
stamp, executable, err := processIdentity(os.Getppid())
if err == nil || stamp != "" || executable != "" {
t.Fatal("unsupported external process identity did not remain unknown", err)
}
layout := startupRecordFixture(t, true)
record, err := ReadRecord(layout)
if err != nil {
t.Fatal(err)
}
record.PID = os.Getppid()
before, err := json.Marshal(record)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(RecordFile(layout), before, 0o600); err != nil {
t.Fatal(err)
}
if err := archiveExitedLock(layout); err == nil {
t.Fatal("unsupported external process control archived an unknown lock")
}
after, err := os.ReadFile(RecordFile(layout))
if err != nil || !bytes.Equal(before, after) {
t.Fatal("unknown process record was changed", err)
}
})
return
}
if os.Args[len(os.Args)-1] == "router-control-cleanup-child" {
for {
time.Sleep(time.Minute)
Expand Down
16 changes: 16 additions & 0 deletions magpie/internal/routercontrol/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ func Verify(r Record) error {
return nil
}
func samePath(a, b string) bool {
if !filepath.IsAbs(a) || !filepath.IsAbs(b) {
return false
}
// OS process queries can retain Windows 8.3 names or a symlinked macOS
// temporary root, while LaunchRecord stores the resolved executable path.
// Compare resolved paths, not file IDs: another hard-linked installation
// is still another executable path. Unavailable paths remain unknown.
var err error
a, err = filepath.EvalSymlinks(a)
if err != nil {
return false
}
b, err = filepath.EvalSymlinks(b)
if err != nil {
return false
}
if runtime.GOOS == "windows" {
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
}
Expand Down
47 changes: 47 additions & 0 deletions magpie/internal/routercontrol/executable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package routercontrol

import (
"os"
"path/filepath"
"testing"
)

func TestExecutableIdentityResolvesAliasesAndRejectsOtherPaths(t *testing.T) {
stamp, executable, err := processIdentity(os.Getpid())
if err != nil {
t.Fatal(err)
}
record := Record{PID: os.Getpid(), Executable: executable, ProcessStamp: stamp}
t.Run("symlink", func(t *testing.T) {
alias := filepath.Join(t.TempDir(), "service-alias")
if err := os.Symlink(executable, alias); err != nil {
t.Skip("symbolic links unavailable: ", err)
}
record.Executable = alias
if err := Verify(record); err != nil {
t.Fatalf("the same executable's resolved alias was rejected: %v", err)
}
})
t.Run("hard-link-is-another-path", func(t *testing.T) {
// A running Windows image cannot be unlinked until the process exits.
// Use closed fixture files to exercise the same path-ownership rule.
root := t.TempDir()
owned := filepath.Join(root, "owned.exe")
if err := os.WriteFile(owned, []byte("executable identity fixture"), 0o600); err != nil {
t.Fatal(err)
}
alias := filepath.Join(root, "another-installation.exe")
if err := os.Link(owned, alias); err != nil {
t.Skip("hard links unavailable: ", err)
}
if samePath(owned, alias) {
t.Fatal("another executable path was adopted merely because it shares a file ID")
}
})
t.Run("missing-path-is-unknown", func(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing-executable")
if samePath(missing, missing) {
t.Fatal("unavailable executable paths were treated as the same identity")
}
})
}
58 changes: 58 additions & 0 deletions magpie/internal/routercontrol/executable_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package routercontrol

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/yetone/magpie/internal/dirs"
"github.com/yetone/magpie/internal/proc"
"golang.org/x/sys/windows"
)

func TestLaunchRecordFromWindowsShortExecutablePath(t *testing.T) {
const child = "routercontrol-short-executable-child"
if os.Args[len(os.Args)-1] == child {
layout := dirs.Layout{State: t.TempDir()}
if err := os.Mkdir(layout.ServiceLock(), 0o700); err != nil {
t.Fatal(err)
}
record, err := LaunchRecord(layout, "test", "short-path-instance", "127.0.0.1:13145", false)
if err != nil {
t.Fatalf("launching the same executable through its Windows short path: %v", err)
}
if err := Verify(record); err != nil {
t.Fatal(err)
}
return
}

executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
long, err := filepath.EvalSymlinks(executable)
if err != nil {
t.Fatal(err)
}
pointer, err := windows.UTF16PtrFromString(long)
if err != nil {
t.Fatal(err)
}
buffer := make([]uint16, 32768)
n, err := windows.GetShortPathName(pointer, &buffer[0], uint32(len(buffer)))
if err != nil || n == 0 || n >= uint32(len(buffer)) {
t.Fatalf("getting the executable's Windows short path: %d, %v", n, err)
}
short := windows.UTF16ToString(buffer[:n])
if strings.EqualFold(long, short) {
t.Skip("the executable's volume does not provide a distinct 8.3 alias")
}
t.Logf("launching %s through %s", long, short)
command := proc.Command(short, "-test.run=^TestLaunchRecordFromWindowsShortExecutablePath$", "--", child)
hide(command)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("short-path service identity failed: %v\n%s", err, output)
}
}
Loading