Skip to content

Commit b19c15b

Browse files
Elastos DAOclaude
andcommitted
enm v0.5.230 — stdio:'ignore' on chain spawn + F28 CR Council Inactive
Two follow-ups to v0.5.229. ## 1. stdio:'ignore' on detached chain spawn (NativeProcessService:567) Pre-230 ENM spawned children with `stdio:['pipe','pipe','pipe']`. The stdin pipe was used (ela reads its keystore password from stdin per node.sh:878 + the BPoS arbiter mode password feed in ElaMainChainAdapter), but stdout/stderr pipes were never read on the ENM side — they were just held open for the child's lifetime, with ENM's runtime as the other end. Consequence: every ENM restart (deploy SIGTERM, crash, OOM) closed the stdout/stderr pipe FDs. The child's next write to stdout/stderr delivered SIGPIPE → default handler is terminate → ALL 8 child chains died on every ENM restart, even with detached:true. autoStart then respawned them ~60s later via the oracle/arbiter pairing logic shipped in v0.5.228. Operator-visible as "chains briefly down on every ENM deploy." Fix: `stdio:['pipe','ignore','ignore']`. stdin stays a pipe for the password feed; stdout/stderr resolve to /dev/null inside the child. The child can write to stdout/stderr forever without anyone closing on them; ENM exiting becomes invisible to the child's stdio. Combined with detached:true + child.unref(), children are now truly long-lived across ENM lifecycle events. Chain binaries already write their own logs via --logdir / --log flags, so dropping the unused pipes loses nothing in observability terms. ## 2. F28 — CR Council MemberState degraded (parallel to F12) F12 fires when a BPoS producer-registry record shows state='Inactive' (producer skipped rotation slots for too many consecutive rounds). F28 is its CR Council sibling: fires when this node's CR Committee record (in `listcurrentcrs.crmembersinfo[]`) shows MemberState != 'Elected'. Same risk class (missed rounds → lost rewards → eventual seat loss); same NEVER_AUTOMATIC tier (ENM can't recover — Activate requires the operator's owner key). Cited file:line for the MemberState enum: Elastos.ELA/cr/state/keyframe.go:24-42 State decision table: Elected → quiet (steady state) Inactive → WARN if impeachmentVotes=0, CRITICAL if >0 Impeached → CRITICAL (seat lost for term) Returned → CRITICAL (voluntary withdrawal) Terminated → CRITICAL (term ended) Illegal → CRITICAL (caught misbehaving — deposit forfeited) Wiring: - snap.cr populated by HealthChecker._fetchCrState (thin wrapper over CrMembershipService.detectCrMembership; reuses 30s cache) - Hard-gated to mainchain (snap.chainId === 'mainchain') - Hard-gated to actual CR members (cr.isCrMember === true) - Defensive null-guard pattern same as F12's null-guard on snap.bpos.producer - Filtered into HealthChecker's rule-runner alongside F12 + F25 Operator-facing copy is state-specific so the right recovery hint surfaces in the audit log. Activate/Recover from Inactive points at Essentials (where the operator's owner key lives). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d596fd0 commit b19c15b

4 files changed

Lines changed: 186 additions & 3 deletions

File tree

enm-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@elacity/enm-server",
3-
"version": "0.5.229",
3+
"version": "0.5.230",
44
"description": "Elastos Node Manager — standalone sidecar server for PC2 that runs and self-heals an Elastos mainchain node for BPoS supernode AND CR Council operators.",
55
"main": "src/server.js",
66
"author": "Elacity",

