Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
a64679c
fix(python apps): fork a child and exec appcmd in the child to avoid …
sicoyle Jan 29, 2026
480a884
fix: final clean testing with pipes for app prefix
sicoyle Jan 29, 2026
6c802c1
Merge branch 'master' into fix-python-hangs
sicoyle Jan 29, 2026
2492ef4
style: appease linter
sicoyle Jan 29, 2026
9526f90
Merge branch 'fix-python-hangs' of ssh://github.com/sicoyle/dapr-cli …
sicoyle Jan 29, 2026
27e160a
fix: make windows tests happy
sicoyle Jan 29, 2026
c51c42f
style: appease linter
sicoyle Feb 2, 2026
647c648
fix: check for windows
sicoyle Feb 2, 2026
9af9922
style: appease linter again
sicoyle Feb 2, 2026
def81e4
Merge branch 'master' into fix-python-hangs
JoshVanL Feb 10, 2026
8e8516b
fix(build): update test
sicoyle Feb 10, 2026
f81bea0
Merge branch 'fix-python-hangs' of ssh://github.com/sicoyle/dapr-cli …
sicoyle Feb 10, 2026
5b2eb25
Merge branch 'master' into fix-python-hangs
sicoyle Feb 10, 2026
5e599f9
fix: updates for build to pass
sicoyle Feb 10, 2026
efe4d8a
Merge branch 'fix-python-hangs' of ssh://github.com/sicoyle/dapr-cli …
sicoyle Feb 10, 2026
e39d3a0
fix: ensure successful status on exit
sicoyle Feb 10, 2026
3335080
fix(build): use more build tags to prevent windows os build failures
sicoyle Feb 10, 2026
3bc794c
fix: use os specific cmds
sicoyle Feb 10, 2026
d4f9047
fix: updates for build
sicoyle Feb 10, 2026
a9b896e
fix: updates for build again
sicoyle Feb 10, 2026
f8460a7
fix: fixes for e2e tests
sicoyle Feb 10, 2026
877699a
fix: final fixes for build i think
sicoyle Feb 10, 2026
167668f
fix(tests): last update for tests
sicoyle Feb 10, 2026
a3abbd8
fix: add check to prevent unrelated panic
sicoyle Feb 10, 2026
b2cae33
fix: updates for e2e tests to be happy
sicoyle Feb 10, 2026
08ae06a
fix: acct for diff responses in assert
sicoyle Feb 10, 2026
c9d9de9
fix: update to require to prevent panic
sicoyle Feb 10, 2026
63922fa
fix: add early err check for same behavior
sicoyle Feb 10, 2026
25702c8
fix: run app like normal on windows
sicoyle Feb 10, 2026
5eb36f6
fix: update test accordingly
sicoyle Feb 10, 2026
ac00a1f
fix: handle windows app exec and test err paths
sicoyle Feb 11, 2026
ade38f1
fix(tests): update for more windows speciftic cmds
sicoyle Feb 16, 2026
99160ae
style: updates per feedback
sicoyle Feb 16, 2026
fd34ce5
fix(tests): allow time for ports to be released in cleanup
sicoyle Feb 16, 2026
4d80fc6
fix: kill processes launched via shell exec
sicoyle Feb 16, 2026
5c48a85
fix: handle app already exiting naturally
sicoyle Feb 16, 2026
0f939d4
fix: handle when process already exited
sicoyle Feb 16, 2026
cd0dc9a
fix: add delay for tests
sicoyle Feb 16, 2026
9523307
fix: handle diff order for test output
sicoyle Feb 16, 2026
cfa739f
style: random comment to bump ci
sicoyle Feb 16, 2026
e6e90d2
Merge branch 'master' into fix-python-hangs
JoshVanL Feb 19, 2026
e6258f9
fix: updates based on ai code review
sicoyle Feb 23, 2026
56f50b5
Merge branch 'fix-python-hangs' of ssh://github.com/sicoyle/dapr-cli …
sicoyle Feb 23, 2026
a7898c5
fix: update for test
sicoyle Feb 23, 2026
af6b33e
fix: handle cases without app cmd in test
sicoyle Feb 23, 2026
9f4e89a
fix: pin docker version to fix unrelated testissues
sicoyle Feb 23, 2026
2c217a7
fix: allow time for port releases
sicoyle Feb 23, 2026
5b36220
fix: address feedback
sicoyle Feb 23, 2026
2f65eef
fix: see if this helps with the e2e faliures
sicoyle Feb 23, 2026
73bf225
fix: up the times
sicoyle Feb 23, 2026
e96aebc
fix: up timeouts
sicoyle Feb 23, 2026
c91b919
fix: handle scheudler port conflict issue flake
sicoyle Feb 23, 2026
e1a10e8
fix: update to fix flakey scheduler test
sicoyle Feb 23, 2026
9ee93d4
style: updates per feedback
sicoyle Feb 24, 2026
1c4af6c
fix: update for test
sicoyle Feb 24, 2026
88883d9
fix: address final feedback
sicoyle Feb 24, 2026
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
97 changes: 48 additions & 49 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ package cmd

