appear 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/appear/lsof.rb CHANGED
@@ -1,6 +1,12 @@
1
1
  require 'appear/service'
2
+ require 'appear/util/memoizer'
3
+ require 'appear/util/value_class'
2
4
 
3
5
  module Appear
6
+ # raised if we can't parse a connection from an output line of the `lsof`
7
+ # command.
8
+ class LsofParseError < Appear::Error; end
9
+
4
10
  # The LSOF service co-ordinates access to the `lsof` system utility. LSOF
5
11
  # stands for "list open files". It can read the "connections" various
6
12
  # programs have to a given file, eg, what programs have a file descriptor for
@@ -12,18 +18,46 @@ module Appear
12
18
 
13
19
  # A connection of a process to a file.
14
20
  # Created from one output row of `lsof`.
15
- class Connection
16
- attr_accessor :command_name, :pid, :user, :fd, :type, :device, :size, :node, :name, :file_name
17
- def initialize(hash)
18
- hash.each do |key, value|
19
- send("#{key}=", value)
20
- end
21
- end
21
+ class Connection < ::Appear::Util::ValueClass
22
+ # @return [String]
23
+ property :command_name
24
+
25
+ # @return [Fixnum]
26
+ property :pid
27
+
28
+ # @return [String]
29
+ property :fd
30
+
31
+ # @return [String]
32
+ property :fd
33
+
34
+ # @return [String]
35
+ property :type
36
+
37
+ # @return [String]
38
+ property :device
39
+
40
+ # @return [String]
41
+ property :size
42
+
43
+ # @return [String]
44
+ property :node
45
+
46
+ # @return [String]
47
+ property :file_name
22
48
  end
23
49
 
24
50
  # Represents a pane's connection to a TTY.
25
51
  class PaneConnection
26
- attr_reader :pane, :connection, :process
52
+ # @return [#tty] a Terminal emulator pane.
53
+ attr_reader :pane
54
+
55
+ # @return [Connection] an LSOF connection
56
+ attr_reader :connection
57
+
58
+ # @return [Appear::Processes::ProcessInfo] the process
59
+ attr_reader :process
60
+
27
61
  # @param pane [#tty] a pane in a terminal emulator
28
62
  # @param connection [Appear::Lsof::Connection] a connection of a process
29
63
  # to a file -- usually a TTY device.
@@ -34,10 +68,12 @@ module Appear
34
68
  @process = process
35
69
  end
36
70
 
71
+ # @return [String] the TTY this connection is to
37
72
  def tty
38
73
  connection.file_name
39
74
  end
40
75
 
76
+ # @return [Fixnum] pid of the process making the connection
41
77
  def pid
42
78
  connection.pid
43
79
  end
@@ -45,7 +81,7 @@ module Appear
45
81
 
46
82
  def initialize(*args)
47
83
  super(*args)
48
- @cache = {}
84
+ @lsof_memo = Util::Memoizer.new
49
85
  end
50
86
 
51
87
  # find any intersections where a process in the given tree is present in
@@ -87,63 +123,60 @@ module Appear
87
123
  hits.values
88
124
  end
89
125
 
90
- # list connections to files
126
+ # list connections to files.
91
127
  #
92
128
  # @param files [Array<String>] files to query
93
129
  # @return [Hash<String, Array<Connection>>] map of filename to connections
94
130
  def lsofs(files, opts = {})
95
- cached = files.select { |f| @cache[f] }
96
- uncached = files.reject { |f| @cache[f] }
97
-
98
- result = parallel_lsof(uncached, opts)
99
- result.each do |file, data|
100
- @cache[file] = data
101
- end
102
-
103
- cached.each do |f|
104
- result[f] = @cache[f]
105
- end
106
-
107
- result
108
- end
109
-
110
- private
111
-
112
- # lsof takes a really long time, so parallelize lookups when you can.
113
- def parallel_lsof(files, opts = {})
131
+ mutex = Mutex.new
114
132
  results = {}
115
133
  threads = files.map do |file|
116
134
  Thread.new do
117
- results[file] = lsof(file, opts)
135
+ single_result = lsof(file, opts)
136
+ mutex.synchronize do
137
+ results[file] = single_result
138
+ end
118
139
  end
119
140
  end
120
141
  threads.each { |t| t.join }
121
142
  results
122
143
  end
123
144
 
145
+ private
146
+
124
147
  def lsof(file, opts = {})
