Skip to content

Commit e4757ce

Browse files
fix: serialize Init against Start (#24)
Init publishes the runtime context, the network mappings, port, grpcPort and the environment-variable entries that Start consumes, all outside any lock, while Start reads them under runnerMu. An overlapping Start could launch uvicorn on a half-written port, and go test -race flags the pair. Guarding only port and grpcPort would leave the environment manager racing: resources.EnvironmentVariableManager has no synchronization of its own, so the two calls are serialized outright, under a lock of their own so Stop and Destroy can still interrupt Init's runner section. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5df1548 commit e4757ce

2 files changed

Lines changed: 202 additions & 0 deletions

File tree

‎init_start_test.go‎

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"slices"
8+
"sync"
9+
"testing"
10+
"time"
11+
12+
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
13+
runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0"
14+
"github.com/codefly-dev/core/resources"
15+
"github.com/codefly-dev/core/standards"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
const initStartProject = `[project]
20+
name = "init-start-probe"
21+
version = "0.0.0"
22+
`
23+
24+
// initStartTestRuntime stands a Runtime up at the point Init is called from,
25+
// with a runner environment already in place so Init runs its publishing
26+
// section — runtime context, network mappings, ports, environment variables —
27+
// without building a real one.
28+
func initStartTestRuntime(t *testing.T) (*Runtime, *fakeRunnerEnvironment, *runtimev0.InitRequest) {
29+
t.Helper()
30+
31+
runtime := NewRuntime(NewService())
32+
runtime.Location = t.TempDir()
33+
// Load is what supplies these; these tests call Init and Start directly.
34+
runtime.Logger = runtime.Wool
35+
runtime.Identity = resources.ServiceIdentityFromProto(&basev0.ServiceIdentity{
36+
Name: "probe",
37+
Module: "mod",
38+
Workspace: "workspace",
39+
WorkspacePath: runtime.Location,
40+
})
41+
42+
source := t.TempDir()
43+
require.NoError(t, os.MkdirAll(filepath.Join(source, "src"), 0o700))
44+
require.NoError(t, os.WriteFile(filepath.Join(source, "pyproject.toml"), []byte(initStartProject), 0o600))
45+
require.NoError(t, os.WriteFile(filepath.Join(source, "src", "main.py"), []byte("app = None\n"), 0o600))
46+
runtime.Service.SourceLocation = source
47+
runtime.cacheLocation = t.TempDir()
48+
49+
endpoint := &basev0.Endpoint{Module: "mod", Service: "probe", Name: "rest", Api: standards.REST}
50+
runtime.FastAPI.RestEndpoint = endpoint
51+
52+
env := &fakeRunnerEnvironment{}
53+
runtime.runnerEnvironment = env
54+
t.Cleanup(runtime.Base.StopWatcher)
55+
56+
return runtime, env, &runtimev0.InitRequest{
57+
RuntimeContext: resources.NewRuntimeContextNative(),
58+
// A configuration leaves Init environment-manager work to do after its
59+
// runner section, which is where a Start can overlap it.
60+
Configuration: &basev0.Configuration{
61+
Origin: "mod/probe",
62+
Infos: []*basev0.ConfigurationInformation{{
63+
Name: "postgres",
64+
ConfigurationValues: []*basev0.ConfigurationValue{{Key: "url", Value: "postgres://localhost"}},
65+
}},
66+
},
67+
ProposedNetworkMappings: []*basev0.NetworkMapping{{
68+
Endpoint: endpoint,
69+
Instances: []*basev0.NetworkInstance{{
70+
Access: resources.NewNativeNetworkAccess(),
71+
Hostname: "localhost",
72+
Port: 9999,
73+
Address: "http://localhost:9999",
74+
}},
75+
}},
76+
}
77+
}
78+
79+
// uvicornProc returns the process Start launched, told apart from the ones Init
80+
// runs on the same environment by the binary it invokes.
81+
func uvicornProc(t *testing.T, env *fakeRunnerEnvironment) *fakeProc {
82+
t.Helper()
83+
for _, proc := range env.started() {
84+
if slices.Contains(proc.args, "uvicorn") {
85+
return proc
86+
}
87+
}
88+
t.Fatal("Start never launched uvicorn")
89+
return nil
90+
}
91+
92+
// gatingEnv holds Init inside its runner section until the test releases it, so
93+
// a Start can be issued while Init is demonstrably mid-flight.
94+
type gatingEnv struct {
95+
*fakeRunnerEnvironment
96+
entered chan struct{}
97+
release chan struct{}
98+
}
99+
100+
func (e *gatingEnv) Init(context.Context) error {
101+
close(e.entered)
102+
<-e.release
103+
return nil
104+
}
105+
106+
// TestStartWaitsForInitToPublishItsPorts is the contract: Init publishes the
107+
// port Start binds uvicorn to, so a Start landing in the middle of an Init runs
108+
// after it and launches on the published port rather than on whatever the field
109+
// happened to hold.
110+
func TestStartWaitsForInitToPublishItsPorts(t *testing.T) {
111+
runtime, fake, req := initStartTestRuntime(t)
112+
env := &gatingEnv{fakeRunnerEnvironment: fake, entered: make(chan struct{}), release: make(chan struct{})}
113+
runtime.runnerEnvironment = env
114+
115+
var wg sync.WaitGroup
116+
var initResp *runtimev0.InitResponse
117+
var initErr error
118+
wg.Add(1)
119+
go func() {
120+
defer wg.Done()
121+
initResp, initErr = runtime.Init(context.Background(), req)
122+
}()
123+
124+
<-env.entered
125+
126+
var startResp *runtimev0.StartResponse
127+
var startErr error
128+
wg.Add(1)
129+
go func() {
130+
defer wg.Done()
131+
startResp, startErr = runtime.Start(context.Background(), &runtimev0.StartRequest{})
132+
}()
133+
134+
// Nothing observable says the Start goroutine reached the lock, so give it
135+
// the room an unserialized Start would need to launch on the unwritten port.
136+
time.Sleep(100 * time.Millisecond)
137+
close(env.release)
138+
wg.Wait()
139+
140+
require.NoError(t, initErr)
141+
require.Equal(t, runtimev0.InitStatus_READY, initResp.GetStatus().GetState(), initResp.GetStatus().GetMessage())
142+
require.NoError(t, startErr)
143+
require.Equal(t, runtimev0.StartStatus_STARTED, startResp.GetStatus().GetState(), startResp.GetStatus().GetMessage())
144+
145+
proc := uvicornProc(t, fake)
146+
require.Contains(t, proc.args, "--port")
147+
require.Equal(t, "9999", proc.args[slices.Index(proc.args, "--port")+1])
148+
}
149+
150+
// TestInitIsSafeWithConcurrentStart overlaps the two calls that own the same
151+
// fields. Under `-race` this is what catches Init publishing the ports, the
152+
// network mappings, the runtime context and the environment-variable manager
153+
// outside the lock Start reads them under; without it, it still pins that
154+
// neither side panics however they interleave.
155+
func TestInitIsSafeWithConcurrentStart(t *testing.T) {
156+
runtime, env, req := initStartTestRuntime(t)
157+
158+
gate := make(chan struct{})
159+
var wg sync.WaitGroup
160+
var initErr, startErr error
161+
wg.Add(2)
162+
go func() {
163+
defer wg.Done()
164+
<-gate
165+
_, initErr = runtime.Init(context.Background(), req)
166+
}()
167+
go func() {
168+
defer wg.Done()
169+
<-gate
170+
_, startErr = runtime.Start(context.Background(), &runtimev0.StartRequest{})
171+
}()
172+
close(gate)
173+
wg.Wait()
174+
175+
require.NoError(t, initErr)
176+
require.NoError(t, startErr)
177+
uvicornProc(t, env)
178+
}

‎runtime.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,24 @@ type Runtime struct {
7878
// and a log stream — with nothing referencing it to shut it down.
7979
runnerCreateMu sync.Mutex
8080

81+
// initMu serializes Init against Start. Init publishes what Start consumes
82+
// — the network mappings, port and grpcPort, and the entries it folds into
83+
// EnvironmentVariables — none of which runnerMu covers, so an overlapping
84+
// Start can otherwise launch uvicorn on a half-written port, or read the
85+
// environment manager while Init is appending to it. The manager is core's
86+
// resources.EnvironmentVariableManager and carries no synchronization of
87+
// its own, so nothing finer than serializing the two calls covers it.
88+
//
89+
// It is a lock of its own rather than runnerMu because Init's runner
90+
// section can pull an image or run `uv sync`: Stop and Destroy take only
91+
// runnerMu, so a teardown is never queued behind that, and Init carries the
92+
// environment it captured through it. Start has nothing to run against
93+
// until Init has published its ports, so Start is the side that waits.
94+
//
95+
// Lock order is initMu, then runnerCreateMu, then runnerMu; nothing takes
96+
// them the other way around.
97+
initMu sync.Mutex
98+
8199
// startInputs renders the StartRequest fields the running process was
82100
// launched with, so a later Start can tell whether they still match.
83101
startInputs string
@@ -330,6 +348,9 @@ func (s *Runtime) Init(ctx context.Context, req *runtimev0.InitRequest) (*runtim
330348

331349
s.Base.Runtime.LogInitRequest(req)
332350

351+
s.initMu.Lock()
352+
defer s.initMu.Unlock()
353+
333354
// Init opens a new lifecycle, so a runtime torn down earlier accepts work
334355
// again — otherwise Init would succeed and every later Start refuse.
335356
s.runnerMu.Lock()
@@ -448,6 +469,9 @@ func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runt
448469
defer s.Wool.Catch()
449470
ctx = s.Wool.Inject(ctx)
450471

472+
s.initMu.Lock()
473+
defer s.initMu.Unlock()
474+
451475
s.runnerMu.Lock()
452476
defer s.runnerMu.Unlock()
453477

0 commit comments

Comments
 (0)