@fatalmistake02/rian 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +155 -2
- package/THIRD_PARTY_NOTICES.txt +673 -0
- package/bin/rian.cjs +12 -0
- package/cmd/rian/main.go +17 -0
- package/dist/binaries/darwin-arm64/rian +0 -0
- package/dist/binaries/darwin-x64/rian +0 -0
- package/dist/binaries/linux-arm64/rian +0 -0
- package/dist/binaries/linux-x64/rian +0 -0
- package/dist/binaries/manifest.json +29 -0
- package/dist/binaries/win32-arm64/rian.exe +0 -0
- package/dist/binaries/win32-x64/rian.exe +0 -0
- package/go.mod +28 -0
- package/go.sum +44 -0
- package/internal/app/app.go +298 -0
- package/internal/app/app_test.go +85 -0
- package/internal/config/config.go +189 -0
- package/internal/config/config_test.go +101 -0
- package/internal/detect/detect.go +252 -0
- package/internal/detect/detect_test.go +86 -0
- package/internal/git/git.go +166 -0
- package/internal/git/git_test.go +73 -0
- package/internal/git/window_unix.go +7 -0
- package/internal/git/window_windows.go +8 -0
- package/internal/process/platform_unix.go +19 -0
- package/internal/process/platform_windows.go +25 -0
- package/internal/process/process.go +172 -0
- package/internal/process/process_test.go +137 -0
- package/internal/project/project.go +180 -0
- package/internal/project/project_test.go +71 -0
- package/internal/tools/lookup.go +41 -0
- package/internal/tools/platform_unix.go +34 -0
- package/internal/tools/platform_windows.go +52 -0
- package/internal/tools/platform_windows_test.go +29 -0
- package/internal/tools/tools.go +152 -0
- package/internal/tools/tools_test.go +56 -0
- package/internal/ui/actions.go +259 -0
- package/internal/ui/forms.go +156 -0
- package/internal/ui/model.go +220 -0
- package/internal/ui/styles.go +85 -0
- package/internal/ui/ui_test.go +170 -0
- package/internal/ui/update.go +470 -0
- package/internal/ui/view.go +459 -0
- package/lib/launcher.cjs +46 -0
- package/lib/platforms.cjs +20 -0
- package/package.json +39 -4
- package/scripts/build-binaries.cjs +60 -0
- package/scripts/check-package.cjs +49 -0
- package/scripts/ensure-binary.cjs +11 -0
- package/test/npm/launcher.test.cjs +45 -0
- package/test/npm/package.test.cjs +26 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Package git reads local repository state. Inspect never fetches the network.
|
|
2
|
+
package git
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"context"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/url"
|
|
8
|
+
"os"
|
|
9
|
+
"os/exec"
|
|
10
|
+
"strconv"
|
|
11
|
+
"strings"
|
|
12
|
+
"time"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
type Status struct {
|
|
16
|
+
Repository bool
|
|
17
|
+
Branch string
|
|
18
|
+
Modified, Untracked, Ahead, Behind int
|
|
19
|
+
Remote, GitHub, Commit string
|
|
20
|
+
CommitTime time.Time
|
|
21
|
+
Error string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func (s Status) Dirty() bool { return s.Modified+s.Untracked > 0 }
|
|
25
|
+
|
|
26
|
+
// GitHubRepo accepts SSH, HTTPS, git protocol and ssh:// remotes, but only
|
|
27
|
+
// github.com. Strict owner/repo validation also makes overrides safe URLs.
|
|
28
|
+
func GitHubRepo(remote string) string {
|
|
29
|
+
remote = strings.TrimSpace(remote)
|
|
30
|
+
var path string
|
|
31
|
+
if strings.HasPrefix(remote, "git@github.com:") {
|
|
32
|
+
path = strings.TrimPrefix(remote, "git@github.com:")
|
|
33
|
+
} else {
|
|
34
|
+
u, err := url.Parse(remote)
|
|
35
|
+
if err != nil || !strings.EqualFold(u.Hostname(), "github.com") {
|
|
36
|
+
return ""
|
|
37
|
+
}
|
|
38
|
+
if u.Scheme != "https" && u.Scheme != "http" && u.Scheme != "ssh" && u.Scheme != "git" {
|
|
39
|
+
return ""
|
|
40
|
+
}
|
|
41
|
+
path = strings.TrimPrefix(u.Path, "/")
|
|
42
|
+
}
|
|
43
|
+
path = strings.TrimSuffix(strings.TrimRight(path, "/"), ".git")
|
|
44
|
+
parts := strings.Split(path, "/")
|
|
45
|
+
if len(parts) != 2 || !validPart(parts[0]) || !validPart(parts[1]) {
|
|
46
|
+
return ""
|
|
47
|
+
}
|
|
48
|
+
return path
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func validPart(part string) bool {
|
|
52
|
+
if part == "" || part == "." || part == ".." {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
for _, r := range part {
|
|
56
|
+
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func NormalizeRepo(repo string) (string, error) {
|
|
64
|
+
if repo == "" {
|
|
65
|
+
return "", nil
|
|
66
|
+
}
|
|
67
|
+
if parsed := GitHubRepo(repo); parsed != "" {
|
|
68
|
+
return parsed, nil
|
|
69
|
+
}
|
|
70
|
+
if parsed := GitHubRepo("https://github.com/" + repo); parsed != "" {
|
|
71
|
+
return parsed, nil
|
|
72
|
+
}
|
|
73
|
+
return "", fmt.Errorf("use a GitHub owner/repository or a github.com URL")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func Inspect(path string) Status {
|
|
77
|
+
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
|
78
|
+
defer cancel()
|
|
79
|
+
run := func(args ...string) (string, error) {
|
|
80
|
+
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", path}, args...)...)
|
|
81
|
+
hideWindow(cmd)
|
|
82
|
+
output, err := cmd.Output()
|
|
83
|
+
if len(args) > 0 && args[0] == "status" {
|
|
84
|
+
return string(output), err
|
|
85
|
+
}
|
|
86
|
+
return strings.TrimSpace(string(output)), err
|
|
87
|
+
}
|
|
88
|
+
var s Status
|
|
89
|
+
if info, err := os.Stat(path); err != nil || !info.IsDir() {
|
|
90
|
+
s.Error = "This directory is no longer available"
|
|
91
|
+
return s
|
|
92
|
+
}
|
|
93
|
+
if _, err := exec.LookPath("git"); err != nil {
|
|
94
|
+
s.Error = "Git is not installed"
|
|
95
|
+
return s
|
|
96
|
+
}
|
|
97
|
+
root, err := run("rev-parse", "--is-inside-work-tree")
|
|
98
|
+
if err != nil || root != "true" {
|
|
99
|
+
return s
|
|
100
|
+
}
|
|
101
|
+
s.Repository = true
|
|
102
|
+
branch, err := run("symbolic-ref", "--quiet", "--short", "HEAD")
|
|
103
|
+
if err != nil {
|
|
104
|
+
branch, _ = run("rev-parse", "--short", "HEAD")
|
|
105
|
+
if branch != "" {
|
|
106
|
+
branch = "detached · " + branch
|
|
107
|
+
} else {
|
|
108
|
+
branch = "unborn"
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
s.Branch = branch
|
|
112
|
+
output, err := run("status", "--porcelain=v1", "-z", "--untracked-files=all")
|
|
113
|
+
if err != nil {
|
|
114
|
+
s.Error = "Unable to read Git status"
|
|
115
|
+
} else {
|
|
116
|
+
s.Modified, s.Untracked = ParsePorcelain(output)
|
|
117
|
+
}
|
|
118
|
+
s.Remote, _ = run("remote", "get-url", "origin")
|
|
119
|
+
if s.Remote == "" {
|
|
120
|
+
remotes, _ := run("remote")
|
|
121
|
+
if names := strings.Fields(remotes); len(names) > 0 {
|
|
122
|
+
s.Remote, _ = run("remote", "get-url", names[0])
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
s.GitHub = GitHubRepo(s.Remote)
|
|
126
|
+
counts, err := run("rev-list", "--left-right", "--count", "HEAD...@{upstream}")
|
|
127
|
+
if err == nil {
|
|
128
|
+
parts := strings.Fields(counts)
|
|
129
|
+
if len(parts) == 2 {
|
|
130
|
+
s.Ahead, _ = strconv.Atoi(parts[0])
|
|
131
|
+
s.Behind, _ = strconv.Atoi(parts[1])
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
commit, err := run("log", "-1", "--format=%ct%n%s")
|
|
135
|
+
if err == nil {
|
|
136
|
+
parts := strings.SplitN(commit, "\n", 2)
|
|
137
|
+
if len(parts) == 2 {
|
|
138
|
+
seconds, _ := strconv.ParseInt(parts[0], 10, 64)
|
|
139
|
+
s.CommitTime = time.Unix(seconds, 0)
|
|
140
|
+
s.Commit = parts[1]
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if ctx.Err() != nil {
|
|
144
|
+
s.Error = "Git status timed out"
|
|
145
|
+
}
|
|
146
|
+
return s
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func ParsePorcelain(output string) (modified, untracked int) {
|
|
150
|
+
entries := strings.Split(output, "\x00")
|
|
151
|
+
for i := 0; i < len(entries); i++ {
|
|
152
|
+
entry := entries[i]
|
|
153
|
+
if len(entry) < 3 {
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if entry[:2] == "??" {
|
|
157
|
+
untracked++
|
|
158
|
+
} else {
|
|
159
|
+
modified++
|
|
160
|
+
if entry[0] == 'R' || entry[0] == 'C' || entry[1] == 'R' || entry[1] == 'C' {
|
|
161
|
+
i++
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return
|
|
166
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package git
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"os/exec"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestGitHubRepo(t *testing.T) {
|
|
11
|
+
for _, remote := range []string{"git@github.com:FatalMistake02/Cardly.git", "https://github.com/FatalMistake02/Cardly.git", "ssh://git@github.com/FatalMistake02/Cardly.git", "git://github.com/FatalMistake02/Cardly", "https://github.com/FatalMistake02/Cardly/"} {
|
|
12
|
+
if actual := GitHubRepo(remote); actual != "FatalMistake02/Cardly" {
|
|
13
|
+
t.Errorf("%q → %q", remote, actual)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
for _, remote := range []string{"", "git@gitlab.com:owner/repo.git", "https://github.com.evil.test/a/b", "https://github.com/a/b/tree/main", "https://evil.test/github.com/a/b", "https://github.com/a/../b", "file:///a/b", "https://github.com/a/b%20x", "https://github.com/a/.."} {
|
|
17
|
+
if actual := GitHubRepo(remote); actual != "" {
|
|
18
|
+
t.Errorf("accepted %q as %q", remote, actual)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if actual, err := NormalizeRepo("owner/repo"); err != nil || actual != "owner/repo" {
|
|
22
|
+
t.Fatalf("override: %q %v", actual, err)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestPorcelainRenamesAndWhitespace(t *testing.T) {
|
|
27
|
+
modified, untracked := ParsePorcelain(" M file with spaces\x00R new name\x00old name\x00?? new file\x00 D deleted\x00")
|
|
28
|
+
if modified != 3 || untracked != 1 {
|
|
29
|
+
t.Fatalf("got modified %d, untracked %d", modified, untracked)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestInspectRepository(t *testing.T) {
|
|
34
|
+
if _, err := exec.LookPath("git"); err != nil {
|
|
35
|
+
t.Skip("git unavailable")
|
|
36
|
+
}
|
|
37
|
+
dir := t.TempDir()
|
|
38
|
+
run := func(args ...string) {
|
|
39
|
+
t.Helper()
|
|
40
|
+
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
|
41
|
+
if data, err := cmd.CombinedOutput(); err != nil {
|
|
42
|
+
t.Fatalf("git %v: %s %v", args, data, err)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
run("init", "-b", "main")
|
|
46
|
+
run("config", "user.name", "Rian Test")
|
|
47
|
+
run("config", "user.email", "rian@example.test")
|
|
48
|
+
run("config", "commit.gpgsign", "false")
|
|
49
|
+
if status := Inspect(dir); !status.Repository || status.Branch != "main" {
|
|
50
|
+
t.Fatalf("unborn branch: %+v", status)
|
|
51
|
+
}
|
|
52
|
+
file := filepath.Join(dir, "file.txt")
|
|
53
|
+
if err := os.WriteFile(file, []byte("first"), 0600); err != nil {
|
|
54
|
+
t.Fatal(err)
|
|
55
|
+
}
|
|
56
|
+
run("add", "--", "file.txt")
|
|
57
|
+
run("commit", "-m", "Initial project")
|
|
58
|
+
run("remote", "add", "origin", "git@github.com:owner/repo.git")
|
|
59
|
+
if err := os.WriteFile(file, []byte("changed"), 0600); err != nil {
|
|
60
|
+
t.Fatal(err)
|
|
61
|
+
}
|
|
62
|
+
if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new"), 0600); err != nil {
|
|
63
|
+
t.Fatal(err)
|
|
64
|
+
}
|
|
65
|
+
s := Inspect(dir)
|
|
66
|
+
if !s.Repository || s.Branch != "main" || s.Modified != 1 || s.Untracked != 1 || s.GitHub != "owner/repo" || s.Commit != "Initial project" || s.CommitTime.IsZero() || !s.Dirty() {
|
|
67
|
+
t.Fatalf("unexpected status: %+v", s)
|
|
68
|
+
}
|
|
69
|
+
run("checkout", "--detach")
|
|
70
|
+
if s = Inspect(dir); s.Branch == "main" || s.Branch == "" {
|
|
71
|
+
t.Fatalf("detached status: %+v", s)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//go:build !windows
|
|
2
|
+
|
|
3
|
+
package process
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"errors"
|
|
7
|
+
"os/exec"
|
|
8
|
+
"syscall"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func prepare(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} }
|
|
12
|
+
|
|
13
|
+
func stopTree(cmd *exec.Cmd) error {
|
|
14
|
+
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
15
|
+
if errors.Is(err, syscall.ESRCH) {
|
|
16
|
+
return nil
|
|
17
|
+
}
|
|
18
|
+
return err
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package process
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os/exec"
|
|
6
|
+
"strconv"
|
|
7
|
+
"syscall"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func prepare(cmd *exec.Cmd) {
|
|
11
|
+
if cmd.SysProcAttr == nil {
|
|
12
|
+
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
|
13
|
+
}
|
|
14
|
+
cmd.SysProcAttr.HideWindow = true
|
|
15
|
+
cmd.SysProcAttr.CreationFlags |= 0x00000200
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func stopTree(cmd *exec.Cmd) error {
|
|
19
|
+
kill := exec.Command("taskkill.exe", "/PID", strconv.Itoa(cmd.Process.Pid), "/T", "/F")
|
|
20
|
+
kill.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
21
|
+
if output, err := kill.CombinedOutput(); err != nil {
|
|
22
|
+
return fmt.Errorf("could not stop process tree: %s (%w)", output, err)
|
|
23
|
+
}
|
|
24
|
+
return nil
|
|
25
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Package process owns project processes and bounded, concurrent output buffers.
|
|
2
|
+
package process
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"os/exec"
|
|
8
|
+
"sync"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"rian/internal/tools"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const outputLimit = 96 * 1024
|
|
15
|
+
|
|
16
|
+
type Snapshot struct {
|
|
17
|
+
Command string
|
|
18
|
+
PID int
|
|
19
|
+
Started, Ended time.Time
|
|
20
|
+
Running bool
|
|
21
|
+
Stopped bool
|
|
22
|
+
Output string
|
|
23
|
+
Error string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type session struct {
|
|
27
|
+
mu sync.Mutex
|
|
28
|
+
cmd *exec.Cmd
|
|
29
|
+
state Snapshot
|
|
30
|
+
buffer []byte
|
|
31
|
+
done chan struct{}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func (s *session) Write(data []byte) (int, error) {
|
|
35
|
+
s.mu.Lock()
|
|
36
|
+
defer s.mu.Unlock()
|
|
37
|
+
n := len(data)
|
|
38
|
+
if n >= outputLimit {
|
|
39
|
+
s.buffer = append(s.buffer[:0], data[n-outputLimit:]...)
|
|
40
|
+
} else {
|
|
41
|
+
if len(s.buffer)+n > outputLimit {
|
|
42
|
+
s.buffer = s.buffer[len(s.buffer)+n-outputLimit:]
|
|
43
|
+
}
|
|
44
|
+
s.buffer = append(s.buffer, data...)
|
|
45
|
+
}
|
|
46
|
+
return n, nil
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func (s *session) snapshot() Snapshot {
|
|
50
|
+
s.mu.Lock()
|
|
51
|
+
defer s.mu.Unlock()
|
|
52
|
+
state := s.state
|
|
53
|
+
state.Output = string(s.buffer)
|
|
54
|
+
return state
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Manager struct {
|
|
58
|
+
mu sync.Mutex
|
|
59
|
+
startMu sync.Mutex
|
|
60
|
+
sessions map[string]*session
|
|
61
|
+
closed bool
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func New() *Manager { return &Manager{sessions: map[string]*session{}} }
|
|
65
|
+
|
|
66
|
+
func (m *Manager) Start(id, path, command string) (Snapshot, error) {
|
|
67
|
+
if info, err := os.Stat(path); err != nil || !info.IsDir() {
|
|
68
|
+
return Snapshot{}, fmt.Errorf("this directory is no longer available: %s", path)
|
|
69
|
+
}
|
|
70
|
+
return m.StartCommand(id, command, tools.Shell(path, command))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func (m *Manager) StartCommand(id, label string, cmd *exec.Cmd) (Snapshot, error) {
|
|
74
|
+
// Launch/shutdown serialize separately from the map lock. Screen snapshots
|
|
75
|
+
// never wait for an OS process launch (which can be slow on Windows).
|
|
76
|
+
m.startMu.Lock()
|
|
77
|
+
defer m.startMu.Unlock()
|
|
78
|
+
m.mu.Lock()
|
|
79
|
+
closed, existing := m.closed, m.sessions[id]
|
|
80
|
+
m.mu.Unlock()
|
|
81
|
+
if closed {
|
|
82
|
+
return Snapshot{}, fmt.Errorf("process manager is shutting down")
|
|
83
|
+
}
|
|
84
|
+
if existing != nil && existing.snapshot().Running {
|
|
85
|
+
return existing.snapshot(), fmt.Errorf("a command is already running for this project; stop it first")
|
|
86
|
+
}
|
|
87
|
+
s := &session{cmd: cmd, state: Snapshot{Command: label, Started: time.Now()}, done: make(chan struct{})}
|
|
88
|
+
cmd.Stdout, cmd.Stderr = s, s
|
|
89
|
+
prepare(cmd)
|
|
90
|
+
if err := cmd.Start(); err != nil {
|
|
91
|
+
return Snapshot{}, fmt.Errorf("could not start command: %w", err)
|
|
92
|
+
}
|
|
93
|
+
s.state.PID, s.state.Running = cmd.Process.Pid, true
|
|
94
|
+
m.mu.Lock()
|
|
95
|
+
m.sessions[id] = s
|
|
96
|
+
m.mu.Unlock()
|
|
97
|
+
go func() {
|
|
98
|
+
err := cmd.Wait()
|
|
99
|
+
s.mu.Lock()
|
|
100
|
+
s.state.Running, s.state.Ended = false, time.Now()
|
|
101
|
+
if err != nil && !s.state.Stopped {
|
|
102
|
+
s.state.Error = err.Error()
|
|
103
|
+
}
|
|
104
|
+
s.mu.Unlock()
|
|
105
|
+
close(s.done)
|
|
106
|
+
}()
|
|
107
|
+
return s.snapshot(), nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func (m *Manager) Snapshot(id string) Snapshot {
|
|
111
|
+
m.mu.Lock()
|
|
112
|
+
s := m.sessions[id]
|
|
113
|
+
m.mu.Unlock()
|
|
114
|
+
if s == nil {
|
|
115
|
+
return Snapshot{}
|
|
116
|
+
}
|
|
117
|
+
return s.snapshot()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func (m *Manager) Stop(id string) error {
|
|
121
|
+
m.mu.Lock()
|
|
122
|
+
s := m.sessions[id]
|
|
123
|
+
m.mu.Unlock()
|
|
124
|
+
if s == nil || !s.snapshot().Running {
|
|
125
|
+
return nil
|
|
126
|
+
}
|
|
127
|
+
s.mu.Lock()
|
|
128
|
+
s.state.Stopped = true
|
|
129
|
+
s.mu.Unlock()
|
|
130
|
+
if err := stopTree(s.cmd); err != nil {
|
|
131
|
+
s.mu.Lock()
|
|
132
|
+
s.state.Stopped = false
|
|
133
|
+
s.mu.Unlock()
|
|
134
|
+
return err
|
|
135
|
+
}
|
|
136
|
+
select {
|
|
137
|
+
case <-s.done:
|
|
138
|
+
return nil
|
|
139
|
+
case <-time.After(4 * time.Second):
|
|
140
|
+
return fmt.Errorf("process did not exit after stop")
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func (m *Manager) Active() int {
|
|
145
|
+
m.mu.Lock()
|
|
146
|
+
defer m.mu.Unlock()
|
|
147
|
+
n := 0
|
|
148
|
+
for _, s := range m.sessions {
|
|
149
|
+
if s.snapshot().Running {
|
|
150
|
+
n++
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return n
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func (m *Manager) Close() {
|
|
157
|
+
m.startMu.Lock()
|
|
158
|
+
m.mu.Lock()
|
|
159
|
+
m.closed = true
|
|
160
|
+
ids := make([]string, 0, len(m.sessions))
|
|
161
|
+
for id := range m.sessions {
|
|
162
|
+
ids = append(ids, id)
|
|
163
|
+
}
|
|
164
|
+
m.mu.Unlock()
|
|
165
|
+
m.startMu.Unlock()
|
|
166
|
+
var wg sync.WaitGroup
|
|
167
|
+
for _, id := range ids {
|
|
168
|
+
wg.Add(1)
|
|
169
|
+
go func(id string) { defer wg.Done(); _ = m.Stop(id) }(id)
|
|
170
|
+
}
|
|
171
|
+
wg.Wait()
|
|
172
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
package process
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"os/exec"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
"time"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func helperCommand(t *testing.T, mode, dir string) *exec.Cmd {
|
|
14
|
+
t.Helper()
|
|
15
|
+
exe, err := os.Executable()
|
|
16
|
+
if err != nil {
|
|
17
|
+
t.Fatal(err)
|
|
18
|
+
}
|
|
19
|
+
cmd := exec.Command(exe, "-test.run=^TestProcessHelper$")
|
|
20
|
+
cmd.Env = append(os.Environ(), "RIAN_TEST_HELPER="+mode, "RIAN_TEST_HELPER_DIR="+dir)
|
|
21
|
+
cmd.Dir = dir
|
|
22
|
+
return cmd
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func TestProcessHelper(t *testing.T) {
|
|
26
|
+
mode := os.Getenv("RIAN_TEST_HELPER")
|
|
27
|
+
if mode == "" {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
switch mode {
|
|
31
|
+
case "output":
|
|
32
|
+
fmt.Fprintln(os.Stdout, "hello from stdout")
|
|
33
|
+
fmt.Fprintln(os.Stderr, "hello from stderr")
|
|
34
|
+
case "long":
|
|
35
|
+
fmt.Fprintln(os.Stdout, "ready")
|
|
36
|
+
time.Sleep(time.Minute)
|
|
37
|
+
case "tree":
|
|
38
|
+
exe, _ := os.Executable()
|
|
39
|
+
child := exec.Command(exe, "-test.run=^TestProcessHelper$")
|
|
40
|
+
child.Env = append(os.Environ(), "RIAN_TEST_HELPER=child")
|
|
41
|
+
if err := child.Start(); err != nil {
|
|
42
|
+
fmt.Fprintln(os.Stdout, err)
|
|
43
|
+
os.Exit(2)
|
|
44
|
+
}
|
|
45
|
+
os.WriteFile(filepath.Join(os.Getenv("RIAN_TEST_HELPER_DIR"), "child.pid"), []byte(fmt.Sprint(child.Process.Pid)), 0600)
|
|
46
|
+
fmt.Fprintln(os.Stdout, "ready")
|
|
47
|
+
time.Sleep(time.Minute)
|
|
48
|
+
case "child":
|
|
49
|
+
file, err := os.OpenFile(filepath.Join(os.Getenv("RIAN_TEST_HELPER_DIR"), "child.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
|
50
|
+
if err != nil {
|
|
51
|
+
os.Exit(3)
|
|
52
|
+
}
|
|
53
|
+
defer file.Close()
|
|
54
|
+
for range 1000 {
|
|
55
|
+
file.WriteString(".")
|
|
56
|
+
file.Sync()
|
|
57
|
+
time.Sleep(20 * time.Millisecond)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
os.Exit(0)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func until(t *testing.T, condition func() bool) {
|
|
64
|
+
t.Helper()
|
|
65
|
+
deadline := time.Now().Add(5 * time.Second)
|
|
66
|
+
for time.Now().Before(deadline) {
|
|
67
|
+
if condition() {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
time.Sleep(20 * time.Millisecond)
|
|
71
|
+
}
|
|
72
|
+
t.Fatal("process state did not arrive")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestCaptureAndOneProcessPerProject(t *testing.T) {
|
|
76
|
+
manager := New()
|
|
77
|
+
defer manager.Close()
|
|
78
|
+
dir := t.TempDir()
|
|
79
|
+
if _, err := manager.StartCommand("p", "helper output", helperCommand(t, "output", dir)); err != nil {
|
|
80
|
+
t.Fatal(err)
|
|
81
|
+
}
|
|
82
|
+
until(t, func() bool { return !manager.Snapshot("p").Running })
|
|
83
|
+
s := manager.Snapshot("p")
|
|
84
|
+
if !strings.Contains(s.Output, "stdout") || !strings.Contains(s.Output, "stderr") || s.PID == 0 || s.Started.IsZero() || s.Ended.IsZero() {
|
|
85
|
+
t.Fatalf("missing process information: %+v", s)
|
|
86
|
+
}
|
|
87
|
+
if _, err := manager.StartCommand("p", "long", helperCommand(t, "long", dir)); err != nil {
|
|
88
|
+
t.Fatal(err)
|
|
89
|
+
}
|
|
90
|
+
if _, err := manager.StartCommand("p", "duplicate", helperCommand(t, "long", dir)); err == nil {
|
|
91
|
+
t.Fatal("a second process for one project was accepted")
|
|
92
|
+
}
|
|
93
|
+
if err := manager.Stop("p"); err != nil {
|
|
94
|
+
t.Fatal(err)
|
|
95
|
+
}
|
|
96
|
+
if s = manager.Snapshot("p"); s.Running || !s.Stopped || s.Error != "" {
|
|
97
|
+
t.Fatalf("stop failed: %+v", s)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func TestStopIncludesChildProcess(t *testing.T) {
|
|
102
|
+
manager := New()
|
|
103
|
+
defer manager.Close()
|
|
104
|
+
dir := t.TempDir()
|
|
105
|
+
if _, err := manager.StartCommand("tree", "tree", helperCommand(t, "tree", dir)); err != nil {
|
|
106
|
+
t.Fatal(err)
|
|
107
|
+
}
|
|
108
|
+
file := filepath.Join(dir, "child.log")
|
|
109
|
+
until(t, func() bool { info, err := os.Stat(file); return err == nil && info.Size() > 0 })
|
|
110
|
+
if err := manager.Stop("tree"); err != nil {
|
|
111
|
+
t.Fatal(err)
|
|
112
|
+
}
|
|
113
|
+
before, _ := os.Stat(file)
|
|
114
|
+
time.Sleep(200 * time.Millisecond)
|
|
115
|
+
after, _ := os.Stat(file)
|
|
116
|
+
if before.Size() != after.Size() {
|
|
117
|
+
t.Fatal("a descendant process kept running after stop")
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func TestOutputIsBounded(t *testing.T) {
|
|
122
|
+
s := &session{}
|
|
123
|
+
s.Write([]byte(strings.Repeat("a", outputLimit*2)))
|
|
124
|
+
s.Write([]byte("tail"))
|
|
125
|
+
state := s.snapshot()
|
|
126
|
+
if len(state.Output) != outputLimit || !strings.HasSuffix(state.Output, "tail") {
|
|
127
|
+
t.Fatal("recent output was not bounded correctly")
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func TestShutdownRejectsLateStarts(t *testing.T) {
|
|
132
|
+
manager := New()
|
|
133
|
+
manager.Close()
|
|
134
|
+
if _, err := manager.StartCommand("late", "late", helperCommand(t, "long", t.TempDir())); err == nil {
|
|
135
|
+
t.Fatal("a process started after shutdown")
|
|
136
|
+
}
|
|
137
|
+
}
|