Protocol.UndefinedError protocol String.chars not implemented for....what?

I’m working on some code that logs into routers and gathers information from them using SSH. I’m using the SSHEx module from hex.pm, which is a wrapper of Erlang ssh with some helper functions.

In the function that does the heavy lifting, I occasionally get an error that I can’t figure out. Here is the function:

def connect_and_run({hostname, command, receiver_pid}) do
    # :ssh_dbg.start()
    # :ssh_dbg.on()
    # :ssh.start()

    case SSHEx.connect(
           ip: to_charlist(hostname),
           user: @creds[:username],
           password: @creds[:password],
           auth_methods: ~c"password",
           connect_timeout: 7000,
           negotiation_timeout: 7000,
           modify_algorithms: [
             {:append, [{:public_key, [:"ssh-rsa"]}, {:kex, [:"diffie-hellman-group1-sha1"]}]}
           ]
         ) do
      {:ok, conn} ->
        sec = :rand.uniform(300)
        :timer.sleep(sec)
        output = run_command(conn, hostname, command)
        output = "*** #{hostname} ***\n" <> output
        send(receiver_pid, output)

      {:error, reason} ->
        send(receiver_pid, "*** Error connecting to #{hostname}: #{reason} ***\n")
    end
  end

If the command is successful, it sends the output from the command to a receiver process that is gathering all the responses. If an error happens, it sends an error to the receiver with some details.

Once in a while, I get this error:

20:58:09.257 [error] Process #PID<0.394.0> raised an exception
** (Protocol.UndefinedError) protocol String.Chars not implemented for {:options, []} of type Tuple. This protocol is implemented for the following type(s): Atom, BitString, Date, DateTime, Float, Integer, List, NaiveDateTime, Time, URI, Version, Version.Requirement
    (elixir 1.14.0) lib/string/chars.ex:3: String.Chars.impl_for!/1
    (elixir 1.14.0) lib/string/chars.ex:22: String.Chars.to_string/1
    (sshtest 0.1.0) lib/multissh.ex:95: MultiSSH.connect_and_run/1

I’m stumped because neither my code nor the SSHEx module have a tuple that looks like {:options, }. I have no idea where that is coming from or what the real problem might be. It says the problem is on line 95 in my code, which is:

end(receiver_pid, "*** Error connecting to #{hostname}: #{reason} ***\n")

How in the world do I troubleshoot this?

Thanks!

If you {hostname, command, receiver_pid} |> IO.inspect what do you get?

2 Likes

I would put an inspect statement for output before the problematic line.

If you {hostname, command, receiver_pid} |> IO.inspect what do you get?

That helped me figure it out, thanks! I was getting the list of devices from a file and the file had a blank line at the end that I wasn’t handling, so the failing attempt was trying to connect using an empty hostname. The actual error I got is still incomprehensible to me, though.

For now, I added “trim: true” to the String.split() function that is getting the device names from the file, so empty lines are discarded. I’m not overly pleased with the error message. I really don’t know how one would figure out the issue based on the message. It’s almost like the answer is to ignore the message details and just accept the fact that it’s telling you that something is wrong in that function, even if it doesn’t tell you what or which line.

The String.Chars protocol allows data types to provide an implementation for String.Chars.to_string/1, which is what Kernel.to_string/1 uses under the hood, but it is also used for String interpolation.

So this error is raised when you try to interpolate something into a string that does not implement the String.Chars protocol—in this case, the tuple {:options, []}:

This may be coming from one of the three places where you are interpolating variables into strings in this function, either hostname or reason. Hard to tell from the backtrace since I don’t know what your line numbers in the snippet are. You should make sure they are strings before interpolating them.

When I am emitting an error message to show what the values of a variable are when something went wrong, I tend to do something like "Error: #{inspect(value)}", as inspect/1 formats datastructures nicely, converts pretty much anything to a string, and often the cause of my error is an unexpected value type, so I cannot rely on it always being a string when trying to debug.

1 Like

I’m also really freaking pleased to report that this Elixir code is roughly 10 times faster than the equivalent Python code to asynchronously connect to these routers and gather this data. It’s unbelievable how much faster this is, but dealing with SSH in Erlang and Elixir is a lot more difficult than with Python. This has been a painful process, but enlightening. Now that I have this much working, I think I might rewrite the a recent Python program entirely in Elixir. The rest is easy. The hard part has been device connectivity, especially considering some of these routers don’t support newer cryptographic protocols. It took me forever (and a lot of time over on the Erlang and Elixir Slack channels) to figure out how to set the correct SSH options.

3 Likes

I wonder if this shows the root of the problem:

iex(7)> to_charlist("foobar")
'foobar'
iex(8)> to_charlist("")      
[]

The hostname is a string originally but must be converted to a charlist. That works for a string with contents, but an empty string becomes an empty list. I suspect that’s the empty list in {:options, []}. I’m still looking through the code for the module I’m using (SSHEx) but I think that might be it.

1 Like

I suspect this line (line 202) is the culprit - it produces the shape you’re seeing:

Note that the underlying :ssh.connect function makes no promises about the shape of errors beyond {:error, term()}:

In general, using inspect/1 is the safe choice for formatting error messages:

send(receiver_pid, "*** Error connecting to #{hostname}: #{inspect(reason)} ***\n")
2 Likes

Nice! I was looking at that exact code but I’m a little rusty and probably wouldn’t have figured it out. I really appreciate the help! Even though I was lost and wandering, at least it’s good to know I was wandering in generally the right direction. lol

IMO yes. You mentioned that you were parsing an empty line and if it’s not parsed to a binary but to a charlist then it’s very logical that you’d get [] as a value.

One note here. to_charlist isn’t doing anything different for an empty string than it is for a regular string. both [] and 'foobar' are lists. If you put the following into a terminal [97], you’ll see 'a', or rather it’s now ~c"a" to make things a bit clearer. Erlang has both string, which are a list of codepoint integers, as well as binaries, of which Elixir’s String.t() is a subset(it’s a subset since not all binaries are valid utf-8 strings. e.g. <<2,5>> is a binary but not a String.t(). <<97,98>> is a String.t(), specifically "ab")