@rhinestone/service-sdk 0.2.16 → 0.2.18
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/README.md +57 -10
- package/dist/chains.d.ts.map +1 -1
- package/dist/chains.js +85 -18
- package/dist/lifecycle.d.ts +82 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +96 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Shared TypeScript utilities for Rhinestone backend services — the pieces every service
|
|
4
4
|
needs and none of them should reimplement: OpenTelemetry setup and helpers, chain-aware gas
|
|
5
|
-
fee estimation, and BigInt-safe JSON serialization.
|
|
5
|
+
fee estimation, graceful-shutdown plumbing, and BigInt-safe JSON serialization.
|
|
6
6
|
|
|
7
7
|
Published to npm and consumed by `orchestrator`, `relayer`, `deposit-service-processor`,
|
|
8
8
|
`route-stats`, `back-office` (api), and `1auth` (passkey, recovery-signer).
|
|
@@ -25,6 +25,7 @@ bun add @rhinestone/service-sdk
|
|
|
25
25
|
|---|---|
|
|
26
26
|
| `@rhinestone/service-sdk` | Re-exports `./serialization` only |
|
|
27
27
|
| `@rhinestone/service-sdk/chains` | Chain-aware gas fee estimation |
|
|
28
|
+
| `@rhinestone/service-sdk/lifecycle` | Termination signals, crashes and explicit aborts as one channel |
|
|
28
29
|
| `@rhinestone/service-sdk/opentelemetry` | SDK setup, tracing/metrics helpers, propagation, integrations |
|
|
29
30
|
| `@rhinestone/service-sdk/serialization` | BigInt-safe JSON serialization |
|
|
30
31
|
|
|
@@ -46,26 +47,28 @@ orchestrator **bills** `maxFeePerGas`, but on-chain a transaction only ever pays
|
|
|
46
47
|
|
|
47
48
|
**Three estimation strategies**, selected per chain:
|
|
48
49
|
|
|
49
|
-
- **Windowed `eth_feeHistory`** (mainnet, sepolia, plasma) — pools tips across a
|
|
50
|
-
window and prices the base from the *next* block, escalating to a higher reward
|
|
50
|
+
- **Windowed `eth_feeHistory`** (mainnet, sepolia, plasma, bsc) — pools tips across a
|
|
51
|
+
20-block window and prices the base from the *next* block, escalating to a higher reward
|
|
51
52
|
percentile when the window's mean `gasUsedRatio` shows congestion. Chains land here
|
|
52
|
-
|
|
53
|
-
`eth_maxPriorityFeePerGas` reports ~3.5x what fills actually clear at)
|
|
54
|
-
|
|
53
|
+
for a volatile base fee (the L1s), a broken tip oracle (plasma, whose
|
|
54
|
+
`eth_maxPriorityFeePerGas` reports ~3.5x what fills actually clear at), or a floor-pinned
|
|
55
|
+
one (bsc, whose oracle reports the 0.05 gwei chain-min even when blocks are 98% full and
|
|
56
|
+
clearing at 0.1).
|
|
57
|
+
- **viem's estimate with a tip floor** (base, polygon, gnosis, hyperEVM, monad) — for
|
|
55
58
|
chains whose base fee is stable but whose tip oracle *under*-reports. Polygon and base
|
|
56
59
|
additionally window the feeHistory tip, since their real tip drifts above a static floor.
|
|
57
60
|
- **viem's estimate plain** — everything else.
|
|
58
61
|
|
|
59
62
|
On both viem paths a per-chain tip **cap** also applies where the oracle *over*-reports
|
|
60
|
-
(optimism, soneium, sonic, avalanche
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
(optimism, soneium, sonic, avalanche). Cap and floor can both be set for one chain, in
|
|
64
|
+
which case the cap applies first and the floor last, so a floor is never undercut. No cap
|
|
65
|
+
applies on the windowed path: a chain moves there precisely so its bid can follow the market.
|
|
63
66
|
|
|
64
67
|
`maxFeePerGas` is rebuilt as `nextBlockBaseFee * multiplier + tip` — the next block's base,
|
|
65
68
|
not the latest mined one, which is where most of the over-charge disappears. The multiplier
|
|
66
69
|
is per-chain and each path has its own table: 1.05x by default on the viem paths (overridden
|
|
67
70
|
for hyperEVM, gnosis, avalanche), and part of the per-chain fee config on the windowed path
|
|
68
|
-
(1.30x sepolia, 1.60x mainnet, 2.00x plasma).
|
|
71
|
+
(1.30x sepolia, 1.60x mainnet, 2.00x plasma, 1.00x bsc whose base fee is 0).
|
|
69
72
|
|
|
70
73
|
**`baseFeePerGas` in the result** lets a caller derive the expected price without a second
|
|
71
74
|
`eth_feeHistory` round trip. It matters because `maxFeePerGas` is a submission *cap* — right
|
|
@@ -105,6 +108,50 @@ from the value alone.
|
|
|
105
108
|
|
|
106
109
|
---
|
|
107
110
|
|
|
111
|
+
## Lifecycle
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import {
|
|
115
|
+
installTerminationHandlers,
|
|
116
|
+
terminationExitCode,
|
|
117
|
+
describeReason,
|
|
118
|
+
} from '@rhinestone/service-sdk/lifecycle'
|
|
119
|
+
|
|
120
|
+
const termination = installTerminationHandlers()
|
|
121
|
+
|
|
122
|
+
const reason = await Promise.race([serve(termination), termination.waitForTermination()])
|
|
123
|
+
|
|
124
|
+
logger.info(`shutting down: ${describeReason(reason)}`)
|
|
125
|
+
await teardownInOrder()
|
|
126
|
+
process.exit(terminationExitCode(reason))
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Folds every way a process can be asked to stop into one channel: `SIGTERM` / `SIGINT`, an
|
|
130
|
+
uncaught exception, an unhandled rejection, and an explicit `terminate(reason)` from code
|
|
131
|
+
that found a broken invariant. `waitForTermination()` resolves once with a discriminated
|
|
132
|
+
`TerminationReason`, so the caller can log it and pick an exit code instead of every call
|
|
133
|
+
site inventing its own.
|
|
134
|
+
|
|
135
|
+
**The first reason wins; later ones are dropped.** That is the point rather than a detail:
|
|
136
|
+
sockets closing during a shutdown routinely orphan promises, and those rejections must not
|
|
137
|
+
restart the shutdown or flip a clean stop to a failure.
|
|
138
|
+
|
|
139
|
+
`isTerminating()` is the same state as a synchronous boolean, for the callers that can't
|
|
140
|
+
await — readiness probes returning 503 the instant a signal lands, admission gates refusing
|
|
141
|
+
new work while in-flight work drains.
|
|
142
|
+
|
|
143
|
+
Two things to know:
|
|
144
|
+
|
|
145
|
+
- **Registering an `uncaughtException` / `unhandledRejection` listener stops Node exiting on
|
|
146
|
+
either.** The caller *must* exit once teardown is done, or a crashed process lingers.
|
|
147
|
+
- Nothing is registered at import time, and each install is independent. The install call is
|
|
148
|
+
the only thing that touches `process`, and `dispose()` removes exactly what it added, so
|
|
149
|
+
tests get a fresh one and own its disposal.
|
|
150
|
+
|
|
151
|
+
This module logs nothing itself; reporting is the owner's job, once the promise resolves.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
108
155
|
## OpenTelemetry
|
|
109
156
|
|
|
110
157
|
### Setup
|
package/dist/chains.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chains.d.ts","sourceRoot":"","sources":["../src/chains.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAmB,YAAY,EAAE,MAAM,MAAM,CAAA;AAmB3E,oDAAoD;AAMpD,MAAM,MAAM,mBAAmB,GAAG,IAAI,CACpC,YAAY,EACZ,oBAAoB,GAAG,eAAe,CACvC,GACC,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC,CAAA;AAEtC,2CAA2C;AAC3C,MAAM,MAAM,sBAAsB,GAAG;IACnC;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,QAAQ,CAAA;IACf;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8CG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAA;CAC/B,CAAA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,gBAAgB,GAAG,gBAAgB,GAAG;IAChD,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B,CAAA;
|
|
1
|
+
{"version":3,"file":"chains.d.ts","sourceRoot":"","sources":["../src/chains.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAmB,YAAY,EAAE,MAAM,MAAM,CAAA;AAmB3E,oDAAoD;AAMpD,MAAM,MAAM,mBAAmB,GAAG,IAAI,CACpC,YAAY,EACZ,oBAAoB,GAAG,eAAe,CACvC,GACC,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC,CAAA;AAEtC,2CAA2C;AAC3C,MAAM,MAAM,sBAAsB,GAAG;IACnC;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,QAAQ,CAAA;IACf;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8CG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAA;CAC/B,CAAA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,gBAAgB,GAAG,gBAAgB,GAAG;IAChD,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B,CAAA;AAqZD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,gBAAgB,CAAC,CAqE3B"}
|
package/dist/chains.js
CHANGED
|
@@ -12,7 +12,12 @@ const viemDefaults_1 = require("./viemDefaults");
|
|
|
12
12
|
// made the latest-block-only read jumpy;
|
|
13
13
|
// - broken-tip-oracle chains (Plasma): eth_maxPriorityFeePerGas over-prices
|
|
14
14
|
// the tip by orders of magnitude vs the chain's real rewards, and this path
|
|
15
|
-
// never consults the oracle — see FEE_CONFIG_OVERRIDES for the specifics
|
|
15
|
+
// never consults the oracle — see FEE_CONFIG_OVERRIDES for the specifics;
|
|
16
|
+
// - floor-pinned-oracle chains (BSC): the oracle reports the validators'
|
|
17
|
+
// configured minimum no matter how full blocks are, because geth's GPO
|
|
18
|
+
// samples the cheapest tx per block and a few min-price txs squeeze into
|
|
19
|
+
// every block. A static floor+cap around that number can never follow a
|
|
20
|
+
// congested market; only the rewards of included txs can (RHI-6956).
|
|
16
21
|
// Other chains stay on viem's default (some with a tip-floor overlay, see
|
|
17
22
|
// TIP_FLOOR_WEI): their base fee is stable or sequencer-pinned, so windowing
|
|
18
23
|
// adds nothing.
|
|
@@ -20,6 +25,7 @@ const HISTORICAL_FEE_CHAINS = new Set([
|
|
|
20
25
|
chains_1.sepolia.id,
|
|
21
26
|
chains_1.mainnet.id,
|
|
22
27
|
chains_1.plasma.id,
|
|
28
|
+
chains_1.bsc.id,
|
|
23
29
|
]);
|
|
24
30
|
const DEFAULT_FEE_CONFIG = {
|
|
25
31
|
historyBlocks: 20, // Geth FullNodeGPO window
|
|
@@ -140,6 +146,43 @@ const FEE_CONFIG_OVERRIDES = {
|
|
|
140
146
|
// every lag from 1 to 50 blocks. The floor only sets what we CHARGE.
|
|
141
147
|
tipFloorWei: 1400000n,
|
|
142
148
|
},
|
|
149
|
+
// BSC is on the historical path because its oracle is pinned to the chain-min,
|
|
150
|
+
// not because its base is volatile (base fee is 0, so the tip IS the bid).
|
|
151
|
+
//
|
|
152
|
+
// Incident 2026-09-10 (RHI-6956): blocks ran 97-98% full for ~2h with
|
|
153
|
+
// feeHistory rewards at 0.1 gwei from p10 up, while eth_gasPrice and
|
|
154
|
+
// eth_maxPriorityFeePerGas kept reporting the 0.05 chain-min. The previous
|
|
155
|
+
// viem-path config (static floor 0.05, static cap 0.055 — "realized 0.0542,
|
|
156
|
+
// market p50 0.052", measured 2026-07) could not bid above 0.055 whatever the
|
|
157
|
+
// market did, so every fill sat unmined for 5-8 minutes per nonce and landed
|
|
158
|
+
// 20+ minutes late, reverted. The tip cap does not apply on this path, which
|
|
159
|
+
// is the point: the bid follows the included-tx rewards.
|
|
160
|
+
//
|
|
161
|
+
// Percentiles simulated against live feeHistory at 8 points over the prior
|
|
162
|
+
// week (20-block windows, nearest-rank p50 across the window):
|
|
163
|
+
//
|
|
164
|
+
// window ratio p10/p25 p25/p50 p50/p75
|
|
165
|
+
// crunch (2026-09-10) 0.97 0.100 0.100 0.100
|
|
166
|
+
// onset (~2h earlier) 0.97 0.053 0.073 0.100
|
|
167
|
+
// busy (3 days prior) 0.56 0.050 0.057 0.117
|
|
168
|
+
// calm (2 samples) 0.21 0.050 0.050 0.050-0.051
|
|
169
|
+
//
|
|
170
|
+
// p25/p50 (mainnet's config) reacts at onset and never exceeds the market
|
|
171
|
+
// median; p50/p75 doubles the price on merely-busy days. BSC crosses the 0.5
|
|
172
|
+
// congestion ratio routinely, so the congested column is the common case.
|
|
173
|
+
[chains_1.bsc.id]: {
|
|
174
|
+
// Base fee is 0 on BSC, so this is inert; 100 states that rather than
|
|
175
|
+
// inheriting a headroom figure that means nothing here.
|
|
176
|
+
baseFeeMultiplierPct: 100n,
|
|
177
|
+
tipPercentile: 25,
|
|
178
|
+
congestedTipPercentile: 50,
|
|
179
|
+
// 0.05 gwei — the post-Maxwell chain-min. Carried over from the old static
|
|
180
|
+
// floor (probed 2026-07-13): it guards an upstream that reports 0 rewards,
|
|
181
|
+
// which on a zero-base chain would zero the ENTIRE bid. The shared 0.1 gwei
|
|
182
|
+
// default would double every calm-market BSC quote, where all included txs
|
|
183
|
+
// pay exactly the min and every percentile reads 0.05.
|
|
184
|
+
tipFloorWei: 50000000n,
|
|
185
|
+
},
|
|
143
186
|
};
|
|
144
187
|
// Chains where viem's default base-fee estimate is fine but its tip oracle
|
|
145
188
|
// (eth_maxPriorityFeePerGas) under-prices the priority fee versus the real
|
|
@@ -149,7 +192,6 @@ const FEE_CONFIG_OVERRIDES = {
|
|
|
149
192
|
// RHI-4468 audit and subsequent fill-tracker probes; each entry dated).
|
|
150
193
|
const TIP_FLOOR_WEI = {
|
|
151
194
|
[chains_1.base.id]: 3000000n, // 0.003 gwei — oracle ~0.001 (network ~p25); real fills ~0.003-0.0036 (probed 2026-07-08)
|
|
152
|
-
[chains_1.bsc.id]: 50000000n, // 0.05 gwei — post-Maxwell chain-min; oracle intermittently returns 0 through some upstreams, and with BSC's zero base fee that zeroes the ENTIRE bid (probed 2026-07-13)
|
|
153
195
|
[chains_1.polygon.id]: 45000000000n, // 45 gwei — Bor min 30 gwei x1.5; real p50 tip ~200 gwei
|
|
154
196
|
// 100 wei. Gnosis operates at a scale where gwei is the wrong unit to think
|
|
155
197
|
// in: its median base fee is 118 WEI (p10 24, p90 278k), and included-tx tips
|
|
@@ -200,8 +242,8 @@ const WINDOWED_TIP_CHAINS = new Set([chains_1.polygon.id, chains_1.base.id]);
|
|
|
200
242
|
// above what transactions on that chain actually pay to get included.
|
|
201
243
|
//
|
|
202
244
|
// Each value is set from the strongest evidence that chain has. Where we fill
|
|
203
|
-
// often enough for our own realized tip to mean something (Optimism, Soneium
|
|
204
|
-
//
|
|
245
|
+
// often enough for our own realized tip to mean something (Optimism, Soneium)
|
|
246
|
+
// the cap sits at that realized price. Where we do not (Sonic, Avalanche:
|
|
205
247
|
// 3-5 fills/wk, too few to be anything but noise — Gnosis' realized price over
|
|
206
248
|
// 3 fills came out BELOW its own base fee, which is impossible) it is set from
|
|
207
249
|
// eth_feeHistory rewards, the tips included transactions actually paid, at
|
|
@@ -217,7 +259,9 @@ const TIP_CAP_WEI = {
|
|
|
217
259
|
[chains_1.soneium.id]: 700000n, // 0.0007 gwei — realized tip 0.000706 across 1198 fills/wk (so this clamps a hair under realized), vs a flat 0.001 oracle quote
|
|
218
260
|
[chains_1.sonic.id]: 100000n, // 0.0001 gwei — deliberately 5x the 0.00002 market p90, since 3 fills/wk is too thin to trust; still a ~5000x cut from the 0.525 the oracle drove
|
|
219
261
|
[chains_1.avalanche.id]: 1000000n, // 0.001 gwei — market p75/p90; charged tip was 0.0117
|
|
220
|
-
|
|
262
|
+
// BSC's 0.055 cap was removed 2026-09-10 (RHI-6956): it doubled as the ceiling
|
|
263
|
+
// of what the relayer could bid, and held fills at 0.055 while the market
|
|
264
|
+
// cleared at 0.1. BSC now prices from feeHistory rewards (HISTORICAL_FEE_CHAINS).
|
|
221
265
|
};
|
|
222
266
|
const WINDOWED_TIP_HISTORY_BLOCKS = 20;
|
|
223
267
|
// Stays at p50 for both windowed chains. Polygon looked like a candidate for a
|
|
@@ -545,14 +589,16 @@ function recordDegradedEstimate(chainId, reason) {
|
|
|
545
589
|
span.addEvent('gas.estimate.degraded', { reason, 'chain.id': chainId });
|
|
546
590
|
}
|
|
547
591
|
/**
|
|
548
|
-
* Cap for the windowed estimator when eth_feeHistory
|
|
549
|
-
* fee. Recovers the base implied by
|
|
550
|
-
*
|
|
592
|
+
* Cap for the windowed estimator when eth_feeHistory could not supply a base
|
|
593
|
+
* fee (answered without one, or failed outright). Recovers the base implied by
|
|
594
|
+
* viem's own estimate and re-applies this chain's multiplier, never returning
|
|
595
|
+
* less than viem's cap or the tip itself.
|
|
551
596
|
*
|
|
552
|
-
*
|
|
597
|
+
* Takes viem's `fees` rather than the client so a caller that already read them
|
|
598
|
+
* (the outage path, which also needs the oracle tip) does not read them twice.
|
|
599
|
+
* Deliberately does NOT adopt viem's tip — see the call sites.
|
|
553
600
|
*/
|
|
554
|
-
|
|
555
|
-
const fees = await client.estimateFeesPerGas();
|
|
601
|
+
function viemFallbackCap(fees, client, config, priorityFee) {
|
|
556
602
|
const oracleTip = fees.maxPriorityFeePerGas;
|
|
557
603
|
const viemCap = fees.maxFeePerGas + (priorityFee - oracleTip);
|
|
558
604
|
let cap = viemCap;
|
|
@@ -575,12 +621,32 @@ async function estimateFeeHistoryGasFees(client, chainId, config, tipPercentileO
|
|
|
575
621
|
// #1:60.000000", verified live 2026-08-10 against publicnode and 1rpc — so the
|
|
576
622
|
// duplicate form is an RPC error, not a pricing choice.
|
|
577
623
|
const overridden = tipPercentileOverride !== undefined;
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
:
|
|
583
|
-
|
|
624
|
+
let feeHistory;
|
|
625
|
+
try {
|
|
626
|
+
feeHistory = await client.getFeeHistory({
|
|
627
|
+
blockCount: config.historyBlocks,
|
|
628
|
+
rewardPercentiles: overridden
|
|
629
|
+
? [tipPercentileOverride]
|
|
630
|
+
: [config.tipPercentile, config.congestedTipPercentile],
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
// eth_feeHistory failed outright. A feeHistory outage must never become a
|
|
635
|
+
// quote outage (CLAUDE.md), and until RHI-6956 this path had no fallback —
|
|
636
|
+
// the viem paths always did. There is no window to price from, so the only
|
|
637
|
+
// tip source left is viem's oracle, floored: on BSC that is exactly the
|
|
638
|
+
// pre-RHI-6956 pricing (chain-min), on Plasma it is the ~3.5x over-priced
|
|
639
|
+
// oracle this path exists to avoid — degraded, and recorded as such, but a
|
|
640
|
+
// quote. The base is recovered from viem's cap as in the missing-base case.
|
|
641
|
+
recordDegradedEstimate(chainId, 'feehistory_error');
|
|
642
|
+
const fees = await client.estimateFeesPerGas();
|
|
643
|
+
const priorityFee = (0, tipClamp_1.clampTip)(fees.maxPriorityFeePerGas, config.tipFloorWei, undefined);
|
|
644
|
+
return {
|
|
645
|
+
maxFeePerGas: viemFallbackCap(fees, client, config, priorityFee),
|
|
646
|
+
maxPriorityFeePerGas: priorityFee,
|
|
647
|
+
baseFeePerGas: null,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
584
650
|
const ratios = feeHistory.gasUsedRatio ?? [];
|
|
585
651
|
const meanRatio = ratios.length > 0
|
|
586
652
|
? ratios.reduce((sum, r) => sum + r, 0) / ratios.length
|
|
@@ -613,8 +679,9 @@ async function estimateFeeHistoryGasFees(client, chainId, config, tipPercentileO
|
|
|
613
679
|
// carrying the most fills through our pricing. The tip we already computed
|
|
614
680
|
// is the calibrated one.
|
|
615
681
|
recordDegradedEstimate(chainId, 'feehistory_missing_base');
|
|
682
|
+
const fees = await client.estimateFeesPerGas();
|
|
616
683
|
return {
|
|
617
|
-
maxFeePerGas:
|
|
684
|
+
maxFeePerGas: viemFallbackCap(fees, client, config, priorityFee),
|
|
618
685
|
maxPriorityFeePerGas: priorityFee,
|
|
619
686
|
baseFeePerGas: null,
|
|
620
687
|
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process termination as one observable event.
|
|
3
|
+
*
|
|
4
|
+
* A shutdown needs two different things, and a bare promise only gives the
|
|
5
|
+
* first: an *ordered* teardown (release this, then close that), and an
|
|
6
|
+
* *immediate* answer to "should I still take on new work?" for readiness probes
|
|
7
|
+
* and admission gates. Handing those callers a promise means each of them keeps
|
|
8
|
+
* its own boolean in sync with it, which is the bug this shape avoids.
|
|
9
|
+
*
|
|
10
|
+
* Every way a process can be asked to stop lands in the same channel:
|
|
11
|
+
*
|
|
12
|
+
* - an external signal — SIGTERM from an orchestrator, SIGINT from a terminal
|
|
13
|
+
* - an uncaught exception or an unhandled rejection
|
|
14
|
+
* - an explicit `terminate()` from code that found a broken invariant
|
|
15
|
+
*
|
|
16
|
+
* The first reason wins; anything after it is dropped. That is what keeps a
|
|
17
|
+
* crash *during* shutdown from restarting the shutdown or changing its exit
|
|
18
|
+
* code — orphaned promises rejecting as sockets close is normal, and must not
|
|
19
|
+
* turn a clean stop into a failed one.
|
|
20
|
+
*
|
|
21
|
+
* Nothing is registered at import time. `installTerminationHandlers` is the only
|
|
22
|
+
* thing that touches `process`, and `dispose()` undoes exactly what it added.
|
|
23
|
+
*/
|
|
24
|
+
/** Why the process was asked to stop. */
|
|
25
|
+
export type TerminationReason = {
|
|
26
|
+
kind: 'signal';
|
|
27
|
+
signal: NodeJS.Signals;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'uncaughtException';
|
|
30
|
+
error: Error;
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'unhandledRejection';
|
|
33
|
+
reason: unknown;
|
|
34
|
+
} | {
|
|
35
|
+
kind: 'explicit';
|
|
36
|
+
reason: string | Error | object;
|
|
37
|
+
};
|
|
38
|
+
export interface Termination {
|
|
39
|
+
/**
|
|
40
|
+
* Resolves once, with whatever asked the process to stop. Never rejects, and
|
|
41
|
+
* safe to await from several places; awaiting after the fact returns the
|
|
42
|
+
* reason that already settled. Reporting the reason is the owner's job — this
|
|
43
|
+
* module logs nothing.
|
|
44
|
+
*/
|
|
45
|
+
waitForTermination(): Promise<TerminationReason>;
|
|
46
|
+
/**
|
|
47
|
+
* True from the instant a reason arrives. For readiness gates and admission
|
|
48
|
+
* checks that must answer synchronously, long before teardown finishes.
|
|
49
|
+
*/
|
|
50
|
+
isTerminating(): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Ask the process to stop: an invariant broke and it must not keep running.
|
|
53
|
+
* The reason is carried through to the awaiting caller — an `Error` where
|
|
54
|
+
* there is one, or a string / context object where the caller has facts
|
|
55
|
+
* rather than a throw. First caller wins.
|
|
56
|
+
*/
|
|
57
|
+
terminate(reason: string | Error | object): void;
|
|
58
|
+
/** Remove the process listeners this install added. Idempotent. */
|
|
59
|
+
dispose(): void;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Register the process listeners and return the channel they feed.
|
|
63
|
+
*
|
|
64
|
+
* Each call is independent: it installs its own listeners and owns its own
|
|
65
|
+
* reason, so two installs in one process both fire. That is deliberate — tests
|
|
66
|
+
* want a fresh one — and it means the caller is responsible for `dispose()`.
|
|
67
|
+
*
|
|
68
|
+
* Registering an `uncaughtException` / `unhandledRejection` listener stops Node
|
|
69
|
+
* from exiting on either, so **the caller must exit itself** once teardown is
|
|
70
|
+
* done. Use `terminationExitCode` for the code.
|
|
71
|
+
*/
|
|
72
|
+
export declare function installTerminationHandlers(): Termination;
|
|
73
|
+
/**
|
|
74
|
+
* The exit code to leave with: 0 for a signal, 1 for everything else.
|
|
75
|
+
*
|
|
76
|
+
* A handled SIGTERM means the process did what it was asked, so a clean 0 —
|
|
77
|
+
* exiting non-zero there makes an orderly rollout look like a failed one.
|
|
78
|
+
*/
|
|
79
|
+
export declare function terminationExitCode(reason: TerminationReason): number;
|
|
80
|
+
/** One-line description of a reason, for the owner's shutdown log. */
|
|
81
|
+
export declare function describeReason(reason: TerminationReason): string;
|
|
82
|
+
//# sourceMappingURL=lifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,yCAAyC;AACzC,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,OAAO,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,mBAAmB,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,GAC3C;IAAE,IAAI,EAAE,oBAAoB,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,CAAA;CAAE,CAAA;AAEzD,MAAM,WAAW,WAAW;IAC1B;;;;;OAKG;IACH,kBAAkB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAAA;IAChD;;;OAGG;IACH,aAAa,IAAI,OAAO,CAAA;IACxB;;;;;OAKG;IACH,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,IAAI,CAAA;IAChD,mEAAmE;IACnE,OAAO,IAAI,IAAI,CAAA;CAChB;AAKD;;;;;;;;;;GAUG;AACH,wBAAgB,0BAA0B,IAAI,WAAW,CA2CxD;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAErE;AAED,sEAAsE;AACtE,wBAAgB,cAAc,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAWhE"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.installTerminationHandlers = installTerminationHandlers;
|
|
4
|
+
exports.terminationExitCode = terminationExitCode;
|
|
5
|
+
exports.describeReason = describeReason;
|
|
6
|
+
const serialization_1 = require("./serialization");
|
|
7
|
+
/** SIGTERM from orchestrators, SIGINT from terminals. */
|
|
8
|
+
const SIGNALS = ['SIGTERM', 'SIGINT'];
|
|
9
|
+
/**
|
|
10
|
+
* Register the process listeners and return the channel they feed.
|
|
11
|
+
*
|
|
12
|
+
* Each call is independent: it installs its own listeners and owns its own
|
|
13
|
+
* reason, so two installs in one process both fire. That is deliberate — tests
|
|
14
|
+
* want a fresh one — and it means the caller is responsible for `dispose()`.
|
|
15
|
+
*
|
|
16
|
+
* Registering an `uncaughtException` / `unhandledRejection` listener stops Node
|
|
17
|
+
* from exiting on either, so **the caller must exit itself** once teardown is
|
|
18
|
+
* done. Use `terminationExitCode` for the code.
|
|
19
|
+
*/
|
|
20
|
+
function installTerminationHandlers() {
|
|
21
|
+
let settledReason;
|
|
22
|
+
let resolve;
|
|
23
|
+
const reasonPromise = new Promise((r) => {
|
|
24
|
+
resolve = r;
|
|
25
|
+
});
|
|
26
|
+
const accept = (next) => {
|
|
27
|
+
if (settledReason !== undefined)
|
|
28
|
+
return;
|
|
29
|
+
settledReason = next;
|
|
30
|
+
resolve(next);
|
|
31
|
+
};
|
|
32
|
+
const onSignal = (signal) => {
|
|
33
|
+
accept({ kind: 'signal', signal });
|
|
34
|
+
};
|
|
35
|
+
const onUncaughtException = (error) => {
|
|
36
|
+
accept({ kind: 'uncaughtException', error });
|
|
37
|
+
};
|
|
38
|
+
const onUnhandledRejection = (reason) => {
|
|
39
|
+
accept({ kind: 'unhandledRejection', reason });
|
|
40
|
+
};
|
|
41
|
+
for (const signal of SIGNALS) {
|
|
42
|
+
process.on(signal, onSignal);
|
|
43
|
+
}
|
|
44
|
+
process.on('uncaughtException', onUncaughtException);
|
|
45
|
+
process.on('unhandledRejection', onUnhandledRejection);
|
|
46
|
+
return {
|
|
47
|
+
waitForTermination: () => reasonPromise,
|
|
48
|
+
isTerminating: () => settledReason !== undefined,
|
|
49
|
+
terminate: (reason) => {
|
|
50
|
+
accept({ kind: 'explicit', reason });
|
|
51
|
+
},
|
|
52
|
+
dispose: () => {
|
|
53
|
+
for (const signal of SIGNALS) {
|
|
54
|
+
process.off(signal, onSignal);
|
|
55
|
+
}
|
|
56
|
+
process.off('uncaughtException', onUncaughtException);
|
|
57
|
+
process.off('unhandledRejection', onUnhandledRejection);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The exit code to leave with: 0 for a signal, 1 for everything else.
|
|
63
|
+
*
|
|
64
|
+
* A handled SIGTERM means the process did what it was asked, so a clean 0 —
|
|
65
|
+
* exiting non-zero there makes an orderly rollout look like a failed one.
|
|
66
|
+
*/
|
|
67
|
+
function terminationExitCode(reason) {
|
|
68
|
+
return reason.kind === 'signal' ? 0 : 1;
|
|
69
|
+
}
|
|
70
|
+
/** One-line description of a reason, for the owner's shutdown log. */
|
|
71
|
+
function describeReason(reason) {
|
|
72
|
+
switch (reason.kind) {
|
|
73
|
+
case 'signal':
|
|
74
|
+
return `signal ${reason.signal}`;
|
|
75
|
+
case 'uncaughtException':
|
|
76
|
+
return `uncaught exception: ${formatValue(reason.error)}`;
|
|
77
|
+
case 'unhandledRejection':
|
|
78
|
+
return `unhandled rejection: ${formatValue(reason.reason)}`;
|
|
79
|
+
case 'explicit':
|
|
80
|
+
return `explicit termination: ${formatValue(reason.reason)}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function formatValue(value) {
|
|
84
|
+
if (value instanceof Error)
|
|
85
|
+
return value.stack ?? value.message;
|
|
86
|
+
if (typeof value === 'string')
|
|
87
|
+
return value;
|
|
88
|
+
try {
|
|
89
|
+
// Guards bigints and circular references, both plausible in a context
|
|
90
|
+
// object assembled at the point an invariant broke.
|
|
91
|
+
return (0, serialization_1.stringifyBigIntFields)(value);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return String(value);
|
|
95
|
+
}
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhinestone/service-sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.18",
|
|
4
4
|
"description": "Shared SDK for Rhinestone services",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Rhinestone",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./dist/index.js",
|
|
20
20
|
"./chains": "./dist/chains.js",
|
|
21
|
+
"./lifecycle": "./dist/lifecycle.js",
|
|
21
22
|
"./opentelemetry": "./dist/opentelemetry/index.js",
|
|
22
23
|
"./serialization": "./dist/serialization.js"
|
|
23
24
|
},
|
|
@@ -26,6 +27,9 @@
|
|
|
26
27
|
"chains": [
|
|
27
28
|
"dist/chains.d.ts"
|
|
28
29
|
],
|
|
30
|
+
"lifecycle": [
|
|
31
|
+
"dist/lifecycle.d.ts"
|
|
32
|
+
],
|
|
29
33
|
"opentelemetry": [
|
|
30
34
|
"dist/opentelemetry/index.d.ts"
|
|
31
35
|
],
|