Merge lp:~chipaca/ubuntu-push/actual-client-v0 into lp:ubuntu-push

Proposed by John Lenton
Status: Rejected
Rejected by: John Lenton
Proposed branch: lp:~chipaca/ubuntu-push/actual-client-v0
Merge into: lp:ubuntu-push
Prerequisite: lp:~chipaca/ubuntu-push/client-v0
Diff against target: 528 lines (+199/-43)
7 files modified
bus/connectivity/connectivity.go (+7/-7)
bus/connectivity/connectivity_test.go (+6/-6)
client.json (+9/-0)
client/client.go (+141/-0)
client/session/session.go (+2/-2)
client/session/session_test.go (+22/-22)
util/redialer.go (+12/-6)
To merge this branch: bzr merge lp:~chipaca/ubuntu-push/actual-client-v0
Reviewer Review Type Date Requested Status
Ubuntu Push Hackers Pending
Review via email: mp+203236@code.launchpad.net

Commit message

v0 of the actual client. Not covered by tests yet.

Description of the change

v0 of the actual client. Not covered by tests yet.

To post a comment you must log in.
28. By John Lenton

moved client config to etc

29. By John Lenton

merged pipeline; conflict in the redialer fixed

30. By John Lenton

merged trunk, resolved conflict in redialer

Revision history for this message
Samuele Pedroni (pedronis) wrote :

I think main is testable if it gets split in some parts

Unmerged revisions

30. By John Lenton

merged trunk, resolved conflict in redialer

29. By John Lenton

merged pipeline; conflict in the redialer fixed

28. By John Lenton

