Class: Roby::App::Cucumber::Controller

Inherits:
Object
  • Object
show all
Defined in:
lib/roby/app/cucumber/controller.rb

Overview

API that starts and communicates with a Roby controller for the benefit of a Cucumber scenario

Defined Under Namespace

Classes: BackgroundJob, ConnectionTimeout, FailedAction, FailedBackgroundJob, InvalidJob, InvalidState, JoinTimedOut

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(port: Roby::Interface::DEFAULT_PORT, keep_running: (ENV["CUCUMBER_KEEP_RUNNING"] == "1"), validation_mode: (ENV["ROBY_VALIDATE_STEPS"] == "1")) ⇒ Controller

Returns a new instance of Controller.

Parameters:

  • port (Integer) (defaults to: Roby::Interface::DEFAULT_PORT)

    the port through which we should connect to the Roby interface. Set to zero to pick a random port.



55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/roby/app/cucumber/controller.rb', line 55

def initialize(
    port: Roby::Interface::DEFAULT_PORT,
    keep_running: (ENV["CUCUMBER_KEEP_RUNNING"] == "1"),
    validation_mode: (ENV["ROBY_VALIDATE_STEPS"] == "1")
)
    @roby_pid = nil
    @roby_port = port
    @background_jobs = []
    @keep_running = keep_running
    @validation_mode = validation_mode
    @pending_actions = []
end

Instance Attribute Details

#background_jobsObject (readonly)

The set of jobs started by #start_monitoring_job



19
20
21
# File 'lib/roby/app/cucumber/controller.rb', line 19

def background_jobs
  @background_jobs
end

#current_batchObject (readonly)

The batch that gathers all the interface operations that will be executed at the next #run_job



23
24
25
# File 'lib/roby/app/cucumber/controller.rb', line 23

def current_batch
  @current_batch
end

#pending_actionsObject (readonly)

Actions that would be started by #current_batch



26
27
28
# File 'lib/roby/app/cucumber/controller.rb', line 26

def pending_actions
  @pending_actions
end

#roby_pidInteger? (readonly)

The PID of the started Roby process

Returns:

  • (Integer, nil)


16
17
18
# File 'lib/roby/app/cucumber/controller.rb', line 16

def roby_pid
  @roby_pid
end

Instance Method Details

#__start_job(description, m, arguments, monitoring) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Helper job-starting method



355
356
357
358
359
360
361
362
363
364
365
366
367
368
# File 'lib/roby/app/cucumber/controller.rb', line 355

def __start_job(description, m, arguments, monitoring)
    if validation_mode?
        validate_job(m, arguments)
        return
    end

    action = Interface::V1::Async::ActionMonitor.new(
        roby_interface, m, arguments
    )
    action.restart(batch: current_batch)
    pending_actions << action
    background_jobs << BackgroundJob.new(action, description, monitoring)
    action
end

#apply_current_batch(*actions, sync: true) ⇒ Object



415
416
417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/roby/app/cucumber/controller.rb', line 415

def apply_current_batch(*actions, sync: true)
    return if current_batch.empty?

    batch_result = current_batch.__process
    if sync
        roby_poll_interface_until do
            (pending_actions + actions).all?(&:async)
        end
    end
    batch_result
ensure
    @current_batch = roby_interface.create_batch
    @pending_actions = []
end

#drop_all_jobs(*extra_jobs) ⇒ Object



509
510
511
512
513
# File 'lib/roby/app/cucumber/controller.rb', line 509

def drop_all_jobs(*extra_jobs)
    jobs = @background_jobs
    @background_jobs = []
    drop_jobs(*extra_jobs, *jobs.map(&:action_monitor))
end

#drop_jobs(*jobs) ⇒ Object



521
522
523
524
525
# File 'lib/roby/app/cucumber/controller.rb', line 521

def drop_jobs(*jobs)
    jobs.each do |act|
        act.drop(batch: current_batch) if !act.terminated? && act.async
    end
end

#drop_monitoring_jobs(*extra_jobs) ⇒ Object



515
516
517
518
519
# File 'lib/roby/app/cucumber/controller.rb', line 515

def drop_monitoring_jobs(*extra_jobs)
    monitoring_jobs, @background_jobs =
        background_jobs.partition(&:monitoring?)
    drop_jobs(*extra_jobs, *monitoring_jobs.map(&:action_monitor))
end

#each_main_jobObject

Enumerate all jobs started with #start_job

