Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ Sprites.close_stdin(command)
Sprites.resize(command, 40, 120)
```

#### Running after disconnect

Commands keep running on the sprite after the client disconnects: by default
non-TTY commands are stopped 10 seconds later, while TTY commands run with no
time limit. Pass `max_run_after_disconnect` (a duration string such as `"10s"`
or `"5m"`) to `Sprites.spawn/4` or `Sprites.cmd/4` to control that timeout:

```elixir
{:ok, command} = Sprites.spawn(sprite, "bash", [], tty: true, max_run_after_disconnect: "10s")
```

#### Provider session identity

Asynchronous callers can opt into `session_info: true` to receive
Expand Down
17 changes: 17 additions & 0 deletions lib/sprites.ex
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ defmodule Sprites do
* `:tty` - Allocate a TTY (default: false)
* `:tty_rows` - TTY rows (default: 24)
* `:tty_cols` - TTY columns (default: 80)
* `:max_run_after_disconnect` - How long the command may keep running
after the client disconnects, as a Go-style duration string such as
`"10s"`, `"5m"` or `"1h30m"` (`"0"` stops it immediately; malformed
values raise `ArgumentError`). When omitted, the server default applies:
non-TTY commands (the default) are stopped 10 seconds after disconnect,
while TTY commands keep running with no time limit. Ignored when
attaching to an existing session.

## Examples

Expand Down Expand Up @@ -143,6 +150,13 @@ defmodule Sprites do
* `:tty` - Allocate a TTY (default: false)
* `:tty_rows` - TTY rows (default: 24)
* `:tty_cols` - TTY columns (default: 80)
* `:max_run_after_disconnect` - How long the command may keep running
after the client disconnects, as a Go-style duration string such as
`"10s"`, `"5m"` or `"1h30m"` (`"0"` stops it immediately; malformed
values raise `ArgumentError`). When omitted, the server default applies:
non-TTY commands (the default) are stopped 10 seconds after disconnect,
while TTY commands keep running with no time limit. Ignored when
attaching to an existing session.
* `:session_info` - Report provider session identity to the owner as
`{:session_info, %{ref: ref}, session_id}` (default: false). The ID is a
string. Missing metadata yields no identity; it is never inferred from
Expand All @@ -153,6 +167,9 @@ defmodule Sprites do

{:ok, cmd} = Sprites.spawn(sprite, "bash", ["-i"], tty: true)

# Stop the command 10s after the client disconnects
{:ok, cmd} = Sprites.spawn(sprite, "bash", [], tty: true, max_run_after_disconnect: "10s")

receive do
{:stdout, ^cmd, data} -> IO.write(data)
{:exit, ^cmd, code} -> IO.puts("Done: \#{code}")
Expand Down
76 changes: 48 additions & 28 deletions lib/sprites/command.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ defmodule Sprites.Command do

* **Direct mode** (default) — opens a new WebSocket per command to `/exec`
* **Control mode** — multiplexes over a persistent WebSocket to `/control`

Both modes accept the same exec options (see `Sprites.spawn/4`), including
`:max_run_after_disconnect`, a duration string such as `"10s"` that limits how
long the command keeps running after the client disconnects.
"""

use GenServer
Expand All @@ -36,9 +40,16 @@ defmodule Sprites.Command do

@doc """
Starts a command asynchronously.

Raises `ArgumentError` if `:max_run_after_disconnect` is not a duration string.
"""
@spec start(Sprite.t(), String.t(), [String.t()], keyword()) :: {:ok, t()} | {:error, term()}
def start(sprite, command, args, opts \\ []) do
# Validate up front so bad options raise in the caller instead of crashing
# the command process (which in control mode would leak a checked-out
# connection).
_ = Sprite.max_run_after_disconnect(opts)

owner = Keyword.get(opts, :owner, self())
tty_mode = Keyword.get(opts, :tty, false)

Expand Down Expand Up @@ -184,38 +195,47 @@ defmodule Sprites.Command do
end

defp build_control_args(command, args, opts, stdin) do
control_args = %{"cmd" => [command | args]}

control_args =
case Keyword.get(opts, :dir) do
nil -> control_args
dir -> Map.put(control_args, "dir", dir)
end

control_args =
case Keyword.get(opts, :env, []) do
[] ->
control_args
%{"cmd" => [command | args]}
|> put_control_dir(opts)
|> put_control_env(opts)
|> put_control_tty(opts)
|> put_control_max_run_after_disconnect(opts)
|> Map.put("stdin", if(stdin, do: "true", else: "false"))
end

defp put_control_dir(control_args, opts) do
case Keyword.get(opts, :dir) do
nil -> control_args
dir -> Map.put(control_args, "dir", dir)
end
end

env_list ->
env_strs = Enum.map(env_list, fn {k, v} -> "#{k}=#{v}" end)
Map.put(control_args, "env", env_strs)
end
defp put_control_env(control_args, opts) do
case Keyword.get(opts, :env, []) do
[] -> control_args
env_list -> Map.put(control_args, "env", Enum.map(env_list, fn {k, v} -> "#{k}=#{v}" end))
end
end

control_args =
if Keyword.get(opts, :tty, false) do
rows = Keyword.get(opts, :tty_rows, 24)
cols = Keyword.get(opts, :tty_cols, 80)
defp put_control_tty(control_args, opts) do
if Keyword.get(opts, :tty, false) do
rows = Keyword.get(opts, :tty_rows, 24)
cols = Keyword.get(opts, :tty_cols, 80)

control_args
|> Map.put("tty", "true")
|> Map.put("rows", to_string(rows))
|> Map.put("cols", to_string(cols))
else
control_args
end
control_args
|> Map.put("tty", "true")
|> Map.put("rows", to_string(rows))
|> Map.put("cols", to_string(cols))
else
control_args
end
end

Map.put(control_args, "stdin", if(stdin, do: "true", else: "false"))
defp put_control_max_run_after_disconnect(control_args, opts) do
case Sprite.max_run_after_disconnect(opts) do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate duration before checking out control connections

When control mode is selected and a non-string value is passed, Control.checkout/1 has already marked conn_pid busy before this validation raises. The init process then exits before ControlConn.start_op/4 and never executes Control.checkin/2, so the still-live connection remains marked busy; repeated rejected spawns consume sockets and can fill the 100-slot pool, forcing later valid control commands down the direct fallback. Validate before checkout or check the connection back in on this failure path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed in 0fd982c: the option is now validated in Command.start/4 before the GenServer starts, so no checkout happens and callers get an ArgumentError directly; the test asserts neither transport is contacted.

nil -> control_args
duration -> Map.put(control_args, "max_run_after_disconnect", duration)
end
end

defp do_direct_init(sprite, command, args, opts, token, state) do
Expand Down
46 changes: 45 additions & 1 deletion lib/sprites/sprite.ex
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,45 @@ defmodule Sprites.Sprite do
client.token
end

# Go `time.ParseDuration` syntax, which is what the server parses: one or
# more decimal numbers each with a unit (e.g. "10s", "1h30m", "1.5s"), or a
# bare "0". Negative durations are rejected.
@duration_regex ~r/^(?:0|(?:(?:\d+\.?\d*|\.\d+)(?:ns|us|µs|μs|ms|s|m|h))+)$/u

@doc """
Returns the validated `:max_run_after_disconnect` option, or `nil` when unset.

The value must be a duration string in Go `time.ParseDuration` syntax, such
as `"10s"`, `"5m"`, `"1h30m"` or `"0"`. Anything else raises `ArgumentError`:
the server ignores unparseable values, which would silently disable the
timeout. Shared by direct (`/exec`) and control-mode exec.
"""
@spec max_run_after_disconnect(keyword()) :: String.t() | nil
def max_run_after_disconnect(opts) do
case Keyword.get(opts, :max_run_after_disconnect) do
nil ->
nil

duration when is_binary(duration) ->
if Regex.match?(@duration_regex, duration) do
duration
else
raise ArgumentError,
"expected :max_run_after_disconnect to be a duration string such as " <>
"\"10s\", \"5m\" or \"1h30m\", got: #{inspect(duration)}"
end

other ->
raise ArgumentError,
"expected :max_run_after_disconnect to be a duration string such as \"10s\", " <>
"got: #{inspect(other)}"
end
end

defp build_query_params(command, args, opts) do
# When attaching to an existing session (`:session_id` set), the server
# ignores cmd/path/env/dir/detachable — they belong to the original spawn.
# ignores cmd/path/env/dir/detachable/max_run_after_disconnect — they
# belong to the original spawn.
# Only stdin/tty are meaningful for the attach side.
case Keyword.get(opts, :session_id) do
nil ->
Expand All @@ -102,6 +138,7 @@ defmodule Sprites.Sprite do
|> add_env_params(opts)
|> add_tty_params(opts)
|> add_detachable_param(opts)
|> add_max_run_after_disconnect_param(opts)

_session_id ->
[]
Expand Down Expand Up @@ -146,4 +183,11 @@ defmodule Sprites.Sprite do
params
end
end

defp add_max_run_after_disconnect_param(params, opts) do
case max_run_after_disconnect(opts) do
nil -> params
duration -> [{"max_run_after_disconnect", duration} | params]
end
end
end
4 changes: 4 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule Sprites.MixProject do
version: @version,
elixir: "~> 1.15",
start_permanent: Mix.env() == :prod,
elixirc_paths: elixirc_paths(Mix.env()),
deps: deps(),
name: "Sprites",
description:
Expand All @@ -26,6 +27,9 @@ defmodule Sprites.MixProject do
]
end

defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]

defp deps do
[
{:req, "~> 0.5"},
Expand Down
Loading
Loading