125
- pids = opts[:pids]
126
- if pids
127
- output = run(['lsof', '-ap', pids.join(','), file])
128
- else
129
- output = run("lsof #{file.shellescape}")
130
- end
131
- rows = output.lines.map do |line|
132
- command, pid, user, fd, type, device, size, node, name = line.strip.split(/\s+/)
133
- Connection.new({
134
- command_name: command,
135
- pid: pid.to_i,
136
- user: user,
137
- fd: fd,
138
- type: type,
139
- device: device,
140
- size: size,
141
- node: node,
142
- file_name: name
143
- })
148
+ @lsof_memo.call(file, opts) do
149
+ pids = opts[:pids]
150
+ if pids
151
+ output = run(['lsof', '-ap', pids.join(','), file], :allow_failure => true)
152
+ else
153
+ output = run(['lsof', file], :allow_failure => true)
154
+ end
155
+ next [] if output.empty?
156
+ rows = output.lines.map do |line|
157
+ line = line.strip
158
+ fields = line.split(/\s+/)
159
+ if fields.length < 9
160
+ raise LsofParseError.new("Not enough fields in line #{line.inspect}")
161
+ end
162
+ command, pid, user, fd, type, device, size, node, name = fields
163
+ Connection.new({
164
+ command_name: command,
165
+ pid: pid.to_i,
166
+ user: user,
167
+ fd: fd,
168
+ type: type,
169
+ device: device,
170
+ size: size,
171
+ node: node.to_i,
172
+ file_name: name
173
+ })
174
+ end
175
+ # drop header row
176
+ rows[1..-1]
144
177
  end
145
- rows[1..-1]
146
- rescue Appear::ExecutionFailure
178
+ rescue LsofParseError => err
179
+ log("lsof: parse error: #{err}")
147
180
  []
148
181
  end
149
182
  end
data/lib/appear/mac_os.rb CHANGED
@@ -4,6 +4,7 @@ require 'appear/constants'
4
4
  require 'appear/service'
5
5
 
6
6
  module Appear
7
+ # Raised if our helper program returns an error.
7
8
  class MacToolError < Error
8
9
  def initialize(message, stack)
9
10
  super("Mac error #{message.inspect}\n#{stack}")
@@ -19,6 +20,10 @@ module Appear
19
20
  SCRIPT = Appear::TOOLS_DIR.join('macOS-helper.js').realpath.to_s
20
21
 
21
22
  # call a method in our helper script. Communicates with JSON!
23
+ # @param method_name [String, Symbol] check the source of macOS-helper.js for method names.
24
+ # @param data [Any, nil] json-able data to pass to the named method.
25
+ # @return [Any] json data returned from the helper
26
+ # @raise [MacToolError] if an error occured
22
27
  def call_method(method_name, data = nil)
23
28
  command = [SCRIPT, method_name.to_s]
24
29
  command << data.to_json unless data.nil?
@@ -32,8 +37,13 @@ module Appear
32
37
  end
33
38
  end
34
39
 
35
- # TODO: ask Applescript if this a GUI application instead of just looking
36
- # at the path
40
+ # Return true if the given process is a macOS GUI process, false otherwise.
41
+ #
42
+ # @todo: ask Applescript if this a GUI application instead of just looking
43
+ # at the path
44
+ #
45
+ # @param process [Appear::Processes::ProcessInfo]
46
+ # @return [Boolean]
37
47
  def has_gui?(process)
38
48
  executable = process.command.first
39
49
  executable =~ /\.app\/Contents\//
data/lib/appear/output.rb CHANGED
@@ -5,16 +5,29 @@ module Appear
5
5
  # The Output service encapsulates writing logging information to log files
6
6
  # and STDERR, and writing output to STDOUT.
7
7
  class Output
8
+ # Create a new Output service.
9
+ #
10
+ # @param log_file_name [String, nil] if a string, log to the file at this path
11
+ # @param silent [Boolean] if true, output to STDERR
8
12
  def initialize(log_file_name, silent)
13
+ @file_logger = nil
14
+ @stderr_logger = nil
15
+
9
16
  @file_logger = Logger.new(log_file_name.to_s) if log_file_name
10
17
  @stderr_logger = Logger.new(STDERR) unless silent
11
18
  end
12
19
 
20
+ # Log a message.
21
+ #
22
+ # @param any [Array<Any>]
13
23
  def log(*any)
14
24
  @stderr_logger.debug(*any) if @stderr_logger
15
25
  @file_logger.debug(*any) if @file_logger
