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:
parent
01b20ef01c
commit
b67824c1f6
@ -42,7 +42,8 @@ defmodule Systant.CommandExecutor do
|
||||
description: cmd["description"],
|
||||
trigger: cmd["trigger"],
|
||||
allowed_params: cmd["allowed_params"] || [],
|
||||
timeout: cmd["timeout"] || 10
|
||||
timeout: cmd["timeout"] || 10,
|
||||
detached: cmd["detached"] || false
|
||||
}
|
||||
end)
|
||||
else
|
||||
@ -115,8 +116,8 @@ defmodule Systant.CommandExecutor do
|
||||
# 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)
|
||||
|
||||
# Always wrap in sh -c for shell features (variables, pipes, etc.)
|
||||
{:ok, ["sh", "-c", final_command_with_user]}
|
||||
# Return the command string directly - we'll handle shell execution in execute_regular_command
|
||||
{:ok, final_command_with_user}
|
||||
else
|
||||
{:error, "Command configuration must be a string"}
|
||||
end
|
||||
@ -172,18 +173,6 @@ defmodule Systant.CommandExecutor do
|
||||
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
|
||||
# For now, use simple mapping: first param is $SERVICE, $PATH, $PROCESS, $HOST, etc.
|
||||
@ -241,66 +230,233 @@ defmodule Systant.CommandExecutor do
|
||||
end
|
||||
|
||||
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
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
# Build environment for command execution
|
||||
env = build_command_environment()
|
||||
|
||||
Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)")
|
||||
if is_detached do
|
||||
Logger.info("Executing detached command: #{inspect(final_command)}")
|
||||
else
|
||||
Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)")
|
||||
end
|
||||
|
||||
Logger.debug("Environment PATH: #{Map.get(env, "PATH")}")
|
||||
Logger.debug("Environment USER: #{Map.get(env, "USER")}")
|
||||
Logger.debug("Environment HOME: #{Map.get(env, "HOME")}")
|
||||
Logger.debug("Environment XDG_RUNTIME_DIR: #{Map.get(env, "XDG_RUNTIME_DIR")}")
|
||||
|
||||
if is_detached do
|
||||
# For detached processes, spawn and immediately return success
|
||||
execute_detached_command(final_command, env, parsed_command)
|
||||
else
|
||||
# For regular processes, wait for completion with timeout
|
||||
execute_regular_command(final_command, env, timeout, parsed_command)
|
||||
end
|
||||
end
|
||||
|
||||
defp execute_detached_command(command_string, env, parsed_command) do
|
||||
try do
|
||||
task = Task.async(fn ->
|
||||
System.cmd(List.first(final_command), Enum.drop(final_command, 1),
|
||||
stderr_to_stdout: true, env: env)
|
||||
end)
|
||||
# Use spawn to start process without waiting
|
||||
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)
|
||||
])
|
||||
|
||||
case Task.await(task, timeout) do
|
||||
{output, 0} ->
|
||||
execution_time = System.monotonic_time(:millisecond) - start_time
|
||||
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(output),
|
||||
execution_time: execution_time / 1000.0,
|
||||
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
|
||||
# Close the port immediately to detach
|
||||
Port.close(port)
|
||||
|
||||
Logger.info("Detached command started successfully")
|
||||
|
||||
{:ok, %{
|
||||
request_id: parsed_command.request_id,
|
||||
command: parsed_command.trigger,
|
||||
status: "success",
|
||||
output: "Command started in detached mode",
|
||||
detached: true,
|
||||
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
|
||||
}}
|
||||
rescue
|
||||
error ->
|
||||
execution_time = System.monotonic_time(:millisecond) - start_time
|
||||
Logger.error("Command execution failed: #{inspect(error)}")
|
||||
Logger.error("Failed to start detached command: #{inspect(error)}")
|
||||
|
||||
{:ok, %{
|
||||
request_id: parsed_command.request_id,
|
||||
command: parsed_command.trigger,
|
||||
status: "error",
|
||||
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,
|
||||
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
|
||||
|
||||
|
||||
@ -120,11 +120,24 @@ log_all_commands = true
|
||||
[[commands.available]]
|
||||
name = "screenshot"
|
||||
trigger = "screenshot"
|
||||
description = "Take a screenshot and save to home directory"
|
||||
command = "grim /home/ryan/screenshot-$(date +%Y%m%d-%H%M%S).png"
|
||||
description = "Take a screenshot and save to ~/Pictures/Screenshots"
|
||||
command = "grim /home/ryan/Pictures/Screenshots/screenshot-$(date +%Y%m%d-%H%M%S).png"
|
||||
|
||||
[[commands.available]]
|
||||
name = "lock_screen"
|
||||
trigger = "lock"
|
||||
description = "Lock the screen immediately"
|
||||
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"
|
||||
|
||||
@ -102,6 +102,14 @@ command = "ping"
|
||||
allowed_params = ["-c", "4", "8.8.8.8", "google.com", "1.1.1.1"]
|
||||
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]
|
||||
level = "info" # debug, info, warn, error
|
||||
log_config_changes = true
|
||||
|
||||
Loading…
Reference in New Issue
Block a user