Skip to content

Instantly share code, notes, and snippets.

@orderthruchaos
Last active November 28, 2019 12:44
Show Gist options
  • Save orderthruchaos/8580c3638acb5eea6407 to your computer and use it in GitHub Desktop.
Save orderthruchaos/8580c3638acb5eea6407 to your computer and use it in GitHub Desktop.
A possible q/0 helper for Elixir.IEx.

A possible q/0 helper for IEx.Helpers

Table of Contents

  • Summary
  • Installation and Usage Instructions
  • Description
    • Interactive Shell Types
      • Smart: :user_drv
      • Dumb: :user
  • Caveats
  • License

Summary

I love Elixir. Period. But, the lack of a q/0 helper in IEx has driven me nuts since (my) day 1.

This is a proof-of-concept that a safe q/0 helper may be possible.

And, yes, it even works after switching nodes via the JCL.

TL;DR: Installation and Usage Instructions

For the impatient, I'll put this right up top. I highly recommend you read the remainder of this document, though. In particular the Caveats may be useful to know.

Append the contents of iex.exs to your ~/.iex.exs file.

Note that the script automagically aliases IExExs.Helpers to H for you.

Within IEx, use H.q/0 to quit:

iex> H.q

You may also wish to try the H.jobs/0 helper:

iex> H.jobs

If you would like to just use jobs/0 and/or q/0, then:

iex> import IExExs.Helpers
iex> jobs
iex> q

Description

Some time ago, I created a naïve pull request for a q/0 function in IEx.Helpers. Anyway, it basically called :erlang.halt/0. @josevalim was kind enough to explain the rejection, rather than flat out reject it:

Unfortunately this is hurtful when running on a remote shell because it turns off the remote node as well. For this reason we haven't added any sort of quit function so far. We'd gladly accept one if it means only turning off the current node.

This says to me that there is a safe way to turn off the IEx node.

Interactive Shell Types

Exploring both the Elixir and Erlang/OTP source showed two basic shell types: dumb and smart. Each of these has its own issues that are worth discussing.

Smart: :user_drv

Smart terminals use :user_drv for user interaction. This allows the user to type Ctrl-G and interact with the Job Control Language (JCL). This allows the user to quit gracefully with the q command.

Unfortunately, there is no way to directly interact with the JCL via programming. I'm not sure why this design decision was made, but I find it a bit frustrating.

It is possible, however, to perform I/O with the :user_drv process on behalf of the user. This is a bit tricky, mostly due to timing reasons. I'll go into the details in Caveats, below.

The important message here: It is possible, though tricky, to interact with the JCL on behalf of the user.

Dumb: :user

First, let me say that the only way I've found to start a dumb terminal is by using iex.bat on Windows without the --werl flag. My experiments with it have shown the following:

  • Dumb terminals cannot use the JCL
  • Dumb terminals cannot be named via --name or --sname
  • Dumb terminals cannot
    • connect to a remote node
    • be connected to by a remote node

Because of this, ending a session with :erlang.halt/0 should be safe. Regardless, I've left it commented out in the iex.exs file (line 83). Uncomment the line if you wish to try it out.

Caveats

The dumb terminal caveats are simply that lack of certainty in safety. If it is safe to use :erlang.halt/0, then this is the easy one.

The smart terminal, on the other hand, is a bit tricky.

