Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
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
83 changes: 37 additions & 46 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,27 @@ 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
envMap := appConfig.GetEnv()
env := os.Environ()
for k, v := range envMap {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
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 @@ -468,8 +447,13 @@ dapr run --run-file /path/to/directory -k
} else if output.AppCMD != 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 +772,13 @@ func startDaprdAndAppProcesses(runConfig *standalone.RunConfig, commandDir strin
return runState, nil
}

if len(runConfig.Command) == 0 || 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
81 changes: 81 additions & 0 deletions cmd/run_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//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 {
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 (ie everything in durabletask-python) will cause random hangs, no matter the python version.
// Doing this this way makes python not sees 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.
pid, err := syscall.ForkExec(binary, args, 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.Exited() || waitStatus.ExitStatus() != 0 {
output.AppErr = fmt.Errorf("app exited with status %d", waitStatus.ExitStatus())
if waitStatus.ExitStatus() != 0 {
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
}
60 changes: 60 additions & 0 deletions cmd/run_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//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 {
appCmd := exec.Command(binary, args...)
appCmd.Env = env
appCmd.Stdin = os.Stdin
appCmd.Stdout = os.Stdout
appCmd.Stderr = os.Stderr
if err := appCmd.Start(); err != nil {
return fmt.Errorf("failed to start app: %w", err)
}
output.AppCMD = appCmd

go func() {
waitErr := appCmd.Wait()
if waitErr != nil {
output.AppErr = waitErr
print.FailureStatusEvent(os.Stderr, "The App process exited with error: %s", waitErr.Error())
} else if appCmd.ProcessState != nil && !appCmd.ProcessState.Success() {
output.AppErr = fmt.Errorf("app exited with status %d", appCmd.ProcessState.ExitCode())
print.FailureStatusEvent(os.Stderr, "The App process exited with error code: %d", appCmd.ProcessState.ExitCode())
} else {
print.SuccessStatusEvent(os.Stdout, "Exited App successfully")
}
sigCh <- os.Interrupt
}()
return nil
}
21 changes: 19 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,22 @@ 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 (/bin/sh -c "exec MyCommand --my-arg").
require.GreaterOrEqual(t, len(output.AppCMD.Args), 3)
assert.Equal(t, "/bin/sh", output.AppCMD.Args[0])
assert.Equal(t, "-c", output.AppCMD.Args[1])
shellCmd := output.AppCMD.Args[2]
assert.Contains(t, shellCmd, "MyCommand")
assert.Contains(t, shellCmd, "--my-arg")
}

assertArgumentEqual(t, "app-channel-address", "localhost", output.DaprCMD.Args)
assertAppEnv(t, basicConfig, output)
})
Expand Down
28 changes: 28 additions & 0 deletions pkg/standalone/process_group_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//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 standalone

import (
"os/exec"
"syscall"
)

// setProcessGroup sets the process group on non-Windows platforms so the child process can be managed independently.
func setProcessGroup(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
}
26 changes: 26 additions & 0 deletions pkg/standalone/process_group_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//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 standalone

import "os/exec"

// setProcessGroup is a no-op on Windows because the syscall.SysProcAttr
// fields used on Unix (such as Setpgid) are not available.
func setProcessGroup(cmd *exec.Cmd) {
// no-op on Windows
// TODO: In future we should check if Windows has the same Async Python issues and address them if so.
_ = cmd
}
Loading
Loading