-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathapp.go
More file actions
496 lines (441 loc) · 17.1 KB
/
Copy pathapp.go
File metadata and controls
496 lines (441 loc) · 17.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
// Package wind provides a minimalist microservice framework following a
// composable (Lego-like) design philosophy. The core [App] manages server
// lifecycles, while registration, logging and instance assembly are left
// entirely to the caller.
//
// This is NOT a battery-included framework. Each subsystem (transport,
// log) exposes only interfaces and helper types so that callers mix and
// match implementations as needed.
package wind
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"sync"
"sync/atomic"
"syscall"
"time"
"golang.org/x/sync/errgroup"
"github.com/tx7do/go-wind/log"
"github.com/tx7do/go-wind/transport"
)
// Option configures an [*App] via functional options.
type Option func(*App)
// App is the central runtime that owns and manages the lifecycle of one or
// more [transport.Server] instances. It is intentionally free of any
// hard-coded integration — callers wire up servers, registries, loggers, etc.
// through the composable Option pattern.
type App struct {
opts options
mu sync.Mutex
cancel context.CancelFunc
// running guards against [Run] being called more than once per
// [*App] instance (FINDING-1). Once set to true it is never reset.
running atomic.Bool
done chan struct{}
closeOnce sync.Once
runErr error
}
// options holds the fully-resolved configuration for an [App]. All fields are
// unexported; callers set them through [Option] functions and read them back
// through the [App] accessor methods.
type options struct {
id string
name string
version string
logger log.Logger
sigs []os.Signal
stopTimeout time.Duration
beforeStop []func(ctx context.Context) error
afterStop []func(ctx context.Context) error
servers []transport.Server
banner bool
instanceID string
}
// WithID sets the unique identifier of the application. It is typically
// used to construct an [Instance] for service registration.
func WithID(id string) Option {
return func(o *App) { o.opts.id = id }
}
// WithName sets the human-readable name of the application.
func WithName(name string) Option {
return func(o *App) { o.opts.name = name }
}
// WithVersion sets the semantic version of the application.
func WithVersion(version string) Option {
return func(o *App) { o.opts.version = version }
}
// WithLogger sets an app-specific [log.Logger]. If not set, [App.Logger]
// falls back to the package-level global logger ([log.GetLogger]).
// This allows callers to give each [*App] instance its own logger without
// affecting the global state.
func WithLogger(l log.Logger) Option {
return func(o *App) { o.opts.logger = l }
}
// WithBeforeStop registers a callback invoked BEFORE any server's Stop is
// called during graceful shutdown. Typical uses include deregistering from
// a service registry, draining an incoming-request queue, or writing a
// final health-check ping.
//
// Multiple callbacks are executed in registration order. If any callback
// returns an error, the error is logged but shutdown continues.
func WithBeforeStop(fn func(ctx context.Context) error) Option {
return func(o *App) { o.opts.beforeStop = append(o.opts.beforeStop, fn) }
}
// WithAfterStop registers a callback invoked AFTER all servers have stopped.
// Typical uses include closing database connections, flushing log buffers,
// or releasing other resources.
//
// Multiple callbacks are executed in registration order. If any callback
// returns an error, the error is logged but the error is not returned from
// Run (the servers have already stopped successfully).
func WithAfterStop(fn func(ctx context.Context) error) Option {
return func(o *App) { o.opts.afterStop = append(o.opts.afterStop, fn) }
}
// WithServer attaches one or more [transport.Server] instances to the [App].
// All servers are started concurrently when [App.Run] is called and stopped
// concurrently during graceful shutdown.
func WithServer(srv ...transport.Server) Option {
return func(o *App) { o.opts.servers = append(o.opts.servers, srv...) }
}
// WithStopTimeout sets the maximum duration allowed for graceful shutdown.
// Each server's Stop call receives a context with this deadline. The default
// is 10 seconds.
func WithStopTimeout(d time.Duration) Option {
return func(o *App) { o.opts.stopTimeout = d }
}
// WithSignal overrides the default set of OS signals that trigger graceful
// shutdown. By default the app listens for SIGTERM, SIGINT and SIGQUIT.
func WithSignal(sigs ...os.Signal) Option {
return func(o *App) { o.opts.sigs = sigs }
}
// WithBanner enables or disables the startup banner. When enabled, App.Run
// prints the application name, version, appId, instanceId, PID and hostname
// at startup. Disabled by default to keep output clean.
func WithBanner(enabled bool) Option {
return func(o *App) { o.opts.banner = enabled }
}
// WithInstanceID sets a custom instance identifier. If not set and banner is
// enabled, one is auto-generated in the format "{id}-{version}@{hostname}@{random}".
func WithInstanceID(id string) Option {
return func(o *App) { o.opts.instanceID = id }
}
// New creates an [*App] with the given options. Sensible defaults are applied:
// - Listens for SIGTERM, SIGINT and SIGQUIT for graceful shutdown.
// - A 10-second stop timeout is enforced during shutdown.
func New(opts ...Option) *App {
o := options{
sigs: []os.Signal{syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT},
stopTimeout: 10 * time.Second,
}
app := &App{opts: o, done: make(chan struct{})}
for _, opt := range opts {
opt(app)
}
return app
}
// --- accessors ---------------------------------------------------------------
//
// These read-only getters let callers retrieve values set via Option during
// composable assembly, e.g. to build an [Instance] for registration or as
// log tags.
// ID returns the unique identifier set via [WithID].
func (a *App) ID() string { return a.opts.id }
// Name returns the application name set via [WithName].
func (a *App) Name() string { return a.opts.name }
// Version returns the application version set via [WithVersion].
func (a *App) Version() string { return a.opts.version }
// InstanceID returns the instance identifier. If set via [WithInstanceID],
// that value is returned. Otherwise an auto-generated ID is returned on
// first access. The auto-generated format is:
//
// "{id}-{version}@{hostname}@{randomShortHex}"
func (a *App) InstanceID() string {
if a.opts.instanceID != "" {
return a.opts.instanceID
}
a.opts.instanceID = generateInstanceID(a.opts.id, a.opts.version)
return a.opts.instanceID
}
// Logger returns the app-specific logger set via [WithLogger]. If no logger
// was set, it falls back to the package-level global logger ([log.GetLogger]).
func (a *App) Logger() log.Logger {
if a.opts.logger != nil {
return a.opts.logger
}
return log.GetLogger()
}
// Instance builds an [*Instance] from the app's configured ID, Name and
// Version, plus the provided endpoint URLs. This is a convenience helper for
// callers who wish to register with a service registry — it does NOT perform
// any registration on its own (composable design: the caller chooses whether
// and how to register).
func (a *App) Instance(endpoints ...string) *Instance {
return &Instance{
ID: a.opts.id,
Name: a.opts.name,
Version: a.opts.version,
Endpoints: endpoints,
}
}
// Done returns a channel that is closed when [Run] finishes — either after a
// normal graceful shutdown or after a server crash. It allows external
// supervisors to wait for the app to terminate without calling [Stop] or
// wrapping [Run] in their own error channel. Done is provided for
// read-only observation; it must not be closed by the caller.
//
// Before [Run] is called the channel is open (not closed).
func (a *App) Done() <-chan struct{} {
return a.done
}
// Err returns the error that caused [Run] to exit. It must be called after
// [Done] is closed; calling it before returns nil.
//
// This complements [Done] by allowing external supervisors to observe both
// the termination and the outcome without wrapping [Run] in their own
// goroutine:
//
// <-app.Done()
// if err := app.Err(); err != nil { ... }
func (a *App) Err() error {
select {
case <-a.done:
default:
return nil
}
return a.runErr
}
// Run starts the application and blocks until all servers have stopped.
//
// All registered servers are started concurrently inside an errgroup. The
// method returns when:
// - A registered OS signal (SIGTERM/SIGINT/SIGQUIT) is received.
// - The provided ctx is cancelled.
// - Any server's Start returns an error (server crash) or nil (server
// self-exit).
//
// On any of these triggers, every server receives a Stop call with a fresh
// context derived from context.Background() — NOT from the run context — so
// the configured stopTimeout is honoured even after a.cancel() fires.
//
// If no servers are registered, Run blocks until ctx is cancelled, a
// signal is received, or [Stop] is called. This is useful for pure worker
// applications that do not expose a network server but still want graceful
// shutdown.
//
// Run must be called at most once per [*App] instance. Calling Run a
// second time returns [ErrAppAlreadyRunning] immediately.
func (a *App) Run(ctx context.Context) error {
if !a.running.CompareAndSwap(false, true) {
return ErrAppAlreadyRunning
}
runCtx, cancel := context.WithCancel(ctx)
a.mu.Lock()
a.cancel = cancel
a.mu.Unlock()
if a.opts.banner {
a.printBanner(runCtx)
}
eg, egCtx := errgroup.WithContext(runCtx)
// Start all servers concurrently in an errgroup. Only Start goroutines
// and the signal watcher are in this group. Stop watchers and lifecycle
// hooks are handled in separate phases below to guarantee correct
// ordering: BeforeStop → Server.Stop → AfterStop.
for _, srv := range a.opts.servers {
srv := srv
// Start the server. If Start returns an error, errgroup cancels egCtx
// (BUG-3). If Start returns nil (server self-exited), we explicitly
// trigger a full shutdown so other servers also stop (ISSUE-3).
eg.Go(func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("wind: server panicked during Start (endpoint %s): %v", srv.Endpoint(), r)
a.Logger().Error(egCtx, "server panicked during Start",
"endpoint", srv.Endpoint(), "panic", r)
}
}()
a.Logger().Info(egCtx, "server starting", "endpoint", srv.Endpoint())
err = srv.Start(egCtx)
if err == nil {
a.Logger().Info(egCtx, "server self-exited, triggering shutdown",
"endpoint", srv.Endpoint())
a.triggerCancel()
} else if !errors.Is(err, context.Canceled) {
a.Logger().Error(egCtx, "server crashed",
"endpoint", srv.Endpoint(), "error", err)
}
return err
})
}
c := make(chan os.Signal, 1)
signal.Notify(c, a.opts.sigs...)
// ISSUE-4: stop relaying signals when Run exits to avoid leaking the
// signal channel across multiple Run/Stop cycles.
defer signal.Stop(c)
eg.Go(func() error {
select {
case <-egCtx.Done():
// Triggered by a server crash or external cancellation; nothing to do.
case sig := <-c:
// Signal received: cancel the main context, which cascades to egCtx
// and triggers all Start goroutines to return.
a.Logger().Info(egCtx, "shutdown signal received", "signal", sig.String())
a.triggerCancel()
}
return nil
})
// Phase 1 complete: wait for all Start goroutines and the signal watcher
// to return (i.e. shutdown has been triggered).
startErr := eg.Wait()
// The shutdown phases (BeforeStop → Stop → AfterStop) start here.
shutdownStart := time.Now()
a.Logger().Info(context.Background(), "graceful shutdown initiated")
// Phase 2: BeforeStop hooks — synchronous, BEFORE any server is stopped.
// Each hook gets a FRESH timeout context created at this point, not at
// Run start (BUG fix: previously the context timed out during normal
// operation and hooks always saw an expired deadline).
for _, fn := range a.opts.beforeStop {
hookCtx, hookCancel := context.WithTimeout(context.Background(), a.opts.stopTimeout)
if err := a.runHookSafely(hookCtx, "beforeStop", fn); err != nil {
a.Logger().Warn(hookCtx, "beforeStop hook error", "error", err)
}
hookCancel()
}
// Phase 3: Server.Stop — concurrent, each with its own timeout context
// derived from context.Background(). We must NOT derive from runCtx /
// egCtx, because cancel() would immediately invalidate it, rendering
// stopTimeout useless (BUG-1 regression guard).
//
// firstStopErr captures the first non-nil error from any server's Stop
// call (FINDING-4). Since Stop is now outside the errgroup, we capture
// errors directly rather than relying on errgroup's first-error-wins.
var firstStopErr error
var stopErrOnce sync.Once
var stopWg sync.WaitGroup
for _, srv := range a.opts.servers {
srv := srv
stopWg.Add(1)
go func() {
defer stopWg.Done()
defer func() {
if r := recover(); r != nil {
panicErr := fmt.Errorf("wind: server panicked during Stop (endpoint %s): %v", srv.Endpoint(), r)
a.Logger().Error(context.Background(), "server panicked during Stop",
"endpoint", srv.Endpoint(), "panic", r)
stopErrOnce.Do(func() { firstStopErr = panicErr })
}
}()
stopCtx, stopCancel := context.WithTimeout(context.Background(), a.opts.stopTimeout)
defer stopCancel()
if err := srv.Stop(stopCtx); err != nil {
a.Logger().Error(stopCtx, "server stop failed",
"endpoint", srv.Endpoint(), "error", err)
stopErrOnce.Do(func() { firstStopErr = err })
}
}()
}
stopWg.Wait()
// Phase 4: AfterStop hooks — synchronous, AFTER all servers have stopped.
for _, fn := range a.opts.afterStop {
hookCtx, hookCancel := context.WithTimeout(context.Background(), a.opts.stopTimeout)
if err := a.runHookSafely(hookCtx, "afterStop", fn); err != nil {
a.Logger().Warn(hookCtx, "afterStop hook error", "error", err)
}
hookCancel()
}
// Determine the final error to return and store for [Err].
var runErr error
if startErr != nil && !errors.Is(startErr, context.Canceled) {
runErr = startErr
} else if firstStopErr != nil {
runErr = firstStopErr
}
// Store the error before signaling completion so [Err] can read it
// safely after [Done] is closed (happens-before via channel close).
a.runErr = runErr
a.closeOnce.Do(func() { close(a.done) })
a.Logger().Info(context.Background(), "shutdown complete",
"duration", time.Since(shutdownStart).String(), "error", runErr)
return runErr
}
// Stop gracefully stops the application by cancelling the main context and
// waiting for all registered servers to finish shutting down.
//
// Stop does NOT call Server.Stop directly — it only triggers cancellation and
// lets the Stop watchers inside [Run] perform the actual shutdown. This avoids
// double-Stop when Stop is called concurrently with an active Run (ISSUE-1).
//
// Stop must be called from a different goroutine than [Run]. If Run has not
// been started, Stop blocks until ctx is done (there is nothing to stop).
func (a *App) Stop(ctx context.Context) error {
a.triggerCancel()
select {
case <-a.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// runHookSafely executes a lifecycle hook, recovering from panics and
// converting them to errors. This ensures that a panicking hook does not
// skip subsequent hooks or the Server.Stop phase. If the Server or hook
// needs its own panic handling (e.g. custom recovery), it may add an inner
// defer-recover which takes precedence over this safety net.
func (a *App) runHookSafely(ctx context.Context, name string, fn func(context.Context) error) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("wind: %s hook panicked: %v", name, r)
a.Logger().Error(ctx, "hook panicked", "hook", name, "panic", r)
}
}()
return fn(ctx)
}
// triggerCancel safely calls a.cancel if it has been set by [Run]. Safe to
// call from any goroutine, including before [Run] is called.
func (a *App) triggerCancel() {
a.mu.Lock()
if a.cancel != nil {
a.cancel()
}
a.mu.Unlock()
}
// printBanner logs the application startup information: name, version,
// appId, instanceId, PID and hostname. It is called once at the start of
// [Run] when the banner option is enabled.
func (a *App) printBanner(ctx context.Context) {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
a.Logger().Info(ctx, "application starting",
"name", a.opts.name,
"version", a.opts.version,
"appId", a.opts.id,
"instanceId", a.InstanceID(),
"pid", os.Getpid(),
"hostname", hostname,
)
}
// generateInstanceID builds a unique instance identifier from the given appId,
// version, hostname and a random suffix. The format is:
//
// "{appId}-{version}@{hostname}@{randomHex}"
func generateInstanceID(appID, version string) string {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
// Remove domain part if present (e.g. "my-host.example.com" → "my-host").
hostname = filepath.Base(hostname)
random := make([]byte, 10)
if _, err := rand.Read(random); err != nil {
// Fallback: use timestamp-based pseudo-random if crypto/rand fails.
copy(random, []byte(hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))))
}
return fmt.Sprintf("%s-%s@%s@%s", appID, version, hostname, hex.EncodeToString(random))
}