Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ca27f60
fix(darwin): probe interception after the host wakes
cuonglm Aug 19, 2026
9d72486
dns proxy: DNS64 synthesis for IPv6-only networks without CLAT
Sep 2, 2026
e109c9b
clientinfo: skip unavailable Ubios Mongo discovery
Sep 4, 2026
3cd20a3
feat(cli): port provisioning hardening to v1.0
awmwong Sep 9, 2026
c75989a
darwin: route Zscaler's DNS echo name through the OS resolver
Sep 15, 2026
7da2507
cli: route organization Internal Domains from managed config
cuonglm Sep 15, 2026
799f14e
macOS: repair network recovery and improve diagnostics
Sep 16, 2026
d169054
dns_intercept: don't write a GP sibling on failed NRPT handback
cuonglm Sep 11, 2026
8934e09
feat(cli): keep a log journal on disk with file headers and an upload…
awmwong Sep 18, 2026
8ae09ac
feat(cli): put the network state of the host in the log journal
awmwong Sep 18, 2026
f823977
fix(net): close losing dials and retire replaced transports
Sep 18, 2026
3127212
fix(cli): release runtime resources across startup and shutdown
Sep 18, 2026
9db6de0
fix(mobile): serialize controller session teardown
Sep 18, 2026
6eed093
fix(cli): skip DNS restoration quietly when the interface is gone
cuonglm Sep 21, 2026
5410cc5
test(cli): cover the DNS reset failure paths through the real code
cuonglm Sep 21, 2026
12ab2a0
feat: recognize every NextDNS endpoint under nextdns.io
cuonglm Sep 21, 2026
f52dfd0
fix(cli): carry the review fixes of the master port to v1.0
awmwong Sep 21, 2026
72507f3
test(cli): verify closed log files portably on Windows
Sep 21, 2026
c7048f7
Scope OS-only recovery and retain DNS-target diagnostics
Sep 23, 2026
e34fef3
fix(cli): bound the DNS loop check probe by upstream timeout
cuonglm Sep 23, 2026
48fa94e
test(cli): scope probe journal assertions to their own log lines
cuonglm Sep 23, 2026
7613c05
macOS: gate DNS-target fallback on native CLAT and diagnose IPv6 bloc…
Sep 23, 2026
d779b53
macOS: use the networksetup service name for USB CLAT targets
Sep 24, 2026
44a638d
macOS: share network service-order parsing
Sep 24, 2026
462f912
Harden DNS64, VPN routing and DNS recovery ownership
Sep 24, 2026
db228a8
fix(cli): fix three log journal defects and test Windows with the rac…
awmwong Oct 1, 2026
9064f0e
docs: known issue, --config start stalls DNS ~100 s behind HasIPv6's …
alexelisenko Oct 2, 2026
5978ddf
cli: add network fallback and explicit-only Internal Domain modes
cuonglm Oct 5, 2026
806683d
test(cli): take the upload time bound before reading the upload
cuonglm Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ ctrld-*
cmd/cli/rsrc_*.syso
ctrld
ctrld.exe

# Local planning artifacts - never commit
/SPEC.md
/tasks/
/SPEC-*.md
206 changes: 0 additions & 206 deletions SPEC.md

This file was deleted.

157 changes: 157 additions & 0 deletions cmd/cli/api_reload_shutdown_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package cli

