Skip to content

Commit a6a1a93

Browse files
author
Kenta Hayashi
committed
windows: route OS-shutdown SIGTERM through SCM to unblock svc.Run
At Windows OS shutdown, when the collector runs as a service child (spawned by start-amazon-cloudwatch-agent.exe), csrss delivers CTRL_SHUTDOWN (mapped to SIGTERM) to the collector but the SCM routes SERVICE_CONTROL_SHUTDOWN through the launcher, not directly here. kardianos/service v1.2.1's non-interactive windowsService.Run waits only on the SCM control channel and does not watch SIGTERM, so svc.Run never returns and the whole OS shutdown deadlocks until the platform's hard-timeout (~4-5 min: Event ID 6008 + Kernel-Power 41). sc stop / Stop-Service is unaffected. On a non-SIGHUP terminating OS signal, reloadLoop's signal goroutine now issues SERVICE_CONTROL_STOP against this process's own SCM entry (found by matching ProcessId, since kardianos's config Name -- 'telegraf' by default -- is not registered in SCM) via x/sys/windows/svc/mgr. That triggers kardianos's normal STOP path (prg.Stop -> close(stop)) and svc.Run returns cleanly, letting main return and OS shutdown proceed. If the SCM path is unavailable (not running as a Windows service, Connect/ListServices/OpenService/Control fails, or prg.Stop doesn't run within stopWaitTimeout=30s), the code falls back to setting a flag that handleTerminatingSignal reads after reloadLoop returns to call os.Exit(0) as a last resort so the OS shutdown is never blocked. No-op on non-Windows, in interactive/console mode, on the SCM STOP path (<-stop), and on SIGHUP reload. No new module dependencies (x/sys is already in go.mod). Tests cover the atomic flag, the dispatch function (cross-platform, via test seam), and the Windows-specific SCM logic and fallback exit (via fake scmManager/scmService and seams over exitFunc / isWinService / ownProcessID / scmConnectFunc). Validated on Windows Server 2022 (EC2 m5.xlarge, WaitToKillServiceTimeout=300000): baseline OS shutdown hung ~290s + 6008/41; with this patch the collector is stopped via its own SCM entry in ~34s (Event ID 6006, no 6008/41). sc stop still ~0.3s.
1 parent 706474e commit a6a1a93

6 files changed

Lines changed: 605 additions & 0 deletions

File tree

cmd/amazon-cloudwatch-agent/amazon-cloudwatch-agent.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ func reloadLoop(
126126
log.Println("I! Reloading Telegraf config")
127127
<-reload
128128
reload <- true
129+
} else {
130+
// Route non-SIGHUP terminating signal through the SCM;
131+
// see shutdown_signal_windows.go.
132+
handleTerminatingSignalDispatch(stop, stopWaitTimeout)
129133
}
130134
cancel()
131135
case <-stop:
@@ -439,6 +443,8 @@ func (p *program) run() {
439443
p.aggregatorFilters,
440444
p.processorFilters,
441445
)
446+
// Windows-only fallback: exit if SCM STOP was not taken.
447+
handleTerminatingSignal()
442448
}
443449
func (p *program) Stop(_ service.Service) error {
444450
close(stop)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package main
5+
6+
import (
7+
"log"
8+
"sync/atomic"
9+
"time"
10+
)
11+
12+
// See shutdown_signal_windows.go for the workaround's purpose.
13+
14+
// stopWaitTimeout bounds how long the signal goroutine waits for prg.Stop
15+
// after requestSCMStop is accepted. Declared as var for testability.
16+
var stopWaitTimeout = 30 * time.Second
17+
18+
// terminatingSignalReceived flags that the SCM STOP path was NOT taken;
19+
// handleTerminatingSignal then os.Exit(0)s so svc.Run cannot block OS
20+
// shutdown. Accessed atomically.
21+
var terminatingSignalReceived atomic.Bool
22+
23+
// requestSCMStopFn is a test seam over requestSCMStop.
24+
var requestSCMStopFn = requestSCMStop
25+
26+
// handleTerminatingSignalDispatch is called by the signal goroutine on a
27+
// non-SIGHUP terminating signal. Tries SCM STOP; falls back to setting the
28+
// flag on failure or timeout.
29+
func handleTerminatingSignalDispatch(stopCh <-chan struct{}, timeout time.Duration) {
30+
if requestSCMStopFn() {
31+
timer := time.NewTimer(timeout)
32+
defer timer.Stop()
33+
select {
34+
case <-stopCh:
35+
case <-timer.C:
36+
log.Println("W! Windows service: SCM stop did not deliver in time; forcing fallback")
37+
terminatingSignalReceived.Store(true)
38+
}
39+
} else {
40+
terminatingSignalReceived.Store(true)
41+
}
42+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
//go:build !windows
5+
6+
package main
7+
8+
// requestSCMStop and handleTerminatingSignal are no-ops on non-Windows.
9+
func requestSCMStop() bool { return false }
10+
func handleTerminatingSignal() {}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package main
5+
6+
import (
7+
"sync"
8+
"testing"
9+
"time"
10+
)
11+
12+
// ---- terminatingSignalReceived atomic flag ---------------------------
13+
14+
func TestTerminatingSignalReceived_DefaultUnset(t *testing.T) {
15+
t.Cleanup(func() { terminatingSignalReceived.Store(false) })
16+
if terminatingSignalReceived.Load() {
17+
t.Fatal("terminatingSignalReceived was true before any Store()")
18+
}
19+
}
20+
21+
func TestTerminatingSignalReceived_SetLoad(t *testing.T) {
22+
t.Cleanup(func() { terminatingSignalReceived.Store(false) })
23+
terminatingSignalReceived.Store(true)
24+
if !terminatingSignalReceived.Load() {
25+
t.Fatal("terminatingSignalReceived was false after Store(true)")
26+
}
27+
}
28+
29+
func TestTerminatingSignalReceived_Race(t *testing.T) {
30+
t.Cleanup(func() { terminatingSignalReceived.Store(false) })
31+
const N = 64
32+
var wg sync.WaitGroup
33+
wg.Add(N)
34+
for i := 0; i < N; i++ {
35+
go func(i int) {
36+
defer wg.Done()
37+
if i%2 == 0 {
38+
terminatingSignalReceived.Store(true)
39+
} else {
40+
_ = terminatingSignalReceived.Load()
41+
}
42+
}(i)
43+
}
44+
wg.Wait()
45+
}
46+
47+
// ---- handleTerminatingSignalDispatch ---------------------------------
48+
49+
// withRequestSCMStopFn temporarily overrides requestSCMStopFn and resets
50+
// the terminatingSignalReceived flag for a test.
51+
func withRequestSCMStopFn(t *testing.T, fn func() bool) {
52+
t.Helper()
53+
old := requestSCMStopFn
54+
requestSCMStopFn = fn
55+
terminatingSignalReceived.Store(false)
56+
t.Cleanup(func() {
57+
requestSCMStopFn = old
58+
terminatingSignalReceived.Store(false)
59+
})
60+
}
61+
62+
// When requestSCMStop returns false (no SCM path), dispatch must set the
63+
// fallback flag so handleTerminatingSignal can os.Exit later.
64+
func TestDispatch_SCMUnavailable_SetsFallbackFlag(t *testing.T) {
65+
withRequestSCMStopFn(t, func() bool { return false })
66+
stopCh := make(chan struct{})
67+
handleTerminatingSignalDispatch(stopCh, 5*time.Millisecond)
68+
if !terminatingSignalReceived.Load() {
69+
t.Fatal("expected fallback flag set when SCM path is unavailable")
70+
}
71+
}
72+
73+
// When requestSCMStop returns true and close(stop) fires before the
74+
// timeout, dispatch must NOT set the fallback flag (SCM path OK).
75+
func TestDispatch_SCMSuccess_NoFallbackFlag(t *testing.T) {
76+
withRequestSCMStopFn(t, func() bool { return true })
77+
stopCh := make(chan struct{})
78+
go func() {
79+
time.Sleep(10 * time.Millisecond)
80+
close(stopCh)
81+
}()
82+
handleTerminatingSignalDispatch(stopCh, 500*time.Millisecond)
83+
if terminatingSignalReceived.Load() {
84+
t.Fatal("expected NO fallback flag when SCM path completes cleanly")
85+
}
86+
}
87+
88+
// When requestSCMStop returns true but close(stop) never fires within the
89+
// timeout, dispatch must set the fallback flag.
90+
func TestDispatch_SCMAcceptedButTimeout_SetsFallbackFlag(t *testing.T) {
91+
withRequestSCMStopFn(t, func() bool { return true })
92+
stopCh := make(chan struct{}) // never closed
93+
handleTerminatingSignalDispatch(stopCh, 20*time.Millisecond)
94+
if !terminatingSignalReceived.Load() {
95+
t.Fatal("expected fallback flag set after SCM path timeout")
96+
}
97+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
//go:build windows
5+
6+
package main
7+
8+
import (
9+
"log"
10+
"os"
11+
12+
winsvc "golang.org/x/sys/windows/svc"
13+
winmgr "golang.org/x/sys/windows/svc/mgr"
14+
)
15+
16+
// Workaround for Windows OS-shutdown deadlock (Event 6008 / Kernel-Power 41):
17+
// at OS shutdown, csrss delivers CTRL_SHUTDOWN (=> SIGTERM) to the collector
18+
// but SERVICE_CONTROL_SHUTDOWN goes to the launcher, not us. kardianos'
19+
// windowsService.Run only watches the SCM channel, so svc.Run stays blocked.
20+
// requestSCMStop finds this process's SCM entry by matching ProcessId (since
21+
// kardianos' config Name -- "telegraf" by default -- is not in SCM) and
22+
// issues STOP so kardianos' normal path can drive svc.Run to return.
23+
24+
// scmManager / scmService: minimal interfaces so tests can substitute fakes.
25+
type scmManager interface {
26+
Disconnect() error
27+
ListServices() ([]string, error)
28+
OpenService(name string) (scmService, error)
29+
}
30+
type scmService interface {
31+
Close() error
32+
Query() (winsvc.Status, error)
33+
Control(cmd winsvc.Cmd) (winsvc.Status, error)
34+
}
35+
36+
type realSCMManager struct{ m *winmgr.Mgr }
37+
38+
func (r *realSCMManager) Disconnect() error { return r.m.Disconnect() }
39+
func (r *realSCMManager) ListServices() ([]string, error) { return r.m.ListServices() }
40+
func (r *realSCMManager) OpenService(name string) (scmService, error) {
41+
s, err := r.m.OpenService(name)
42+
if err != nil {
43+
return nil, err
44+
}
45+
return &realSCMService{s: s}, nil
46+
}
47+
48+
type realSCMService struct{ s *winmgr.Service }
49+
50+
func (r *realSCMService) Close() error { return r.s.Close() }
51+
func (r *realSCMService) Query() (winsvc.Status, error) { return r.s.Query() }
52+
func (r *realSCMService) Control(cmd winsvc.Cmd) (winsvc.Status, error) { return r.s.Control(cmd) }
53+
54+
// Test seams (single-writer via t.Cleanup, single-reader in shutdown path).
55+
var (
56+
exitFunc = os.Exit
57+
isWinService = func() bool { return windowsRunAsService() }
58+
ownProcessID = func() uint32 { return uint32(os.Getpid()) }
59+
scmConnectFunc = func() (scmManager, error) {
60+
m, err := winmgr.Connect()
61+
if err != nil {
62+
return nil, err
63+
}
64+
return &realSCMManager{m: m}, nil
65+
}
66+
)
67+
68+
// findOwnSCMServiceName returns the SCM service whose ProcessId matches
69+
// this process. Empty string if not found. Entries that cannot be opened
70+
// or queried are skipped.
71+
func findOwnSCMServiceName(m scmManager) (string, error) {
72+
names, err := m.ListServices()
73+
if err != nil {
74+
return "", err
75+
}
76+
myPID := ownProcessID()
77+
for _, name := range names {
78+
s, err := m.OpenService(name)
79+
if err != nil {
80+
continue
81+
}
82+
status, err := s.Query()
83+
s.Close()
84+
if err == nil && status.ProcessId == myPID {
85+
return name, nil
86+
}
87+
}
88+
return "", nil
89+
}
90+
91+
// requestSCMStop issues SERVICE_CONTROL_STOP against this process's own
92+
// SCM entry. Returns true on success.
93+
func requestSCMStop() bool {
94+
if !isWinService() {
95+
return false
96+
}
97+
m, err := scmConnectFunc()
98+
if err != nil {
99+
log.Printf("W! Windows service: SCM Connect failed: %v", err)
100+
return false
101+
}
102+
defer m.Disconnect()
103+
104+
scmName, err := findOwnSCMServiceName(m)
105+
if err != nil {
106+
log.Printf("W! Windows service: SCM ListServices failed: %v", err)
107+
return false
108+
}
109+
if scmName == "" {
110+
log.Printf("W! Windows service: no SCM service found for own PID=%d", ownProcessID())
111+
return false
112+
}
113+
114+
s, err := m.OpenService(scmName)
115+
if err != nil {
116+
log.Printf("W! Windows service: SCM OpenService(%q) failed: %v", scmName, err)
117+
return false
118+
}
119+
defer s.Close()
120+
if _, err := s.Control(winsvc.Stop); err != nil {
121+
log.Printf("W! Windows service: SCM Control(Stop) on %q failed: %v", scmName, err)
122+
return false
123+
}
124+
log.Printf("I! Windows service: SCM Control(Stop) accepted on %q; waiting for prg.Stop", scmName)
125+
return true
126+
}
127+
128+
// handleTerminatingSignal is the fallback: exit the process iff the flag is
129+
// set (SCM path unavailable) and we're running as a Windows service.
130+
func handleTerminatingSignal() {
131+
if !terminatingSignalReceived.Load() {
132+
return
133+
}
134+
if !isWinService() {
135+
return
136+
}
137+
log.Println("I! Windows service: SCM stop path unavailable; exiting to release svc.Run")
138+
exitFunc(0)
139+
}

0 commit comments

Comments
 (0)