copy_tuner_client 2.2.0 → 2.3.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.
@@ -8,6 +8,10 @@
8
8
  # bin/rails runner .claude/skills/copy-tuner-to-locales-migrate-prefix/scripts/migrate_prefix.rb \
9
9
  # -- --prefix date --export tmp/copy_tuner_all.yml --out config/locales/0010_date.yml
10
10
  #
11
+ # このスクリプト自身の自己検証(gem メンテナ向け。Rails も bundler も不要):
12
+ #
13
+ # ruby skills/copy-tuner-to-locales-migrate-prefix/scripts/migrate_prefix.rb --self-test
14
+ #
11
15
  # What it does, in one pass:
12
16
  # (1) Place : extract the target prefix subtree from the 0000_original_*.yml
13
17
  # originals (load-order deep_merge), deep_merge the export subtree on top
@@ -29,6 +33,127 @@
29
33
  require 'optparse'
30
34
  require 'yaml'
31
35
 
36
+ # ---- self-test ----
37
+ # 引数パースより前に分岐する。このスクリプトはトップレベルで即実行する造りなので、`--self-test` の
38
+ # ときは本体へ進ませずテストだけ走らせてトップレベル return で抜ける。
39
+ if ARGV.delete('--self-test')
40
+ require 'minitest/autorun'
41
+ require 'tmpdir'
42
+ require 'fileutils'
43
+ require 'rbconfig'
44
+
45
+ # 本体はトップレベルで exit するため load では検証できない。fixture を tmpdir に作り、i18n だけを
46
+ # 直接設定する薄いランナー経由でサブプロセス実行する(スクリプトが触るのは I18n.load_path と
47
+ # Backend::Simple だけなので Rails は不要)。
48
+ class MigratePrefixSelfTest < Minitest::Test
49
+ SCRIPT = File.expand_path(__FILE__)
50
+ # 対象 prefix(date)を持つファイル。これだけが書き戻し対象になるのが期待挙動。
51
+ WITH_PREFIX = "ja:\n date:\n formats:\n default: \"%Y\"\n"
52
+ WITHOUT_PREFIX = "ja:\n greeting: \"hello\"\n"
53
+ # 片方だけが対象 prefix を持つ 2 ファイル構成。「1 ファイルだけ書き戻る」ことの検証に使う。
54
+ MIXED_FILES = { '0000_original_a.yml' => WITH_PREFIX, '0000_original_b.yml' => WITHOUT_PREFIX }.freeze
55
+ EXPORT_JA = "ja:\n date:\n formats:\n default: \"%Y/%m/%d\"\n"
56
+ EXPORT_JA_EN = "#{EXPORT_JA}en:\n date:\n formats:\n default: \"%m/%d/%Y\"\n".freeze
57
+ # to_yaml の既定 line_width(80)を確実に超える長さにして折り返しを誘発する。
58
+ LONG_VALUE = (['word'] * 60).join(' ').freeze
59
+
60
+ def test_untouched_file_keeps_bytes
61
+ # コメント・空行・ダブルクォートが 1 バイトも変わらないことを見る。
62
+ untouched = <<~YAML
63
+ ja:
64
+ # これは greeting のコメント
65
+ greeting: "hello"
66
+
67
+ farewell: "bye"
68
+ YAML
69
+ migrate({ '0000_original_a.yml' => WITH_PREFIX, '0000_original_b.yml' => untouched }) do |out, dir|
70
+ # binread は ASCII-8BIT を返すため、UTF-8 の期待値を b で揃えてバイト列として比較する。
71
+ assert_equal(untouched.b, read(dir, '0000_original_b.yml'), "無関係なファイルが書き換わった:\n#{out}")
72
+ end
73
+ end
74
+
75
+ def test_missing_locale_root_is_not_added
76
+ migrate(MIXED_FILES, locales: %w[ja en], export: EXPORT_JA_EN) do |_out, dir|
77
+ MIXED_FILES.each_key do |name|
78
+ refute_includes(read(dir, name), 'en:', "#{name} に元ファイルに無い en ルートが新設された")
79
+ end
80
+ end
81
+ end
82
+
83
+ def test_prefix_is_pruned_from_matching_file
84
+ migrate({ '0000_original_a.yml' => "#{WITH_PREFIX} greeting: \"hello\"\n" }) do |_out, dir|
85
+ assert_equal({ 'ja' => { 'greeting' => 'hello' } }, load_yaml(dir, '0000_original_a.yml'))
86
+ assert_equal(
87
+ { 'ja' => { 'date' => { 'formats' => { 'default' => '%Y/%m/%d' } } } },
88
+ load_yaml(dir, '0010_migrated.yml')
89
+ )
90
+ end
91
+ end
92
+
93
+ def test_long_value_is_not_wrapped
94
+ migrate({ '0000_original_a.yml' => "#{WITH_PREFIX} greeting: \"#{LONG_VALUE}\"\n" }) do |_out, dir|
95
+ body = read(dir, '0000_original_a.yml')
96
+ assert_includes(body, "greeting: #{LONG_VALUE}\n", "長い値が折り返された:\n#{body}")
97
+ end
98
+ end
99
+
100
+ def test_summary_counts_changed_files
101
+ migrate(MIXED_FILES) do |out, _dir|
102
+ assert_includes(out, 'オリジナル 2 ファイル中 1 ファイル')
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ # fixture を tmpdir に配置してサブプロセス実行し、標準出力と locales ディレクトリを yield する。
109
+ def migrate(files, locales: %w[ja], export: EXPORT_JA)
110
+ Dir.mktmpdir do |dir|
111
+ locales_dir = File.join(dir, 'config/locales')
112
+ FileUtils.mkdir_p(locales_dir)
113
+ FileUtils.mkdir_p(File.join(dir, 'tmp'))
114
+ files.each { |name, body| File.write(File.join(locales_dir, name), body) }
115
+ File.write(File.join(dir, 'tmp/export.yml'), export)
116
+ write_runner(dir, locales)
117
+
118
+ ok, out = spawn_script(dir, locales)
119
+ assert(ok, "スクリプトが異常終了した:\n#{out}")
120
+ yield(out, locales_dir)
121
+ end
122
+ end
123
+
124
+ def write_runner(dir, locales)
125
+ File.write(File.join(dir, 'runner.rb'), <<~RUBY)
126
+ require 'i18n'
127
+ I18n.load_path = Dir['config/locales/*.yml']
128
+ I18n.available_locales = #{locales.map(&:to_sym).inspect}
129
+ I18n.default_locale = #{locales.first.to_sym.inspect}
130
+ load #{SCRIPT.inspect}
131
+ RUBY
132
+ end
133
+
134
+ def spawn_script(dir, locales)
135
+ args = [
136
+ '--prefix', 'date', '--locales', locales.join(','),
137
+ '--export', 'tmp/export.yml', '--out', 'config/locales/0010_migrated.yml'
138
+ ]
139
+ Dir.chdir(dir) do
140
+ read_io, write_io = IO.pipe
141
+ pid = spawn(RbConfig.ruby, File.join(dir, 'runner.rb'), '--', *args, out: write_io, err: write_io)
142
+ write_io.close
143
+ out = read_io.read
144
+ _, status = Process.waitpid2(pid)
145
+ [status.success?, out]
146
+ end
147
+ end
148
+
149
+ def read(dir, name) = File.binread(File.join(dir, name))
150
+
151
+ def load_yaml(dir, name) = YAML.safe_load_file(File.join(dir, name))
152
+ end
153
+
154
+ return
155
+ end
156
+
32
157
  # NOTE: `bin/rails runner` は Kernel#abort が投げる SystemExit を握りつぶし終了コードが 0 になる
