Skip to content

Commit 3085b73

Browse files
committed
stratum: reap idle connections (SO_RCVTIMEO + TCP keepalive)
The recv loop previously blocked forever with no timeout, so half-open TCPs from crashed miners and clients that authenticate but never submit sat in ESTABLISHED state indefinitely — each holding a pthread, fd, and per-conn state. Observed on the live pool: 22 stale sockets vs 1 active miner. New idle_timeout_sec config (default 600). Every accepted socket gets: - SO_KEEPALIVE + TCP_KEEPIDLE=120 / TCP_KEEPINTVL=30 / TCP_KEEPCNT=3 (OS drops truly-dead TCPs after ~3.5 min of unacked probes) - SO_RCVTIMEO clamped to min(idle_timeout_sec, 30s) so recv() wakes and the loop can compare last_activity_ms against the budget Set idle_timeout_sec < 0 to disable and get the old blocking behaviour. Tests verify SO_RCVTIMEO is applied with the correct clamp, and that idle_timeout_sec = 0 leaves the socket in blocking mode.
1 parent 0c7147a commit 3085b73

6 files changed

Lines changed: 149 additions & 3 deletions

File tree

src/config.c

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ void proxy_config_defaults(proxy_config_t *cfg) {
4848
cfg->vardiff_min = 1.0;
4949
cfg->vardiff_max = 1e12;
5050
cfg->vardiff_window_sec = 30;
51+
cfg->idle_timeout_sec = 600; /* 10 min silent recv → reap */
5152

5253
cfg->redis_url[0] = '\0';
5354
cfg->redis_publish_timeout_ms = 200;
@@ -149,6 +150,7 @@ int proxy_config_load(const char *path, proxy_config_t *cfg,
149150
else if (strcmp(k, "vardiff_min") == 0) cfg->vardiff_min = atof(v);
150151
else if (strcmp(k, "vardiff_max") == 0) cfg->vardiff_max = atof(v);
151152
else if (strcmp(k, "vardiff_window_sec") == 0) cfg->vardiff_window_sec = atoi(v);
153+
else if (strcmp(k, "idle_timeout_sec") == 0) cfg->idle_timeout_sec = atoi(v);
152154
else if (strcmp(k, "db_path") == 0) copy_str(cfg->db_path, sizeof cfg->db_path, v);
153155
else if (strcmp(k, "commit_window_ms") == 0) cfg->commit_window_ms = atoi(v);
154156
else if (strcmp(k, "commit_max_shares") == 0) cfg->commit_max_shares = atoi(v);

src/config.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ typedef struct {
1919
double vardiff_max; /* default 1e12; clamped by network diff */
2020
int vardiff_window_sec; /* retarget interval, default 30 */
2121

22+
/* Idle-connection reaper. A connection that hasn't sent any bytes in
23+
* idle_timeout_sec is closed. Guards against half-open TCPs from
24+
* crashed miners and clients that connect but never authenticate.
25+
* Set to a negative value to disable entirely; 0 uses the default. */
26+
int idle_timeout_sec; /* default 600 (10 min) */
27+
2228
/* bitcoind */
2329
char bitcoind_url[512];
2430
char bitcoind_user[128];

src/main.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,7 @@ int main(int argc, char **argv) {
533533
stcfg.vardiff_min = cfg.vardiff_min;
534534
stcfg.vardiff_max = cfg.vardiff_max;
535535
stcfg.vardiff_window_sec = cfg.vardiff_window_sec;
536+
stcfg.idle_timeout_sec = cfg.idle_timeout_sec;
536537

537538
/* PPS / Thunder. Both pps and pps-classic modes require Thunder-address
538539
* usernames and produce PPS accruals; they differ only in the coinbase

src/stratum.c

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ struct stratum_conn {
212212
uint64_t vd_window_start_ms;
213213
uint32_t vd_window_shares;
214214

215+
/* Monotonic timestamp of the most recent recv() that got any bytes.
216+
* The conn thread checks this against cfg.idle_timeout_sec after each
217+
* SO_RCVTIMEO wake-up so silent connections are reaped. */
218+
uint64_t last_activity_ms;
219+
215220
pthread_mutex_t write_lock;
216221

217222
struct stratum_conn *next; /* server->conns_head linked list */
@@ -1160,6 +1165,53 @@ static int write_all(int fd, const char *buf, size_t len) {
11601165
return 0;
11611166
}
11621167

1168+
/* Configure an accepted socket so idle miners get reaped instead of
1169+
* clogging fds/threads indefinitely. Two mechanisms, belt-and-suspenders:
1170+
*
1171+
* 1. Application-level: SO_RCVTIMEO gives recv() a bounded wake so we
1172+
* can compare last_activity_ms against cfg.idle_timeout_sec. Catches
1173+
* miners that hold the TCP open but never send anything (e.g. bad
1174+
* username, misconfigured worker).
1175+
* 2. OS-level: SO_KEEPALIVE + tightened TCP_KEEPIDLE/INTVL/CNT so Linux
1176+
* drops the socket after ~5 min of unacked probes. Catches half-open
1177+
* TCPs where the miner box vanished from the network without FIN.
1178+
*
1179+
* idle_timeout_sec <= 0 disables the read-timeout path (legacy blocking
1180+
* recv). Returns 0 on success, -1 on fatal setsockopt failure. */
1181+
static int conn_socket_setup(int fd, int idle_timeout_sec) {
1182+
int one = 1;
1183+
/* TCP_NODELAY: stratum is tiny latency-sensitive JSON, don't Nagle. */
1184+
(void)setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
1185+
1186+
/* Kernel keepalive. Default kernel setting is ~2h before probes even
1187+
* start, useless for our purposes — override with tight values. */
1188+
(void)setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof(one));
1189+
#ifdef TCP_KEEPIDLE
1190+
int idle = 120; /* start probing after 2 min of inactivity */
1191+
int intvl = 30; /* probe every 30s */
1192+
int cnt = 3; /* drop after 3 unacked probes ≈ 3.5 min total */
1193+
(void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
1194+
(void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl));
1195+
(void)setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));
1196+
#endif
1197+
1198+
if (idle_timeout_sec > 0) {
1199+
/* Poll interval: min(idle_timeout, 30s). Longer wastes the tail
1200+
* of the timeout; shorter costs one recv wake per fd per interval
1201+
* (500 conns × wake/30s = ~16/s, negligible). */
1202+
int poll_s = idle_timeout_sec < 30 ? idle_timeout_sec : 30;
1203+
struct timeval tv = { .tv_sec = poll_s, .tv_usec = 0 };
1204+
if (setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) {
1205+
return -1;
1206+
}
1207+
}
1208+
return 0;
1209+
}
1210+
1211+
int stratum_socket_setup_for_test(int fd, int idle_timeout_sec) {
1212+
return conn_socket_setup(fd, idle_timeout_sec);
1213+
}
1214+
11631215
static void conn_register(stratum_server_t *s, stratum_conn_t *c) {
11641216
pthread_mutex_lock(&s->conns_lock);
11651217
c->next = s->conns_head;
@@ -1183,9 +1235,34 @@ static void *conn_thread(void *arg) {
11831235
char buf[MAX_LINE_BYTES + 1];
11841236
size_t blen = 0;
11851237

1238+
/* Seed activity tracking at connect time — a client that never sends
1239+
* a single byte is still governed by cfg.idle_timeout_sec. */
1240+
c->last_activity_ms = mono_ms();
1241+
const uint64_t idle_timeout_ms =
1242+
s->cfg.idle_timeout_sec > 0
1243+
? (uint64_t)s->cfg.idle_timeout_sec * 1000u
1244+
: 0;
1245+
11861246
while (!atomic_load(&s->stop)) {
11871247
ssize_t n = recv(c->fd, buf + blen, sizeof(buf) - 1 - blen, 0);
1188-
if (n <= 0) break;
1248+
if (n < 0) {
1249+
if (errno == EINTR) continue;
1250+
if (errno == EAGAIN || errno == EWOULDBLOCK) {
1251+
/* SO_RCVTIMEO wake. Drop iff we've been silent past the
1252+
* configured budget. Otherwise loop and try again. */
1253+
if (idle_timeout_ms > 0 &&
1254+
mono_ms() - c->last_activity_ms > idle_timeout_ms) {
1255+
LOG_INFO("stratum: idle timeout after %us — closing fd=%d worker='%s'",
1256+
s->cfg.idle_timeout_sec, c->fd,
1257+
c->worker_name[0] ? c->worker_name : "(unauthorized)");
1258+
goto done;
1259+
}
1260+
continue;
1261+
}
1262+
break; /* real socket error */
1263+
}
1264+
if (n == 0) break; /* peer closed */
1265+
c->last_activity_ms = mono_ms();
11891266
blen += (size_t)n;
11901267
buf[blen] = '\0';
11911268
for (;;) {
@@ -1234,8 +1311,12 @@ static void *listener_thread(void *arg) {
12341311
close(fd);
12351312
continue;
12361313
}
1237-
int one = 1;
1238-
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
1314+
if (conn_socket_setup(fd, s->cfg.idle_timeout_sec) < 0) {
1315+
LOG_WARN("stratum: socket setup failed for accepted fd: %s",
1316+
strerror(errno));
1317+
close(fd);
1318+
continue;
1319+
}
12391320
stratum_conn_t *c = stratum_conn_new_for_test(s);
12401321
if (!c) { close(fd); continue; }
12411322
c->fd = fd;
@@ -1261,6 +1342,9 @@ int stratum_server_start(const stratum_cfg_t *cfg, stratum_server_t **out) {
12611342
s->cfg = *cfg;
12621343
if (s->cfg.max_conns <= 0) s->cfg.max_conns = 500;
12631344
if (s->cfg.initial_diff <= 0) s->cfg.initial_diff = 1.0;
1345+
/* Negative → explicit disable. 0 → apply default (10 min). Positive kept. */
1346+
if (s->cfg.idle_timeout_sec == 0) s->cfg.idle_timeout_sec = 600;
1347+
else if (s->cfg.idle_timeout_sec < 0) s->cfg.idle_timeout_sec = 0;
12641348
pthread_rwlock_init(&s->job_lock, NULL);
12651349
pthread_mutex_init(&s->recent_lock, NULL);
12661350
pthread_mutex_init(&s->conns_lock, NULL);

src/stratum.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ typedef struct {
103103
double vardiff_max;
104104
int vardiff_window_sec;
105105

106+
/* Drop a connection whose recv() has been silent for this long. Guards
107+
* against half-open TCPs from crashed miners and misconfigured clients
108+
* that connect but never authenticate. 0 disables (legacy). Default 600. */
109+
int idle_timeout_sec;
110+
106111
void *ctx;
107112
share_observer_fn on_share;
108113
reject_observer_fn on_reject;
@@ -137,6 +142,12 @@ const char *stratum_conn_payout_address_for_test(const stratum_conn_t *c);
137142
int stratum_conn_authorized_for_test(const stratum_conn_t *c);
138143
int stratum_conn_subscribed_for_test(const stratum_conn_t *c);
139144

145+
/* Apply the same socket options the listener applies to every accepted
146+
* connection: TCP_NODELAY, SO_KEEPALIVE + TCP_KEEP{IDLE,INTVL,CNT}, and
147+
* SO_RCVTIMEO (poll interval derived from idle_timeout_sec). Exposed for
148+
* tests. */
149+
int stratum_socket_setup_for_test(int fd, int idle_timeout_sec);
150+
140151
/* Process one JSON-RPC line. Appends one or more newline-delimited JSON
141152
* messages to *out_buf (caller-owned, will be realloc'd). Returns 0 on
142153
* success, negative on protocol error (caller should disconnect). */

tests/test_stratum.c

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
#include <stdio.h>
77
#include <stdlib.h>
88
#include <string.h>
9+
#include <sys/socket.h>
10+
#include <sys/time.h>
11+
#include <unistd.h>
912

1013
static int g_pass = 0;
1114
static int g_fail = 0;
@@ -280,13 +283,52 @@ static void test_authorize_address_with_label(void) {
280283
stratum_server_free(s);
281284
}
282285

286+
/* Idle-socket reaper: verify the accepted-socket setup path applies
287+
* SO_RCVTIMEO derived from idle_timeout_sec. We can't cheaply test the
288+
* "silent client gets dropped" path in a unit test — that would need a
289+
* real 3s+ sleep — but confirming SO_RCVTIMEO is present is enough to
290+
* guarantee the recv loop wakes and gets to check last_activity_ms. */
291+
static void test_socket_setup_applies_rcvtimeo(void) {
292+
int sp[2];
293+
CHECK(socketpair(AF_UNIX, SOCK_STREAM, 0, sp) == 0);
294+
/* idle_timeout=45 → poll interval clamped to 30s (SO_RCVTIMEO ceiling). */
295+
CHECK(stratum_socket_setup_for_test(sp[0], 45) == 0);
296+
struct timeval tv = {0};
297+
socklen_t len = sizeof tv;
298+
CHECK(getsockopt(sp[0], SOL_SOCKET, SO_RCVTIMEO, &tv, &len) == 0);
299+
CHECK(tv.tv_sec == 30);
300+
/* idle_timeout=5 → poll interval matches (below the 30s clamp). */
301+
CHECK(stratum_socket_setup_for_test(sp[1], 5) == 0);
302+
struct timeval tv2 = {0};
303+
socklen_t len2 = sizeof tv2;
304+
CHECK(getsockopt(sp[1], SOL_SOCKET, SO_RCVTIMEO, &tv2, &len2) == 0);
305+
CHECK(tv2.tv_sec == 5);
306+
close(sp[0]);
307+
close(sp[1]);
308+
}
309+
310+
/* idle_timeout_sec=0 leaves SO_RCVTIMEO unset — legacy blocking recv. */
311+
static void test_socket_setup_disabled(void) {
312+
int sp[2];
313+
CHECK(socketpair(AF_UNIX, SOCK_STREAM, 0, sp) == 0);
314+
CHECK(stratum_socket_setup_for_test(sp[0], 0) == 0);
315+
struct timeval tv = {0};
316+
socklen_t len = sizeof tv;
317+
CHECK(getsockopt(sp[0], SOL_SOCKET, SO_RCVTIMEO, &tv, &len) == 0);
318+
CHECK(tv.tv_sec == 0 && tv.tv_usec == 0);
319+
close(sp[0]);
320+
close(sp[1]);
321+
}
322+
283323
int main(void) {
284324
test_subscribe();
285325
test_authorize_triggers_setdiff_notify();
286326
test_submit_unknown_job();
287327
test_submit_share_and_dedupe();
288328
test_authorize_rejects_non_address();
289329
test_authorize_address_with_label();
330+
test_socket_setup_applies_rcvtimeo();
331+
test_socket_setup_disabled();
290332
printf("test_stratum: %d passed, %d failed\n", g_pass, g_fail);
291333
return g_fail == 0 ? 0 : 1;
292334
}

0 commit comments

Comments
 (0)