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"],
|
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
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user