33
158
  # (実機確認済み)。中断を呼び出し側へ確実に伝えるため、abort ではなく warn + exit(1) を使う。
34
159
  def die(message)
@@ -160,7 +285,12 @@ end
160
285
  # locale ルートを持つ raw Hash(`{ "ja" => {...}, "en" => {...} }`)から、対象 prefix を全 locale で刈った
161
286
  # 新しい Hash を返す(非破壊)。移行漏れ検証のシミュレーションと実削除の両方で使う。
162
287
  def prune_prefix_all_locales(raw, locales, keys)
163
- locales.reduce(raw) { |acc, locale| acc.merge(locale => prune_prefix(acc[locale] || {}, keys)) }
288
+ locales.reduce(raw) do |acc, locale|
289
+ # 元ファイルに無い locale ルートを新設すると、ja だけのファイルに `en: {}` が追記されてしまう。
290
+ next acc unless acc.key?(locale)
291
+
292
+ acc.merge(locale => prune_prefix(acc[locale], keys))
293
+ end
164
294
  end
165
295
 
166
296
  # ---- (1) 配置 ----
@@ -277,10 +407,18 @@ unless leaks.empty?
277
407
  die('中断。--out の内容・採番・regexp を確認すること。')
278
408
  end
279
409
 
280
- original_files.each do |f|
281
- raw = YAML.safe_load_file(f, permitted_classes: [Symbol], aliases: true) || {}
282
- File.write(f, prune_prefix_all_locales(raw, LOCALES, prefix_keys).to_yaml)
283
- end
410
+ changed =
411
+ original_files.count do |f|
412
+ raw = YAML.safe_load_file(f, permitted_classes: [Symbol], aliases: true) || {}
413
+ pruned = prune_prefix_all_locales(raw, LOCALES, prefix_keys)
414
+ # 対象 prefix を含まないファイルを to_yaml で書き戻すと、クォート・アンカー名・折り返し・コメントの
415
+ # 無関係な整形差分が出る。実質的な変更があるファイルだけ書き戻す。
416
+ next false if pruned == raw
417
+
418
+ # 折り返しだけは line_width で無効化できる(アンカー名・クォートは Psych の仕様で制御できない)。
419
+ File.write(f, pruned.to_yaml(line_width: -1))
420
+ true
421
+ end
284
422
 
