Fix command executor timeout handling and add detached mode

- Add detached mode for long-running processes via detached=true config
- Fix process termination on timeout using process groups and kill -TERM/-KILL
- Remove double shell wrapping that was breaking command execution
- Track PIDs via wrapper script to enable proper process cleanup
- Flush port messages after timeout to prevent Tortoise MQTT errors
- Update example config to demonstrate detached command usage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ryan 2025-08-12 17:18:39 -07:00
parent 01b20ef01c
commit b67824c1f6
3 changed files with 231 additions and 54 deletions

View File

@ -42,7 +42,8 @@ defmodule Systant.CommandExecutor do
description: cmd["description"], description: cmd["description"],
trigger: cmd["trigger"], trigger: cmd["trigger"],
allowed_params: cmd["allowed_params"] || [], allowed_params: cmd["allowed_params"] || [],
timeout: cmd["timeout"] || 10 timeout: cmd["timeout"] || 10,
detached: cmd["detached"] || false
} }
end) end)
else else
@ -115,8 +116,8 @@ defmodule Systant.CommandExecutor do
# If running as root and this looks like a Wayland command, wrap with sudo # If running as root and this looks like a Wayland command, wrap with sudo
final_command_with_user = maybe_wrap_with_sudo(final_command_string, command_config) final_command_with_user = maybe_wrap_with_sudo(final_command_string, command_config)
# Always wrap in sh -c for shell features (variables, pipes, etc.) # Return the command string directly - we'll handle shell execution in execute_regular_command
{:ok, ["sh", "-c", final_command_with_user]} {:ok, final_command_with_user}
else else
{:error, "Command configuration must be a string"} {:error, "Command configuration must be a string"}
end end
@ -172,18 +173,6 @@ defmodule Systant.CommandExecutor do
end) end)
end end
defp substitute_parameters(command_parts, params) do
param_map = build_param_map(params)
Enum.map(command_parts, fn part ->
case part do
"$" <> var_name ->
Map.get(param_map, var_name, part)
_ ->
part
end
end)
end
defp build_param_map(params) do defp build_param_map(params) do
# For now, use simple mapping: first param is $SERVICE, $PATH, $PROCESS, $HOST, etc. # For now, use simple mapping: first param is $SERVICE, $PATH, $PROCESS, $HOST, etc.
@ -241,66 +230,233 @@ defmodule Systant.CommandExecutor do
end end
defp execute_system_command(final_command, command_config, parsed_command) do defp execute_system_command(final_command, command_config, parsed_command) do
is_detached = command_config["detached"] || false
timeout = (command_config["timeout"] || 10) * 1000 # Convert to milliseconds timeout = (command_config["timeout"] || 10) * 1000 # Convert to milliseconds
start_time = System.monotonic_time(:millisecond)
# Build environment for command execution # Build environment for command execution
env = build_command_environment() env = build_command_environment()
if is_detached do
Logger.info("Executing detached command: #{inspect(final_command)}")
else
Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)") Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)")
end
Logger.debug("Environment PATH: #{Map.get(env, "PATH")}") Logger.debug("Environment PATH: #{Map.get(env, "PATH")}")
Logger.debug("Environment USER: #{Map.get(env, "USER")}") Logger.debug("Environment USER: #{Map.get(env, "USER")}")
Logger.debug("Environment HOME: #{Map.get(env, "HOME")}") Logger.debug("Environment HOME: #{Map.get(env, "HOME")}")
Logger.debug("Environment XDG_RUNTIME_DIR: #{Map.get(env, "XDG_RUNTIME_DIR")}") Logger.debug("Environment XDG_RUNTIME_DIR: #{Map.get(env, "XDG_RUNTIME_DIR")}")
try do if is_detached do
task = Task.async(fn -> # For detached processes, spawn and immediately return success
System.cmd(List.first(final_command), Enum.drop(final_command, 1), execute_detached_command(final_command, env, parsed_command)
stderr_to_stdout: true, env: env) else
end) # For regular processes, wait for completion with timeout
execute_regular_command(final_command, env, timeout, parsed_command)
end
end
case Task.await(task, timeout) do defp execute_detached_command(command_string, env, parsed_command) do
{output, 0} -> try do
execution_time = System.monotonic_time(:millisecond) - start_time # Use spawn to start process without waiting
Logger.info("Command completed successfully in #{execution_time}ms") port = Port.open({:spawn_executable, "/bin/sh"}, [
:binary,
:exit_status,
args: ["-c", command_string],
env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end)
])
# Close the port immediately to detach
Port.close(port)
Logger.info("Detached command started successfully")
{:ok, %{ {:ok, %{
request_id: parsed_command.request_id, request_id: parsed_command.request_id,
command: parsed_command.trigger, command: parsed_command.trigger,
status: "success", status: "success",
output: String.trim(output), output: "Command started in detached mode",
execution_time: execution_time / 1000.0, detached: true,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601() timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
}} }}
{output, exit_code} ->
execution_time = System.monotonic_time(:millisecond) - start_time
Logger.warning("Command failed with exit code #{exit_code} in #{execution_time}ms")
{:ok, %{
request_id: parsed_command.request_id,
command: parsed_command.trigger,
status: "error",
output: String.trim(output),
error: "Command exited with code #{exit_code}",
execution_time: execution_time / 1000.0,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
}}
end
rescue rescue
error -> error ->
execution_time = System.monotonic_time(:millisecond) - start_time Logger.error("Failed to start detached command: #{inspect(error)}")
Logger.error("Command execution failed: #{inspect(error)}")
{:ok, %{ {:ok, %{
request_id: parsed_command.request_id, request_id: parsed_command.request_id,
command: parsed_command.trigger, command: parsed_command.trigger,
status: "error", status: "error",
output: "", output: "",
error: "Execution failed: #{inspect(error)}", error: "Failed to start detached command: #{inspect(error)}",
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
}}
end
end
defp execute_regular_command(command_string, env, timeout, parsed_command) do
start_time = System.monotonic_time(:millisecond)
# Wrap the command with PID tracking
wrapper_script = """
echo "SYSTANT_PID:$$"
exec #{command_string}
"""
port = Port.open({:spawn_executable, "/bin/sh"}, [
:binary,
:exit_status,
:stderr_to_stdout,
args: ["-c", wrapper_script],
env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end)
])
# Set up monitoring
ref = Port.monitor(port)
# Collect output with PID extraction
output = collect_port_output_with_pid(port, ref, timeout, "", nil)
case output do
{:ok, data, exit_status, _pid} ->
execution_time = System.monotonic_time(:millisecond) - start_time
case exit_status do
0 ->
Logger.info("Command completed successfully in #{execution_time}ms")
{:ok, %{
request_id: parsed_command.request_id,
command: parsed_command.trigger,
status: "success",
output: String.trim(data),
execution_time: execution_time / 1000.0, execution_time: execution_time / 1000.0,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601() timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
}} }}
code ->
Logger.warning("Command failed with exit code #{code} in #{execution_time}ms")
{:ok, %{
request_id: parsed_command.request_id,
command: parsed_command.trigger,
status: "error",
output: String.trim(data),
error: "Command exited with code #{code}",
execution_time: execution_time / 1000.0,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
}}
end
{:timeout, partial_output, pid} ->
execution_time = System.monotonic_time(:millisecond) - start_time
# First, close the port to prevent more data
try do
Port.close(port)
rescue
_ -> :ok
end
# Kill the process group if we have a PID
if pid do
kill_process_group(pid)
end
# Flush any remaining port messages to prevent them from going to other processes
flush_port_messages(port)
Logger.error("Command timed out after #{timeout}ms and was terminated")
{:ok, %{
request_id: parsed_command.request_id,
command: parsed_command.trigger,
status: "error",
output: String.trim(partial_output),
error: "Command timed out after #{timeout / 1000} seconds and was terminated",
execution_time: execution_time / 1000.0,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
}}
{:error, reason} ->
execution_time = System.monotonic_time(:millisecond) - start_time
Logger.error("Command execution failed: #{inspect(reason)}")
{:ok, %{
request_id: parsed_command.request_id,
command: parsed_command.trigger,
status: "error",
output: "",
error: "Execution failed: #{inspect(reason)}",
execution_time: execution_time / 1000.0,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
}}
end
end
defp kill_process_group(pid) when is_integer(pid) do
# Kill the entire process group
Logger.info("Killing process group for PID #{pid}")
System.cmd("kill", ["-TERM", "-#{pid}"], stderr_to_stdout: true)
# Give it a moment to terminate gracefully
Process.sleep(100)
# Force kill if still alive
case System.cmd("kill", ["-0", "#{pid}"], stderr_to_stdout: true) do
{_, 0} ->
Logger.warning("Process #{pid} still alive, sending SIGKILL")
System.cmd("kill", ["-KILL", "-#{pid}"], stderr_to_stdout: true)
_ ->
:ok
end
end
defp kill_process_group(_), do: :ok
defp flush_port_messages(port) do
receive do
{^port, _} ->
# Recursively flush more messages
flush_port_messages(port)
after
0 ->
# No more messages
:ok
end
end
defp collect_port_output_with_pid(port, ref, timeout, acc, pid) do
receive do
{^port, {:data, data}} ->
# Extract PID if we see it in the output
{new_pid, cleaned_data} = extract_pid(data, pid)
collect_port_output_with_pid(port, ref, timeout, acc <> cleaned_data, new_pid)
{^port, {:exit_status, status}} ->
# Demonitor to avoid receiving DOWN message
Port.demonitor(ref, [:flush])
{:ok, acc, status, pid}
{:DOWN, ^ref, :port, ^port, reason} ->
{:error, reason}
after
timeout ->
# Demonitor to avoid receiving DOWN message after timeout
Port.demonitor(ref, [:flush])
{:timeout, acc, pid}
end
end
defp extract_pid(data, current_pid) do
case Regex.run(~r/SYSTANT_PID:(\d+)\n/, data) do
[full_match, pid_str] ->
pid = String.to_integer(pid_str)
cleaned = String.replace(data, full_match, "")
{pid, cleaned}
nil ->
{current_pid, data}
end end
end end

