Skip to content

refactor: separate the application service from the Bubble Tea TUI - #131

Open
evg4b wants to merge 7 commits into
mainfrom
refactor/service-tui-boundary
Open

refactor: separate the application service from the Bubble Tea TUI#131
evg4b wants to merge 7 commits into
mainfrom
refactor/service-tui-boundary

Conversation

@evg4b

@evg4b evg4b commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Separates the application service from the Bubble Tea layer, in seven independently reviewable phases. Each commit builds, passes make check and the integration suite, and leaves the application working.

Why

Interactive and non-interactive mode implemented the same orchestration twice — headless with four bare goroutines in run_non_interactive.go, interactive as tea.Cmds plus a config.Watcher the Bubble Tea model owned itself.

They had already drifted, and the drift was a user-visible bug: headless handled a bad config reload correctly, interactive discarded the error and dereferenced nil. A YAML typo killed the TUI. That is the concrete cost of having no single owner of application behaviour.

Much of the groundwork already existed — di.Runtime scopes resources to a config generation, di.Proxy owns generation transitions, RequestTracker is a non-blocking event stream. The missing piece was an owner for orchestration.

What changed

internal/app.Service now owns the active configuration, the reload lifecycle, the config watcher, the version check, signal handling and the service lifetime context. Both modes drive it; they differ only in the client attached.

internal/render is the single place that turns a service event into console output, so the two modes cannot drift again in what they report.

The headline result: internal/app, di, server, handler, config and version now depend on zero charm.land packages, transitively. tests/architecture fails the build if that regresses.

Phase Commit Result
0 8d1a04e Correctness fixes the boundary would otherwise cement in
1 e4c48fe internal/app.Service — one orchestration owner
2 9a50b28 Structured Lifecycle/Log events + internal/render
3 ebd7205 Explicit boundary, enforced in CI
4 bccfd2f In-flight request state moved out of the widgets
5 0856dd8 Terminal libraries removed from the service
6 db20914 Restart rollback, transport and cert-cache ownership

Bugs fixed

Each has a test that was verified to fail without its fix.

  • A YAML typo killed the TUI. run_interactive.go discarded the error from LoadConfiguration, so a rejected config handed nil to proxy.Restart, which BuildRuntime dereferenced at runtime.go:44. PanicInterceptor re-panics in non-release builds. Headless already handled this correctly.
  • Stale in-flight rows after a file-triggered reload. TrackerWidget cleared itself on the restart key but not on a config save. The in-flight set is now service state, cleared on reload, so both paths behave identically.
  • A failed rebind left the server bound to nothing. Server.Restart shut the old listeners down before knowing the new ones could bind — and in headless mode that also ended the process, because every listener goroutine had exited and Wait returned. It now restores the previous targets.
  • Container.closers was declared and iterated but never appended to, so container.Close() was a no-op and RequestTracker was never closed.
  • Per-generation HTTP transports were owned by nobody; idle pools of every superseded config survived until exit.
  • The per-host certificate cache is driven by traffic, not config — a {placeholder} mapping serves any name, each entry an RSA-2048 key pair. Now bounded at 128 with oldest-first eviction.
  • Watcher.Watch used a check-then-set guard Close never released. Making it reusable exposed a data race on the fsnotify handle between a previous run goroutine and a new Watch; run now owns the watcher it is handed.

For the reviewer

Output was verified byte-identical at every phase that touched it. For each, I ran the real binary and the previous commit's binary through start → request → reload → rejected config → SIGTERM and diffed. Phase 5 was compared with ANSI included — the PROXY/MOCK badge bytes are unchanged.

That comparison caught a regression I had introduced: Reloading was announced before the config loaded, so a rejected config falsely claimed the server was restarting. It is now emitted only once the config is known good.

Interactive mode was driven end-to-end in a pty: request served, r reload reaching both widgets, q clean exit, port released.

Three deliberate deviations from the plan:

  1. No command queue. The protocol is two commands (Reload, Shutdown) and process separation is out of scope, so a queue would be machinery with no consumer. The TUI depends on a narrow consumer-side interface instead.
  2. Signal handling stayed headless-only. Bubble Tea already owns SIGINT; a second signal.Notify would double-fire. The service provides it, interactive does not opt in.
  3. WithCliOutput was hardened, not deleted. Deleting it requires replacing contracts.Output across the handler layer. It now panics if applied after the output has been built, instead of silently sending output to the terminal underneath the TUI. Two tests were asserting that footgun and now assert the contract.

