Skip to content

Commit 67ef2b2

Browse files
authored
Merge pull request #609 from wippyai/fix/contract-future-cancel
fix(contract): cancel async calls at the callee
2 parents fbded4d + 2ef39f8 commit 67ef2b2

4 files changed

Lines changed: 255 additions & 5 deletions

File tree

runtime/lua/modules/contract/module.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,7 @@ func futureCancelImpl(l *lua.LState) int {
797797
return 0
798798
}
799799

800+
f.MarkCanceled()
800801
yield := AcquireAsyncCancelYield()
801802
yield.Topic = f.Topic
802803
l.Push(yield)

runtime/lua/modules/contract/module_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
"github.com/wippyai/runtime/api/registry"
1717
"github.com/wippyai/runtime/api/runtime"
1818
secapi "github.com/wippyai/runtime/api/security"
19+
"github.com/wippyai/runtime/runtime/lua/engine"
20+
"github.com/wippyai/runtime/runtime/lua/modules/future"
1921
)
2022

2123
type mockInstanceForTest struct {
@@ -430,6 +432,23 @@ func TestAsyncCancelYield_HandleResult(t *testing.T) {
430432
assert.Equal(t, lua.LNil, results[1])
431433
}
432434

435+
func TestFutureCancelImplMarksFutureCanceled(t *testing.T) {
436+
l := lua.NewState()
437+
defer l.Close()
438+
439+
f := future.New("@future:test", engine.NewChannel(1))
440+
ud := l.NewUserData()
441+
ud.Value = f
442+
l.Push(ud)
443+
444+
require.Equal(t, -1, futureCancelImpl(l))
445+
require.True(t, f.IsCanceled())
446+
yield, ok := l.Get(-1).(*AsyncCancelYield)
447+
require.True(t, ok)
448+
require.Equal(t, "@future:test", yield.Topic)
449+
ReleaseAsyncCancelYield(yield)
450+
}
451+
433452
func TestPoolConcurrency(_ *testing.T) {
434453
const goroutines = 50
435454
const iterations = 100

system/contract/dispatcher.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package contract
44

55
import (
66
"context"
7+
"sync"
78

89
ctxapi "github.com/wippyai/runtime/api/context"
910
"github.com/wippyai/runtime/api/contract"
@@ -17,8 +18,18 @@ import (
1718

1819
// Dispatcher handles contract commands.
1920
type Dispatcher struct {
20-
node relay.Node
21-
logger *zap.Logger
21+
node relay.Node
22+
logger *zap.Logger
23+
asyncCalls sync.Map // map[asyncCallKey]*asyncCall
24+
}
25+
26+
type asyncCallKey struct {
27+
target string
28+
topic string
29+
}
30+
31+
type asyncCall struct {
32+
cancel context.CancelFunc
2233
}
2334

2435
// NewDispatcher creates a new contract dispatcher with relay node for async routing.
@@ -34,8 +45,14 @@ func (d *Dispatcher) Start(_ context.Context) error {
3445
return nil
3546
}
3647

37-
// Stop is a no-op for contract dispatcher.
48+
// Stop cancels calls owned by this dispatcher.
3849
func (d *Dispatcher) Stop(_ context.Context) error {
50+
d.asyncCalls.Range(func(key, value any) bool {
51+
if d.asyncCalls.CompareAndDelete(key, value) {
52+
value.(*asyncCall).cancel()
53+
}
54+
return true
55+
})
3956
return nil
4057
}
4158

@@ -145,10 +162,24 @@ func (d *Dispatcher) handleAsyncCall(ctx context.Context, cmd dispatcher.Command
145162
logger := d.logger
146163

147164
callCtx, fc := ctxapi.ForkFrameContext(ctx)
165+
callCtx, cancel := context.WithCancel(callCtx)
166+
key := asyncCallKey{target: framePID.String(), topic: topic}
167+
ownedCall := &asyncCall{cancel: cancel}
168+
if previous, replaced := d.asyncCalls.Swap(key, ownedCall); replaced {
169+
previous.(*asyncCall).cancel()
170+
}
148171

149172
go func(callCtx context.Context, callFC ctxapi.FrameContext) {
150173
defer ctxapi.ReleaseFrameContext(callFC)
174+
defer cancel()
151175
result, err := instance.Call(callCtx, method, args, options)
176+
// Cancellation and topic replacement both remove ownership before
177+
// interrupting the call. Only the still-owned invocation may publish a
178+
// result; otherwise its terminal frame could close a newer future that
179+
// reused the same caller-owned topic.
180+
if !d.asyncCalls.CompareAndDelete(key, ownedCall) {
181+
return
182+
}
152183

153184
resultPayload := resultToPayload(result, err)
154185
if err := sendAsyncResult(node, framePID, topic, resultPayload); err != nil {
@@ -191,9 +222,15 @@ func (d *Dispatcher) handleAsyncCancel(ctx context.Context, cmd dispatcher.Comma
191222
return nil
192223
}
193224

194-
if err := sendAsyncCancel(d.node, framePID, cancelCmd.Topic); err != nil {
225+
topic := cancelCmd.Topic
226+
key := asyncCallKey{target: framePID.String(), topic: topic}
227+
if call, ok := d.asyncCalls.LoadAndDelete(key); ok {
228+
call.(*asyncCall).cancel()
229+
}
230+
231+
if err := sendAsyncCancel(d.node, framePID, topic); err != nil {
195232
d.logger.Warn("failed to send async cancel",
196-
zap.String("topic", cancelCmd.Topic),
233+
zap.String("topic", topic),
197234
zap.String("target", framePID.String()),
198235
zap.Error(err))
199236
}

system/contract/dispatcher_test.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,173 @@ func TestAsyncCancelHandler(t *testing.T) {
344344
}
345345
}
346346

347+
func startContractAsyncForTest(
348+
ctx context.Context,
349+
t *testing.T,
350+
d *Dispatcher,
351+
instance contract.Instance,
352+
method string,
353+
topic string,
354+
) {
355+
t.Helper()
356+
cmd := contract.AcquireAsyncCallCmd()
357+
defer cmd.Release()
358+
cmd.Instance = instance
359+
cmd.Method = method
360+
cmd.Topic = topic
361+
done := make(chan contract.AsyncCallResult, 1)
362+
require.NoError(t, d.handleAsyncCall(ctx, cmd, 0, &testReceiver{cb: func(data any, _ error) {
363+
done <- data.(contract.AsyncCallResult)
364+
}}))
365+
select {
366+
case result := <-done:
367+
require.NoError(t, result.Error)
368+
case <-time.After(time.Second):
369+
t.Fatal("timeout waiting for async contract call")
370+
}
371+
}
372+
373+
func cancelContractAsyncForTest(ctx context.Context, t *testing.T, d *Dispatcher, topic string) {
374+
t.Helper()
375+
cmd := contract.AcquireAsyncCancelCmd()
376+
defer cmd.Release()
377+
cmd.Topic = topic
378+
done := make(chan struct{}, 1)
379+
require.NoError(t, d.handleAsyncCancel(ctx, cmd, 0, &testReceiver{cb: func(_ any, _ error) {
380+
done <- struct{}{}
381+
}}))
382+
select {
383+
case <-done:
384+
case <-time.After(time.Second):
385+
t.Fatal("timeout waiting for async contract cancellation")
386+
}
387+
}
388+
389+
func TestAsyncCancelHandler_CancelsRunningCallContext(t *testing.T) {
390+
node := &mockRelayNode{packages: make(chan *relay.Package, 10)}
391+
started := make(chan struct{})
392+
canceled := make(chan error, 1)
393+
instance := &mockInstance{callFn: func(ctx context.Context, _ string, _ payload.Payloads, _ runtime.Options) (*runtime.Result, error) {
394+
close(started)
395+
<-ctx.Done()
396+
canceled <- ctx.Err()
397+
return nil, ctx.Err()
398+
}}
399+
d := NewDispatcher(node, nil)
400+
ctx := setupAsyncTestContext()
401+
startContractAsyncForTest(ctx, t, d, instance, "run", "@future:cancel")
402+
403+
select {
404+
case <-started:
405+
case <-time.After(time.Second):
406+
t.Fatal("timeout waiting for contract call to start")
407+
}
408+
cancelContractAsyncForTest(ctx, t, d, "@future:cancel")
409+
select {
410+
case err := <-canceled:
411+
require.ErrorIs(t, err, context.Canceled)
412+
case <-time.After(time.Second):
413+
t.Fatal("timeout waiting for contract call cancellation")
414+
}
415+
require.Len(t, node.packages, 1, "canceled call must not publish a second terminal result")
416+
}
417+
418+
func TestAsyncCallHandler_CleansCancelHandleAfterCompletion(t *testing.T) {
419+
node := &mockRelayNode{packages: make(chan *relay.Package, 10)}
420+
d := NewDispatcher(node, nil)
421+
instance := &mockInstance{callFn: func(_ context.Context, _ string, _ payload.Payloads, _ runtime.Options) (*runtime.Result, error) {
422+
return &runtime.Result{Value: payload.New("done")}, nil
423+
}}
424+
ctx := setupAsyncTestContext()
425+
framePID, ok := runtime.GetFramePID(ctx)
426+
require.True(t, ok)
427+
startContractAsyncForTest(ctx, t, d, instance, "run", "@future:complete")
428+
429+
select {
430+
case <-node.packages:
431+
case <-time.After(time.Second):
432+
t.Fatal("timeout waiting for async contract result")
433+
}
434+
key := asyncCallKey{target: framePID.String(), topic: "@future:complete"}
435+
require.Eventually(t, func() bool {
436+
_, exists := d.asyncCalls.Load(key)
437+
return !exists
438+
}, time.Second, 10*time.Millisecond)
439+
}
440+
441+
func TestAsyncCancelHandler_DoesNotCancelSameTopicForDifferentCallerPID(t *testing.T) {
442+
node := &mockRelayNode{packages: make(chan *relay.Package, 10)}
443+
started := make(chan context.Context, 1)
444+
canceled := make(chan error, 1)
445+
instance := &mockInstance{callFn: func(ctx context.Context, _ string, _ payload.Payloads, _ runtime.Options) (*runtime.Result, error) {
446+
started <- ctx
447+
<-ctx.Done()
448+
canceled <- ctx.Err()
449+
return nil, ctx.Err()
450+
}}
451+
d := NewDispatcher(node, nil)
452+
453+
root := ctxapi.NewRootContext()
454+
ownerCtx, _ := ctxapi.OpenFrameContext(root)
455+
require.NoError(t, runtime.SetFramePID(ownerCtx, pid.PID{Host: "test", UniqID: "owner"}))
456+
startContractAsyncForTest(ownerCtx, t, d, instance, "run", "@future:shared")
457+
var callCtx context.Context
458+
select {
459+
case callCtx = <-started:
460+
case <-time.After(time.Second):
461+
t.Fatal("timeout waiting for contract call to start")
462+
}
463+
464+
otherCtx, _ := ctxapi.OpenFrameContext(root)
465+
require.NoError(t, runtime.SetFramePID(otherCtx, pid.PID{Host: "test", UniqID: "other"}))
466+
cancelContractAsyncForTest(otherCtx, t, d, "@future:shared")
467+
require.NoError(t, callCtx.Err())
468+
469+
cancelContractAsyncForTest(ownerCtx, t, d, "@future:shared")
470+
select {
471+
case err := <-canceled:
472+
require.ErrorIs(t, err, context.Canceled)
473+
case <-time.After(time.Second):
474+
t.Fatal("timeout waiting for owning caller cancellation")
475+
}
476+
}
477+
478+
func TestAsyncCallHandler_ReusedOwnedTopicCancelsOnlyTheReplacedCall(t *testing.T) {
479+
node := &mockRelayNode{packages: make(chan *relay.Package, 10)}
480+
started := make(chan context.Context, 2)
481+
canceled := make(chan error, 2)
482+
instance := &mockInstance{callFn: func(ctx context.Context, _ string, _ payload.Payloads, _ runtime.Options) (*runtime.Result, error) {
483+
started <- ctx
484+
<-ctx.Done()
485+
canceled <- ctx.Err()
486+
return nil, ctx.Err()
487+
}}
488+
d := NewDispatcher(node, nil)
489+
ctx := setupAsyncTestContext()
490+
491+
startContractAsyncForTest(ctx, t, d, instance, "first", "@future:reused")
492+
firstCtx := <-started
493+
startContractAsyncForTest(ctx, t, d, instance, "second", "@future:reused")
494+
secondCtx := <-started
495+
select {
496+
case err := <-canceled:
497+
require.ErrorIs(t, err, context.Canceled)
498+
case <-time.After(time.Second):
499+
t.Fatal("timeout waiting for replaced call cancellation")
500+
}
501+
require.ErrorIs(t, firstCtx.Err(), context.Canceled)
502+
require.NoError(t, secondCtx.Err())
503+
504+
cancelContractAsyncForTest(ctx, t, d, "@future:reused")
505+
select {
506+
case err := <-canceled:
507+
require.ErrorIs(t, err, context.Canceled)
508+
case <-time.After(time.Second):
509+
t.Fatal("timeout waiting for current call cancellation")
510+
}
511+
require.Len(t, node.packages, 1, "replaced call must not terminate the current future")
512+
}
513+
347514
func TestDispatcher_RegisterAll(t *testing.T) {
348515
d := NewDispatcher(nil, nil)
349516

@@ -508,6 +675,32 @@ func TestDispatcher_StartStop(t *testing.T) {
508675
assert.NoError(t, err)
509676
}
510677

678+
func TestDispatcher_StopCancelsOwnedAsyncCalls(t *testing.T) {
679+
node := &mockRelayNode{packages: make(chan *relay.Package, 10)}
680+
started := make(chan struct{})
681+
canceled := make(chan error, 1)
682+
instance := &mockInstance{callFn: func(ctx context.Context, _ string, _ payload.Payloads, _ runtime.Options) (*runtime.Result, error) {
683+
close(started)
684+
<-ctx.Done()
685+
canceled <- ctx.Err()
686+
return nil, ctx.Err()
687+
}}
688+
d := NewDispatcher(node, nil)
689+
startContractAsyncForTest(setupAsyncTestContext(), t, d, instance, "run", "@future:stop")
690+
select {
691+
case <-started:
692+
case <-time.After(time.Second):
693+
t.Fatal("timeout waiting for contract call to start")
694+
}
695+
require.NoError(t, d.Stop(context.Background()))
696+
select {
697+
case err := <-canceled:
698+
require.ErrorIs(t, err, context.Canceled)
699+
case <-time.After(time.Second):
700+
t.Fatal("timeout waiting for dispatcher shutdown cancellation")
701+
}
702+
}
703+
511704
func TestOpenHandler_ContextCanceled(t *testing.T) {
512705
d := NewDispatcher(nil, nil)
513706
mockInst := &mockInstantiator{

0 commit comments

Comments
 (0)