CI on Linux flagged a race in TestStartWorker_HealthCallback_StatusChange:
the test read ui.updates while the healthchecker's per-port goroutine
(spawned by Register at checker.go:164) was still running and could
fire UpdateStatus through notifyStatusChange.
The earlier mutex on MockStatusUpdater protected the writes; the read
side was unprotected, and the goroutine had not finished by the time
the test started ranging over the slice on slower runners.
Fix:
- call healthChecker.Unregister(fwd.ID()) to drain the per-port
goroutine before reading
- hold ui.mu around the slice read for belt-and-suspenders happens-
before, regardless of goroutine timing
Verified locally with go test -race -count=20 on the targeted test
and -count=3 on the full forward package.
The mock recorded calls into unprotected slices while being invoked
concurrently from the test goroutine and from the health-checker
callback registered by Manager.startWorker (and the watchdog hung
callback). go test -race -count=20 reliably tripped the race detector
on TestStartWorker_HealthCallback_StatusChange.
Wrap the slice writes in a sync.Mutex. Read-side code in tests reads
only after manager.Stop() drains the background goroutines (Stop's
wg.Wait establishes a happens-before edge), so direct reads remain
race-free.
Previously a second Stop() call would panic from a double-close on
eventBus and re-stop the healthChecker/watchdog whose contexts had
already been cancelled. Wrapping the body in sync.Once makes
sequential and concurrent double-Stop safe.
TestManager_Stop_Idempotent covers both paths.
P0 #2 — currentConfig data race
Manager.currentConfig was written without locking in Start/Reload but
read from the health-checker callback goroutine. All accesses now go
through workersMu (read or write as appropriate).
P0 #3 — Reload kills health checker permanently
Reload's zero-forward branch called m.Stop() which tore down the
health checker, watchdog, and event bus. After that, EnableForward
silently registered callbacks against dead components. Now the branch
stops only the running workers; the supervisory infrastructure stays
alive across config changes.
P0 #4 — rest.Config write-write race
executePortForward was mutating .Dial on the cached *rest.Config
shared by all forwards in the same kube context. Cloning the config
with rest.CopyConfig before mutation isolates per-forward dialers.
P0 #5 — ForwardWorker.Stop() double-close panic
close(w.stopChan) is now wrapped in sync.Once, so concurrent Stop
calls (Manager.Stop racing stopWorkerInternal) are safe.
New tests in internal/forward/concurrency_test.go exercise each fix
under -race: 16 concurrent worker Stops, repeated sequential Stops,
empty-Reload preserves infra pointers, and concurrent currentConfig
read/write.
- [x] Add golangci-lint v2 configuration with formatters section
- [x] Reorganize linters-settings under linters section
- [x] Replace if-else chains with switch statements for clarity
- [x] Wrap all ignored error returns with `_ = ` pattern
- [x] Add OSC 8 hyperlink helper function for clickable ports
- [x] Add blank line in table styling function
- [x] Remove unnecessary type assertion in test
When user starts kportal for the first time, and there is no config file,
kportal should create an empty config file with default values and empty
forwarding rules, so that user can easily edit the config file and add their
own rules.
* Fix enter misbehaving.
* Cleanup after previous tui implementation.
* Fix race condition and improve logging
* Add filtering of the namespaces by text input in the wizard UI