These jobs are usually the job-under-test, hence the ‘main’ moniker



383
384
385
386
387
388
389
# File 'lib/roby/app/cucumber/controller.rb', line 383

def each_main_job
    return enum_for(__method__) unless block_given?

    background_jobs.each do |job|
        yield(job) unless job.monitoring?
    end
end

#each_monitoring_jobObject

Enumerate all jobs started with #start_monitoring_job



371
372
373
374
375
376
377
# File 'lib/roby/app/cucumber/controller.rb', line 371

def each_monitoring_job
    return enum_for(__method__) unless block_given?

    background_jobs.each do |job|
        yield(job) if job.monitoring?
    end
end

#find_failed_monitoring_jobObject

Find one monitoring job that failed



392
393
394
395
396
# File 'lib/roby/app/cucumber/controller.rb', line 392

def find_failed_monitoring_job
    each_monitoring_job.find do |background_job|
        background_job.terminated? && !background_job.success?
    end
end

#last_main_job_idnil, Integer

The job ID of the last started

Returns:

  • (nil, Integer)

    nil if the job has not yet been started, and the ID otherwise. It’s the caller responsibility to call #apply_current_batch



327
328
329
# File 'lib/roby/app/cucumber/controller.rb', line 327

def last_main_job_id
    each_main_job.to_a.last&.job_id
end

#roby_connect(timeout: 20) ⇒ Object

Wait for the Roby controller started with #roby_start to be available

Raises:



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/roby/app/cucumber/controller.rb', line 151

def roby_connect(timeout: 20)
    raise InvalidState, "already connected" if roby_connected?

    deadline = Time.now + timeout

    until roby_connected?
        roby_try_connect
        _, status = Process.waitpid2(roby_pid, Process::WNOHANG)
        if status
            raise InvalidState,
                  "remote Roby controller quit before "\
                  "we could get a connection"
        end
        roby_interface.wait(timeout: timeout / 10)

        if Time.now > deadline
            raise ConnectionTimeout,
                  "failed to connect to a Roby controller in less than "\
                  "#{timeout}s"
        end
    end
    @current_batch = @roby_interface.create_batch
end

#roby_connected?Boolean

Whether we have a connection to the started Roby controller

Returns:

  • (Boolean)


49
50
51
# File 'lib/roby/app/cucumber/controller.rb', line 49

def roby_connected?
    @roby_interface&.connected?
end

#roby_disconnectObject

Disconnect the interface to the controller, but does not stop the controller

Raises:



177
178
179
180
181
# File 'lib/roby/app/cucumber/controller.rb', line 177

def roby_disconnect
    raise InvalidState, "not connected" unless roby_connected?

    @roby_interface.close
end

#roby_enable_backtrace_filtering(enable: true) ⇒ Object

Enable or disable backtrace filtering on the Roby instance



279
280
281
282
283
284
285
286
287
# File 'lib/roby/app/cucumber/controller.rb', line 279

def roby_enable_backtrace_filtering(enable: true)
    unless roby_connected?
        raise InvalidState,
              "you need to successfully connect to the Roby "\
              "controller with #roby_connect before you can call "\
              "#roby_enable_backtrace_filtering"
    end
    roby_interface.client.enable_backtrace_filtering(enable: enable)
end

#roby_interfaceRoby::Interface::V1::Async::Interface

The object used to communicate with the Roby instance



71
72
73
74
75
76
77
78
79
80
81
# File 'lib/roby/app/cucumber/controller.rb', line 71

def roby_interface
    if @roby_port == 0
        raise InvalidState,
              "you passed port: 0 to .new, but has not yet "\
              "called #roby_start"
    end

    @roby_interface ||=
        Roby::Interface::V1::Async::Interface
        .new("localhost", port: @roby_port)
end

#roby_join(timeout: nil) ⇒ Object

Wait for the remote process to quit



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/roby/app/cucumber/controller.rb', line 237

def roby_join(timeout: nil)
    unless roby_running?
        raise InvalidState,
              "cannot call #roby_join without a running Roby controller"
    end

    status = nil
    if timeout
        deadline = Time.now + timeout
        loop do
            _, status = Process.waitpid2(roby_pid, Process::WNOHANG)
            break if status

            sleep 0.1
            if Time.now > deadline
                raise JoinTimedOut,
                      "roby_join timed out waiting for end of "\
                      "PID #{roby_pid}"
            end
        end
    else
        _, status = Process.waitpid2(roby_pid)
    end
    @roby_pid = nil
    status
