@marcel91200 / caddy Mirror · read-only / commits / 8e237f19
Fork 0

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 +}