16
26
  end
17
27
 
28
+ # Log an error
29
+ #
30
+ # @param err [Error]
18
31
  def log_error(err)
19
32
  log("Error #{err.inspect}: #{err.to_s.inspect}")
20
33
  if err.backtrace
@@ -22,6 +35,9 @@ module Appear
22
35
  end
23
36
  end
24
37
 
38
+ # Output a message to STDOUT, and also to the log file.
39
+ #
40
+ # @param any [Array<Any>]
25
41
  def output(*any)
26
42
  STDOUT.puts(*any)
27
43
  @file_logger.debug(*any) if @file_logger
@@ -1,5 +1,6 @@
1
1
  require 'appear/constants'
2
2
  require 'appear/service'
3
+ require 'appear/util/memoizer'
3
4
 
4
5
  module Appear
5
6
  # Raised if Processes tries to get info for a dead process, or a PID that is
@@ -24,7 +25,7 @@ module Appear
24
25
 
25
26
  def initialize(*args)
26
27
  super(*args)
27
- @cache = {}
28
+ @get_info_memo = Util::Memoizer.new
28
29
  end
29
30
 
30
31
  # Get info about a process by PID, including its command and parent_pid.
@@ -32,12 +33,9 @@ module Appear
32
33
  # @param pid [Integer]
33
34
  # @return [ProcessInfo]
34
35
  def get_info(pid)
35
- result = @cache[pid]
36
- unless result
37
- result = fetch_info(pid)
38
- @cache[pid] = result
36
+ @get_info_memo.call(pid) do
37
+ fetch_info(pid)
39
38
  end
40
- result
41
39
  end
42
40
 
43
41
  # Is the given process alive?
@@ -1,5 +1,7 @@
1
1
  require 'appear/service'
2
- require 'appear/join'
2
+ require 'appear/util/join'
3
+ require 'appear/terminal'
4
+ require 'ostruct'
3
5
 
4
6
  module Appear
5
7
  # stores all the ways we can appear something
@@ -11,6 +13,11 @@ module Appear
11
13
  module Revealers
12
14
  # extend to implement more revealers
13
15
  class BaseRevealer < Service
16
+ # Reveal `tree` if supported by this revealer. You can get a tree from
17
+ # {Processes#process_tree}.
18
+ #
19
+ # @param tree [Array<Processes::ProcessInfo>]
20
+ # @return [true, nil] return true if we revealed something, otherwise nil
14
21
  def call(tree)
15
22
  target, *rest = tree
16
23
  if supports_tree?(target, rest)
@@ -18,98 +25,82 @@ module Appear
18
25
  end
19
26
  end
20
27
 
21
- # TODO
28
+ # Reveal `tree`. Should be implemented by subclasses.
29
+ #
30
+ # @abstract subclasses must implement this method.
31
+ # @param tree [Array<Processes::ProcessInfo>]
32
+ # @return [true, nil] return true if we revealed something, otherwise nil
22
33
  def reveal_tree(tree)
23
34
  raise "not implemented"
24
35
  end
25
36
 
26
- # appear the first process in this process tree.
27
- # should return nil if no action was performed.
28
- # otherwise, return true.
37
+ # Returns true if this revealer may be able to reveal something in the
38
+ # tree. For this method, the caller splits the tree into the target and
39
+ # the rest of the tree, which sometimes simplifies the implementation of
40
+ # this method.
41
+ #
42
+ # @abstract subclasses must implement this method.
43
+ # @param target [Processes::ProcessInfo] bottom (child-most) item in the process
44
+ # tree
45
+ # @param rest [Array<Processes::ProcessInfo>] the rest of the tree
46
+ # @return [Boolean]
29
47
  def supports_tree?(target, rest)
30
48
  raise "not implemented"
31
49
  end
32
50
 
51
+ # Register this class as a revealer so it will be called by
52
+ # {Instance#call}
33
53
  def self.register!
34
54
  Appear::REVEALERS.push(self)
35
55
  end
36
56
  end
37
57
 
58
+ # Base class for Mac-terminal revealers.
38
59
  class MacRevealer < BaseRevealer
39
- delegate :join_via_tty, :lsof
40
60
  require_service :mac_os
61
+ require_service :mac_term
62
+ require_service :lsof
41
63
 
42
- def panes
43
- raise "not implemented"
44
- end
45
-
46
- def reveal_hit(hit)
47
- raise "not implemented"
48
- end
49
-
64
+ # Implementation.
65
+ # @see BaseRevealer#reveal_tree
50
66
  def reveal_tree(tree)
