Skip to content

Commit af9037f

Browse files
committed
Polish rolling deploy review follow-ups (round 3)
Address four optional review findings from claude[bot] on PR #3173: - Tighten TEMPORARY_DIRECTORY_PATTERN to require >=4-digit PID and >=8-char hex suffix so a real bundle hash like `bundle.staging-1-abc123` cannot accidentally match the temp-dir sweep pattern and be removed after STALE_TEMP_DIR_TTL_SECONDS. - Log a per-hash success message after replace_bundle_directory so successful rolling-deploy seeding is visible in production logs (previously every failure path warned but success was silent). - Document why restore_previous_bundle_directory's File.exist? guard intentionally skips the mv on a TOCTOU race (defer to runtime 410-retry rather than overwrite a competing writer; backup_dir gets swept on a later run). - Filter `.staging-...`/`.previous-...` temp dirs out of the doctor's renderer-cache bundle-hash count via a new rolling_deploy_temp_dir_pattern helper, with a fallback constant for when the Pro gem isn't loaded. Adds regression specs for each change.
1 parent d1aab55 commit af9037f

4 files changed

Lines changed: 92 additions & 4 deletions

File tree

react_on_rails/lib/react_on_rails/doctor.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2908,16 +2908,32 @@ def rolling_deploy_discovery_timeout_seconds
29082908
end
29092909
end
29102910

2911+
# Mirror of ReactOnRailsPro::RollingDeployCacheStager::TEMPORARY_DIRECTORY_PATTERN
2912+
# used as a fallback when the Pro gem isn't loaded so doctor still filters
2913+
# leftover staging/backup dirs out of the bundle-hash count.
2914+
ROLLING_DEPLOY_TEMP_DIR_PATTERN = /\.(?:staging|previous)-\d{4,}-[0-9a-f]{8,}\z/
2915+
29112916
def report_resolved_cache_dir
29122917
cache_dir = ReactOnRailsPro::Utils.resolve_renderer_cache_dir
29132918
if File.directory?(cache_dir)
2914-
subdirs = Dir.children(cache_dir).select { |c| File.directory?(File.join(cache_dir, c)) }
2919+
temp_dir_pattern = rolling_deploy_temp_dir_pattern
2920+
subdirs = Dir.children(cache_dir).select do |c|
2921+
File.directory?(File.join(cache_dir, c)) && !c.match?(temp_dir_pattern)
2922+
end
29152923
checker.add_info("ℹ️ Resolved renderer cache dir: #{cache_dir} (#{subdirs.length} bundle-hash subdir(s))")
29162924
else
29172925
checker.add_info("ℹ️ Resolved renderer cache dir: #{cache_dir} (does not exist yet)")
29182926
end
29192927
end
29202928

2929+
def rolling_deploy_temp_dir_pattern
2930+
if defined?(ReactOnRailsPro::RollingDeployCacheStager::TEMPORARY_DIRECTORY_PATTERN)
2931+
ReactOnRailsPro::RollingDeployCacheStager::TEMPORARY_DIRECTORY_PATTERN
2932+
else
2933+
ROLLING_DEPLOY_TEMP_DIR_PATTERN
2934+
end
2935+
end
2936+
29212937
# The base 'react-on-rails' npm package is a transitive dependency of 'react-on-rails-pro',
29222938
# so `import ... from 'react-on-rails'` resolves silently — loading the base package instead
29232939
# of Pro. Components registered through the base package won't have Pro features (streaming,

react_on_rails/spec/lib/react_on_rails/doctor_spec.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2589,6 +2589,48 @@ def self.upload(_hash, **_opts); end
25892589
end
25902590
end
25912591

2592+
describe "report_resolved_cache_dir" do
2593+
let(:doctor) { described_class.new(verbose: false, fix: false) }
2594+
let(:checker) { doctor.instance_variable_get(:@checker) }
2595+
let(:cache_dir) { Dir.mktmpdir("doctor-cache") }
2596+
2597+
before do
2598+
cache_dir_value = cache_dir
2599+
pro_module = Module.new
2600+
utils_module = Module.new
2601+
utils_module.define_singleton_method(:resolve_renderer_cache_dir) { cache_dir_value }
2602+
pro_module.const_set(:Utils, utils_module)
2603+
stub_const("ReactOnRailsPro", pro_module)
2604+
end
2605+
2606+
after { FileUtils.rm_rf(cache_dir) }
2607+
2608+
it "excludes leftover staging/backup temp dirs from the bundle-hash count" do
2609+
FileUtils.mkdir_p(File.join(cache_dir, "abc123"))
2610+
FileUtils.mkdir_p(File.join(cache_dir, "def456"))
2611+
FileUtils.mkdir_p(File.join(cache_dir, "abc123.staging-1234-deadbeef"))
2612+
FileUtils.mkdir_p(File.join(cache_dir, "abc123.previous-1234-feedface"))
2613+
2614+
doctor.send(:report_resolved_cache_dir)
2615+
2616+
info = checker.messages.find { |m| m[:type] == :info && m[:content].include?(cache_dir) }
2617+
expect(info[:content]).to include("(2 bundle-hash subdir(s))")
2618+
end
2619+
2620+
it "uses the Pro stager constant when the Pro gem is loaded" do
2621+
stager_module = Module.new
2622+
stager_module.const_set(:TEMPORARY_DIRECTORY_PATTERN, /\.tempmarker\z/)
2623+
ReactOnRailsPro.const_set(:RollingDeployCacheStager, stager_module)
2624+
FileUtils.mkdir_p(File.join(cache_dir, "abc123"))
2625+
FileUtils.mkdir_p(File.join(cache_dir, "abc123.tempmarker"))
2626+
2627+
doctor.send(:report_resolved_cache_dir)
2628+
2629+
info = checker.messages.find { |m| m[:type] == :info && m[:content].include?(cache_dir) }
2630+
expect(info[:content]).to include("(1 bundle-hash subdir(s))")
2631+
end
2632+
end
2633+
25922634
describe "check_deprecated_renderer_cache_task" do
25932635
let(:doctor) { described_class.new(verbose: false, fix: false) }
25942636
let(:checker) { doctor.instance_variable_get(:@checker) }