First, this implementation is highly dependent on the internals of user_drv.erl. If I had my druthers, I would rewrite it and make the JCL more directly programmable. However, this is a proof of concept, and that seems to be going a bit far (though, I'm still thinking of trying it).

Second, I/O timing can be tricky. Sometimes a pause is needed for :user_drv to process the Ctrl-G. I did find source in the Erlang/OTP test suite that showed a way to calibrate the pause, but it is not implemented here.

The short of it is, if q or j comes too quickly after Ctrl-G, :user_drv will not observe the input, and the user is left hanging at a JCL prompt.

License

Copyright 2015 Brett DiFrischia

  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.
if ! Enum.member?(:erlang.loaded, IEx.UserDrv.Config) do
defmodule IEx.UserDrv.Config do
import Process, only: [group_leader: 0]
@moduledoc """
Structure to hold :user_drv configuration information.
"""
defstruct node: Node.self, pid: nil, port: nil, leader: group_leader
@doc """
Send an input string to a port on a pid as set within the config struct.
"""
def send_input(%IEx.UserDrv.Config{pid: nil}, _), do: no_dumb_terminals
def send_input(%IEx.UserDrv.Config{port: nil}, _), do: no_dumb_terminals
def send_input(cfg, chars) do
send cfg.pid, {cfg.port, {:data, chars}}
end
defp no_dumb_terminals do
{:error, "Sorry, cannot interact with dumb terminals."}
end
end
defmodule IEx.UserDrv.Utils do
@moduledoc """
Brett's little helpers for IEx.UserDrv
"""
@ud_eol '\r'
@doc """
Add JCL end-of-line (\r) to the given input.
"""
defmacro jcl_eol(c) when is_integer c do
[ c | @ud_eol ]
end
defmacro jcl_eol(c, v) do
quote do
'#{unquote(c)} #{unquote(v)}#{unquote(@ud_eol)}'
end
end
end
defmodule IEx.UserDrv do
require IEx.UserDrv.Utils
import IEx.UserDrv.Utils
import Process, only: [group_leader: 0]
alias IEx.UserDrv.Config, as: Cfg
@moduledoc """
Interact with the 'user switch'/JCL programmatically.
"""
@ctrl_c [3]
@ctrl_g [7]
@doc """
Open the 'user switch' (JCL) interface for a given config.
"""
def user_switch(), do: user_switch(io_config)
def user_switch(%Cfg{} = cfg), do: send_user_drv(cfg, @ctrl_g)
@doc """
Open the JCL and enter the q command for the user. This assumes that
entering the JCL via Ctrl-G and using the q command is safe.
"""
def quit!() do
case user_drv_pid do
nil -> quit_dumb_terminal
_ -> user_switch() |> send_user_drv(jcl_eol(?q))
end
end
defp quit_dumb_terminal() do
{:error, "Do not know if quitting a dumb terminal is safe."}
# # If quitting a dumb terminal via :erlang.halt is safe, uncomment:
# :erlang.halt()
end
@doc """
Open the JCL then enter the j and c commands for the user.
"""
def jobs(), do: jobs(io_config)
def jobs(%Cfg{} = cfg) do
cfg
|> user_switch()
|> send_user_drv(jcl_eol(?j))
|> send_user_drv(jcl_eol(?c))
IO.puts ""
IEx.dont_display_result
end
@doc """
Determine the driver ID for the group leader.
"""
def get_user_drv(gl \\ group_leader) do
send gl, {:driver_id, self()}
receive do
{^gl, :driver_id, id} -> id
_ -> get_user_drv(gl)
after 1000 -> nil
end
end
def io_config(pid \\ group_leader) do
_io_config(Node.alive?, pid)
end
defp _io_config(false, _pid), do: quick_config
defp _io_config(true, pid) do
case node(pid) do
nil -> nil
nd ->
Node.spawn(nd, __MODULE__, :get_io_config_for, [self, pid])
recv_config
end
end
def get_io_config_for(other, pid) do
fnd = case this_node_has_process(pid) do
p when is_pid(p) -> quick_config
_ -> nil
end
send other, { :config, fnd }
end
# Helpers
# Generate a default configuration based on the current node and
# group leader.
defp quick_config do
%Cfg{ node: Node.self, pid: user_drv_pid, port: io_port }
end
# Configuration receiver.
defp recv_config() do
receive do
{ :config, msg } when not is_nil msg -> msg
_ -> recv_config()
end
end
# Send the :user_drv an array of characters and wait for the response.
defp send_user_drv(nil, _), do: nil
defp send_user_drv({:error, _} = err, _), do: err
defp send_user_drv(%Cfg{node: to_node} = cfg, chars) do
if Node.alive? do
Node.spawn to_node, Cfg, :send_input, [cfg, chars]
cfg
else
resp = Cfg.send_input cfg, chars
case resp do
{:error, _} -> resp
_ -> cfg
end
end
end
defp send_user_drv(cmd, chars) when is_atom cmd do
send get_user_drv, {io_port, {:data, chars}}
end
# Get the Pid for the :user_drv.
defp user_drv_pid, do: Process.whereis :user_drv
# Search ports for the IO port linked to the :user_drv.
defp io_port do
_udrv_port = Port.list |> Enum.find(fn(p) ->
Dict.get(Port.info(p), :links, []) |>
is_user_driver_linked?
end)
end
# Determine if a group of links has a connection to the :user_drv.
defp is_user_driver_linked?(links) do
Enum.any? links, &(&1 == user_drv_pid)
end
# Return if the given Pid is on this node.
defp this_node_has_process(pid) do
Process.list
|> Enum.find(fn (p) -> p == pid end)
end
end
defmodule IExExs.Helpers do
require IEx.UserDrv
@moduledoc """
Brett's little helpers for IEx.
"""
@doc """
IExExs.Helpers.q/0 for the quitter!
"""
def q, do: IEx.UserDrv.quit!
def prompt_after_load(_node \\ Node.self) do
IO.puts "\n\nimport IExExs.Helpers\n# or\nimport H\n"
end
def jobs, do: IEx.UserDrv.jobs
end
end
alias IExExs.Helpers, as: H
IExExs.Helpers.prompt_after_load()
:ok
Copyright 2015 Brett DiFrischia
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.
@orderthruchaos
Copy link
Author

I updated the iex.exs filename to IEx.exs so the name of the gist would be more appropriate.

@orderthruchaos
Copy link
Author

I have a feeling that if one were to attach a process to the hosting IEx.Server, it may cause issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment