From 83d18ebd87e3b044ef080d2928f5f47a0584a63b Mon Sep 17 00:00:00 2001 From: Justin Ramos Date: Fri, 24 Apr 2026 20:26:12 -0700 Subject: [PATCH] chore: extract spec/support to cgminer_test_support gem Removes the duplicated FakeCgminer + CgminerFixtures files in favor of the shared gem (pinned to v0.1.0 via git tag in the development group). spec_helper now requires the gem directly; integration specs reference the moved constants. script/fake_cgminer becomes a thin shim that delegates to the gem's exe/ runner so the operator-facing sandbox path is preserved. --- CHANGELOG.md | 6 + Gemfile | 4 + script/fake_cgminer | 45 ++----- spec/integration/cli_spec.rb | 18 +-- spec/integration/miner_integration_spec.rb | 32 ++--- spec/spec_helper.rb | 6 +- spec/support/cgminer_fixtures.rb | 101 --------------- spec/support/fake_cgminer.rb | 136 --------------------- 8 files changed, 43 insertions(+), 305 deletions(-) delete mode 100644 spec/support/cgminer_fixtures.rb delete mode 100644 spec/support/fake_cgminer.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index a611c4c..1506d35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`MinerPool#load_miners!`** now raises `CgminerApiClient::Error` (was `RuntimeError`) when `config/miners.yml` is missing. Existing `rescue StandardError` clauses still work. +- Test-support code (FakeCgminer, CgminerFixtures) extracted to the + shared `cgminer_test_support` gem. Spec references now use + `CgminerTestSupport::FakeCgminer` and + `CgminerTestSupport::Fixtures::SUMMARY` etc. `script/fake_cgminer` + is now a thin shim that delegates to `bundle exec fake_cgminer`; + operator muscle memory unchanged. ### Fixed - **`MinerPool` no longer silently defaults a miners.yml entry diff --git a/Gemfile b/Gemfile index 92993a4..fb816a1 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,10 @@ gemspec group :development do gem 'bundler-audit', '>= 0.9' + gem 'cgminer_test_support', + git: 'https://github.com/jramos/cgminer_test_support.git', + tag: 'v0.1.0', + require: false gem 'rake', '>= 13.2' gem 'rspec', '>= 3.13' gem 'rubocop', '>= 1.60' diff --git a/script/fake_cgminer b/script/fake_cgminer index 296a43a..91dc35a 100755 --- a/script/fake_cgminer +++ b/script/fake_cgminer @@ -1,41 +1,10 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Spin up a fake cgminer for manual testing. Reuses the same -# FakeCgminer class the integration specs use, so behavior is -# identical to what tests verify. -# -# Usage: -# ./script/fake_cgminer # listen on 127.0.0.1:4028 -# ./script/fake_cgminer 5000 # listen on 127.0.0.1:5000 -# -# Then in another terminal, point config/miners.yml at that -# host/port and run the CLI: -# -# cp config/miners.yml.example config/miners.yml -# bundle exec bin/cgminer_api_client summary -# -# This file lives in script/ rather than bin/ deliberately: the -# gemspec packages bin/* but not script/*, so this stays a -# developer tool and is not shipped to gem consumers. - -$stdout.sync = true -$LOAD_PATH.unshift File.expand_path('../spec/support', __dir__) -require 'json' -require 'socket' -require 'cgminer_fixtures' -require 'fake_cgminer' - -port = (ARGV[0] || 4028).to_i -server = FakeCgminer.new(port: port).start - -puts "fake cgminer listening on 127.0.0.1:#{server.port}" -puts "commands available: #{CgminerFixtures::DEFAULT.keys.sort.join(', ')}" -puts 'press Ctrl-C to stop' - -trap('INT') do - server.stop - exit 0 -end - -sleep +# Thin shim. The real runner now lives in cgminer_test_support's +# exe/fake_cgminer. Kept at this path so historical operator muscle +# memory ('./script/fake_cgminer 4028') keeps working from any CWD — +# we Dir.chdir into the api_client repo root first so Bundler can +# resolve the dev-group git+tag dependency. +Dir.chdir(File.expand_path('..', __dir__)) +exec 'bundle', 'exec', 'fake_cgminer', *ARGV diff --git a/spec/integration/cli_spec.rb b/spec/integration/cli_spec.rb index 6b28c0d..51dd0cc 100644 --- a/spec/integration/cli_spec.rb +++ b/spec/integration/cli_spec.rb @@ -5,7 +5,7 @@ require 'tmpdir' # End-to-end tests for bin/cgminer_api_client. Spawns the real -# binary via Open3 against a live FakeCgminer on an ephemeral +# binary via Open3 against a live CgminerTestSupport::FakeCgminer on an ephemeral # port, with a temporary config/miners.yml written to a tmpdir so # the CLI's MinerPool.new finds it. Asserts on exit code, # stdout/stderr split, and DEBUG=1 backtrace behavior. @@ -30,7 +30,7 @@ def run_cli(args, miners:, env: {}) describe 'happy path: single miner, successful query' do it 'prints the summary on stdout with a host:port header and exits 0' do - FakeCgminer.with do |port| + CgminerTestSupport::FakeCgminer.with do |port| stdout, stderr, status = run_cli( %w[summary], miners: [{ host: '127.0.0.1', port: port }] @@ -87,7 +87,7 @@ def run_cli(args, miners:, env: {}) closed_port = dummy.addr[1] dummy.close - FakeCgminer.with do |good_port| + CgminerTestSupport::FakeCgminer.with do |good_port| stdout, stderr, status = run_cli( %w[summary], miners: [ @@ -133,7 +133,7 @@ def run_cli(args, miners:, env: {}) describe '-v / --verbose flag' do it 'logs >>> / <<< lines with host:port prefix when -v precedes the command' do - FakeCgminer.with do |port| + CgminerTestSupport::FakeCgminer.with do |port| stdout, stderr, status = run_cli( ['-v', 'summary'], miners: [{ host: '127.0.0.1', port: port }] @@ -149,7 +149,7 @@ def run_cli(args, miners:, env: {}) end it 'accepts --verbose as the long form' do - FakeCgminer.with do |port| + CgminerTestSupport::FakeCgminer.with do |port| _stdout, stderr, status = run_cli( ['--verbose', 'summary'], miners: [{ host: '127.0.0.1', port: port }] @@ -162,7 +162,7 @@ def run_cli(args, miners:, env: {}) end it 'permutes: the flag still works when it follows the command' do - FakeCgminer.with do |port| + CgminerTestSupport::FakeCgminer.with do |port| _stdout, stderr, status = run_cli( ['summary', '-v'], miners: [{ host: '127.0.0.1', port: port }] @@ -174,7 +174,7 @@ def run_cli(args, miners:, env: {}) end it 'is silent on stderr without the flag' do - FakeCgminer.with do |port| + CgminerTestSupport::FakeCgminer.with do |port| _stdout, stderr, status = run_cli( %w[summary], miners: [{ host: '127.0.0.1', port: port }] @@ -187,8 +187,8 @@ def run_cli(args, miners:, env: {}) end it 'prefixes each line with the originating miner so the fan-out is grep-able' do - FakeCgminer.with do |a_port| - FakeCgminer.with do |b_port| + CgminerTestSupport::FakeCgminer.with do |a_port| + CgminerTestSupport::FakeCgminer.with do |b_port| _stdout, stderr, status = run_cli( ['-v', 'summary'], miners: [ diff --git a/spec/integration/miner_integration_spec.rb b/spec/integration/miner_integration_spec.rb index b830dfc..215e09d 100644 --- a/spec/integration/miner_integration_spec.rb +++ b/spec/integration/miner_integration_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' # End-to-end integration tests: a real Miner instance talks to a real -# TCP socket served by FakeCgminer in a background thread. These +# TCP socket served by CgminerTestSupport::FakeCgminer in a background thread. These # tests exercise the entire request → wire → response → parse → # result path, which the mock-based unit tests can't. Every test # here validates a distinct code path in Miner#perform_request or @@ -15,7 +15,7 @@ def miner_at(port) describe 'read-only commands' do it 'returns a sanitized Hash for a single-result command (summary)' do - FakeCgminer.with do |port| + CgminerTestSupport::FakeCgminer.with do |port| result = miner_at(port).summary expect(result).to include( mhs_av: 56_789.12, @@ -27,7 +27,7 @@ def miner_at(port) end it 'returns an Array of sanitized Hashes for an array-result command (devs)' do - FakeCgminer.with do |port| + CgminerTestSupport::FakeCgminer.with do |port| result = miner_at(port).devs expect(result).to be_an(Array) expect(result.size).to eq(2) @@ -37,7 +37,7 @@ def miner_at(port) end it 'parses a multi-command response and iterates per-command statuses (summary+pools)' do - FakeCgminer.with do |port| + CgminerTestSupport::FakeCgminer.with do |port| result = miner_at(port).query('summary+pools') expect(result).to be_a(Hash) expect(result).to include(:summary, :pools) @@ -55,8 +55,8 @@ def miner_at(port) # The POOLS_WITH_CONTROL_BYTE fixture contains a 0x01 byte. # Without the gem's \uXXXX escape path, JSON.parse would reject # it as invalid JSON. - responses = { 'pools' => CgminerFixtures::POOLS_WITH_CONTROL_BYTE } - FakeCgminer.with(responses: responses) do |port| + responses = { 'pools' => CgminerTestSupport::Fixtures::POOLS_WITH_CONTROL_BYTE } + CgminerTestSupport::FakeCgminer.with(responses: responses) do |port| result = miner_at(port).pools expect(result).to be_an(Array) # The 0x01 byte is encoded as U+0001 in the parsed JSON @@ -69,8 +69,8 @@ def miner_at(port) describe 'parameter escaping on the wire' do it 'doubles backslashes and escapes commas in outgoing parameters' do received = [] - FakeCgminer.with( - responses: { 'foo' => CgminerFixtures::SUMMARY }, + CgminerTestSupport::FakeCgminer.with( + responses: { 'foo' => CgminerTestSupport::Fixtures::SUMMARY }, on_request: ->(bytes) { received << bytes } ) do |port| miner_at(port).query(:foo, "a\\b,c") @@ -87,17 +87,17 @@ def miner_at(port) describe 'error handling' do it 'raises ApiError when the server returns STATUS=E for an unknown command' do - FakeCgminer.with(responses: {}) do |port| + CgminerTestSupport::FakeCgminer.with(responses: {}) do |port| expect { miner_at(port).query(:totally_fake_command) } .to raise_error(CgminerApiClient::ApiError, /14: Invalid command/) end end it 'returns false from #privileged when the server responds with access denied' do - responses = CgminerFixtures::DEFAULT.merge( - 'privileged' => CgminerFixtures::PRIVILEGED_DENIED + responses = CgminerTestSupport::Fixtures::DEFAULT.merge( + 'privileged' => CgminerTestSupport::Fixtures::PRIVILEGED_DENIED ) - FakeCgminer.with(responses: responses) do |port| + CgminerTestSupport::FakeCgminer.with(responses: responses) do |port| expect(miner_at(port).privileged).to be(false) end end @@ -121,16 +121,16 @@ def miner_at(port) # socket instead of the real one, every admin verb that ships a secret # (addpool password, setconfig/ascset/pgaset values) would break in # production while the unit specs for on_wire redaction stay green. - # Assert against FakeCgminer's on_request hook, which captures the raw + # Assert against CgminerTestSupport::FakeCgminer's on_request hook, which captures the raw # bytes the server received. it 'sends the unredacted password to cgminer even when on_wire redacts it' do received = [] responses = { - 'addpool' => CgminerFixtures::ADDPOOL_OK, - 'privileged' => CgminerFixtures::PRIVILEGED_OK + 'addpool' => CgminerTestSupport::Fixtures::ADDPOOL_OK, + 'privileged' => CgminerTestSupport::Fixtures::PRIVILEGED_OK } - FakeCgminer.with(responses: responses, on_request: ->(bytes) { received << bytes }) do |port| + CgminerTestSupport::FakeCgminer.with(responses: responses, on_request: ->(bytes) { received << bytes }) do |port| logged = [] miner = CgminerApiClient::Miner.new('127.0.0.1', port, 2, on_wire: ->(*args) { logged << args }) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index eb39cff..ca15957 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,11 +7,7 @@ require 'cgminer_api_client' -# Load every support file (fixtures, FakeCgminer, etc). Fixtures -# must come before FakeCgminer since the latter references the -# former as a default — alphabetical order handles this since -# Dir[] returns sorted results on Ruby 3.0+. -Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f } +require 'cgminer_test_support' RSpec.configure do |config| config.filter_run :focus diff --git a/spec/support/cgminer_fixtures.rb b/spec/support/cgminer_fixtures.rb deleted file mode 100644 index 9070670..0000000 --- a/spec/support/cgminer_fixtures.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -# Canned cgminer API responses used by the integration specs and the -# `script/fake_cgminer` manual sandbox. -# -# Every fixture is grounded in cgminer's actual wire format: -# -# - Message codes (MSG_SUMM=11, MSG_DEVS=9, MSG_POOL=7, MSG_ADDPOOL=55, -# MSG_INVCMD=14, MSG_ACCDENY=45, MSG_ACCOK=46, MSG_VERSION=22) come -# from the `codes[]` table in cgminer/api.c. -# - Response envelope keys are uppercase (STATUS, Code, Msg, When, -# Description), command-data keys are uppercase (SUMMARY, DEVS, -# POOLS, ...), and `id` is lowercase. This matches what -# Miner#check_status reads out of the parsed response. -# -# Where a fixture deliberately exercises a gem code path (the `}{` -# malformed-multi-command repair, the \uXXXX control-byte escape, -# the parameter-escape assertion), that intent is noted on the -# fixture itself. -module CgminerFixtures - WHEN = 1_700_000_000 - - # Single-result success. Used by the summary integration test. - # MSG_SUMM = 11, Msg = "Summary". - SUMMARY = <<~JSON.tr("\n", '') - {"STATUS":[{"STATUS":"S","When":#{WHEN},"Code":11,"Msg":"Summary","Description":"cgminer 4.11.1"}],"SUMMARY":[{"Elapsed":12345,"MHS av":56789.12,"Found Blocks":0,"Getworks":42,"Accepted":100,"Rejected":1,"Hardware Errors":0,"Utility":4.87,"Discarded":8,"Stale":0,"Get Failures":0,"Local Work":1024,"Remote Failures":0,"Network Blocks":1,"Total MH":702345.67,"Work Utility":4.85,"Difficulty Accepted":6400.0,"Difficulty Rejected":64.0,"Difficulty Stale":0.0,"Best Share":123456789,"Device Hardware%":0.0000,"Device Rejected%":0.9901,"Pool Rejected%":0.9901,"Pool Stale%":0.0000,"Last getwork":#{WHEN}}],"id":1} - JSON - - # Array-result success. Used by the devs integration test. - # MSG_DEVS = 9 (the "%d ASC(s) - %d PGA(s)" variant is close enough). - DEVS = <<~JSON.tr("\n", '') - {"STATUS":[{"STATUS":"S","When":#{WHEN},"Code":9,"Msg":"2 ASC(s)","Description":"cgminer 4.11.1"}],"DEVS":[{"ASC":0,"Name":"BTM","ID":0,"Enabled":"Y","Status":"Alive","Temperature":65.0,"MHS av":14000000.0,"MHS 5s":14000100.0,"Accepted":50,"Rejected":0,"Hardware Errors":0,"Utility":2.44,"Last Share Pool":0,"Last Share Time":#{WHEN},"Total MH":175432.1,"Diff1 Work":0,"Difficulty Accepted":3200.0,"Difficulty Rejected":0.0,"Last Share Difficulty":64.0,"Last Valid Work":#{WHEN},"Device Hardware%":0.0,"Device Rejected%":0.0},{"ASC":1,"Name":"BTM","ID":1,"Enabled":"Y","Status":"Alive","Temperature":64.5,"MHS av":14000000.0,"MHS 5s":13999900.0,"Accepted":49,"Rejected":1,"Hardware Errors":0,"Utility":2.40,"Last Share Pool":0,"Last Share Time":#{WHEN},"Total MH":175400.0,"Diff1 Work":0,"Difficulty Accepted":3136.0,"Difficulty Rejected":64.0,"Last Share Difficulty":64.0,"Last Valid Work":#{WHEN},"Device Hardware%":0.0,"Device Rejected%":2.0}],"id":1} - JSON - - # Pools listing. Used as the happy-path pools fixture. - # MSG_POOL = 7, Msg = "%d Pool(s)". - POOLS = <<~JSON.tr("\n", '') - {"STATUS":[{"STATUS":"S","When":#{WHEN},"Code":7,"Msg":"1 Pool(s)","Description":"cgminer 4.11.1"}],"POOLS":[{"POOL":0,"URL":"stratum+tcp://example.pool:3333","Status":"Alive","Priority":0,"Quota":1,"Long Poll":"N","Getworks":10,"Accepted":100,"Rejected":1,"Works":500,"Discarded":5,"Stale":0,"Get Failures":0,"Remote Failures":0,"User":"worker1","Last Share Time":#{WHEN},"Diff1 Shares":0,"Proxy Type":"","Proxy":"","Difficulty Accepted":6400.0,"Difficulty Rejected":64.0,"Difficulty Stale":0.0,"Last Share Difficulty":64.0,"Has Stratum":true,"Stratum Active":true,"Stratum URL":"example.pool","Has GBT":false,"Best Share":123456789,"Pool Rejected%":0.9901,"Pool Stale%":0.0000}],"id":1} - JSON - - # Multi-command response. Modern cgminer emits a single top-level - # object with one key per sub-command, each value an array whose - # first element has its own STATUS block. This matches the shape - # Miner#perform_request expects when the command name contains - # '+' — it iterates data with each_pair and calls check_status on - # each response.first. - # - # Note: the gem also has gsub! repair logic for a `}{` malformed - # variant, but that repair does not actually produce valid JSON - # from any format I can reproduce, and may be legacy defensive - # code for a cgminer version that no longer exists. If the repair - # is ever confirmed to fire on real traffic, add a targeted unit - # test at the perform_request layer rather than here. - SUMMARY_PLUS_POOLS = <<~JSON.tr("\n", '') - {"summary":[{"STATUS":[{"STATUS":"S","When":#{WHEN},"Code":11,"Msg":"Summary","Description":"cgminer 4.11.1"}],"SUMMARY":[{"Elapsed":1,"MHS av":1.0}]}],"pools":[{"STATUS":[{"STATUS":"S","When":#{WHEN},"Code":7,"Msg":"1 Pool(s)","Description":"cgminer 4.11.1"}],"POOLS":[{"POOL":0,"URL":"stratum+tcp://example.pool:3333","Status":"Alive"}]}],"id":1} - JSON - - # Response containing a 0x01 byte inside a string value. The gem - # re-escapes bytes below 0x20 as \uXXXX before JSON.parse so this - # fixture exercises the control-byte escape path in - # Miner#perform_request. Real cgminer can emit control bytes in - # user-supplied pool names and worker IDs. - POOLS_WITH_CONTROL_BYTE = (+<<~JSON.tr("\n", '')).b - {"STATUS":[{"STATUS":"S","When":#{WHEN},"Code":7,"Msg":"1 Pool(s)","Description":"cgminer 4.11.1"}],"POOLS":[{"POOL":0,"URL":"\x01weird","Status":"Alive","User":"worker1"}],"id":1} - JSON - - # Successful addpool. MSG_ADDPOOL = 55. - ADDPOOL_OK = <<~JSON.tr("\n", '') - {"STATUS":[{"STATUS":"S","When":#{WHEN},"Code":55,"Msg":"Added pool 1: 'stratum+tcp://x:1'","Description":"cgminer 4.11.1"}],"id":1} - JSON - - # Successful privileged access check. MSG_ACCOK = 46. - PRIVILEGED_OK = <<~JSON.tr("\n", '') - {"STATUS":[{"STATUS":"S","When":#{WHEN},"Code":46,"Msg":"Privileged access OK","Description":"cgminer 4.11.1"}],"id":1} - JSON - - # Privileged access denied. MSG_ACCDENY = 45. Same envelope format - # as a success but with STATUS=E. check_status will raise ApiError - # with "45: Access denied". - PRIVILEGED_DENIED = <<~JSON.tr("\n", '') - {"STATUS":[{"STATUS":"E","When":#{WHEN},"Code":45,"Msg":"Access denied","Description":"cgminer 4.11.1"}],"id":1} - JSON - - # Default map used by FakeCgminer when a test doesn't supply one. - DEFAULT = { - 'summary' => SUMMARY, - 'devs' => DEVS, - 'pools' => POOLS, - 'summary+pools' => SUMMARY_PLUS_POOLS, - 'addpool' => ADDPOOL_OK, - 'privileged' => PRIVILEGED_OK - }.freeze - - # Synthesized "unknown command" response. Real cgminer emits this - # with MSG_INVCMD = 14 when it receives a command it doesn't know. - def self.invalid_command(name) - <<~JSON.tr("\n", '') - {"STATUS":[{"STATUS":"E","When":#{WHEN},"Code":14,"Msg":"Invalid command","Description":"cgminer 4.11.1 (#{name})"}],"id":1} - JSON - end -end diff --git a/spec/support/fake_cgminer.rb b/spec/support/fake_cgminer.rb deleted file mode 100644 index a5dff8f..0000000 --- a/spec/support/fake_cgminer.rb +++ /dev/null @@ -1,136 +0,0 @@ -# frozen_string_literal: true - -require 'json' -require 'socket' - -# A tiny fake cgminer-protocol server for integration testing and -# manual sandbox use. Accepts TCP connections on an ephemeral port -# (or a caller-specified port), reads a JSON request, looks up the -# response in a fixtures hash, writes it, and closes the connection. -# -# Connection lifecycle mirrors real cgminer: the gem does -# `s.write(request.to_json)` then `s.read` (read-to-EOF), so the -# server MUST close the socket after writing the response for the -# client's read to return. -# -# Usage: -# -# FakeCgminer.with do |port| -# # connect a Miner to 127.0.0.1:port and make assertions -# end -# -# With a custom response map and a request-observer callback for -# asserting on the raw bytes the server received on the wire: -# -# received = [] -# FakeCgminer.with( -# responses: { 'foo' => '...' }, -# on_request: ->(bytes) { received << bytes } -# ) do |port| -# # tests... -# end -# expect(received.first).to include('parameter') -class FakeCgminer - attr_reader :port - - def initialize(responses: CgminerFixtures::DEFAULT, port: 0, on_request: nil) - @responses = responses - @on_request = on_request - @server = TCPServer.new('127.0.0.1', port) - @port = @server.addr[1] - end - - def start - @thread = Thread.new { accept_loop } - self - end - - def stop - # Close the listening socket FIRST. On macOS, `Thread#kill` does - # not reliably interrupt a thread blocked in a C-level `accept` - # syscall, so join would hang forever. Closing the socket causes - # accept to raise IOError, which the accept loop catches to - # break out cleanly. - @server.close unless @server.closed? - @thread&.join - end - - # Bracket a block with start/stop. Cleans up even if the block - # raises. Yields the port the server is listening on. - def self.with(**) - server = new(**).start - begin - yield server.port - ensure - server.stop - end - end - - private - - def accept_loop - loop do - client = accept_next_client - break if client.nil? # server socket closed from #stop - - handle_connection_safely(client) - end - end - - # Returns the next accepted client, or nil if the server socket - # has been closed (which is the normal shutdown path from #stop). - # Only exits the loop on errors that come from the listening - # socket itself — NOT errors from client I/O, which are handled - # separately so a bad client doesn't take down the server. - def accept_next_client - @server.accept - rescue IOError, Errno::EBADF - nil - end - - # Handles one client connection in isolation. Any per-connection - # error (EOFError from an immediately-closed client, JSON parse - # failures, write errors, etc.) is swallowed so the server keeps - # running for the next connection. This tolerance matters for - # Miner#available? — a caller that opens a socket and immediately - # closes it for a reachability probe produces an EOFError here, - # which must NOT propagate. (Note: Miner#query no longer invokes - # available? as a pre-flight since 0.3.0, but the probe pattern - # is still part of the public surface.) - def handle_connection_safely(client) - handle_request(client) - rescue StandardError - # ignore — next connection is unaffected - end - - def handle_request(client) - request_bytes = read_until_parseable(client) - @on_request&.call(request_bytes) - request = JSON.parse(request_bytes) - client.write(lookup_response(request['command'])) - ensure - client&.close - end - - # Real cgminer requests fit in a single TCP packet over loopback so - # this is almost always a single readpartial. The loop is here for - # correctness if a request is ever fragmented. - def read_until_parseable(client) - buf = +'' - loop do - buf << client.readpartial(4096) - return buf if complete_json?(buf) - end - end - - def complete_json?(buf) - JSON.parse(buf) - true - rescue JSON::ParserError - false - end - - def lookup_response(command) - @responses.fetch(command) { CgminerFixtures.invalid_command(command) } - end -end