Behaviour changes worth a look:

  • Reload debounce raised from 10ms to 150ms, and reloads are serialised and coalesced. The old 10ms window was tight enough that one editor save could trigger two reloads — the repo's own test conceded this by asserting callCount <= 3 for 5 writes.
  • TUI shutdown grace period aligned from 5s to the 15s used elsewhere.
  • History scrollback capped at 10,000 lines; it previously grew one line per request for the life of the process.
  • config.LoadConfiguration takes an optional WithUsage renderer. Existing call sites are unaffected (variadic); only the CLI passes it, so --help output is unchanged.
  • Container.GenerateCertsCommand removed — the CLI constructs it, which is what lets di drop its internal/tui import.

Testing

make check and make test-integration pass. New coverage: service running with no client attached, reload keeping the generation when config fails, reload port migration, concurrent reloads never overlapping, in-flight cleared on reload, restart rollback, full start/reload/shutdown cycles asserting no goroutine growth, and the architecture guard itself (verified it fails on a real violation, and is cache-correct).

🤖 Generated with Claude Code

evg4b and others added 7 commits September 3, 2026 17:41
…des (Phase 0)

Pre-migration correctness pass. These are the bugs the service/TUI boundary
would otherwise cement in, most of them caused by interactive and headless
mode implementing the same behaviour twice.

- run_interactive.go discarded the error from LoadConfiguration, so a config
  that failed to parse or validate handed a nil *UncorsConfig to
  proxy.Restart, which BuildRuntime dereferenced at runtime.go:44.
  PanicInterceptor re-panics in non-release builds, so a YAML typo killed the
  TUI. Headless already handled this correctly. The loader now returns an
  error and both reload paths report it and keep the running generation.
- handleServerError quit without shutting the proxy down, stranding the
  generation a failed start left behind; it now goes through shutdownCmd.
- The TUI's shutdown grace period was 5s against 15s in cli and server.
- Container.closers was declared and iterated but never appended to, so
  container.Close() was a no-op. Server and RequestTracker now register
  themselves; Close releases in reverse creation order and is idempotent, so
  the server stops before the sink it emits into.
- Watcher.Watch used a check-then-set guard that Close never released, making
  a Watcher permanently "watching". It now claims atomically and Close
  releases the claim. Making the watcher reusable exposed a data race on the
  fsnotify handle between a previous run goroutine and a new Watch, so run
  now owns the watcher it was handed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ner (Phase 1)

Interactive and non-interactive mode each implemented start, config watching,
reload, the version check and shutdown separately. They had already drifted
(see the previous commit), so this gives that orchestration one owner that
both modes drive.

internal/app.Service owns the active configuration, the reload lifecycle, the
config.Watcher, the version check, signal handling and the service lifetime
context. It has no dependency on a terminal.

- runNonInteractive shrinks to draining the request tracker and calling
  service.Run; awaitShutdown, watchConfig and versionCheck moved into the
  service verbatim in behaviour.
- UncorsApp drops proxy, container, cfg, loadConfig, configPath, watcher and
  its private root context. handleServerStarted is now empty: the service
  starts watching and version-checking itself. The model sends Start, Reload
  and Shutdown, and renders what comes back.
- Both modes now share one config loader, so a reload cannot behave
  differently depending on the mode.
- Reloads are serialised and coalesced. The decision to stop looping and the
  clearing of the running flag happen under the lock that sets the pending
  flag, so a request arriving mid-reload is never dropped.

Tests: internal/app/service_test.go covers the service running with no client
attached, reload keeping the generation when the config fails, reload moving
to a new port, config-file watching, idempotent shutdown, and concurrent
reloads never overlapping. The watcher tests move out of the TUI package,
which no longer owns a watcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Service code no longer decides what the console looks like. di.Proxy printed
the logo, the disclaimer, the mappings box and the restart messages from
inside the generation-transition code, which meant the dependency-injection
layer knew what a terminal was.

- internal/app now emits LifecycleEvent and LogEvent. The set is small and
  matches what the application already communicated: starting, started,
  start-failed, reloading, reloaded, reload-failed, stopping, stopped.
- Lifecycle is recorded as Status as well as notified. Notifications are
  dropped under pressure like any other event, which is only safe because the
  latest state stays readable - a client that misses a notification can still
  read the truth.
- Log events are dropped and counted, mirroring RequestTracker. Presentation
  must never be able to stall the service.
- internal/render is the single place that turns an event into console output.
  Both modes use it, so neither can drift from the other in what it reports.
- di.Proxy prints nothing at all now.

Verified the headless output is byte-identical to the previous commit by
running both binaries through start, reload, a rejected config and SIGTERM,
stripping ANSI and normalising ports. That comparison caught one real
regression: Reloading was announced before the config had loaded, so a
rejected config claimed the server was restarting. It is now emitted only
once the config is known to be good.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e 3)

