@@ -83,6 +83,12 @@ type Runtime struct {
8383 grpcPort uint16
8484
8585 cacheLocation string
86+
87+ // destroyed marks the runtime as torn down, guarded by runnerMu. Destroy
88+ // cannot stop a Start that is already on its way — the watcher's debounce
89+ // can still fire one after StopWatcher — so Start consults this rather
90+ // than launching a process against resources Destroy is removing.
91+ destroyed bool
8692}
8793
8894func NewRuntime (svc * Service ) * Runtime {
@@ -138,19 +144,27 @@ func (s *Runtime) DockerEnvPath() string {
138144 return path .Join (s .Location , ".cache/container/.venv" )
139145}
140146
147+ // resolveRuntimeImage returns the image the service runs in: the settings
148+ // override when one is set, else the codefly-built default. Strict pinning —
149+ // :latest and untagged refs are rejected so builds stay reproducible.
150+ func (s * Runtime ) resolveRuntimeImage () (* resources.DockerImage , error ) {
151+ override := s .FastAPI .Settings .RuntimeImage
152+ if override == "" {
153+ return runtimeImage , nil
154+ }
155+ parsed , err := resources .ParsePinnedImage (override )
156+ if err != nil {
157+ return nil , s .Wool .Wrapf (err , "invalid docker-image override in service.codefly.yaml" )
158+ }
159+ s .Wool .Info ("using docker-image override (not recommended)" , wool .Field ("image" , parsed .FullName ()))
160+ return parsed , nil
161+ }
162+
141163func (s * Runtime ) CreateRunnerEnvironment (ctx context.Context ) error {
142164 s .Wool .Debug ("creating runner environment in" , wool .DirField (s .Identity .WorkspacePath ))
143- // Resolve the runtime image: settings override (if any) takes priority,
144- // else fall back to the codefly-built default. Strict pinning — we
145- // reject :latest and untagged refs so builds stay reproducible.
146- image := runtimeImage
147- if override := s .FastAPI .Settings .RuntimeImage ; override != "" {
148- parsed , perr := resources .ParsePinnedImage (override )
149- if perr != nil {
150- return s .Wool .Wrapf (perr , "invalid docker-image override in service.codefly.yaml" )
151- }
152- s .Wool .Info ("using docker-image override (not recommended)" , wool .Field ("image" , parsed .FullName ()))
153- image = parsed
165+ image , err := s .resolveRuntimeImage ()
166+ if err != nil {
167+ return err
154168 }
155169
156170 switch {
@@ -260,6 +274,12 @@ func (s *Runtime) Init(ctx context.Context, req *runtimev0.InitRequest) (*runtim
260274
261275 s .Base .Runtime .LogInitRequest (req )
262276
277+ // Init opens a new lifecycle, so a runtime torn down earlier accepts work
278+ // again — otherwise Init would succeed and every later Start refuse.
279+ s .runnerMu .Lock ()
280+ s .destroyed = false
281+ s .runnerMu .Unlock ()
282+
263283 if err := s .SetRuntimeContext (ctx , req .RuntimeContext ); err != nil {
264284 return s .Base .Runtime .InitError (err )
265285 }
@@ -378,6 +398,10 @@ func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runt
378398
379399 s .Base .Runtime .LogStartRequest (req )
380400
401+ if s .destroyed {
402+ return s .Base .Runtime .StartError (s .Wool .NewError ("service has been destroyed (Init must run before it can start again)" ))
403+ }
404+
381405 inputs := startInputs (req )
382406
383407 if s .runner != nil {
@@ -769,7 +793,10 @@ func withOverrides(envs []*resources.EnvironmentVariable, overrides map[string]s
769793// earlier one fails, so one stubborn resource cannot strand the rest, and the
770794// environment is forgotten only once it is actually down — a caller retrying
771795// after a failure must still be able to reach it.
772- func (s * Runtime ) endExecution (ctx context.Context ) error {
796+ //
797+ // It reports whether the owned environment was actually shut down, which in
798+ // container mode is what tells Destroy the container is already gone.
799+ func (s * Runtime ) endExecution (ctx context.Context ) (bool , error ) {
773800 // Cancel the watcher and let its Start goroutine's deferred close of Events
774801 // run exactly once — never close Events here, or it races that goroutine
775802 // into a "close of closed channel" panic.
@@ -779,18 +806,20 @@ func (s *Runtime) endExecution(ctx context.Context) error {
779806 if err := s .stopRunner (ctx ); err != nil {
780807 errs = append (errs , s .Wool .Wrapf (err , "cannot stop the fastapi app" ))
781808 }
809+ released := false
782810 if s .runnerEnvironment != nil {
783811 if err := s .runnerEnvironment .Shutdown (ctx ); err != nil {
784812 errs = append (errs , s .Wool .Wrapf (err , "cannot shut down the runner environment" ))
785813 } else {
786814 // A shut-down environment cannot serve another process — a docker
787815 // one has closed its client — so drop it and let a later Init build
788816 // a fresh one rather than hand Code and the REPL a dead handle.
817+ released = true
789818 s .runnerEnvironment = nil
790819 s .FastAPI .Service .ActiveEnv = nil
791820 }
792821 }
793- return errors .Join (errs ... )
822+ return released , errors .Join (errs ... )
794823}
795824
796825// Stop releases the execution resources and keeps everything on disk, so a
@@ -803,7 +832,7 @@ func (s *Runtime) Stop(ctx context.Context, req *runtimev0.StopRequest) (*runtim
803832 defer s .runnerMu .Unlock ()
804833
805834 s .Wool .Debug ("stopping service" )
806- if err := s .endExecution (ctx ); err != nil {
835+ if _ , err := s .endExecution (ctx ); err != nil {
807836 return s .Base .Runtime .StopError (err )
808837 }
809838 return s .Base .Runtime .StopResponse ()
@@ -823,31 +852,34 @@ func (s *Runtime) Destroy(ctx context.Context, req *runtimev0.DestroyRequest) (*
823852 var errs []error
824853
825854 s .runnerMu .Lock ()
826- if err := s .endExecution (ctx ); err != nil {
855+ s .destroyed = true
856+ released , err := s .endExecution (ctx )
857+ if err != nil {
827858 errs = append (errs , err )
828859 }
860+ cacheLocation := s .cacheLocation
829861 s .runnerMu .Unlock ()
830862
831- // Only once execution has ended: a process still running against the cache
832- // repopulates what this removes, and is reading the venv being pulled out
833- // from under it. An empty location means Init never claimed one — there is
834- // nothing owned to clear , and EmptyDir rejects the empty path as an error.
835- if s . cacheLocation != "" {
836- s . Wool . Debug ( "removing cache" )
837- if err := shared . EmptyDir (ctx , s . cacheLocation ); err != nil {
838- errs = append (errs , s . Wool . Wrapf ( err , "cannot remove cache" ) )
863+ // A container outlives the agent that started it, so a Destroy whose
864+ // runtime holds no environment — a fresh agent, or an Init that never ran —
865+ // still has to reach it by name. Shutting the owned environment down has
866+ // already removed it , and reaching for docker again there would only invent
867+ // a way for a finished teardown to fail.
868+ if s . Base . Runtime . IsContainerRuntime () && ! released {
869+ if err := s . destroyContainerByIdentity (ctx ); err != nil {
870+ errs = append (errs , err )
839871 }
840872 }
841873
842- if s . Base . Runtime . IsContainerRuntime () {
843- // A container outlives the agent that started it, so a Destroy whose
844- // runtime never held one still has to reach it by name.
845- s . Wool . Debug ( " running in container" )
846- dockerEnv , err := dockerrun . NewDockerEnvironment ( ctx , runtimeImage , s . Service . SourceLocation , s . Base . Runtime . UniqueWithWorkspace ())
847- if err != nil {
848- errs = append ( errs , s .Wool .Wrapf ( err , "cannot reach the container environment" ) )
849- } else if err := dockerEnv . Shutdown (ctx ); err != nil {
850- errs = append (errs , s .Wool .Wrapf (err , "cannot shut down the container environment " ))
874+ // Last, and only once nothing is executing any more. In container mode the
875+ // venv and the uv cache are bind-mounted out of this directory, so wiping
876+ // it while the container is still up pulls the interpreter out from under
877+ // the running service. An empty location means Init never claimed one —
878+ // nothing owned to clear, and EmptyDir rejects the empty path as an error.
879+ if cacheLocation != "" {
880+ s .Wool .Debug ( "removing cache" )
881+ if err := shared . EmptyDir (ctx , cacheLocation ); err != nil {
882+ errs = append (errs , s .Wool .Wrapf (err , "cannot remove cache " ))
851883 }
852884 }
853885
@@ -857,6 +889,27 @@ func (s *Runtime) Destroy(ctx context.Context, req *runtimev0.DestroyRequest) (*
857889 return s .Base .Runtime .DestroyResponse ()
858890}
859891
892+ // destroyContainerByIdentity removes the service's container by the name it is
893+ // registered under, for a Destroy holding no environment of its own.
894+ func (s * Runtime ) destroyContainerByIdentity (ctx context.Context ) error {
895+ s .Wool .Debug ("running in container" )
896+ image , err := s .resolveRuntimeImage ()
897+ if err != nil {
898+ // The container is found by name, never by image, so an override this
899+ // agent cannot parse must not be what leaves it running.
900+ s .Wool .Warn ("cannot resolve the runtime image, falling back to the default" , wool .ErrField (err ))
901+ image = runtimeImage
902+ }
903+ dockerEnv , err := dockerrun .NewDockerEnvironment (ctx , image , s .Service .SourceLocation , s .Base .Runtime .UniqueWithWorkspace ())
904+ if err != nil {
905+ return s .Wool .Wrapf (err , "cannot reach the container environment" )
906+ }
907+ if err := dockerEnv .Shutdown (ctx ); err != nil {
908+ return s .Wool .Wrapf (err , "cannot shut down the container environment" )
909+ }
910+ return nil
911+ }
912+
860913func (s * Runtime ) EventHandler (event code.Change ) error {
861914 if strings .Contains (event .Path , "api.json" ) {
862915 return nil
0 commit comments