View File

@ -120,11 +120,24 @@ log_all_commands = true
[[commands.available]] [[commands.available]]
name = "screenshot" name = "screenshot"
trigger = "screenshot" trigger = "screenshot"
description = "Take a screenshot and save to home directory" description = "Take a screenshot and save to ~/Pictures/Screenshots"
command = "grim /home/ryan/screenshot-$(date +%Y%m%d-%H%M%S).png" command = "grim /home/ryan/Pictures/Screenshots/screenshot-$(date +%Y%m%d-%H%M%S).png"
[[commands.available]] [[commands.available]]
name = "lock_screen" name = "lock_screen"
trigger = "lock" trigger = "lock"
description = "Lock the screen immediately" description = "Lock the screen immediately"
command = "hyprctl dispatch exec swaylock" command = "hyprctl dispatch exec swaylock"
[[commands.available]]
name = "android_mirror_start"
trigger = "android_mirror_start"
description = "Start Android screen mirroring"
command = "scrcpy --tcpip=luna.home --window-title luna.scrcpy -S --audio-source=playback --audio-dup"
detached = true
[[commands.available]]
name = "android_mirror_stop"
trigger = "android_mirror_stop"
description = "Stop Android screen mirroring"
command = "hyprctl clients -j | jq '. | map(select(.title == \"luna.scrcpy\")).[0].pid' | xargs kill"

View File

@ -102,6 +102,14 @@ command = "ping"
allowed_params = ["-c", "4", "8.8.8.8", "google.com", "1.1.1.1"] allowed_params = ["-c", "4", "8.8.8.8", "google.com", "1.1.1.1"]
description = "Network connectivity test" description = "Network connectivity test"
# Example of a detached command for long-running processes
[[commands.available]]
trigger = "start_app"
command = "firefox"
detached = true # Don't wait for the process to exit, just launch it
timeout = 5 # Timeout only applies to launching, not running
description = "Start Firefox browser (detached)"
[logging] [logging]
level = "info" # debug, info, warn, error level = "info" # debug, info, warn, error
log_config_changes = true log_config_changes = true