99 "os/exec"
1010 "os/signal"
1111 "path/filepath"
12+ "runtime"
1213 "strconv"
1314 "strings"
1415 "sync"
@@ -641,6 +642,35 @@ func newAppWatcher(cfg *config.ForgeConfig, app *AppInfo) (*appWatcher, error) {
641642// have been extracted to dev_shared.go as package-level functions shared with
642643// dockerAppWatcher.
643644
645+ // buildApp compiles the user's main package into a stable per-app path inside
646+ // the project's .forge/dev directory. We rebuild in place each time so the
647+ // path stays predictable (helpful for debuggers and codesigning) and old
648+ // artifacts don't accumulate in the OS temp dir.
649+ func (aw * appWatcher ) buildApp () (string , error ) {
650+ buildDir := filepath .Join (aw .config .RootDir , ".forge" , "dev" )
651+ if err := os .MkdirAll (buildDir , 0o755 ); err != nil {
652+ return "" , fmt .Errorf ("create build dir: %w" , err )
653+ }
654+
655+ binName := aw .app .Name
656+ if binName == "" {
657+ binName = "app"
658+ }
659+ if runtime .GOOS == "windows" {
660+ binName += ".exe"
661+ }
662+ binPath := filepath .Join (buildDir , binName )
663+
664+ cmd := exec .Command ("go" , "build" , "-tags" , "forge_debug" , "-o" , binPath , aw .mainFile )
665+ cmd .Dir = aw .config .RootDir
666+ cmd .Stdout = os .Stdout
667+ cmd .Stderr = os .Stderr
668+ if err := cmd .Run (); err != nil {
669+ return "" , fmt .Errorf ("go build: %w" , err )
670+ }
671+ return binPath , nil
672+ }
673+
644674// Start starts the application process.
645675func (aw * appWatcher ) Start (ctx cli.CommandContext ) error {
646676 aw .mu .Lock ()
@@ -671,9 +701,19 @@ func (aw *appWatcher) Start(ctx cli.CommandContext) error {
671701
672702 ctx .Success (fmt .Sprintf ("Starting %s..." , aw .app .Name ))
673703
674- // Create new command — compile with forge_debug tag so the in-process
675- // debug server is included; disabled automatically in release builds.
676- cmd := exec .Command ("go" , "run" , "-tags" , "forge_debug" , aw .mainFile )
704+ // Build the binary first, then exec it directly. We deliberately avoid
705+ // `go run` here: it spawns the compiled binary as a grandchild, and on
706+ // abrupt shutdown (kill -9 of forge dev, parent shell exit) those
707+ // grandchildren get reparented to PID 1 and survive with their dispatch
708+ // loops still hammering the database. Building once and exec'ing the
709+ // binary directly means there's exactly one process to track and
710+ // killProcessGroup reliably reaches it.
711+ binPath , err := aw .buildApp ()
712+ if err != nil {
713+ return fmt .Errorf ("build failed: %w" , err )
714+ }
715+
716+ cmd := exec .Command (binPath )
677717 cmd .Dir = aw .config .RootDir
678718 cmd .Stdout = os .Stdout
679719 cmd .Stderr = os .Stderr
@@ -1000,11 +1040,15 @@ func (p *DevPlugin) startContributorDevServers(ctx cli.CommandContext) []*contri
10001040 devCmd = adapter .DefaultDevCmd ()
10011041 }
10021042
1003- // Start the dev server
1043+ // Start the dev server. Put the shell in its own process group so
1044+ // killProcessGroup can take down the whole tree (npm/node/vite/...)
1045+ // instead of just the sh wrapper, which would otherwise leave the
1046+ // real dev server orphaned on rebuild.
10041047 cmd := exec .Command ("sh" , "-c" , devCmd )
10051048 cmd .Dir = uiDir
10061049 cmd .Stdout = os .Stdout
10071050 cmd .Stderr = os .Stderr
1051+ setupProcessGroup (cmd )
10081052
10091053 if err := cmd .Start (); err != nil {
10101054 ctx .Warning (fmt .Sprintf ("Failed to start contributor dev server %s: %v" , cfg .Name , err ))
@@ -1024,9 +1068,13 @@ func (p *DevPlugin) startContributorDevServers(ctx cli.CommandContext) []*contri
10241068// stopContributorDevServers stops all running contributor dev server processes.
10251069func stopContributorDevServers (processes []* contributorDevProcess ) {
10261070 for _ , proc := range processes {
1027- if proc .Cmd != nil && proc .Cmd .Process != nil {
1028- proc .Cmd .Process .Kill ()
1029- proc .Cmd .Process .Wait ()
1071+ if proc .Cmd == nil || proc .Cmd .Process == nil {
1072+ continue
10301073 }
1074+ // killProcessGroup tears down the whole sh→npm→node tree. A bare
1075+ // Process.Kill would only stop the sh wrapper, leaving npm/node
1076+ // running and holding the dev port.
1077+ killProcessGroup (proc .Cmd )
1078+ _ , _ = proc .Cmd .Process .Wait ()
10311079 }
10321080}
0 commit comments