@sproutboat/runtime 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +16 -7
- package/src/subtle/README.md +4 -4
- package/src/subtle/subtle.c +19 -7
- package/src/subtle/subtle.test.ts +1 -1
- package/src/transport-embedded.js +37 -12
- package/test/bearssl.ts +5 -4
- package/test/fixtures/async-fetch/README.md +9 -0
- package/test/fixtures/async-fetch/slow.ts +17 -0
- package/test/fixtures/async-fetch/sproutboat.jsonc +5 -0
- package/test/fixtures/async-fetch/src/index.js +50 -0
- package/test/standalone-restart-stress.py +348 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.41.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1eacf43: Secrets read from the environment (a standalone binary, a sproutd worker) are decoded as UTF-8 from the proposed compatibility date 2026-11-07. Before, the handler got the environment's bytes one char per byte, so `é` arrived as `é`. The broker transport is unchanged: its secrets already arrive as real strings. Bytes that are not valid UTF-8 pass through as before.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- f74835a: `crypto.subtle` signs and verifies P-256 (ES256 and ECDH) with BearSSL's 64-bit `ec_p256_m64` where the target has a 128-bit multiply, and keeps the portable code everywhere else (and for P-384 and P-521). BearSSL never picks m64 by itself, so the C asks for it with `br_ec_p256_m64_get()`, which returns null on a target without one. Through the runtime on macOS arm64, ES256 signs 2.2x faster (2,563 to 5,621 per second) and verifies 3.1x faster (1,318 to 4,050 per second). Results are unchanged, so there is no new compatibility date.
|
|
12
|
+
|
|
13
|
+
This needs the BearSSL that the CLI pins from the release that ships with it: m64 is not in BearSSL 0.6, so a CLI that still links 0.6 cannot link this runtime.
|
|
14
|
+
|
|
15
|
+
## 0.40.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 1ae7843: From the proposed compatibility date 2026-11-16, `fetch()` on the standalone binary runs off the event loop. It used to run each request to the end before it returned, so 20 concurrent fetches to a 100 ms endpoint took 2,000 ms and every other request waited behind them. They now go to a pool of up to 32 I/O threads (the same client sproutd runs on 8), the handler suspends, and the server keeps serving; the 20 fetches take about 100 ms. The reply is delivered on the loop through the uWS waker the standalone server already installs for outbound WebSockets, so JavaScript still runs on one thread. Earlier dates keep the synchronous call, because a handler that relied on one request at a time across an `await fetch()` now interleaves with others, as it does in workerd. Behaviour on sproutd and through the broker is unchanged.
|
|
20
|
+
|
|
3
21
|
## 0.39.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -9397,6 +9397,14 @@ static const sbc_u8 sbc_oid_x25519[] = { 0x2b, 0x65, 0x6e };
|
|
|
9397
9397
|
static int sbc_oid_eq(const sbc_u8* a, size_t al, const sbc_u8* b, size_t bl) { return al == bl && memcmp(a, b, al) == 0; }
|
|
9398
9398
|
|
|
9399
9399
|
static int sbc_curve_br(int c) { return c == 1 ? BR_EC_secp256r1 : c == 2 ? BR_EC_secp384r1 : c == 3 ? BR_EC_secp521r1 : 0; }
|
|
9400
|
+
|
|
9401
|
+
static const br_ec_impl* sbc_ec(int br_curve) {
|
|
9402
|
+
if (br_curve == BR_EC_secp256r1) {
|
|
9403
|
+
const br_ec_impl* fast = br_ec_p256_m64_get();
|
|
9404
|
+
if (fast) return fast;
|
|
9405
|
+
}
|
|
9406
|
+
return br_ec_get_default();
|
|
9407
|
+
}
|
|
9400
9408
|
static int sbc_curve_from_br(int c) { return c == BR_EC_secp256r1 ? 1 : c == BR_EC_secp384r1 ? 2 : c == BR_EC_secp521r1 ? 3 : 0; }
|
|
9401
9409
|
static size_t sbc_curve_size(int c) { return c == 1 ? 32 : c == 2 ? 48 : c == 3 ? 66 : 0; }
|
|
9402
9410
|
static void sbc_curve_oid(int c, const sbc_u8** o, size_t* n) {
|
|
@@ -9579,7 +9587,7 @@ static int sbc_decode(int pkcs8, const sbc_u8* der, size_t len, sbc_buf* out) {
|
|
|
9579
9587
|
if (cv) {
|
|
9580
9588
|
sbc_u8 pub[133];
|
|
9581
9589
|
br_ec_public_key pk;
|
|
9582
|
-
if (br_ec_compute_pub(
|
|
9590
|
+
if (br_ec_compute_pub(sbc_ec(sk->curve), &pk, pub, sk) != 0) {
|
|
9583
9591
|
sbc_put1(out, SBC_K_EC); sbc_put1(out, (sbc_u8)cv); sbc_put1(out, 1);
|
|
9584
9592
|
sbc_field(out, pk.q, pk.qlen);
|
|
9585
9593
|
|
|
@@ -9862,7 +9870,7 @@ static int sbc_ecdsa_sign(int bits, const sbc_u8* key, size_t kl, const sbc_u8*
|
|
|
9862
9870
|
sbc_u8 h[64], sig[132];
|
|
9863
9871
|
rc = SBC_OPERATION;
|
|
9864
9872
|
if (sbc_digest(bits, m, ml, h)) {
|
|
9865
|
-
size_t n = br_ecdsa_sign_raw_get_default()(
|
|
9873
|
+
size_t n = br_ecdsa_sign_raw_get_default()(sbc_ec(sk->curve), sbc_hash(bits), h, sk, sig);
|
|
9866
9874
|
if (n) { sbc_put(out, sig, n); rc = SBC_OK; }
|
|
9867
9875
|
}
|
|
9868
9876
|
free(dc);
|
|
@@ -9877,7 +9885,7 @@ static int sbc_ecdsa_verify(int bits, int curve, const sbc_u8* point, size_t pl,
|
|
|
9877
9885
|
if (sl != 2 * fs) return SBC_OK;
|
|
9878
9886
|
br_ec_public_key pk;
|
|
9879
9887
|
pk.curve = sbc_curve_br(curve); pk.q = (sbc_u8*)point; pk.qlen = pl;
|
|
9880
|
-
if (br_ecdsa_vrfy_raw_get_default()(
|
|
9888
|
+
if (br_ecdsa_vrfy_raw_get_default()(sbc_ec(pk.curve), h, sbc_hlen(bits), &pk, sig, sl)) *ok = 1;
|
|
9881
9889
|
return SBC_OK;
|
|
9882
9890
|
}
|
|
9883
9891
|
|
|
@@ -9887,12 +9895,12 @@ static int sbc_ec_point_ok(int curve, const sbc_u8* point, size_t pl) {
|
|
|
9887
9895
|
sbc_u8 q[133];
|
|
9888
9896
|
memcpy(q, point, pl);
|
|
9889
9897
|
sbc_u8 one[1] = { 1 };
|
|
9890
|
-
return
|
|
9898
|
+
return sbc_ec(sbc_curve_br(curve))->mul(q, pl, one, 1, sbc_curve_br(curve)) != 0;
|
|
9891
9899
|
}
|
|
9892
9900
|
static int sbc_ec_keygen(int curve, sbc_buf* out) {
|
|
9893
9901
|
br_ec_private_key sk; br_ec_public_key pk;
|
|
9894
9902
|
sbc_u8 kb[BR_EC_KBUF_PRIV_MAX_SIZE], pb[BR_EC_KBUF_PUB_MAX_SIZE];
|
|
9895
|
-
const br_ec_impl* impl =
|
|
9903
|
+
const br_ec_impl* impl = sbc_ec(sbc_curve_br(curve));
|
|
9896
9904
|
if (!br_ec_keygen(&sbc_prng, impl, &sk, kb, sbc_curve_br(curve))) return SBC_OPERATION;
|
|
9897
9905
|
if (!br_ec_compute_pub(impl, &pk, pb, &sk)) return SBC_OPERATION;
|
|
9898
9906
|
size_t need = br_encode_ec_pkcs8_der(0, &sk, &pk);
|
|
@@ -10059,8 +10067,9 @@ static int sbc_ecdh(int curve, const sbc_u8* pkcs8, size_t kl, const sbc_u8* pee
|
|
|
10059
10067
|
if (sk->curve == sbc_curve_br(curve) && fs && pl == 1 + 2 * fs && peer[0] == 4) {
|
|
10060
10068
|
sbc_u8 pt[133];
|
|
10061
10069
|
memcpy(pt, peer, pl);
|
|
10062
|
-
|
|
10063
|
-
|
|
10070
|
+
const br_ec_impl* impl = sbc_ec(sk->curve);
|
|
10071
|
+
if (impl->mul(pt, pl, sk->x, sk->xlen, sk->curve)) {
|
|
10072
|
+
size_t xl; size_t xo = impl->xoff(sk->curve, &xl);
|
|
10064
10073
|
sbc_put(out, pt + xo, xl); rc = SBC_OK;
|
|
10065
10074
|
}
|
|
10066
10075
|
}
|
package/src/subtle/README.md
CHANGED
|
@@ -12,10 +12,10 @@ uses it.
|
|
|
12
12
|
|
|
13
13
|
| Need | Source | Licence |
|
|
14
14
|
| --- | --- | --- |
|
|
15
|
-
| SHA-1/256/384/512, HMAC, HKDF | BearSSL
|
|
16
|
-
| RSA (private and public operations, key generation, PKCS#1 v1.5 padding), RSA-OAEP | BearSSL
|
|
17
|
-
| ECDSA and ECDH on P-256, P-384, P-521, EC key generation | BearSSL
|
|
18
|
-
| AES (constant-time), CBC, CTR, GCM | BearSSL
|
|
15
|
+
| SHA-1/256/384/512, HMAC, HKDF | BearSSL (master 7bea48e) | MIT |
|
|
16
|
+
| RSA (private and public operations, key generation, PKCS#1 v1.5 padding), RSA-OAEP | BearSSL (master 7bea48e) | MIT |
|
|
17
|
+
| ECDSA and ECDH on P-256, P-384, P-521, EC key generation | BearSSL (master 7bea48e) | MIT |
|
|
18
|
+
| AES (constant-time), CBC, CTR, GCM | BearSSL (master 7bea48e) | MIT |
|
|
19
19
|
| Ed25519, X25519 | Monocypher 4.0.2, vendored in `vendor/` | BSD-2-Clause or CC0-1.0 |
|
|
20
20
|
|
|
21
21
|
BearSSL is already linked into every embedded sprout (standalone binaries and sproutd) for
|
package/src/subtle/subtle.c
CHANGED
|
@@ -183,6 +183,17 @@ static int sbc_oid_eq(const sbc_u8* a, size_t al, const sbc_u8* b, size_t bl) {
|
|
|
183
183
|
|
|
184
184
|
/* Curve codes used on the wire to JS: 1 = P-256, 2 = P-384, 3 = P-521. */
|
|
185
185
|
static int sbc_curve_br(int c) { return c == 1 ? BR_EC_secp256r1 : c == 2 ? BR_EC_secp384r1 : c == 3 ? BR_EC_secp521r1 : 0; }
|
|
186
|
+
/* BearSSL's default EC implementation is the portable m31 (m15 where there is no 64-bit multiply
|
|
187
|
+
result). P-256 has a 64-bit specialisation, m64, that is roughly 3x faster to sign and 5x to verify,
|
|
188
|
+
but BearSSL never selects it by itself. br_ec_p256_m64_get() returns null on a target without a
|
|
189
|
+
128-bit multiply, and m64 only knows P-256, so every other curve keeps the default. */
|
|
190
|
+
static const br_ec_impl* sbc_ec(int br_curve) {
|
|
191
|
+
if (br_curve == BR_EC_secp256r1) {
|
|
192
|
+
const br_ec_impl* fast = br_ec_p256_m64_get();
|
|
193
|
+
if (fast) return fast;
|
|
194
|
+
}
|
|
195
|
+
return br_ec_get_default();
|
|
196
|
+
}
|
|
186
197
|
static int sbc_curve_from_br(int c) { return c == BR_EC_secp256r1 ? 1 : c == BR_EC_secp384r1 ? 2 : c == BR_EC_secp521r1 ? 3 : 0; }
|
|
187
198
|
static size_t sbc_curve_size(int c) { return c == 1 ? 32 : c == 2 ? 48 : c == 3 ? 66 : 0; }
|
|
188
199
|
static void sbc_curve_oid(int c, const sbc_u8** o, size_t* n) {
|
|
@@ -380,7 +391,7 @@ static int sbc_decode(int pkcs8, const sbc_u8* der, size_t len, sbc_buf* out) {
|
|
|
380
391
|
if (cv) {
|
|
381
392
|
sbc_u8 pub[133];
|
|
382
393
|
br_ec_public_key pk;
|
|
383
|
-
if (br_ec_compute_pub(
|
|
394
|
+
if (br_ec_compute_pub(sbc_ec(sk->curve), &pk, pub, sk) != 0) {
|
|
384
395
|
sbc_put1(out, SBC_K_EC); sbc_put1(out, (sbc_u8)cv); sbc_put1(out, 1);
|
|
385
396
|
sbc_field(out, pk.q, pk.qlen);
|
|
386
397
|
/* The scalar, left padded to the field size as JWK d wants it. */
|
|
@@ -677,7 +688,7 @@ static int sbc_ecdsa_sign(int bits, const sbc_u8* key, size_t kl, const sbc_u8*
|
|
|
677
688
|
sbc_u8 h[64], sig[132];
|
|
678
689
|
rc = SBC_OPERATION;
|
|
679
690
|
if (sbc_digest(bits, m, ml, h)) {
|
|
680
|
-
size_t n = br_ecdsa_sign_raw_get_default()(
|
|
691
|
+
size_t n = br_ecdsa_sign_raw_get_default()(sbc_ec(sk->curve), sbc_hash(bits), h, sk, sig);
|
|
681
692
|
if (n) { sbc_put(out, sig, n); rc = SBC_OK; }
|
|
682
693
|
}
|
|
683
694
|
free(dc);
|
|
@@ -692,7 +703,7 @@ static int sbc_ecdsa_verify(int bits, int curve, const sbc_u8* point, size_t pl,
|
|
|
692
703
|
if (sl != 2 * fs) return SBC_OK;
|
|
693
704
|
br_ec_public_key pk;
|
|
694
705
|
pk.curve = sbc_curve_br(curve); pk.q = (sbc_u8*)point; pk.qlen = pl;
|
|
695
|
-
if (br_ecdsa_vrfy_raw_get_default()(
|
|
706
|
+
if (br_ecdsa_vrfy_raw_get_default()(sbc_ec(pk.curve), h, sbc_hlen(bits), &pk, sig, sl)) *ok = 1;
|
|
696
707
|
return SBC_OK;
|
|
697
708
|
}
|
|
698
709
|
/* A public point is valid when it is on the curve (the group is prime order, cofactor 1). */
|
|
@@ -702,12 +713,12 @@ static int sbc_ec_point_ok(int curve, const sbc_u8* point, size_t pl) {
|
|
|
702
713
|
sbc_u8 q[133];
|
|
703
714
|
memcpy(q, point, pl);
|
|
704
715
|
sbc_u8 one[1] = { 1 };
|
|
705
|
-
return
|
|
716
|
+
return sbc_ec(sbc_curve_br(curve))->mul(q, pl, one, 1, sbc_curve_br(curve)) != 0;
|
|
706
717
|
}
|
|
707
718
|
static int sbc_ec_keygen(int curve, sbc_buf* out) {
|
|
708
719
|
br_ec_private_key sk; br_ec_public_key pk;
|
|
709
720
|
sbc_u8 kb[BR_EC_KBUF_PRIV_MAX_SIZE], pb[BR_EC_KBUF_PUB_MAX_SIZE];
|
|
710
|
-
const br_ec_impl* impl =
|
|
721
|
+
const br_ec_impl* impl = sbc_ec(sbc_curve_br(curve));
|
|
711
722
|
if (!br_ec_keygen(&sbc_prng, impl, &sk, kb, sbc_curve_br(curve))) return SBC_OPERATION;
|
|
712
723
|
if (!br_ec_compute_pub(impl, &pk, pb, &sk)) return SBC_OPERATION;
|
|
713
724
|
size_t need = br_encode_ec_pkcs8_der(0, &sk, &pk);
|
|
@@ -885,8 +896,9 @@ static int sbc_ecdh(int curve, const sbc_u8* pkcs8, size_t kl, const sbc_u8* pee
|
|
|
885
896
|
if (sk->curve == sbc_curve_br(curve) && fs && pl == 1 + 2 * fs && peer[0] == 4) {
|
|
886
897
|
sbc_u8 pt[133];
|
|
887
898
|
memcpy(pt, peer, pl);
|
|
888
|
-
|
|
889
|
-
|
|
899
|
+
const br_ec_impl* impl = sbc_ec(sk->curve);
|
|
900
|
+
if (impl->mul(pt, pl, sk->x, sk->xlen, sk->curve)) {
|
|
901
|
+
size_t xl; size_t xo = impl->xoff(sk->curve, &xl);
|
|
890
902
|
sbc_put(out, pt + xo, xl); rc = SBC_OK;
|
|
891
903
|
}
|
|
892
904
|
}
|
|
@@ -9,7 +9,7 @@ import { assembleSubtleC } from "./assemble";
|
|
|
9
9
|
|
|
10
10
|
const bear = join(
|
|
11
11
|
process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? join(homedir(), ".cache/sproutboat"),
|
|
12
|
-
"bearssl-
|
|
12
|
+
"bearssl-7bea48e/BearSSL-7bea48e",
|
|
13
13
|
);
|
|
14
14
|
const lib = join(bear, "build/libbearssl.a");
|
|
15
15
|
// SUBTLE_DRIVER: a prebuilt driver binary (a machine with no C compiler, or a cross-built one).
|
|
@@ -3145,11 +3145,16 @@ typedef struct sb_async_job {
|
|
|
3145
3145
|
char *host, *path, *method, *headers, *body, *out;
|
|
3146
3146
|
size_t body_len;
|
|
3147
3147
|
} sb_async_job;
|
|
3148
|
+
// sproutd runs one host per sprout among hundreds, so a few threads each; a standalone binary is the
|
|
3149
|
+
// only process of its app, and workerd lets one request hold 50 subrequests.
|
|
3148
3150
|
#define SB_ASYNC_THREADS 8
|
|
3151
|
+
#define SB_ASYNC_THREADS_STANDALONE 32
|
|
3149
3152
|
static pthread_mutex_t sb_async_lock = PTHREAD_MUTEX_INITIALIZER;
|
|
3150
3153
|
static sb_async_job *sb_async_queue = 0, *sb_async_queue_tail = 0, *sb_async_done = 0, *sb_async_current = 0;
|
|
3151
3154
|
static int sb_async_pipe[2] = { -1, -1 };
|
|
3152
3155
|
static int sb_async_threads = 0;
|
|
3156
|
+
// The standalone server's way back onto its loop (uWS defer, patchAsyncWaker); set by sb_async_set_waker.
|
|
3157
|
+
static void (*sb_wsc_waker)(void) = 0;
|
|
3153
3158
|
|
|
3154
3159
|
int sb_async_enable(void) {
|
|
3155
3160
|
if (sb_async_pipe[0] >= 0) return sb_async_pipe[0];
|
|
@@ -3160,7 +3165,10 @@ int sb_async_enable(void) {
|
|
|
3160
3165
|
}
|
|
3161
3166
|
return sb_async_pipe[0];
|
|
3162
3167
|
}
|
|
3163
|
-
|
|
3168
|
+
// A host runs requests off its loop when it watches the pipe (sproutd) or gave a waker (standalone).
|
|
3169
|
+
int sb_async_enabled(void) { return sb_async_pipe[0] >= 0 || sb_wsc_waker != 0; }
|
|
3170
|
+
// Only the pipe host (sproutd) has always answered fetch() with a promise; see __sbAsyncAtAnyDate.
|
|
3171
|
+
int sb_async_piped(void) { return sb_async_pipe[0] >= 0; }
|
|
3164
3172
|
static void sb_async_free(sb_async_job* j) {
|
|
3165
3173
|
free(j->host); free(j->path); free(j->method); free(j->headers); free(j->body); free(j->out); free(j);
|
|
3166
3174
|
}
|
|
@@ -3168,8 +3176,12 @@ static void sb_async_free(sb_async_job* j) {
|
|
|
3168
3176
|
static void sb_async_finish_locked(sb_async_job* j) {
|
|
3169
3177
|
j->next = sb_async_done;
|
|
3170
3178
|
sb_async_done = j;
|
|
3171
|
-
|
|
3172
|
-
|
|
3179
|
+
if (sb_async_pipe[1] >= 0) {
|
|
3180
|
+
char one = 1;
|
|
3181
|
+
while (write(sb_async_pipe[1], &one, 1) < 0 && errno == EINTR) {}
|
|
3182
|
+
}
|
|
3183
|
+
// uWS's defer is thread-safe and never takes sb_async_lock, so calling it here cannot deadlock.
|
|
3184
|
+
if (sb_wsc_waker) sb_wsc_waker();
|
|
3173
3185
|
}
|
|
3174
3186
|
static void* sb_async_thread(void* unused) {
|
|
3175
3187
|
(void)unused;
|
|
@@ -3192,7 +3204,7 @@ static void sb_async_start(sb_async_job* j) {
|
|
|
3192
3204
|
j->next = 0;
|
|
3193
3205
|
if (sb_async_queue_tail) sb_async_queue_tail->next = j; else sb_async_queue = j;
|
|
3194
3206
|
sb_async_queue_tail = j;
|
|
3195
|
-
int spawn = sb_async_threads < SB_ASYNC_THREADS;
|
|
3207
|
+
int spawn = sb_async_threads < (sb_async_pipe[0] >= 0 ? SB_ASYNC_THREADS : SB_ASYNC_THREADS_STANDALONE);
|
|
3196
3208
|
if (spawn) sb_async_threads++;
|
|
3197
3209
|
pthread_mutex_unlock(&sb_async_lock);
|
|
3198
3210
|
if (!spawn) return;
|
|
@@ -3236,7 +3248,7 @@ static void (*sb_wsc_drain_hook)(void) = 0;
|
|
|
3236
3248
|
// Host: the fd became readable. Fires each finished request's callback.
|
|
3237
3249
|
void sb_async_drain(void) {
|
|
3238
3250
|
char buf[64];
|
|
3239
|
-
while (read(sb_async_pipe[0], buf, sizeof(buf)) > 0) {}
|
|
3251
|
+
if (sb_async_pipe[0] >= 0) while (read(sb_async_pipe[0], buf, sizeof(buf)) > 0) {}
|
|
3240
3252
|
if (sb_wsc_drain_hook) sb_wsc_drain_hook();
|
|
3241
3253
|
pthread_mutex_lock(&sb_async_lock);
|
|
3242
3254
|
sb_async_job* done = sb_async_done;
|
|
@@ -3294,7 +3306,6 @@ typedef struct sb_wsc {
|
|
|
3294
3306
|
static pthread_mutex_t sb_wsc_lock = PTHREAD_MUTEX_INITIALIZER;
|
|
3295
3307
|
static sb_wsc* sb_wscs = 0;
|
|
3296
3308
|
static unsigned sb_wsc_next = 0;
|
|
3297
|
-
static void (*sb_wsc_waker)(void) = 0;
|
|
3298
3309
|
void sb_async_set_waker(void (*wake)(void)) { sb_wsc_waker = wake; }
|
|
3299
3310
|
static int sb_wsc_pipe_enabled(void) { return sb_async_pipe[0] >= 0; }
|
|
3300
3311
|
static void sb_async_poke(void) {
|
|
@@ -4318,12 +4329,21 @@ function __sbHttpAsyncResult(asyncId) {
|
|
|
4318
4329
|
// placeholder timer cleared, the reply is dropped when it arrives.
|
|
4319
4330
|
function __sbCancelAsync() {}
|
|
4320
4331
|
|
|
4321
|
-
// sproutd ran fetch() off its loop, as a promise, at every compatibility date (B18).
|
|
4332
|
+
// sproutd ran fetch() off its loop, as a promise, at every compatibility date (B18). The standalone
|
|
4333
|
+
// server (a waker, no pipe) keeps the date its fetch() promise has always had.
|
|
4322
4334
|
function __sbAsyncAtAnyDate() {
|
|
4323
|
-
|
|
4335
|
+
let res = false;
|
|
4336
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4337
|
+
Porffor.c`
|
|
4338
|
+
int sb_async_piped(void);
|
|
4339
|
+
if (sb_async_piped()) res = porf_box(1, 2);
|
|
4340
|
+
`;
|
|
4341
|
+
return res;
|
|
4324
4342
|
}
|
|
4325
4343
|
|
|
4326
|
-
// Whether the host runs async requests (sb_async_enable was called)
|
|
4344
|
+
// Whether the host runs async requests: sproutd (sb_async_enable was called) at any date, the
|
|
4345
|
+
// standalone server (it gave a waker) from the date its fetch() leaves the loop, so an app that
|
|
4346
|
+
// relied on one request at a time across an `await fetch()` keeps that until it opts in.
|
|
4327
4347
|
function __sbHttpAsyncEnabled() {
|
|
4328
4348
|
let res = false;
|
|
4329
4349
|
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
@@ -4331,7 +4351,7 @@ function __sbHttpAsyncEnabled() {
|
|
|
4331
4351
|
int sb_async_enabled(void);
|
|
4332
4352
|
if (sb_async_enabled()) res = porf_box(1, 2);
|
|
4333
4353
|
`;
|
|
4334
|
-
return res;
|
|
4354
|
+
return res && (__sbAsyncAtAnyDate() || __sbCompatDate() >= "2026-11-16");
|
|
4335
4355
|
}
|
|
4336
4356
|
|
|
4337
4357
|
// The outbound client's JSON reply -> the fetch op's result.
|
|
@@ -4942,8 +4962,13 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
4942
4962
|
if (op === "secret.get") {
|
|
4943
4963
|
// Secrets reach a standalone binary through the environment, the same
|
|
4944
4964
|
// channel the launcher and the supervisor use; there is nothing to decrypt.
|
|
4945
|
-
const
|
|
4946
|
-
if (!
|
|
4965
|
+
const raw = __sbEnv(String(msg.name));
|
|
4966
|
+
if (!raw) throw new Error("secret not set: " + msg.name);
|
|
4967
|
+
// __sbEnv hands back the environment's bytes as a bytestring, one char per byte, so a UTF-8 secret ("é" is
|
|
4968
|
+
// C3 A9) reached the app as "é". From compatibility date 2026-11-07 it is decoded as UTF-8, as workerd's
|
|
4969
|
+
// secrets are text. Bytes that are not valid UTF-8 pass through unchanged (__sbFromUtf8's rule). The broker
|
|
4970
|
+
// transport is not touched: its secrets arrive as JSON, already real strings.
|
|
4971
|
+
const value = __sbCompatDate() >= "2026-11-07" ? __sbFromUtf8(raw) : raw;
|
|
4947
4972
|
return { ok: true, value };
|
|
4948
4973
|
}
|
|
4949
4974
|
|
package/test/bearssl.ts
CHANGED
|
@@ -7,9 +7,10 @@ import { join, resolve } from "node:path";
|
|
|
7
7
|
// The prelude carries crypto.subtle's native half on every transport, so a test that compiles a
|
|
8
8
|
// handler using crypto.subtle needs BearSSL on the include path and link line, as the CLI arranges
|
|
9
9
|
// for a build. Same pinned source as the CLI's src/bearssl.ts; built once into the toolchain cache.
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
10
|
+
const COMMIT = "7bea48e5e850ab4cafbe68d3765cdaba13a86d6f";
|
|
11
|
+
const VERSION = COMMIT.slice(0, 7);
|
|
12
|
+
const URL_ = `https://bearssl.org/gitweb/?p=BearSSL;a=snapshot;h=${COMMIT};sf=tgz`;
|
|
13
|
+
const SHA256 = "ed397832b1613582737d9c1ce01c1af74ad6a9e9c52a341e24433987cda0f372";
|
|
13
14
|
|
|
14
15
|
const walk = async (dir: string): Promise<string[]> =>
|
|
15
16
|
(
|
|
@@ -28,7 +29,7 @@ export async function bearsslEnv(): Promise<{ SB_EXTRA_CFLAGS: string; SB_EXTRA_
|
|
|
28
29
|
process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? join(homedir(), ".cache/sproutboat"),
|
|
29
30
|
`bearssl-${VERSION}-test`,
|
|
30
31
|
);
|
|
31
|
-
const tree = join(root, `
|
|
32
|
+
const tree = join(root, `BearSSL-${VERSION}`);
|
|
32
33
|
const objects = join(root, "obj");
|
|
33
34
|
const marker = join(objects, ".complete");
|
|
34
35
|
if (!existsSync(marker)) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Repro for standalone `fetch()` off the event loop (compatibility date 2026-11-16).
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
bun slow.ts 19100 & # /slow?delay=MS, /echo
|
|
5
|
+
sproutboat build --standalone --target host . # compatibility_date 2026-11-16
|
|
6
|
+
PORT=19201 SB_EGRESS_ALLOW=127.0.0.1 ./dist/af-fan &
|
|
7
|
+
curl 'localhost:19201/fan?n=20&delay=100' # about 110 ms; 2,040 ms at 2026-11-15
|
|
8
|
+
curl 'localhost:19201/order?delay=200' & curl 'localhost:19201/order?delay=200' # interleave from 11-16
|
|
9
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// bun /tmp/af/slow.ts PORT : /slow?delay=ms answers "slow-<i>" after delay; /echo returns the body
|
|
2
|
+
Bun.serve({
|
|
3
|
+
port: Number(process.argv[2] || 19100),
|
|
4
|
+
hostname: "0.0.0.0",
|
|
5
|
+
async fetch(req) {
|
|
6
|
+
const u = new URL(req.url);
|
|
7
|
+
if (u.pathname === "/slow") {
|
|
8
|
+
await Bun.sleep(Number(u.searchParams.get("delay") || 100));
|
|
9
|
+
return new Response("slow-" + (u.searchParams.get("i") ?? "x"));
|
|
10
|
+
}
|
|
11
|
+
if (u.pathname === "/echo")
|
|
12
|
+
return new Response("echo:" + (await req.text()) + ":" + req.headers.get("x-test"), {
|
|
13
|
+
headers: { "content-type": "text/x-echo" },
|
|
14
|
+
});
|
|
15
|
+
return new Response("nf", { status: 404 });
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
let counter = 0;
|
|
2
|
+
export default {
|
|
3
|
+
async fetch(request) {
|
|
4
|
+
const u = new URL(request.url);
|
|
5
|
+
if (u.pathname === "/ping") return new Response("ok");
|
|
6
|
+
const base = u.searchParams.get("base") || "http://127.0.0.1:19100";
|
|
7
|
+
if (u.pathname === "/fan") {
|
|
8
|
+
const n = Number(u.searchParams.get("n") || 20);
|
|
9
|
+
const delay = u.searchParams.get("delay") || "100";
|
|
10
|
+
const t0 = Date.now();
|
|
11
|
+
const rs = await Promise.all(
|
|
12
|
+
Array.from({ length: n }, (_, i) => fetch(base + "/slow?i=" + i + "&delay=" + delay).then((r) => r.text())),
|
|
13
|
+
);
|
|
14
|
+
return Response.json({ n, ms: Date.now() - t0, bodies: rs.slice(0, 3), last: rs[n - 1] });
|
|
15
|
+
}
|
|
16
|
+
if (u.pathname === "/one") {
|
|
17
|
+
const r = await fetch(base + "/slow?delay=" + (u.searchParams.get("delay") || "0"));
|
|
18
|
+
return new Response(await r.text(), { status: r.status });
|
|
19
|
+
}
|
|
20
|
+
if (u.pathname === "/post") {
|
|
21
|
+
const r = await fetch(base + "/echo", { method: "POST", body: "hello-" + ++counter, headers: { "x-test": "1" } });
|
|
22
|
+
return Response.json({ status: r.status, text: await r.text(), ct: r.headers.get("content-type") });
|
|
23
|
+
}
|
|
24
|
+
if (u.pathname === "/refuse") {
|
|
25
|
+
try {
|
|
26
|
+
await fetch("http://127.0.0.1:1/");
|
|
27
|
+
return new Response("no error", { status: 500 });
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return Response.json({ rejected: true, name: e.name, message: String(e.message) });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (u.pathname === "/abort") {
|
|
33
|
+
const ac = new AbortController();
|
|
34
|
+
setTimeout(() => ac.abort(), 20);
|
|
35
|
+
try {
|
|
36
|
+
await fetch(base + "/slow?delay=500", { signal: ac.signal });
|
|
37
|
+
return new Response("no abort", { status: 500 });
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return Response.json({ aborted: true, name: e.name });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (u.pathname === "/order") {
|
|
43
|
+
// global state across an await fetch
|
|
44
|
+
const mine = ++counter;
|
|
45
|
+
await fetch(base + "/slow?delay=" + (u.searchParams.get("delay") || "50")).then((r) => r.text());
|
|
46
|
+
return Response.json({ mine, after: counter });
|
|
47
|
+
}
|
|
48
|
+
return new Response("not found", { status: 404 });
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Restart stress for a standalone binary (docs/standalone.md, "Restarting without dropping a request").
|
|
3
|
+
|
|
4
|
+
standalone-restart-stress.py BINARY --mode MODE [--restarts N] [--keepalive N] [--fresh N] [--writes]
|
|
5
|
+
|
|
6
|
+
Linux. Continuous load against one port while the binary is replaced N times; the count that matters
|
|
7
|
+
is how many requests failed. MODE says how the swap is done:
|
|
8
|
+
|
|
9
|
+
overlap the old recipe: start the new copy on the same port (SO_REUSEPORT), SIGTERM the old one.
|
|
10
|
+
Closing the old listener resets whatever sits in its accept queue.
|
|
11
|
+
stop-start SIGTERM the old copy, wait for it, start the new one. Nothing listens in between.
|
|
12
|
+
systemd a socket + service pair of user units under `systemd --user`; each swap is
|
|
13
|
+
`systemctl --user restart`. The socket stays open, so connections queue.
|
|
14
|
+
usr2 run the binary directly; each swap replaces the file on disk (install + rename) and sends
|
|
15
|
+
SIGUSR2. The running copy starts the new one on its listening socket and drains.
|
|
16
|
+
|
|
17
|
+
The load is FRESH threads (a connection per request) and KEEPALIVE threads (one connection reused,
|
|
18
|
+
as a proxy's pool does). A reused connection the server closes in the same instant is retried once on
|
|
19
|
+
a new connection, as every client retries an idempotent request, and counted separately. With --writes
|
|
20
|
+
half the requests are POST /w against a D1 table (the app's /n route returns the row count), and the
|
|
21
|
+
run ends by checking that every acknowledged write is in the table, so a swap that loses or doubles one
|
|
22
|
+
shows up. The app is expected to answer GET /x with 200 and, for --writes, POST /w with the new row id.
|
|
23
|
+
|
|
24
|
+
Prints one JSON line; exit status 1 on any failed request or lost write.
|
|
25
|
+
"""
|
|
26
|
+
import argparse, http.client, json, os, shutil, signal, socket, subprocess, sys, tempfile, threading, time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
ap = argparse.ArgumentParser()
|
|
30
|
+
ap.add_argument("binary")
|
|
31
|
+
ap.add_argument("--mode", required=True, choices=["overlap", "stop-start", "systemd", "usr2"])
|
|
32
|
+
ap.add_argument("--restarts", type=int, default=30)
|
|
33
|
+
ap.add_argument("--keepalive", type=int, default=3)
|
|
34
|
+
ap.add_argument("--fresh", type=int, default=3)
|
|
35
|
+
ap.add_argument("--writes", action="store_true")
|
|
36
|
+
ap.add_argument("--fresh-delay-ms", type=float, default=3, help="pause between a fresh loader's requests (conntrack: every connection leaves a TIME_WAIT entry, and a full nf_conntrack table silently drops SYNs)")
|
|
37
|
+
ap.add_argument("--settle-ms", type=int, default=250, help="pause between swaps")
|
|
38
|
+
ap.add_argument("--env", action="append", default=[], help="KEY=value for the binary")
|
|
39
|
+
args = ap.parse_args()
|
|
40
|
+
|
|
41
|
+
binary = Path(args.binary).resolve()
|
|
42
|
+
scratch = Path(tempfile.mkdtemp(prefix="sb-restart-"))
|
|
43
|
+
installed = scratch / "app"
|
|
44
|
+
shutil.copy(binary, installed)
|
|
45
|
+
installed.chmod(0o755)
|
|
46
|
+
data = scratch / "data"
|
|
47
|
+
data.mkdir()
|
|
48
|
+
with socket.socket() as s:
|
|
49
|
+
s.bind(("127.0.0.1", 0))
|
|
50
|
+
port = s.getsockname()[1]
|
|
51
|
+
env = dict(os.environ, PORT=str(port), SB_DATA_DIR=str(data))
|
|
52
|
+
for item in args.env:
|
|
53
|
+
k, _, v = item.partition("=")
|
|
54
|
+
env[k] = v
|
|
55
|
+
|
|
56
|
+
stop = [False]
|
|
57
|
+
lock = threading.Lock()
|
|
58
|
+
stats = {"ok": 0, "retried": 0, "writes_acked": 0}
|
|
59
|
+
failures = []
|
|
60
|
+
latencies = []
|
|
61
|
+
t_start = time.time()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def note_failure(kind, err):
|
|
65
|
+
with lock:
|
|
66
|
+
failures.append({"t": round(time.time() - t_start, 3), "kind": kind, "error": err})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def one_request(conn, write):
|
|
70
|
+
t0 = time.time()
|
|
71
|
+
if write:
|
|
72
|
+
conn.request("POST", "/w", body=b"x")
|
|
73
|
+
else:
|
|
74
|
+
conn.request("GET", "/x")
|
|
75
|
+
r = conn.getresponse()
|
|
76
|
+
body = r.read()
|
|
77
|
+
dt = time.time() - t0
|
|
78
|
+
with lock:
|
|
79
|
+
latencies.append(dt)
|
|
80
|
+
return r.status, body.decode(errors="replace"), r.will_close
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def record(status, body, write, kind):
|
|
84
|
+
if status == 200 or (write and status in (200, 201)):
|
|
85
|
+
with lock:
|
|
86
|
+
stats["ok"] += 1
|
|
87
|
+
if write:
|
|
88
|
+
stats["writes_acked"] += 1
|
|
89
|
+
else:
|
|
90
|
+
note_failure(kind, f"status {status} {body[:40]}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fresh_loader(index):
|
|
94
|
+
n = 0
|
|
95
|
+
while not stop[0]:
|
|
96
|
+
time.sleep(args.fresh_delay_ms / 1000)
|
|
97
|
+
n += 1
|
|
98
|
+
write = args.writes and (n + index) % 2 == 0
|
|
99
|
+
c = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
|
|
100
|
+
try:
|
|
101
|
+
status, body, _ = one_request(c, write)
|
|
102
|
+
record(status, body, write, "fresh")
|
|
103
|
+
except Exception as e:
|
|
104
|
+
note_failure("fresh", type(e).__name__ + ": " + str(e)[:80])
|
|
105
|
+
finally:
|
|
106
|
+
c.close()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def keepalive_loader(index):
|
|
110
|
+
conn = None
|
|
111
|
+
n = 0
|
|
112
|
+
while not stop[0]:
|
|
113
|
+
n += 1
|
|
114
|
+
write = args.writes and (n + index) % 2 == 0
|
|
115
|
+
reused = conn is not None
|
|
116
|
+
if conn is None:
|
|
117
|
+
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
|
|
118
|
+
try:
|
|
119
|
+
status, body, closing = one_request(conn, write)
|
|
120
|
+
if closing:
|
|
121
|
+
conn.close()
|
|
122
|
+
conn = None
|
|
123
|
+
record(status, body, write, "keepalive")
|
|
124
|
+
except Exception as e:
|
|
125
|
+
conn.close()
|
|
126
|
+
conn = None
|
|
127
|
+
if not reused or write:
|
|
128
|
+
# A write is not retried: it may have been applied, and the point is to see it fail.
|
|
129
|
+
note_failure("keepalive", ("new connection: " if not reused else "write on reused connection: ") + type(e).__name__ + ": " + str(e)[:80])
|
|
130
|
+
continue
|
|
131
|
+
c2 = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
|
|
132
|
+
try:
|
|
133
|
+
status, body, _ = one_request(c2, False)
|
|
134
|
+
with lock:
|
|
135
|
+
stats["retried"] += 1
|
|
136
|
+
record(status, body, False, "keepalive")
|
|
137
|
+
except Exception as e2:
|
|
138
|
+
note_failure("keepalive", "retry failed: " + type(e2).__name__ + ": " + str(e2)[:80])
|
|
139
|
+
finally:
|
|
140
|
+
c2.close()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def conntrack():
|
|
144
|
+
try:
|
|
145
|
+
return int(Path("/proc/sys/net/netfilter/nf_conntrack_count").read_text())
|
|
146
|
+
except OSError:
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def wait_ready(timeout=10):
|
|
151
|
+
deadline = time.time() + timeout
|
|
152
|
+
while time.time() < deadline:
|
|
153
|
+
try:
|
|
154
|
+
c = http.client.HTTPConnection("127.0.0.1", port, timeout=2)
|
|
155
|
+
c.request("GET", "/x")
|
|
156
|
+
if c.getresponse().status == 200:
|
|
157
|
+
c.close()
|
|
158
|
+
return True
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
time.sleep(0.05)
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
procs = []
|
|
166
|
+
unit = f"sbrs-{os.getpid()}"
|
|
167
|
+
unit_dir = Path.home() / ".config/systemd/user"
|
|
168
|
+
log_n = [0]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def spawn(extra_env=None):
|
|
172
|
+
log_n[0] += 1
|
|
173
|
+
log = open(scratch / f"run{log_n[0]}.log", "w")
|
|
174
|
+
p = subprocess.Popen([str(installed)], cwd=scratch, env=dict(env, **(extra_env or {})), stdout=log, stderr=log, start_new_session=True)
|
|
175
|
+
procs.append(p)
|
|
176
|
+
return p
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def exited(p, limit):
|
|
180
|
+
try:
|
|
181
|
+
p.wait(limit)
|
|
182
|
+
return True
|
|
183
|
+
except subprocess.TimeoutExpired:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def alive(pid, child=None):
|
|
188
|
+
"""Is pid still running? A zombie nobody reaped (the container case) is gone, and our own child is reaped here."""
|
|
189
|
+
if child is not None:
|
|
190
|
+
return child.poll() is None
|
|
191
|
+
try:
|
|
192
|
+
with open(f"/proc/{pid}/stat") as f:
|
|
193
|
+
return f.read().rsplit(")", 1)[1].split()[0] != "Z"
|
|
194
|
+
except OSError:
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def replace_binary():
|
|
199
|
+
tmp = scratch / "app.new"
|
|
200
|
+
shutil.copy(binary, tmp)
|
|
201
|
+
tmp.chmod(0o755)
|
|
202
|
+
os.replace(tmp, installed)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def sctl(*a):
|
|
206
|
+
return subprocess.run(["systemctl", "--user", *a], capture_output=True, text=True)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
summary = {"mode": args.mode, "binary": str(binary), "port": port}
|
|
210
|
+
threads = []
|
|
211
|
+
try:
|
|
212
|
+
while conntrack() > 20000:
|
|
213
|
+
time.sleep(5)
|
|
214
|
+
summary["conntrack_at_start"] = conntrack()
|
|
215
|
+
swap_ms = []
|
|
216
|
+
if args.mode == "systemd":
|
|
217
|
+
# Real unit files, not systemd-run: a transient service is garbage-collected when it stops,
|
|
218
|
+
# which takes its socket with it. These are the units docs/standalone.md describes.
|
|
219
|
+
unit_dir.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
(unit_dir / f"{unit}.socket").write_text(f"[Socket]\nListenStream=127.0.0.1:{port}\nBacklog=4096\nTriggerLimitIntervalSec=0\n")
|
|
221
|
+
(unit_dir / f"{unit}.service").write_text(
|
|
222
|
+
f"[Unit]\nRequires={unit}.socket\n# A stress run restarts several times a second; the default limit (5 starts in 10 s) would fail the unit.\nStartLimitIntervalSec=0\n\n[Service]\nExecStart={installed}\nWorkingDirectory={scratch}\n"
|
|
223
|
+
f"Environment=PORT={port} SB_DATA_DIR={data}\n"
|
|
224
|
+
+ "".join(f"Environment={item}\n" for item in args.env)
|
|
225
|
+
+ "KillSignal=SIGTERM\nTimeoutStopSec=30\nMemoryMax=256M\nMemorySwapMax=0\n"
|
|
226
|
+
)
|
|
227
|
+
for step in (["daemon-reload"], ["start", f"{unit}.socket"]):
|
|
228
|
+
r = sctl(*step)
|
|
229
|
+
if r.returncode:
|
|
230
|
+
raise SystemExit("systemctl " + " ".join(step) + " failed: " + r.stderr)
|
|
231
|
+
else:
|
|
232
|
+
current = spawn()
|
|
233
|
+
newest_pid = current.pid
|
|
234
|
+
if not wait_ready():
|
|
235
|
+
raise SystemExit("the binary did not answer GET /x")
|
|
236
|
+
t_start = time.time()
|
|
237
|
+
for i in range(args.fresh):
|
|
238
|
+
threads.append(threading.Thread(target=fresh_loader, args=(i,), daemon=True))
|
|
239
|
+
for i in range(args.keepalive):
|
|
240
|
+
threads.append(threading.Thread(target=keepalive_loader, args=(i,), daemon=True))
|
|
241
|
+
for t in threads:
|
|
242
|
+
t.start()
|
|
243
|
+
time.sleep(1)
|
|
244
|
+
seen_logs = set()
|
|
245
|
+
for i in range(args.restarts):
|
|
246
|
+
t0 = time.time()
|
|
247
|
+
if args.mode == "overlap":
|
|
248
|
+
nxt = spawn()
|
|
249
|
+
time.sleep(0.4) # up and accepting beside the old one
|
|
250
|
+
current.send_signal(signal.SIGTERM)
|
|
251
|
+
if not exited(current, 15):
|
|
252
|
+
note_failure("restart", "old copy did not exit in 15 s")
|
|
253
|
+
current.kill()
|
|
254
|
+
current = nxt
|
|
255
|
+
elif args.mode == "stop-start":
|
|
256
|
+
current.send_signal(signal.SIGTERM)
|
|
257
|
+
if not exited(current, 15):
|
|
258
|
+
note_failure("restart", "old copy did not exit in 15 s")
|
|
259
|
+
current.kill()
|
|
260
|
+
current = spawn()
|
|
261
|
+
elif args.mode == "systemd":
|
|
262
|
+
replace_binary()
|
|
263
|
+
r = sctl("restart", f"{unit}.service")
|
|
264
|
+
if r.returncode:
|
|
265
|
+
note_failure("restart", "systemctl restart: " + r.stderr[:80])
|
|
266
|
+
elif args.mode == "usr2":
|
|
267
|
+
replace_binary()
|
|
268
|
+
pid = newest_pid
|
|
269
|
+
os.kill(pid, signal.SIGUSR2)
|
|
270
|
+
# The successor is the old copy's child; it logs its pid into the old copy's log file.
|
|
271
|
+
deadline = time.time() + 15
|
|
272
|
+
newest_pid = None
|
|
273
|
+
logfile = scratch / f"run1.log"
|
|
274
|
+
while time.time() < deadline and newest_pid is None:
|
|
275
|
+
for line in logfile.read_text().splitlines():
|
|
276
|
+
if "restart: started" in line and line not in seen_logs:
|
|
277
|
+
seen_logs.add(line)
|
|
278
|
+
newest_pid = int(line.rsplit(" ", 1)[1])
|
|
279
|
+
if newest_pid is None:
|
|
280
|
+
time.sleep(0.05)
|
|
281
|
+
if newest_pid is None:
|
|
282
|
+
note_failure("restart", "no successor started")
|
|
283
|
+
break
|
|
284
|
+
# wait for the old pid to be gone
|
|
285
|
+
while time.time() < deadline and alive(pid, current if i == 0 else None):
|
|
286
|
+
time.sleep(0.02)
|
|
287
|
+
if not wait_ready(10):
|
|
288
|
+
note_failure("restart", "successor never answered")
|
|
289
|
+
swap_ms.append(round((time.time() - t0) * 1000))
|
|
290
|
+
time.sleep(args.settle_ms / 1000)
|
|
291
|
+
time.sleep(1)
|
|
292
|
+
stop[0] = True
|
|
293
|
+
for t in threads:
|
|
294
|
+
t.join(30)
|
|
295
|
+
lost = None
|
|
296
|
+
if args.writes:
|
|
297
|
+
try:
|
|
298
|
+
c = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
|
|
299
|
+
c.request("GET", "/n")
|
|
300
|
+
rows = int(c.getresponse().read().decode().strip())
|
|
301
|
+
lost = stats["writes_acked"] - rows
|
|
302
|
+
summary["rows_in_table"] = rows
|
|
303
|
+
except Exception as e:
|
|
304
|
+
lost = f"could not read /n: {e}"
|
|
305
|
+
lat = sorted(latencies)
|
|
306
|
+
summary.update({
|
|
307
|
+
"restarts": args.restarts,
|
|
308
|
+
"swap_ms_median": sorted(swap_ms)[len(swap_ms) // 2] if swap_ms else 0,
|
|
309
|
+
"swap_ms_max": max(swap_ms or [0]),
|
|
310
|
+
"requests_ok": stats["ok"],
|
|
311
|
+
"reused_retried": stats["retried"],
|
|
312
|
+
"writes_acked": stats["writes_acked"],
|
|
313
|
+
"writes_unaccounted": lost,
|
|
314
|
+
"failed": len(failures),
|
|
315
|
+
"failures": failures[:12],
|
|
316
|
+
"latency_ms": {"p50": round(lat[len(lat) // 2] * 1000, 2) if lat else 0, "p99": round(lat[int(len(lat) * 0.99)] * 1000, 1) if lat else 0, "max": round(lat[-1] * 1000, 1) if lat else 0},
|
|
317
|
+
"seconds": round(time.time() - t_start, 1),
|
|
318
|
+
"conntrack_at_end": conntrack(),
|
|
319
|
+
"loaders": {"fresh": args.fresh, "keepalive": args.keepalive},
|
|
320
|
+
})
|
|
321
|
+
finally:
|
|
322
|
+
stop[0] = True
|
|
323
|
+
for p in procs:
|
|
324
|
+
if p.poll() is None:
|
|
325
|
+
try:
|
|
326
|
+
os.killpg(p.pid, signal.SIGTERM)
|
|
327
|
+
except ProcessLookupError:
|
|
328
|
+
pass
|
|
329
|
+
if args.mode == "usr2":
|
|
330
|
+
# successors are not our children: stop each by the pid it logged
|
|
331
|
+
for log in scratch.glob("run*.log"):
|
|
332
|
+
for line in log.read_text().splitlines():
|
|
333
|
+
if "restart: started" in line:
|
|
334
|
+
try:
|
|
335
|
+
os.kill(int(line.rsplit(" ", 1)[1]), signal.SIGKILL)
|
|
336
|
+
except (ProcessLookupError, ValueError):
|
|
337
|
+
pass
|
|
338
|
+
if args.mode == "systemd":
|
|
339
|
+
sctl("stop", f"{unit}.service", f"{unit}.socket")
|
|
340
|
+
for ext in ("socket", "service"):
|
|
341
|
+
(unit_dir / f"{unit}.{ext}").unlink(missing_ok=True)
|
|
342
|
+
sctl("daemon-reload")
|
|
343
|
+
time.sleep(0.5)
|
|
344
|
+
if not os.environ.get("KEEP_SCRATCH"):
|
|
345
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
346
|
+
print(json.dumps(summary))
|
|
347
|
+
bad = summary.get("failed", 1) or (summary.get("writes_unaccounted") not in (None, 0))
|
|
348
|
+
sys.exit(1 if bad else 0)
|