Skip to content

Migration: controls signals are opt-in (go/controls v0.2.0)

gitlab.com/phpboyscout/go/controls v0.2.0 stops registering an OS signal handler by default, removes WithoutSignals, and makes ErrShutdown the cause of every stop the controller drives.

GTB picks this up from v0.35.0. Most tools need no change at all; some need one line deleted.

Why

signal.Notify is additive: every registered channel receives a copy of every signal. GTB's root command has translated SIGINT/SIGTERM into cancellation of cmd.Context() since v0.17.0, and the controller registered its own handler as well. Both fired on a single Ctrl-C, so two independent shutdown drivers ran concurrently.

The visible symptom was duplicated "received signal" logging. The real damage was that context.Cause(ctx) == controls.ErrShutdown — the documented way for a service to recognise an orderly teardown — became a race. When the parent context cancelled first, the child inherited context.Canceled and the controller's own cancel(ErrShutdown) was a no-op, because the first cancellation wins.

What changed

  • No handler by default. NewController no longer calls signal.Notify.
  • WithSignals() added, for a standalone main where the controller genuinely is the outermost layer.
  • WithoutSignals() removed — not deprecated. A no-op option claiming to disable something already off is its own trap, so this is a compile error rather than a silent behaviour change.
  • ErrShutdown is unconditional. The controller owns its cancellation and treats the parent context's completion as a trigger for the normal shutdown sequence. A parent cancellation, an expired parent deadline, a direct Stop() and a signal all now report ErrShutdown.

How to migrate

If your controller is inside a GTB command

Delete the option; add nothing.

 controller := controls.NewController(cmd.Context(),
-    controls.WithoutSignals(),
     controls.WithLogger(log),
 )

Do not add WithSignals() here. The root command owns signals and cancels cmd.Context(); the controller observes that cancellation and shuts down through its normal sequence. Adding a handler would reintroduce the exact race this release removes.

If you never passed WithoutSignals(), you were affected by the defect and this release fixes it with no change on your side.

If your controller is in a standalone main

Opt in explicitly:

-controller := controls.NewController(ctx)
+controller := controls.NewController(ctx, controls.WithSignals())

In tests

Delete the option. Signals are already off, so a test needs nothing:

-c := controls.NewController(ctx, controls.WithoutSignals())
+c := controls.NewController(ctx)

Behaviour change to be aware of

A service can no longer distinguish an upstream cancellation from a controlled stop by inspecting the context cause — both report ErrShutdown.

That distinction was never dependable; which cause won was the race being fixed. If a service genuinely needs to know why it is stopping, watch the parent context yourself rather than inferring it from the cause.

One improvement comes with it: an expired parent deadline now produces an orderly teardown bounded by WithShutdownTimeout, instead of handing every WithStop a context that was already dead on arrival.

If your tool must own signals itself

GTB's root command can be told to stand down:

gtbRoot.Execute(rootCmd, p, gtbRoot.WithoutSignals())

A tool that opts out takes on the whole contract in the framework's place — cancelling the command context, flushing buffered telemetry before exit, and choosing an exit code. This is deliberately rare; needing a controls.Controller is not a reason to reach for it.