react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ module RollingDeployCacheStager # rubocop:disable Metrics/ModuleLength
3838
DISCOVERY_TIMEOUT_SECONDS = 10
3939
FETCH_TIMEOUT_SECONDS = 30
4040
STALE_TEMP_DIR_TTL_SECONDS = 3600
41-
TEMPORARY_DIRECTORY_PATTERN = /\.(?:staging|previous)-\d+-[0-9a-f]+\z/
41+
# Match temp dirs created by `temporary_bundle_directory` (and the analogous
42+
# `.previous-` backup suffix in `replace_bundle_directory`). The minimum
43+
# widths (`\d{4,}` PID, `[0-9a-f]{8,}` random) defeat false positives where
44+
# a real bundle hash happens to end with `.staging-<digits>-<hex>` — without
45+
# them, a hash like `bundle.staging-1-abc123` could match and be swept.
46+
TEMPORARY_DIRECTORY_PATTERN = /\.(?:staging|previous)-\d{4,}-[0-9a-f]{8,}\z/
4247

4348
def self.call(cache_dir:, current_hashes:, mode:)
4449
adapter = ReactOnRailsPro.configuration.rolling_deploy_adapter
@@ -135,6 +140,7 @@ def self.seed_previous_hash(adapter, hash, cache_dir, mode)
135140

136141
replace_bundle_directory(staging_dir, bundle_dir)
137142
staging_dir = nil
143+
puts "[ReactOnRailsPro] Seeded previous bundle hash #{hash} at #{bundle_dir}."
138144
rescue StandardError => e
139145
# Remove only files created by this attempt. If the hash directory was
140146
# already valid from an earlier seed on a persistent cache volume, keep it
@@ -371,6 +377,12 @@ def self.restore_previous_bundle_directory(backup_dir, bundle_dir)
371377
return unless backup_dir && File.exist?(backup_dir)
372378

373379
FileUtils.rm_rf(bundle_dir)
380+
# The `unless File.exist?` guard catches the narrow TOCTOU window where
381+
# another writer recreates `bundle_dir` between the rm_rf and the mv.
382+
# We deliberately skip the mv in that case rather than overwriting that
383+
# writer's work — the runtime 410-retry path is still a valid fallback,
384+
# and `backup_dir` will be swept by `sweep_stale_temporary_directories`
385+
# on the next run.
374386
FileUtils.mv(backup_dir, bundle_dir) unless File.exist?(bundle_dir)
375387
rescue StandardError => e
376388
warn "[ReactOnRailsPro] Could not restore previous rolling-deploy bundle directory #{backup_dir} " \

react_on_rails_pro/spec/dummy/spec/rolling_deploy_cache_stager_spec.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ def source_file(name, contents: "// #{name}")
6969
expect(File.symlink?(File.join(bundle_dir, "abc123.js"))).to be(false)
7070
end
7171

72+
it "logs a per-hash success message after promotion" do
73+
expect { described_class.call(cache_dir: cache_dir, current_hashes: [], mode: :copy) }
74+
.to output(/Seeded previous bundle hash abc123 at/).to_stdout
75+
end
76+
7277
it "creates relative symlinks in :symlink mode" do
7378
described_class.call(cache_dir: cache_dir, current_hashes: [], mode: :symlink)
7479

@@ -400,8 +405,8 @@ def source_file(name, contents: "// #{name}")
400405
end
401406

402407
context "when stale temporary bundle directories are present" do
403-
let(:stale_staging_dir) { File.join(cache_dir, "abc123.staging-123-deadbeef") }
404-
let(:fresh_previous_dir) { File.join(cache_dir, "abc123.previous-123-feedface") }
408+
let(:stale_staging_dir) { File.join(cache_dir, "abc123.staging-1234-deadbeef") }
409+
let(:fresh_previous_dir) { File.join(cache_dir, "abc123.previous-1234-feedface") }
405410

406411
before do
407412
stub_const("ReactOnRailsPro::RollingDeployCacheStager::STALE_TEMP_DIR_TTL_SECONDS", 60)
@@ -420,6 +425,19 @@ def source_file(name, contents: "// #{name}")
420425
expect(File.exist?(stale_staging_dir)).to be(false)
421426
expect(File.exist?(fresh_previous_dir)).to be(true)
422427
end
428+
429+
it "does not match real bundle hash dirs that look superficially similar" do
430+
# Hash dir whose suffix has a sub-4-digit PID and sub-8-char hex segment.
431+
# The tightened TEMPORARY_DIRECTORY_PATTERN must reject this so a real
432+
# bundle is never silently swept after the TTL.
433+
false_positive_dir = File.join(cache_dir, "bundle.staging-1-abc123")
434+
FileUtils.mkdir_p(false_positive_dir)
435+
File.utime(Time.now - 120, Time.now - 120, false_positive_dir)
436+
437+
described_class.call(cache_dir: cache_dir, current_hashes: [], mode: :copy)
438+
439+
expect(File.exist?(false_positive_dir)).to be(true)
440+
end
423441
end
424442

425443
context "when RSC support is enabled" do

0 commit comments

Comments
 (0)