socketry/async-io

SNMP-Get blocks

paddor opened this issue · 7 comments

I expect the SNMP-GET request not to block:

require 'bundler/setup'
require 'snmp'
require 'async'
require 'async/io'
require 'async/io/udp_socket'

OID_SYSNAME = "1.3.6.1.2.1.1.5.0"
OID_SYSLOCATION = "1.3.6.1.2.1.1.6.0"

module SNMP
  include Async::IO
end

Async do |task|
  task.async do
    puts "BEGIN"
    puts "Ruby version: #{RUBY_VERSION}"
  end

  task.async do |t|
    t.sleep 0.5
    manager = SNMP::Manager.new(host: '127.0.0.1', port: 1024, community: 'linksys-system') #do |manager|
    puts "Created manager."
    response = manager.get([OID_SYSNAME, OID_SYSLOCATION])
    puts "Got response."
    response.each_varbind do |vb|
        puts "#{vb.name.to_s}  #{vb.value.to_s}  #{vb.value.asn1_type}"
    end
  end

  task.async do
    puts "MIDDLE"
  end

  task.async do |t|
    5.times do
      puts "X"
      t.sleep 0.5
    end
    puts "END"
  end
end

To run an agent locally:

$ pip install snmpsim
$ mkdir snmp-data
$ cat > snmp-data/linksys-system.snmprec
1.3.6.1.2.1.1.1.0|4|BEFSX41
1.3.6.1.2.1.1.2.0|6|1.3.6.1.4.1.3955.1.1
1.3.6.1.2.1.1.3.0|67|638239
1.3.6.1.2.1.1.4.0|4|Linksys
1.3.6.1.2.1.1.5.0|4|isp-gw
1.3.6.1.2.1.1.6.0|4|4, Petersburger strasse, Berlin, Germany
1.3.6.1.2.1.1.8.0|67|4
^D
$ snmpsimd.py  --data-dir=snmp-data/ --agent-udpv4-endpoint=127.0.0.1:1024 --log-level=debug --v2c-arch

When the SNMP agent is running:

$ ruby snmp.rb
BEGIN
Ruby version: 3.0.0
MIDDLE
X
Created manager.
Got response.
SNMPv2-MIB::sysName.0  isp-gw  OCTET STRING
SNMPv2-MIB::sysLocation.0  4, Petersburger strasse, Berlin, Germany  OCTET STRING
X
X
X
X
END

When it's not running:

$ ruby snmp.rb
BEGIN
Ruby version: 3.0.0
MIDDLE
X
Created manager.
  0.0s    error: Async::Task [oid=0xca8] [pid=17081] [2020-12-10 13:50:35 +0100]
               |   SNMP::RequestTimeout: host 127.0.0.1 not responding
               |   → /home/user/dev/oss/ruby-snmp/lib/snmp/manager.rb:246 in `get'
               |     snmp.rb:24 in `block (2 levels) in <main>'
               |     /home/user/dev/oss/async/lib/async/task.rb:265 in `block in make_fiber'
X
X
X
X
END

I expect something like:

$ ruby snmp.rb
BEGIN
Ruby version: 3.0.0
MIDDLE
X
Created manager.
X
X
X
X
END
  0.0s    error: Async::Task [oid=0xca8] [pid=17081] [2020-12-10 13:50:35 +0100]
               |   SNMP::RequestTimeout: host 127.0.0.1 not responding
               |   → /home/user/dev/oss/ruby-snmp/lib/snmp/manager.rb:246 in `get'
               |     snmp.rb:24 in `block (2 levels) in <main>'
               |     /home/user/dev/oss/async/lib/async/task.rb:265 in `block in make_fiber'

Ruby-SNMP uses stdlib's Timeout.timeout, which uses Kernel#sleep. AFAIK in Ruby 3.0 Kernel#sleep should be aware of the Fiber scheduler?

Ruby: 3.0.0preview2
async: HEAD
async-io: HEAD
ruby-snmp: 1.3.2

Yes, this seems reasonable. However, I believe Timeout.timeout uses separate thread with sleep, so the scheduler is not active in that thread. Then, it uses Thread.raise? It will cause problems in scheduler unless there is some way for us to hook that operation.. Timeout.timeout is bad design. However, perhaps we can add hook for Thread.raise.

We could probably make Timeout.timeout delegate to the scheduler.... hmmmm...

Good idea. I tried to implement that. It seems to work fine if the host is unreachable. But if it's reachable, I get #<LocalJumpError: unexpected return> from the return statement in SNMP::Manager#try_request.

Here's timeout.rb for Async-IO:

require 'timeout'

module Async
  module IO
    module Timeout
      Error = ::Timeout::Error

      module_function

      def timeout(sec, klass = nil, message = nil)   #:yield: +sec+
        return yield(sec) if sec == nil or sec.zero?

        message ||= "execution expired".freeze
        klass ||= Error

        stopper = nil
        task = Async annotation: 'timeout task' do
          # warn "#{self.class}: starting task with timeout"
          yield.tap do
            # warn "#{self.class}: task with timeout finished in time"
            stopper.stop if stopper
          end
        end

        stopper = Async annotation: 'stopper' do |t|
          t.sleep sec

          if task&.running?
            # warn "#{self.class}: expired. stopping task"
            task.stop
            raise klass, message
          end
        end

        task.wait
      rescue
        # warn "#{self.class}: raising error #{$!.inspect}"
        raise
      end

    end
  end
end

Any ideas?

Also, in my test script, I had to include Async::IO directly into SNMP::Manager for some reason:

module SNMP
  include Async::IO

  class Manager
    include Async::IO
  end
end

Instead of:

            task.stop
            raise klass, message

I think you need:

task.raise(klass, message)

or something like that.

Async does not define Async::Task#raise. I just tried calling it and it raised NoMethodError because that's Kernel#raise.

I tried to use Async::Task#fail! to do this, which looked okay, but when the SNMP simulator is running, I still get LocalJumpError: unexpected return from SNMP::Manager#try_request. That method looks like this:

    def try_request(request, community=@community, host=@host, port=@port)
      (@retries + 1).times do |n|
        send_request(request, community, host, port)
        begin
          Timeout.timeout(@timeout) do
            return get_response(request)
          end
        rescue Timeout::Error
          # no action - try again
        rescue => e
          warn e.to_s
        end
      end
      raise RequestTimeout, "host #{config[:host]} not responding", caller
    end

Any way to make this work without modifying ruby-snmp?

paddor commented

I can confirm this now works without any modifications to the normal usage of SNMP::Manager.

#! /usr/bin/env ruby
require 'bundler/setup'
require 'snmp'
require 'async'

OID_SYSNAME = "1.3.6.1.2.1.1.5.0"
OID_SYSLOCATION = "1.3.6.1.2.1.1.6.0"

Async do |task|
  task.async do
    puts "BEGIN"
    puts "Ruby version: #{RUBY_VERSION}"
  end

  task.async do |t|
    t.sleep 0.5
    manager = SNMP::Manager.new(host: '127.0.0.1', port: 1024, community: 'linksys-system') #do |manager|
    puts "Created manager."
    response = manager.get([OID_SYSNAME, OID_SYSLOCATION])
    puts "Got response."
    response.each_varbind do |vb|
        puts "#{vb.name.to_s}  #{vb.value.to_s}  #{vb.value.asn1_type}"
    end
  end

  task.async do
    puts "MIDDLE"
  end

  task.async do |t|
    5.times do
      puts "X"
      t.sleep 0.5
    end
    puts "END"
  end
end

This works as expected whether the SNMP agent is running or not.

Wow thanks for the follow up. Glad to see it’s all working now!