Repository navigation
Expand file tree
/
Copy pathuvicorn_lifecycle_test.go
More file actions
280 lines (245 loc) · 11 KB
/
Copy pathuvicorn_lifecycle_test.go
File metadata and controls
280 lines (245 loc) · 11 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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0"
runners "github.com/codefly-dev/core/runners/base"
"github.com/stretchr/testify/require"
)
// These tests drive the real thing: `uv run uvicorn` against a throwaway
// project, because the defects they cover — a process that dies after Start
// committed STARTED, a second Start taking ownership of a port that already has
// a listener — only exist in the interaction with an actual process. The
// fixture is a bare ASGI callable rather than a FastAPI app: uvicorn is the
// process under test, and pulling FastAPI in would only make the sync slower.
const (
// servingApp answers every request, which is what "ready" means here.
servingApp = `async def app(scope, receive, send):
await send({"type": "http.response.start", "status": 200, "headers": [[b"content-type", b"text/plain"]]})
await send({"type": "http.response.body", "body": b"ok"})
`
// brokenApp fails at import, the way a service does when a dependency is
// missing or a module-level statement raises.
brokenApp = `raise ImportError("codefly-lifecycle-boom")
`
uvicornProject = `[project]
name = "lifecycle-probe"
version = "0.0.0"
requires-python = ">=3.10"
dependencies = ["uvicorn>=0.30"]
`
)
// uvProject materializes a synced uv project holding app as src/main.py. It
// skips rather than fails when uv or its network are missing: the prerequisite
// is named so a skipped run is not read as a passing one.
func uvProject(t *testing.T, app string) string {
t.Helper()
if _, err := exec.LookPath("uv"); err != nil {
t.Skip("uv is not installed: the real uvicorn lifecycle cannot be exercised")
}
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "src"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(uvicornProject), 0o644))
writeApp(t, dir, app)
sync := exec.Command("uv", "sync")
sync.Dir = dir
if out, err := sync.CombinedOutput(); err != nil {
t.Skipf("uv sync failed, uvicorn is unavailable: %v\n%s", err, out)
}
return dir
}
func writeApp(t *testing.T, dir, app string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, "src", "main.py"), []byte(app), 0o644))
}
// uvicornRuntime stands a Runtime up at the point Init leaves it: a native
// runner environment over the project, and a port to bind.
func uvicornRuntime(t *testing.T, source string, hotReload bool) *Runtime {
t.Helper()
ctx := context.Background()
runtime := NewRuntime(NewService())
runtime.Location = t.TempDir()
runtime.Logger = runtime.Wool
runtime.Service.SourceLocation = source
runtime.FastAPI.Settings.HotReload = hotReload
env, err := runners.NewNativeEnvironment(ctx, source)
require.NoError(t, err)
runtime.runnerEnvironment = env
runtime.port = freePort(t)
t.Cleanup(func() {
_, _ = runtime.Stop(context.Background(), &runtimev0.StopRequest{})
})
return runtime
}
func freePort(t *testing.T) uint16 {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := listener.Addr().(*net.TCPAddr).Port
require.NoError(t, listener.Close())
return uint16(port)
}
// serves reports whether the app answers on its own address — the readiness
// question, as opposed to "is a process alive".
func serves(port uint16) bool {
client := http.Client{Timeout: 500 * time.Millisecond}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/", port))
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
func runtimeStartState(t *testing.T, runtime *Runtime) *runtimev0.StartStatus {
t.Helper()
info, err := runtime.Base.Runtime.InformationResponse(context.Background(), &runtimev0.InformationRequest{})
require.NoError(t, err)
return info.GetStartStatus()
}
// TestUvicornDeathRevokesStartedAndSourceFixRecovers is the headline case: an
// import error kills uvicorn seconds after Start returned STARTED. The status
// has to follow the process down, carry the traceback that explains it, and a
// later Start against fixed source has to bring the service back.
func TestUvicornDeathRevokesStartedAndSourceFixRecovers(t *testing.T) {
ctx := context.Background()
source := uvProject(t, brokenApp)
runtime := uvicornRuntime(t, source, false)
resp, err := runtime.Start(ctx, &runtimev0.StartRequest{})
require.NoError(t, err)
require.Equal(t, runtimev0.StartStatus_STARTED, resp.GetStatus().GetState(), resp.GetStatus().GetMessage())
require.Eventually(t, func() bool {
return runtimeStartState(t, runtime).GetState() == runtimev0.StartStatus_ERROR
}, 30*time.Second, 100*time.Millisecond, "a uvicorn that died on an import error must not stay STARTED")
require.Contains(t, runtimeStartState(t, runtime).GetMessage(), "codefly-lifecycle-boom",
"the diagnostic must name what killed the process")
writeApp(t, source, servingApp)
resp, err = runtime.Start(ctx, &runtimev0.StartRequest{})
require.NoError(t, err)
require.Equal(t, runtimev0.StartStatus_STARTED, resp.GetStatus().GetState(), resp.GetStatus().GetMessage())
require.Eventually(t, func() bool { return serves(runtime.port) }, 30*time.Second, 200*time.Millisecond,
"fixing the source must let a later start serve")
require.Equal(t, runtimev0.StartStatus_STARTED, runtimeStartState(t, runtime).GetState())
}
// TestStartReturnsOnceUvicornServes is the contract dependents are started on:
// with the endpoint address Init resolves, STARTED means the app answers, not
// that uv was launched. Asserted with no Eventually: the answer must already be
// there when Start returns.
func TestStartReturnsOnceUvicornServes(t *testing.T) {
ctx := context.Background()
source := uvProject(t, servingApp)
runtime := uvicornRuntime(t, source, false)
runtime.serveAddress = fmt.Sprintf("127.0.0.1:%d", runtime.port)
resp, err := runtime.Start(ctx, &runtimev0.StartRequest{})
require.NoError(t, err)
require.Equal(t, runtimev0.StartStatus_STARTED, resp.GetStatus().GetState(), resp.GetStatus().GetMessage())
require.True(t, serves(runtime.port), "STARTED was returned before uvicorn answered")
}
// TestStartFailsWhenUvicornDiesBeforeServing: an import error is now a failed
// Start carrying the traceback, rather than a STARTED revoked moments later.
func TestStartFailsWhenUvicornDiesBeforeServing(t *testing.T) {
ctx := context.Background()
source := uvProject(t, brokenApp)
runtime := uvicornRuntime(t, source, false)
runtime.serveAddress = fmt.Sprintf("127.0.0.1:%d", runtime.port)
resp, err := runtime.Start(ctx, &runtimev0.StartRequest{})
require.NoError(t, err)
require.Equal(t, runtimev0.StartStatus_ERROR, resp.GetStatus().GetState())
require.Contains(t, resp.GetStatus().GetMessage(), "codefly-lifecycle-boom")
}
// TestRepeatedStartWithoutHotReloadOwnsOneProcess pins process ownership for
// the setting that used to skip the reuse check: an unchanged request must keep
// the process that is serving, and a changed one must reap it before binding
// the port again.
func TestRepeatedStartWithoutHotReloadOwnsOneProcess(t *testing.T) {
ctx := context.Background()
source := uvProject(t, servingApp)
runtime := uvicornRuntime(t, source, false)
_, err := runtime.Start(ctx, &runtimev0.StartRequest{})
require.NoError(t, err)
require.Eventually(t, func() bool { return serves(runtime.port) }, 30*time.Second, 200*time.Millisecond)
first := currentRunner(runtime)
_, err = runtime.Start(ctx, &runtimev0.StartRequest{})
require.NoError(t, err)
require.Same(t, first, currentRunner(runtime), "an unchanged request must not launch a second uvicorn")
require.True(t, serves(runtime.port))
_, err = runtime.Start(ctx, &runtimev0.StartRequest{Fixture: "seed"})
require.NoError(t, err)
require.NotSame(t, first, currentRunner(runtime), "changed inputs need a process carrying them")
running, err := first.proc.IsRunning(ctx)
require.NoError(t, err)
require.False(t, running, "the replaced process must be gone, not left on the port")
require.Eventually(t, func() bool { return serves(runtime.port) }, 30*time.Second, 200*time.Millisecond,
"the replacement must own the port the old process released")
}
// TestHotReloadParentOutlivesBrokenApp is the boundary this agent must not blur.
// uvicorn --reload keeps its supervisor alive when the application fails to
// import, so process liveness stays STARTED while nothing serves: readiness is
// the orchestrator's endpoint probe, not this status. A reload of fixed source
// then restores the serving app without a new process.
func TestHotReloadParentOutlivesBrokenApp(t *testing.T) {
ctx := context.Background()
source := uvProject(t, brokenApp)
runtime := uvicornRuntime(t, source, true)
_, err := runtime.Start(ctx, &runtimev0.StartRequest{})
require.NoError(t, err)
require.Never(t, func() bool {
return runtimeStartState(t, runtime).GetState() == runtimev0.StartStatus_ERROR
}, 5*time.Second, 250*time.Millisecond, "the reload supervisor is alive, so the process did not exit")
require.False(t, serves(runtime.port), "an app that failed to import serves nothing, whatever the process status says")
supervisor := currentRunner(runtime)
writeApp(t, source, servingApp)
require.Eventually(t, func() bool { return serves(runtime.port) }, 30*time.Second, 200*time.Millisecond,
"a successful reload must restore a serving app")
require.Same(t, supervisor, currentRunner(runtime), "the reload happens inside the process uvicorn already owns")
}
func TestRealProcessFixtureWithdrawalAndInitAuthority(t *testing.T) {
source := uvProject(t, `import os
async def app(scope, receive, send):
value = os.environ.get("CODEFLY__FIXTURE", "none").encode()
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": value})
`)
runtime := uvicornRuntime(t, source, false)
ctx := context.Background()
assertBody := func(want string) {
t.Helper()
require.Eventually(t, func() bool {
client := http.Client{Timeout: time.Second}
response, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/", runtime.port))
if err != nil {
return false
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1024))
return err == nil && string(body) == want
}, 10*time.Second, 100*time.Millisecond)
}
_, err := runtime.Start(ctx, &runtimev0.StartRequest{Fixture: "seed"})
require.NoError(t, err)
assertBody("seed")
_, err = runtime.Start(ctx, &runtimev0.StartRequest{})
require.NoError(t, err)
assertBody("none")
// uvicornRuntime establishes the state after Init. Preserve that invocation
// selection even if a later Start carries a different legacy selector.
runtime.runnerMu.Lock()
runtime.initFixture = "installed"
runtime.runnerMu.Unlock()
_, err = runtime.Start(ctx, &runtimev0.StartRequest{Fixture: "other"})
require.NoError(t, err)
assertBody("installed")
runtime.runnerMu.Lock()
runtime.initFixture = "replacement"
runtime.runnerMu.Unlock()
_, err = runtime.Start(ctx, &runtimev0.StartRequest{Fixture: "other"})
require.NoError(t, err)
assertBody("replacement")
}