51
- hits = join_via_tty(tree, panes)
67
+ hits = services.lsof.join_via_tty(tree, services.mac_term.panes)
52
68
  actual_hits = hits.uniq {|hit| hit.tty }.
53
69
  reject {|hit| services.mac_os.has_gui?(hit.process) }.
54
- each { |hit| reveal_hit(hit) }
70
+ each { |hit| services.mac_term.reveal_pane(hit) }
55
71
 
56
72
  return actual_hits.length > 0
57
73
  end
58
74
 
59
- # TODO: read the bundle identifier somehow, but this is close enough.
60
- # or get the bundle identifier and enhance the process lists with it?
61
- def has_gui_app_named?(tree, name)
62
- tree.any? do |process|
63
- process.name == name && services.mac_os.has_gui?(process)
75
+ # Implementation
76
+ # @see BaseRevealer#supports_tree?
77
+ def supports_tree?(target, rest)
78
+ rest.any? do |process|
79
+ process.name == services.mac_term.app_name && services.mac_os.has_gui?(process)
64
80
  end
65
81
  end
66
82
  end
67
83
 
84
+ # Iterm2 revealer support
68
85
  class Iterm2 < MacRevealer
69
- require_service :processes
70
-
71
- def supports_tree?(target, rest)
72
- has_gui_app_named?(rest, 'iTerm2')
73
- end
74
-
75
- def panes
76
- pids = services.processes.pgrep('iTerm2')
77
- services.mac_os.call_method('iterm2_panes').map do |hash|
78
- hash[:pids] = pids
79
- OpenStruct.new(hash)
80
- end
81
- end
82
-
83
- def reveal_hit(hit)
84
- services.mac_os.call_method('iterm2_reveal_tty', hit.tty)
86
+ def initialize(services)
87
+ super(services.merge(
88
+ :mac_term => Appear::Terminal::Iterm2.new(services)
89
+ ))
85
90
  end
86
91
  end
87
92
 
93
+ # TerminalApp revealer support
88
94
  class TerminalApp < MacRevealer
89
- require_service :processes
90
-
91
- def supports_tree?(target, rest)
92
- has_gui_app_named?(rest, 'Terminal')
93
- end
94
-
95
- def panes
96
- pids = services.processes.pgrep('Terminal.app')
97
- services.mac_os.call_method('terminal_panes').map do |hash|
98
- hash[:pids] = pids
99
- OpenStruct.new(hash)
100
- end
101
- end
102
-
103
- def reveal_hit(hit)
104
- # iterm2 runs a non-gui server process. Because of implementation
105
- # details of MacOs#has_gui?, we don't *techinically* have to worry
106
- # about this, but we should in case I ever implement real mac
107
- # gui-or-not lookup.
108
- return if hit.process.name == 'iTerm2'
109
- services.mac_os.call_method('terminal_reveal_tty', hit.tty)
95
+ def initialize(services)
96
+ super(services.merge(
97
+ :mac_term => Appear::Terminal::TerminalApp.new(services)
98
+ ))
110
99
  end
111
100
  end
112
101
 
102
+ # support for the cross-platform Tmux multiplexer. Also reveals a connected
103
+ # tmux client, if possible.
113
104
  class Tmux < BaseRevealer
114
105
  # TODO: cache services.tmux.panes, services.tmux.clients for this revealer?
115
106
  require_service :tmux
@@ -117,12 +108,16 @@ module Appear
117
108
  require_service :revealer
118
109
  require_service :processes
119
110
 
111
+ # Implementation
112
+ # @see BaseRevealer#supports_tree?
120
113
  def supports_tree?(target, rest)
121
114
  rest.any? { |p| p.name == 'tmux' }
122
115
  end
123
116
 
117
+ # Implementation.
118
+ # @see BaseRevealer#reveal_tree
124
119
  def reveal_tree(tree)
125
- relevent_panes = Join.join(:pid, tree, services.tmux.panes)
120
+ relevent_panes = Util::Join.join(:pid, tree, services.tmux.panes)
126
121
  relevent_panes.each do |pane|
127
122
  log("#{self.class.name}: revealing pane #{pane}")
128
123
  services.tmux.reveal_pane(pane)
@@ -141,17 +136,21 @@ module Appear
141
136
  # to find the PID of a tmux client is to lsof() the TTY that the client
