Skip to content

Commit ea6b1f3

Browse files
fix: guard the active runner environment behind accessors (#79) (#80)
Service.ActiveEnv was an exported interface field written by a specialization's Init/Stop and read by the Code, Tooling and REPL handlers on their own gRPC goroutines, with nothing ordering the two. A specialization cannot close that gap: its lifecycle lock is not reachable from these handlers. The field is now unexported behind ActiveEnv/SetActiveEnv under an RWMutex, and fixPython snapshots it once per request so a publish or clear landing mid-fix cannot send ruff check and ruff format to two different interpreters. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9c10958 commit ea6b1f3

8 files changed

Lines changed: 211 additions & 18 deletions

File tree

‎agent.codefly.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
publisher: codefly.dev
22
kind: codefly:service
33
name: python
4-
version: 0.0.59
4+
version: 0.0.60

‎pkg/code/callgraph.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ print(json.dumps({'calls': calls, 'implements': implements, 'module': workdir}))
137137
// so AST analysis uses the same interpreter as the running service.
138138
// Pre-Init falls through ResolveStandaloneEnvironment which picks Nix
139139
// if declared, native otherwise — keeps mode consistency when possible.
140-
env := c.Service.ActiveEnv
140+
env := c.Service.ActiveEnv()
141141
if env == nil {
142142
var rctx *basev0.RuntimeContext
143143
if c.Service.Base != nil && c.Service.Base.Runtime != nil {

‎pkg/code/code.go‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ func (c *Code) Execute(ctx context.Context, req *codev0.CodeRequest) (*codev0.Co
7575
// formatter. Both stages operate on stdin and return source on stdout, so the
7676
// core Code server remains the sole writer and can honor VFS/dry-run semantics.
7777
func (c *Code) fixPython(ctx context.Context, input corecode.FixInput) (corecode.FixResult, error) {
78+
// One snapshot for the whole request: a specialization publishing or
79+
// clearing its environment mid-fix must not send check and format to
80+
// two different interpreters.
81+
active := c.Service.ActiveEnv()
82+
7883
checkArgs := []string{"check", "--fix-only"}
7984
if input.Mode == basev0.FixMode_FIX_MODE_AGGRESSIVE {
8085
checkArgs = append(checkArgs, "--unsafe-fixes")
@@ -86,13 +91,14 @@ func (c *Code) fixPython(ctx context.Context, input corecode.FixInput) (corecode
8691
// download the project's dependency graph. Factory Nix shells include Ruff,
8792
// the Docker runtime exposes /venv/bin on PATH, and native users may supply
8893
// either a project-local .venv binary or Ruff on the host PATH.
89-
ruff := c.ruffCommand()
90-
checked, checkLogBytes, err := runners.RunInput(ctx, c.runnerEnvironment(ctx), c.SourceDir(), input.Content, ruff, checkArgs...)
94+
ruff := ruffCommand(active, c.SourceDir())
95+
env := c.runnerEnvironment(ctx, active)
96+
checked, checkLogBytes, err := runners.RunInput(ctx, env, c.SourceDir(), input.Content, ruff, checkArgs...)
9197
checkLog := string(checkLogBytes)
9298
if err != nil {
9399
return corecode.FixResult{}, fmt.Errorf("ruff check --fix-only (Ruff must already be provisioned): %w: %s", err, strings.TrimSpace(checkLog))
94100
}
95-
formatted, formatLogBytes, err := runners.RunInput(ctx, c.runnerEnvironment(ctx), c.SourceDir(), checked, ruff, "format", "--stdin-filename", input.Path, "-")
101+
formatted, formatLogBytes, err := runners.RunInput(ctx, env, c.SourceDir(), checked, ruff, "format", "--stdin-filename", input.Path, "-")
96102
formatLog := string(formatLogBytes)
97103
if err != nil {
98104
return corecode.FixResult{}, fmt.Errorf("ruff format: %w: %s", err, strings.TrimSpace(formatLog))
@@ -105,20 +111,19 @@ func (c *Code) fixPython(ctx context.Context, input corecode.FixInput) (corecode
105111
}, nil
106112
}
107113

108-
func (c *Code) ruffCommand() string {
109-
if c.Service.ActiveEnv == nil {
110-
candidate := filepath.Join(c.SourceDir(), ".venv", "bin", "ruff")
114+
func ruffCommand(active runners.RunnerEnvironment, sourceDir string) string {
115+
if active == nil {
116+
candidate := filepath.Join(sourceDir, ".venv", "bin", "ruff")
111117
if info, err := os.Stat(candidate); err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 {
112118
return candidate
113119
}
114120
}
115121
return "ruff"
116122
}
117123

118-
func (c *Code) runnerEnvironment(ctx context.Context) runners.RunnerEnvironment {
119-
env := c.Service.ActiveEnv
120-
if env != nil {
121-
return env
124+
func (c *Code) runnerEnvironment(ctx context.Context, active runners.RunnerEnvironment) runners.RunnerEnvironment {
125+
if active != nil {
126+
return active
122127
}
123128
var runtimeContext *basev0.RuntimeContext
124129
if c.Service.Base != nil && c.Service.Base.Runtime != nil {

‎pkg/code/fix_test.go‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import (
66
"os/exec"
77
"path/filepath"
88
"strings"
9+
"sync"
910
"testing"
1011

1112
codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0"
1213
"github.com/codefly-dev/core/resources"
14+
runners "github.com/codefly-dev/core/runners/base"
1315
pythoncode "github.com/codefly-dev/service-python/pkg/code"
1416
pythonservice "github.com/codefly-dev/service-python/pkg/service"
1517
)
@@ -80,3 +82,63 @@ func TestApplyEditRunsRuffSafeFixerByDefault(t *testing.T) {
8082
t.Fatalf("ApplyEdit did not commit returned content: err=%v content=%q", err, written)
8183
}
8284
}
85+
86+
// TestCodeIsSafeWhileTheActiveEnvironmentIsRepublished drives the two Code
87+
// entry points that resolve a runner environment — Fix (Ruff) and the call
88+
// graph (AST) — while a specialization publishes its environment from Init
89+
// and clears it from Stop. The RPCs run on one goroutine so the only thing
90+
// racing the publisher is the environment lookup itself.
91+
//
92+
// Both requests must keep answering: a Fix that lands between publish and
93+
// clear falls back to a standalone environment rather than failing.
94+
func TestCodeIsSafeWhileTheActiveEnvironmentIsRepublished(t *testing.T) {
95+
if _, err := exec.LookPath("ruff"); err != nil {
96+
t.Skip("ruff is not installed")
97+
}
98+
ctx := context.Background()
99+
dir := t.TempDir()
100+
if err := os.WriteFile(filepath.Join(dir, "sample.py"), []byte("def answer( ):\n return 42\n"), 0o644); err != nil {
101+
t.Fatal(err)
102+
}
103+
104+
svc := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"})
105+
svc.SourceLocation = dir
106+
env, err := runners.NewNativeEnvironment(ctx, dir)
107+
if err != nil {
108+
t.Fatalf("native environment: %v", err)
109+
}
110+
server := pythoncode.New(svc)
111+
112+
done := make(chan struct{})
113+
var publisher sync.WaitGroup
114+
publisher.Add(1)
115+
go func() {
116+
defer publisher.Done()
117+
for {
118+
select {
119+
case <-done:
120+
return
121+
default:
122+
svc.SetActiveEnv(env)
123+
svc.SetActiveEnv(nil)
124+
}
125+
}
126+
}()
127+
128+
for i := 0; i < 10; i++ {
129+
response, err := server.Execute(ctx, &codev0.CodeRequest{Operation: &codev0.CodeRequest_Fix{Fix: &codev0.FixRequest{
130+
File: "sample.py", DryRun: true,
131+
}}})
132+
if err != nil {
133+
t.Fatalf("fix %d: %v", i, err)
134+
}
135+
if fix := response.GetFix(); !fix.GetSuccess() {
136+
t.Fatalf("fix %d did not run: %+v failure=%+v", i, fix, response.GetFailure())
137+
}
138+
if graph := server.ComputePythonCallGraph(ctx, dir); graph.Error != "" {
139+
t.Fatalf("call graph %d: %s", i, graph.Error)
140+
}
141+
}
142+
close(done)
143+
publisher.Wait()
144+
}

‎pkg/runtime/commands.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ func (s *Runtime) getOrStartRepl(ctx context.Context) (*PythonRepl, error) {
145145
// resolveReplEnv returns the plugin's ActiveEnv or a standalone one
146146
// when Runtime.Init hasn't populated it. Same pattern as Code/Tooling.
147147
func (s *Runtime) resolveReplEnv(ctx context.Context) runners.RunnerEnvironment {
148-
if env := s.Service.ActiveEnv; env != nil {
148+
if env := s.Service.ActiveEnv(); env != nil {
149149
return env
150150
}
151151
var rctx *basev0.RuntimeContext

‎pkg/runtime/runtime_internal_test.go‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
package runtime
22

33
import (
4+
"context"
45
"errors"
56
"os"
67
"path/filepath"
78
"strings"
9+
"sync"
810
"testing"
911
"time"
1012
"unicode/utf8"
1113

1214
runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0"
1315
"github.com/codefly-dev/core/resources"
16+
runners "github.com/codefly-dev/core/runners/base"
1417
pythonhelpers "github.com/codefly-dev/core/runners/python"
1518
pythonservice "github.com/codefly-dev/service-python/pkg/service"
1619
)
@@ -421,3 +424,43 @@ func TestResolveTestFormulaOverlaysCommandlessHealOntoDerivedFormula(t *testing.
421424
t.Fatalf("derived provisioning must survive the overlay, got %v", prov)
422425
}
423426
}
427+
428+
// TestResolveReplEnvIsSafeWhileTheActiveEnvironmentIsRepublished covers the
429+
// REPL's half of the same lookup: `exec` resolves an environment on its own
430+
// gRPC goroutine while a specialization's Init publishes one and Stop clears
431+
// it. Whichever side wins, the REPL must get an environment to boot into.
432+
func TestResolveReplEnvIsSafeWhileTheActiveEnvironmentIsRepublished(t *testing.T) {
433+
ctx := context.Background()
434+
service := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"})
435+
service.SourceLocation = t.TempDir()
436+
rt := New(service)
437+
438+
env, err := runners.NewNativeEnvironment(ctx, service.SourceLocation)
439+
if err != nil {
440+
t.Fatalf("native environment: %v", err)
441+
}
442+
443+
done := make(chan struct{})
444+
var publisher sync.WaitGroup
445+
publisher.Add(1)
446+
go func() {
447+
defer publisher.Done()
448+
for {
449+
select {
450+
case <-done:
451+
return
452+
default:
453+
service.SetActiveEnv(env)
454+
service.SetActiveEnv(nil)
455+
}
456+
}
457+
}()
458+
459+
for i := 0; i < 200; i++ {
460+
if rt.resolveReplEnv(ctx) == nil {
461+
t.Fatalf("resolveReplEnv returned nil on attempt %d", i)
462+
}
463+
}
464+
close(done)
465+
publisher.Wait()
466+
}

‎pkg/service/service.go‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"os"
99
"path/filepath"
10+
"sync"
1011

1112
"github.com/codefly-dev/core/agents/services"
1213
agentv0 "github.com/codefly-dev/core/generated/go/codefly/services/agent/v0"
@@ -45,11 +46,33 @@ type Service struct {
4546
// Load). Specializations may override at Load time.
4647
SourceLocation string
4748

48-
// ActiveEnv is a specialization's active RunnerEnvironment, consumed by
49-
// Code / Tooling so every spawn routes through the same mode (native /
50-
// docker / nix). The generic runtime leaves it nil; call sites fall back
51-
// to a fresh NativeEnvironment.
52-
ActiveEnv runners.RunnerEnvironment
49+
// activeEnv is a specialization's active RunnerEnvironment, consumed by
50+
// Code / Tooling / the REPL so every spawn routes through the same mode
51+
// (native / docker / nix). The generic runtime leaves it nil; call sites
52+
// fall back to a fresh NativeEnvironment.
53+
//
54+
// A specialization publishes it from Init and clears it from Stop, while
55+
// Code, Tooling and the REPL read it on their own gRPC goroutines, so it
56+
// is reachable only through ActiveEnv / SetActiveEnv.
57+
activeEnvMu sync.RWMutex
58+
activeEnv runners.RunnerEnvironment
59+
}
60+
61+
// ActiveEnv returns the environment a specialization published, or nil when
62+
// none is active — before Init, or after the specialization tore it down.
63+
func (s *Service) ActiveEnv() runners.RunnerEnvironment {
64+
s.activeEnvMu.RLock()
65+
defer s.activeEnvMu.RUnlock()
66+
return s.activeEnv
67+
}
68+
69+
// SetActiveEnv publishes the specialization's active RunnerEnvironment.
70+
// Pass nil once the environment is shut down: readers then fall back to a
71+
// standalone environment instead of spawning into a dead one.
72+
func (s *Service) SetActiveEnv(env runners.RunnerEnvironment) {
73+
s.activeEnvMu.Lock()
74+
defer s.activeEnvMu.Unlock()
75+
s.activeEnv = env
5376
}
5477

5578
// New builds a generic Python Service bound to the given agent manifest.

‎pkg/service/service_test.go‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package service_test
33
import (
44
"context"
55
"strings"
6+
"sync"
67
"testing"
78

89
"gopkg.in/yaml.v3"
910

1011
agentv0 "github.com/codefly-dev/core/generated/go/codefly/services/agent/v0"
1112
"github.com/codefly-dev/core/resources"
13+
runners "github.com/codefly-dev/core/runners/base"
1214

1315
pythonservice "github.com/codefly-dev/service-python/pkg/service"
1416
)
@@ -113,3 +115,61 @@ func TestGetAgentInformationGeneric(t *testing.T) {
113115
}
114116
}
115117
}
118+
119+
// TestActiveEnvPublishAndClear locks in the contract specializations use:
120+
// what Init publishes is what Code, Tooling and the REPL read, and what Stop
121+
// clears sends them back to the standalone fallback.
122+
func TestActiveEnvPublishAndClear(t *testing.T) {
123+
svc := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"})
124+
if svc.ActiveEnv() != nil {
125+
t.Fatal("a Service with no specialization environment must read nil")
126+
}
127+
128+
env, err := runners.NewNativeEnvironment(context.Background(), t.TempDir())
129+
if err != nil {
130+
t.Fatalf("native environment: %v", err)
131+
}
132+
svc.SetActiveEnv(env)
133+
if svc.ActiveEnv() != env {
134+
t.Fatal("published environment is not the one readers get")
135+
}
136+
137+
svc.SetActiveEnv(nil)
138+
if svc.ActiveEnv() != nil {
139+
t.Fatal("cleared environment must read nil, not a shut-down one")
140+
}
141+
}
142+
143+
// TestActiveEnvUnderConcurrentPublishAndRead is the -race guard for the
144+
// field itself: a specialization publishes from Init and clears from Stop
145+
// while Code, Tooling and the REPL read on their own gRPC goroutines.
146+
func TestActiveEnvUnderConcurrentPublishAndRead(t *testing.T) {
147+
svc := pythonservice.New(&resources.Agent{Kind: "codefly:service", Name: "python"})
148+
env, err := runners.NewNativeEnvironment(context.Background(), t.TempDir())
149+
if err != nil {
150+
t.Fatalf("native environment: %v", err)
151+
}
152+
153+
var wg sync.WaitGroup
154+
wg.Add(1)
155+
go func() {
156+
defer wg.Done()
157+
for i := 0; i < 1000; i++ {
158+
svc.SetActiveEnv(env)
159+
svc.SetActiveEnv(nil)
160+
}
161+
}()
162+
for readers := 0; readers < 3; readers++ {
163+
wg.Add(1)
164+
go func() {
165+
defer wg.Done()
166+
for i := 0; i < 1000; i++ {
167+
if got := svc.ActiveEnv(); got != nil && got != env {
168+
t.Errorf("read an environment that was never published: %#v", got)
169+
return
170+
}
171+
}
172+
}()
173+
}
174+
wg.Wait()
175+
}

0 commit comments

Comments
 (0)