rescue Errno::ECHILD
    @roby_pid = nil
end

#roby_join!Object

Wait for the remote process to quit

It raises an exception if the process does not terminate successfully



270
271
272
273
274
275
276
# File 'lib/roby/app/cucumber/controller.rb', line 270

def roby_join!
    if (status = roby_join) && !status.success?
        raise InvalidState, "Roby process exited with status #{status}"
    end
rescue Errno::ENOCHILD
    @roby_pid = nil
end

#roby_join_or_kill(join_timeout: 5, signal: "INT", next_signal: signal) ⇒ Object



227
228
229
230
231
232
233
234
# File 'lib/roby/app/cucumber/controller.rb', line 227

def roby_join_or_kill(join_timeout: 5, signal: "INT", next_signal: signal)
    roby_join(timeout: join_timeout)
rescue JoinTimedOut
    STDERR.puts "timed out while waiting for a Roby controller to stop"
    STDERR.puts "trying the #{signal} signal"
    roby_kill(signal: signal, join: false)
    roby_join_or_kill(join_timeout: join_timeout, signal: next_signal)
end

#roby_kill(join: true, join_timeout: 5, signal: "INT") ⇒ Object

Kill the Roby controller process



214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/roby/app/cucumber/controller.rb', line 214

def roby_kill(join: true, join_timeout: 5, signal: "INT")
    unless roby_running?
        raise InvalidState,
              "cannot call #roby_kill if no controllers were started"
    end

    Process.kill(signal, roby_pid)
    return unless join

    roby_join_or_kill(join_timeout: join_timeout,
                      signal: "INT", next_signal: "KILL")
end

#roby_log_dirObject

The log dir of the Roby app

Since the roby app is local, this is a valid local path



292
293
294
# File 'lib/roby/app/cucumber/controller.rb', line 292

def roby_log_dir
    roby_interface.client.log_dir
end

#roby_poll_interface_untilObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Poll the interface until the block returns a truthy value



401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/roby/app/cucumber/controller.rb', line 401

def roby_poll_interface_until
    until (result = yield)
        if defined?(::Cucumber) &&
           ::Cucumber.respond_to?(:wants_to_quit) &&
           ::Cucumber.wants_to_quit
            raise Interrupt, "Interrupted"
        end

        roby_interface.poll
        roby_interface.wait
    end
    result
end

#roby_running?Boolean

Whether this started a Roby controller

Returns:

  • (Boolean)


44
45
46
# File 'lib/roby/app/cucumber/controller.rb', line 44

def roby_running?
    @roby_pid
end

#roby_start(robot_name, robot_type, connect: true, controller: true, app_dir: Dir.pwd, log_dir: nil, state: {}, **spawn_options) ⇒ Object

Start a Roby controller

Parameters:

  • robot_name (String)

    the name of the robot configuration

  • robot_type (String)

    the type of the robot configuration

  • controller (Boolean) (defaults to: true)

    whether the configuration’s controller blocks should be executed

  • state (Hash) (defaults to: {})

    initial values for the state

  • [Boolean] (Hash)

    a customizable set of options

Raises:

  • InvalidState if a controller is already running



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/roby/app/cucumber/controller.rb', line 94

def roby_start(
    robot_name, robot_type,
    connect: true, controller: true, app_dir: Dir.pwd,
    log_dir: nil, state: {}, **spawn_options
)
    if roby_running?
        raise InvalidState,
              "a Roby controller is already running, "\
              "call #roby_stop and #roby_join first"
    end

    options = []
    options << "--log-dir=#{log_dir}" if log_dir
    if @roby_port == 0
        server = TCPServer.new("localhost", 0)
        options << "--interface-fd=#{server.fileno}"
        spawn_options = spawn_options.merge({ server => server })
        @roby_port = server.local_address.ip_port
    else
        options << "--port=#{@roby_port}"
    end
    @roby_pid = spawn(
        Gem.ruby, File.join(Roby::BIN_DIR, "roby"), "run",
        "--robot=#{robot_name},#{robot_type}",
        "--controller",
        "--quiet",
        *options,
        *state.map { |k, v| "--set=#{k}=#{v}" },
        chdir: app_dir,
        pgroup: 0,
        **spawn_options
    )
    server&.close
    roby_connect if connect
    roby_pid
end

#roby_stop(join: true, join_timeout: 5) ⇒ Object

Stops an already started Roby controller

