Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 02:15 UTC
windows: serialise service status updates (#8274)
* publish service status reported before the SCM handler attaches * notify: serialize Windows service status updates
Krish Singh Hura committed
Oct 24, 2026 at 21:57 UTC
8e237f195506741d3109ba7792e81d57a5f65036
4 files changed
+352
-34
notify/notify_windows.go
+75
-29
@@ -17,48 +17,101 @@ package notify
17
import (
18
"log"
19
"strings"
20
+ "sync"
21
22
"golang.org/x/sys/windows/svc"
23
)
24
24
-// globalStatus store windows service status, it can be
25
-// use to notify caddy status.
26
-var globalStatus chan<- svc.Status
25
+// statusMu guards the service status below. It is held while sending
26
+// to globalStatus so that every update reaches the SCM in order. The
27
+// receiver is the dispatcher loop in golang.org/x/sys/windows/svc,
28
+// which always accepts status updates and never takes this lock.
29
+var statusMu sync.Mutex
30
+
31
+var (
32
+ // globalStatus store windows service status, it can be
33
+ // use to notify caddy status.
34
+ globalStatus chan<- svc.Status
35
+
36
+ // currentStatus is the authoritative service status. It is kept
37
+ // while no channel is attached, so a status reported before the
38
+ // service handler starts is not lost.
39
+ currentStatus = svc.Status{State: svc.StartPending}
40
+
41
+ // stopping is set once shutdown begins; after that, only stop
42
+ // states are accepted.
43
+ stopping bool
44
+)
45
46
// SetGlobalStatus assigns the channel through which status updates
29
-// will be sent to the SCM. This is typically provided by the service
30
-// handler when the service starts.
47
+// will be sent to the SCM and publishes the current status to it.
48
+// Passing nil detaches the channel and resets the status, which the
49
+// service handler does before it returns.
50
func SetGlobalStatus(status chan<- svc.Status) {
51
+ statusMu.Lock()
52
+ defer statusMu.Unlock()
53
globalStatus = status
54
+ if status == nil {
55
+ currentStatus = svc.Status{State: svc.StartPending}
56
+ stopping = false
57
+ return
58
+ }
59
+ status <- currentStatus
60
+}
61
+
62
+// Interrogate republishes the current status, in response to the
63
+// SCM's Interrogate control request.
64
+func Interrogate() {
65
+ statusMu.Lock()
66
+ defer statusMu.Unlock()
67
+ if globalStatus != nil {
68
+ globalStatus <- currentStatus
69
+ }
70
+}
71
+
72
+// setStatus makes s the current status and publishes it if a channel
73
+// is attached. Once shutdown has begun, statuses other than StopPending
74
+// and Stopped are ignored, so a late Ready cannot undo it.
75
+func setStatus(s svc.Status) {
76
+ statusMu.Lock()
77
+ defer statusMu.Unlock()
78
+ setStatusLocked(s)
79
+}
80
+
81
+func setStatusLocked(s svc.Status) {
82
+ if stopping && s.State != svc.StopPending && s.State != svc.Stopped {
83
+ return
84
+ }
85
+ currentStatus = s
86
+ if globalStatus != nil {
87
+ globalStatus <- s
88
+ }
89
}
90
91
// Ready notifies the SCM that the service is fully running and ready
92
// to accept stop or shutdown control requests.
93
func Ready() error {
38
- if globalStatus != nil {
39
- globalStatus <- svc.Status{
40
- State: svc.Running,
41
- Accepts: svc.AcceptStop | svc.AcceptShutdown,
42
- }
43
- }
94
+ setStatus(svc.Status{
95
+ State: svc.Running,
96
+ Accepts: svc.AcceptStop | svc.AcceptShutdown,
97
+ })
98
return nil
99
}
100
101
// Reloading notifies the SCM that the service is entering a transitional
102
// state.
103
func Reloading() error {
50
- if globalStatus != nil {
51
- globalStatus <- svc.Status{State: svc.StartPending}
52
- }
104
+ setStatus(svc.Status{State: svc.StartPending})
105
return nil
106
}
107
108
// Stopping notifies the SCM that the service is in the process of stopping.
109
// This allows Windows to track the shutdown transition properly.
110
func Stopping() error {
59
- if globalStatus != nil {
60
- globalStatus <- svc.Status{State: svc.StopPending}
61
- }
111
+ statusMu.Lock()
112
+ defer statusMu.Unlock()
113
+ stopping = true
114
+ setStatusLocked(svc.Status{State: svc.StopPending})
115
return nil
116
}
117
@@ -66,10 +119,6 @@ func Stopping() error {
119
// identifier of [svc.State].
120
// The unknown states will be logged.
121
func Status(name string) error {
69
- if globalStatus == nil {
70
- return nil
71
- }
72
-
122
var state svc.State
123
var accepts svc.Accepted
124
accepts = 0
@@ -96,19 +145,16 @@ func Status(name string) error {
145
return nil
146
}
147
99
- globalStatus <- svc.Status{State: state, Accepts: accepts}
148
+ setStatus(svc.Status{State: state, Accepts: accepts})
149
return nil
150
}
151
152
// Error notifies the SCM that the service is stopping due to a failure,
153
// including a service-specific exit code.
154
func Error(err error, code int) error {
106
- if globalStatus != nil {
107
- globalStatus <- svc.Status{
108
- State: svc.StopPending,
109
- ServiceSpecificExitCode: uint32(code),
110
- }
111
- }
112
-
155
+ setStatus(svc.Status{
156
+ State: svc.StopPending,
157
+ ServiceSpecificExitCode: uint32(code),
158
+ })
159
return nil
160
}
notify/notify_windows_test.go
new
+118
@@ -0,0 +1,118 @@
1
+// Copyright 2015 Matthew Holt and The Caddy Authors
2
+//
3
+// Licensed under the Apache License, Version 2.0 (the "License");
4
+// you may not use this file except in compliance with the License.
5
+// You may obtain a copy of the License at
6
+//
7
+// http://www.apache.org/licenses/LICENSE-2.0
8
+//
9
+// Unless required by applicable law or agreed to in writing, software
10
+// distributed under the License is distributed on an "AS IS" BASIS,
11
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+// See the License for the specific language governing permissions and
13
+// limitations under the License.
14
+
15
+package notify
16
+
17
+import (
18
+ "testing"
19
+ "time"
20
+
21
+ "golang.org/x/sys/windows/svc"
22
+)
23
+
24
+func await(t *testing.T, done <-chan struct{}) {
25
+ t.Helper()
26
+ select {
27
+ case <-done:
28
+ case <-time.After(5 * time.Second):
29
+ t.Fatal("status operation did not return")
30
+ }
31
+}
32
+
33
+func checkStatus(t *testing.T, statuses <-chan svc.Status, want svc.Status) {
34
+ t.Helper()
35
+ select {
36
+ case got := <-statuses:
37
+ if got != want {
38
+ t.Fatalf("status: got %+v, want %+v", got, want)
39
+ }
40
+ case <-time.After(5 * time.Second):
41
+ t.Fatal("status was not published")
42
+ }
43
+}
44
+
45
+func TestStatusOwner(t *testing.T) {
46
+ running := svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
47
+ for _, tc := range []struct {
48
+ name string
49
+ prepare func()
50
+ want svc.Status
51
+ }{
52
+ {"initial", func() {}, svc.Status{State: svc.StartPending}},
53
+ {"ready", func() { _ = Reloading(); _ = Ready() }, running},
54
+ {"error", func() { _ = Ready(); _ = Error(nil, 7) }, svc.Status{State: svc.StopPending, ServiceSpecificExitCode: 7}},
55
+ {"explicit", func() { _ = Status("paused") }, svc.Status{State: svc.Paused, Accepts: svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPauseAndContinue}},
56
+ {"stopping", func() { _ = Stopping(); _ = Ready(); _ = Reloading() }, svc.Status{State: svc.StopPending}},
57
+ } {
58
+ t.Run(tc.name, func(t *testing.T) {
59
+ SetGlobalStatus(nil)
60
+ t.Cleanup(func() { SetGlobalStatus(nil) })
61
+ statuses := make(chan svc.Status, 2)
62
+ done := make(chan struct{})
63
+ go func() {
64
+ defer close(done)
65
+ tc.prepare() // No attached channel: must return without a receiver.
66
+ SetGlobalStatus(statuses)
67
+ Interrogate()
68
+ }()
69
+ await(t, done)
70
+ checkStatus(t, statuses, tc.want)
71
+ checkStatus(t, statuses, tc.want)
72
+ })
73
+ }
74
+}
75
+
76
+func TestStatusOrderWithUnbufferedReceiver(t *testing.T) {
77
+ SetGlobalStatus(nil)
78
+ statuses := make(chan svc.Status)
79
+ done := make(chan struct{})
80
+ go func() {
81
+ defer close(done)
82
+ SetGlobalStatus(statuses)
83
+ _ = Ready()
84
+ _ = Reloading()
85
+ _ = Error(nil, 3)
86
+ _ = Ready() // A reload error is not an irreversible shutdown.
87
+ _ = Stopping()
88
+ _ = Ready()
89
+ _ = Status("running")
90
+ _ = Status("stopped")
91
+ Interrogate()
92
+ }()
93
+ // Drain on assertion failure so a blocked sender can release statusMu.
94
+ t.Cleanup(func() {
95
+ for {
96
+ select {
97
+ case <-statuses:
98
+ case <-done:
99
+ SetGlobalStatus(nil)
100
+ return
101
+ case <-time.After(5 * time.Second):
102
+ t.Error("sender did not finish during cleanup")
103
+ return
104
+ }
105
+ }
106
+ })
107
+ for _, want := range []svc.Status{
108
+ {State: svc.StartPending},
109
+ {State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown},
110
+ {State: svc.StartPending},
111
+ {State: svc.StopPending, ServiceSpecificExitCode: 3},
112
+ {State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown},
113
+ {State: svc.StopPending}, {State: svc.Stopped}, {State: svc.Stopped},
114
+ } {
115
+ checkStatus(t, statuses, want)
116
+ }
117
+ await(t, done)
118
+}
service_windows.go
+9
-5
@@ -44,20 +44,24 @@ func init() {
44
type runner struct{}
45
46
func (runner) Execute(args []string, request <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
47
+ // All status updates go through notify, which publishes the current
48
+ // status now: StartPending, or whatever Caddy reported before the
49
+ // service started. Detach before returning, since the dispatcher
50
+ // stops receiving status updates once Execute returns.
51
notify.SetGlobalStatus(status)
48
- status <- svc.Status{State: svc.StartPending}
52
+ defer notify.SetGlobalStatus(nil)
53
50
- for {
51
- req := <-request
54
+ for req := range request {
55
// Unsupported service controls are ignored.
56
//exhaustive:ignore
57
switch req.Cmd {
58
case svc.Interrogate:
56
- status <- req.CurrentStatus
59
+ notify.Interrogate()
60
case svc.Stop, svc.Shutdown:
58
- status <- svc.Status{State: svc.StopPending}
61
+ _ = notify.Stopping()
62
exitProcessFromSignal("SIGINT")
63
return false, 0
64
}
65
}
66
+ return false, 0
67
}
service_windows_test.go
new
+150
@@ -0,0 +1,150 @@
1
+// Copyright 2015 Matthew Holt and The Caddy Authors
2
+//
3
+// Licensed under the Apache License, Version 2.0 (the "License");
4
+// you may not use this file except in compliance with the License.
5
+// You may obtain a copy of the License at
6
+//
7
+// http://www.apache.org/licenses/LICENSE-2.0
8
+//
9
+// Unless required by applicable law or agreed to in writing, software
10
+// distributed under the License is distributed on an "AS IS" BASIS,
11
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+// See the License for the specific language governing permissions and
13
+// limitations under the License.
14
+
15
+package caddy
16
+
17
+import (
18
+ "context"
19
+ "fmt"
20
+ "os"
21
+ "os/exec"
22
+ "strings"
23
+ "testing"
24
+ "time"
25
+
26
+ "github.com/caddyserver/caddy/v2/notify"
27
+ "golang.org/x/sys/windows/svc"
28
+)
29
+
30
+func TestRunnerStatusOrdering(t *testing.T) {
31
+ for _, order := range []string{"ready first", "runner first", "concurrent"} {
32
+ t.Run(order, func(t *testing.T) {
33
+ notify.SetGlobalStatus(nil)
34
+ t.Cleanup(func() { notify.SetGlobalStatus(nil) })
35
+ requests := make(chan svc.ChangeRequest)
36
+ statuses := make(chan svc.Status, 4)
37
+ done := make(chan struct{})
38
+ if order == "ready first" {
39
+ _ = notify.Ready()
40
+ }
41
+ go func() {
42
+ defer close(done)
43
+ runner{}.Execute(nil, requests, statuses)
44
+ }()
45
+ if order == "runner first" {
46
+ select {
47
+ case s := <-statuses:
48
+ if s.State != svc.StartPending {
49
+ t.Fatalf("initial status: %+v", s)
50
+ }
51
+ case <-time.After(5 * time.Second):
52
+ t.Fatal("runner did not start")
53
+ }
54
+ }
55
+ requestDone := make(chan struct{})
56
+ go func() {
57
+ defer close(requestDone)
58
+ defer close(requests)
59
+ if order != "ready first" {
60
+ _ = notify.Ready()
61
+ }
62
+ // The snapshot is deliberately stale: the owner must publish RUNNING.
63
+ requests <- svc.ChangeRequest{Cmd: svc.Interrogate, CurrentStatus: svc.Status{State: svc.StartPending}}
64
+ }()
65
+ t.Cleanup(func() {
66
+ select {
67
+ case <-done:
68
+ case <-time.After(5 * time.Second):
69
+ t.Error("runner did not return")
70
+ }
71
+ })
72
+ select {
73
+ case <-requestDone:
74
+ case <-time.After(5 * time.Second):
75
+ t.Fatal("notification or Interrogate blocked")
76
+ }
77
+ select {
78
+ case <-done:
79
+ case <-time.After(5 * time.Second):
80
+ t.Fatal("runner did not return")
81
+ }
82
+ close(statuses)
83
+ want := svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
84
+ var last svc.Status
85
+ ready := false
86
+ for s := range statuses {
87
+ ready = ready || s == want
88
+ if ready && s != want {
89
+ t.Fatalf("stale status after RUNNING: %+v", s)
90
+ }
91
+ last = s
92
+ }
93
+ if last != want {
94
+ t.Fatalf("last status: got %+v, want %+v", last, want)
95
+ }
96
+ returned := make(chan struct{})
97
+ go func() { _ = notify.Ready(); close(returned) }()
98
+ select {
99
+ case <-returned:
100
+ case <-time.After(5 * time.Second):
101
+ t.Fatal("Ready blocked after runner returned")
102
+ }
103
+ })
104
+ }
105
+}
106
+
107
+func TestRunnerStopRacesReady(t *testing.T) {
108
+ const envKey = "CADDY_TEST_SERVICE_CONTROL"
109
+ controls := map[string]svc.Cmd{"stop": svc.Stop, "shutdown": svc.Shutdown}
110
+ if name := os.Getenv(envKey); name != "" {
111
+ requests := make(chan svc.ChangeRequest)
112
+ statuses := make(chan svc.Status)
113
+ go runner{}.Execute(nil, requests, statuses)
114
+ go func() {
115
+ for s := range statuses {
116
+ fmt.Println(s.State)
117
+ }
118
+ }()
119
+ go func() {
120
+ for {
121
+ _ = notify.Ready()
122
+ }
123
+ }()
124
+ requests <- svc.ChangeRequest{Cmd: controls[name]}
125
+ time.Sleep(10 * time.Second)
126
+ os.Exit(5) // Stop/Shutdown must exit the process before this fallback.
127
+ }
128
+ for name := range controls {
129
+ t.Run(name, func(t *testing.T) {
130
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
131
+ defer cancel()
132
+ child := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestRunnerStopRacesReady$", "-test.count=1")
133
+ child.Env = append(os.Environ(), envKey+"="+name)
134
+ out, err := child.Output()
135
+ if err != nil {
136
+ t.Fatalf("child failed: %v; output:\n%s", err, out)
137
+ }
138
+ stopping := false
139
+ for line := range strings.FieldsSeq(string(out)) {
140
+ stopping = stopping || line == fmt.Sprint(svc.StopPending)
141
+ if stopping && line == fmt.Sprint(svc.Running) {
142
+ t.Fatalf("RUNNING after STOP_PENDING:\n%s", out)
143
+ }
144
+ }
145
+ if !stopping {
146
+ t.Fatalf("STOP_PENDING missing:\n%s", out)
147
+ }
148
+ })
149
+ }
150
+}