Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-single-don-capability-serving.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink": patch
---

Fix capability serving in single-DON topologies where the same DON is both workflow and capability DON (e.g. local CRE). Previously capabilities failed with "empty workflowDONs provided" because only remote workflow DONs were passed to serveCapabilities. #bugfix
10 changes: 9 additions & 1 deletion core/capabilities/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,8 +401,16 @@ func (w *launcher) OnNewRegistry(ctx context.Context, localRegistry *registrysyn

belongsToACapabilityDON := len(myCapabilityDONs) > 0
if belongsToACapabilityDON {
// Include both remote workflow DONs and the node's own workflow DONs.
// In single-DON topologies (e.g. local CRE), the same DON is both a
// workflow DON and a capability DON, so remoteWorkflowDONs is empty.
// Without including myWorkflowDONs, capabilities fail to serve with
// "empty workflowDONs provided".
allWorkflowDONs := make([]registrysyncer.DON, 0, len(remoteWorkflowDONs)+len(myWorkflowDONs))
allWorkflowDONs = append(allWorkflowDONs, remoteWorkflowDONs...)
allWorkflowDONs = append(allWorkflowDONs, myWorkflowDONs...)
for _, myDON := range myCapabilityDONs {
w.serveCapabilities(ctx, w.myPeerID, myDON, localRegistry, remoteWorkflowDONs)
w.serveCapabilities(ctx, w.myPeerID, myDON, localRegistry, allWorkflowDONs)
Comment on lines +404 to +413
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a regression test covering the single-DON topology described in the PR (node belongs to both workflow and capability roles in the same DON). Today this change is untested, and without a test it’s easy to reintroduce passing an empty workflow DON allowlist into the executable/trigger servers (which hard-fail with "empty workflowDONs provided"). A launcher_test.go case should assert that OnNewRegistry successfully serves a capability when remoteWorkflowDONs is empty but myWorkflowDONs is non-empty (e.g., by verifying dispatcher.SetReceiver is called and OnNewRegistry returns nil).

Copilot uses AI. Check for mistakes.
}
}

Expand Down
67 changes: 67 additions & 0 deletions core/capabilities/launcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,73 @@ func TestLauncher(t *testing.T) {
assert.Equal(t, 1, observedLogs.FilterMessage("failed to serve capability").Len())
})

t.Run("OK-single_DON_serves_capabilities", func(t *testing.T) {
// Regression test: in single-DON topologies (e.g. local CRE), the same
// DON is both a workflow DON and a capability DON. The DON appears in
// myWorkflowDONs (not remoteWorkflowDONs). Previously, serveCapabilities
// only received remoteWorkflowDONs, causing executable.Server.SetConfig
// to fail with "empty workflowDONs provided".
lggr, observedLogs := logger.TestObserved(t, zapcore.DebugLevel)
registry := NewRegistry(lggr)
dispatcher := remoteMocks.NewDispatcher(t)

nodes := newNodes(4)
peer := mocks.NewPeer(t)
peer.On("UpdateConnections", mock.Anything).Return(nil)
peer.On("ID").Return(nodes[0])
peer.On("IsBootstrap").Return(false)
wrapper := mocks.NewPeerWrapper(t)
wrapper.On("GetPeer").Return(peer)

fullTriggerCapID := "streams-trigger@1.0.0"
mt := newMockTrigger(capabilities.MustNewCapabilityInfo(
fullTriggerCapID,
capabilities.CapabilityTypeTrigger,
"streams trigger",
))
require.NoError(t, registry.Add(t.Context(), mt))

fullTargetID := "write-chain_evm_1@1.0.0"
mtarg := &mockCapability{
CapabilityInfo: capabilities.MustNewCapabilityInfo(
fullTargetID,
capabilities.CapabilityTypeTarget,
"write chain",
),
}
require.NoError(t, registry.Add(t.Context(), mtarg))

triggerCapID := RandomUTF8BytesWord()
targetCapID := RandomUTF8BytesWord()

// Single DON: acceptsWorkflows=true AND has capability configurations.
// This puts it in both myWorkflowDONs and myCapabilityDONs.
dID := uint32(1)
localRegistry := buildLocalRegistry()
addDON(localRegistry, dID, uint32(0), uint8(1), true, true, nodes, []string{"zone-a"}, 1, [][32]byte{triggerCapID, targetCapID})
addCapabilityToDON(localRegistry, dID, fullTriggerCapID, capabilities.CapabilityTypeTrigger, nil)
addCapabilityToDON(localRegistry, dID, fullTargetID, capabilities.CapabilityTypeTarget, nil)

launcher, err := NewLauncher(
lggr,
wrapper,
nil,
nil,
dispatcher,
registry,
&mockDonNotifier{},
)
require.NoError(t, err)
require.NoError(t, launcher.Start(t.Context()))
defer launcher.Close()

dispatcher.On("SetReceiver", fullTriggerCapID, dID, mock.AnythingOfType("*remote.triggerPublisher")).Return(nil)
dispatcher.On("SetReceiver", fullTargetID, dID, mock.AnythingOfType("*executable.server")).Return(nil)

require.NoError(t, launcher.OnNewRegistry(t.Context(), localRegistry))
assert.Equal(t, 0, observedLogs.FilterMessage("failed to serve capability").Len())
})

t.Run("start and close with nil peer wrapper", func(t *testing.T) {
lggr := logger.Test(t)
registry := NewRegistry(lggr)
Expand Down
Loading