From 1a76f906f96996857906b0a5de01c84f86f6e6dd Mon Sep 17 00:00:00 2001 From: Joseph Cumines Date: Sun, 19 Jul 2020 12:51:06 +1000 Subject: [PATCH] feat(Context): provides context integration I arrived on a solution to provide context support that I was happy to include in the core implementation. Peripheral, and entirely optional extra functionality, as usual. The new Context type simplifies context utilisation, via composition of basic building blocks. --- README.md | 117 +++++++++++++++++++++++++++++++++++++++++++++++- context.go | 101 +++++++++++++++++++++++++++++++++++++++++ context_test.go | 103 ++++++++++++++++++++++++++++++++++++++++++ example_test.go | 111 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 context.go create mode 100644 context_test.go diff --git a/README.md b/README.md index fb6a611..8bc57df 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,9 @@ finite state machines, which tends to cripple the modularity of any interruptabl it's own exit condition(s). Golang's `context` doesn't really help in that case, either, being a communication mechanism, rather than a control mechanism. As an alternative, implementations may use pre-conditions (preceding child(ren) in a sequence), guarding an asynchronous tick. Context support may be desirable, and may be implemented as -`Tick` implementations(s). Context support is peripheral in that it's only relevant to a subset of implementations, -and was deliberately omitted (from `Tick`). +`Tick` implementations(s). This package provides a `Context` implementation to address several common use cases, such +as operations with timeouts. Context support is peripheral in that it's only relevant to a subset of implementations, +and was deliberately omitted from the core `Tick` type. ### Modularity @@ -430,4 +431,116 @@ func ExampleBackground_asyncJobQueue() { //[client] job "3. 150ms" FINISHED //running jobs: 0 } + + +// ExampleContext demonstrates how the Context implementation may be used to integrate with the context package +func ExampleContext() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var ( + btCtx = new(Context).WithTimeout(ctx, time.Millisecond*100) + debug = func(args ...interface{}) Tick { + return func([]Node) (Status, error) { + fmt.Println(args...) + return Success, nil + } + } + counter int + counterEqual = func(v int) Tick { + return func([]Node) (Status, error) { + if counter == v { + return Success, nil + } + return Failure, nil + } + } + counterInc Tick = func([]Node) (Status, error) { + counter++ + //fmt.Printf("counter = %d\n", counter) + return Success, nil + } + ticker = NewTicker(ctx, time.Millisecond, New( + Sequence, + New( + Selector, + New(Not(btCtx.Err)), + New( + Sequence, + New(debug(`(re)initialising btCtx...`)), + New(btCtx.Init), + New(Not(btCtx.Err)), + ), + ), + New( + Selector, + New( + Sequence, + New(counterEqual(0)), + New(debug(`blocking on context-enabled tick...`)), + New( + btCtx.Tick(func(ctx context.Context, children []Node) (Status, error) { + fmt.Printf("NOTE children (%d) passed through\n", len(children)) + <-ctx.Done() + return Success, nil + }), + New(Sequence), + New(Sequence), + ), + New(counterInc), + ), + New( + Sequence, + New(counterEqual(1)), + New(debug(`blocking on done...`)), + New(btCtx.Done), + New(counterInc), + ), + New( + Sequence, + New(counterEqual(2)), + New(debug(`canceling local then rechecking the above...`)), + New(btCtx.Cancel), + New(btCtx.Err), + New(btCtx.Tick(func(ctx context.Context, children []Node) (Status, error) { + <-ctx.Done() + return Success, nil + })), + New(btCtx.Done), + New(counterInc), + ), + New( + Sequence, + New(counterEqual(3)), + New(debug(`canceling parent then rechecking the above...`)), + New(func([]Node) (Status, error) { + cancel() + return Success, nil + }), + New(btCtx.Err), + New(btCtx.Tick(func(ctx context.Context, children []Node) (Status, error) { + <-ctx.Done() + return Success, nil + })), + New(btCtx.Done), + New(debug(`exiting...`)), + ), + ), + )) + ) + + <-ticker.Done() + + //output: + //(re)initialising btCtx... + //blocking on context-enabled tick... + //NOTE children (2) passed through + //(re)initialising btCtx... + //blocking on done... + //(re)initialising btCtx... + //canceling local then rechecking the above... + //(re)initialising btCtx... + //canceling parent then rechecking the above... + //exiting... +} ``` diff --git a/context.go b/context.go new file mode 100644 index 0000000..f3346fd --- /dev/null +++ b/context.go @@ -0,0 +1,101 @@ +/* + Copyright 2020 Joseph Cumines + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package behaviortree + +import ( + "context" + "time" +) + +// Context provides support for tick(s) utilising context as a means of cancelation, with cancelation triggered by +// either BT-driven logic or the normal means (parent cancelation, deadline / timeout). +// +// Note that it must be initialised by means of it's Init method (implements a tick) prior to use (Context.Tick tick). +// Init may be ticked any number of times (each time triggering cancelation of any prior context). +type Context struct { + parent func() (context.Context, context.CancelFunc) + ctx context.Context + cancel context.CancelFunc +} + +// WithCancel configures the receiver to initialise context like context.WithCancel(parent), returning the receiver +func (c *Context) WithCancel(parent context.Context) *Context { + c.parent = func() (context.Context, context.CancelFunc) { return context.WithCancel(parent) } + return c +} + +// WithDeadline configures the receiver to initialise context like context.WithDeadline(parent, deadline), returning +// the receiver +func (c *Context) WithDeadline(parent context.Context, deadline time.Time) *Context { + c.parent = func() (context.Context, context.CancelFunc) { return context.WithDeadline(parent, deadline) } + return c +} + +// WithTimeout configures the receiver to initialise context like context.WithTimeout(parent, timeout), returning +// the receiver +func (c *Context) WithTimeout(parent context.Context, timeout time.Duration) *Context { + c.parent = func() (context.Context, context.CancelFunc) { return context.WithTimeout(parent, timeout) } + return c +} + +// Init implements a tick that will cancel existing context, (re)initialise the context, then succeed, note that it +// must not be called concurrently with any other method, and it must be ticked prior to any Context.Tick tick +func (c *Context) Init([]Node) (Status, error) { + if c.cancel != nil { + c.cancel() + } + if c.parent != nil { + c.ctx, c.cancel = c.parent() + } else { + c.ctx, c.cancel = context.WithCancel(context.Background()) + } + return Success, nil +} + +// Tick returns a tick that will call fn with the receiver's context, returning nil if fn is nil (for consistency +// with other implementations in this package), note that a Init node must have already been ticked on all possible +// execution paths, or a panic may occur, due to fn being passed a nil context.Context +func (c *Context) Tick(fn func(ctx context.Context, children []Node) (Status, error)) Tick { + if fn != nil { + return func(children []Node) (Status, error) { return fn(c.ctx, children) } + } + return nil +} + +// Cancel implements a tick that will cancel the receiver's context (noop if it has none) then succeed +func (c *Context) Cancel([]Node) (Status, error) { + if c.cancel != nil { + c.cancel() + } + return Success, nil +} + +// Err implements a tick that will succeed if the receiver does not have a context or it has been canceled +func (c *Context) Err([]Node) (Status, error) { + if c.ctx == nil || c.ctx.Err() != nil { + return Success, nil + } + return Failure, nil +} + +// Done implements a tick that will block on the receiver's context being canceled (noop if it has none) then succeed +func (c *Context) Done([]Node) (Status, error) { + if c.ctx != nil { + <-c.ctx.Done() + } + return Success, nil +} diff --git a/context_test.go b/context_test.go new file mode 100644 index 0000000..c93e3dd --- /dev/null +++ b/context_test.go @@ -0,0 +1,103 @@ +/* + Copyright 2020 Joseph Cumines + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package behaviortree + +import ( + "context" + "testing" + "time" +) + +func TestContext_Tick_nilFn(t *testing.T) { + if v := new(Context).Tick(nil); v != nil { + t.Error(`expected nil`) + } +} + +func TestContext_Cancel_noContext(t *testing.T) { + if status, err := new(Context).Cancel(nil); err != nil || status != Success { + t.Error(status, err) + } +} + +func TestContext_Done_noContext(t *testing.T) { + if status, err := new(Context).Done(nil); err != nil || status != Success { + t.Error(status, err) + } +} + +func TestContext_Init_default(t *testing.T) { + c := new(Context) + if status, err := c.Init(nil); err != nil || status != Success { + t.Fatal(status, err) + } + ctx := c.ctx + if err := ctx.Err(); err != nil { + t.Fatal(err) + } + if status, err := c.Init(nil); err != nil || status != Success { + t.Fatal(status, err) + } + if err := ctx.Err(); err == nil { + t.Error(c) + } + if c.ctx == nil || c.cancel == nil || c.ctx.Err() != nil { + t.Fatal(c) + } + c.cancel() + if c.ctx.Err() == nil { + t.Fatal(c) + } +} + +func TestContext_WithCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c := new(Context) + if v := c.WithCancel(ctx); v != c { + t.Error(v) + } + if c.ctx != nil || c.cancel != nil || c.parent == nil { + t.Fatal(c) + } + if status, err := c.Init(nil); err != nil || status != Success { + t.Fatal(status, err) + } + if c.ctx == nil || c.cancel == nil || c.ctx.Err() != nil { + t.Fatal(c) + } + cancel() + if err := c.ctx.Err(); err != context.Canceled { + t.Fatal(err) + } +} + +func TestContext_WithDeadline(t *testing.T) { + c := new(Context) + if v := c.WithDeadline(context.Background(), time.Now().Add(-time.Second)); v != c { + t.Error(v) + } + if c.ctx != nil || c.cancel != nil || c.parent == nil { + t.Fatal(c) + } + if status, err := c.Init(nil); err != nil || status != Success { + t.Fatal(status, err) + } + if err := c.ctx.Err(); err != context.DeadlineExceeded { + t.Fatal(err) + } +} diff --git a/example_test.go b/example_test.go index 36dfaa2..183e316 100644 --- a/example_test.go +++ b/example_test.go @@ -361,3 +361,114 @@ func ExampleBackground_asyncJobQueue() { //[client] job "3. 150ms" FINISHED //running jobs: 0 } + +// ExampleContext demonstrates how the Context implementation may be used to integrate with the context package +func ExampleContext() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var ( + btCtx = new(Context).WithTimeout(ctx, time.Millisecond*100) + debug = func(args ...interface{}) Tick { + return func([]Node) (Status, error) { + fmt.Println(args...) + return Success, nil + } + } + counter int + counterEqual = func(v int) Tick { + return func([]Node) (Status, error) { + if counter == v { + return Success, nil + } + return Failure, nil + } + } + counterInc Tick = func([]Node) (Status, error) { + counter++ + //fmt.Printf("counter = %d\n", counter) + return Success, nil + } + ticker = NewTicker(ctx, time.Millisecond, New( + Sequence, + New( + Selector, + New(Not(btCtx.Err)), + New( + Sequence, + New(debug(`(re)initialising btCtx...`)), + New(btCtx.Init), + New(Not(btCtx.Err)), + ), + ), + New( + Selector, + New( + Sequence, + New(counterEqual(0)), + New(debug(`blocking on context-enabled tick...`)), + New( + btCtx.Tick(func(ctx context.Context, children []Node) (Status, error) { + fmt.Printf("NOTE children (%d) passed through\n", len(children)) + <-ctx.Done() + return Success, nil + }), + New(Sequence), + New(Sequence), + ), + New(counterInc), + ), + New( + Sequence, + New(counterEqual(1)), + New(debug(`blocking on done...`)), + New(btCtx.Done), + New(counterInc), + ), + New( + Sequence, + New(counterEqual(2)), + New(debug(`canceling local then rechecking the above...`)), + New(btCtx.Cancel), + New(btCtx.Err), + New(btCtx.Tick(func(ctx context.Context, children []Node) (Status, error) { + <-ctx.Done() + return Success, nil + })), + New(btCtx.Done), + New(counterInc), + ), + New( + Sequence, + New(counterEqual(3)), + New(debug(`canceling parent then rechecking the above...`)), + New(func([]Node) (Status, error) { + cancel() + return Success, nil + }), + New(btCtx.Err), + New(btCtx.Tick(func(ctx context.Context, children []Node) (Status, error) { + <-ctx.Done() + return Success, nil + })), + New(btCtx.Done), + New(debug(`exiting...`)), + ), + ), + )) + ) + + <-ticker.Done() + + //output: + //(re)initialising btCtx... + //blocking on context-enabled tick... + //NOTE children (2) passed through + //(re)initialising btCtx... + //blocking on done... + //(re)initialising btCtx... + //canceling local then rechecking the above... + //(re)initialising btCtx... + //canceling parent then rechecking the above... + //exiting... +}