Skip to main content

Graceful Shutdown

devtool-mcp implements sophisticated shutdown handling to ensure clean process termination and resource cleanup.

Shutdown Flow

When devtool-mcp receives a termination signal:

SIGINT/SIGTERM


┌─────────────────┐
│ Signal Handler │
└────────┬────────┘


┌─────────────────┐
│ Set Deadline │ (2 seconds for Ctrl+C)
└────────┬────────┘

┌────┴────┐
│ │
▼ ▼
ProcessMgr ProxyMgr
(parallel) (parallel)
│ │
▼ ▼
All Done ────────► Exit

Shutdown Modes

Aggressive Mode (Ctrl+C)

When deadline is less than 3 seconds:

  • Immediate SIGKILL to all processes
  • No graceful termination period
  • Completes in under 500ms typically
// Ctrl+C triggers 2-second deadline
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
pm.Shutdown(ctx)

Normal Mode (Programmatic)

When deadline is 3+ seconds:

  1. Send SIGTERM to all processes
  2. Wait up to 5 seconds for graceful exit
  3. Send SIGKILL to remaining processes
// Programmatic shutdown with 30-second deadline
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
pm.Shutdown(ctx)

Process Group Handling

devtool-mcp uses process groups to ensure child processes are terminated:

cmd := exec.Command(name, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true, // Create new process group
}

When stopping:

// Signal entire process group
syscall.Kill(-pid, syscall.SIGTERM)

// After timeout
syscall.Kill(-pid, syscall.SIGKILL)

This handles cases where your dev server spawns child processes (watchers, compilers, etc.).

Shutdown Coordination

Preventing New Work

func (pm *ProcessManager) Register(id string, proc *ManagedProcess) error {
if pm.shuttingDown.Load() {
return ErrShuttingDown
}
// ... registration logic
}

Health Check Termination

func (pm *ProcessManager) Shutdown(ctx context.Context) error {
// Stop health check goroutine
close(pm.shutdownCh)

// Wait for it to exit
<-pm.healthDone

// ... continue shutdown
}

sync.Once Protection

var shutdownOnce sync.Once

func (pm *ProcessManager) Shutdown(ctx context.Context) error {
var err error
shutdownOnce.Do(func() {
err = pm.doShutdown(ctx)
})
return err
}

Prevents duplicate shutdown if signal received multiple times.

Real-World Scenarios

Dev Server with Watchers

# Your dev command spawns multiple processes:
pnpm dev
├── next dev (PID 1234)
│ └── webpack --watch (PID 1235)
└── tsc --watch (PID 1236)

Without process groups, only the parent dies. With devtool-mcp:

Ctrl+C


SIGKILL to process group (-1234)

├── PID 1234 killed
├── PID 1235 killed
└── PID 1236 killed

Build in Progress

# Long-running build
go build -o app ./...

Behavior depends on mode:

Aggressive (Ctrl+C):

  • Build immediately killed
  • Partial output files may remain
  • Fast exit

Normal:

  • SIGTERM sent
  • Go compiler handles gracefully
  • Clean exit within grace period

Multiple Proxies

// ProxyManager shuts down all proxies in parallel
func (pm *ProxyManager) Shutdown(ctx context.Context) error {
var wg sync.WaitGroup

pm.proxies.Range(func(key, value any) bool {
wg.Add(1)
go func(p *ProxyServer) {
defer wg.Done()
p.Stop()
}(value.(*ProxyServer))
return true
})

wg.Wait()
return nil
}

Context Cancellation

All operations respect context cancellation:

func (p *ManagedProcess) Stop(ctx context.Context) error {
// Send SIGTERM
p.signal(syscall.SIGTERM)

select {
case <-p.doneCh:
return nil // Process exited gracefully
case <-ctx.Done():
// Context cancelled - force kill
p.signal(syscall.SIGKILL)
return ctx.Err()
case <-time.After(gracefulTimeout):
// Grace period expired - force kill
p.signal(syscall.SIGKILL)
}
}

Error Handling

Shutdown errors are collected but don't prevent other shutdowns:

func (pm *ProcessManager) Shutdown(ctx context.Context) error {
var errs []error

pm.processes.Range(func(key, value any) bool {
if err := value.(*ManagedProcess).Stop(ctx); err != nil {
errs = append(errs, err)
}
return true
})

return errors.Join(errs...)
}

Timing Configuration

const (
// Ctrl+C gets aggressive shutdown
AggressiveThreshold = 3 * time.Second

// Grace period before SIGKILL
GracefulTimeout = 5 * time.Second

// Total shutdown timeout for Ctrl+C
ShutdownTimeout = 2 * time.Second
)

Testing Shutdown

func TestGracefulShutdown(t *testing.T) {
pm := NewProcessManager(config)

// Start a process
proc := pm.Start("test", cmd)

// Shutdown with short timeout
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

start := time.Now()
err := pm.Shutdown(ctx)
elapsed := time.Since(start)

// Should complete quickly in aggressive mode
assert.Less(t, elapsed, 200*time.Millisecond)

// Process should be stopped
assert.Equal(t, StateStopped, proc.State())
}

Platform Considerations

Linux/macOS

Process groups work as expected:

  • Setpgid: true creates new group
  • kill(-pid, signal) signals entire group

Windows

Process groups work differently:

  • Uses job objects instead
  • Setpgid not available
  • devtool-mcp handles this transparently

Best Practices

  1. Use 2-second Ctrl+C timeout - Fast response for interactive use
  2. Use longer timeouts for CI - Allow graceful cleanup
  3. Always signal process group - Catch child processes
  4. Check shuttingDown flag - Prevent work during shutdown
  5. Use sync.Once - Prevent duplicate shutdown

Debugging Shutdown Issues

Hanging on Shutdown

# Check for orphan processes
ps aux | grep your-script

# Force cleanup
proc {action: "cleanup_port", port: 3000}

Zombie Processes

The health check goroutine detects zombies:

func (pm *ProcessManager) healthCheck() {
ticker := time.NewTicker(10 * time.Second)
for {
select {
case <-ticker.C:
pm.processes.Range(func(key, value any) bool {
proc := value.(*ManagedProcess)
if proc.IsZombie() {
proc.MarkFailed()
}
return true
})
case <-pm.shutdownCh:
return
}
}
}

Next Steps