Raises:

  • InvalidState if no controllers were started



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/roby/app/cucumber/controller.rb', line 189

def roby_stop(join: true, join_timeout: 5)
    if !roby_running?
        raise InvalidState,
              "cannot call #roby_stop if no controllers were started"
    elsif !roby_connected?
        raise InvalidState,
              "you need to successfully connect to the Roby "\
              "controller with #roby_connect before you can call "\
              "#roby_stop"
    end

    begin
        roby_interface.quit
    rescue Interface::ComError
        puts "QUIT FAILED"
    ensure
        roby_interface.close
    end
    return unless join

    roby_join_or_kill(join_timeout: join_timeout,
                      signal: "INT", next_signal: "KILL")
end

#roby_try_connectBoolean

Try connecting to the Roby controller

Returns:

  • (Boolean)

    true if the interface is connected, false otherwise



134
135
136
137
138
139
140
141
142
143
144
# File 'lib/roby/app/cucumber/controller.rb', line 134

def roby_try_connect
    # If in auto-port mode, we can't connect until roby_start
    # has been called
    return if @roby_port == 0

    if !roby_interface.connecting? && !roby_interface.connected?
        roby_interface.attempt_connection
    end
    roby_interface.poll
    roby_interface.connected?
end

#run_job(m, arguments = {}) ⇒ Object

Start an action



431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
# File 'lib/roby/app/cucumber/controller.rb', line 431

def run_job(m, arguments = {})
    if validation_mode?
        validate_job(m, arguments)
        return
    end

    action = Interface::V1::Async::ActionMonitor.new(
        roby_interface, m, arguments
    )
    action.restart(batch: current_batch)
    apply_current_batch(action)
    @has_run_job = true

    failed_monitor = roby_poll_interface_until do
        break if action.terminated?

        find_failed_monitoring_job
    end

    return if action.success?

    if failed_monitor
        if keep_running?
            STDERR.puts "\n                FAILED: monitoring job \#{failed_monitor.description} failed\n                In 'keep running' mode. Interrupt with CTRL+C\n            MESSAGE\n            roby_poll_interface_until { false }\n        else\n            raise FailedBackgroundJob,\n                  \"monitoring job \#{failed_monitor.description} failed\"\n        end\n    elsif keep_running?\n        STDERR.puts <<~MESSAGE\n\n            FAILED: action \#{m} failed\"\n            In 'keep running' mode. Interrupt with CTRL+C\"\n        MESSAGE\n        roby_poll_interface_until { false }\n    else\n        raise FailedAction, \"action \#{m} failed\"\n    end\nensure\n    # Kill the monitoring actions as well as the main actions\n    drop_monitoring_jobs(*Array(action))\nend\n"

#start_job(description, m, arguments = {}) ⇒ Object

Start a job in the background

Its failure will make the next #run_job step fail. Unlike a job created by #start_monitoring_job, it will not be stopped when #run_job is called.



336
337
338
339
340
341
342
# File 'lib/roby/app/cucumber/controller.rb', line 336

def start_job(description, m, arguments = {})
    if @has_run_job
        drop_all_jobs unless validation_mode?
        @has_run_job = false
    end
    __start_job(description, m, arguments, false)
end

#start_monitoring_job(description, m, arguments = {}) ⇒ Object

Start a background action whose failure will make the next #run_job step fail

This action will be stopped at the end of the next #run_job



348
349
350
# File 'lib/roby/app/cucumber/controller.rb', line 348

def start_monitoring_job(description, m, arguments = {})
    __start_job(description, m, arguments, true)
end

#validate_job(m, arguments) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Validate that the given action name and arguments match the interface’s description

Raises:



486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
# File 'lib/roby/app/cucumber/controller.rb', line 486

def validate_job(m, arguments)
    unless (action = roby_interface.client.find_action_by_name(m))
        raise InvalidJob, "no action is named '#{m}'"
    end

    arguments = arguments.dup
    action.arguments.each do |arg|
        arg_sym = arg.name.to_sym
        has_arg = arguments.key?(arg_sym)
        if !has_arg && arg.required?
            raise InvalidJob,
                  "#{m} requires an argument named #{arg.name} "\
                  "which is not provided"
        end
        arguments.delete(arg_sym)
    end
    return if arguments.empty?

    raise InvalidJob,
          "arguments #{arguments.keys.map(&:to_s).sort.join(', ')} "\
          "are not declared arguments of #{m}"
end