|
| 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 | +} |
0 commit comments