//go:build windows package compose import ( "context" "errors" "os" "os/exec" "strconv" "strings" "testing" "time" "golang.org/x/sys/windows" ) func TestOwnedProcessUsesCreationTimeJobAttribute(t *testing.T) { if procThreadAttributeJobList == 0 { t.Fatal("owned Windows process must carry a creation-time job-list attribute") } cmd := exec.Command("cmd.exe") configureOwnedProcess(cmd) if cmd.SysProcAttr != nil && cmd.SysProcAttr.CreationFlags&windows.CREATE_SUSPENDED != 0 { t.Fatal("owned Windows process must not rely on a post-start assignment race") } } func TestOwnedJobPolicyKillsDescendantsOnClose(t *testing.T) { if windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0 { t.Fatal("owned job must use kill-on-close policy") } } // TestWindowsProcessHelper is both a test helper and the child process used by the runtime // ownership regression below. The grandchild records its PID before the parent can exit. The // parent then either exits, blocks for cancellation, or overflows stdout. func TestWindowsProcessHelper(t *testing.T) { mode := os.Getenv("THOTHCTL_WINDOWS_HELPER") if mode == "" { return } pidFile := os.Getenv("THOTHCTL_WINDOWS_PID_FILE") releaseFile := os.Getenv("THOTHCTL_WINDOWS_RELEASE_FILE") if mode == "grandchild" { if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil { t.Fatal(err) } for { time.Sleep(time.Hour) } } child := exec.Command(os.Args[0], "-test.run=TestWindowsProcessHelper", "--") child.Env = append(os.Environ(), "THOTHCTL_WINDOWS_HELPER=grandchild", "THOTHCTL_WINDOWS_PID_FILE="+pidFile, ) if err := child.Start(); err != nil { t.Fatal(err) } waitForWindowsPIDFile(t, pidFile) waitForWindowsReleaseFile(t, releaseFile) if mode == "exit" { return } if mode == "cancel" { for { time.Sleep(time.Hour) } } for { _, _ = os.Stdout.Write([]byte("overflow\n")) } } func waitForWindowsPIDFile(t *testing.T, path string) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { contents, err := os.ReadFile(path) if err == nil { pid, parseErr := strconv.Atoi(strings.TrimSpace(string(contents))) if parseErr == nil && pid > 0 { return } } time.Sleep(time.Millisecond) } t.Fatalf("grandchild PID was not recorded in %s", path) } func waitForWindowsReleaseFile(t *testing.T, path string) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if _, err := os.Stat(path); err == nil { return } time.Sleep(time.Millisecond) } t.Fatalf("test did not release Windows helper via %s", path) } func openWindowsProcessForTest(t *testing.T, pid int) windows.Handle { t.Helper() const access = windows.SYNCHRONIZE | windows.PROCESS_QUERY_LIMITED_INFORMATION handle, err := windows.OpenProcess(access, false, uint32(pid)) if err != nil { t.Fatalf("OpenProcess(%d): %v", pid, err) } return handle } func assertWindowsProcessTerminated(t *testing.T, handle windows.Handle, pid int) { t.Helper() const stillActive = 259 status, err := windows.WaitForSingleObject(handle, uint32((5*time.Second)/time.Millisecond)) if err != nil { t.Fatalf("WaitForSingleObject(%d): %v", pid, err) } if status != windows.WAIT_OBJECT_0 { t.Fatalf("WaitForSingleObject(%d) = %#x, want signaled terminated process", pid, status) } var exitCode uint32 if err := windows.GetExitCodeProcess(handle, &exitCode); err != nil { t.Fatalf("GetExitCodeProcess(%d): %v", pid, err) } if exitCode == stillActive { t.Fatalf("GetExitCodeProcess(%d) still reports STILL_ACTIVE", pid) } } func TestOwnedProcessTerminatesDescendantOnOverflowCancelAndParentExit(t *testing.T) { for _, mode := range []string{"overflow", "cancel", "exit"} { t.Run(mode, func(t *testing.T) { pidFile, err := os.CreateTemp("", "thothctl-windows-grandchild-*.pid") if err != nil { t.Fatal(err) } pidPath := pidFile.Name() _ = pidFile.Close() _ = os.Remove(pidPath) defer os.Remove(pidPath) releaseFile, err := os.CreateTemp("", "thothctl-windows-release-*") if err != nil { t.Fatal(err) } releasePath := releaseFile.Name() _ = releaseFile.Close() _ = os.Remove(releasePath) defer os.Remove(releasePath) t.Setenv("THOTHCTL_WINDOWS_HELPER", mode) t.Setenv("THOTHCTL_WINDOWS_PID_FILE", pidPath) t.Setenv("THOTHCTL_WINDOWS_RELEASE_FILE", releasePath) ctx := context.Background() var cancel context.CancelFunc if mode == "cancel" { ctx, cancel = context.WithCancel(ctx) defer cancel() } result := make(chan error, 1) go func() { _, runErr := runBoundedWindows(ctx, os.Args[0], []string{"-test.run=TestWindowsProcessHelper", "--"}, nil, CaptureLimits{StdoutBytes: 1024, StderrBytes: 1024}) result <- runErr }() waitForWindowsPIDFile(t, pidPath) contents, err := os.ReadFile(pidPath) if err != nil { t.Fatal(err) } pid, err := strconv.Atoi(strings.TrimSpace(string(contents))) if err != nil { t.Fatal(err) } handle := openWindowsProcessForTest(t, pid) defer windows.CloseHandle(handle) if err := os.WriteFile(releasePath, []byte("release"), 0o600); err != nil { t.Fatal(err) } if mode == "cancel" { cancel() } select { case runErr := <-result: if mode == "overflow" && !errors.Is(runErr, ErrOutputLimit) { t.Fatalf("overflow error = %v, want ErrOutputLimit", runErr) } if mode == "cancel" && !errors.Is(runErr, context.Canceled) { t.Fatalf("cancel error = %v, want context.Canceled", runErr) } case <-time.After(5 * time.Second): t.Fatal("owned process did not terminate") } assertWindowsProcessTerminated(t, handle, pid) }) } }