import (
"context"
"encoding/base64"
"net"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/stretchr/testify/require"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)

func TestAPIConfigReloadShutdown(t *testing.T) {
const env = "CTRLD_TEST_API_RELOAD_SHUTDOWN"
mode := os.Getenv(env)
if mode == "" {
for _, mode := range []string{"stop-exclude", "abort-exclude", "stop-custom", "abort-custom", "stop-internal", "abort-internal", "stop-fetch", "abort-fetch", "live-exclude", "live-custom", "live-internal"} {
t.Run(mode, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestAPIConfigReloadShutdown$", "-test.v")
cmd.Env = append(os.Environ(), env+"="+mode)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "%s", out)
})
}
return
}
cdUID = "testuid"
version = "dev"
p := &prog{
cfg: &ctrld.Config{}, rc: &controld.ResolverConfig{},
stopCh: make(chan struct{}), runAbortCh: make(chan struct{}),
apiForceReloadCh: make(chan struct{}), apiReloadCh: make(chan *ctrld.Config),
}
rc := &controld.ResolverConfig{Exclude: []string{"internal.test"}}
if strings.HasSuffix(mode, "internal") {
rc.Exclude = nil
rc.SplitDNS = []controld.SplitDNS{{Domain: "internal.test", Mode: controld.SplitDNSModeOS}}
}
if strings.HasSuffix(mode, "custom") {
rc.Ctrld.CustomConfig = base64.StdEncoding.EncodeToString([]byte(`[listener.0]
ip = "127.0.0.1"
port = 5354
[network.0]
cidrs = ["127.0.0.0/8"]
[upstream.0]
type = "legacy"
endpoint = "127.0.0.1"
`))
}
entered, canceled := make(chan struct{}), make(chan struct{})
fetchResolverConfigForReload = func(ctx context.Context, _ *controld.ResolverConfigRequest, _ bool) (*controld.ResolverConfig, error) {
close(entered)
if strings.HasSuffix(mode, "fetch") {
<-ctx.Done()
close(canceled)
// A late successful response must not schedule a reload after stop.
return rc, nil
}
return rc, nil
}
done := make(chan struct{})
go func() { p.apiConfigReload(); close(done) }()
p.apiForceReloadCh <- struct{}{}
awaitShutdown(t, entered)
if strings.HasPrefix(mode, "live-") {
select {
case got := <-p.apiReloadCh:
if strings.HasSuffix(mode, "custom") {
require.NotNil(t, got)
require.Equal(t, "127.0.0.1", got.Listener["0"].IP)
} else {
require.Nil(t, got)
}
case <-time.After(2 * time.Second):
t.Fatal("running API loop did not deliver its reload")
}
} else if !strings.HasSuffix(mode, "fetch") {
marker := "exclude list changes detected, reloading"
if strings.HasSuffix(mode, "custom") {
marker = "custom config changes detected, reloading"
} else if strings.HasSuffix(mode, "internal") {
marker = "internal domain changes detected, reloading"
}
require.Eventually(t, func() bool { return strings.Contains(logOutput.String(), marker) }, time.Second, time.Millisecond)
}
if strings.HasPrefix(mode, "abort-") {
close(p.runAbortCh)
} else {
close(p.stopCh)
}
awaitShutdown(t, done)
if strings.HasSuffix(mode, "fetch") {
awaitShutdown(t, canceled)
require.Empty(t, p.rc.Exclude, "late fetch must not update run state")
}
}

func TestForceFetchingAPIStopsWithoutReceiver(t *testing.T) {
old := cdUID
cdUID = "testuid"
t.Cleanup(func() { cdUID = old })
for _, abort := range []bool{false, true} {
for _, delivered := range []bool{false, true} {
p := &prog{cfg: &ctrld.Config{}, stopCh: make(chan struct{}), runAbortCh: make(chan struct{}), apiForceReloadCh: make(chan struct{})}
p.forceFetchingAPI("testuid.verify.controld.com")
done := p.apiForceReloadGroup.DoChan("force_sync_api", func() (interface{}, error) { t.Error("lost active force-reload worker"); return nil, nil })
if delivered {
select {
case <-p.apiForceReloadCh:
case <-time.After(time.Second):
t.Fatal("no force-reload signal")
}
}
if abort {
close(p.runAbortCh)
} else {
close(p.stopCh)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("force-reload send/cooldown survived stop")
}
}
}
}

type closeCountingLogConn struct {
net.Conn
closes atomic.Int32
}

func (c *closeCountingLogConn) Close() error { c.closes.Add(1); return nil }
func (*closeCountingLogConn) Write(b []byte) (int, error) { return len(b), nil }

func TestStartupLogConnectionCloseOwnership(t *testing.T) {
c := &closeCountingLogConn{}
p := &prog{cfg: &ctrld.Config{}, dnsWatcherStopCh: make(chan struct{}), logConn: c}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() { defer wg.Done(); p.notifyExitToLogServer(); p.closeLogConn() }()
}
wg.Wait()
require.NoError(t, p.shutdown())
require.EqualValues(t, 1, c.closes.Load())
require.Nil(t, p.logConn)
}
Loading
Loading