285
- puts "削除: prefix '#{PREFIX}' をオリジナル #{original_files.size} ファイルから刈り取った。"
423
+ puts "削除: prefix '#{PREFIX}' をオリジナル #{original_files.size} ファイル中 #{changed} ファイルから刈り取った。"
286
424
  puts '完了。手順7(local_first_key_regexp 追加)が未済なら次に実施すること。'
@@ -398,6 +398,179 @@ describe CopyTunerClient::Configuration do
398
398
  end
399
399
  end
400
400
 
401
+ describe 'middleware_position の初期値' do
402
+ let(:config) { described_class.new }
403
+
404
+ context 'Warden::Manager と Devise がどちらも定義済みのとき' do
405
+ before do
406
+ stub_const('Warden::Manager', Class.new)
407
+ stub_const('Devise', Module.new)
408
+ end
409
+
410
+ it '{ before: Warden::Manager } になる' do
411
+ expect(config.middleware_position).to eq({ before: Warden::Manager })
412
+ end
413
+ end
414
+
415
+ context 'Warden::Manager が未定義のとき' do
416
+ before { hide_const('Warden::Manager') }
417
+
418
+ it 'nil のままになる' do
419
+ expect(config.middleware_position).to be_nil
420
+ end
421
+ end
422
+
423
+ # NOTE: authtrail のように warden を require するだけで Warden::Manager をスタックへ積まない
424
+ # gem があるため、Warden 単独では既定位置にしない(insert_before が起動時例外になる)。
425
+ context 'Warden::Manager は定義済みだが Devise が未定義のとき' do
426
+ before do
427
+ stub_const('Warden::Manager', Class.new)
428
+ hide_const('Devise')
429
+ end
430
+
431
+ it 'nil のままになる' do
432
+ expect(config.middleware_position).to be_nil
433
+ end
434
+ end
435
+
436
+ context 'configure ブロック内で明示指定したとき' do
437
+ before do
438
+ stub_const('Warden::Manager', Class.new)
439
+ stub_const('Devise', Module.new)
440
+ end
441
+
442
+ it '明示指定した値が優先される' do
443
+ other = Class.new
444
+ CopyTunerClient.configure(apply: false) do |c|
445
+ c.middleware_position = { before: other }
446
+ end
447
+ expect(CopyTunerClient.configuration.middleware_position).to eq({ before: other })
448
+ end
449
+ end
450
+ end
451
+
452
+ context 'Warden::Manager がスタックにある状態で apply したとき' do
453
+ it_behaves_like 'applied configuration' do
454
+ it 'デフォルト値経由で Warden::Manager の直前に RequestSync → CopyrayMiddleware の順で入る' do
455
+ expect(middleware.classes).to eq(
456
+ [
457
+ Rack::ETag, Rack::TempfileReaper, CopyTunerClient::RequestSync, CopyTunerClient::CopyrayMiddleware,
458
+ Warden::Manager, Rack::Static
459
+ ]
460
+ )
461
+ end
462
+
463
+ it 'Warden::Manager より外側に配置される' do
464
+ expect(middleware.index(CopyTunerClient::RequestSync)).to be < middleware.index(Warden::Manager)
465
+ expect(middleware.index(CopyTunerClient::CopyrayMiddleware)).to be < middleware.index(Warden::Manager)
466
+ end
467
+
468
+ it 'RequestSync に poller / cache / interval / ignore_regex が渡る' do
469
+ args = middleware.args_for(CopyTunerClient::RequestSync)
470
+ expect(args.first).to include(
471
+ poller:, # rubocop:disable Sgcop/Rspec/NoMethodCallInExpectation
472
+ cache:,
473
+ interval: configuration.sync_interval,
474
+ ignore_regex: configuration.sync_ignore_path_regex
475
+ )
476
+ end
477
+ end
478
+
479
+ # NOTE: 実アプリの bin/rails middleware 出力を模した標準スタック。Warden::Manager / Devise は
480
+ # gem の依存にないため stub_const で fake の定数として定義する。
481
+ before do
482
+ stub_const('Warden::Manager', Class.new)
483
+ stub_const('Devise', Module.new)
484
+ end
485
+
486
+ let(:middleware) do
487
+ MiddlewareStack.new([Rack::ETag, Rack::TempfileReaper, Warden::Manager, Rack::Static])
488
+ end
489
+
490
+ def apply
491
+ configuration.middleware = middleware
492
+ configuration.environment_name = 'development'
493
+ configuration.apply
494
+ end
495
+ end
496
+
497
+ context 'Warden::Manager が未定義の状態で apply したとき' do
498
+ it_behaves_like 'applied configuration' do
499
+ it 'Warden::Manager 未定義時は従来どおりスタック末尾へ use される' do
500
+ expect(middleware.classes).to eq([CopyTunerClient::RequestSync, CopyTunerClient::CopyrayMiddleware])
501
+ end
502
+ end
503
+
504
+ before { hide_const('Warden::Manager') }
505
+
506
+ let(:middleware) { MiddlewareStack.new }
507
+
508
+ def apply
509
+ configuration.middleware = middleware
510
+ configuration.environment_name = 'development'
511
+ configuration.apply
512
+ end
513
+ end
514
+
515
+ context 'middleware_position に after を明示指定して apply したとき' do
516
+ it_behaves_like 'applied configuration' do
517
+ it '{after: X} 指定時は X の直後に RequestSync → CopyrayMiddleware の順で入る' do
518
+ expect(middleware.classes).to eq(
519
+ [:a, :x, CopyTunerClient::RequestSync, CopyTunerClient::CopyrayMiddleware, :b]
520
+ )
521
+ end
522
+ end
523
+
524
+ let(:middleware) { MiddlewareStack.new(%i[a x b]) }
525
+
526
+ def apply
527
+ configuration.middleware = middleware
528
+ configuration.middleware_position = { after: :x }
529
+ configuration.environment_name = 'development'
530
+ configuration.apply
531
+ end
532
+ end
533
+
534
+ context 'middleware_position に before を明示指定して apply したとき' do
535
+ it_behaves_like 'applied configuration' do
536
+ it '{before: X} 指定時は X の直前に RequestSync → CopyrayMiddleware の順で入る' do
537
+ expect(middleware.classes).to eq(
538
+ [:a, CopyTunerClient::RequestSync, CopyTunerClient::CopyrayMiddleware, :x, :b]
539
+ )
540
+ end
541
+ end
542
+
543
+ let(:middleware) { MiddlewareStack.new(%i[a x b]) }
544
+
545
+ def apply
546
+ configuration.middleware = middleware
547
+ configuration.middleware_position = { before: :x }
548
+ configuration.environment_name = 'development'
549
+ configuration.apply
550
+ end
551
+ end
552
+
553
+ # NOTE: { before: SomeClass if cond } のように条件次第で値が nil になる書き方を想定する。
554
+ # キーの有無だけで分岐すると insert_before(nil) が対象を見つけられず例外になる。
555
+ context 'middleware_position の値が nil のとき' do
556
+ it_behaves_like 'applied configuration' do
557
+ it '例外を投げずスタック末尾へ use する' do
558
+ expect(middleware.classes).to eq(
559
+ [:a, :x, :b, CopyTunerClient::RequestSync, CopyTunerClient::CopyrayMiddleware]
560
+ )
561
+ end
562
+ end
563
+
564
+ let(:middleware) { MiddlewareStack.new(%i[a x b]) }
565
+
566
+ def apply
567
+ configuration.middleware = middleware
568
+ configuration.middleware_position = { before: nil }
569
+ configuration.environment_name = 'development'
570
+ configuration.apply
571
+ end
572
+ end
573
+
401
574
  context 'applied without locale filter' do