Phase 1 already removed the model's di.Proxy field, so the remaining work was
to make the boundary something the compiler and CI can hold.

- UncorsApp now depends on a narrow service interface declared at the point of
  use - Start, Reload, Shutdown, Close, Context, Events - rather than on
  *app.Service. Commands go down, events come back up, and the model reaches
  for nothing else.
- tests/architecture asserts that internal/app, di, server, handler and config
  do not depend on Bubble Tea or Bubbles, directly or transitively, and do not
  depend on the TUI package. Verified the guard fails when the import is
  actually added.
- The guard shells out to go list, which Go's test cache cannot see, so it also
  reads the sources it guards. Without that a violating import could be masked
  by a cached pass; verified invalidation works from a primed cache.

Lip Gloss is deliberately not yet in the forbidden set: internal/di still
styles handler prefixes through internal/tui/styles. Phase 5 removes that and
adds it.

No command queue: the plan's command set is two entries and process separation
is out of scope, so a queue would be machinery without a consumer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The set of in-flight requests was a fact about the server that only a Bubble
Tea widget knew, rebuilt purely from the events that widget happened to
witness. That is what caused the stale-rows bug: TrackerWidget cleared itself
on the restart key but not on a reload triggered by saving the config file,
so the UI kept showing requests from a generation that no longer existed.

- The service is now the single consumer of the request tracker. It maintains
  the authoritative in-flight set, exposes it through InFlight(), and
  republishes activity on its own event stream.
- A completed reload clears that set, so both reload paths behave identically.
  The widgets learn about it from StateReloaded rather than from a message the
  restart key synthesised, which is what removes the asymmetry.
- The TUI no longer reads the tracker, and request rendering moved into
  internal/render, so headless drops the separate RequestPrinter goroutine and
  renders requests the same way it renders everything else.
- History scrollback is capped at 10,000 lines. It grew without limit before,
  one line per request, for as long as the process lived. The existing test
  asserting unbounded growth is updated to assert the cap.

Verified headless output is byte-identical to the previous commit across
start, two requests, reload, a request on the new port, a rejected config and
SIGTERM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The service styled its own output. di rendered handler badges with Lip Gloss,
config imported the console package to draw --help, and the version checker
imported it for one message constant. Between them they pulled Lip Gloss into
every service package, so "the service does not depend on the TUI" was not
actually true.

- di now emits plain handler names - PROXY, MOCK, CACHE and so on - and
  internal/tui styles them when it renders a prefix. The badge lookup is the
  only place that decision is made.
- config takes a usage renderer as an option instead of reaching for the
  console; the CLI supplies it, which is the only place --help is reachable.
- The new-version notice moved to internal/version, where it is emitted.
- The container defaults to a null output and the composition root installs
  the real one, so di no longer imports internal/tui at all. Building the
  generate-certs command moved to the CLI for the same reason.
- The TUI's output adapter rendered every message into a throwaway CliOutput
  and pushed the resulting string. It is now just an io.Writer over a channel:
  the service hands the model structured events, internal/render decides what
  they say, and CliOutput decides how they look.
- Overriding CliOutput after it has been built now panics. It was a silent
  no-op that would have sent output to the terminal underneath the TUI. Two
  tests were asserting that footgun and now assert the contract instead.

internal/app, di, server, handler, config and version are now free of
charm.land entirely, and tests/architecture enforces it.

Verified byte-identical console output against the previous commit with ANSI
included, covering the proxy and mock badges, reload and a rejected config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three resources outlived what created them.

- Server.Restart shut the old listeners down before it knew the new ones could
  bind. Proxy.Restart carefully builds the new generation first, but that
  discipline was defeated one layer down: a port that could not be rebound left
  the server bound to nothing, and in headless mode that also ended the
  process, because every listener goroutine had exited and Wait returned.
  Restart now restores the previous targets when the new ones fail, so a
  rejected configuration costs a short interruption rather than the whole
  proxy. ErrRollbackFailed reports the case where the old ones cannot be
  restored either. Verified the test fails without the rollback.
- The upstream HTTP client is created per generation but was owned by nobody,
  so the idle connection pool of every superseded configuration survived until
  the process exited. It is now built by the Runtime and released with it.
- The per-host certificate cache is driven by traffic rather than by
  configuration - a {placeholder} mapping serves whatever host is asked for,
  and each entry is an RSA-2048 key pair. It is now bounded at 128 entries with
  oldest-first eviction.

Tests: a full service start/reload/shutdown cycle repeated 12 times asserts no
goroutine growth, which covers the config watcher, the request pump and the
listeners rather than the runtime alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant