-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy pathtest_stdio_shutdown.py
More file actions
166 lines (146 loc) · 5.75 KB
/
Copy pathtest_stdio_shutdown.py
File metadata and controls
166 lines (146 loc) · 5.75 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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
"""Public lifecycle regressions for host cleanup after stdio EOF."""
import asyncio
import shutil
import subprocess
import sys
import time
from pathlib import Path
import pytest
from copilot import CopilotClient, RuntimeConnection
_RUNTIME = Path(__file__).parent.parent / "test" / "harness" / "stdio-shutdown-runtime.cjs"
_EXIT_PROBE = """
const fs = require("node:fs");
const { spawnSync } = require("node:child_process");
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;
}
// Force-stop does not reap children; a zombie has already exited.
if (process.platform === "linux") {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
// The parenthesized command name can itself contain spaces and parentheses.
if (stat.charAt(stat.lastIndexOf(")") + 2) === "Z") return;
} catch (error) {
if (error.code === "ENOENT") return;
throw error;
}
} else if (process.platform !== "win32") {
const status = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" });
if (status.status === 0 && status.stdout.trim().startsWith("Z")) return;
}
if (Date.now() >= deadline) throw new Error("Child still running");
setTimeout(check, 25);
}
check();
"""
async def _assert_child_exited(directory: Path, wait_millis: int = 0) -> None:
node = shutil.which("node")
assert node is not None
result = await asyncio.to_thread(
subprocess.run,
[node, "-e", _EXIT_PROBE, str(directory / "pid"), str(wait_millis)],
capture_output=True,
text=True,
timeout=10,
check=False,
)
assert result.returncode == 0, result.stdout + result.stderr
async def test_exit_probe_distinguishes_running_and_exited_child(tmp_path, monkeypatch):
node = shutil.which("node")
assert node is not None
if sys.platform == "linux":
# Linux must not depend on procps options absent from Alpine's BusyBox ps.
(tmp_path / "node").symlink_to(node)
monkeypatch.setenv("PATH", str(tmp_path))
child = subprocess.Popen([node, "-e", "setInterval(() => {}, 1000)"])
try:
(tmp_path / "pid").write_text(str(child.pid))
with pytest.raises(AssertionError, match="Child still running"):
await _assert_child_exited(tmp_path)
child.kill()
# Keep the Popen alive without wait/poll so POSIX retains an exited zombie.
await _assert_child_exited(tmp_path, wait_millis=5000)
finally:
child.kill()
child.wait(timeout=10)
def _client(directory: Path, mode: str) -> CopilotClient:
node = shutil.which("node")
assert node is not None, "Node.js is required for the shared SDK shutdown fixture"
return CopilotClient(
connection=RuntimeConnection.for_stdio(
path=node,
args=[
str(_RUNTIME),
str(directory / "cleanup.jsonl"),
mode,
str(directory / "pid"),
],
)
)
@pytest.mark.parametrize("mode", ["stop", "dispose"])
@pytest.mark.timeout(60)
async def test_graceful_shutdown_waits_for_host_cleanup(tmp_path, mode):
client = _client(tmp_path, mode)
try:
if mode == "dispose":
async with client:
assert not (tmp_path / "cleanup.jsonl").exists()
else:
await client.start()
assert not (tmp_path / "cleanup.jsonl").exists()
await client.stop()
assert (tmp_path / "cleanup.jsonl").read_text() == '{"type":"span"}\n'
await _assert_child_exited(tmp_path)
with pytest.raises(RuntimeError, match="Client is not connected"):
_ = client.rpc
finally:
await client.force_stop()
@pytest.mark.timeout(60)
async def test_force_stop_does_not_run_graceful_host_cleanup(tmp_path):
client = _client(tmp_path, "force")
try:
await client.start()
started = time.monotonic()
await asyncio.wait_for(client.force_stop(), timeout=30)
assert time.monotonic() - started < 10
await _assert_child_exited(tmp_path, wait_millis=5000)
assert not (tmp_path / "cleanup.jsonl").exists()
with pytest.raises(RuntimeError, match="Client is not connected"):
_ = client.rpc
finally:
await client.force_stop()
@pytest.mark.timeout(60)
async def test_stop_terminates_uncooperative_child_after_grace_period(tmp_path):
client = _client(tmp_path, "fallback")
try:
await client.start()
started = time.monotonic()
await asyncio.wait_for(client.stop(), timeout=45)
assert time.monotonic() - started >= 10
assert (tmp_path / "cleanup.jsonl").read_text() == '{"type":"span"}\n'
await _assert_child_exited(tmp_path)
with pytest.raises(RuntimeError, match="Client is not connected"):
_ = client.rpc
finally:
await client.force_stop()
@pytest.mark.timeout(60)
async def test_force_stop_cleans_up_after_startup_failure(tmp_path):
client = _client(tmp_path, "start-failure")
try:
with pytest.raises(RuntimeError, match="[Pp]rotocol"):
await client.start()
await asyncio.wait_for(client.force_stop(), timeout=30)
await _assert_child_exited(tmp_path, wait_millis=5000)
assert not (tmp_path / "cleanup.jsonl").exists()
with pytest.raises(RuntimeError, match="Client is not connected"):
_ = client.rpc
finally:
await client.force_stop()