Air — Patterns#

Concurrency patterns#

Channel-as-semaphore + cancellation carrier#

  • Usage: buildRunCh chan chan struct{} (capacity 1) in runner/engine.go
  • Example: engine.go:40-43 — a capacity-1 channel that serializes builds AND carries the stop token for the in-flight build
  • Assessment: Highly idiomatic and elegant. The buffer=1 prevents a second buildRun() from starting while one is in flight (it would block trying to send). The inner chan struct{} is the cancellation token: start() closes it to abort the current build. This is a pattern worth highlighting — one channel doing two jobs cleanly.

Graceful shutdown via closed channel#

  • Usage: exitCh chan bool in engine.go
  • Example: engine.go:851close(e.exitCh) is called by Stop(), all goroutines select on <-e.exitCh in their loops
  • Assessment: Standard Go idiom. Broadcast shutdown with no data needed — closing is the right choice over sending a value. All goroutines respect the exit signal through select.

Debounce with time.Sleep + channel drain#

  • Usage: engine.go:401-404
  • Example: After receiving a file event, the engine sleeps buildDelay ms, then calls flushEvents() to drain any further events that arrived in the window before triggering a build
  • Assessment: Simple and correct for a CLI tool. More sophisticated implementations (timer reset on each event) would add complexity without meaningful benefit here.

Fan-out via goroutine per watched path#

  • Usage: engine.go:350go func(dir string) spawns one goroutine per watched directory
  • Example: Each goroutine reads from watcher.Events() and pushes matching paths into the shared eventCh (buffered 1000)
  • Assessment: Classic fan-in pattern — multiple producers, single consumer. The 1000-element buffer prevents watcher goroutines from blocking on bursts.

Signal goroutine → Stop() delegation#

  • Usage: main.go:121-128
  • Example: A dedicated goroutine blocks on <-sigs, then calls r.Stop(), then calls os.Exit(). No signal handling inside the Engine.
  • Assessment: Clean separation — the main package handles OS signals, the Engine exposes a Stop method. The engine does not depend on OS signals directly.

Atomic state flags#

  • Usage: engine.go:29 (running atomic.Bool), proxy_stream.go:13 (count atomic.Int32)
  • Example: engine.go:29 tracks whether a binary subprocess is running; read and set atomically to avoid data races without locking
  • Assessment: Appropriate use of sync/atomic for simple boolean/counter state shared between goroutines.

Error handling#

  • Style: Wrapped errors with fmt.Errorf %w, plus bare errors.New for static messages. No custom error types.
  • Error types defined: None. All errors are plain or wrapped stdlib errors.
  • Wrapping approach: fmt.Errorf("failed to X: %w", err) throughout runner/config.go and runner/proxy.go. Message prefix convention is consistent: "failed to <verb> <object>: %w".
  • Consumer side: errors.Is used in tests (engine_test.go:314, engine_test.go:645) for syscall.ECONNREFUSED detection; not used in production code.
  • Examples:
    • config.go:222fmt.Errorf("failed to check for existing configuration: %w", err)
    • proxy.go:92fmt.Errorf("proxy inject: failed to init gzip reader: %w", err)
    • util.go:334errors.New("empty file, forcing rebuild without updating checksum")
  • Notable: Zero usage of context.Context anywhere in the codebase. Air does not pass contexts through its call stack — cancellation is achieved entirely via custom channels (myStopCh, exitCh). This is a deliberate, consistent choice rather than an oversight.

Configuration pattern#

  • Approach: Flat config struct with TOML tags, loaded by go-toml, merged with hardcoded defaults via mergo. CLI override is injected via reflection after loading.
  • Example:
    // config.go — load → merge → override
    cfg, _ := toml.DecodeFile(path, &c)
    mergo.Merge(&c, defaultConfig(), mergo.WithTransformers(sliceTransformer{}))
    // flag.go — reflection-based CLI override
    setValue2Struct(reflect.ValueOf(cfg), fieldName, flagValue)
  • Notable: The sliceTransformer (a mergo.Transformers implementation) prevents default slices from overwriting user-provided non-empty slices. This is a precise fix for mergo’s zero-value overwrite behavior — a common gotcha when using that library.

Dependency injection#

  • Approach: Manual wiring; no framework.
  • Evidence: main.go calls runner.InitConfig()runner.NewEngineWithConfig(cfg, debug). Within NewEngineWithConfig, sub-components (logger, watcher, proxy) are constructed inline. No service locator, no wire, no dig, no fx.
  • Testability accommodation: The exiter interface (exiter.go:5) is the only seam introduced for DI — allows tests to intercept os.Exit calls without killing the test process.

Other notable patterns#

Reflection for CLI flag generation#

  • Where: runner/util.go:366-409 (setValue2Struct), runner/flag.go (flag registration loop over struct fields with usage tag)
  • What: The Config struct is walked via reflection to auto-register a flag.Flag for every field annotated with a usage struct tag. This avoids manually writing flag registration code for ~30+ config fields.
  • Assessment: Pragmatic for a config-heavy tool. The approach is contained and well-bounded — not a general ORM or DI system.

//go:embed for static assets#

  • Where: runner/proxy.go:20,23
  • What: proxy.js and worker.js are embedded into the binary at compile time. No external files, no CDN dependency, no side-car asset management.
  • Assessment: Exemplary use of Go 1.16+ embed directive. Keeps the binary self-contained and deployment trivial.

Observer / pub-sub for SSE#

  • Where: runner/proxy_stream.go
  • What: ProxyStream maintains a map[int32]*Subscriber guarded by sync.Mutex. Each subscriber holds a chan StreamMessage. Notify() iterates all subscribers and sends to their channels; AddSubscriber/RemoveSubscriber manage lifecycle. Each SSE HTTP connection gets its own subscriber goroutine.
  • Assessment: Textbook pub-sub via channels. The map + mutex + per-subscriber channel is idiomatic Go for small fan-out (browser tabs). For large-scale, a ring buffer or lock-free structure would be considered, but this is appropriate here.

Type switch for TOML union values#

  • Where: runner/config.go:46
  • What: TOML can represent entrypoint as either a string or []string. A type switch on the raw interface{} value normalizes it to []string before the rest of config loading proceeds.
  • Assessment: Correct use of type switch for discriminated union. Single occurrence — not overused.

Table-driven tests#

  • Prevalence: 39 usages of t.Run( in test files — heavy use.
  • Style: Anonymous struct slices with descriptive names, e.g.:
    tests := []struct{ name string; input ...; expected ... }{ ... }
    for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ... }) }
  • Assessment: Consistent and idiomatic. All major logic paths (config parsing, file filtering, path utilities) have table-driven coverage.

No generics#

  • Air uses Go 1.23 (from go.mod) but contains zero use of generics. The codebase’s low complexity makes generics unnecessary — no collections, no type-parameterized algorithms are needed.

Interface minimalism#

  • Only two interfaces defined in the entire codebase: exiter (1 method) and Streamer (4 methods in proxy). Both exist solely for test seams. No interfaces defined for “good design” — only for necessity. This is consistent with the architecture’s philosophy of minimal abstraction.