From eb6d47fa369d091420fe501fe454cd3ebf470b55 Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Fri, 2 Feb 2024 01:48:40 +0100 Subject: [PATCH 1/5] Store Session and SshChannel callbacks in their structs This is necessary to prevent them accidentally getting garbage collected, which would cause segfaults. Also cleaned up some other code that worked around that previously by explicitly storing the callbacks in other structs. --- src/LibSSH.jl | 2 +- src/channel.jl | 4 +++- src/server.jl | 27 +++++++++++++-------------- src/session.jl | 7 ++++--- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/LibSSH.jl b/src/LibSSH.jl index 68c16d6..a2100bd 100644 --- a/src/LibSSH.jl +++ b/src/LibSSH.jl @@ -159,8 +159,8 @@ function lib_version() end include("pki.jl") -include("session.jl") include("callbacks.jl") +include("session.jl") include("channel.jl") include("message.jl") include("server.jl") diff --git a/src/channel.jl b/src/channel.jl index e280b0e..636c2d4 100644 --- a/src/channel.jl +++ b/src/channel.jl @@ -20,6 +20,7 @@ mutable struct SshChannel session::Union{Session, Nothing} close_lock::ReentrantLock local_eof::Bool + callbacks::Union{Callbacks.ChannelCallbacks, Nothing} @doc """ $(TYPEDSIGNATURES) @@ -36,7 +37,7 @@ mutable struct SshChannel elseif own && !isnothing(session) && !session.owning throw(ArgumentError("Cannot create a SshChannel from a non-owning Session")) end - self = new(ptr, own, session, ReentrantLock(), false) + self = new(ptr, own, session, ReentrantLock(), false, nothing) if own push!(session.channels, self) @@ -267,6 +268,7 @@ function set_channel_callbacks(sshchan::SshChannel, callbacks::Callbacks.Channel if ret != SSH_OK throw(LibSSHException("Error when setting channel callbacks: $(ret)")) end + sshchan.callbacks = callbacks end """ diff --git a/src/server.jl b/src/server.jl index 3017493..d316d83 100644 --- a/src/server.jl +++ b/src/server.jl @@ -473,6 +473,7 @@ function set_server_callbacks(session::Session, callbacks::ServerCallbacks) if ret != SSH_OK throw(LibSSHException("Error setting server callbacks: $(ret)")) end + session.server_callbacks = callbacks end """ @@ -679,7 +680,6 @@ end authenticated::Bool = false session_event::Union{ssh.SessionEvent, Nothing} = nothing - server_callbacks::ServerCallbacks = ServerCallbacks() channel_callbacks::ChannelCallbacks = ChannelCallbacks() unclaimed_channels::Vector{ssh.SshChannel} = ssh.SshChannel[] channel_operations::Vector{Any} = [] @@ -824,11 +824,11 @@ function _handle_client(session::ssh.Session, ds::DemoServer) session, password=ds.password, verbose=ds.verbose) - client.server_callbacks = ServerCallbacks(client; - on_auth_password=on_auth_password, - on_auth_none=on_auth_none, - on_service_request=on_service_request, - on_channel_open_request_session=on_channel_open) + server_callbacks = ServerCallbacks(client; + on_auth_password=on_auth_password, + on_auth_none=on_auth_none, + on_service_request=on_service_request, + on_channel_open_request_session=on_channel_open) client.channel_callbacks = ChannelCallbacks(client; on_eof=on_channel_eof, on_close=on_channel_close, @@ -837,7 +837,7 @@ function _handle_client(session::ssh.Session, ds::DemoServer) on_env_request=on_channel_env_request) client.task = current_task() - ssh.set_server_callbacks(session, client.server_callbacks) + ssh.set_server_callbacks(session, server_callbacks) if !ssh.handle_key_exchange(session) @error "Key exchange failed" return @@ -1011,19 +1011,18 @@ end @kwdef mutable struct Forwarder client::Client sshchan::ssh.SshChannel - channel_callbacks::ChannelCallbacks = ChannelCallbacks() socket::Sockets.TCPSocket = Sockets.TCPSocket() task::Union{Task, Nothing} = nothing end function Forwarder(client::Client, sshchan::ssh.SshChannel, hostname::String, port::Integer) self = Forwarder(; client, sshchan) - self.channel_callbacks = ChannelCallbacks(self; - on_eof=on_fwd_channel_eof, - on_close=on_fwd_channel_close, - on_data=on_fwd_channel_data, - on_exit_status=on_fwd_channel_exit_status) - ssh.set_channel_callbacks(sshchan, self.channel_callbacks) + channel_callbacks = ChannelCallbacks(self; + on_eof=on_fwd_channel_eof, + on_close=on_fwd_channel_close, + on_data=on_fwd_channel_data, + on_exit_status=on_fwd_channel_exit_status) + ssh.set_channel_callbacks(sshchan, channel_callbacks) # Set up the listener socket. Restrict ourselves to IPv4 for simplicity # since the test HTTP servers bind to the IPv4 loopback interface (and diff --git a/src/session.jl b/src/session.jl index 88b3119..e257dc0 100644 --- a/src/session.jl +++ b/src/session.jl @@ -12,6 +12,7 @@ mutable struct Session owning::Bool log_verbosity::Int channels::Vector{Any} + server_callbacks::Union{Callbacks.ServerCallbacks, Nothing} @doc """ $(TYPEDSIGNATURES) @@ -33,7 +34,7 @@ mutable struct Session # Set to non-blocking mode lib.ssh_set_blocking(ptr, 0) - session = new(ptr, own, -1, []) + session = new(ptr, own, -1, [], nothing) if !isnothing(log_verbosity) session.log_verbosity = log_verbosity end @@ -179,7 +180,7 @@ const SESSION_PROPERTY_OPTIONS = Dict(:host => (SSH_OPTIONS_HOST, Cstring), const SAVED_PROPERTIES = (:log_verbosity,) function Base.propertynames(::Session, private::Bool=false) - (:host, :port, :user, :log_verbosity, :owning, (private ? (:ptr, :channels) : ())...) + (:host, :port, :user, :log_verbosity, :owning, (private ? (:ptr, :channels, :server_callbacks) : ())...) end function Base.getproperty(session::Session, name::Symbol) @@ -229,7 +230,7 @@ function Base.setproperty!(session::Session, name::Symbol, value) error("type Session has no field $(name)") end - if name == :ptr + if name == :ptr || name == :server_callbacks return setfield!(session, name, value) end From dcd19cd810608eb1e29a8059d63a289c12894561 Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Fri, 2 Feb 2024 13:13:37 +0100 Subject: [PATCH 2/5] Implement Base.show() for some types These are quite large and otherwise they'll swamp the screen. --- src/server.jl | 8 ++++++++ src/session.jl | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/server.jl b/src/server.jl index d316d83..436fb5e 100644 --- a/src/server.jl +++ b/src/server.jl @@ -193,6 +193,10 @@ mutable struct Bind end end +function Base.show(io::IO, bind::Bind) + print(io, Bind, "(addr=$(bind.addr), port=$(bind.port))") +end + """ $(TYPEDSIGNATURES) @@ -714,6 +718,10 @@ $(TYPEDFIELDS) clients::Vector{Client} = Client[] end +function Base.show(io::IO, ds::DemoServer) + print(io, DemoServer, "(bind.port=$(ds.bind.port))") +end + """ $(TYPEDSIGNATURES) diff --git a/src/session.jl b/src/session.jl index e257dc0..46e09ce 100644 --- a/src/session.jl +++ b/src/session.jl @@ -47,6 +47,14 @@ mutable struct Session end end +function Base.show(io::IO, session::Session) + if isopen(session) + print(io, Session, "(host=$(session.host), port=$(session.port), user=$(session.user), connected=$(isconnected(session)))") + else + print(io, Session, "()") + end +end + # Non-throwing finalizer for Session objects function _finalizer(session::Session) try From d49074d951a99f73fd0ba5ebff965bf16fb5965f Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Fri, 2 Feb 2024 13:27:12 +0100 Subject: [PATCH 3/5] Ensure that the DemoServer closes lingering sessions on shutdown This makes it a lot more useful for playing with in the REPL since you don't have to worry about closing all the sessions you opened. --- src/LibSSH.jl | 18 ++++++++++++++++++ src/server.jl | 33 +++++++++++++-------------------- src/session.jl | 20 ++++++++++++++++++-- test/LibSSHTests.jl | 6 ++++++ 4 files changed, 55 insertions(+), 22 deletions(-) diff --git a/src/LibSSH.jl b/src/LibSSH.jl index a2100bd..bb35149 100644 --- a/src/LibSSH.jl +++ b/src/LibSSH.jl @@ -158,6 +158,24 @@ function lib_version() VersionNumber(lib.LIBSSH_VERSION_MAJOR, lib.LIBSSH_VERSION_MINOR, lib.LIBSSH_VERSION_MICRO) end +# Safe wrapper around poll_fd(). There's a race condition in older Julia +# versions between the loop condition evaluation and this line, so we wrap +# poll_fd() in a try-catch in case the bind (and thus the file descriptor) has +# been closed in the meantime, which would cause poll_fd() to throw an IOError: +# https://github.com/JuliaLang/julia/pull/52377 +function _safe_poll_fd(args...; kwargs...) + result = nothing + try + result = FileWatching.poll_fd(args...; kwargs...) + catch ex + if !(ex isa Base.IOError) + rethrow() + end + end + + return result +end + include("pki.jl") include("callbacks.jl") include("session.jl") diff --git a/src/server.jl b/src/server.jl index 436fb5e..d040f12 100644 --- a/src/server.jl +++ b/src/server.jl @@ -338,8 +338,8 @@ doesn't matter much. It'll only control how frequently the listen loop wakes up to check if the bind has been closed yet. """ function listen(handler::Function, bind::Bind; poll_timeout=0.1) - if poll_timeout < 0 - throw(ArgumentError("poll_timeout cannot be negative!")) + if poll_timeout <= 0 + throw(ArgumentError("poll_timeout=$(poll_timeout), it must be greater than 0")) end ret = lib.ssh_bind_listen(bind.ptr) @@ -361,20 +361,11 @@ function listen(handler::Function, bind::Bind; poll_timeout=0.1) notify(bind._listener_event) end - # Wait for new connection attempts. Note that there's a race condition - # between the loop condition evaluation and this line, so we wrap - # poll_fd() in a try-catch in case the bind (and thus the file - # descriptor) has been closed in the meantime, which would cause - # poll_fd() to throw an IOError. - local poll_result - try - poll_result = FileWatching.poll_fd(fd, poll_timeout; readable=true) - catch ex - if ex isa Base.IOError - continue - else - rethrow() - end + poll_result = _safe_poll_fd(fd, poll_timeout; readable=true) + if isnothing(poll_result) + # This means the session's file descriptor has been closed (see the + # comments for _safe_poll_fd()). + continue end # The first thing we do is check if the Bind has been closed, because @@ -701,6 +692,7 @@ function Base.close(client::Client) end close(client.session_event) + close(client.session) wait(client.task) end @@ -885,12 +877,13 @@ $(TYPEDSIGNATURES) Stop a [`DemoServer`](@ref). """ function stop(demo_server::DemoServer) - for client in demo_server.clients - close(client) - end - if !isnothing(demo_server.listener_task) close(demo_server.bind) + + for client in demo_server.clients + close(client) + end + wait(demo_server.listener_task) demo_server.listener_task = nothing end diff --git a/src/session.jl b/src/session.jl index 46e09ce..6f09d2e 100644 --- a/src/session.jl +++ b/src/session.jl @@ -281,8 +281,15 @@ $(TYPEDSIGNATURES) Waits for a session in non-blocking mode. If the session is in blocking mode the function will return immediately. + +The `poll_timeout` argument has the same meaning as [`listen(::Function, +::Bind)`](@ref). """ -function Base.wait(session::Session) +function Base.wait(session::Session; poll_timeout=0.1) + if poll_timeout <= 0 + throw(ArgumentError("poll_timeout=$(poll_timeout), it must be greater than 0")) + end + if lib.ssh_is_blocking(session.ptr) == 1 return end @@ -292,7 +299,16 @@ function Base.wait(session::Session) writable = (poll_flags & lib.SSH_WRITE_PENDING) > 0 fd = RawFD(lib.ssh_get_fd(session.ptr)) - FileWatching.poll_fd(fd; readable, writable) + while isopen(session) + result = _safe_poll_fd(fd, poll_timeout; readable, writable) + if isnothing(result) + # This means the session's file descriptor has been closed (see the + # comments for _safe_poll_fd()). + continue + elseif !result.timedout + break + end + end return nothing end diff --git a/test/LibSSHTests.jl b/test/LibSSHTests.jl index 48743da..fe24c97 100644 --- a/test/LibSSHTests.jl +++ b/test/LibSSHTests.jl @@ -182,6 +182,12 @@ end end @test length(demo_server.clients) == 2 end + + # Test that the DemoServer cleans up lingering sessions + server_task = Threads.@spawn DemoServer(2222; timeout=10) do + session = ssh.Session("127.0.0.1", 2222) + end + @test timedwait(() -> istaskdone(server_task), 5) == :ok end @testset "Session" begin From 2c1a99aa6cd2180dfb6aa4322577bbef89cd9c3b Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Fri, 2 Feb 2024 17:36:36 +0100 Subject: [PATCH 4/5] Refactor the command execution to match Julia's API Now we use `Base.run()` and `Base.read()` instead of `ssh.execute()`. --- docs/src/examples.jl | 2 +- docs/src/sessions_and_channels.md | 13 +- src/channel.jl | 273 +++++++++++++++++++++++------- test/LibSSHTests.jl | 24 +-- 4 files changed, 238 insertions(+), 74 deletions(-) diff --git a/docs/src/examples.jl b/docs/src/examples.jl index a2b71e9..99219ff 100644 --- a/docs/src/examples.jl +++ b/docs/src/examples.jl @@ -73,7 +73,7 @@ ssh.userauth_list(session) # Now we're authenticated to the server and we can actually do something, like # running a command: -ssh.execute(session, "echo 'Hello world!'") +@assert read(`echo 'Hello world!'`, session, String) == "Hello world!\n" # What we get back is a tuple of the return code and the output from the # command. diff --git a/docs/src/sessions_and_channels.md b/docs/src/sessions_and_channels.md index b1d7049..4b7b3ac 100644 --- a/docs/src/sessions_and_channels.md +++ b/docs/src/sessions_and_channels.md @@ -98,8 +98,19 @@ You should prefer using these instead of more low-level methods, if you can. #### Command execution +LibSSH.jl attempts to mimic Julia's API for running local commands with `run()` +etc. But some features are not supported and we attempt to document all of the +differences. + ```@docs -execute +SshProcessFailedException +SshProcess +Base.wait(::SshProcess) +Base.success(::SshProcess) +Base.run(::Cmd, ::Session) +Base.read(::Cmd, ::Session) +Base.read(::Cmd, ::Session, ::Type{String}) +Base.success(::Cmd, ::Session) ``` #### Direct port forwarding diff --git a/src/channel.jl b/src/channel.jl index 636c2d4..9d3615a 100644 --- a/src/channel.jl +++ b/src/channel.jl @@ -345,101 +345,252 @@ end ## execute() -function _log(msg, userdata) - if userdata[:verbose] +function _log(msg, process) + if process._verbose @info "execute(): $(msg)" end end -function _on_channel_data(session, sshchan, data, is_stderr, userdata) +function _on_channel_data(session, sshchan, data, is_stderr, process) is_stderr = Bool(is_stderr) fd_msg = is_stderr ? "stderr" : "stdout" - _log("channel_data $(length(data)) bytes from $fd_msg", userdata) + _log("channel_data $(length(data)) bytes from $fd_msg", process) - put!(userdata[:channel], copy(data)) + append!(is_stderr ? process.err : process.out, data) return length(data) end -function _on_channel_eof(session, sshchan, userdata) - _log("channel_eof", userdata) +function _on_channel_eof(session, sshchan, process) + _log("channel_eof", process) end -function _on_channel_close(session, sshchan, userdata) - _log("channel_close", userdata) +function _on_channel_close(session, sshchan, process) + _log("channel_close", process) end -function _on_channel_exit_status(session, sshchan, ret, userdata) - _log("exit_status $ret", userdata) - userdata[:exit_code] = Int(ret) +function _on_channel_exit_status(session, sshchan, ret, process) + _log("exit_status $ret", process) + process.exitcode = Int(ret) end +""" +$(TYPEDEF) +$(TYPEDFIELDS) + +This is analogous to `Base.Process`, it represents a command running over an +SSH session. The stdout and stderr output are stored as byte arrays in +`SshProcess.out` and `SshProcess.err` respectively. They can be converted to +strings using e.g. `String(process.out)`. +""" +@kwdef mutable struct SshProcess + out::Vector{UInt8} = Vector{UInt8}() + err::Vector{UInt8} = Vector{UInt8}() + + cmd::Union{Cmd, Nothing} = nothing + exitcode::Int = typemin(Int) + + _sshchan::Union{SshChannel, Nothing} = nothing + _task::Union{Task, Nothing} = nothing + _verbose::Bool = false +end + +function Base.show(io::IO, process::SshProcess) + status = process_running(process) ? "ProcessRunning" : "ProcessExited($(process.exitcode))" + print(io, SshProcess, "(cmd=$(process.cmd), $status)") +end + +Base.process_running(process::SshProcess) = !istaskdone(process._task) +Base.process_exited(process::SshProcess) = istaskdone(process._task) + +""" +$(TYPEDSIGNATURES) + +Check if the process succeeded. +""" +Base.success(process::SshProcess) = process_exited(process) && process.exitcode == 0 + """ $(TYPEDSIGNATURES) -Execute `command` remotely. This will return a tuple of -`(return_code::Union{Int, Nothing}, output::String)`. The `return_code` may be -`nothing` if it wasn't sent by the server (which would point to an incorrect -server implementation). +# Throws +- [`SshProcessFailedException`](@ref): if `ignorestatus()` wasn't used. """ -function execute(session::Session, command::AbstractString; verbose=false) - userdata = Dict{Symbol, Any}(:channel => Channel(), - :exit_code => nothing, - :verbose => verbose) - callbacks = Callbacks.ChannelCallbacks(userdata; +function Base.wait(process::SshProcess) + try + wait(process._task) + catch ex + if !process.cmd.ignorestatus + rethrow() + end + end +end + +""" +$(TYPEDEF) +$(TYPEDFIELDS) + +This is analogous to `ProcessFailedException`. +""" +struct SshProcessFailedException <: Exception + process::SshProcess +end + +function _exec_command(process::SshProcess) + sshchan = process._sshchan + session = sshchan.session + cmd_str = join(process.cmd.exec, " ") + + # Open the session channel + ret = _session_trywait(session) do + lib.ssh_channel_open_session(sshchan.ptr) + end + if ret != SSH_OK + throw(LibSSHException("Failed to open a session channel: $(ret)")) + end + + # Make the request + ret = _session_trywait(session) do + GC.@preserve cmd_str begin + lib.ssh_channel_request_exec(sshchan.ptr, Base.unsafe_convert(Ptr{Cchar}, cmd_str)) + end + end + if ret != SSH_OK + err = get_error(session) + throw(LibSSHException("Error from lib.ssh_channel_request_exec, could not execute command: $(err)")) + end + + # Wait for data to be read + ret = poll_loop(sshchan) + + # Close the channel + lib.ssh_channel_send_eof(sshchan.ptr) + close(sshchan) + + # Check the result of the read for an error + if ret == SSH_ERROR + throw(LibSSHException("Error while reading data from channel: $(ret)")) + end + + if !process.cmd.ignorestatus && process.exitcode != 0 + throw(SshProcessFailedException(process)) + end +end + +""" +$(TYPEDSIGNATURES) + +Run a command on the remote host over an SSH session. Things that aren't +supported compared to `run()`: +- Pipelined commands (use a regular pipe like `foo | bar` instead). +- Setting the directory to execute the command in. +- Setting environment variables (support is possible, it just hasn't been + implemented yet). + +# Throws +- [`SshProcessFailedException`](@ref): if the command fails and `ignorestatus()` + wasn't used. + +# Arguments +- `cmd`: The command to run. This will be converted to a string for running + remotely. +- `session`: The session to run the command over. +- `wait=true`: Wait for the command to finish before returning. +- `verbose=false`: Print debug logging messages. Note that this is not the same + as setting the `log_verbosity` on a [`Session`](@ref). +- `combine_outputs=true`: Write the `stderr` command output to the `IOBuffer` + for the commands `stdout`. If this is `true` then `SshProcess.out` and + `SshProcess.err` will refer to the same object. +- `print_out=true`: Print the output (stdout + stderr by default) of the + command. + +# Examples +```julia-repl +julia> import LibSSH as ssh + +julia> ssh.Demo.DemoServer(2222; password="foo") do + session = ssh.Session("127.0.0.1", 2222) + @assert ssh.userauth_password(session, "foo") == ssh.AuthStatus_Success + + @info "1" + run(`echo foo`, session) + + println() + @info "2" + run(ignorestatus(`foo`), session) + end +[ Info: 1 +foo + +[ Info: 2 +sh: line 1: foo: command not found +``` +""" +function Base.run(cmd::Cmd, session::Session; + wait::Bool=true, verbose::Bool=false, + combine_outputs::Bool=true, print_out::Bool=true) + process = SshProcess(; cmd, _verbose=verbose) + if combine_outputs + process.err = process.out + end + + callbacks = Callbacks.ChannelCallbacks(process; on_eof=_on_channel_eof, on_close=_on_channel_close, on_data=_on_channel_data, on_exit_status=_on_channel_exit_status) + process._sshchan = SshChannel(session) + set_channel_callbacks(process._sshchan, callbacks) - SshChannel(session) do sshchan - set_channel_callbacks(sshchan, callbacks) + process._task = Threads.@spawn _exec_command(process) + if wait + # Note the use of Base.wait() to avoid aliasing with the `wait` argument + Base.wait(process._task) - # Open the session - ret = _session_trywait(session) do - lib.ssh_channel_open_session(sshchan.ptr) - end - if ret != SSH_OK - throw(LibSSHException("Failed to open a session channel: $(ret)")) + if print_out + print(String(process.out)) end + end - # Make the request - ret = _session_trywait(session) do - GC.@preserve command begin - lib.ssh_channel_request_exec(sshchan.ptr, Base.unsafe_convert(Ptr{Cchar}, command)) - end - end - if ret != SSH_OK - err = get_error(session) - throw(LibSSHException("Error from channel_request_exec, could not execute command: $(err)")) - end + return process +end - # Start a task to read incoming data and append it to a vector - cmd_output = String[] - reader_task = Threads.@spawn for data in userdata[:channel] - try - push!(cmd_output, String(data)) - catch ex - @error "Error handling command output" exception=(ex, catch_backtrace()) - end - end +""" +$(TYPEDSIGNATURES) - # Wait for data to be read - ret = poll_loop(sshchan) +Read the output from the command in bytes. +""" +function Base.read(cmd::Cmd, session::Session) + process = run(cmd, session; print_out=false) + return process.out +end - # Close the reader task and send an EOF - close(userdata[:channel]) - wait(reader_task) - lib.ssh_channel_send_eof(sshchan.ptr) +""" +$(TYPEDSIGNATURES) - # Check the result of the read for an error - if ret == SSH_ERROR - throw(LibSSHException("Error while reading data from channel: $(ret)")) - end +Read the output from the command as a String. - return (userdata[:exit_code]::Union{Int, Nothing}, string(cmd_output...)) - end -end +# Examples +```julia-repl +julia> import LibSSH as ssh + +julia> ssh.Demo.DemoServer(2222; password="foo") do + session = ssh.Session("127.0.0.1", 2222) + @assert ssh.userauth_password(session, "foo") == ssh.AuthStatus_Success + + @show read(`echo foo`, session, String) + end +read(`echo foo`, session, String) = "foo\\n" +``` +""" +Base.read(cmd::Cmd, session::Session, ::Type{String}) = String(read(cmd, session)) + +""" +$(TYPEDSIGNATURES) + +Check the command succeeded. +""" +Base.success(cmd::Cmd, session::Session) = success(run(cmd, session; print_out=false)) ## Direct port forwarding diff --git a/test/LibSSHTests.jl b/test/LibSSHTests.jl index fe24c97..d3eeb7f 100644 --- a/test/LibSSHTests.jl +++ b/test/LibSSHTests.jl @@ -325,18 +325,20 @@ end end @testset "Executing commands" begin - # Test executing commands demo_server_with_session(2222) do session - ret, output = ssh.execute(session, "whoami") - @test ret == 0 - @test strip(output) == username() - end - - # Check that we read stderr as well as stdout - demo_server_with_session(2222) do session - ret, output = ssh.execute(session, "thisdoesntexist") - @test ret == 127 - @test !isempty(output) + # Smoke test + process = run(`whoami`, session; print_out=false) + @test success(process) + @test chomp(String(process.out)) == username() + + # Check that we read stderr as well as stdout + process = run(ignorestatus(`thisdoesntexist`), session; print_out=false) + @test process.exitcode == 127 + @test !isempty(String(process.out)) + + # Test Base methods + @test read(`echo foo`, session, String) == "foo\n" + @test success(`whoami`, session) end end From a0d05921332df6f83480c34f91a8ee5a322e30da Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Fri, 2 Feb 2024 18:16:14 +0100 Subject: [PATCH 5/5] Bump the version to 0.2.0 --- Project.toml | 2 +- docs/src/changelog.md | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index de89f71..8a061db 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "LibSSH" uuid = "00483490-30f8-4353-8aba-35b82f51f4d0" authors = ["James Wrigley and contributors"] -version = "0.1.0" +version = "0.2.0" [deps] CEnum = "fa961155-64e5-5f13-b03f-caf6b980ea82" diff --git a/docs/src/changelog.md b/docs/src/changelog.md index 2d66b4f..885b8ee 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -3,6 +3,19 @@ This documents notable changes in LibSSH.jl. The format is based on [Keep a Changelog](https://keepachangelog.com). +## [v0.2.0] - 2024-02-01 + +### Changed + +- The [Command execution](@ref) API was completely rewritten to match Julia's + API ([#2]). This is a breaking change, any code using the old `ssh.execute()` + will need to be rewritten. + +### Fixed + +- A cause of segfaults was fixed by storing callbacks properly, so they don't get + garbage collected accidentally ([#2]). + ## [v0.1.0] - 2024-01-29 The initial release 🎉 ✨