woods 2.0.0.beta4 → 2.0.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +495 -471
- data/CONTRIBUTING.md +12 -2
- data/README.md +11 -26
- data/docs/AGENT_GUIDE.md +31 -12
- data/docs/AGENT_SETUP.md +17 -10
- data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
- data/docs/BACKEND_MATRIX.md +13 -7
- data/docs/CLIENT_HOOKS.md +1 -1
- data/docs/CONFIGURATION_REFERENCE.md +44 -27
- data/docs/CONSOLE_MCP_SETUP.md +95 -16
- data/docs/DOCKER_SETUP.md +15 -0
- data/docs/EVALUATION.md +10 -4
- data/docs/EXTRACTOR_REFERENCE.md +14 -2
- data/docs/FAQ.md +14 -3
- data/docs/GETTING_STARTED.md +18 -17
- data/docs/INCREMENTAL_EXTRACTION.md +8 -3
- data/docs/INDEX_LAYOUT.md +2 -2
- data/docs/MCP_HTTP_TRANSPORT.md +54 -2
- data/docs/MCP_SERVERS.md +28 -11
- data/docs/MCP_TOOL_COOKBOOK.md +1 -1
- data/docs/MCP_WORKTREE_SETUP.md +13 -1
- data/docs/PUBLISHED_INDEX.md +1 -1
- data/docs/README.md +2 -1
- data/docs/RETRIEVAL_GUIDE.md +57 -8
- data/docs/SOURCE_FRESHNESS.md +1 -1
- data/docs/TOKEN_BENCHMARK.md +16 -10
- data/docs/TROUBLESHOOTING.md +133 -37
- data/docs/UPGRADING_TO_2.md +69 -7
- data/docs/WATCH_DAEMON.md +172 -17
- data/docs/WHY_WOODS.md +9 -5
- data/exe/woods-console +13 -11
- data/exe/woods-mcp-http +16 -9
- data/exe/woods-watch +5 -0
- data/lib/generators/woods/watch_generator.rb +53 -0
- data/lib/puma/plugin/woods.rb +10 -0
- data/lib/tasks/woods.rake +14 -0
- data/lib/woods/cache/cache_middleware.rb +18 -11
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +401 -43
- data/lib/woods/console/model_validator.rb +8 -0
- data/lib/woods/console/rack_middleware.rb +39 -10
- data/lib/woods/console/redactor.rb +24 -10
- data/lib/woods/console/safe_context.rb +44 -7
- data/lib/woods/console/sql_noise_stripper.rb +41 -12
- data/lib/woods/console/sql_table_scanner.rb +45 -34
- data/lib/woods/console/sql_validator.rb +37 -2
- data/lib/woods/console/stdio_transport.rb +27 -0
- data/lib/woods/extractor.rb +25 -7
- data/lib/woods/git_command.rb +6 -7
- data/lib/woods/git_provenance.rb +4 -6
- data/lib/woods/mcp/bearer_auth.rb +1 -1
- data/lib/woods/mcp/bootstrapper.rb +3 -1
- data/lib/woods/mcp/initialization_guidance.rb +1 -1
- data/lib/woods/mcp/origin_guard.rb +24 -77
- data/lib/woods/mcp/origin_policy.rb +124 -0
- data/lib/woods/mcp/server.rb +41 -9
- data/lib/woods/railtie_support.rb +8 -0
- data/lib/woods/retrieval/corpus_status.rb +46 -0
- data/lib/woods/retriever.rb +19 -7
- data/lib/woods/storage/local_corpus_stats.rb +32 -0
- data/lib/woods/storage/metadata_store.rb +20 -0
- data/lib/woods/storage/vector_store.rb +10 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/child_environment.rb +30 -0
- data/lib/woods/watch/cli.rb +91 -0
- data/lib/woods/watch/daemon.rb +55 -7
- data/lib/woods/watch/event_stream.rb +70 -0
- data/lib/woods/watch/guardian.rb +142 -0
- data/lib/woods/watch/installation/layout.rb +70 -0
- data/lib/woods/watch/installation/options.rb +128 -0
- data/lib/woods/watch/installation/planner.rb +128 -0
- data/lib/woods/watch/installation/probe.rb +101 -0
- data/lib/woods/watch/installation/receipt.rb +77 -0
- data/lib/woods/watch/installation/recovery.rb +64 -0
- data/lib/woods/watch/installation/templates.rb +58 -0
- data/lib/woods/watch/installation.rb +56 -0
- data/lib/woods/watch/lifecycle.rb +182 -0
- data/lib/woods/watch/managed_child.rb +113 -0
- data/lib/woods/watch/managed_cleanup.rb +48 -0
- data/lib/woods/watch/managed_process.rb +144 -0
- data/lib/woods/watch/puma_adapter.rb +87 -0
- data/lib/woods/watch/puma_child.rb +66 -0
- data/lib/woods/watch/supervision_records.rb +95 -0
- data/lib/woods/watch/supervision_status.rb +104 -0
- data/lib/woods/watch/supervisor.rb +161 -0
- data/lib/woods/watch/supervisor_reporting.rb +46 -0
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/skills/woods-agent-enable/SKILL.md +1 -1
- data/plugin/skills/woods-diagnose/SKILL.md +88 -8
- data/plugin/skills/woods-investigate/SKILL.md +6 -6
- data/plugin/skills/woods-mcp-config/SKILL.md +43 -1
- data/plugin/skills/woods-setup/SKILL.md +66 -4
- metadata +37 -5
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'managed_child'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Watch
|
|
8
|
+
# Validates the private attempt protocol independently of application logs.
|
|
9
|
+
class Lifecycle # rubocop:disable Metrics/ClassLength -- one attempt's ordered protocol state must stay together
|
|
10
|
+
# @param launcher [String] expected launcher token
|
|
11
|
+
# @param attempt [String] expected attempt token
|
|
12
|
+
# @param root [String] selected application root
|
|
13
|
+
def initialize(launcher:, attempt:, root:)
|
|
14
|
+
@launcher = launcher
|
|
15
|
+
@attempt = attempt
|
|
16
|
+
@root = File.realpath(root)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [String, nil] resolved index directory, only after Rails boot
|
|
20
|
+
attr_reader :index, :child_pid, :terminal, :exit_code, :state, :reason
|
|
21
|
+
|
|
22
|
+
# @return [Boolean] Rails has resolved the application/index identity
|
|
23
|
+
def booted?
|
|
24
|
+
!@index.nil?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] guardian explicitly reported the application's exit
|
|
28
|
+
def finished?
|
|
29
|
+
@exited == true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param line [String] bounded JSON event from the private descriptor
|
|
33
|
+
# @return [Hash] validated event
|
|
34
|
+
def accept(line)
|
|
35
|
+
raise ArgumentError, 'oversized Woods lifecycle record' if line.bytesize > ManagedChild::MAX_BYTES
|
|
36
|
+
|
|
37
|
+
record = JSON.parse(line)
|
|
38
|
+
validate_envelope!(record)
|
|
39
|
+
event = record.fetch('event')
|
|
40
|
+
raise ArgumentError, 'event after guardian exit' if @exited
|
|
41
|
+
|
|
42
|
+
if %w[hello spawned exit spawn_error].include?(event)
|
|
43
|
+
guardian_event(record)
|
|
44
|
+
else
|
|
45
|
+
task_event(record)
|
|
46
|
+
end
|
|
47
|
+
record
|
|
48
|
+
rescue JSON::ParserError, KeyError, TypeError, SystemCallError
|
|
49
|
+
raise ArgumentError, 'invalid Woods lifecycle protocol'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def validate_envelope!(record)
|
|
55
|
+
return if record.is_a?(Hash) && record['version'] == 1 && record['launcher'] == @launcher &&
|
|
56
|
+
record['attempt'] == @attempt && record['pid'].is_a?(Integer) && record['pid'].positive?
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, 'invalid Woods lifecycle envelope'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def guardian_event(record)
|
|
62
|
+
case record['event']
|
|
63
|
+
when 'hello'
|
|
64
|
+
raise ArgumentError, 'duplicate guardian hello' if @guardian
|
|
65
|
+
|
|
66
|
+
@guardian = record['pid']
|
|
67
|
+
when 'spawned'
|
|
68
|
+
spawned(record)
|
|
69
|
+
when 'exit', 'spawn_error'
|
|
70
|
+
validate_guardian!(record)
|
|
71
|
+
validate_exit!(record)
|
|
72
|
+
@exit_code = record['event'] == 'spawn_error' ? 127 : record['code']
|
|
73
|
+
@exited = true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def spawned(record)
|
|
78
|
+
validate_guardian!(record)
|
|
79
|
+
raise ArgumentError, 'duplicate child spawn' if @spawned
|
|
80
|
+
|
|
81
|
+
pid = record['child_pid']
|
|
82
|
+
raise ArgumentError, 'invalid child PID' unless pid.is_a?(Integer) && pid.positive?
|
|
83
|
+
raise ArgumentError, 'child PID changed' if @child_pid && @child_pid != pid
|
|
84
|
+
|
|
85
|
+
@child_pid = pid
|
|
86
|
+
@spawned = true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_exit!(record)
|
|
90
|
+
return if record['event'] == 'spawn_error' && !@spawned
|
|
91
|
+
return if record['event'] == 'exit' && @spawned && exit_status_valid?(record)
|
|
92
|
+
|
|
93
|
+
raise ArgumentError, 'invalid guardian exit'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def exit_status_valid?(record)
|
|
97
|
+
code, signal = record.values_at('code', 'signal')
|
|
98
|
+
return code.between?(0, 255) if code.is_a?(Integer)
|
|
99
|
+
|
|
100
|
+
code.nil? && signal.is_a?(Integer) && signal.positive?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_guardian!(record)
|
|
104
|
+
raise ArgumentError, 'guardian identity mismatch' unless @guardian == record['pid']
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def task_event(record)
|
|
108
|
+
validate_task_identity!(record)
|
|
109
|
+
event = record['event']
|
|
110
|
+
case event
|
|
111
|
+
when 'task_loaded' then load_task(record)
|
|
112
|
+
when 'identity' then resolve_identity(record)
|
|
113
|
+
when 'backend_ready' then ready_backend
|
|
114
|
+
when 'startup' then startup(record)
|
|
115
|
+
when 'terminal' then finish(record)
|
|
116
|
+
else raise ArgumentError, 'unknown Woods lifecycle event'
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def validate_task_identity!(record)
|
|
121
|
+
raise ArgumentError, 'missing guardian hello' unless @guardian
|
|
122
|
+
raise ArgumentError, 'task event after terminal' if @terminal
|
|
123
|
+
raise ArgumentError, 'task identity mismatch' if @child_pid && @child_pid != record['pid']
|
|
124
|
+
|
|
125
|
+
@child_pid = record['pid']
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def load_task(record)
|
|
129
|
+
version = record['woods_version']
|
|
130
|
+
unless !@task && version.is_a?(String) && Gem::Version.correct?(version)
|
|
131
|
+
raise ArgumentError, 'invalid task handshake'
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@task = true
|
|
135
|
+
@state = 'booting'
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def resolve_identity(record)
|
|
139
|
+
root, index = record.values_at('root', 'index')
|
|
140
|
+
unless @task && !@index && root.is_a?(String) && File.realpath(root) == @root &&
|
|
141
|
+
index.is_a?(String) && File.expand_path(index) == index
|
|
142
|
+
raise ArgumentError, 'invalid task root/index identity'
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@index = index
|
|
146
|
+
@state = 'starting'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def ready_backend
|
|
150
|
+
raise ArgumentError, 'invalid backend ready event' unless booted? && !@backend
|
|
151
|
+
|
|
152
|
+
@backend = true
|
|
153
|
+
@state = 'reconciling'
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def startup(record)
|
|
157
|
+
validate_startup!(record)
|
|
158
|
+
reconciled = record['reason'] == 'reconciled' && record['generation'].positive?
|
|
159
|
+
raise ArgumentError, 'inconsistent startup completion' unless (record['state'] == 'ready') == reconciled
|
|
160
|
+
|
|
161
|
+
@state = record['state']
|
|
162
|
+
@reason = record['reason']
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def validate_startup!(record)
|
|
166
|
+
return if @backend && %w[ready degraded].include?(record['state']) &&
|
|
167
|
+
ManagedChild::STARTUP_REASONS.include?(record['reason']) &&
|
|
168
|
+
record['generation'].is_a?(Integer) && record['generation'] >= 0
|
|
169
|
+
|
|
170
|
+
raise ArgumentError, 'invalid startup completion'
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def finish(record)
|
|
174
|
+
unless @task && ManagedChild::TERMINAL_REASONS.include?(record['reason'])
|
|
175
|
+
raise ArgumentError, 'invalid terminal event'
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
@terminal = record['reason']
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Watch
|
|
7
|
+
# Private, bounded lifecycle reporting for a task owned by woods-watch.
|
|
8
|
+
# Raw rake tasks do not open descriptors or alter their lifecycle.
|
|
9
|
+
class ManagedChild
|
|
10
|
+
ENV_KEYS = %w[WOODS_WATCH_EVENT_FD WOODS_WATCH_LAUNCHER_TOKEN WOODS_WATCH_ATTEMPT_TOKEN].freeze
|
|
11
|
+
TOKEN = /\A[A-Za-z0-9_-]{1,128}\z/
|
|
12
|
+
MAX_BYTES = 4096
|
|
13
|
+
FIELDS = {
|
|
14
|
+
task_loaded: %i[woods_version], identity: %i[root index], backend_ready: [],
|
|
15
|
+
startup: %i[state generation reason], terminal: %i[reason]
|
|
16
|
+
}.freeze
|
|
17
|
+
STARTUP_REASONS = %w[reconciled startup_failed pending_work restart_required no_index catch_up_disabled].freeze
|
|
18
|
+
TERMINAL_REASONS = %w[restart_required already_running stopped idle unsupported_environment].freeze
|
|
19
|
+
|
|
20
|
+
# @param env [#[]] child process environment
|
|
21
|
+
# @return [ManagedChild, nil] nil for an ordinary unmanaged task
|
|
22
|
+
def self.from_env(env: ENV)
|
|
23
|
+
values = ENV_KEYS.map { |key| env[key] }
|
|
24
|
+
return if values.all?(&:nil?)
|
|
25
|
+
|
|
26
|
+
descriptor, launcher, attempt = values
|
|
27
|
+
validate_environment!(descriptor, [launcher, attempt])
|
|
28
|
+
|
|
29
|
+
unless env['WOODS_WATCH_IDLE_TIMEOUT'].to_s.empty?
|
|
30
|
+
raise ArgumentError, 'WOODS_WATCH_IDLE_TIMEOUT must be unset for managed watching'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
new(IO.for_fd(descriptor.to_i, 'w'), launcher: launcher, attempt: attempt)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.validate_environment!(descriptor, tokens)
|
|
37
|
+
valid_tokens = tokens.all? { |token| token.is_a?(String) && token.match?(TOKEN) }
|
|
38
|
+
valid_fd = descriptor.is_a?(String) && descriptor.match?(/\A\d+\z/) && descriptor.to_i >= 3
|
|
39
|
+
return if valid_tokens && valid_fd
|
|
40
|
+
|
|
41
|
+
raise ArgumentError, 'invalid Woods managed-child environment'
|
|
42
|
+
end
|
|
43
|
+
private_class_method :validate_environment!
|
|
44
|
+
|
|
45
|
+
# @param io [IO] private event descriptor owned by this reporter
|
|
46
|
+
# @param launcher [String] supervisor identity token
|
|
47
|
+
# @param attempt [String] current child attempt identity token
|
|
48
|
+
def initialize(io, launcher:, attempt:)
|
|
49
|
+
@io = io
|
|
50
|
+
@io.binmode
|
|
51
|
+
@io.sync = true
|
|
52
|
+
@io.close_on_exec = true
|
|
53
|
+
@launcher = launcher
|
|
54
|
+
@attempt = attempt
|
|
55
|
+
@mutex = Mutex.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @param event [Symbol] an allowlisted lifecycle boundary
|
|
59
|
+
# @param fields [Hash] bounded protocol data, never application exceptions
|
|
60
|
+
# @return [void]
|
|
61
|
+
def call(event, **fields)
|
|
62
|
+
validate!(event, fields)
|
|
63
|
+
message = JSON.generate(version: 1, launcher: @launcher, attempt: @attempt,
|
|
64
|
+
event: event.to_s, pid: Process.pid, **fields) << "\n"
|
|
65
|
+
raise ArgumentError, 'Woods lifecycle message exceeds size limit' if message.bytesize > MAX_BYTES
|
|
66
|
+
|
|
67
|
+
@mutex.synchronize { @io.write(message) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [void]
|
|
71
|
+
def close
|
|
72
|
+
@mutex.synchronize { @io.close unless @io.closed? }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def validate!(event, fields)
|
|
78
|
+
expected = FIELDS[event]
|
|
79
|
+
raise ArgumentError, 'invalid Woods lifecycle event fields' unless expected && fields.keys.sort == expected.sort
|
|
80
|
+
|
|
81
|
+
validate_payload!(event, fields)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_payload!(event, fields)
|
|
85
|
+
case event
|
|
86
|
+
when :startup then validate_startup!(fields)
|
|
87
|
+
when :terminal
|
|
88
|
+
raise ArgumentError, 'invalid Woods terminal reason' unless TERMINAL_REASONS.include?(fields[:reason])
|
|
89
|
+
when :identity
|
|
90
|
+
validate_identity!(fields)
|
|
91
|
+
when :task_loaded
|
|
92
|
+
raise ArgumentError, 'invalid Woods version' unless fields[:woods_version].is_a?(String)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_identity!(fields)
|
|
97
|
+
return if %i[root index].all? do |key|
|
|
98
|
+
fields[key].is_a?(String) && fields[key] == File.expand_path(fields[key])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, 'Woods lifecycle identity must contain absolute paths'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_startup!(fields)
|
|
105
|
+
return if %w[ready degraded].include?(fields[:state]) &&
|
|
106
|
+
fields[:generation].is_a?(Integer) && fields[:generation] >= 0 &&
|
|
107
|
+
STARTUP_REASONS.include?(fields[:reason])
|
|
108
|
+
|
|
109
|
+
raise ArgumentError, 'invalid Woods startup state or reason'
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Watch
|
|
5
|
+
# Bounded, idempotent cleanup of groups registered by an owned guardian.
|
|
6
|
+
module ManagedCleanup
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def stop_owned
|
|
10
|
+
return if @stopped
|
|
11
|
+
|
|
12
|
+
@io_mutex.synchronize { @liveness.close if @liveness && !@liveness.closed? }
|
|
13
|
+
wait_until_stopped(@config[:shutdown_timeout] + 1)
|
|
14
|
+
force_stop if alive? || @status&.signaled?
|
|
15
|
+
@stopped = true
|
|
16
|
+
ensure
|
|
17
|
+
close
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def force_stop
|
|
21
|
+
child = @stream&.child_pid
|
|
22
|
+
signal_owned_group('KILL', child) if child
|
|
23
|
+
signal_owned_group('KILL', @pid) if alive?
|
|
24
|
+
wait_until_stopped(1)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def wait_until_stopped(seconds)
|
|
28
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
29
|
+
while alive? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
30
|
+
drain_cleanup_events
|
|
31
|
+
sleep 0.02
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def drain_cleanup_events
|
|
36
|
+
read_events
|
|
37
|
+
rescue ArgumentError
|
|
38
|
+
@io_mutex.synchronize { @reader.close unless @reader.closed? }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def signal_owned_group(signal, pid)
|
|
42
|
+
Process.kill(signal, -pid)
|
|
43
|
+
rescue Errno::ESRCH
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rbconfig'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require_relative 'child_environment'
|
|
7
|
+
require_relative 'event_stream'
|
|
8
|
+
require_relative 'managed_cleanup'
|
|
9
|
+
|
|
10
|
+
module Woods
|
|
11
|
+
module Watch
|
|
12
|
+
# Owns one guardian and its private pipes. File-based PIDs never grant
|
|
13
|
+
# signal authority. The guardian starts before the application bundle.
|
|
14
|
+
class ManagedProcess
|
|
15
|
+
include ManagedCleanup
|
|
16
|
+
|
|
17
|
+
MAX_EVENT_BYTES = 4096
|
|
18
|
+
|
|
19
|
+
# @param command [Array<String>] explicit application argument vector
|
|
20
|
+
# @param root [String] application working directory
|
|
21
|
+
# @param env [Hash] application environment
|
|
22
|
+
# @param shutdown_timeout [Numeric] graceful shutdown seconds
|
|
23
|
+
# @param events [Boolean] expose the private task protocol to the child
|
|
24
|
+
# @param launcher [String] launcher identity
|
|
25
|
+
# @param attempt [String] fresh attempt identity
|
|
26
|
+
# rubocop:disable-next Metrics/ParameterLists -- the process and protocol identities are explicit collaborators
|
|
27
|
+
def initialize(command:, root:, env:, shutdown_timeout: 10, events: false,
|
|
28
|
+
launcher: SecureRandom.hex(16), attempt: SecureRandom.hex(16))
|
|
29
|
+
@config = { command: command, root: root, env: ChildEnvironment.build(ENV.to_h.merge(env), root: root),
|
|
30
|
+
shutdown_timeout: shutdown_timeout,
|
|
31
|
+
events: events, launcher: launcher, attempt: attempt }
|
|
32
|
+
@io_mutex = Mutex.new
|
|
33
|
+
@stop_mutex = Mutex.new
|
|
34
|
+
@pending_events = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Integer, nil] owned guardian PID
|
|
38
|
+
attr_reader :pid
|
|
39
|
+
|
|
40
|
+
# @return [ManagedProcess] this started process
|
|
41
|
+
def start
|
|
42
|
+
raise ArgumentError, 'process already started' if @pid
|
|
43
|
+
|
|
44
|
+
parent, @liveness = IO.pipe
|
|
45
|
+
@reader, writer = IO.pipe
|
|
46
|
+
config_reader, config_writer = IO.pipe
|
|
47
|
+
spawn_guardian(parent, writer, config_reader)
|
|
48
|
+
@stream = EventStream.new(reader: @reader, guardian: @pid,
|
|
49
|
+
launcher: @config[:launcher], attempt: @config[:attempt])
|
|
50
|
+
[parent, writer, config_reader].each(&:close)
|
|
51
|
+
config_writer.write(JSON.generate(child_config))
|
|
52
|
+
config_writer.close
|
|
53
|
+
await_registration
|
|
54
|
+
self
|
|
55
|
+
rescue StandardError
|
|
56
|
+
[parent, writer, config_reader, config_writer].compact.each { |io| io.close unless io.closed? }
|
|
57
|
+
close
|
|
58
|
+
raise
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] guardian still owns an active attempt
|
|
62
|
+
def alive?
|
|
63
|
+
return false unless @pid
|
|
64
|
+
return false if @status
|
|
65
|
+
|
|
66
|
+
@status = Process.waitpid2(@pid, Process::WNOHANG)&.last
|
|
67
|
+
@status.nil?
|
|
68
|
+
rescue Errno::ECHILD
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Integer, nil] observed guardian exit code
|
|
73
|
+
def exit_status
|
|
74
|
+
alive?
|
|
75
|
+
@status&.exitstatus
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Array<String>] bounded complete private protocol records
|
|
79
|
+
def read_events
|
|
80
|
+
@io_mutex.synchronize do
|
|
81
|
+
pending = @pending_events
|
|
82
|
+
@pending_events = []
|
|
83
|
+
pending + (@stream ? @stream.read : [])
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Boolean] private stream ended
|
|
88
|
+
def eof?
|
|
89
|
+
@stream&.eof? == true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Stop only this owned group, with cleanup delegated to its guardian.
|
|
93
|
+
# @return [void]
|
|
94
|
+
def stop
|
|
95
|
+
@stop_mutex.synchronize { stop_owned }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Drop ownership; guardian observes EOF even if application boot hangs.
|
|
99
|
+
# @return [void]
|
|
100
|
+
def close
|
|
101
|
+
@io_mutex.synchronize do
|
|
102
|
+
[@liveness, @reader].compact.each { |io| io.close unless io.closed? }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def await_registration
|
|
109
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
110
|
+
loop do
|
|
111
|
+
@pending_events.concat(@stream.read)
|
|
112
|
+
if @stream.child_pid
|
|
113
|
+
@liveness.write('S')
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
raise ArgumentError, 'guardian could not register its child' unless alive?
|
|
117
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
118
|
+
raise ArgumentError,
|
|
119
|
+
'guardian registration timed out'
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
sleep 0.01
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def spawn_guardian(parent, writer, config_reader)
|
|
127
|
+
guardian = File.expand_path('guardian.rb', __dir__)
|
|
128
|
+
@pid = Process.spawn({ 'RUBYOPT' => nil, 'RUBYLIB' => nil }, RbConfig.ruby, '--disable-gems', guardian,
|
|
129
|
+
3 => writer, 4 => parent, 5 => config_reader, in: File::NULL,
|
|
130
|
+
pgroup: true, close_others: true)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def child_config
|
|
134
|
+
config = @config.dup
|
|
135
|
+
env = config[:env].dup
|
|
136
|
+
if config[:events]
|
|
137
|
+
env.merge!('WOODS_WATCH_EVENT_FD' => '3', 'WOODS_WATCH_LAUNCHER_TOKEN' => config[:launcher],
|
|
138
|
+
'WOODS_WATCH_ATTEMPT_TOKEN' => config[:attempt])
|
|
139
|
+
end
|
|
140
|
+
config.merge(env: env, owner_pid: Process.pid)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'puma_child'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Watch
|
|
7
|
+
# Optional Puma master-process integration. Requiring Woods itself never
|
|
8
|
+
# loads Puma or starts a watcher; only `plugin :woods` installs these hooks.
|
|
9
|
+
class PumaAdapter
|
|
10
|
+
SUPPORTED_MAJORS = [6, 7, 8].freeze
|
|
11
|
+
FALLBACK = 'Run bin/woods-watch through your development process manager instead.'
|
|
12
|
+
|
|
13
|
+
# @param launcher [Puma::Launcher] configured master launcher
|
|
14
|
+
# @param puma_version [String] loaded Puma version
|
|
15
|
+
# @param platform [String] Ruby platform used for supported-mode checks
|
|
16
|
+
def initialize(launcher, puma_version: ::Puma::Const::PUMA_VERSION, platform: RUBY_PLATFORM)
|
|
17
|
+
@launcher = launcher
|
|
18
|
+
@puma_version = puma_version
|
|
19
|
+
@platform = platform
|
|
20
|
+
@master_pid = Process.pid
|
|
21
|
+
# Puma has already chdir'd before plugin start. Expanding its raw
|
|
22
|
+
# relative `directory` option here would apply that directory twice.
|
|
23
|
+
@root = Dir.pwd
|
|
24
|
+
@started = false
|
|
25
|
+
@installed = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Register master-only lifecycle callbacks without starting extraction.
|
|
29
|
+
# @return [void]
|
|
30
|
+
def install
|
|
31
|
+
return if @installed
|
|
32
|
+
|
|
33
|
+
@installed = true
|
|
34
|
+
unless supported?
|
|
35
|
+
log("Puma #{@puma_version} on #{@platform} is unsupported by the Woods plugin. #{FALLBACK}")
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
hook(:after_booted, :on_booted) { start }
|
|
40
|
+
hook(:before_restart, :on_restart) { stop }
|
|
41
|
+
hook(:after_stopped, :on_stopped) { stop }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Start at most once after Puma has finalized its environment and booted.
|
|
45
|
+
# @return [void]
|
|
46
|
+
def start
|
|
47
|
+
return unless Process.pid == @master_pid
|
|
48
|
+
return if @started || @launcher.options[:environment].to_s != 'development'
|
|
49
|
+
|
|
50
|
+
@started = true
|
|
51
|
+
unless File.file?(File.join(@root, 'bin/woods-watch'))
|
|
52
|
+
log('Missing bin/woods-watch; run bin/rails generate woods:watch --mode=puma. ' \
|
|
53
|
+
'Automatic maintenance is inactive.')
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@child = PumaChild.new(root: @root, environment: 'development', logger: @launcher.log_writer)
|
|
58
|
+
pid = @child.start
|
|
59
|
+
log("Started launcher #{pid}; index readiness is reported separately by woods-watch.")
|
|
60
|
+
rescue SystemCallError => e
|
|
61
|
+
log("Could not start launcher (#{e.class}); automatic maintenance is inactive. #{FALLBACK}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Stop only the launcher owned by this master process.
|
|
65
|
+
# @return [void]
|
|
66
|
+
def stop
|
|
67
|
+
@child&.stop if Process.pid == @master_pid
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def supported?
|
|
73
|
+
SUPPORTED_MAJORS.include?(@puma_version.to_s.split('.').first.to_i) &&
|
|
74
|
+
Process.respond_to?(:fork) && !@platform.match?(/mswin|mingw|cygwin|java/)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def hook(current, legacy, &block)
|
|
78
|
+
events = @launcher.events
|
|
79
|
+
events.public_send(events.respond_to?(current) ? current : legacy, &block)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def log(message)
|
|
83
|
+
@launcher.log_writer.log("[woods-watch] #{message}")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
require_relative 'managed_process'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Watch
|
|
8
|
+
# Owns one foreground launcher. Retry policy belongs to that launcher, never
|
|
9
|
+
# to Puma; a failed launcher must not terminate or restart the web server.
|
|
10
|
+
class PumaChild
|
|
11
|
+
# The launcher gives its extraction child ten seconds to stop. Allow its
|
|
12
|
+
# guardian to finish that cleanup before escalating against the launcher.
|
|
13
|
+
STOP_GRACE = 15
|
|
14
|
+
|
|
15
|
+
# @param root [String] application root containing bin/woods-watch
|
|
16
|
+
# @param environment [String] finalized Puma environment
|
|
17
|
+
# @param logger [#log] Puma's log writer
|
|
18
|
+
# @param stop_grace [Numeric] graceful owned-process shutdown seconds
|
|
19
|
+
def initialize(root:, environment:, logger:, stop_grace: STOP_GRACE)
|
|
20
|
+
@root = root
|
|
21
|
+
@environment = environment
|
|
22
|
+
@logger = logger
|
|
23
|
+
@stop_grace = stop_grace
|
|
24
|
+
@stopping = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Integer] owned guardian PID, not proof of index readiness
|
|
28
|
+
def start
|
|
29
|
+
@process = ManagedProcess.new(command: [RbConfig.ruby, File.join(@root, 'bin/woods-watch')],
|
|
30
|
+
root: @root, env: child_environment, shutdown_timeout: @stop_grace, events: false)
|
|
31
|
+
@process.start
|
|
32
|
+
@observer = Thread.new { observe }
|
|
33
|
+
@observer.name = 'woods-puma-launcher' if @observer.respond_to?(:name=)
|
|
34
|
+
@process.pid
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [void]
|
|
38
|
+
def stop
|
|
39
|
+
@stopping = true
|
|
40
|
+
# Puma can invoke stopped/restart callbacks from a signal trap.
|
|
41
|
+
# Only request and join here; the observer owns locks and IO cleanup.
|
|
42
|
+
(@observer || Thread.new { @process&.stop }).join
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def child_environment
|
|
48
|
+
{ 'APP_ENV' => @environment, 'RACK_ENV' => @environment, 'RAILS_ENV' => @environment }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def observe
|
|
52
|
+
until @stopping
|
|
53
|
+
unless @process.alive?
|
|
54
|
+
@process.stop
|
|
55
|
+
@logger.log('[woods-watch] Puma launcher stopped unexpectedly; automatic maintenance is inactive. ' \
|
|
56
|
+
'Fix the launcher configuration and restart Puma, or run bin/woods-watch separately.')
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
sleep 0.1
|
|
60
|
+
end
|
|
61
|
+
ensure
|
|
62
|
+
@process.stop
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|