mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* fix(daemon): keep host TMP/TEMP for every Windows task Git Bash's /tmp usertemp mount is shared by all live MSYS processes of a user, whichever runtime started them. #8985 stopped Windows Claude tasks from seeding it with a task-private dir, but any other provider still could, and later tasks then lost /tmp once that dir was deleted. Apply the rule to every provider on Windows: TMP/TEMP inherit the host temp, TMPDIR stays task-private, and Claude keeps its CLAUDE_CODE_TMPDIR default. The Git Bash integration test now alternates providers. Closes #8824 Co-authored-by: multica-agent <[email protected]> * test(daemon): expect inherited TMP/TEMP on Windows in env test Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-eve <[email protected]> Co-authored-by: multica-agent <[email protected]>
195 lines
5.6 KiB
Go
195 lines
5.6 KiB
Go
//go:build windows && gitbashintegration
|
|
|
|
package daemon
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
|
)
|
|
|
|
// This is an opt-in test of real Git for Windows, not an agent CLI or account.
|
|
// Run with -tags=gitbashintegration and MULTICA_TEST_GIT_BASH=<bash.exe>.
|
|
// Tasks alternate providers: the MSYS /tmp mount is shared by every live Bash
|
|
// of this user, so a non-Claude task seeding it must be just as safe (#8824).
|
|
func TestWindowsGitBashTaskTempLifecycle(t *testing.T) {
|
|
bash := os.Getenv("MULTICA_TEST_GIT_BASH")
|
|
if bash == "" {
|
|
t.Skip("set MULTICA_TEST_GIT_BASH to explicitly select Git for Windows")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
base := t.TempDir()
|
|
hostTemp := filepath.Join(base, "user-temp")
|
|
if err := os.Mkdir(hostTemp, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("TMP", hostTemp)
|
|
t.Setenv("TEMP", hostTemp)
|
|
t.Setenv("CLAUDE_CODE_TMPDIR", "")
|
|
|
|
newTask := func() (string, *os.File) {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp(base, execenv.TaskTempDirPrefix)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lock, err := execenv.LockTaskTempDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { execenv.ReleaseTaskTempLock(lock) })
|
|
return dir, lock
|
|
}
|
|
|
|
start := func(dir, provider string, orphan bool) (map[string]string, func()) {
|
|
t.Helper()
|
|
script := `printf 'PID='; /usr/bin/cat /proc/$$/winpid; printf '\n'
|
|
printf 'TMPDIR=%s\n' "$(/usr/bin/cygpath -w "$TMPDIR")"
|
|
printf 'TARGET=%s\n' "$(/usr/bin/cygpath -w /tmp)"
|
|
if file=$(/usr/bin/mktemp /tmp/multica-regression-XXXXXXXX); then
|
|
/usr/bin/rm -- "$file"
|
|
printf 'TMP_OK=yes\n'
|
|
else
|
|
printf 'TMP_OK=no\n'
|
|
fi
|
|
printf 'READY\n'
|
|
IFS= read -r release
|
|
`
|
|
if orphan {
|
|
// The launcher's inherited stdin stays open in the Bash child.
|
|
script = "exec 3<&0\n/usr/bin/bash --noprofile --norc -c '" + strings.ReplaceAll(script, "'", "'\"'\"'") + "' <&3 &\n"
|
|
}
|
|
input, feed, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
output, sink, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stderr, err := os.CreateTemp(base, "bash-stderr-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { feed.Close(); output.Close(); stderr.Close() })
|
|
cmd := exec.CommandContext(ctx, bash, "--noprofile", "--norc", "-c", script)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = input, sink, stderr
|
|
cmd.Env = os.Environ()
|
|
for key, value := range taskMulticaEnvironment(Task{}, provider, "", "", "", "", "", 0, 0, dir) {
|
|
cmd.Env = append(cmd.Env, key+"="+value)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
input.Close()
|
|
sink.Close()
|
|
go func() { <-ctx.Done(); feed.Close(); output.Close() }()
|
|
snapshot := map[string]string{}
|
|
scanner := bufio.NewScanner(output)
|
|
ready := false
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "READY" {
|
|
ready = true
|
|
break
|
|
}
|
|
if key, value, ok := strings.Cut(line, "="); ok {
|
|
snapshot[key] = value
|
|
}
|
|
}
|
|
if !ready {
|
|
cmd.Process.Kill()
|
|
cmd.Wait()
|
|
t.Fatalf("Git Bash did not become ready: %v, snapshot=%v", scanner.Err(), snapshot)
|
|
}
|
|
pid, err := strconv.Atoi(snapshot["PID"])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
child, err := os.FindProcess(pid)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if orphan {
|
|
if err := cmd.Wait(); err != nil {
|
|
t.Fatalf("Bash launcher did not exit successfully: %v", err)
|
|
}
|
|
}
|
|
stopped := false
|
|
stop := func() {
|
|
if stopped {
|
|
return
|
|
}
|
|
stopped = true
|
|
io.WriteString(feed, "release\n")
|
|
feed.Close()
|
|
if orphan {
|
|
child.Wait()
|
|
} else {
|
|
cmd.Wait()
|
|
child.Release()
|
|
}
|
|
}
|
|
t.Cleanup(stop)
|
|
if !strings.EqualFold(snapshot["TMPDIR"], dir) {
|
|
t.Fatalf("Bash TMPDIR=%q, want this task's %q", snapshot["TMPDIR"], dir)
|
|
}
|
|
return snapshot, stop
|
|
}
|
|
|
|
first, firstLock := newTask()
|
|
initial, stopFirst := start(first, "codex", false)
|
|
if !strings.EqualFold(initial["TARGET"], hostTemp) && !strings.EqualFold(initial["TARGET"], first) {
|
|
t.Skipf("another MSYS process already owns /tmp at %q", initial["TARGET"])
|
|
}
|
|
second, secondLock := newTask()
|
|
overlap, stopOrphan := start(second, "claude", true)
|
|
stopFirst()
|
|
execenv.ReleaseTaskTempLock(firstLock)
|
|
if err := execenv.RemoveTaskTempDir(first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(first); !os.IsNotExist(err) {
|
|
t.Fatalf("completed task temp was not removed: %v", err)
|
|
}
|
|
if removed, _ := execenv.PruneTaskTempDirs(base, 0, time.Now(), nil); removed != 0 {
|
|
t.Fatalf("GC removed %d dirs while the second task held its lock", removed)
|
|
}
|
|
third, thirdLock := newTask()
|
|
afterDelete, stopThird := start(third, "opencode", false)
|
|
for name, snapshot := range map[string]map[string]string{"overlap": overlap, "after deletion with orphan alive": afterDelete} {
|
|
if snapshot["TMP_OK"] != "yes" {
|
|
t.Errorf("%s: /tmp unusable: %v", name, snapshot)
|
|
}
|
|
if !strings.EqualFold(snapshot["TARGET"], hostTemp) {
|
|
t.Errorf("%s: /tmp target %q, want stable host temp %q", name, snapshot["TARGET"], hostTemp)
|
|
}
|
|
}
|
|
stopThird()
|
|
stopOrphan()
|
|
for dir, lock := range map[string]*os.File{second: secondLock, third: thirdLock} {
|
|
execenv.ReleaseTaskTempLock(lock)
|
|
if err := execenv.RemoveTaskTempDir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
fourth, _ := newTask()
|
|
sequential, stopFourth := start(fourth, "claude", false)
|
|
if sequential["TMP_OK"] != "yes" || !strings.EqualFold(sequential["TARGET"], hostTemp) {
|
|
t.Fatalf("sequential task after cleanup: %v", sequential)
|
|
}
|
|
stopFourth()
|
|
}
|