diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9af7bd5..209c756 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,9 @@ jobs: hiveos/h-run.sh \ hiveos/h-stats.sh + - name: Run shell launcher regressions + run: bash tests/start_c29_launcher_test.sh + windows: name: Windows CPU and launcher checks runs-on: windows-latest @@ -133,6 +136,10 @@ jobs: throw 'HiveOS manifest must use LF line endings.' } + - name: Run PowerShell launcher regressions + shell: powershell + run: .\tests\start_c29_launcher_test.ps1 + - name: Run GPU recall verifier fixtures shell: powershell run: python tests\tari_c29_gpu_recall.py --self-test diff --git a/README.md b/README.md index cd28bac..12e551f 100644 --- a/README.md +++ b/README.md @@ -184,9 +184,40 @@ TARI_LOGIN_SEPARATOR=/ \ together: a pool defines both the endpoint and the login format it accepts. Passing `--login-separator` after the starter command works as well. +The wallet is checked before the first connection. Whitespace, control +characters, and inputs larger than the longest supported Tari text encoding are +rejected outright. A login with one of the two Base58 address lengths that uses +a `0`, `O`, `I`, or `l` emits a typo warning before mining starts. It is not +rejected solely for that warning, because pools exist that expect a username +rather than an address. + The pool connection is plain TCP. Do not use a sensitive password for `--pass`; the default `x` is sufficient for LuckyPool. +### Exit Codes + +A miner worker keeps running through anything it can recover from, including a +dropped connection and a pool outage. It exits non-zero only for a condition +that needs a restart or an operator, so a rig supervisor can act on the code: + +| Code | Meaning | +|------|---------| +| 0 | Clean shutdown, or `--max-runtime-sec` elapsed | +| 1 | Startup failure: sockets unavailable, no such CUDA device, or not enough VRAM for one solver | +| 2 | Invalid command line | +| 3 | A GPU solution failed host verification; the GPU or its tuning is suspect | +| 4 | The pool rejected the login repeatedly; check wallet, worker, password, and separator | +| 5 | Solver failure: a CUDA error, or three consecutive graphs with no surviving edges | +| 6 | The pool accepted the connection but never sent a job | +| 7 | The pool repeatedly sent invalid protocol data | + +The starters propagate these. When a GPU worker exits non-zero, the starter +stops the remaining workers and exits with that same code, rather than carrying +on with its healthy GPUs — otherwise HiveOS never sees the failure. A starter +that fails before any worker runs uses its own codes: 2 for a missing wallet, 3 +when `nvidia-smi` finds no GPU, 4 when `TARI_DEVICES` matches none, 5 for a +missing backend binary, and 130 for Ctrl+C. + ## Test The Solver The standalone solver checks GPU results with an independent CPU verifier. diff --git a/mean_c29.cu b/mean_c29.cu index 07deb2b..cc9569c 100644 --- a/mean_c29.cu +++ b/mean_c29.cu @@ -1077,25 +1077,42 @@ struct solver_ctx { // print_log(" (%x, %x)", soledges[j].x, soledges[j].y); } // print_log("\n"); - outSols.resize(outSols.size() + PROOFSIZE); - checkCudaErrors(cudaMemcpyToSymbol(recoveredges, soledges, sizeof(soledges))); + // Recovery fills this slot. On failure it is removed again, so a caller + // never sees a half-written proof of zeros that would then be reported as + // a verification failure. + const size_t solbase = outSols.size(); + outSols.resize(solbase + PROOFSIZE); + cudaError_t rc = cudaMemcpyToSymbol(recoveredges, soledges, sizeof(soledges)); #if RECOVERY_SMALL_OUTPUT - checkCudaErrors(cudaMemset(recoverIndexes, 0, PROOFSIZE * sizeof(u32))); - Recovery<<>>(keys, (ulonglong4*)trimmer.bufferA, (int *)recoverIndexes); - checkCudaErrors(cudaGetLastError()); - checkCudaErrors(cudaMemcpy(&outSols[outSols.size()-PROOFSIZE], recoverIndexes, - PROOFSIZE * sizeof(u32), cudaMemcpyDeviceToHost)); + if (rc == cudaSuccess) + rc = cudaMemset(recoverIndexes, 0, PROOFSIZE * sizeof(u32)); + if (rc == cudaSuccess) { + Recovery<<>>(keys, (ulonglong4*)trimmer.bufferA, (int *)recoverIndexes); + rc = cudaGetLastError(); + } + if (rc == cudaSuccess) + rc = cudaMemcpy(&outSols[solbase], recoverIndexes, + PROOFSIZE * sizeof(u32), cudaMemcpyDeviceToHost); #else - checkCudaErrors(cudaMemset(trimmer.indexesE[1], 0, trimmer.indexesSize)); - Recovery<<>>(keys, (ulonglong4*)trimmer.bufferA, (int *)trimmer.indexesE[1]); - checkCudaErrors(cudaGetLastError()); - checkCudaErrors(cudaMemcpy(&outSols[outSols.size()-PROOFSIZE], trimmer.indexesE[1], - PROOFSIZE * sizeof(u32), cudaMemcpyDeviceToHost)); + if (rc == cudaSuccess) + rc = cudaMemset(trimmer.indexesE[1], 0, trimmer.indexesSize); + if (rc == cudaSuccess) { + Recovery<<>>(keys, (ulonglong4*)trimmer.bufferA, (int *)trimmer.indexesE[1]); + rc = cudaGetLastError(); + } + if (rc == cudaSuccess) + rc = cudaMemcpy(&outSols[solbase], trimmer.indexesE[1], + PROOFSIZE * sizeof(u32), cudaMemcpyDeviceToHost); #endif // Recovery uses the calling thread's default stream. Synchronizing that // stream preserves overlap with trims running in other host threads. - checkCudaErrors(cudaStreamSynchronize(0)); - qsort(&outSols[outSols.size()-PROOFSIZE], PROOFSIZE, sizeof(u32), cg.nonce_cmp); + if (rc == cudaSuccess) + rc = cudaStreamSynchronize(0); + if (rc != cudaSuccess) { + outSols.resize(solbase); + return gpuAssert(rc, __FILE__, __LINE__); + } + qsort(&outSols[solbase], PROOFSIZE, sizeof(u32), cg.nonce_cmp); } return 0; } diff --git a/start-c29.ps1 b/start-c29.ps1 index efde14c..34b52a4 100644 --- a/start-c29.ps1 +++ b/start-c29.ps1 @@ -121,6 +121,7 @@ if (-not [string]::IsNullOrEmpty($logDir) -and -not (Test-Path -LiteralPath $log $workers = @() $workerFailed = $false +$workerExitCode = 0 try { foreach ($item in $plan) { $startArgs = @{ @@ -151,19 +152,35 @@ try { } Write-Host 'Press Ctrl+C to stop all GPU workers.' + # A worker exits non-zero only for something a restart must clear: a + # repeatedly rejected login (4), a failed solver (5), an unresponsive pool + # (6), or invalid pool protocol data (7). Stop the survivors and surface that + # code, so a rig supervisor sees the failure instead of a launcher still + # babysitting its healthy GPUs. while ($true) { - $alive = @($workers | Where-Object { -not $_.HasExited }) - if ($alive.Count -eq 0) { break } - Start-Sleep -Seconds 2 - } - Write-Host 'All GPU workers have exited.' - foreach ($worker in $workers) { - $worker.WaitForExit() - $exitCode = $worker.ExitCode - if ($exitCode -ne 0) { - Write-Host "ERROR: Miner worker PID $($worker.Id) exited with code $exitCode." + $exited = @($workers | Where-Object { $_.HasExited }) + $failed = @($exited | + Where-Object { $_.ExitCode -ne 0 } | + Sort-Object ExitTime, Id | + Select-Object -First 1) + if ($failed.Count -gt 0) { + $worker = $failed[0] + Write-Host "ERROR: Miner worker PID $($worker.Id) exited with code $($worker.ExitCode)." + $workerExitCode = $worker.ExitCode $workerFailed = $true } + if ($workerFailed) { + $alive = @($workers | Where-Object { -not $_.HasExited }) + if ($alive.Count -gt 0) { + Write-Host "Stopping $($alive.Count) remaining GPU worker(s)." + } + break + } + if ($exited.Count -eq $workers.Count) { + Write-Host 'All GPU workers have exited.' + break + } + Start-Sleep -Seconds 2 } } catch { @@ -179,6 +196,9 @@ finally { } } +if ($workerFailed) { + if ($workerExitCode -ne 0) { exit $workerExitCode } + exit 1 +} if ($missing -gt 0) { exit 5 } -if ($workerFailed) { exit 1 } exit 0 diff --git a/start-c29.sh b/start-c29.sh index 461cd2d..c30aac6 100755 --- a/start-c29.sh +++ b/start-c29.sh @@ -121,17 +121,72 @@ if [[ "${TARI_DRY_RUN:-0}" == "1" ]]; then exit 0 fi +kill_workers() { + if ((${#pids[@]} > 0)); then + local all_pids=("${pids[@]}") + local survivors=("${pids[@]}") + local pass pid + kill "${all_pids[@]}" 2>/dev/null || true + # A wedged miner must not hold the launcher (and therefore the rig + # supervisor) forever. Give TERM two seconds, then force the survivors. + for ((pass = 0; pass < 20 && ${#survivors[@]} > 0; pass++)); do + sleep 0.1 + local remaining=() + for pid in "${survivors[@]}"; do + kill -0 "$pid" 2>/dev/null && remaining+=("$pid") + done + survivors=("${remaining[@]}") + done + if ((${#survivors[@]} > 0)); then + kill -KILL "${survivors[@]}" 2>/dev/null || true + fi + wait "${all_pids[@]}" 2>/dev/null || true + pids=() + fi +} + stop_workers() { trap - INT TERM - kill "${pids[@]}" 2>/dev/null || true - wait "${pids[@]}" 2>/dev/null || true + kill_workers exit 130 } trap stop_workers INT TERM status=0 -((missing == 0)) || status=1 -for pid in "${pids[@]}"; do - wait "$pid" || status=1 +((missing == 0)) || status=5 + +# A miner worker exits non-zero when it hits something only a restart can clear: +# a repeatedly rejected login (4), a failed solver (5), an unresponsive pool +# (6), or invalid pool protocol data (7). Stop the surviving workers and exit +# with that code, so a rig supervisor sees the failure instead of a launcher +# that keeps running its healthy GPUs. +worker_failure=0 +while ((${#pids[@]} > 0)); do + remaining=() + for pid in "${pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + remaining+=("$pid") + continue + fi + worker_status=0 + wait "$pid" || worker_status=$? + if ((worker_status != 0)) && ((worker_failure == 0)); then + echo "ERROR: GPU worker $pid exited with code $worker_status." >&2 + worker_failure="$worker_status" + fi + done + pids=(${remaining[@]+"${remaining[@]}"}) + ((worker_failure == 0)) || break + ((${#pids[@]} > 0)) || break + sleep 1 done + +if ((worker_failure != 0)); then + if ((${#pids[@]} > 0)); then + echo "Stopping ${#pids[@]} remaining GPU worker(s)." >&2 + kill_workers + fi + exit "$worker_failure" +fi + exit "$status" diff --git a/tari_c29_pool_miner.cu b/tari_c29_pool_miner.cu index ddabd91..6795c91 100644 --- a/tari_c29_pool_miner.cu +++ b/tari_c29_pool_miner.cu @@ -253,12 +253,7 @@ static bool json_get_string_from(const std::string &line, const char *key, std:: } static bool json_get_uint_from(const std::string &line, const char *key, uint64_t &out, size_t start = 0) { - size_t p = 0; - if (!tari_pool::json_find_value_from(line, key, p, start)) - return false; - char *end = nullptr; - out = strtoull(line.c_str() + p, &end, 10); - return end && end != line.c_str() + p; + return tari_pool::json_uint_from(line, key, out, start); } static uint64_t nonce_prefix_base(const std::string &xn_hex, uint64_t *counter_mask) { @@ -374,7 +369,7 @@ public: return running_.load(); } - bool wait_for_job(Job &job, int timeout_ms) { + tari_miner::JobWaitOutcome wait_for_job(Job &job, int timeout_ms) { double end = now_sec() + timeout_ms / 1000.0; uint64_t last = 0; while (now_sec() < end) { @@ -382,14 +377,25 @@ public: std::lock_guard lk(mu_); if (job_.seq != 0 && job_.seq != last) { job = job_; - return true; + return tari_miner::JobWaitOutcome::Job; } last = job_.seq; } - if (!alive()) return false; + if (login_failed_.load()) + return tari_miner::JobWaitOutcome::LoginRejected; + if (protocol_error_.load()) + return tari_miner::JobWaitOutcome::ProtocolError; + if (!alive()) + return tari_miner::JobWaitOutcome::Disconnected; std::this_thread::sleep_for(std::chrono::milliseconds(50)); } - return false; + if (login_failed_.load()) + return tari_miner::JobWaitOutcome::LoginRejected; + if (protocol_error_.load()) + return tari_miner::JobWaitOutcome::ProtocolError; + if (!alive()) + return tari_miner::JobWaitOutcome::Disconnected; + return tari_miner::JobWaitOutcome::Timeout; } Job current_job() { @@ -433,7 +439,7 @@ public: uint64_t accepted() const { return accepted_.load(); } uint64_t rejected() const { return rejected_.load(); } - bool login_failed() const { return login_failed_.load(); } + bool protocol_error() const { return protocol_error_.load(); } private: bool send_line(const std::string &line) { @@ -455,6 +461,7 @@ private: })) { fprintf(stderr, "pool sent a line larger than %zu bytes; disconnecting\n", tari_pool::MAX_LINE_BYTES); + protocol_error_.store(true); break; } } @@ -462,6 +469,13 @@ private: } void handle_line(const std::string &line) { + if (!tari_pool::json_root_object_is_valid(line)) { + fprintf(stderr, "pool sent invalid JSON; disconnecting\n"); + protocol_error_.store(true); + running_.store(false); + shutdown_socket(socket_.load()); + return; + } uint64_t response_id = 0; bool has_id = tari_pool::json_root_uint(line, "id", response_id); size_t error_position = 0; @@ -474,6 +488,11 @@ private: tari_pool::json_find_root_value(line, "result", result_position); bool result_true = tari_pool::json_root_literal(line, "result", "true"); bool result_false = tari_pool::json_root_literal(line, "result", "false"); + // Some deployments answer a submit with the same {"status":"OK"} object + // they use for login rather than a bare true. Only a response whose id + // matches an outstanding submit is counted, so the login reply itself + // cannot be mistaken for an accepted share. + bool result_status_ok = tari_pool::json_result_status_ok(line); tari_miner::PoolResponseKind response; { std::lock_guard lk(mu_); @@ -481,7 +500,7 @@ private: response = responses_.classify( has_id, response_id, has_error, has_result, result_true, result_false, - login_pending + login_pending, result_status_ok ); } @@ -534,6 +553,7 @@ private: (unsigned long long)job_.target_diff, safe_xn.c_str()); } else if (invalid_target) { fprintf(stderr, "invalid pool target; disconnecting\n"); + protocol_error_.store(true); running_.store(false); shutdown_socket(socket_.load()); } @@ -547,6 +567,7 @@ private: tari_miner::PoolResponseTracker responses_; std::atomic running_{false}; std::atomic login_failed_{false}; + std::atomic protocol_error_{false}; std::atomic accepted_{0}; std::atomic rejected_{0}; }; @@ -649,10 +670,27 @@ static bool parse_args(int argc, char **argv, Options &o) { } tari_miner::WalletValidationError wallet_error = tari_miner::validate_wallet(o.wallet); - if (wallet_error == tari_miner::WalletValidationError::WhitespaceOrControl) { - fprintf(stderr, "--wallet must not contain whitespace or control characters\n"); - return false; + switch (wallet_error) { + case tari_miner::WalletValidationError::None: + break; + case tari_miner::WalletValidationError::Empty: + fprintf(stderr, "--wallet is required\n"); + break; + case tari_miner::WalletValidationError::WhitespaceOrControl: + fprintf(stderr, "--wallet must not contain whitespace or control characters\n"); + break; + case tari_miner::WalletValidationError::TooLong: + fprintf(stderr, "--wallet is %zu bytes; the supported maximum is %zu\n", + o.wallet.size(), tari_miner::MAX_WALLET_LENGTH); + break; + case tari_miner::WalletValidationError::TariAddressCharset: + fprintf(stderr, + "warning: --wallet has a Tari address length but contains a " + "character Base58 never uses (0, O, I or l); check it for a typo\n"); + break; } + if (tari_miner::wallet_validation_is_fatal(wallet_error)) + return false; if (o.intensity < 1) o.intensity = 1; if (o.intensity > 100) o.intensity = 100; if (o.pipeline_set) @@ -753,6 +791,8 @@ int main(int argc, char **argv) { uint64_t graphs = 0, cycles = 0, submitted = 0, verify_failures = 0; int exit_code = 0; tari_miner::LoginFailurePolicy login_failures; + tari_miner::PoolSilencePolicy pool_silence; + tari_miner::ProtocolErrorPolicy protocol_errors; std::vector solver_watchdogs(contexts.size()); double start = now_sec(); double last_report = start; @@ -769,6 +809,22 @@ int main(int argc, char **argv) { return false; }; + auto record_protocol_error = [&]() { + bool fatal = protocol_errors.record_failure(); + if (fatal) { + fprintf(stderr, + "pool sent invalid protocol data %u times; exiting " + "for operator review\n", + protocol_errors.consecutive_failures()); + exit_code = tari_miner::POOL_PROTOCOL_EXIT_CODE; + return true; + } + fprintf(stderr, "pool protocol error (%u/%u); retrying in 5s\n", + protocol_errors.consecutive_failures(), + tari_miner::MAX_PROTOCOL_ERRORS); + return false; + }; + while (true) { double elapsed = now_sec() - start; if (opt.max_runtime_sec > 0 && elapsed >= opt.max_runtime_sec) break; @@ -778,15 +834,21 @@ int main(int argc, char **argv) { PoolClient pool; if (!pool.connect_login(opt.pool, login, opt.pass)) { + pool_silence.reset(); + protocol_errors.reset(); fprintf(stderr, "pool connection/login send failed; retrying in 5s\n"); std::this_thread::sleep_for(std::chrono::seconds(5)); continue; } Job job; - if (!pool.wait_for_job(job, 20000)) { - if (pool.login_failed()) { + tari_miner::JobWaitOutcome wait_outcome = + pool.wait_for_job(job, 20000); + if (wait_outcome != tari_miner::JobWaitOutcome::Job) { + if (wait_outcome == tari_miner::JobWaitOutcome::LoginRejected) { pool.stop(); + pool_silence.reset(); + protocol_errors.reset(); bool fatal = login_failures.record_failure(); if (fatal) { fprintf(stderr, @@ -804,10 +866,44 @@ int main(int argc, char **argv) { std::chrono::seconds(tari_miner::LOGIN_RETRY_SECONDS)); continue; } - fprintf(stderr, "no job received; reconnecting\n"); + pool.stop(); + if (tari_miner::counts_as_pool_silence(wait_outcome)) { + protocol_errors.reset(); + // The connection stayed open but sent no job and no error, so + // there is nothing for the login policy to count. Back off, and + // give up eventually rather than reconnecting forever in silence. + bool silent_fatal = pool_silence.record_silence(); + if (silent_fatal) { + fprintf(stderr, + "no job received from %s after %u attempts; exiting for " + "supervisor restart\n", + opt.pool.c_str(), pool_silence.consecutive_silences()); + exit_code = tari_miner::POOL_SILENT_EXIT_CODE; + break; + } + unsigned backoff = pool_silence.backoff_seconds(); + fprintf(stderr, "no job received (%u/%u); reconnecting in %us\n", + pool_silence.consecutive_silences(), + tari_miner::MAX_SILENT_CYCLES, backoff); + std::this_thread::sleep_for(std::chrono::seconds(backoff)); + continue; + } + pool_silence.reset(); + if (wait_outcome == tari_miner::JobWaitOutcome::ProtocolError) { + if (record_protocol_error()) + break; + std::this_thread::sleep_for(std::chrono::seconds(5)); + continue; + } + protocol_errors.reset(); + fprintf(stderr, + "pool disconnected before the first valid job; retrying in 5s\n"); + std::this_thread::sleep_for(std::chrono::seconds(5)); continue; } login_failures.record_success(); + pool_silence.record_job(); + protocol_errors.record_valid_job(job.seq); uint64_t last_seq = 0; uint64_t base = 0, mask = ~0ULL, counter = 0; @@ -897,6 +993,11 @@ int main(int argc, char **argv) { continue; } if (job.seq != last_seq) { + // Do not reset on the initial job: a pool that sends one valid + // job and then malformed updates on every reconnect must still + // reach the protocol-error limit. A later valid update proves + // the connection has recovered and breaks that streak. + protocol_errors.record_valid_job(job.seq); last_seq = job.seq; base = nonce_prefix_base(job.xn_hex, &mask); counter = ((uint64_t)(now_sec() * 1000000.0)) & mask; @@ -940,6 +1041,7 @@ int main(int argc, char **argv) { Job launch_job = pool.current_job(); if (launch_job.seq == 0) return false; if (launch_job.seq != last_seq) { + protocol_errors.record_valid_job(launch_job.seq); last_seq = launch_job.seq; base = nonce_prefix_base(launch_job.xn_hex, &mask); counter = ((uint64_t)(now_sec() * 1000000.0)) & mask; @@ -1022,6 +1124,21 @@ int main(int argc, char **argv) { } if (exit_code) break; + if (pool.protocol_error()) { + pool.stop(); + // The reader can parse a valid update and malformed data in the + // same receive batch before the mining loop observes that update. + // Consult the synchronized final job state before counting the + // protocol failure so recovery does not depend on thread timing. + protocol_errors.record_valid_job(pool.current_job().seq); + if (record_protocol_error()) + break; + std::this_thread::sleep_for(std::chrono::seconds(5)); + continue; + } + // A connection that ends without malformed data also breaks the + // protocol-error streak. + protocol_errors.reset(); } double elapsed = now_sec() - start; diff --git a/tari_miner_reliability.h b/tari_miner_reliability.h index cd8be79..3f985ac 100644 --- a/tari_miner_reliability.h +++ b/tari_miner_reliability.h @@ -4,24 +4,102 @@ #include #include +#include #include -#include namespace tari_miner { enum class WalletValidationError { None, + Empty, WhitespaceOrControl, + TooLong, + TariAddressCharset, }; +// Tari address sizes from base_layer/common_types/src/tari_address/mod.rs. +// Base58 single addresses encode to 45-48 characters and dual addresses to +// 89-443. A dual address can contain 67-323 bytes once its optional 256-byte +// payment ID is included; hex needs two characters per byte and each emoji is +// at most four UTF-8 bytes. +constexpr size_t TARI_SINGLE_ADDRESS_MIN_LENGTH = 45; +constexpr size_t TARI_SINGLE_ADDRESS_MAX_LENGTH = 48; +constexpr size_t TARI_DUAL_ADDRESS_MIN_LENGTH = 89; +constexpr size_t TARI_DUAL_ADDRESS_MAX_LENGTH = 443; +constexpr size_t TARI_DUAL_INTERNAL_MAX_SIZE = 67 + 256; +constexpr size_t TARI_HEX_MAX_LENGTH = TARI_DUAL_INTERNAL_MAX_SIZE * 2; +constexpr size_t TARI_EMOJI_MAX_UTF8_LENGTH = + TARI_DUAL_INTERNAL_MAX_SIZE * 4; +constexpr size_t TARI_GROUPED_EMOJI_MAX_UTF8_LENGTH = + TARI_EMOJI_MAX_UTF8_LENGTH + TARI_DUAL_INTERNAL_MAX_SIZE - 1; +constexpr size_t MAX_WALLET_LENGTH = TARI_GROUPED_EMOJI_MAX_UTF8_LENGTH; + +// Bitcoin base58: the digits and letters, minus 0 O I l. Those four are exactly +// the characters a mistyped or OCR-read address tends to gain. +inline bool is_base58_char(unsigned char c) { + if (c >= '1' && c <= '9') return true; + if (c >= 'a' && c <= 'z') return c != 'l'; + if (c >= 'A' && c <= 'Z') return c != 'I' && c != 'O'; + return false; +} + +inline bool has_tari_address_length(size_t length) { + return (length >= TARI_SINGLE_ADDRESS_MIN_LENGTH && + length <= TARI_SINGLE_ADDRESS_MAX_LENGTH) || + (length >= TARI_DUAL_ADDRESS_MIN_LENGTH && + length <= TARI_DUAL_ADDRESS_MAX_LENGTH); +} + +inline bool is_ascii_alnum(unsigned char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z'); +} + +inline bool is_hex_string(const std::string &text) { + for (unsigned char c : text) { + bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'); + if (!hex) return false; + } + return true; +} + +// This field is not always a Tari address: pools exist that expect a username, +// which is why --login-separator exists. So the charset rule is applied only to +// strings already shaped like an address - the length of one, and nothing but +// ASCII letters and digits. Anything else is passed through, and it is the pool +// that decides whether the login is good. inline WalletValidationError validate_wallet(const std::string &wallet) { + if (wallet.empty()) return WalletValidationError::Empty; + for (unsigned char c : wallet) { if (c <= 0x20 || c == 0x7f) return WalletValidationError::WhitespaceOrControl; } + if (wallet.size() > MAX_WALLET_LENGTH) + return WalletValidationError::TooLong; + + if (!has_tari_address_length(wallet.size())) + return WalletValidationError::None; + for (unsigned char c : wallet) { + if (!is_ascii_alnum(c)) return WalletValidationError::None; + } + // An all-hex login of the same length is a login, not a mistyped address: + // the only non-base58 character it can hold is '0'. + if (is_hex_string(wallet)) return WalletValidationError::None; + + for (unsigned char c : wallet) { + if (!is_base58_char(c)) + return WalletValidationError::TariAddressCharset; + } return WalletValidationError::None; } +inline bool wallet_validation_is_fatal(WalletValidationError error) { + return error != WalletValidationError::None && + error != WalletValidationError::TariAddressCharset; +} + enum class PoolResponseKind { Other, LoginError, @@ -33,11 +111,18 @@ enum class PoolResponseKind { constexpr uint64_t LOGIN_REQUEST_ID = 1; constexpr uint64_t FIRST_SUBMIT_REQUEST_ID = 4; +// A pool that never answers a submit would otherwise grow the pending set for +// as long as the miner runs. Beyond this many outstanding submits the oldest +// is forgotten; it can then only be classified as an unmatched response. +constexpr size_t MAX_PENDING_SUBMITS = 256; + class PoolResponseTracker { public: uint64_t begin_submit() { uint64_t id = next_submit_id_++; pending_submits_.insert(id); + while (pending_submits_.size() > MAX_PENDING_SUBMITS) + pending_submits_.erase(pending_submits_.begin()); return id; } @@ -52,7 +137,8 @@ class PoolResponseTracker { bool has_result, bool result_true, bool result_false, - bool login_pending + bool login_pending, + bool result_status_ok = false ) { if ((has_error || result_false) && login_pending && ((!has_id) || id == LOGIN_REQUEST_ID)) @@ -66,7 +152,7 @@ class PoolResponseTracker { if (has_error || result_false) { return PoolResponseKind::ShareRejected; } - if (result_true) { + if (result_true || result_status_ok) { return PoolResponseKind::ShareAccepted; } return PoolResponseKind::Other; @@ -82,7 +168,8 @@ class PoolResponseTracker { private: uint64_t next_submit_id_ = FIRST_SUBMIT_REQUEST_ID; - std::unordered_set pending_submits_; + // Ordered so the oldest outstanding submit is the one dropped at the cap. + std::set pending_submits_; }; constexpr unsigned MAX_LOGIN_FAILURES = 3; @@ -108,6 +195,92 @@ class LoginFailurePolicy { unsigned consecutive_failures_ = 0; }; +// A pool that accepts the connection but never sends a job produces no error to +// count, so the login policy above never fires. Bound that case separately: +// back off between attempts, then exit so a supervisor can react. +constexpr unsigned MAX_SILENT_CYCLES = 10; +constexpr int POOL_SILENT_EXIT_CODE = 6; +constexpr unsigned FIRST_SILENT_BACKOFF_SECONDS = 5; +constexpr unsigned MAX_SILENT_BACKOFF_SECONDS = 60; + +class PoolSilencePolicy { +public: + // Returns true when the miner should stop retrying. + bool record_silence() { + consecutive_silences_++; + return consecutive_silences_ >= MAX_SILENT_CYCLES; + } + + void record_job() { + reset(); + } + + void reset() { + consecutive_silences_ = 0; + } + + unsigned consecutive_silences() const { + return consecutive_silences_; + } + + // Doubling backoff, capped, so a pool outage is not hammered. + unsigned backoff_seconds() const { + unsigned seconds = FIRST_SILENT_BACKOFF_SECONDS; + for (unsigned i = 1; i < consecutive_silences_; ++i) { + if (seconds >= MAX_SILENT_BACKOFF_SECONDS) + return MAX_SILENT_BACKOFF_SECONDS; + seconds *= 2; + } + return seconds < MAX_SILENT_BACKOFF_SECONDS + ? seconds + : MAX_SILENT_BACKOFF_SECONDS; + } + +private: + unsigned consecutive_silences_ = 0; +}; + +enum class JobWaitOutcome { + Job, + LoginRejected, + ProtocolError, + Disconnected, + Timeout, +}; + +inline bool counts_as_pool_silence(JobWaitOutcome outcome) { + return outcome == JobWaitOutcome::Timeout; +} + +constexpr unsigned MAX_PROTOCOL_ERRORS = 3; +constexpr int POOL_PROTOCOL_EXIT_CODE = 7; + +class ProtocolErrorPolicy { +public: + bool record_failure() { + consecutive_failures_++; + return consecutive_failures_ >= MAX_PROTOCOL_ERRORS; + } + + void record_valid_job(uint64_t session_job_sequence) { + // The first job is expected before every retry, so it does not prove + // recovery from a pool that always fails on its first update. + if (session_job_sequence > 1) + reset(); + } + + void reset() { + consecutive_failures_ = 0; + } + + unsigned consecutive_failures() const { + return consecutive_failures_; + } + +private: + unsigned consecutive_failures_ = 0; +}; + constexpr unsigned MAX_CONSECUTIVE_ZERO_YIELDS = 3; constexpr int SOLVER_FAILURE_EXIT_CODE = 5; diff --git a/tari_pool_protocol.h b/tari_pool_protocol.h index f92108b..1bb4fdf 100644 --- a/tari_pool_protocol.h +++ b/tari_pool_protocol.h @@ -158,31 +158,40 @@ inline bool json_root_literal( delimiter == '\r' || delimiter == '\n'; } -inline bool json_root_uint( +// Strict unsigned parse at an already-located value position. Rejects a sign, +// leading whitespace, and anything that does not terminate on a JSON +// delimiter, so "-1" cannot arrive as a huge unsigned value. +inline bool parse_uint_at( const std::string &json, - const char *key, + size_t position, uint64_t &value ) { - size_t position = 0; - if (!json_find_root_value(json, key, position) || - position == json.size() || + if (position >= json.size() || json[position] < '0' || json[position] > '9') { return false; } uint64_t parsed = 0; size_t i = position; + if (json[position] == '0' && position + 1 < json.size() && + json[position + 1] >= '0' && json[position + 1] <= '9') { + return false; + } for (; i < json.size() && json[i] >= '0' && json[i] <= '9'; ++i) { unsigned digit = (unsigned)(json[i] - '0'); if (parsed > (std::numeric_limits::max() - digit) / 10) return false; parsed = parsed * 10 + digit; } + while (i < json.size() && + (json[i] == ' ' || json[i] == '\t' || + json[i] == '\r' || json[i] == '\n')) { + i++; + } if (i < json.size()) { char delimiter = json[i]; if (delimiter != ',' && delimiter != '}' && delimiter != ']' && - delimiter != ' ' && delimiter != '\t' && - delimiter != '\r' && delimiter != '\n') { + delimiter != ' ' && delimiter != '\t') { return false; } } @@ -190,6 +199,349 @@ inline bool json_root_uint( return true; } +inline bool json_root_uint( + const std::string &json, + const char *key, + uint64_t &value +) { + size_t position = 0; + if (!json_find_root_value(json, key, position)) return false; + return parse_uint_at(json, position, value); +} + +inline bool json_uint_from( + const std::string &json, + const char *key, + uint64_t &value, + size_t start = 0 +) { + size_t position = 0; + if (!json_find_value_from(json, key, position, start)) return false; + return parse_uint_at(json, position, value); +} + +// Read the quoted string that begins at `position`. +inline bool json_string_at( + const std::string &json, + size_t position, + std::string &out, + size_t *end_position = nullptr +) { + if (position >= json.size() || json[position] != '"') return false; + std::string value; + bool escaped = false; + for (size_t i = position + 1; i < json.size(); ++i) { + char c = json[i]; + if (escaped) { + if (c == 'u') { + if (i + 4 >= json.size()) return false; + for (size_t j = 1; j <= 4; ++j) { + if (hex_value(json[i + j]) < 0) return false; + } + value.push_back('?'); + i += 4; + } else if (c == '"' || c == '\\' || c == '/') { + value.push_back(c); + } else if (c == 'b' || c == 'f' || c == 'n' || + c == 'r' || c == 't') { + value.push_back('?'); + } else { + return false; + } + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + out = value; + if (end_position) *end_position = i + 1; + return true; + } else if ((unsigned char)c < 0x20) { + return false; + } else { + value.push_back(c); + } + } + return false; +} + +// Extract the balanced object or array beginning at `position`, skipping over +// braces that appear inside strings. +inline bool json_container_slice( + const std::string &json, + size_t position, + std::string &out, + size_t *end_position = nullptr +) { + if (position >= json.size() || + (json[position] != '{' && json[position] != '[')) { + return false; + } + + std::string expected_closers; + bool in_string = false; + bool escaped = false; + for (size_t i = position; i < json.size(); ++i) { + char c = json[i]; + if (in_string) { + if (escaped) { + if (c == 'u') { + if (i + 4 >= json.size()) return false; + for (size_t j = 1; j <= 4; ++j) { + if (hex_value(json[i + j]) < 0) return false; + } + i += 4; + } else if (c != '"' && c != '\\' && c != '/' && + c != 'b' && c != 'f' && c != 'n' && + c != 'r' && c != 't') { + return false; + } + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + in_string = false; + } else if ((unsigned char)c < 0x20) { + return false; + } + continue; + } + if (c == '"') { + in_string = true; + } else if (c == '{' || c == '[') { + expected_closers.push_back(c == '{' ? '}' : ']'); + } else if (c == '}' || c == ']') { + if (expected_closers.empty() || + expected_closers.back() != c) { + return false; + } + expected_closers.pop_back(); + if (expected_closers.empty()) { + out = json.substr(position, i - position + 1); + if (end_position) *end_position = i + 1; + return true; + } + } + } + return false; +} + +inline void json_skip_whitespace(const std::string &json, size_t &position) { + while (position < json.size() && + (json[position] == ' ' || json[position] == '\t' || + json[position] == '\r' || json[position] == '\n')) { + position++; + } +} + +inline bool json_skip_string(const std::string &json, size_t &position) { + if (position >= json.size() || json[position++] != '"') return false; + while (position < json.size()) { + unsigned char c = (unsigned char)json[position++]; + if (c == '"') return true; + if (c < 0x20) return false; + if (c != '\\') continue; + if (position >= json.size()) return false; + char escaped = json[position++]; + if (escaped == 'u') { + if (position + 4 > json.size()) return false; + for (size_t i = 0; i < 4; ++i) { + if (hex_value(json[position + i]) < 0) return false; + } + position += 4; + } else if (escaped != '"' && escaped != '\\' && escaped != '/' && + escaped != 'b' && escaped != 'f' && escaped != 'n' && + escaped != 'r' && escaped != 't') { + return false; + } + } + return false; +} + +inline bool json_skip_number(const std::string &json, size_t &position) { + if (position < json.size() && json[position] == '-') position++; + if (position >= json.size()) return false; + if (json[position] == '0') { + position++; + if (position < json.size() && + json[position] >= '0' && json[position] <= '9') { + return false; + } + } else { + if (json[position] < '1' || json[position] > '9') return false; + while (position < json.size() && + json[position] >= '0' && json[position] <= '9') { + position++; + } + } + if (position < json.size() && json[position] == '.') { + position++; + if (position >= json.size() || + json[position] < '0' || json[position] > '9') { + return false; + } + while (position < json.size() && + json[position] >= '0' && json[position] <= '9') { + position++; + } + } + if (position < json.size() && + (json[position] == 'e' || json[position] == 'E')) { + position++; + if (position < json.size() && + (json[position] == '+' || json[position] == '-')) { + position++; + } + if (position >= json.size() || + json[position] < '0' || json[position] > '9') { + return false; + } + while (position < json.size() && + json[position] >= '0' && json[position] <= '9') { + position++; + } + } + return true; +} + +inline bool json_skip_value( + const std::string &json, + size_t &position, + unsigned depth +); + +inline bool json_skip_object( + const std::string &json, + size_t &position, + unsigned depth +) { + if (depth > 128 || position >= json.size() || + json[position++] != '{') { + return false; + } + json_skip_whitespace(json, position); + if (position < json.size() && json[position] == '}') { + position++; + return true; + } + while (position < json.size()) { + if (!json_skip_string(json, position)) return false; + json_skip_whitespace(json, position); + if (position >= json.size() || json[position++] != ':') return false; + if (!json_skip_value(json, position, depth + 1)) return false; + json_skip_whitespace(json, position); + if (position < json.size() && json[position] == '}') { + position++; + return true; + } + if (position >= json.size() || json[position++] != ',') return false; + json_skip_whitespace(json, position); + if (position >= json.size() || json[position] == '}') return false; + } + return false; +} + +inline bool json_skip_array( + const std::string &json, + size_t &position, + unsigned depth +) { + if (depth > 128 || position >= json.size() || + json[position++] != '[') { + return false; + } + json_skip_whitespace(json, position); + if (position < json.size() && json[position] == ']') { + position++; + return true; + } + while (position < json.size()) { + if (!json_skip_value(json, position, depth + 1)) return false; + json_skip_whitespace(json, position); + if (position < json.size() && json[position] == ']') { + position++; + return true; + } + if (position >= json.size() || json[position++] != ',') return false; + json_skip_whitespace(json, position); + if (position >= json.size() || json[position] == ']') return false; + } + return false; +} + +inline bool json_skip_value( + const std::string &json, + size_t &position, + unsigned depth +) { + if (depth > 128) return false; + json_skip_whitespace(json, position); + if (position >= json.size()) return false; + char c = json[position]; + if (c == '{') return json_skip_object(json, position, depth); + if (c == '[') return json_skip_array(json, position, depth); + if (c == '"') return json_skip_string(json, position); + if (c == '-' || (c >= '0' && c <= '9')) + return json_skip_number(json, position); + for (const char *literal : {"true", "false", "null"}) { + size_t length = std::strlen(literal); + if (json.compare(position, length, literal) == 0) { + position += length; + return true; + } + } + return false; +} + +inline bool json_root_object_is_valid(const std::string &json) { + size_t position = 0; + json_skip_whitespace(json, position); + if (!json_skip_object(json, position, 0)) return false; + json_skip_whitespace(json, position); + return position == json.size(); +} + +inline bool json_value_has_delimiter( + const std::string &json, + size_t end_position +) { + while (end_position < json.size() && + (json[end_position] == ' ' || json[end_position] == '\t' || + json[end_position] == '\r' || json[end_position] == '\n')) { + end_position++; + } + if (end_position == json.size()) return true; + char delimiter = json[end_position]; + return delimiter == ',' || delimiter == '}' || delimiter == ']'; +} + +// True for a response whose root "result" is an object carrying "status":"OK". +// Pools in this dialect answer a submit either with `"result":true` or with the +// same status object they use for login, so both forms must be recognised. +inline bool json_result_status_ok(const std::string &json) { + if (!json_root_object_is_valid(json)) return false; + size_t position = 0; + if (!json_find_root_value(json, "result", position)) return false; + if (position >= json.size() || json[position] != '{') return false; + std::string object; + size_t object_end = 0; + if (!json_container_slice(json, position, object, &object_end) || + !json_value_has_delimiter(json, object_end)) { + return false; + } + + size_t status_position = 0; + if (!json_find_root_value(object, "status", status_position)) return false; + std::string status; + size_t status_end = 0; + if (!json_string_at(object, status_position, status, &status_end) || + !json_value_has_delimiter(object, status_end)) { + return false; + } + return status.size() == 2 && + (status[0] == 'O' || status[0] == 'o') && + (status[1] == 'K' || status[1] == 'k'); +} + class LineBuffer { public: template diff --git a/tests/start_c29_launcher_test.ps1 b/tests/start_c29_launcher_test.ps1 new file mode 100644 index 0000000..aa10c56 --- /dev/null +++ b/tests/start_c29_launcher_test.ps1 @@ -0,0 +1,96 @@ +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Definition) +$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ( + 'tari-c29-launcher-test-' + [Guid]::NewGuid().ToString('N') +) + +function New-TestRoot { + param([string]$Name) + $caseRoot = Join-Path $tempRoot $Name + New-Item -ItemType Directory -Path (Join-Path $caseRoot 'bin') -Force | + Out-Null + Copy-Item (Join-Path $root 'start-c29.ps1') $caseRoot + @' +@echo off +echo 0, 12.0, Test GPU 0 +echo 1, 8.9, Test GPU 1 +'@ | Set-Content -LiteralPath (Join-Path $caseRoot 'nvidia-smi.cmd') -Encoding Ascii + return $caseRoot +} + +function Invoke-Launcher { + param([string]$CaseRoot) + $env:TARI_WALLET = 'test-login' + $env:TARI_NVIDIA_SMI = Join-Path $CaseRoot 'nvidia-smi.cmd' + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File (Join-Path $CaseRoot 'start-c29.ps1') 2>&1 + return [pscustomobject]@{ + Code = $LASTEXITCODE + Output = ($output -join [Environment]::NewLine) + } +} + +try { + New-Item -ItemType Directory -Path $tempRoot | Out-Null + $workerSource = @' +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; + +public static class LauncherTestWorker { + public static int Main(string[] args) { + string name = Path.GetFileNameWithoutExtension( + Process.GetCurrentProcess().MainModule.FileName + ); + string arch = name.EndsWith("sm_120", StringComparison.OrdinalIgnoreCase) + ? "SM120" + : "SM89"; + Thread.Sleep(Int32.Parse( + Environment.GetEnvironmentVariable("TARI_TEST_" + arch + "_DELAY") + )); + return Int32.Parse( + Environment.GetEnvironmentVariable("TARI_TEST_" + arch + "_CODE") + ); + } +} +'@ + $worker = Join-Path $tempRoot 'worker.exe' + Add-Type -TypeDefinition $workerSource -OutputAssembly $worker ` + -OutputType ConsoleApplication + + $missingRoot = New-TestRoot 'missing' + Copy-Item $worker ( + Join-Path $missingRoot 'bin\tari_c29_pool_miner_sm_120.exe' + ) + $env:TARI_TEST_SM120_DELAY = '20' + $env:TARI_TEST_SM120_CODE = '0' + $missing = Invoke-Launcher $missingRoot + if ($missing.Code -ne 5) { + throw "Expected missing backend exit 5, got $($missing.Code).`n$($missing.Output)" + } + + $orderRoot = New-TestRoot 'failure-order' + Copy-Item $worker ( + Join-Path $orderRoot 'bin\tari_c29_pool_miner_sm_120.exe' + ) + Copy-Item $worker ( + Join-Path $orderRoot 'bin\tari_c29_pool_miner_sm_89.exe' + ) + $env:TARI_TEST_SM120_DELAY = '500' + $env:TARI_TEST_SM120_CODE = '5' + $env:TARI_TEST_SM89_DELAY = '100' + $env:TARI_TEST_SM89_CODE = '4' + $ordered = Invoke-Launcher $orderRoot + if ($ordered.Code -ne 4) { + throw "Expected earliest worker exit 4, got $($ordered.Code).`n$($ordered.Output)" + } + + Write-Host 'PowerShell launcher regressions passed' + $global:LASTEXITCODE = 0 +} +finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } +} diff --git a/tests/start_c29_launcher_test.sh b/tests/start_c29_launcher_test.sh new file mode 100755 index 0000000..a6a185b --- /dev/null +++ b/tests/start_c29_launcher_test.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/tari-c29-launcher-test.XXXXXX")" +SURVIVOR_PID_FILE="$TEMP_ROOT/survivor.pid" + +cleanup() { + if [[ -f "$SURVIVOR_PID_FILE" ]]; then + survivor_pid="$(cat "$SURVIVOR_PID_FILE")" + if [[ "$survivor_pid" =~ ^[0-9]+$ ]]; then + kill -KILL "$survivor_pid" 2>/dev/null || true + fi + fi + rm -rf -- "$TEMP_ROOT" +} +trap cleanup EXIT + +make_case() { + local name="$1" + local case_root="$TEMP_ROOT/$name" + mkdir -p "$case_root/bin" + cp "$ROOT/start-c29.sh" "$case_root/start-c29.sh" + chmod +x "$case_root/start-c29.sh" + printf '%s' "$case_root" +} + +write_gpu_list() { + local destination="$1" + cat > "$destination" <<'EOF' +#!/usr/bin/env bash +printf '0, 12.0, Test GPU 0\n1, 8.9, Test GPU 1\n' +EOF + chmod +x "$destination" +} + +run_launcher() { + local case_root="$1" + set +e + TARI_WALLET=test-login \ + TARI_NVIDIA_SMI="$case_root/nvidia-smi" \ + SURVIVOR_PID_FILE="$SURVIVOR_PID_FILE" \ + timeout 10s "$case_root/start-c29.sh" >"$case_root/output.log" 2>&1 + launcher_status=$? + set -e +} + +missing_root="$(make_case missing)" +write_gpu_list "$missing_root/nvidia-smi" +cat > "$missing_root/bin/tari_c29_pool_miner_sm_120" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +chmod +x "$missing_root/bin/tari_c29_pool_miner_sm_120" +run_launcher "$missing_root" +if ((launcher_status != 5)); then + cat "$missing_root/output.log" + echo "expected missing backend exit 5, got $launcher_status" >&2 + exit 1 +fi + +wedged_root="$(make_case wedged)" +write_gpu_list "$wedged_root/nvidia-smi" +cat > "$wedged_root/bin/tari_c29_pool_miner_sm_120" <<'EOF' +#!/usr/bin/env bash +sleep 0.1 +exit 5 +EOF +cat > "$wedged_root/bin/tari_c29_pool_miner_sm_89" <<'EOF' +#!/usr/bin/env bash +trap '' TERM +printf '%s\n' "$$" > "$SURVIVOR_PID_FILE" +while :; do sleep 1; done +EOF +chmod +x \ + "$wedged_root/bin/tari_c29_pool_miner_sm_120" \ + "$wedged_root/bin/tari_c29_pool_miner_sm_89" +run_launcher "$wedged_root" +if ((launcher_status != 5)); then + cat "$wedged_root/output.log" + echo "expected worker exit 5, got $launcher_status" >&2 + exit 1 +fi +survivor_pid="$(cat "$SURVIVOR_PID_FILE")" +if kill -0 "$survivor_pid" 2>/dev/null; then + cat "$wedged_root/output.log" + echo "launcher left wedged worker $survivor_pid running" >&2 + exit 1 +fi +rm -f -- "$SURVIVOR_PID_FILE" + +echo "shell launcher regressions passed" diff --git a/tests/tari_miner_reliability_test.cpp b/tests/tari_miner_reliability_test.cpp index f80fde8..23bfde0 100644 --- a/tests/tari_miner_reliability_test.cpp +++ b/tests/tari_miner_reliability_test.cpp @@ -47,6 +47,109 @@ static void test_wallet_validation() { tari_miner::WalletValidationError::WhitespaceOrControl, "embedded control byte is rejected"); } + + check(tari_miner::validate_wallet("") == + tari_miner::WalletValidationError::Empty, + "an empty wallet is rejected"); + check(tari_miner::validate_wallet( + std::string(tari_miner::TARI_HEX_MAX_LENGTH, 'a')) == + tari_miner::WalletValidationError::None, + "the longest hex address length is accepted"); + + std::string max_emoji_address; + for (size_t i = 0; i < tari_miner::TARI_DUAL_INTERNAL_MAX_SIZE; ++i) + max_emoji_address += "\xf0\x9f\x90\xa2"; + check(max_emoji_address.size() == + tari_miner::TARI_EMOJI_MAX_UTF8_LENGTH && + tari_miner::validate_wallet(max_emoji_address) == + tari_miner::WalletValidationError::None, + "the longest UTF-8 emoji address length is accepted"); + + std::string grouped_emoji_address; + for (size_t i = 0; i < tari_miner::TARI_DUAL_INTERNAL_MAX_SIZE; ++i) { + if (i) grouped_emoji_address.push_back('|'); + grouped_emoji_address += "\xf0\x9f\x90\xa2"; + } + check(grouped_emoji_address.size() == + tari_miner::TARI_GROUPED_EMOJI_MAX_UTF8_LENGTH && + tari_miner::validate_wallet(grouped_emoji_address) == + tari_miner::WalletValidationError::None, + "the longest pipe-grouped emoji address length is accepted"); + check(tari_miner::validate_wallet(std::string(444, 'z')) == + tari_miner::WalletValidationError::None, + "a long generic pool login is accepted"); + check(tari_miner::validate_wallet( + std::string(tari_miner::MAX_WALLET_LENGTH + 1, 'a')) == + tari_miner::WalletValidationError::TooLong, + "an overlong wallet or login is rejected"); +} + +static void test_tari_address_charset() { + std::puts("Tari address charset:"); + // A dual address is 89-443 Base58 characters; a single address is 45-48. + // The filler is deliberately not a hex digit: a login made only of [0-9a-f] + // is exempt from the charset rule, and a real address is not hex. + const std::string dual = "f2" + std::string(89, 'z'); + const std::string single = "f2" + std::string(44, 'z'); + check(tari_miner::validate_wallet(dual) == + tari_miner::WalletValidationError::None, + "a dual-length Base58 address is accepted"); + check(tari_miner::validate_wallet(single) == + tari_miner::WalletValidationError::None, + "a single-length Base58 address is accepted"); + + for (char typo : std::string("0OIl")) { + std::string address = dual; + address[40] = typo; + check(tari_miner::validate_wallet(address) == + tari_miner::WalletValidationError::TariAddressCharset, + "an address-shaped login with a non-Base58 character is rejected"); + + std::string short_address = single; + short_address[20] = typo; + check(tari_miner::validate_wallet(short_address) == + tari_miner::WalletValidationError::TariAddressCharset, + "a single-length address with a non-Base58 character is rejected"); + } + + std::string generic_single = "12" + std::string(44, 'z'); + generic_single[20] = '0'; + check(tari_miner::validate_wallet(generic_single) == + tari_miner::WalletValidationError::TariAddressCharset && + !tari_miner::wallet_validation_is_fatal( + tari_miner::validate_wallet(generic_single)), + "an ambiguous address-length login warns but is not rejected"); + std::string generic_dual = "f2" + std::string(89, 'z'); + generic_dual[40] = 'O'; + check(tari_miner::validate_wallet(generic_dual) == + tari_miner::WalletValidationError::TariAddressCharset && + !tari_miner::wallet_validation_is_fatal( + tari_miner::validate_wallet(generic_dual)), + "a long ambiguous login warns but is not rejected"); + std::string prefix_typo = "fO" + std::string(89, 'z'); + check(tari_miner::validate_wallet(prefix_typo) == + tari_miner::WalletValidationError::TariAddressCharset, + "a Base58 typo in the Tari prefix is detected"); + + // Lengths between and beyond the address ranges are logins, not addresses. + for (size_t length : {size_t(44), size_t(60), size_t(88)}) { + std::string login(length, '0'); + check(tari_miner::validate_wallet(login) == + tari_miner::WalletValidationError::None, + "a login that is not address-shaped skips the charset rule"); + } + + std::string with_symbol = dual; + with_symbol[40] = '-'; + check(tari_miner::validate_wallet(with_symbol) == + tari_miner::WalletValidationError::None, + "a punctuated login of address length is not treated as an address"); + check(tari_miner::validate_wallet(std::string(96, '0')) == + tari_miner::WalletValidationError::None, + "an all-hex login of address length is not treated as an address"); + check(tari_miner::validate_wallet("custom-pool-login") == + tari_miner::WalletValidationError::None, + "a short custom login is still accepted"); } static void test_pool_response_classification() { @@ -116,6 +219,117 @@ static void test_pool_response_classification() { "an unrelated pool error is not counted as a share reject"); } +static void test_pending_submit_bound() { + std::puts("Pending submit bound:"); + tari_miner::PoolResponseTracker tracker; + uint64_t first = tracker.begin_submit(); + for (size_t i = 1; i < tari_miner::MAX_PENDING_SUBMITS; ++i) + tracker.begin_submit(); + check(tracker.pending_submits() == tari_miner::MAX_PENDING_SUBMITS, + "the tracker fills to its bound"); + + uint64_t newest = tracker.begin_submit(); + check(tracker.pending_submits() == tari_miner::MAX_PENDING_SUBMITS, + "a silent pool cannot grow the pending set without limit"); + check(tracker.classify(true, first, false, true, true, false, false) == + tari_miner::PoolResponseKind::Other, + "the oldest submit is the one forgotten at the bound"); + check(tracker.classify(true, newest, false, true, true, false, false) == + tari_miner::PoolResponseKind::ShareAccepted, + "the newest submit is still tracked"); +} + +static void test_status_ok_acceptance() { + std::puts("Status-object share acceptance:"); + using tari_miner::PoolResponseKind; + tari_miner::PoolResponseTracker tracker; + uint64_t submit = tracker.begin_submit(); + check(tracker.classify(true, submit, false, true, false, false, false, true) == + PoolResponseKind::ShareAccepted, + "a submit answered with a status object counts as accepted"); + + tari_miner::PoolResponseTracker login_tracker; + check(login_tracker.classify( + true, tari_miner::LOGIN_REQUEST_ID, false, true, false, false, + true, true) == PoolResponseKind::Other, + "the login status object is not counted as an accepted share"); + + uint64_t rejected = tracker.begin_submit(); + check(tracker.classify(true, rejected, true, true, false, false, false, true) == + PoolResponseKind::ShareRejected, + "an error outranks a status object on the same response"); +} + +static void test_pool_silence_policy() { + std::puts("Pool silence policy:"); + check(tari_miner::POOL_SILENT_EXIT_CODE == 6, + "an unresponsive pool uses exit code 6"); + tari_miner::PoolSilencePolicy policy; + check(!policy.record_silence() && policy.consecutive_silences() == 1, + "the first silent connection retries"); + check(policy.backoff_seconds() == tari_miner::FIRST_SILENT_BACKOFF_SECONDS, + "the first retry uses the base backoff"); + check(!policy.record_silence() && + policy.backoff_seconds() == + tari_miner::FIRST_SILENT_BACKOFF_SECONDS * 2, + "the backoff doubles"); + + for (unsigned i = 0; i < 6; ++i) policy.record_silence(); + check(policy.backoff_seconds() == tari_miner::MAX_SILENT_BACKOFF_SECONDS, + "the backoff is capped"); + + policy.record_job(); + check(policy.consecutive_silences() == 0 && + policy.backoff_seconds() == + tari_miner::FIRST_SILENT_BACKOFF_SECONDS, + "a received job resets the policy"); + + tari_miner::PoolSilencePolicy fatal_policy; + bool fatal = false; + for (unsigned i = 0; i < tari_miner::MAX_SILENT_CYCLES; ++i) + fatal = fatal_policy.record_silence(); + check(fatal, "a persistently silent pool eventually exits"); + + using tari_miner::JobWaitOutcome; + check(tari_miner::counts_as_pool_silence(JobWaitOutcome::Timeout), + "a connected no-job timeout counts as pool silence"); + check(!tari_miner::counts_as_pool_silence(JobWaitOutcome::Disconnected) && + !tari_miner::counts_as_pool_silence(JobWaitOutcome::ProtocolError) && + !tari_miner::counts_as_pool_silence(JobWaitOutcome::LoginRejected), + "disconnects and protocol/login errors are not pool silence"); + tari_miner::PoolSilencePolicy interrupted; + interrupted.record_silence(); + interrupted.reset(); + check(interrupted.consecutive_silences() == 0, + "non-silent activity breaks a silence streak"); +} + +static void test_protocol_error_policy() { + std::puts("Pool protocol error policy:"); + check(tari_miner::POOL_PROTOCOL_EXIT_CODE == 7, + "a persistently invalid pool uses exit code 7"); + tari_miner::ProtocolErrorPolicy policy; + check(!policy.record_failure() && !policy.record_failure(), + "the first two protocol errors retry"); + check(policy.record_failure() && + policy.consecutive_failures() == + tari_miner::MAX_PROTOCOL_ERRORS, + "the third consecutive protocol error exits"); + policy.record_valid_job(1); + check(policy.consecutive_failures() == tari_miner::MAX_PROTOCOL_ERRORS, + "an initial job on a retry does not reset protocol errors"); + policy.record_valid_job(2); + check(policy.consecutive_failures() == 0, + "a valid job update resets protocol errors"); + policy.record_failure(); + policy.record_valid_job(2); + check(!policy.record_failure() && policy.consecutive_failures() == 1, + "a valid update observed on the error path resets before counting"); + policy.reset(); + check(policy.consecutive_failures() == 0, + "a clean connection or valid job update resets protocol errors"); +} + static void test_login_failure_policy() { std::puts("Login failure policy:"); check(tari_miner::LOGIN_FAILURE_EXIT_CODE == 4, @@ -172,7 +386,12 @@ static void test_solver_watchdog() { int main() { test_wallet_validation(); + test_tari_address_charset(); test_pool_response_classification(); + test_pending_submit_bound(); + test_status_ok_acceptance(); + test_pool_silence_policy(); + test_protocol_error_policy(); test_login_failure_policy(); test_solver_watchdog(); std::printf("\n%s (%d failure%s)\n", diff --git a/tests/tari_pool_protocol_test.cpp b/tests/tari_pool_protocol_test.cpp index 7a3e5e2..1738fd7 100644 --- a/tests/tari_pool_protocol_test.cpp +++ b/tests/tari_pool_protocol_test.cpp @@ -223,10 +223,114 @@ static void test_socket_state() { "concurrent stop preserves socket lifecycle order"); } +static void test_strict_uint_parsing() { + std::puts("Strict unsigned parsing:"); + uint64_t value = 7; + check(tari_pool::json_uint_from("{\"job\":{\"height\":42}}", "height", value) && + value == 42, + "a nested unsigned value is read"); + + value = 7; + check(!tari_pool::json_uint_from("{\"job\":{\"height\":-1}}", "height", value) && + value == 7, + "a negative height is rejected rather than wrapping"); + check(!tari_pool::json_uint_from("{\"job\":{\"height\":+1}}", "height", value), + "an explicitly signed height is rejected"); + check(!tari_pool::json_uint_from("{\"job\":{\"height\":\"12\"}}", "height", value), + "a quoted height is not an unsigned value"); + check(!tari_pool::json_uint_from("{\"job\":{\"height\":12x}}", "height", value), + "trailing garbage is rejected"); + check(!tari_pool::json_root_uint( + "{\"id\":4 garbage,\"result\":true}", "id", value), + "whitespace followed by garbage is rejected"); + check(!tari_pool::json_root_uint("{\"id\":04}", "id", value), + "a leading-zero integer is rejected"); + check(!tari_pool::json_uint_from( + "{\"job\":{\"height\":99999999999999999999999}}", "height", value), + "an overflowing height is rejected"); + + value = 0; + check(tari_pool::json_uint_from( + "{\"job\":{\"height\":18446744073709551615}}", "height", value) && + value == std::numeric_limits::max(), + "the largest unsigned value still parses"); +} + +static void test_result_status_ok() { + std::puts("Submit result status:"); + check(tari_pool::json_root_object_is_valid( + "{\"id\":4,\"result\":true,\"error\":null}"), + "a normal bare-true response is valid JSON"); + check(!tari_pool::json_root_object_is_valid( + "{\"id\":4,,\"result\":true}"), + "a malformed bare-true response is invalid JSON"); + check(!tari_pool::json_root_object_is_valid( + "{\"id\":4,\"result\":true garbage}"), + "garbage after a bare result is invalid JSON"); + check(tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"status\":\"OK\"},\"error\":null}"), + "a status object is recognised as success"); + check(tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"status\":\"ok\"}}"), + "status matching is case-insensitive"); + check(tari_pool::json_result_status_ok( + "{\"id\":1,\"result\":{\"id\":\"s\",\"job\":{\"status\":\"NO\"}," + "\"status\":\"OK\"}}"), + "a nested job object does not hide the root status"); + check(tari_pool::json_result_status_ok( + "{\"meta\":[null,true,false,-1.25e+2],\"result\":{" + "\"note\":\"line\\n\\u0041\",\"status\":\"OK\"}}"), + "valid JSON values and escapes remain accepted"); + + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"status\":\"KO\"}}"), + "a non-OK status is not success"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"job\":{\"status\":\"OK\"}}}"), + "a status nested below the result is not the result status"); + check(!tari_pool::json_result_status_ok("{\"id\":4,\"result\":true}"), + "a boolean result has no status object"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"error\":{\"status\":\"OK\"}}"), + "a status inside an error is not success"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"note\":\"} \\\" {\",\"status\":\"OK\""), + "an unterminated result object is not success"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"status\":\"OK\"]}"), + "mismatched result delimiters are not success"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"status\":\"OK\"junk}}"), + "garbage after the status string is not success"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"status\":\"OK\"}junk}"), + "garbage after the result object is not success"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"status\":\"O\\K\"}}"), + "an invalid string escape is not success"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"status\":\"OK\"}}junk"), + "garbage after the root object is not success"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"status\":\"OK\"}]"), + "a mismatched root delimiter is not success"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"note\":\"\\K\",\"status\":\"OK\"}}"), + "an invalid escape elsewhere in the response is not success"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,,\"result\":{\"status\":\"OK\"}}"), + "consecutive object commas are not success"); + check(!tari_pool::json_result_status_ok( + "{\"id\":4,\"result\":{\"status\":\"OK\"},}"), + "a trailing object comma is not success"); +} + int main() { test_target_conversion(); test_terminal_sanitizing(); test_json_root_fields(); + test_strict_uint_parsing(); + test_result_status_ok(); test_line_buffer(); test_socket_state(); std::printf("\n%s (%d failure%s)\n",