Skip to content

chore: upgrade to Go 1.26 and modernize - #6

Merged
jakthom merged 2 commits into
mainfrom
chore/go-1.26-modernization
Jul 31, 2026
Merged

chore: upgrade to Go 1.26 and modernize#6
jakthom merged 2 commits into
mainfrom
chore/go-1.26-modernization

Conversation

@jakthom

@jakthom jakthom commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Upgrades the module to Go 1.26 and takes up the standard-library features the code predated. Two commits, separable for review.

1. chore: upgrade to Go 1.26 and modernize

go.mod, CI and the README move to 1.26.

I checked what 1.26 actually offers here by diffing the 1.25.12 and 1.26.5 source trees and reading the compiler's version gates, rather than working from memory. Two results worth recording:

  • new(expr) is 1.26's only language change and has no site in this module. No pointer-helper generics exist, and the &cloned returns in event.go mutate the local first.
  • errors.AsType does not apply — this module uses errors.Is exclusively and never errors.As.

So the reductions come from slices, maps, cmp and the min/max builtins, which the code had not yet adopted:

  • slices/maps replace hand-rolled clone, contains, equal, sort and dedup loops
  • slices.Sorted(maps.Keys(...)) replaces collect-then-sort for deterministic map iteration
  • min/max replace clamp blocks; Duration.Abs replaces a manual negate
  • sync.WaitGroup.Go, range-over-int and strings.SplitSeq in tests
  • a generic asEvents helper collapses six copies of the same widen-to-interface loop in gesture and action
  • ControlOf extracts the stick/trigger mapping it repeated four times across value and pointer cases

2. refactor: model the safety lifecycle as a state machine

Adopts github.com/open-ships/statemachine for safety.Guard's operator lifecycle — the one place in the repo with a genuine event-driven state machine.

Three booleans (armed, latched, estop) mutated by hand in five places become one field with three states, moved by five commands through a compiled transition table. Arm's two refusals — no bound controller, and a latched e-stop that only Reset may clear — are now Guards on table rows rather than conditionals inside the method.

Mapping the booleans onto a table showed latched was written in four places and read in none. It is removed.

Arm still reports ErrUnbound and its own emergency-stop error rather than the machine's refusal, so the package's public error contract is unchanged for callers comparing with ==.

Deliberately not changed

  • omitempty on nested struct fields (event.go, recorder.go) are genuine no-ops that omitzero would fix, but they are hashed into the audit record chain and compat_test.go pins format versions 1–3. Changing them breaks verification of logs already on disk — that is a format-version bump, not a cleanup.
  • append([]byte(nil), x...) in audit/recorder.go and merkle.go collapses empty to nil, which HMACKey depends on: it is tested with != nil to select the HMAC chain, and WithHMAC builds a non-nil empty key rejected later. slices.Clone preserves empty and would change chain selection.
  • cloneEvent's type switch is the largest remaining block. Giving all nine event types a CloneEvent() method collapses it to ~6 lines, but value-receiver methods are in *T's method set, so pointer events would return as values — silently changing the dynamic type for external processors publishing *ButtonEvent. A generic helper preserves types but turns one itab-dispatched switch into a linear scan of nine closures, on a per-event per-subscriber dispatch path. The duplication is load-bearing.

Verification

Both commits build and pass independently. Full matrix on the final tree: darwin/linux/windows (plus linux-arm64), CGO_ENABLED 0 and 1, go vet per platform, gofmt, and -race. modernize reports nothing outside the omitempty items above.

Because the two behavior-sensitive rewrites had thin existing coverage, each was checked against a verbatim transcription of the code it replaced, then the check was deleted:

  • the safety lifecycle, over every command sequence to depth six, bound and unbound, comparing Arm's error and both predicates Evaluate reads
  • ControlOf, over all six arms including invalid IDs and nil

Note

stickControl/triggerControl now exist as identical unexported copies in teleop, gesture and action. Unifying them means exporting new public API, so I left the copies — happy to do it if wanted.

jakthom added 2 commits July 31, 2026 06:23
Move the go directive and CI to 1.26, then adopt the standard-library
features the code predated.

Go 1.26's only language change is new(expr), which has no site here: no
pointer-helper generics exist, and the &cloned returns in event.go mutate
the local first. errors.AsType likewise does not apply, since this module
uses errors.Is exclusively. The reductions therefore come from slices,
maps, cmp and the min/max builtins, which the code had not yet taken up.

  - slices/maps replace hand-rolled clone, contains, equal, sort and
    dedup loops
  - slices.Sorted(maps.Keys(...)) replaces collect-then-sort for
    deterministic map iteration
  - min/max replace clamp blocks; Duration.Abs replaces a manual negate
  - sync.WaitGroup.Go, range-over-int and strings.SplitSeq in tests
  - a generic asEvents helper collapses six copies of the same
    widen-to-interface loop in gesture and action
  - ControlOf extracts the stick and trigger mapping it repeated four
    times across its value and pointer cases

Two idioms are deliberately kept. The append([]byte(nil), x...) clones in
audit/recorder.go and merkle.go collapse empty to nil, which HMACKey
depends on: it is tested with != nil to select the HMAC chain, and
WithHMAC builds a non-nil empty key that is rejected later. And the
omitempty tags on nested struct fields, though genuine no-ops, are hashed
into the audit record chain, so changing them to omitzero would break
verification of logs already on disk.

Verified across darwin, linux and windows, with and without cgo, under
the race detector.
Guard tracked the operator's standing intent in three booleans — armed,
latched and estop — mutated by hand in five places. The invariants
between them were implicit: an emergency stop had to also force armed to
false, and every assignment restated that by convention rather than by
construction.

Replace them with a compiled transition table from
github.com/open-ships/statemachine. The lifecycle is now one field with
three states (idle, armed, stopped) moved only by five commands: arm,
disarm, trip, stop and reset. Arm's two refusals — no bound controller,
and a latched emergency stop that only Reset may clear — become Guards on
table rows rather than conditionals buried in the method.

Mapping the booleans onto the table showed that latched was written in
four places and read in none, so it is removed.

The library holds no state, so the current lifecycle stays on the Guard
under the Guard's own mutex, which already protects everything a
transition touches.

Arm still reports ErrUnbound and its own emergency-stop error rather than
the machine's refusal. Fire wraps statemachine.ErrNotPermitted, and
letting that sentinel into this package's public error contract would
break callers comparing with ==.

Guard.Evaluate's published State (safe, armed, live) is unchanged: it is
derived per call from live conditions, which is a separate concept from
the lifecycle this table models.

Behavior was checked against a verbatim transcription of the replaced
boolean logic over every command sequence up to depth six, bound and
unbound, comparing Arm's error and both predicates Evaluate reads.
@jakthom
jakthom merged commit 0ede7dc into main Jul 31, 2026
6 checks passed
@jakthom
jakthom deleted the chore/go-1.26-modernization branch July 31, 2026 10:27
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