402
575
  include_context 'stubbed configuration'
403
576
 
@@ -0,0 +1,171 @@
1
+ require 'spec_helper'
2
+
3
+ describe CopyTunerClient::ForkHook do
4
+ let(:client) { FakeClient.new }
5
+ let(:cache) { CopyTunerClient::Cache.new(client, logger: FakeLogger.new) }
6
+ let(:poller) do
7
+ config = CopyTunerClient::Configuration.new.to_hash
8
+ CopyTunerClient::Poller.new(cache, config.update(logger: FakeLogger.new, polling_delay:))
9
+ end
10
+
11
+ def polling_delay
12
+ 0.5
13
+ end
14
+
15
+ before do
16
+ described_class.install
17
+ # フックは CopyTunerClient.configuration&.poller を見るので、実際の設定に載せる
18
+ CopyTunerClient.configuration.poller = poller
19
+ end
20
+
21
+ after do
22
+ poller.stop
23
+ end
24
+
25
+ describe '停止と再開の順序' do
26
+ # 実際に fork してしまうと「fork の瞬間にスレッドが止まっているか」を観測できないため、
27
+ # _fork の代わりに記録だけするオブジェクトに prepend して順序を見る
28
+ def build_forkable(events, &fork_body)
29
+ forkable = Object.new
30
+ forkable.define_singleton_method(:_fork) do
31
+ events << :fork
32
+ fork_body ? fork_body.call : 0
33
+ end
34
+ forkable.singleton_class.prepend(described_class)
35
+ forkable
36
+ end
37
+
38
+ def stub_poller(events, stopped:)
39
+ allow(poller).to receive(:stop) do
40
+ events << :stop
41
+ stopped
42
+ end
43
+ allow(poller).to receive(:start) { events << :start }
44
+ end
45
+
46
+ it 'fork の前に poller を停止し、fork の後に再開する' do
47
+ events = []
48
+ stub_poller(events, stopped: true)
49
+
50
+ build_forkable(events)._fork
51
+
52
+ expect(events).to eq(%i[stop fork start])
53
+ end
54
+
55
+ it 'fork 前に poller が動いていなければ再開しない' do
56
+ events = []
57
+ stub_poller(events, stopped: false)
58
+
59
+ build_forkable(events)._fork
60
+
61
+ expect(events).to eq(%i[stop fork])
62
+ end
63
+
64
+ it 'fork が失敗しても poller を再開する' do
65
+ events = []
66
+ stub_poller(events, stopped: true)
67
+
68
+ forkable = build_forkable(events) { raise Errno::EAGAIN }
69
+
70
+ expect { forkable._fork }.to raise_error(Errno::EAGAIN)
71
+ expect(events).to eq(%i[stop fork start])
72
+ end
73
+ end
74
+
75
+ describe 'poller を取得できないとき' do
76
+ it 'configuration が nil でも fork を壊さない' do
77
+ # Process._fork への prepend は外せないので、ここで例外を漏らすとアプリの
78
+ # すべての fork が失敗する
79
+ CopyTunerClient.configuration = nil
80
+
81
+ expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
82
+ end
83
+
84
+ it 'ログ出力自体が失敗しても fork は成立する' do
85
+ allow(poller).to receive(:stop).and_return(true)
86
+ allow(poller).to receive(:start).and_raise(ThreadError, 'cannot create thread')
87
+ allow(CopyTunerClient.configuration.logger).to receive(:error).and_raise('logger is broken')
88
+
89
+ expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
90
+ end
91
+
92
+ it 'fork 後の poller 起動に失敗しても fork 自体は成立する' do
93
+ allow(poller).to receive(:stop).and_return(true)
94
+ allow(poller).to receive(:start).and_raise(ThreadError, 'cannot create thread')
95
+
96
+ expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
97
+ end
98
+ end
99
+
100
+ describe '実際に fork したとき' do
101
+ it 'fork 前に poller が例外で死んでいても親子とも張り直す' do
102
+ # rails server の cluster では ForkHook が唯一の再開経路なので、ここで張り直さないと
103
+ # 親子とも poller を失う
104
+ fail_once = true
105
+ allow(cache).to receive(:sync).and_wrap_original do |original, *args|
106
+ if fail_once
107
+ fail_once = false
108
+ raise 'boom'
109
+ end
110
+ original.call(*args)
111
+ end
112
+
113
+ poller.start
114
+ sleep(polling_delay * 0.4) # スレッドが例外で終わる
115
+
116
+ reader, writer = IO.pipe
117
+ pid =
118
+ fork do
119
+ reader.close
120
+ client['child.key'] = 'value'
121
+ sleep(polling_delay * 3)
122
+ writer.write(cache['child.key'].to_s)
123
+ writer.close
124
+ exit!(0)
125
+ end
126
+
127
+ writer.close
128
+ child_result = reader.read
129
+ Process.waitpid(pid)
130
+
131
+ client['parent.key'] = 'value'
132
+ sleep(polling_delay * 3)
133
+
134
+ expect(child_result).to eq('value')
135
+ expect(cache['parent.key']).to eq('value')
136
+ end
137
+
138
+ it '子プロセスでも poller がポーリングを続ける' do
139
+ poller.start
140
+ reader, writer = IO.pipe
141
+
142
+ pid =
143
+ fork do
144
+ reader.close
145
+ client['test.key'] = 'value'
146
+ sleep(polling_delay * 3)
147
+ writer.write(cache['test.key'].to_s)
148
+ writer.close
149
+ exit!(0)
150
+ end
151
+
152
+ writer.close
153
+ result = reader.read
154
+ Process.waitpid(pid)
155
+
156
+ expect(result).to eq('value')
157
+ end
158
+
159
+ it '親プロセスでも poller がポーリングを続ける' do
160
+ poller.start
161
+ sleep(polling_delay * 0.2)
162
+
163
+ Process.waitpid(fork { exit!(0) })
164
+
165
+ client['test.key'] = 'value'
166
+ sleep(polling_delay * 3)
167
+
168
+ expect(cache['test.key']).to eq('value')
169
+ end
170
+ end
171
+ end
@@ -11,7 +11,7 @@ describe CopyTunerClient::Poller do
11
11
 