moved client config to etc

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bus/connectivity/connectivity.go'
2--- bus/connectivity/connectivity.go 2014-01-23 14:33:08 +0000
3+++ bus/connectivity/connectivity.go 2014-01-27 18:11:05 +0000
4@@ -34,21 +34,21 @@
5
6 // the configuration for ConnectedState, with the idea that you'd populate it
7 // from a config file.
8-type Config struct {
9+type ConnectivityConfig struct {
10 // how long to wait after a state change to make sure it's "stable"
11 // before acting on it
12- StabilizingTimeout config.ConfigTimeDuration
13+ StabilizingTimeout config.ConfigTimeDuration `json:"stabilizing_timeout"`
14 // How long to wait between online connectivity checks.
15- RecheckTimeout config.ConfigTimeDuration
16+ RecheckTimeout config.ConfigTimeDuration `json:"recheck_timeout"`
17 // The URL against which to do the connectivity check.
18- ConnectivityCheckURL string
19+ ConnectivityCheckURL string `json:"connectivity_check_url"`
20 // The expected MD5 of the content at the ConnectivityCheckURL
21- ConnectivityCheckMD5 string
22+ ConnectivityCheckMD5 string `json:"connectivity_check_md5"`
23 }
24
25 type connectedState struct {
26 networkStateCh <-chan networkmanager.State
27- config Config
28+ config ConnectivityConfig
29 log logger.Logger
30 endp bus.Endpoint
31 connAttempts uint32
32@@ -140,7 +140,7 @@
33 //
34 // The endpoint need not be dialed; connectivity will Dial() and Close()
35 // it as it sees fit.
36-func ConnectedState(endp bus.Endpoint, config Config, log logger.Logger, out chan<- bool) {
37+func ConnectedState(endp bus.Endpoint, config ConnectivityConfig, log logger.Logger, out chan<- bool) {
38 wg := NewWebchecker(config.ConnectivityCheckURL, config.ConnectivityCheckMD5, log)
39 cs := &connectedState{
40 config: config,
41
42=== modified file 'bus/connectivity/connectivity_test.go'
43--- bus/connectivity/connectivity_test.go 2014-01-24 14:12:22 +0000
44+++ bus/connectivity/connectivity_test.go 2014-01-27 18:11:05 +0000
45@@ -58,7 +58,7 @@
46 // when given a working config and bus, Start() will work
47 func (s *ConnSuite) TestStartWorks(c *C) {
48 endp := testingbus.NewTestingEndpoint(condition.Work(true), condition.Work(true), uint32(networkmanager.Connecting))
49- cs := connectedState{config: Config{}, log: nullog, endp: endp}
50+ cs := connectedState{config: ConnectivityConfig{}, log: nullog, endp: endp}
51
52 c.Check(cs.start(), Equals, networkmanager.Connecting)
53 }
54@@ -66,7 +66,7 @@
55 // if the bus fails a couple of times, we're still OK
56 func (s *ConnSuite) TestStartRetriesConnect(c *C) {
57 endp := testingbus.NewTestingEndpoint(condition.Fail2Work(2), condition.Work(true), uint32(networkmanager.Connecting))
58- cs := connectedState{config: Config{}, log: nullog, endp: endp}
59+ cs := connectedState{config: ConnectivityConfig{}, log: nullog, endp: endp}
60
61 c.Check(cs.start(), Equals, networkmanager.Connecting)
62 c.Check(cs.connAttempts, Equals, uint32(3)) // 1 more than the Fail2Work
63@@ -75,7 +75,7 @@
64 // when the calls to NetworkManager fail for a bit, we're still OK
65 func (s *ConnSuite) TestStartRetriesCall(c *C) {
66 endp := testingbus.NewTestingEndpoint(condition.Work(true), condition.Fail2Work(5), uint32(networkmanager.Connecting))
67- cs := connectedState{config: Config{}, log: nullog, endp: endp}
68+ cs := connectedState{config: ConnectivityConfig{}, log: nullog, endp: endp}
69
70 c.Check(cs.start(), Equals, networkmanager.Connecting)
71
72@@ -93,7 +93,7 @@
73 endp := testingbus.NewTestingEndpoint(condition.Work(true), nmcond,
74 uint32(networkmanager.Connecting),
75 uint32(networkmanager.ConnectedGlobal))
76- cs := connectedState{config: Config{}, log: nullog, endp: endp}
77+ cs := connectedState{config: ConnectivityConfig{}, log: nullog, endp: endp}
78
79 c.Check(cs.start(), Equals, networkmanager.Connecting)
80 c.Check(cs.connAttempts, Equals, uint32(2))
81@@ -109,7 +109,7 @@
82 var webget_p condition.Interface = condition.Work(true)
83 recheck_timeout := 50 * time.Millisecond
84
85- cfg := Config{
86+ cfg := ConnectivityConfig{
87 RecheckTimeout: config.ConfigTimeDuration{recheck_timeout},
88 }
89 ch := make(chan networkmanager.State, 10)
90@@ -184,7 +184,7 @@
91 ts := httptest.NewServer(mkHandler(staticText))
92 defer ts.Close()
93
94- cfg := Config{
95+ cfg := ConnectivityConfig{
96 ConnectivityCheckURL: ts.URL,
97 ConnectivityCheckMD5: staticHash,
98 RecheckTimeout: config.ConfigTimeDuration{time.Second},
99
100=== added file 'client.json'
101--- client.json 1970-01-01 00:00:00 +0000
102+++ client.json 2014-01-27 18:11:05 +0000
103@@ -0,0 +1,9 @@
104+{
105+ "exchange_timeout": "30s",
106+ "addr": ":9090",
107+ "cert_pem_file": "server/acceptance/config/testing.cert",
108+ "stabilizing_timeout": "2s",
109+ "recheck_timeout": "10m",
110+ "connectivity_check_url": "http://start.ubuntu.com/connectivity-check.html",
111+ "connectivity_check_md5": "4589f42e1546aa47ca181e5d949d310b"
112+}
113
114=== added file 'client/client.go'
115--- client/client.go 1970-01-01 00:00:00 +0000
116+++ client/client.go 2014-01-27 18:11:05 +0000
117@@ -0,0 +1,141 @@
118+package main
119+
120+import (
121+ "launchpad.net/go-dbus/v1"
122+ "launchpad.net/ubuntu-push/bus"
123+ "launchpad.net/ubuntu-push/bus/connectivity"
124+ "launchpad.net/ubuntu-push/bus/networkmanager"
125+ "launchpad.net/ubuntu-push/bus/notifications"
126+ "launchpad.net/ubuntu-push/bus/urldispatcher"
127+ "launchpad.net/ubuntu-push/client/session"
128+ "launchpad.net/ubuntu-push/config"
129+ "launchpad.net/ubuntu-push/logger"
130+ "launchpad.net/ubuntu-push/util"
131+ "launchpad.net/ubuntu-push/whoopsie/identifier"
132+ "os"
133+)
134+
135+type configuration struct {
136+ connectivity.ConnectivityConfig
137+ session.ClientConfig
138+}
139+
140+const (
141+ configFName string = "/etc/ubuntu-push/client.json"
142+)
143+
144+func notify_update(nots *notifications.RawNotifications, log logger.Logger) {
145+ action_id := "my_action_id"
146+ a := []string{action_id, "Go get it!"} // action value not visible on the phone
147+ h := map[string]*dbus.Variant{"x-canonical-switch-to-application": &dbus.Variant{true}}
148+ not_id, err := nots.Notify(
149+ "ubuntu-push-client", // app name
150+ uint32(0), // id
151+ "update_manager_icon", // icon
152+ "There's an updated system image!", // summary
153+ "You've got to get it! Now! Run!", // body
154+ a, // actions
155+ h, // hints
156+ int32(10*1000), // timeout
157+ )
158+ if err != nil {
159+ log.Fatalf("%s", err)
160+ }
161+ log.Debugf("Got notification id %d\n", not_id)
162+}
163+
164+func main() {
165+ log := logger.NewSimpleLogger(os.Stderr, "debug")
166+ f, err := os.Open(configFName)
167+ if err != nil {
168+ log.Fatalf("reading config: %v", err)
169+ }
170+ cfg := &configuration{}
171+ err = config.ReadConfig(f, cfg)
172+ if err != nil {
173+ log.Fatalf("reading config: %v", err)
174+ }
175+ whopId := identifier.New()
176+ err = whopId.Generate()
177+ if err != nil {
178+ log.Fatalf("Generating device id: %v", err)
179+ }
180+ deviceId := whopId.String()
181+ log.Debugf("Connecting as device id %s", deviceId)
182+ session, err := session.NewSession(cfg.ClientConfig, log, deviceId)
183+ if err != nil {
184+ log.Fatalf("%s", err)
185+ }
186+ // ^^ up to this line, things that never change
187+ var is_connected, ok bool
188+ // vv from this line, things that never stay the same
189+ for {
190+ log.Debugf("Here we go!")
191+ is_connected = false
192+ connCh := make(chan bool)
193+ iniCh := make(chan uint32)
194+
195+ notEndp := bus.SessionBus.Endpoint(notifications.BusAddress, log)
196+ urlEndp := bus.SessionBus.Endpoint(urldispatcher.BusAddress, log)
197+
198+ go func() { iniCh <- util.AutoRetry(session.Reset) }()
199+ go func() { iniCh <- util.AutoRedial(notEndp) }()
200+ go func() { iniCh <- util.AutoRedial(urlEndp) }()
201+ go connectivity.ConnectedState(bus.SystemBus.Endpoint(networkmanager.BusAddress, log), cfg.ConnectivityConfig, log, connCh)
202+
203+ <-iniCh
204+ <-iniCh
205+ <-iniCh
206+ nots := notifications.Raw(notEndp, log)
207+ urld := urldispatcher.New(urlEndp, log)
208+
209+ actnCh, err := nots.WatchActions()
210+ if err != nil {
211+ log.Errorf("%s", err)
212+ continue
213+ }
214+
215+ InnerLoop:
216+ for {
217+ select {
218+ case is_connected, ok = <-connCh:
219+ // handle connectivty changes
220+ // disconnect session if offline, reconnect if online
221+ if !ok {
222+ log.Errorf("connectivity checker crashed? restarting everything")
223+ break InnerLoop
224+ }
225+ // fallthrough
226+ // oh, silly ol' go doesn't like that.
227+ if is_connected {
228+ err = session.Reset()
229+ if err != nil {
230+ break InnerLoop
231+ }
232+ }
233+ case <-session.ErrCh:
234+ // handle session errors
235+ // restart if online, otherwise ignore
236+ if is_connected {
237+ err = session.Reset()
238+ if err != nil {
239+ break InnerLoop
240+ }
241+ }
242+ case <-session.MsgCh:
243+ // handle push notifications
244+ // pop up client notification
245+ // what to do does not depend on the rest
246+ // (... for now)
247+ log.Debugf("got a notification! let's pop it up.")
248+ notify_update(nots, log)
249+ case <-actnCh:
250+ // handle action clicks
251+ // launch system updates
252+ // what to do does not depend on the rest
253+ // (... for now)
254+ urld.DispatchURL("settings:///system/system-update")
255+ }
256+ }
257+ }
258+}
259
260=== modified file 'client/session/session.go'
261--- client/session/session.go 2014-01-27 18:11:05 +0000
262+++ client/session/session.go 2014-01-27 18:11:05 +0000
263@@ -50,7 +50,7 @@
264
265 var _ LevelMap = &mapLevelMap{}
266
267-type Config struct {
268+type ClientConfig struct {
269 // session configuration
270 ExchangeTimeout config.ConfigTimeDuration `json:"exchange_timeout"`
271 // server connection config
272@@ -75,7 +75,7 @@
273 MsgCh chan *Notification
274 }
275
276-func NewSession(config Config, log logger.Logger, deviceId string) (*ClientSession, error) {
277+func NewSession(config ClientConfig, log logger.Logger, deviceId string) (*ClientSession, error) {
278 sess := &ClientSession{
279 ExchangeTimeout: config.ExchangeTimeout.TimeDuration(),
280 ServerAddr: config.Addr.HostPort(),
281
282=== modified file 'client/session/session_test.go'
283--- client/session/session_test.go 2014-01-27 18:11:05 +0000
284+++ client/session/session_test.go 2014-01-27 18:11:05 +0000
285@@ -49,7 +49,7 @@
286 ****************************************************************/
287
288 func (cs *clientSessionSuite) TestNewSessionPlainWorks(c *C) {
289- cfg := Config{}
290+ cfg := ClientConfig{}
291 sess, err := NewSession(cfg, nullog, "wah")
292 c.Check(sess, NotNil)
293 c.Check(err, IsNil)
294@@ -58,7 +58,7 @@
295 var certfile string = helpers.SourceRelative("../../server/acceptance/config/testing.cert")
296
297 func (cs *clientSessionSuite) TestNewSessionPEMWorks(c *C) {
298- cfg := Config{CertPEMFile: certfile}
299+ cfg := ClientConfig{CertPEMFile: certfile}
300 sess, err := NewSession(cfg, nullog, "wah")
301 c.Check(sess, NotNil)
302 c.Assert(err, IsNil)
303@@ -66,14 +66,14 @@
304 }
305
306 func (cs *clientSessionSuite) TestNewSessionBadPEMFilePathFails(c *C) {
307- cfg := Config{CertPEMFile: "/no/such/path"}
308+ cfg := ClientConfig{CertPEMFile: "/no/such/path"}
309 sess, err := NewSession(cfg, nullog, "wah")
310 c.Check(sess, IsNil)
311 c.Check(err, NotNil)
312 }
313
314 func (cs *clientSessionSuite) TestNewSessionBadPEMFileContentFails(c *C) {
315- cfg := Config{CertPEMFile: "/etc/passwd"}
316+ cfg := ClientConfig{CertPEMFile: "/etc/passwd"}
317 sess, err := NewSession(cfg, nullog, "wah")
318 c.Check(sess, IsNil)
319 c.Check(err, NotNil)
320@@ -214,7 +214,7 @@
321 ****************************************************************/
322
323 func (cs *clientSessionSuite) TestRunFailsIfNilConnection(c *C) {
324- sess, err := NewSession(Config{}, debuglog, "wah")
325+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
326 c.Assert(err, IsNil)
327 // not connected!
328 err = sess.run()
329@@ -223,7 +223,7 @@
330 }
331
332 func (cs *clientSessionSuite) TestRunFailsIfNilProtocolator(c *C) {
333- sess, err := NewSession(Config{}, debuglog, "wah")
334+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
335 c.Assert(err, IsNil)
336 sess.Connection = &testConn{Name: testname()} // ok, have a constructor
337 sess.Protocolator = nil // but no protocol, seeficare.
338@@ -233,7 +233,7 @@
339 }
340
341 func (cs *clientSessionSuite) TestRunFailsIfSetDeadlineFails(c *C) {
342- sess, err := NewSession(Config{}, debuglog, "wah")
343+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
344 c.Assert(err, IsNil)
345 sess.Connection = &testConn{Name: testname(),
346 DeadlineCondition: condition.Work(false)} // setdeadline will fail
347@@ -243,7 +243,7 @@
348 }
349
350 func (cs *clientSessionSuite) TestRunFailsIfWriteFails(c *C) {
351- sess, err := NewSession(Config{}, debuglog, "wah")
352+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
353 c.Assert(err, IsNil)
354 sess.Connection = &testConn{Name: testname(),
355 WriteCondition: condition.Work(false)} // write will fail
356@@ -253,7 +253,7 @@
357 }
358
359 func (cs *clientSessionSuite) TestRunConnectMessageFails(c *C) {
360- sess, err := NewSession(Config{}, debuglog, "wah")
361+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
362 c.Assert(err, IsNil)
363 sess.Connection = &testConn{Name: testname()}
364 errCh := make(chan error, 1)
365@@ -278,7 +278,7 @@
366 }
367
368 func (cs *clientSessionSuite) TestRunConnackReadError(c *C) {
369- sess, err := NewSession(Config{}, debuglog, "wah")
370+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
371 c.Assert(err, IsNil)
372 sess.Connection = &testConn{Name: testname()}
373 errCh := make(chan error, 1)
374@@ -300,7 +300,7 @@
375 }
376
377 func (cs *clientSessionSuite) TestRunBadConnack(c *C) {
378- sess, err := NewSession(Config{}, debuglog, "wah")
379+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
380 c.Assert(err, IsNil)
381 sess.Connection = &testConn{Name: testname()}
382 errCh := make(chan error, 1)
383@@ -322,7 +322,7 @@
384 }
385
386 func (cs *clientSessionSuite) TestRunMainloopReadError(c *C) {
387- sess, err := NewSession(Config{}, debuglog, "wah")
388+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
389 c.Assert(err, IsNil)
390 sess.Connection = &testConn{Name: testname()}
391 errCh := make(chan error, 1)
392@@ -349,7 +349,7 @@
393 }
394
395 func (cs *clientSessionSuite) TestRunPongWriteError(c *C) {
396- sess, err := NewSession(Config{}, debuglog, "wah")
397+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
398 c.Assert(err, IsNil)
399 sess.Connection = &testConn{Name: testname()}
400 errCh := make(chan error, 1)
401@@ -378,7 +378,7 @@
402 }
403
404 func (cs *clientSessionSuite) TestRunPingPong(c *C) {
405- sess, err := NewSession(Config{}, debuglog, "wah")
406+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
407 c.Assert(err, IsNil)
408 sess.Connection = &testConn{Name: testname()}
409 errCh := make(chan error, 1)
410@@ -406,7 +406,7 @@
411 }
412
413 func (cs *clientSessionSuite) TestRunBadAckWrite(c *C) {
414- sess, err := NewSession(Config{}, debuglog, "wah")
415+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
416 c.Assert(err, IsNil)
417 sess.Connection = &testConn{Name: testname()}
418 errCh := make(chan error, 1)
419@@ -444,7 +444,7 @@
420 }
421
422 func (cs *clientSessionSuite) TestRunBroadcastWrongChannel(c *C) {
423- sess, err := NewSession(Config{}, debuglog, "wah")
424+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
425 c.Assert(err, IsNil)
426 sess.Connection = &testConn{Name: testname()}
427 errCh := make(chan error, 1)
428@@ -482,7 +482,7 @@
429 }
430
431 func (cs *clientSessionSuite) TestRunBroadcastRightChannel(c *C) {
432- sess, err := NewSession(Config{}, debuglog, "wah")
433+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
434 c.Assert(err, IsNil)
435 sess.Connection = &testConn{Name: testname()}
436 sess.ErrCh = make(chan error, 1)
437@@ -528,7 +528,7 @@
438 */
439
440 func (cs *clientSessionSuite) TestDialFailsWithNoAddress(c *C) {
441- sess, err := NewSession(Config{}, debuglog, "wah")
442+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
443 c.Assert(err, IsNil)
444 err = sess.Dial()
445 c.Assert(err, NotNil)
446@@ -539,7 +539,7 @@
447 lp, err := net.Listen("tcp", ":0")
448 c.Assert(err, IsNil)
449 defer lp.Close()
450- sess, err := NewSession(Config{}, debuglog, "wah")
451+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
452 c.Assert(err, IsNil)
453 sess.ServerAddr = lp.Addr().String()
454 err = sess.Dial()
455@@ -548,7 +548,7 @@
456 }
457
458 func (cs *clientSessionSuite) TestResetFailsWithoutProtocolator(c *C) {
459- sess, _ := NewSession(Config{}, debuglog, "wah")
460+ sess, _ := NewSession(ClientConfig{}, debuglog, "wah")
461 sess.Protocolator = nil
462 err := sess.Reset()
463 c.Assert(err, NotNil)
464@@ -556,7 +556,7 @@
465 }
466
467 func (cs *clientSessionSuite) TestResetFailsWithNoAddress(c *C) {
468- sess, err := NewSession(Config{}, debuglog, "wah")
469+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
470 c.Assert(err, IsNil)
471 err = sess.Reset()
472 c.Assert(err, NotNil)
473@@ -571,7 +571,7 @@
474 c.Assert(err, IsNil)
475 defer lp.Close()
476
477- sess, err := NewSession(Config{}, debuglog, "wah")
478+ sess, err := NewSession(ClientConfig{}, debuglog, "wah")
479 c.Assert(err, IsNil)
480 sess.ServerAddr = lp.Addr().String()
481 sess.Connection = &testConn{Name: testname()}
482
483=== modified file 'util/redialer.go'
484--- util/redialer.go 2014-01-27 13:02:26 +0000
485+++ util/redialer.go 2014-01-27 18:11:05 +0000
486@@ -49,15 +49,14 @@
487 return time.Duration(rand.Int63n(2*n+1) - n)
488 }
489
490-// AutoRedialer takes a Dialer and retries its Dial() method until it
491-// stops returning an error. It does exponential (optionally
492-// jitter'ed) backoff.
493-func AutoRedial(dialer Dialer) uint32 {
494+// AutoRetry keeps on calling f() until it stops returning an error.
495+// It does exponential backoff, adding jitter at each step back.
496+func AutoRetry(f func() error, jitter func(time.Duration) time.Duration) uint32 {
497 var timeout time.Duration
498 var dialAttempts uint32 = 0 // unsigned so it can wrap safely ...
499 var numTimeouts uint32 = uint32(len(Timeouts))
500 for {
501- if dialer.Dial() == nil {
502+ if f() == nil {
503 return dialAttempts + 1
504 }
505 if dialAttempts < numTimeouts {
506@@ -65,7 +64,7 @@
507 } else {
508 timeout = Timeouts[numTimeouts-1]
509 }
510- timeout += dialer.Jitter(timeout)
511+ timeout += jitter(timeout)
512 dialAttempts++
513 select {
514 case <-quitRedialing:
515@@ -75,6 +74,13 @@
516 }
517 }
518
519+// AutoRedialer takes a Dialer and retries its Dial() method until it
520+// stops returning an error. It does exponential (optionally
521+// jitter'ed) backoff.
522+func AutoRedial(dialer Dialer) uint32 {
523+ return AutoRetry(dialer.Dial, dialer.Jitter)
524+}
525+
526 func init() {
527 ps := []int{1, 2, 5, 11, 19, 37, 67, 113, 191} // 3 pₙ₊₁ ≥ 5 pₙ
528 Timeouts = make([]time.Duration, len(ps))

Subscribers

People subscribed via source and target branches