import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
Expand Down Expand Up @@ -160,9 +162,9 @@ dapr run --run-file /path/to/directory -k
fmt.Println(print.WhiteBold("WARNING: no application command found."))
}

daprDirPath, err := standalone.GetDaprRuntimePath(cmdruntime.GetDaprRuntimePath())
if err != nil {
print.FailureStatusEvent(os.Stderr, "Failed to get Dapr install directory: %v", err)
daprDirPath, pathErr := standalone.GetDaprRuntimePath(cmdruntime.GetDaprRuntimePath())
if pathErr != nil {
print.FailureStatusEvent(os.Stderr, "Failed to get Dapr install directory: %v", pathErr)
os.Exit(1)
}

Expand Down Expand Up @@ -227,7 +229,7 @@ dapr run --run-file /path/to/directory -k
sharedRunConfig.SchedulerHostAddress = &addr
}
}
output, err := runExec.NewOutput(&standalone.RunConfig{
appConfig := &standalone.RunConfig{
AppID: appID,
AppChannelAddress: appChannelAddress,
AppPort: appPort,
Expand All @@ -239,7 +241,8 @@ dapr run --run-file /path/to/directory -k
UnixDomainSocket: unixDomainSocket,
InternalGRPCPort: internalGRPCPort,
SharedRunConfig: *sharedRunConfig,
})
}
output, err := runExec.NewOutput(appConfig)
if err != nil {
print.FailureStatusEvent(os.Stderr, err.Error())
os.Exit(1)
Expand Down Expand Up @@ -280,6 +283,8 @@ dapr run --run-file /path/to/directory -k

output.DaprCMD.Stdout = os.Stdout
output.DaprCMD.Stderr = os.Stderr
// Set process group so sidecar survives when we exec the app process.
setDaprProcessGroupForRun(output.DaprCMD)

err = output.DaprCMD.Start()
if err != nil {
Expand Down Expand Up @@ -355,53 +360,26 @@ dapr run --run-file /path/to/directory -k
return
}

stdErrPipe, pipeErr := output.AppCMD.StderrPipe()
if pipeErr != nil {
print.FailureStatusEvent(os.Stderr, "Error creating stderr for App: "+err.Error())
command := args[0]
var binary string
binary, err = exec.LookPath(command)
if err != nil {
print.FailureStatusEvent(os.Stderr, fmt.Sprintf("Failed to find command %s: %v", command, err))
appRunning <- false
return
}

stdOutPipe, pipeErr := output.AppCMD.StdoutPipe()
if pipeErr != nil {
print.FailureStatusEvent(os.Stderr, "Error creating stdout for App: "+err.Error())
appRunning <- false
return
env := output.AppCMD.Env
if len(env) == 0 {
env = os.Environ()
}
env = append(env, fmt.Sprintf("DAPR_HTTP_PORT=%d", output.DaprHTTPPort))
env = append(env, fmt.Sprintf("DAPR_GRPC_PORT=%d", output.DaprGRPCPort))

errScanner := bufio.NewScanner(stdErrPipe)
outScanner := bufio.NewScanner(stdOutPipe)
go func() {
for errScanner.Scan() {
fmt.Println(print.Blue("== APP == " + errScanner.Text()))
}
}()

go func() {
for outScanner.Scan() {
fmt.Println(print.Blue("== APP == " + outScanner.Text()))
}
}()

err = output.AppCMD.Start()
if err != nil {
print.FailureStatusEvent(os.Stderr, err.Error())
if startErr := startAppProcessInBackground(output, binary, args, env, sigCh); startErr != nil {
print.FailureStatusEvent(os.Stderr, startErr.Error())
appRunning <- false
return
}

go func() {
appErr := output.AppCMD.Wait()

if appErr != nil {
output.AppErr = appErr
print.FailureStatusEvent(os.Stderr, "The App process exited with error code: %s", appErr.Error())
} else {
print.SuccessStatusEvent(os.Stdout, "Exited App successfully")
}
sigCh <- os.Interrupt
}()

appRunning <- true
}()

Expand Down Expand Up @@ -465,11 +443,16 @@ dapr run --run-file /path/to/directory -k
if output.AppErr != nil {
exitWithError = true
print.FailureStatusEvent(os.Stderr, fmt.Sprintf("Error exiting App: %s", output.AppErr))
} else if output.AppCMD != nil && (output.AppCMD.ProcessState == nil || !output.AppCMD.ProcessState.Exited()) {
} else if output.AppCMD != nil && output.AppCMD.Process != nil && (output.AppCMD.ProcessState == nil || !output.AppCMD.ProcessState.Exited()) {
err = output.AppCMD.Process.Kill()
if err != nil {
exitWithError = true
print.FailureStatusEvent(os.Stderr, fmt.Sprintf("Error exiting App: %s", err))
// If the process already exited on its own, treat this as a clean shutdown.
if errors.Is(err, os.ErrProcessDone) {
print.SuccessStatusEvent(os.Stdout, "Exited App successfully")
} else {
exitWithError = true
print.FailureStatusEvent(os.Stderr, fmt.Sprintf("Error exiting App: %s", err))
}
} else {
print.SuccessStatusEvent(os.Stdout, "Exited App successfully")
}
Expand Down Expand Up @@ -788,6 +771,13 @@ func startDaprdAndAppProcesses(runConfig *standalone.RunConfig, commandDir strin
return runState, nil
}

if strings.TrimSpace(runConfig.Command[0]) == "" {
noCmdErr := errors.New("exec: no command")
print.StatusEvent(appErrorWriter, print.LogFailure, "Error starting app process: %s", noCmdErr.Error())
_ = killDaprdProcess(runState)
return nil, noCmdErr
}

// Start App process.
go startAppProcess(runConfig, runState, appRunning, sigCh, startErrChan)

Expand Down Expand Up @@ -836,7 +826,7 @@ func stopDaprdAndAppProcesses(runState *runExec.RunExec) bool {
if appErr != nil {
exitWithError = true
print.StatusEvent(runState.AppCMD.ErrorWriter, print.LogFailure, "Error exiting App: %s", appErr)
} else if runState.AppCMD.Command != nil && (runState.AppCMD.Command.ProcessState == nil || !runState.AppCMD.Command.ProcessState.Exited()) {
} else if runState.AppCMD.Command != nil && runState.AppCMD.Command.Process != nil && (runState.AppCMD.Command.ProcessState == nil || !runState.AppCMD.Command.ProcessState.Exited()) {
err = killAppProcess(runState)
if err != nil {
exitWithError = true
Expand Down Expand Up @@ -1009,11 +999,20 @@ func killDaprdProcess(runE *runExec.RunExec) error {

// killAppProcess is used to kill the App process and return error on failure.
func killAppProcess(runE *runExec.RunExec) error {
if runE.AppCMD.Command == nil {
if runE.AppCMD.Command == nil || runE.AppCMD.Command.Process == nil {
return nil
}
// Check if the process has already exited on its own.
if runE.AppCMD.Command.ProcessState != nil && runE.AppCMD.Command.ProcessState.Exited() {
// Process already exited, no need to kill it.
return nil
}
err := runE.AppCMD.Command.Process.Kill()
if err != nil {
// If the process already exited on its own
if errors.Is(err, os.ErrProcessDone) {
return nil
}
print.StatusEvent(runE.DaprCMD.ErrorWriter, print.LogFailure, "Error exiting App: %s", err)
return err
}
Expand Down
87 changes: 87 additions & 0 deletions cmd/run_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//go:build !windows

/*
Copyright 2026 The Dapr Authors
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 cmd

import (
"fmt"
"os"
"os/exec"
"syscall"

"github.com/dapr/cli/pkg/print"
runExec "github.com/dapr/cli/pkg/runexec"
)

// setDaprProcessGroupForRun sets the process group on the daprd command so the
// sidecar can be managed independently (e.g. when the app is started via exec).
func setDaprProcessGroupForRun(cmd *exec.Cmd) {
if cmd == nil {
return
}
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
}

// startAppProcessInBackground starts the app process using ForkExec.
// This prevents the child from seeing a fork, avoiding Python async/threading issues,
// and sets output.AppCMD.Process.
// It then runs a goroutine that waits and signals sigCh.
func startAppProcessInBackground(output *runExec.RunOutput, binary string, args []string, env []string, sigCh chan os.Signal) error {
if output.AppCMD == nil || output.AppCMD.Process != nil {
return fmt.Errorf("app command is nil")
}

procAttr := &syscall.ProcAttr{
Env: env,
// stdin, stdout, and stderr inherit directly from the parent
// This prevents Python from detecting pipes because if the app is Python then it will detect the pipes and think
// it's a fork and will cause random hangs due to async python in durabletask-python.
Files: []uintptr{0, 1, 2},
Sys: &syscall.SysProcAttr{
Setpgid: true,
},
}

// Use ForkExec to fork a child, then exec python in the child.
// NOTE: This is needed bc forking a python app with async python running (i.e., everything in durabletask-python) will cause random hangs, no matter the python version.
// Doing this this way makes python not see the fork, starts via exec, so it doesn't cause random hangs due to when forking async python apps where locks and such get corrupted in forking.
argv := append([]string{binary}, args[1:]...)
pid, err := syscall.ForkExec(binary, argv, procAttr)
if err != nil {
return fmt.Errorf("failed to fork/exec app: %w", err)
}
output.AppCMD.Process = &os.Process{Pid: pid}

go func() {
var waitStatus syscall.WaitStatus
_, err := syscall.Wait4(pid, &waitStatus, 0, nil)
if err != nil {
output.AppErr = err
print.FailureStatusEvent(os.Stderr, "The App process exited with error: %s", err.Error())
} else if waitStatus.Signaled() {
output.AppErr = fmt.Errorf("app terminated by signal: %s", waitStatus.Signal())
print.FailureStatusEvent(os.Stderr, "The App process was terminated by signal: %s", waitStatus.Signal())
} else if waitStatus.Exited() && waitStatus.ExitStatus() != 0 {
output.AppErr = fmt.Errorf("app exited with status %d", waitStatus.ExitStatus())
print.FailureStatusEvent(os.Stderr, "The App process exited with error code: %d", waitStatus.ExitStatus())
} else {
print.SuccessStatusEvent(os.Stdout, "Exited App successfully")
}
sigCh <- os.Interrupt
}()
return nil
}
66 changes: 66 additions & 0 deletions cmd/run_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//go:build windows

/*
Copyright 2026 The Dapr Authors
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 cmd

import (
"fmt"
"os"
"os/exec"

"github.com/dapr/cli/pkg/print"
runExec "github.com/dapr/cli/pkg/runexec"
)

// setDaprProcessGroupForRun is a no-op on Windows (SysProcAttr.Setpgid does not exist).
func setDaprProcessGroupForRun(cmd *exec.Cmd) {
// no-op on Windows
_ = cmd
}

// startAppProcessInBackground starts the app process using exec.Command,
// sets output.AppCMD to the new command, and runs a goroutine that waits and signals sigCh.
func startAppProcessInBackground(output *runExec.RunOutput, binary string, args []string, env []string, sigCh chan os.Signal) error {
cmdArgs := args[1:]
if output.AppCMD == nil {
output.AppCMD = exec.Command(binary, cmdArgs...)
} else {
output.AppCMD.Path = binary
output.AppCMD.Args = append([]string{binary}, cmdArgs...)
}
output.AppCMD.Env = env
output.AppCMD.Stdin = os.Stdin
output.AppCMD.Stdout = os.Stdout
output.AppCMD.Stderr = os.Stderr

if err := output.AppCMD.Start(); err != nil {
return fmt.Errorf("failed to start app: %w", err)
}

go func() {
waitErr := output.AppCMD.Wait()
if waitErr != nil {
output.AppErr = waitErr
print.FailureStatusEvent(os.Stderr, "The App process exited with error: %s", waitErr.Error())
} else if output.AppCMD.ProcessState != nil && !output.AppCMD.ProcessState.Success() {
output.AppErr = fmt.Errorf("app exited with status %d", output.AppCMD.ProcessState.ExitCode())
print.FailureStatusEvent(os.Stderr, "The App process exited with error code: %d", output.AppCMD.ProcessState.ExitCode())
} else {
print.SuccessStatusEvent(os.Stdout, "Exited App successfully")
}
sigCh <- os.Interrupt
}()
return nil
}
22 changes: 20 additions & 2 deletions pkg/runexec/runexec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"fmt"
"os"
"regexp"
"runtime"
"strings"
"testing"

Expand All @@ -27,6 +28,8 @@ import (
"github.com/dapr/cli/pkg/standalone"
)

const windowsOsType = "windows"

func assertArgumentEqual(t *testing.T, key string, expectedValue string, args []string) {
var value string
for index, arg := range args {
Expand Down Expand Up @@ -205,8 +208,23 @@ func TestRun(t *testing.T) {
assert.NoError(t, err)

assertCommonArgs(t, basicConfig, output)
assert.Equal(t, "MyCommand", output.AppCMD.Args[0])
assert.Equal(t, "--my-arg", output.AppCMD.Args[1])
require.NotNil(t, output.AppCMD)
if runtime.GOOS == windowsOsType {
// On Windows the app is run directly (no shell).
require.GreaterOrEqual(t, len(output.AppCMD.Args), 2)
assert.Equal(t, "MyCommand", output.AppCMD.Args[0])
assert.Equal(t, "--my-arg", output.AppCMD.Args[1])
} else {
// On Unix the app command is executed via a shell wrapper
require.GreaterOrEqual(t, len(output.AppCMD.Args), 5)
assert.Equal(t, "sh", output.AppCMD.Args[0])
assert.Equal(t, "-c", output.AppCMD.Args[1])
assert.Equal(t, "exec \"$@\"", output.AppCMD.Args[2])
assert.Equal(t, "sh", output.AppCMD.Args[3])
assert.Equal(t, "MyCommand", output.AppCMD.Args[4])
assert.Equal(t, "--my-arg", output.AppCMD.Args[5])
}

assertArgumentEqual(t, "app-channel-address", "localhost", output.DaprCMD.Args)
assertAppEnv(t, basicConfig, output)
})
Expand Down
Loading
Loading