Skip to content

Commit 4f458d3

Browse files
authored
Alternate strategy for unhandled exceptions (#2037)
This PRs adds an alternate strategy to handle unhandled exceptions. The goal is to avoid unnecessary mass force-close, but it is reserved to advanced users who closely monitor the node. Available strategies are: - local force close of the channel (default) - log an error message and stop the node Default settings maintain the same behavior as before.
1 parent 2c0c24e commit 4f458d3

6 files changed

Lines changed: 67 additions & 3 deletions

File tree

docs/release-notes/eclair-vnext.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
## Major changes
66

7+
### Alternate strategy to avoid mass force-close of channels in certain cases
8+
9+
The default strategy, when an unhandled exception or internal error happens, is to locally force-close the channel. Not only is there a delay before the channel balance gets refunded, but if the exception was due to some misconfiguration or bug in eclair that affects all channels, we risk force-closing all channels.
10+
11+
This is why an alternative behavior is to simply log an error and stop the node. Note that if you don't closely monitor your node, there is a risk that your peers take advantage of the downtime to try and cheat by publishing a revoked commitment. Additionally, while there is no known way of triggering an internal error in eclair from the outside, there may very well be a bug that allows just that, which could be used as a way to remotely stop the node (with the default behavior, it would "only" cause a local force-close of the channel).
12+
713
### Separate log for important notifications
814

915
Eclair added a new log file (`notifications.log`) for important notifications that require an action from the node operator.

eclair-core/src/main/resources/reference.conf

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ eclair {
9595
max-block-processing-delay = 30 seconds // we add a random delay before processing blocks, capped at this value, to prevent herd effect
9696
max-tx-publish-retry-delay = 60 seconds // we add a random delay before retrying failed transaction publication
9797

98+
// The default strategy, when we encounter an unhandled exception or internal error, is to locally force-close the
99+
// channel. Not only is there a delay before the channel balance gets refunded, but if the exception was due to some
100+
// misconfiguration or bug in eclair that affects all channels, we risk force-closing all channels.
101+
// This is why an alternative behavior is to simply log an error and stop the node. Note that if you don't closely
102+
// monitor your node, there is a risk that your peers take advantage of the downtime to try and cheat by publishing a
103+
// revoked commitment. Additionally, while there is no known way of triggering an internal error in eclair from the
104+
// outside, there may very well be a bug that allows just that, which could be used as a way to remotely stop the node
105+
// (with the default behavior, it would "only" cause a local force-close of the channel).
106+
unhandled-exception-strategy = "local-close" // local-close or stop
107+
98108
relay {
99109
fees {
100110
// Fees for public channels

eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import fr.acinq.bitcoin.{Block, ByteVector32, Crypto, Satoshi}
2222
import fr.acinq.eclair.Setup.Seeds
2323
import fr.acinq.eclair.blockchain.fee._
2424
import fr.acinq.eclair.channel.Channel
25+
import fr.acinq.eclair.channel.Channel.UnhandledExceptionStrategy
2526
import fr.acinq.eclair.crypto.Noise.KeyPair
2627
import fr.acinq.eclair.crypto.keymanager.{ChannelKeyManager, NodeKeyManager}
2728
import fr.acinq.eclair.db._
@@ -76,6 +77,7 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
7677
relayParams: RelayParams,
7778
reserveToFundingRatio: Double,
7879
maxReserveToFundingRatio: Double,
80+
unhandledExceptionStrategy: UnhandledExceptionStrategy,
7981
db: Databases,
8082
revocationTimeout: FiniteDuration,
8183
autoReconnect: Boolean,
@@ -357,6 +359,11 @@ object NodeParams extends Logging {
357359
PathFindingExperimentConf(experiments.toMap)
358360
}
359361

362+
val unhandledExceptionStrategy = config.getString("unhandled-exception-strategy") match {
363+
case "local-close" => UnhandledExceptionStrategy.LocalClose
364+
case "stop" => UnhandledExceptionStrategy.Stop
365+
}
366+
360367
val routerSyncEncodingType = config.getString("router.sync.encoding-type") match {
361368
case "uncompressed" => EncodingType.UNCOMPRESSED
362369
case "zlib" => EncodingType.COMPRESSED_ZLIB
@@ -423,6 +430,7 @@ object NodeParams extends Logging {
423430
),
424431
reserveToFundingRatio = config.getDouble("reserve-to-funding-ratio"),
425432
maxReserveToFundingRatio = config.getDouble("max-reserve-to-funding-ratio"),
433+
unhandledExceptionStrategy = unhandledExceptionStrategy,
426434
db = database,
427435
revocationTimeout = FiniteDuration(config.getDuration("revocation-timeout").getSeconds, TimeUnit.SECONDS),
428436
autoReconnect = config.getBoolean("auto-reconnect"),

eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ object Channel {
127127
/** We don't immediately process [[CurrentBlockCount]] to avoid herd effects */
128128
case class ProcessCurrentBlockCount(c: CurrentBlockCount)
129129

130+
// @formatter:off
131+
/** What do we do if we have a local unhandled exception. */
132+
sealed trait UnhandledExceptionStrategy
133+
object UnhandledExceptionStrategy {
134+
/** Ask our counterparty to close the channel. This may be the best choice for smaller loosely administered nodes.*/
135+
case object LocalClose extends UnhandledExceptionStrategy
136+
/** Just log an error and stop the node. May be better for larger nodes, to prevent unwanted mass force-close.*/
137+
case object Stop extends UnhandledExceptionStrategy
138+
}
139+
// @formatter:on
140+
130141
}
131142

132143
class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remoteNodeId: PublicKey, blockchain: typed.ActorRef[ZmqWatcher.Command], relayer: ActorRef, txPublisherFactory: Channel.TxPublisherFactory, origin_opt: Option[ActorRef] = None)(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) extends FSM[ChannelState, ChannelData] with FSMDiagnosticActorLogging[ChannelState, ChannelData] {
@@ -1669,6 +1680,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo
16691680
case syncSuccess: SyncResult.Success =>
16701681
var sendQueue = Queue.empty[LightningMessage]
16711682
// normal case, our data is up-to-date
1683+
16721684
if (channelReestablish.nextLocalCommitmentNumber == 1 && d.commitments.localCommit.index == 0) {
16731685
// If next_local_commitment_number is 1 in both the channel_reestablish it sent and received, then the node MUST retransmit funding_locked, otherwise it MUST NOT
16741686
log.debug("re-sending fundingLocked")
@@ -2288,7 +2300,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo
22882300

22892301
private def handleLocalError(cause: Throwable, d: ChannelData, msg: Option[Any]) = {
22902302
cause match {
2291-
case _: ForcedLocalCommit => log.warning(s"force-closing channel at user request")
2303+
case _: ForcedLocalCommit =>
2304+
log.warning(s"force-closing channel at user request")
22922305
case _ if msg.exists(_.isInstanceOf[OpenChannel]) || msg.exists(_.isInstanceOf[AcceptChannel]) =>
22932306
// invalid remote channel parameters are logged as warning
22942307
log.warning(s"${cause.getMessage} while processing msg=${msg.getOrElse("n/a").getClass.getSimpleName} in state=$stateName")
@@ -2308,7 +2321,31 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo
23082321
log.info(s"we have a valid closing tx, publishing it instead of our commitment: closingTxId=${bestUnpublishedClosingTx.tx.txid}")
23092322
// if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that
23102323
handleMutualClose(bestUnpublishedClosingTx, Left(negotiating))
2311-
case dd: HasCommitments => spendLocalCurrent(dd) sending error // otherwise we use our current commitment
2324+
case dd: HasCommitments =>
2325+
cause match {
2326+
case _: ChannelException =>
2327+
// known channel exception: we force close using our current commitment
2328+
spendLocalCurrent(dd) sending error
2329+
case _ =>
2330+
// unhandled exception: we apply the configured strategy
2331+
nodeParams.unhandledExceptionStrategy match {
2332+
case UnhandledExceptionStrategy.LocalClose =>
2333+
spendLocalCurrent(dd) sending error
2334+
case UnhandledExceptionStrategy.Stop =>
2335+
log.error("unhandled exception: standard procedure would be to force-close the channel, but eclair has been configured to halt instead.")
2336+
NotificationsLogger.logFatalError(
2337+
s"""stopping node as configured strategy to unhandled exceptions for nodeId=$remoteNodeId channelId=${d.channelId}
2338+
|
2339+
|Eclair has been configured to shut down when an unhandled exception happens, instead of requesting a
2340+
|force-close from the peer. This gives the operator a chance of avoiding an unnecessary mass force-close
2341+
|of channels that may be caused by a bug in Eclair, or issues like running out of disk space, etc.
2342+
|
2343+
|You should get in touch with Eclair developers and provide logs of your node for analysis.
2344+
|""".stripMargin, cause)
2345+
System.exit(1)
2346+
stop(FSM.Shutdown)
2347+
}
2348+
}
23122349
case _ => goto(CLOSED) sending error // when there is no commitment yet, we just send an error to our peer and go to CLOSED state
23132350
}
23142351
}

eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ object Helpers {
324324
case class LocalLateUnproven(ourRemoteCommitmentNumber: Long, theirLocalCommitmentNumber: Long) extends Failure
325325
case class RemoteLying(ourLocalCommitmentNumber: Long, theirRemoteCommitmentNumber: Long, invalidPerCommitmentSecret: PrivateKey) extends Failure
326326
case object RemoteLate extends Failure
327-
}
327+
}
328328
// @formatter:on
329329

330330
/**

eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import fr.acinq.bitcoin.{Block, ByteVector32, Satoshi, SatoshiLong, Script}
2020
import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional}
2121
import fr.acinq.eclair.Features._
2222
import fr.acinq.eclair.blockchain.fee._
23+
import fr.acinq.eclair.channel.Channel.UnhandledExceptionStrategy
2324
import fr.acinq.eclair.channel.LocalParams
2425
import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager}
2526
import fr.acinq.eclair.io.{Peer, PeerConnection}
@@ -143,6 +144,7 @@ object TestConstants {
143144
feeProportionalMillionths = 30)),
144145
reserveToFundingRatio = 0.01, // note: not used (overridden below)
145146
maxReserveToFundingRatio = 0.05,
147+
unhandledExceptionStrategy = UnhandledExceptionStrategy.LocalClose,
146148
db = TestDatabases.inMemoryDb(),
147149
revocationTimeout = 20 seconds,
148150
autoReconnect = false,
@@ -269,6 +271,7 @@ object TestConstants {
269271
feeProportionalMillionths = 30)),
270272
reserveToFundingRatio = 0.01, // note: not used (overridden below)
271273
maxReserveToFundingRatio = 0.05,
274+
unhandledExceptionStrategy = UnhandledExceptionStrategy.LocalClose,
272275
db = TestDatabases.inMemoryDb(),
273276
revocationTimeout = 20 seconds,
274277
autoReconnect = false,

0 commit comments

Comments
 (0)