142
137
  # is connected to, and then deduce the client PID, which will be a tmux
143
138
  # process PID that is not the server PID.
139
+ #
140
+ # @param tree [Array<Processes::ProcessInfo>]
141
+ # @return [Number, nil] pid of a tmux client, if one was found. Otherwise
142
+ # nil.
144
143
  def tmux_client_for_tree(tree)
145
144
  tmux_server = tree.find {|p| p.name == 'tmux'}
146
145
 
147
146
  # join processes on tmux panes by PID.
148
- proc_and_panes = Join.join(:pid, services.tmux.panes, tree)
147
+ proc_and_panes = Util::Join.join(:pid, services.tmux.panes, tree)
149
148
 
150
149
  # Join the list of tmux clients with process_and_pid on :session.
151
150
  # In tmux, every pane is addressed by session_name:window_index:pane_index.
152
151
  # This gives us back a list of all the clients that have a pane that
153
152
  # contains a process in our given process tree.
154
- proc_and_clients = Join.join(:session, services.tmux.clients, proc_and_panes)
153
+ proc_and_clients = Util::Join.join(:session, services.tmux.clients, proc_and_panes)
155
154
 
156
155
  # there *should* be only one of these, unless there are two clients
157
156
  # connected to the same tmux session. In that case we just choose one
@@ -167,11 +166,15 @@ module Appear
167
166
  [tty_of_client],
168
167
  :pids => services.processes.pgrep('tmux')
169
168
  )[tty_of_client]
169
+
170
170
  client_connection = connections_to_tty.find do |conn|
171
171
  (conn.command_name =~ /^tmux/) && (conn.pid != tmux_server.pid)
172
172
  end
173
173
 
174
- client_connection.pid if client_connection
174
+ if client_connection
175
+ log("tmux_client_for_tree: found client pid=#{client_connection.pid} for tree pid=#{tree.first.pid}")
176
+ return client_connection.pid
177
+ end
175
178
  end
176
179
  end
177
180
 
data/lib/appear/runner.rb CHANGED
@@ -21,8 +21,17 @@ module Appear
21
21
  # either be a string, or an array of command name and parameters.
22
22
  # Returns the combinded STDERR and STDOUT of the command.
23
23
  #
24
- # @return String
25
- def run(command)
24
+ # @param command [String, Array] command to run, as an argv array, or as a
25
+ # sh command string.
26
+ # @param opts [Hash] options
27
+ # @option opts [Boolean] :allow_failure (false) permit running the command
28
+ # to fail. Do not raise an ExecutionFailure error.
29
+ #
30
+ # @raise [ExecutionFailure] if the command exists non-zero
31
+ #
32
+ # @return [String]
33
+ def run(command, opts = {})
34
+ allow_failure = opts[:allow_failure] || false
26
35
  start = Time.new
27
36
  if command.is_a? Array
28
37
  output, status = Open3.capture2e(*command)
@@ -31,14 +40,20 @@ module Appear
31
40
  end
32
41
  finish = Time.new
33
42
  log("Runner: ran #{command.inspect} in #{finish - start}s")
34
- raise ExecutionFailure.new(command, output) unless status.success?
43
+ if !status.success? && !allow_failure
44
+ raise ExecutionFailure.new(command, output)
45
+ end
35
46
  output
36
47
  end
37
48
  end
38
49
 
39
50
  # Records every command run to a directory; intended to be useful for later integration tests.
40
51
  class RunnerRecorder < Runner
52
+ # The location to write recorded runs to. Currently hard-coded to a
53
+ # location inside the gem's spec folder.
41
54
  OUTPUT_DIR = MODULE_DIR.join('spec/command_output')
55
+
56
+ # the time that this class file was loaded at
42
57
  INIT_AT = Time.new
43
58
 
44
59
  def initialize(*args)
@@ -46,14 +61,19 @@ module Appear
46
61
  @command_runs = Hash.new { |h, k| h[k] = [] }
47
62
  end
48
63
 
49
- def run(command)
64
+ # @see Runner#run
65
+ def run(command, opts = {})
50
66
  begin
51
67
  result = super(command)
52
68
  record_success(command, result)
53
69
  return result
54
70
  rescue ExecutionFailure => err
55
71
  record_error(command, err)
56
- raise err
72
+ if opts[:allow_failure]
73
+ return err.output
74
+ else
75
+ raise err
76
+ end
57
77
  end
58
78
  end
59
79