server
Server state machine pattern for sshserver/, tuiserver/, and core/serverbase/. Covers lifecycle states (Created→Starting→Running→Stopping→Stopped), atomic state reads, idempotent stop.
SKILL.md.claude/skills/server
GitHubServer Pattern
Use this skill when changing internal/sshserver/, internal/tuiserver/,
internal/core/serverbase/, or adding a long-running server component.
State Machine
Servers compose internal/core/serverbase.Base and use serverbase.State.
Created -> Starting -> Running -> Stopping -> Stopped
| |
+-----------> Failed
Stopped and Failed are terminal. A server instance is single-use: once it
stops or fails, create a new instance. Terminal states are absorbing under
concurrent transitions: TransitionToFailed and TransitionToStopped
compare-and-swap from the observed state, and TransitionToStarting stores the
server context in the same critical section as its CAS, so a concurrent stop
always cancels it. formal/tla/Serverbase.tla checks both; keep them when
changing internal/core/serverbase.
| State | Meaning |
|---|---|
serverbase.StateCreated |
Constructed, not started |
serverbase.StateStarting |
Start(ctx) is initializing |
serverbase.StateRunning |
Ready and accepting work |
serverbase.StateStopping |
Graceful shutdown in progress |
serverbase.StateStopped |
Terminal clean stop |
serverbase.StateFailed |
Terminal startup or serve failure |
Required Shape
Concrete servers keep the base private and expose only their API:
type Server struct {
base *serverbase.Base
// server-specific fields
}
func (s *Server) Start(ctx context.Context) error
func (s *Server) Stop() error
func (s *Server) State() serverbase.State { return s.base.State() }
func (s *Server) IsRunning() bool { return s.base.IsRunning() }
func (s *Server) Err() <-chan error { return s.base.Err() }
Use LastError() when callers need the recorded failure after the server reaches
Failed.
Lifecycle Rules
Start
- Call
s.base.TransitionToStarting(ctx)first. - Initialize listeners/resources.
- Start background goroutines with
AddGoroutine()anddefer DoneGoroutine(). - Call
TransitionToRunning()only when the server is ready. - Use
WaitForReady(ctx)orStartedChannel()to block startup until ready or failed. - On startup or serve errors, call
TransitionToFailed(err)and return that error. - SSH servers must always configure a host key explicitly (see
sshserver.hostKeyOption()): never let wish's default pick one — it writes./id_ed25519into the process working directory, which once leaked private keys into this repository.
func (s *Server) Start(ctx context.Context) error {
if err := s.base.TransitionToStarting(ctx); err != nil {
return err
}
// Initialize listener/server resources here.
s.base.AddGoroutine()
go func() {
defer s.base.DoneGoroutine()
s.base.TransitionToRunning()
if err := s.serve(s.base.Context()); err != nil {
_ = s.base.TransitionToFailed(err)
}
}()
if err := s.base.WaitForReady(ctx); err != nil {
return s.base.TransitionToFailed(err)
}
return nil
}
Stop
Stop() must be idempotent and safe from any state. Use the base transition
loop instead of hand-rolled locks.
func (s *Server) Stop() error {
if !s.base.TransitionToStopping() {
s.base.WaitForShutdown()
return nil
}
// Close listeners, request shutdown, and clean server-specific resources.
s.base.WaitForShutdown()
s.base.TransitionToStopped()
return nil
}
For stop-before-start, TransitionToStopping() moves Created -> Stopped,
closes the error channel, and returns false.
Goroutines And Errors
- Call
AddGoroutine()before starting each background goroutine. defer DoneGoroutine()at the top of the goroutine.- Use
s.base.Context()inside goroutines afterTransitionToStarting(). - Send non-fatal async errors with
SendError(err); transition fatal serve errors withTransitionToFailed(err). - Ensure listeners unblock promptly on shutdown before waiting for goroutines.
Testing Requirements
Server tests should cover:
Created -> Running -> Stopped.- Double
Start()returns an invalid-state error. - Double
Stop()is a no-op. - Stop before start transitions
Created -> Stopped. - Cancelled startup transitions to
Failed. - Runtime serve errors transition
Running -> Failed, closeErr(), and populateLastError(). - Terminal states are irreversible.
Use focused commands first:
go test -race -count=1 ./internal/core/serverbase
go test -race -count=1 ./internal/sshserver ./internal/tuiserver
Then follow .agents/rules/checklist.md for the final verification scope.