@@ -6,12 +6,14 @@ import (
66 "fmt"
77 "io"
88 "maps"
9+ "net/http"
910 "os"
1011 "path"
1112 "slices"
1213 "strings"
1314 "sync"
1415 "sync/atomic"
16+ "time"
1517
1618 "github.com/codefly-dev/core/agents/helpers/code"
1719 "github.com/codefly-dev/core/agents/services"
@@ -132,6 +134,18 @@ type Runtime struct {
132134
133135 port uint16
134136
137+ // serveAddress is the address Init resolved for the HTTP endpoint, as the
138+ // host reaches it. Start answers STARTED only once an HTTP request to it gets
139+ // a response. Empty (no Init) skips the wait.
140+ serveAddress string
141+
142+ // serveTimeout bounds that wait; zero means defaultServeTimeout.
143+ serveTimeout time.Duration
144+
145+ // answers performs one attempt to reach the endpoint; nil means an HTTP
146+ // GET (httpAnswers). Tests whose process is a stand-in replace it.
147+ answers func (ctx context.Context , target string ) error
148+
135149 // grpcPort is the mapped port for the service-owned gRPC listener, passed
136150 // to the Python process as CODEFLY_GRPC_PORT. Zero when gRPC is disabled.
137151 grpcPort uint16
@@ -438,6 +452,7 @@ func (s *Runtime) Init(ctx context.Context, req *runtimev0.InitRequest) (*runtim
438452
439453 s .Infof ("will run on %s" , net .Address )
440454 s .port = uint16 (net .Port )
455+ s .serveAddress = net .Address
441456
442457 if s .FastAPI .GRPCEndpoint != nil {
443458 grpcNet , grpcErr := resources .FindNetworkInstanceInNetworkMappings (ctx , s .NetworkMappings , s .FastAPI .GRPCEndpoint , resources .NewNativeNetworkAccess ())
@@ -623,6 +638,24 @@ func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runt
623638 return s .Base .Runtime .StartError (err )
624639 }
625640
641+ // STARTED is what the orchestrator starts dependents on, so it must mean
642+ // the endpoint answers, not that uv was launched: uvicorn binds only after
643+ // the app has imported and its lifespan startup has run, which for a real
644+ // service is seconds after the process exists.
645+ if err := s .awaitServing (ctx , handle ); err != nil {
646+ s .runner = nil
647+ cancel ()
648+ if stopErr := handle .proc .Stop (context .WithoutCancel (ctx )); stopErr != nil {
649+ s .Wool .Warn ("cannot stop the fastapi app that never served" , wool .ErrField (stopErr ))
650+ }
651+ s .Base .StopWatcher ()
652+ message := err .Error ()
653+ if tail := handle .tail .Tail (); tail != "" {
654+ message = fmt .Sprintf ("%s\n %s" , message , tail )
655+ }
656+ return s .Base .Runtime .StartError (s .Wool .NewError ("%s" , message ))
657+ }
658+
626659 // STARTED is recorded before the supervisor is armed: revoking a status
627660 // that was never committed would leave the orchestrator reading STARTED for
628661 // a process that is already gone.
@@ -633,6 +666,82 @@ func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runt
633666 return resp , err
634667}
635668
669+ // defaultServeTimeout bounds how long Start waits for the HTTP endpoint to
670+ // answer. It covers `uv run` resolving the environment, the app import and its
671+ // lifespan startup; a service that has not answered by then is not coming up.
672+ const defaultServeTimeout = 3 * time .Minute
673+
674+ // servePollInterval is the pause between two attempts to reach the endpoint.
675+ const servePollInterval = 250 * time .Millisecond
676+
677+ // awaitServing returns once an HTTP request to the service's endpoint gets a
678+ // response — any status: readiness here means the endpoint accepts and serves
679+ // connections, and what the app answers on "/" is its own business. A bare TCP
680+ // accept is not enough: in a container runtime the published port is accepted
681+ // by the engine's proxy before anything listens behind it. It fails when the
682+ // process exits first, when ctx ends, or after the serve timeout.
683+ func (s * Runtime ) awaitServing (ctx context.Context , handle * runnerHandle ) error {
684+ if s .serveAddress == "" {
685+ return nil
686+ }
687+ target := s .serveAddress
688+ if ! strings .Contains (target , "://" ) {
689+ target = "http://" + target
690+ }
691+ target = strings .TrimRight (target , "/" ) + "/"
692+ timeout := s .serveTimeout
693+ if timeout <= 0 {
694+ timeout = defaultServeTimeout
695+ }
696+ answers := s .answers
697+ if answers == nil {
698+ answers = httpAnswers
699+ }
700+ deadline := time .NewTimer (timeout )
701+ defer deadline .Stop ()
702+ started := time .Now ()
703+ var last error
704+ for {
705+ err := answers (ctx , target )
706+ if err == nil {
707+ s .Infof ("fastapi app serving on %s after %s" , s .serveAddress , time .Since (started ).Round (100 * time .Millisecond ))
708+ return nil
709+ }
710+ last = err
711+ if ! s .runnerAlive (ctx , handle ) {
712+ return fmt .Errorf ("fastapi app exited before its HTTP endpoint at %s answered" , s .serveAddress )
713+ }
714+ select {
715+ case <- ctx .Done ():
716+ return fmt .Errorf ("start abandoned before the HTTP endpoint at %s answered: %w" , s .serveAddress , ctx .Err ())
717+ case <- deadline .C :
718+ return fmt .Errorf ("the HTTP endpoint at %s did not answer within %s: %v" , s .serveAddress , timeout , last )
719+ case <- time .After (servePollInterval ):
720+ }
721+ }
722+ }
723+
724+ // serveClient is what httpAnswers probes with: one bounded attempt, and a
725+ // redirect counts as an answer since the endpoint is serving.
726+ var serveClient = & http.Client {
727+ Timeout : time .Second ,
728+ CheckRedirect : func (* http.Request , []* http.Request ) error { return http .ErrUseLastResponse },
729+ }
730+
731+ // httpAnswers reports whether one GET to target got an HTTP response.
732+ func httpAnswers (ctx context.Context , target string ) error {
733+ request , err := http .NewRequestWithContext (ctx , http .MethodGet , target , nil )
734+ if err != nil {
735+ return err
736+ }
737+ response , err := serveClient .Do (request )
738+ if err != nil {
739+ return err
740+ }
741+ _ , _ = io .Copy (io .Discard , io .LimitReader (response .Body , 4 << 10 ))
742+ return response .Body .Close ()
743+ }
744+
636745// uvicornArgs renders the `uv run` invocation. --reload is conditional: it
637746// makes uvicorn fork a supervisor that re-executes the app on source edits,
638747// which is not what a service configured without hot reload asked for.
@@ -698,7 +807,8 @@ func (s *Runtime) stopRunner(ctx context.Context) error {
698807//
699808// This is process supervision, not application readiness: with --reload the
700809// uvicorn supervisor survives an application that fails to import, so a live
701- // process is a weaker claim than a serving one. Readiness stays with the
810+ // process is a weaker claim than a serving one. Start established once that the
811+ // endpoint answered (awaitServing); readiness after that stays with the
702812// orchestrator's probes against the endpoints the service declares.
703813func (s * Runtime ) superviseRunner (ctx context.Context , handle * runnerHandle ) {
704814 err := handle .proc .Wait (ctx )
0 commit comments