enm-server/src/services/HealthChecker.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,14 @@ class HealthChecker {
605605
? await this._fetchBposState(chainId, chainCfg, s)
606606
: null;
607607

608+
// v0.5.230 — CR Council membership snapshot for F28. Only the
609+
// mainchain has CR Committee state; non-Class-A chains pass null
610+
// and F28 self-gates on the chainId === 'mainchain' check. Best
611+
// effort; failure leaves cr=null and F28 stays quiet.
612+
const cr = (chainId === 'mainchain')
613+
? await this._fetchCrState(chainCfg).catch(() => null)
614+
: null;
615+
608616
const snap = {
609617
chainId,
610618
processStatus: this.processService.statusSync(chainId),
@@ -616,6 +624,7 @@ class HealthChecker {
616624
chainConfig: chainCfg,
617625
ruleState: s,
618626
bpos,
627+
cr,
619628
clockSkew,
620629
hostConflicts,
621630
};
@@ -625,7 +634,8 @@ class HealthChecker {
625634
const dets = HealthRules.runAll(snap).filter((d) =>
626635
d.ruleId === 'F5' || d.ruleId === 'F6' || d.ruleId === 'F8'
627636
|| d.ruleId === 'F11' || d.ruleId === 'F12' || d.ruleId === 'F13'
628-
|| d.ruleId === 'F19' || d.ruleId === 'F25');
637+
|| d.ruleId === 'F19' || d.ruleId === 'F25'
638+
|| d.ruleId === 'F28'); // v0.5.230 — CR Council MemberState degraded
629639
if (dets.length > 0) {
630640
await this.engine.apply(chainId, dets, chainCfg);
631641
}
@@ -931,6 +941,39 @@ class HealthChecker {
931941
snap.crossChainReach = reach;
932942
}
933943

944+
/**
945+
* v0.5.230 — fetch CR Council membership state for F28.
946+
*
947+
* Thin wrapper over CrMembershipService.detectCrMembership, sharing
948+
* its 30s in-process cache so the slow-tick re-poll doesn't hammer
949+
* mainchain RPC. Returns the same shape the service returns:
950+
* { isCrMember, state, nickname, impeachmentVotes, source, ... }
951+
* F28 reads .isCrMember + .state + .impeachmentVotes; everything else
952+
* is informational. Failure modes (no pubkey / RPC unreachable /
953+
* not-in-Committee) all surface via source !== 'matched'; F28
954+
* self-gates on isCrMember=true so non-Council operators don't
955+
* trigger it.
956+
*
957+
* @private
958+
* @param {object} chainCfg mainchain cfg block (read pubkey + RPC from)
959+
* @returns {Promise<object|null>}
960+
*/
961+
async _fetchCrState(chainCfg) {
962+
if (!chainCfg || !chainCfg.dpos || !chainCfg.dpos.nodePublicKey) {
963+
return null;
964+
}
965+
try {
966+
const CrMembershipService = require('./CrMembershipService');
967+
const ConfigStore = require('./ConfigStore');
968+
const cfg = await ConfigStore.load();
969+
return await CrMembershipService.detectCrMembership(cfg, {
970+
log: this.extensionHandle && this.extensionHandle.log,
971+
});
972+
} catch (_) {
973+
return null;
974+
}
975+
}
976+
934977
/**
935978
* beta.0.3.5 (Wave M4.5) — enrich a snapshot with parent-chain
936979
* fields for Class C (oracle) chains. F24 reads snap.parentChainId

enm-server/src/services/HealthRules.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,113 @@ function detectF27(snap) {
10481048
};
10491049
}
10501050

1051+
/**
1052+
* F28 — CR Council MemberState degraded (Class A / mainchain only).
1053+
*
1054+
* v0.5.230 — parallel to F12 for the Council operator audience. F12 fires
1055+
* when the BPoS producer-registry record reads state='Inactive'; F28
1056+
* fires when this node's CR Committee record (in `listcurrentcrs`'s
1057+
* `crmembersinfo[]`) reads MemberState != 'Elected'. Both rules surface
1058+
* the same kind of operator-facing risk (missed rotation rounds → lost
1059+
* rewards) for the two distinct roles a node can play in Elastos DPoS.
1060+
*
1061+
* Snap shape consumed: `snap.cr` populated by HealthChecker._fetchCrState
1062+
* (mirrors _fetchBposState), itself a thin wrapper over CrMembership
1063+
* Service.detectCrMembership. Null when:
1064+
* - chain is not class A (rule self-gates below)
1065+
* - operator has no node pubkey configured
1066+
* - mainchain RPC unreachable (CrMembershipService returns source='error')
1067+
* - the operator is not a CR Committee member (source='not-in-committee')
1068+
* In all those cases F28 stays quiet (returns null), same defensive
1069+
* pattern as F12's null-guard on snap.bpos.producer.
1070+
*
1071+
* State decision table (mirrors Elastos.ELA/cr/state/keyframe.go:24-42):
1072+
* Elected → no fire (steady state, healthy)
1073+
* Inactive → WARN if impeachmentVotes==0, CRITICAL if > 0 (close to
1074+
* impeachment threshold). Recoverable via Essentials →
1075+
* Activate CR member.
1076+
* Impeached → CRITICAL (impeachment threshold reached; seat lost for
1077+
* the rest of this term)
1078+
* Returned → CRITICAL (operator voluntarily withdrew; deposit
1079+
* returnable but seat gone)
1080+
* Terminated → CRITICAL (term ended without re-election; informational
1081+
* only post-term)
1082+
* Illegal → CRITICAL (caught misbehaving — deposit forfeited)
1083+
*
1084+
* Tier: NEVER_AUTOMATIC. ENM cannot recover any of these states for the
1085+
* operator: Activate / RecoverFromInactive require the operator's owner
1086+
* key (which ENM intentionally never holds, Rev-6 RNG findings). The
1087+
* summary points the operator at Essentials.
1088+
*
1089+
* Hard-gated to mainchain (snap.chainId === 'mainchain') because CR
1090+
* Committee membership is a Class-A-only concept; the rule runner would
1091+
* still skip non-A chains via _fetchCrState returning null, but the
1092+
* explicit chainId gate makes the intent clear in code-review.
1093+
*/
1094+
function detectF28(snap) {
1095+
if (!snap || snap.chainId !== 'mainchain') return null;
1096+
if (!snap.cr) return null;
1097+
const cr = snap.cr;
1098+
if (!cr.isCrMember) return null;
1099+
const state = String(cr.state || '').toLowerCase();
1100+
if (state === 'elected') return null; // healthy steady state
1101+
1102+
// Pre-fire severity decision. Inactive with no impeachment votes is
1103+
// WARN (still recoverable cheaply); Inactive with votes climbing,
1104+
// or any terminal state, is CRITICAL.
1105+
const impeachmentVotes = parseFloat(cr.impeachmentVotes || '0');
1106+
const isCritical = (state !== 'inactive') || (impeachmentVotes > 0);
1107+
const severity = isCritical ? 'CRITICAL' : 'WARNING';
1108+
1109+
// Recovery copy is state-specific so the operator gets the right hint.
1110+
let summaryAction;
1111+
let summaryReason;
1112+
if (state === 'inactive') {
1113+
summaryAction = 'CR Council member Inactive — Activate via Essentials';
1114+
summaryReason = 'Your CR Committee member record reads MemberState=Inactive '
1115+
+ '(the chain skipped your DPoS slot for too many consecutive rounds). '
1116+
+ 'You can recover by signing an Activate transaction from the wallet '
1117+
+ 'that holds your CR registration deposit, via Elastos Essentials. '
1118+
+ 'ENM cannot do this for you.'
1119+
+ (impeachmentVotes > 0 ? ` Impeachment votes: ${cr.impeachmentVotes} — `
1120+
+ 'address this before votes pass the impeachment threshold.' : '');
1121+
} else if (state === 'impeached') {
1122+
summaryAction = 'CR Council member Impeached — seat lost for this term';
1123+
summaryReason = 'Your CR Committee member record reads MemberState=Impeached. '
1124+
+ 'The impeachment vote threshold was reached on-chain; your seat is gone '
1125+
+ 'for the rest of this Committee term. Your registration deposit is still '
1126+
+ 'yours but Activate is no longer an option. Re-register via Essentials '
1127+
+ 'in the next CR election cycle.';
1128+
} else if (state === 'returned') {
1129+
summaryAction = 'CR Council member Returned — deposit refundable';
1130+
summaryReason = 'Your CR Committee member record reads MemberState=Returned, '
1131+
+ 'which means you voluntarily withdrew from the seat (or the chain '
1132+
+ 'returned you after impeachment). Deposit is refundable via Essentials; '
1133+
+ 'your DPoS slot is no longer in the arbiter slate.';
1134+
} else {
1135+
// Terminated / Illegal / any future MemberState value.
1136+
summaryAction = `CR Council member ${cr.state} — investigate via Essentials`;
1137+
summaryReason = `Your CR Committee member record reads MemberState=${cr.state}. `
1138+
+ 'This is a terminal state for the current term; check Elastos Essentials '
1139+
+ 'for the specific cause and next steps.';
1140+
}
1141+
1142+
return {
1143+
ruleId: 'F28',
1144+
tier: HEALING_TIERS.NEVER_AUTOMATIC,
1145+
severity,
1146+
summaryAction,
1147+
summaryReason,
1148+
payload: {
1149+
action: 'cr-council-investigate',
1150+
chainId: snap.chainId,
1151+
crState: cr.state,
1152+
impeachmentVotes: cr.impeachmentVotes || null,
1153+
nickname: cr.nickname || null,
1154+
},
1155+
};
1156+
}
1157+
10511158
/**
10521159
* Per-rule enable defaults. Per Architectural Invariant #7, healing ships
10531160
* with F1 (auto-restart on unexpected exit) only. F2-F19 are off until
@@ -1135,6 +1242,9 @@ const RULE_METADATA = Object.freeze({
11351242
// v0.5.185 (P1-A) — Class B-only, alert-only. PBFT consensus-recovery stall.
11361243
F27: { tier: 'CRITICAL_NOTIFY', title: 'EVM consensus-recovery stall',
11371244
description: 'On an EVM sidechain (ESC/EID/PG) that is stuck for >20 min with peers but a PBFT recovery-stall log signature ("wait for recoved states" / "can not find active peer"), surface a critical alert. This is a quorum / peer problem, not a data fork — an auto-resync cannot fix it, so F26 yields to this alert and the operator restores peers/bootnodes instead.' },
1245+
// v0.5.230 — Class A (mainchain) — alert-only. CR Council MemberState drift.
1246+
F28: { tier: 'NEVER_AUTOMATIC', title: 'CR Council member state degraded',
1247+
description: 'Parallel to F12 for BPoS producers. Fires when this node\'s CR Committee MemberState is anything other than \'Elected\' — typically Inactive (skipped slots for too many consecutive rounds), Impeached, Returned, Terminated, or Illegal. Recovery for Inactive is the Activate flow in Elastos Essentials; other states are terminal-for-the-term and need the operator to investigate via Essentials. ENM cannot recover this for the operator (no owner key).' },
11381248
});
11391249

11401250
// beta.3.22 — every rule is enabled by default. The operator-facing
@@ -1171,6 +1281,7 @@ const DEFAULT_ENABLED = Object.freeze({
11711281
F23: true, // beta.0.3.14 — Class D arbiter cross-chain unreachable
11721282
F26: true, // v0.5.184 — Class B wedged-fork auto-resync (rate-limited)
11731283
F27: true, // v0.5.185 — Class B PBFT recovery-stall alert (alert-only)
1284+
F28: true, // v0.5.230 — Class A CR Council MemberState degraded (alert-only)
11741285
});
11751286

11761287
// Global rule overrides (apply to all chains). Pre-3.87 this was the only
@@ -1303,6 +1414,8 @@ function runAll(snap) {
13031414
['F24', detectF24],
13041415
// beta.0.3.14 (Wave M6.5) — Class D arbiter cross-chain.
13051416
['F23', detectF23],
1417+
// v0.5.230 — Class A CR Council MemberState degraded (F12 sibling).
1418+
['F28', detectF28],
13061419
];
13071420

13081421
// beta.3.87 — Wave M1.3 — DPoS-only rules. F11 (rotation stuck),
@@ -1399,6 +1512,7 @@ module.exports = {
13991512
detectF23, // beta.0.3.14 (Wave M6.5)
14001513
detectF26, // v0.5.184 — Class B wedged-fork auto-resync
14011514
detectF27, // v0.5.185 (P1-A) — Class B PBFT recovery-stall alert
1515+
detectF28, // v0.5.230 — Class A CR Council MemberState degraded
14021516
EVM_FORK_STALL_GRACE_MS,
14031517
SPV_CAUGHTUP_MAX_DELTA, // v0.5.185 (P0-B)
14041518
PEER_ZERO_GRACE_MS,

enm-server/src/services/NativeProcessService.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,13 +558,39 @@ class NativeProcessService extends EventEmitter {
558558
// stdin, so the post-spawn keystore-password pipe (ela/arbiter) still
559559
// reaches the chain. argv is forwarded verbatim ($0=binary, "$@"=args).
560560
const NOFILE_SOFT_TARGET = 40960;
561+
// v0.5.230 — stdio: keep stdin as a pipe (ela reads its keystore
562+
// password from stdin per node.sh:878 + the BPoS arbiter mode
563+
// password feed below), but route stdout/stderr to /dev/null
564+
// ('ignore') instead of through ENM's runtime pipes.
565+
//
566+
// Why: every chain binary already writes its own logs via its
567+
// own --log/--logdir flags (ela → chains/mainchain/elastos/logs/
568+
// node/*.log; geth forks → their own logdir; oracle scripts →
569+
// their stdout was unread anyway). The pre-230 ['pipe', 'pipe',
570+
// 'pipe'] only existed for the stdin password feed; the stdout/
571+
// stderr pipes back to ENM were never read, but they DID hold an
572+
// FD attached to ENM's process lifecycle.
573+
//
574+
// The consequence pre-230: when ENM exited (deploy SIGTERM,
575+
// crash, OOM, anything), Node closed those pipe FDs. The
576+
// chain's NEXT write to stdout/stderr would deliver SIGPIPE →
577+
// the chain process terminates by default. Net effect: every
578+
// ENM restart killed all 8 child chains, even with detached:
579+
// true. autoStart then respawned them ~60s later. Operator-
580+
// visible as "all chains briefly down on every deploy."
581+
//
582+
// 'ignore' makes the kernel-level fd be /dev/null inside the
583+
// child. The child can write to stdout/stderr forever without
584+
// anyone closing on them — ENM exiting is invisible to the
585+
// child's stdio. Combined with detached:true + child.unref(),
586+
// children are now truly long-lived across ENM lifecycle events.
561587
const child = spawn(
562588
'/bin/sh',
563589
['-c', `ulimit -n ${NOFILE_SOFT_TARGET} 2>/dev/null; exec "$0" "$@"`, binaryPath, ...spawnArgs],
564590
{
565591
cwd,
566592
env: childEnv,
567-
stdio: ['pipe', 'pipe', 'pipe'],
593+
stdio: ['pipe', 'ignore', 'ignore'],
568594
detached: true,
569595
},
570596
);

0 commit comments

Comments
 (0)