Repository navigation
Expand file tree
/
Copy pathclient_shutdown_test.go
More file actions
127 lines (118 loc) · 3.7 KB
/
Copy pathclient_shutdown_test.go
File metadata and controls
127 lines (118 loc) · 3.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// Copyright (c) Microsoft Corporation. All rights reserved.
package copilot_test
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
copilot "github.com/github/copilot-sdk/go"
)
func TestOwnedStdioShutdown(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Fatal("shutdown fixture requires Node.js:", err)
}
fixture, err := filepath.Abs("../test/harness/stdio-shutdown-runtime.cjs")
if err != nil {
t.Fatal(err)
}
for _, mode := range []string{"stop", "force", "fallback", "start-failure"} {
t.Run(mode, func(t *testing.T) {
directory := t.TempDir()
marker := filepath.Join(directory, "telemetry.jsonl")
pidFile := filepath.Join(directory, "runtime.pid")
client := copilot.NewClient(&copilot.ClientOptions{
Connection: copilot.StdioConnection{
Path: node,
Args: []string{fixture, marker, mode, pidFile},
},
UseLoggedInUser: copilot.Bool(false),
})
t.Cleanup(client.ForceStop)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
err := client.Start(ctx)
if mode == "start-failure" {
if err == nil || !strings.Contains(err.Error(), "protocol version") {
t.Fatalf("expected protocol version failure, got %v", err)
}
} else {
if err != nil {
t.Fatal("Start failed:", err)
}
started := time.Now()
if mode == "force" {
runShutdownWithWatchdog(t, func() error {
client.ForceStop()
return nil
})
if elapsed := time.Since(started); elapsed >= 10*time.Second {
t.Fatalf("ForceStop waited for graceful timeout: %s", elapsed)
}
} else {
runShutdownWithWatchdog(t, client.Stop)
}
if mode == "fallback" && time.Since(started) < 10*time.Second {
t.Fatal("Stop did not allow the full graceful exit timeout")
}
}
// Force-stop and failed startup kill without waiting for the child to be reaped.
exitWait := "0"
if mode == "force" || mode == "start-failure" {
exitWait = "5000"
}
assertShutdownChildExited(t, node, pidFile, exitWait)
contents, err := os.ReadFile(marker)
if mode == "force" || mode == "start-failure" {
if !os.IsNotExist(err) {
t.Fatalf("forced termination unexpectedly finalized telemetry: %q (error: %v)", contents, err)
}
} else if err != nil || string(contents) != "{\"type\":\"span\"}\n" {
t.Fatalf("Stop returned without EOF cleanup: %q (error: %v)", contents, err)
}
runShutdownWithWatchdog(t, client.Stop)
})
}
}
func runShutdownWithWatchdog(t *testing.T, stop func() error) {
t.Helper()
done := make(chan error, 1)
go func() { done <- stop() }()
select {
case err := <-done:
if err != nil {
t.Fatal("shutdown failed:", err)
}
case <-time.After(40 * time.Second):
// Cover shutdown RPC, graceful exit, and forced reap budgets, plus scheduling slack.
t.Fatal("shutdown exceeded all cleanup budgets")
}
}
func assertShutdownChildExited(t *testing.T, node, pidFile, waitMillis string) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
// Node's process probe is portable, unlike os.Process.Signal(0) on Windows.
cmd := exec.CommandContext(ctx, node, "-e", `
const fs = require("node:fs");
const pid = Number(fs.readFileSync(process.argv[1], "utf8"));
const deadline = Date.now() + Number(process.argv[2]);
function check() {
try {
process.kill(pid, 0);
} catch (error) {
if (error.code === "ESRCH") return;
throw error;
}
if (Date.now() >= deadline) throw new Error("Child still running");
setTimeout(check, 25);
}
check();
`, pidFile, waitMillis)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("child process did not exit: %v\n%s", err, output)
}
}