Air — Patterns#
Concurrency patterns#
Channel-as-semaphore + cancellation carrier#
- Usage:
buildRunCh chan chan struct{}(capacity 1) inrunner/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 innerchan 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 boolinengine.go - Example:
engine.go:851—close(e.exitCh)is called byStop(), all goroutines select on<-e.exitChin 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
buildDelayms, then callsflushEvents()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:350—go func(dir string)spawns one goroutine per watched directory - Example: Each goroutine reads from
watcher.Events()and pushes matching paths into the sharedeventCh(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 callsr.Stop(), then callsos.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:29tracks whether a binary subprocess is running; read and set atomically to avoid data races without locking - Assessment: Appropriate use of
sync/atomicfor simple boolean/counter state shared between goroutines.
Error handling#
- Style: Wrapped errors with
fmt.Errorf %w, plus bareerrors.Newfor 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)throughoutrunner/config.goandrunner/proxy.go. Message prefix convention is consistent:"failed to <verb> <object>: %w". - Consumer side:
errors.Isused in tests (engine_test.go:314,engine_test.go:645) forsyscall.ECONNREFUSEDdetection; not used in production code. - Examples:
config.go:222—fmt.Errorf("failed to check for existing configuration: %w", err)proxy.go:92—fmt.Errorf("proxy inject: failed to init gzip reader: %w", err)util.go:334—errors.New("empty file, forcing rebuild without updating checksum")
- Notable: Zero usage of
context.Contextanywhere 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 viamergo. 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(amergo.Transformersimplementation) prevents default slices from overwriting user-provided non-empty slices. This is a precise fix formergo’s zero-value overwrite behavior — a common gotcha when using that library.
Dependency injection#
- Approach: Manual wiring; no framework.
- Evidence:
main.gocallsrunner.InitConfig()→runner.NewEngineWithConfig(cfg, debug). WithinNewEngineWithConfig, sub-components (logger, watcher, proxy) are constructed inline. No service locator, nowire, nodig, nofx. - Testability accommodation: The
exiterinterface (exiter.go:5) is the only seam introduced for DI — allows tests to interceptos.Exitcalls 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 withusagetag) - What: The
Configstruct is walked via reflection to auto-register aflag.Flagfor every field annotated with ausagestruct 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.jsandworker.jsare 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:
ProxyStreammaintains amap[int32]*Subscriberguarded bysync.Mutex. Each subscriber holds achan StreamMessage.Notify()iterates all subscribers and sends to their channels;AddSubscriber/RemoveSubscribermanage 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
entrypointas either a string or[]string. A type switch on the rawinterface{}value normalizes it to[]stringbefore 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) andStreamer(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.