12
12
  def build_poller(config = {})
13
13
  config[:logger] ||= FakeLogger.new
14
- config[:polling_delay] = polling_delay
14
+ config[:polling_delay] ||= polling_delay
15
15
  default_config = CopyTunerClient::Configuration.new.to_hash
16
16
  poller = CopyTunerClient::Poller.new(cache, default_config.update(config))
17
17
  pollers << poller
@@ -95,4 +95,151 @@ describe CopyTunerClient::Poller do
95
95
 
96
96
  expect(logger).to have_received(:flush).at_least(:once)
97
97
  end
98
+
99
+ describe '#stop' do
100
+ it 'スレッドを停止したときは true を返す' do
101
+ poller = build_poller
102
+ poller.start
103
+
104
+ expect(poller.stop).to be true
105
+ end
106
+
107
+ it 'スレッドが動いていないときは false を返す' do
108
+ poller = build_poller
109
+
110
+ expect(poller.stop).to be false
111
+ end
112
+
113
+ it 'スレッドが例外で終わっていても例外を再送出しない' do
114
+ # stop は fork の直前にも呼ばれる。ここで join が poller の例外を再送出すると
115
+ # アプリ側の fork まで巻き添えになる
116
+ logger = FakeLogger.new
117
+ allow(cache).to receive(:sync).and_raise('boom')
118
+ poller = build_poller(logger:)
119
+ poller.start
120
+ sleep(polling_delay * 0.2)
121
+
122
+ expect { poller.stop }.not_to raise_error
123
+ expect(logger).to have_entry(:error, 'boom')
124
+ end
125
+
126
+ it 'start していないときに stop してもキューに :stop を残さない' do
127
+ poller = build_poller
128
+ poller.stop
129
+
130
+ poller.start
131
+
132
+ # 1 周目の sync だけでは「:stop がキューに残っている」状態と区別できないため、
133
+ # 2 周目以降も同期が続くことを確かめる
134
+ wait_for_next_sync
135
+ client['test.key'] = 'value'
136
+ wait_for_next_sync
137
+
138
+ expect(cache['test.key']).to eq('value')
139
+ end
140
+ end
141
+
142
+ describe '世代をまたいだコマンドの混入' do
143
+ # :stop は pop されるまでキューに残る。前の世代のスレッド宛に積まれた :stop を
144
+ # 次の世代のスレッドが拾うと、起動直後に自分を止めてしまう
145
+ it 'スレッドが例外で死んだ後に stop → start してもポーリングが続く' do
146
+ poller = build_poller
147
+ allow(cache).to receive(:sync).and_raise('boom')
148
+ poller.start
149
+ sleep(polling_delay * 0.3) # スレッドが例外で終わるのを待つ
150
+
151
+ allow(cache).to receive(:sync).and_call_original
152
+ poller.stop
153
+ poller.start
154
+
155
+ wait_for_next_sync
156
+ client['test.key'] = 'value'
157
+ wait_for_next_sync
158
+
159
+ expect(cache['test.key']).to eq('value')
160
+ end
161
+
162
+ it 'stop の直後に sync が例外で終わっても、次の start でポーリングが続く' do
163
+ poller = build_poller
164
+ # stop が :stop を積んだ後にスレッドが例外で終わる順序を作る。
165
+ # sync に入ったところで stop を待たせ、:stop を積み終えてから例外にする
166
+ syncing = Queue.new
167
+ resume = Queue.new
168
+ allow(cache).to receive(:sync) do
169
+ syncing << true
170
+ resume.pop
171
+ raise 'boom'
172
+ end
173
+
174
+ poller.start
175
+ syncing.pop # スレッドが sync に入った
176
+
177
+ stopper = Thread.new { poller.stop }
178
+ sleep(0.1) # :stop がキューに積まれるのを待つ
179
+ resume << true # ここで sync が例外になり、:stop は消費されない
180
+ stopper.join
181
+
182
+ allow(cache).to receive(:sync).and_call_original
183
+ poller.start
184
+
185
+ wait_for_next_sync
186
+ client['test.key'] = 'value'
187
+ wait_for_next_sync
188
+
189
+ expect(cache['test.key']).to eq('value')
190
+ end
191
+
192
+ # stop の戻り値は ForkHook の「fork 後に張り直すか」の判断に使われる。スレッドの生死ではなく
193
+ # 「ポーリングを継続する意図があるか」を返す必要がある
194
+ it 'スレッドが例外で死んでいても、起動中だったなら stop は true を返す' do
195
+ poller = build_poller
196
+ allow(cache).to receive(:sync).and_raise('boom')
197
+ poller.start
198
+ sleep(polling_delay * 0.3)
199
+
200
+ expect(poller.stop).to be true
201
+ end
202
+
203
+ it '回復の見込みがない理由で終了した後の stop は false を返す' do
204
+ poller = build_poller
205
+ allow(cache).to receive(:sync).and_raise(CopyTunerClient::InvalidApiKey, 'Invalid API key')
206
+ poller.start
207
+ sleep(polling_delay * 0.3)
208
+
209
+ # API キーが不正なら fork のたびに張り直しても同じ理由で死ぬだけなので再開させない
210
+ expect(poller.stop).to be false
211
+ end
212
+ end
213
+
214
+ describe '再開時の sync 間隔' do
215
+ # 経過時間で判定するため、CI の負荷や GC で sleep が伸びても落ちないよう
216
+ # 他のテストより長い間隔を使ってマージンを稼ぐ
217
+ let(:slow_delay) { 2.0 }
218
+
219
+ it '初回の start では待たずに sync する' do
220
+ poller = build_poller(polling_delay: slow_delay)
221
+
222
+ poller.start
223
+ sleep(slow_delay * 0.2)
224
+
225
+ expect(client.downloads).to eq(1)
226
+ end
227
+
228
+ it '停止直後に再開したときは前回 sync からの残り時間を待ってから sync する' do
229
+ poller = build_poller(polling_delay: slow_delay)
230
+ poller.start
231
+ sleep(slow_delay * 0.2)
232
+ poller.stop
233
+
234
+ poller.start
235
+
236
+ # 前回 sync から polling_delay 経つまでは sync しない
237
+ sleep(slow_delay * 0.4)
238
+ expect(client.downloads).to eq(1)
239
+
240
+ # 残り時間が過ぎれば sync が再開する
241
+ sleep(slow_delay * 0.8)
242
+ expect(client.downloads).to eq(2)
243
+ end
244
+ end
98
245
  end