Skip to content

Commit 08ff7e2

Browse files
authored
fix: Multiple gRPC Create/Init/Destroy cycles causes multiple pluggable-discovery zombie process (#2985)
* Refactor mocked-discovery integration tests * Added integration-test to check handling of multiple discovery instances * Correctly clean-up pluggable-discoveries on gRPC instance close
1 parent 869fced commit 08ff7e2

File tree

6 files changed

+152
-55
lines changed

6 files changed

+152
-55
lines changed

commands/internal/instances/instances.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,12 @@ func IsValid(inst *rpc.Instance) bool {
172172
// Delete removes an instance.
173173
func Delete(inst *rpc.Instance) bool {
174174
instancesMux.Lock()
175-
defer instancesMux.Unlock()
176-
if _, ok := instances[inst.GetId()]; !ok {
175+
instance, ok := instances[inst.GetId()]
176+
delete(instances, inst.GetId())
177+
instancesMux.Unlock()
178+
if !ok {
177179
return false
178180
}
179-
delete(instances, inst.GetId())
181+
instance.pm.Destroy()
180182
return true
181183
}

internal/arduino/cores/packagemanager/package_manager.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ func (pm *PackageManager) NewExplorer() (explorer *Explorer, release func()) {
197197
}, pm.packagesLock.RUnlock
198198
}
199199

200+
// Destroy releases all resources held by the PackageManager.
201+
func (pm *PackageManager) Destroy() {
202+
pm.discoveryManager.Clear()
203+
}
204+
200205
// GetProfile returns the active profile for this package manager, or nil if no profile is selected.
201206
func (pme *Explorer) GetProfile() *sketch.Profile {
202207
return pme.profile

internal/integrationtest/arduino-cli.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,13 @@ func (cli *ArduinoCLI) Create() *ArduinoCLIInstance {
506506
}
507507
}
508508

509+
// Destroy calls the "Destroy" gRPC method.
510+
func (inst *ArduinoCLIInstance) Destroy(ctx context.Context) error {
511+
logCallf(">>> Destroy(%v)\n", inst.instance.GetId())
512+
_, err := inst.cli.daemonClient.Destroy(ctx, &commands.DestroyRequest{Instance: inst.instance})
513+
return err
514+
}
515+
509516
// SetValue calls the "SetValue" gRPC method.
510517
func (cli *ArduinoCLI) SetValue(key, jsonData string) error {
511518
req := &commands.SettingsSetValueRequest{

internal/integrationtest/board/board_test.go

Lines changed: 25 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -71,23 +71,6 @@ func TestCorrectBoardListOrdering(t *testing.T) {
7171
]`)
7272
}
7373

74-
func TestBoardList(t *testing.T) {
75-
env, cli := integrationtest.CreateArduinoCLIWithEnvironment(t)
76-
defer env.CleanUp()
77-
78-
_, _, err := cli.Run("core", "update-index")
79-
require.NoError(t, err)
80-
81-
cli.InstallMockedSerialDiscovery(t)
82-
83-
stdout, _, err := cli.Run("board", "list", "--json")
84-
require.NoError(t, err)
85-
// check is a valid json and contains a list of ports
86-
requirejson.Parse(t, stdout).
87-
Query(`[ .detected_ports | .[].port | select(.protocol == null or .protocol_label == null) ]`).
88-
MustBeEmpty()
89-
}
90-
9174
func TestBoardListMock(t *testing.T) {
9275
env, cli := integrationtest.CreateArduinoCLIWithEnvironment(t)
9376
defer env.CleanUp()
@@ -97,11 +80,20 @@ func TestBoardListMock(t *testing.T) {
9780

9881
cli.InstallMockedSerialDiscovery(t)
9982

100-
stdout, _, err := cli.Run("board", "list", "--json")
101-
require.NoError(t, err)
83+
t.Run("IsValidJsonPortList", func(t *testing.T) {
84+
// 1. Check is a valid JSON
85+
// 2. Check is a valid port list (has address/protocol)
86+
stdout, _, err := cli.Run("board", "list", "--json")
87+
require.NoError(t, err)
88+
requirejson.Parse(t, stdout).
89+
Query(`[ .detected_ports | .[].port | select(.address == null or .protocol == null) ]`).
90+
MustBeEmpty()
91+
})
10292

103-
// check is a valid json and contains a list of ports
104-
requirejson.Contains(t, stdout, `{
93+
t.Run("ContainsMockedPorts", func(t *testing.T) {
94+
stdout, _, err := cli.Run("board", "list", "--json")
95+
require.NoError(t, err)
96+
requirejson.Contains(t, stdout, `{
10597
"detected_ports": [
10698
{
10799
"matching_boards": [
@@ -124,35 +116,20 @@ func TestBoardListMock(t *testing.T) {
124116
}
125117
}
126118
]
127-
}`)
128-
}
129-
130-
func TestBoardListWithFqbnFilter(t *testing.T) {
131-
env, cli := integrationtest.CreateArduinoCLIWithEnvironment(t)
132-
defer env.CleanUp()
133-
134-
_, _, err := cli.Run("core", "update-index")
135-
require.NoError(t, err)
136-
137-
cli.InstallMockedSerialDiscovery(t)
138-
139-
stdout, _, err := cli.Run("board", "list", "-b", "foo:bar:baz", "--json")
140-
require.NoError(t, err)
141-
requirejson.Query(t, stdout, `.detected_ports | length`, `0`)
142-
}
143-
144-
func TestBoardListWithFqbnFilterInvalid(t *testing.T) {
145-
env, cli := integrationtest.CreateArduinoCLIWithEnvironment(t)
146-
defer env.CleanUp()
147-
148-
_, _, err := cli.Run("core", "update-index")
149-
require.NoError(t, err)
119+
}`)
120+
})
150121

151-
cli.InstallMockedSerialDiscovery(t)
122+
t.Run("ListWithFqbnFilter", func(t *testing.T) {
123+
stdout, _, err := cli.Run("board", "list", "-b", "foo:bar:baz", "--json")
124+
require.NoError(t, err)
125+
requirejson.Query(t, stdout, `.detected_ports | length`, `0`)
126+
})
152127

153-
_, stderr, err := cli.Run("board", "list", "-b", "yadayada", "--json")
154-
require.Error(t, err)
155-
requirejson.Query(t, stderr, ".error", `"Invalid FQBN: not an FQBN: yadayada"`)
128+
t.Run("ListWithFqbnFilterInvalid", func(t *testing.T) {
129+
_, stderr, err := cli.Run("board", "list", "-b", "yadayada", "--json")
130+
require.Error(t, err)
131+
requirejson.Query(t, stderr, ".error", `"Invalid FQBN: not an FQBN: yadayada"`)
132+
})
156133
}
157134

158135
func TestBoardListall(t *testing.T) {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// This file is part of arduino-cli.
2+
//
3+
// Copyright 2025 ARDUINO SA (http://www.arduino.cc/)
4+
//
5+
// This software is released under the GNU General Public License version 3,
6+
// which covers the main part of arduino-cli.
7+
// The terms of this license can be found at:
8+
// https://www.gnu.org/licenses/gpl-3.0.en.html
9+
//
10+
// You can be released from the requirements of the above licenses by purchasing
11+
// a commercial license. Buying such a license is mandatory if you want to
12+
// modify or otherwise use the software for commercial activities involving the
13+
// Arduino software without disclosing the source code of your own applications.
14+
// To purchase a commercial license, send an email to [email protected].
15+
16+
package daemon
17+
18+
import (
19+
"fmt"
20+
"testing"
21+
"time"
22+
23+
"github.com/arduino/arduino-cli/internal/integrationtest"
24+
"github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1"
25+
"github.com/stretchr/testify/require"
26+
)
27+
28+
func TestBoardListMock(t *testing.T) {
29+
env, cli := integrationtest.CreateEnvForDaemon(t)
30+
defer env.CleanUp()
31+
32+
_, _, err := cli.Run("core", "update-index")
33+
require.NoError(t, err)
34+
35+
cli.InstallMockedSerialDiscovery(t)
36+
37+
var tmp1, tmp2 string
38+
39+
{
40+
// Create a new instance of the daemon
41+
grpcInst := cli.Create()
42+
require.NoError(t, grpcInst.Init("", "", func(ir *commands.InitResponse) {
43+
fmt.Printf("INIT> %v\n", ir.GetMessage())
44+
}))
45+
46+
// Run a BoardList
47+
resp, err := grpcInst.BoardList(time.Second)
48+
require.NoError(t, err)
49+
require.NotEmpty(t, resp.Ports)
50+
for _, port := range resp.Ports {
51+
if port.GetPort().GetProtocol() == "serial" {
52+
tmp1 = port.Port.GetProperties()["discovery_tmp"]
53+
}
54+
}
55+
require.NotEmpty(t, tmp1)
56+
57+
// Close instance
58+
require.NoError(t, grpcInst.Destroy(t.Context()))
59+
}
60+
61+
{
62+
// Create a second instance of the daemon
63+
grpcInst := cli.Create()
64+
require.NoError(t, grpcInst.Init("", "", func(ir *commands.InitResponse) {
65+
fmt.Printf("INIT> %v\n", ir.GetMessage())
66+
}))
67+
68+
// Run a BoardList
69+
resp, err := grpcInst.BoardList(time.Second)
70+
require.NoError(t, err)
71+
require.NotEmpty(t, resp.Ports)
72+
for _, port := range resp.Ports {
73+
if port.GetPort().GetProtocol() == "serial" {
74+
tmp2 = port.Port.GetProperties()["discovery_tmp"]
75+
}
76+
}
77+
require.NotEmpty(t, tmp2)
78+
79+
// Close instance
80+
require.NoError(t, grpcInst.Destroy(t.Context()))
81+
}
82+
83+
// Check if the discoveries have been successfully close
84+
require.NoFileExists(t, tmp1, "discovery has not been closed")
85+
require.NoFileExists(t, tmp2, "discovery has not been closed")
86+
}

internal/mock_serial_discovery/main.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,39 @@ package main
1919

2020
import (
2121
"errors"
22+
"fmt"
2223
"os"
2324
"time"
2425

26+
"github.com/arduino/go-paths-helper"
2527
"github.com/arduino/go-properties-orderedmap"
2628
discovery "github.com/arduino/pluggable-discovery-protocol-handler/v2"
2729
)
2830

2931
type mockSerialDiscovery struct {
3032
startSyncCount int
3133
closeChan chan<- bool
34+
tmpFile string
3235
}
3336

3437
func main() {
35-
dummy := &mockSerialDiscovery{}
38+
// Write a file in a $TMP/mock_serial_discovery folder.
39+
// This file will be used by the integration tests to detect if the discovery is running.
40+
tmpDir := paths.TempDir().Join("mock_serial_discovery")
41+
if err := tmpDir.MkdirAll(); err != nil {
42+
fmt.Fprintf(os.Stderr, "Error creating temp dir: %v\n", err)
43+
os.Exit(1)
44+
}
45+
tmpFile, err := paths.MkTempFile(tmpDir, "")
46+
if err != nil {
47+
fmt.Fprintf(os.Stderr, "Error creating temp file: %v\n", err)
48+
os.Exit(1)
49+
}
50+
tmpFile.Close()
51+
defer os.Remove(tmpFile.Name())
52+
53+
// Run mock discovery
54+
dummy := &mockSerialDiscovery{tmpFile: tmpFile.Name()}
3655
server := discovery.NewServer(dummy)
3756
if err := server.Run(os.Stdin, os.Stdout); err != nil {
3857
os.Exit(1)
@@ -80,9 +99,10 @@ func (d *mockSerialDiscovery) StartSync(eventCB discovery.EventCallback, errorCB
8099
ProtocolLabel: "Serial",
81100
HardwareID: "123456",
82101
Properties: properties.NewFromHashmap(map[string]string{
83-
"vid": "0x2341",
84-
"pid": "0x0041",
85-
"serial": "123456",
102+
"vid": "0x2341",
103+
"pid": "0x0041",
104+
"serial": "123456",
105+
"discovery_tmp": d.tmpFile,
86106
}),
87107
})
88108

0 commit comments

Comments
 (0)