feat: tlsping and tcpping using step-by-step (#815)

## Checklist

- [x] I have read the [contribution guidelines](https://github.com/ooni/probe-cli/blob/master/CONTRIBUTING.md)
- [x] reference issue for this pull request: https://github.com/ooni/probe/issues/2158
- [x] if you changed anything related how experiments work and you need to reflect these changes in the ooni/spec repository, please link to the related ooni/spec pull request: https://github.com/ooni/spec/pull/250

## Description

This diff refactors the codebase to reimplement tlsping and tcpping
to use the step-by-step measurements style.

See docs/design/dd-003-step-by-step.md for more information on the
step-by-step measurement style.
This commit is contained in:
Simone Basso
2022-07-01 12:22:22 +02:00
committed by GitHub
parent 5371c7f486
commit 5ebdeb56ca
48 changed files with 2825 additions and 299 deletions
+2
View File
@@ -0,0 +1,2 @@
// Package testingx contains code useful for testing.
package testingx
+138
View File
@@ -0,0 +1,138 @@
package testingx
import (
"math/rand"
"reflect"
"sync"
"time"
)
// FakeFiller fills specific data structures with random data. The only
// exception to this behaviour is time.Time, which is instead filled
// with the current time plus a small random number of seconds.
//
// We use this implementation to initialize data in our model. The code
// has been written with that in mind. It will require some hammering in
// case we extend the model with new field types.
//
// Caveat: this kind of fillter does not support filling interfaces
// and channels and other complex types. The current behavior when this
// kind of data types is encountered is to just ignore them.
//
// This struct is quite limited in scope and we can fill only the
// structures you typically send over as JSONs.
//
// As part of future work, we aim to investigate whether we can
// replace this implementation with https://go.dev/blog/fuzz-beta.
type FakeFiller struct {
// mu provides mutual exclusion
mu sync.Mutex
// Now is OPTIONAL and allows to mock the current time
Now func() time.Time
// rnd is the random number generator and is
// automatically initialized on first use
rnd *rand.Rand
}
func (ff *FakeFiller) getRandLocked() *rand.Rand {
if ff.rnd == nil {
now := time.Now
if ff.Now != nil {
now = ff.Now
}
ff.rnd = rand.New(rand.NewSource(now().UnixNano()))
}
return ff.rnd
}
func (ff *FakeFiller) getRandomString() string {
defer ff.mu.Unlock()
ff.mu.Lock()
rnd := ff.getRandLocked()
n := rnd.Intn(63) + 1
// See https://stackoverflow.com/a/31832326
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rnd.Intn(len(letterRunes))]
}
return string(b)
}
func (ff *FakeFiller) getRandomInt64() int64 {
defer ff.mu.Unlock()
ff.mu.Lock()
rnd := ff.getRandLocked()
return rnd.Int63()
}
func (ff *FakeFiller) getRandomBool() bool {
defer ff.mu.Unlock()
ff.mu.Lock()
rnd := ff.getRandLocked()
return rnd.Float64() >= 0.5
}
func (ff *FakeFiller) getRandomSmallPositiveInt() int {
defer ff.mu.Unlock()
ff.mu.Lock()
rnd := ff.getRandLocked()
return int(rnd.Int63n(8)) + 1 // safe cast
}
func (ff *FakeFiller) doFill(v reflect.Value) {
for v.Type().Kind() == reflect.Ptr {
if v.IsNil() {
// if the pointer is nil, allocate an element
v.Set(reflect.New(v.Type().Elem()))
}
// switch to the element
v = v.Elem()
}
switch v.Type().Kind() {
case reflect.String:
v.SetString(ff.getRandomString())
case reflect.Int64:
v.SetInt(ff.getRandomInt64())
case reflect.Bool:
v.SetBool(ff.getRandomBool())
case reflect.Struct:
if v.Type().String() == "time.Time" {
// Implementation note: we treat the time specially
// and we avoid attempting to set its fields.
v.Set(reflect.ValueOf(time.Now().Add(
time.Duration(ff.getRandomSmallPositiveInt()) * time.Second)))
return
}
for idx := 0; idx < v.NumField(); idx++ {
ff.doFill(v.Field(idx)) // visit all fields
}
case reflect.Slice:
kind := v.Type().Elem()
total := ff.getRandomSmallPositiveInt()
for idx := 0; idx < total; idx++ {
value := reflect.New(kind) // make a new element
ff.doFill(value)
v.Set(reflect.Append(v, value.Elem())) // append to slice
}
case reflect.Map:
if v.Type().Key().Kind() != reflect.String {
panic("fakefill: we only support string key types")
}
v.Set(reflect.MakeMap(v.Type())) // we need to init the map
total := ff.getRandomSmallPositiveInt()
kind := v.Type().Elem()
for idx := 0; idx < total; idx++ {
value := reflect.New(kind)
ff.doFill(value)
v.SetMapIndex(reflect.ValueOf(ff.getRandomString()), value.Elem())
}
}
}
// Fill fills the input structure or pointer with random data.
func (ff *FakeFiller) Fill(in interface{}) {
ff.doFill(reflect.ValueOf(in))
}
+91
View File
@@ -0,0 +1,91 @@
package testingx
import (
"testing"
"time"
)
// exampleStructure is an example structure we fill.
type exampleStructure struct {
CategoryCodes string
CountryCode string
Enabled bool
MaxResults int64
Now time.Time
}
func TestFakeFillWorksWithCustomTime(t *testing.T) {
var req *exampleStructure
ff := &FakeFiller{
Now: func() time.Time {
return time.Date(1992, time.January, 24, 17, 53, 0, 0, time.UTC)
},
}
ff.Fill(&req)
if req == nil {
t.Fatal("we expected non nil here")
}
}
func TestFakeFillAllocatesIntoAPointerToPointer(t *testing.T) {
var req *exampleStructure
ff := &FakeFiller{}
ff.Fill(&req)
if req == nil {
t.Fatal("we expected non nil here")
}
}
func TestFakeFillAllocatesIntoAMapLikeWithStringKeys(t *testing.T) {
var resp map[string]*exampleStructure
ff := &FakeFiller{}
ff.Fill(&resp)
if resp == nil {
t.Fatal("we expected non nil here")
}
if len(resp) < 1 {
t.Fatal("we expected some data here")
}
for _, value := range resp {
if value == nil {
t.Fatal("expected non-nil here")
}
}
}
func TestFakeFillAllocatesIntoAMapLikeWithNonStringKeys(t *testing.T) {
var panicmsg string
func() {
defer func() {
if v := recover(); v != nil {
panicmsg = v.(string)
}
}()
var resp map[int64]*exampleStructure
ff := &FakeFiller{}
ff.Fill(&resp)
if resp != nil {
t.Fatal("we expected nil here")
}
}()
if panicmsg != "fakefill: we only support string key types" {
t.Fatal("unexpected panic message", panicmsg)
}
}
func TestFakeFillAllocatesIntoASlice(t *testing.T) {
var resp *[]*exampleStructure
ff := &FakeFiller{}
ff.Fill(&resp)
if resp == nil {
t.Fatal("we expected non nil here")
}
if len(*resp) < 1 {
t.Fatal("we expected some data here")
}
for _, entry := range *resp {
if entry == nil {
t.Fatal("expected non-nil here")
}
}
}
+48
View File
@@ -0,0 +1,48 @@
package testingx
import (
"sync"
"time"
)
// TimeDeterministic implements time.Now in a deterministic fashion
// such that every time.Time call returns a moment in time that occurs
// one second after the configured zeroTime.
//
// It's safe to use this struct from multiple goroutine contexts.
type TimeDeterministic struct {
// counter counts the number of "ticks" passed since the zero time: each
// call to Now increments this counter by one second.
counter time.Duration
// mu protects fields in this structure from concurrent access.
mu sync.Mutex
// zeroTime is the lazy-initialized zero time. The first call to Now
// will initialize this field with the current time.
zeroTime time.Time
}
// NewTimeDeterministic creates a new instance using the given zeroTime value.
func NewTimeDeterministic(zeroTime time.Time) *TimeDeterministic {
return &TimeDeterministic{
counter: 0,
mu: sync.Mutex{},
zeroTime: zeroTime,
}
}
// Now is like time.Now but more deterministic. The first call returns the
// configured zeroTime and subsequent calls return moments in time that occur
// exactly one second after the time returned by the previous call.
func (td *TimeDeterministic) Now() time.Time {
td.mu.Lock()
if td.zeroTime.IsZero() {
td.zeroTime = time.Now()
}
offset := td.counter
td.counter += time.Second
res := td.zeroTime.Add(offset)
td.mu.Unlock()
return res
}
+22
View File
@@ -0,0 +1,22 @@
package testingx
import (
"testing"
"time"
)
func TestTimeDeterministic(t *testing.T) {
td := &TimeDeterministic{}
t0 := td.Now()
if !t0.Equal(td.zeroTime) {
t.Fatal("invalid t0 value")
}
t1 := td.Now()
if t1.Sub(t0) != time.Second {
t.Fatal("invalid t1 value")
}
t2 := td.Now()
if t2.Sub(t1) != time.Second {
t.Fatal("invalid t2 value")
}
}