This commit is contained in:
Vandit Singh 2026-07-27 11:12:23 +02:00 committed by GitHub
commit c6d6a783f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 716 additions and 113 deletions

View file

@ -579,7 +579,8 @@ func defaultConfig() *Config {
// loadAndValidateConfig loads the terminal's main configuration and validates
// its content.
func loadAndValidateConfig(ctx context.Context,
interceptor signal.Interceptor) (*Config, error) {
interceptor signal.Interceptor,
litdShutdown *shutdownSource) (*Config, error) {
// Start with the default configuration.
preCfg := defaultConfig()
@ -614,7 +615,7 @@ func loadAndValidateConfig(ctx context.Context,
preCfg.Lnd.SubLogMgr = build.NewSubLoggerManager(
build.NewDefaultLogHandlers(logCfg, preCfg.Lnd.LogRotator)...,
)
SetupLoggers(preCfg.Lnd.SubLogMgr, interceptor)
SetupLoggers(preCfg.Lnd.SubLogMgr, interceptor, litdShutdown)
// Load the main configuration file and parse any command line options.
// This function will also set up logging properly.

View file

@ -33,8 +33,23 @@
### Functional Changes/Additions
* [Keep litd running after lnd
stops](https://github.com/lightninglabs/lightning-terminal/pull/1329):
In integrated mode, litd no longer shuts down when lnd stops. It now marks lnd
as errored, tears down the lnd dependent sub-servers and keeps running so its
status endpoint stays available to report what happened. Note that calling
lnd's `StopDaemon` (for example `lncli stop`) no longer stops litd; use litd's
own `StopDaemon` to stop everything.
### Technical and Architectural Updates
* [Stop itest litd nodes via litd's own
`StopDaemon`](https://github.com/lightninglabs/lightning-terminal/pull/1356):
The itest harness now sets up a lit connection for every node, seed based or
not, so nodes are stopped through litd's own `StopDaemon`. For stateless init
nodes, which keep no macaroon files on disk, the harness bakes a lit super
macaroon from the admin macaroon to authenticate that call.
* [Report litd's own version for `litd
-V`](https://github.com/lightninglabs/lightning-terminal/pull/1337): The `-V`
flag now prints litd's version instead of the integrated lnd version.

View file

@ -0,0 +1,129 @@
package itest
import (
"context"
"fmt"
"github.com/lightninglabs/lightning-terminal/litrpc"
"github.com/lightninglabs/lightning-terminal/subservers"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntest/wait"
"github.com/stretchr/testify/require"
)
// testLitdSurvivesLndShutdown asserts that when lnd stops in integrated mode,
// litd keeps running and its status endpoint stays reachable, reporting lnd as
// stopped rather than cascading to a full litd shutdown.
func testLitdSurvivesLndShutdown(ctx context.Context, net *NetworkHarness,
t *harnessTest) {
// Use a dedicated, throwaway node so that stopping its lnd cannot
// affect any other test.
node, err := net.NewNode(
t.t, "lnd-stop", nil, false, true, "--autopilot.disable",
)
require.NoError(t.t, err)
// The harness ShutdownNode stops litd (via its StopDaemon) and waits
// for the process to exit, so no manual litd shutdown is needed here.
defer func() {
_ = net.ShutdownNode(node)
}()
ctxt, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
rawConn, err := connectLitRPC(
ctxt, node.Cfg.LitAddr(), node.Cfg.LitTLSCertPath,
node.Cfg.LitMacPath,
)
require.NoError(t.t, err)
defer func() { _ = rawConn.Close() }()
statusClient := litrpc.NewStatusClient(rawConn)
// lnd should be running before we stop it.
resp, err := statusClient.SubServerStatus(
ctxt, &litrpc.SubServerStatusReq{},
)
require.NoError(t.t, err)
lndStatus, ok := resp.SubServers[subservers.LND]
require.True(t.t, ok, "expected lnd sub-server status")
require.True(t.t, lndStatus.Running, "lnd should be running initially")
// Stop lnd (but not litd) via the lnrpc StopDaemon. The call may return
// an error as the connection is torn down, which is expected.
_, _ = node.LightningClient.StopDaemon(ctxt, &lnrpc.StopRequest{})
// litd's status endpoint must remain reachable and must report lnd as
// no longer running, with an error explaining why. We poll because lnd
// shutdown is asynchronous.
err = wait.NoError(func() error {
ctxs, cancels := context.WithTimeout(
context.Background(), defaultTimeout,
)
defer cancels()
resp, err := statusClient.SubServerStatus(
ctxs, &litrpc.SubServerStatusReq{},
)
if err != nil {
return fmt.Errorf("litd status endpoint unreachable "+
"after lnd stopped: %w", err)
}
lndStatus, ok := resp.SubServers[subservers.LND]
if !ok {
return fmt.Errorf("no lnd sub-server status returned")
}
if lndStatus.Running {
return fmt.Errorf("lnd still reported as running")
}
if lndStatus.Error == "" {
return fmt.Errorf("lnd error message not yet set")
}
return nil
}, defaultTimeout)
require.NoError(t.t, err)
// litd's process itself must keep running after lnd has stopped: its
// status endpoint stays reachable. The LIT sub-server is reported as no
// longer running, since litd cannot function without lnd, but the
// endpoint continuing to answer at all is what proves litd did not
// cascade down with lnd. Check this holds over time.
err = wait.InvariantNoError(func() error {
ctxs, cancels := context.WithTimeout(
context.Background(), defaultTimeout,
)
defer cancels()
resp, err := statusClient.SubServerStatus(
ctxs, &litrpc.SubServerStatusReq{},
)
if err != nil {
return fmt.Errorf("litd status endpoint must stay "+
"reachable after lnd stopped: %w", err)
}
litStatus, ok := resp.SubServers[subservers.LIT]
if !ok {
return fmt.Errorf("no lit sub-server status returned")
}
if litStatus.Running {
return fmt.Errorf("lit sub-server should be reported " +
"as stopped once lnd has stopped")
}
if litStatus.Error == "" {
return fmt.Errorf("lit sub-server error message not set")
}
return nil
}, defaultTimeout)
require.NoError(t.t, err)
}

View file

@ -45,7 +45,10 @@ import (
"github.com/lightningnetwork/lnd/lntest/wait"
"github.com/lightningnetwork/lnd/macaroons"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"gopkg.in/macaroon.v2"
)
@ -384,6 +387,13 @@ type HarnessNode struct {
// litConn is the underlying connection to Lit's grpc endpoint.
litConn *grpc.ClientConn
// litMacPath, when set, is the macaroon file used to authenticate
// litConn. It is set for stateless init nodes, which have no lit
// macaroon on disk: we bake a super macaroon from the in-memory admin
// macaroon and point this at it so the node can still be stopped via
// litd's own StopDaemon.
litMacPath string
// RouterClient, WalletKitClient, WatchtowerClient cannot be embedded,
// because a name collision would occur with LightningClient.
RouterClient routerrpc.RouterClient
@ -776,31 +786,9 @@ func (hn *HarnessNode) Start(litdBinary string, litdError chan<- error,
return nil
}
err = hn.initLightningClient(conn)
if err != nil {
return fmt.Errorf("could not init Lightning Client: %w", err)
}
// Also connect to Lit's RPC port for any Litd specific calls.
litConn, err := connectLitRPC(
context.Background(), hn.Cfg.LitAddr(), hn.Cfg.LitTLSCertPath,
hn.Cfg.LitMacPath,
)
if err != nil {
return fmt.Errorf("could not connect to Lit RPC: %w", err)
}
hn.litConn = litConn
ctxt, cancel := context.WithTimeout(
context.Background(), lntest.DefaultTimeout,
)
defer cancel()
return wait.NoError(func() error {
litConn := litrpc.NewProxyClient(hn.litConn)
_, err = litConn.GetInfo(ctxt, &litrpc.GetInfoRequest{})
return err
}, lntest.DefaultTimeout)
// initLightningClient also connects to Lit's RPC port and sets
// hn.litConn so the node can be stopped via litd's own StopDaemon.
return hn.initLightningClient(conn)
}
// WaitForLNDWalletReady waits until the wallet state flips from
@ -1087,6 +1075,60 @@ func (hn *HarnessNode) initClientWhenReady(timeout time.Duration) error {
return hn.initLightningClient(conn)
}
// bakeLitSuperMacaroon bakes a lit super macaroon from the given admin macaroon
// (returned in-memory during stateless init, where no lit macaroon is written
// to disk), writes it next to the node's lit config, and records its path so
// litConn can authenticate against it. This lets the harness stop a stateless
// node via litd's own StopDaemon.
func (hn *HarnessNode) bakeLitSuperMacaroon(adminMac []byte) error {
ctxt, cancel := context.WithTimeout(
context.Background(), lntest.DefaultTimeout,
)
defer cancel()
rawConn, err := connectLitRPC(
ctxt, hn.Cfg.LitAddr(), hn.Cfg.LitTLSCertPath, "",
)
if err != nil {
return err
}
defer rawConn.Close()
// Attach the admin macaroon to the request context so litd authorizes
// the bake call.
macCtx := metadata.NewOutgoingContext(ctxt, metadata.MD{
"macaroon": []string{hex.EncodeToString(adminMac)},
})
litConn := litrpc.NewProxyClient(rawConn)
resp, err := litConn.BakeSuperMacaroon(
macCtx, &litrpc.BakeSuperMacaroonRequest{
RootKeyIdSuffix: 0,
ReadOnly: false,
},
)
if err != nil {
return err
}
// BakeSuperMacaroon hex encodes the returned macaroon.
superMacBytes, err := hex.DecodeString(resp.Macaroon)
if err != nil {
return err
}
macPath := filepath.Join(
filepath.Dir(hn.Cfg.LitMacPath), "lit-super.macaroon",
)
if err := os.WriteFile(macPath, superMacBytes, 0644); err != nil {
return err
}
hn.litMacPath = macPath
return nil
}
// Init initializes a harness node by passing the init request via rpc. After
// the request is submitted, this method will block until a
// macaroon-authenticated RPC connection can be established to the harness node.
@ -1126,6 +1168,17 @@ func (hn *HarnessNode) Init(ctx context.Context,
return nil, err
}
// In stateless init mode no lit macaroon is written to disk, so bake a
// super macaroon from the admin macaroon we just received. The harness
// uses it to stop the node via litd's own StopDaemon.
if initReq.StatelessInit {
err := hn.bakeLitSuperMacaroon(response.AdminMacaroon)
if err != nil {
return nil, fmt.Errorf("could not bake lit super "+
"macaroon: %w", err)
}
}
return response, hn.initLightningClient(conn)
}
@ -1169,6 +1222,17 @@ func (hn *HarnessNode) InitChangePassword(ctx context.Context,
return nil, err
}
// In stateless init mode no lit macaroon is written to disk, so bake a
// super macaroon from the admin macaroon we just received. The harness
// uses it to stop the node via litd's own StopDaemon.
if chngPwReq.StatelessInit {
err := hn.bakeLitSuperMacaroon(response.AdminMacaroon)
if err != nil {
return nil, fmt.Errorf("could not bake lit super "+
"macaroon: %w", err)
}
}
return response, hn.initLightningClient(conn)
}
@ -1252,6 +1316,41 @@ func (hn *HarnessNode) initLightningClient(conn *grpc.ClientConn) error {
return err
}
// Also connect to Lit's RPC port for any litd specific calls. We do
// this here, rather than only on the no-seed path in start, so that
// seed based nodes (which reach this method via Init or Unlock) get a
// litConn too. Stop relies on it to shut the node down via litd's own
// StopDaemon, which is the only way to bring litd down now that its
// lifecycle is decoupled from lnd's. For stateless nodes litMacPath
// points at a baked super macaroon; otherwise the lit macaroon on disk
// is used.
macPath := hn.Cfg.LitMacPath
if hn.litMacPath != "" {
macPath = hn.litMacPath
}
litConn, err := connectLitRPC(
context.Background(), hn.Cfg.LitAddr(), hn.Cfg.LitTLSCertPath,
macPath,
)
if err != nil {
return fmt.Errorf("could not connect to Lit RPC: %w", err)
}
hn.litConn = litConn
ctxt, cancel := context.WithTimeout(
context.Background(), lntest.DefaultTimeout,
)
defer cancel()
err = wait.NoError(func() error {
litClient := litrpc.NewProxyClient(hn.litConn)
_, err := litClient.GetInfo(ctxt, &litrpc.GetInfoRequest{})
return err
}, lntest.DefaultTimeout)
if err != nil {
return err
}
// Launch the watcher that will hook into graph related topology change
// from the PoV of this node.
hn.wg.Add(1)
@ -1423,9 +1522,31 @@ func (hn *HarnessNode) Stop() error {
return nil
}
// If start() failed before creating a client, we will just wait for the
// child process to die.
if !hn.Cfg.RemoteMode && hn.LightningClient != nil {
// litd owns its own lifecycle, so we ask it to stop via its own
// StopDaemon RPC. In integrated mode this also brings down the embedded
// lnd.
switch {
case hn.litConn != nil:
ctx, cancel := context.WithTimeout(
context.Background(), lntest.DefaultTimeout,
)
litConn := litrpc.NewProxyClient(hn.litConn)
// litd normally answers before it shuts down, but the
// connection can also be torn down first as litd begins
// shutting down. That surfaces as an Unavailable status and
// is expected. Any other error is not, so surface it. We
// confirm the process actually exits via processExit below.
_, err := litConn.StopDaemon(ctx, &litrpc.StopDaemonRequest{})
cancel()
if err != nil && status.Code(err) != codes.Unavailable {
return err
}
// If start() failed before the lit connection was created, fall back to
// stopping the integrated lnd directly and just wait for the child
// process to die.
case !hn.Cfg.RemoteMode && hn.LightningClient != nil:
// Don't watch for error because sometimes the RPC connection
// gets closed before a response is returned.
req := lnrpc.StopRequest{}
@ -1450,19 +1571,6 @@ func (hn *HarnessNode) Stop() error {
if err != nil {
return err
}
} else if hn.Cfg.RemoteMode {
// If lit is running in remote mode, then calling LNDs
// StopDaemon method will not shut down Lit, and so we need to
// explicitly request lit to shut down.
ctx, cancel := context.WithTimeout(
context.Background(), lntest.DefaultTimeout,
)
litConn := litrpc.NewProxyClient(hn.litConn)
_, err := litConn.StopDaemon(ctx, &litrpc.StopDaemonRequest{})
cancel()
if err != nil {
return err
}
}
// Wait for litd process and other goroutines to exit.

View file

@ -31,4 +31,8 @@ var allTestCases = []*testCase{
name: "kvdb to sql migration",
test: testKvdbSQLMigration,
},
{
name: "litd survives lnd shutdown",
test: testLitdSurvivesLndShutdown,
},
}

32
log.go
View file

@ -61,9 +61,13 @@ func UseLogger(logger btclog.Logger) {
log = logger
}
// SetupLoggers initializes all package-global logger variables.
func SetupLoggers(root *build.SubLoggerManager, intercept signal.Interceptor) {
genLogger := genSubLogger(root, intercept)
// SetupLoggers initializes all package-global logger variables. The passed
// litdShutdown is litd's own shutdown source, used to bring litd down on a
// critical log independently of lnd's signal.Interceptor.
func SetupLoggers(root *build.SubLoggerManager, intercept signal.Interceptor,
litdShutdown *shutdownSource) {
genLogger := genSubLogger(root, intercept, litdShutdown)
log = build.NewSubLogger(Subsystem, genLogger)
interceptor = intercept
@ -103,17 +107,26 @@ func SetupLoggers(root *build.SubLoggerManager, intercept signal.Interceptor) {
tap.SetupLoggers(root, intercept)
// Setup the gRPC loggers too.
grpclog.SetLoggerV2(NewGrpcLogLogger(root, intercept, GrpcLogSubsystem))
grpclog.SetLoggerV2(NewGrpcLogLogger(
root, intercept, litdShutdown, GrpcLogSubsystem,
))
}
// genSubLogger creates a logger for a subsystem. We provide an instance of
// a signal.Interceptor to be able to shutdown in the case of a critical error.
func genSubLogger(root *build.SubLoggerManager,
interceptor signal.Interceptor) func(string) btclog.Logger {
interceptor signal.Interceptor,
litdShutdown *shutdownSource) func(string) btclog.Logger {
// Create a shutdown function which will request shutdown from our
// interceptor if it is listening.
// interceptor if it is listening. We also request shutdown from litd's
// own shutdown source so that a critical error brings litd down too,
// not just lnd (litd no longer exits simply because lnd stopped).
shutdown := func() {
if litdShutdown != nil {
litdShutdown.RequestShutdown()
}
if !interceptor.Listening() {
return
}
@ -131,9 +144,12 @@ func genSubLogger(root *build.SubLoggerManager,
// NewGrpcLogLogger creates a new grpclog compatible logger and attaches it as
// a sub logger to the passed root logger.
func NewGrpcLogLogger(root *build.SubLoggerManager,
intercept signal.Interceptor, subsystem string) *GrpcLogLogger {
intercept signal.Interceptor, litdShutdown *shutdownSource,
subsystem string) *GrpcLogLogger {
logger := build.NewSubLogger(subsystem, genSubLogger(root, intercept))
logger := build.NewSubLogger(
subsystem, genSubLogger(root, intercept, litdShutdown),
)
lnd.SetSubLogger(root, subsystem, logger)
return &GrpcLogLogger{
Logger: logger,

View file

@ -73,7 +73,8 @@ func (e *proxyErr) Unwrap() error {
func newRpcProxy(cfg *Config, validator macaroons.MacaroonValidator,
superMacValidator litmac.SuperMacaroonValidator,
permsMgr *perms.Manager, subServerMgr *subservers.Manager,
statusMgr *litstatus.Manager, getLNDClient lndBasicClientFn) *rpcProxy {
statusMgr *litstatus.Manager, getLNDClient lndBasicClientFn,
litdShutdown *shutdownSource) *rpcProxy {
// The gRPC web calls are protected by HTTP basic auth which is defined
// by base64(username:password). Because we only have a password, we
@ -96,6 +97,7 @@ func newRpcProxy(cfg *Config, validator macaroons.MacaroonValidator,
subServerMgr: subServerMgr,
statusMgr: statusMgr,
getBasicLNDClient: getLNDClient,
litdShutdown: litdShutdown,
}
p.grpcServer = grpc.NewServer(
// From the grpxProxy doc: This codec is *crucial* to the
@ -173,6 +175,10 @@ type rpcProxy struct {
statusMgr *litstatus.Manager
getBasicLNDClient lndBasicClientFn
// litdShutdown is litd's own shutdown source. It is needed alongside
// the interceptor because litd's lifecycle is decoupled from lnd's.
litdShutdown *shutdownSource
bakeSuperMac bakeSuperMac
macValidator macaroons.MacaroonValidator
@ -228,6 +234,11 @@ func (p *rpcProxy) StopDaemon(_ context.Context,
log.Infof("StopDaemon rpc request received")
// Stop litd via its own shutdown source, and stop the embedded lnd via
// its interceptor. Both are needed because litd's lifecycle is
// decoupled from lnd's interceptor.
p.litdShutdown.RequestShutdown()
interceptor.RequestShutdown()
return &litrpc.StopDaemonResponse{}, nil

85
shutdown.go Normal file
View file

@ -0,0 +1,85 @@
package terminal
import (
"os"
"os/signal"
"sync"
"syscall"
)
// shutdownSource is litd's own shutdown trigger. It is intentionally separate
// from the signal.Interceptor that litd hands to lnd: lnd and litd are forced
// to share a single signal.Interceptor (lnd's signal package only allows one
// interceptor and lnd.Main takes it by value), so litd cannot tell an lnd
// shutdown apart from its own by watching that interceptor's channel. Owning
// its own shutdown source lets litd decide its own lifecycle and keep running
// (e.g. to serve its status endpoint) after lnd alone has stopped.
//
// litd is shut down when:
// - an OS interrupt or termination signal is received,
// - litd's StopDaemon RPC is called, or
// - a fatal litd-specific error occurs.
//
// It is NOT shut down when lnd alone stops.
type shutdownSource struct {
// quit is closed exactly once when litd should shut down.
quit chan struct{}
// once guards closing quit so that RequestShutdown is idempotent.
once sync.Once
// signals is the channel OS signals are delivered on. We keep a
// reference to it so it can be deregistered in Stop.
signals chan os.Signal
}
// newShutdownSource constructs a shutdownSource and starts listening for OS
// interrupt and termination signals.
//
// Note that Go delivers a signal to every channel registered via
// signal.Notify, so listening here does not prevent lnd's own
// signal.Interceptor from also receiving the same signal. An OS signal
// therefore stops both lnd and litd.
func newShutdownSource() *shutdownSource {
s := &shutdownSource{
quit: make(chan struct{}),
signals: make(chan os.Signal, 1),
}
signal.Notify(s.signals, os.Interrupt, syscall.SIGTERM)
go func() {
select {
case <-s.signals:
s.RequestShutdown()
// If shutdown was requested by some other means, there is
// nothing left to wait for.
case <-s.quit:
}
}()
return s
}
// RequestShutdown closes the shutdown channel, signalling that litd should shut
// down. It is safe to call multiple times and from multiple goroutines.
func (s *shutdownSource) RequestShutdown() {
s.once.Do(func() {
close(s.quit)
})
}
// ShutdownChannel returns the channel that is closed when litd should shut
// down. The same channel is returned on every call, so callers may select on
// it repeatedly without spawning extra goroutines.
func (s *shutdownSource) ShutdownChannel() <-chan struct{} {
return s.quit
}
// Stop deregisters the OS signal handler. It does not itself request a
// shutdown. It is mainly useful so that tests don't leave signal handlers
// registered on the process.
func (s *shutdownSource) Stop() {
signal.Stop(s.signals)
}

67
shutdown_test.go Normal file
View file

@ -0,0 +1,67 @@
package terminal
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
// waitClosed asserts that the given channel is closed within a short timeout.
func waitClosed(t *testing.T, c <-chan struct{}) {
t.Helper()
select {
case <-c:
case <-time.After(time.Second):
require.FailNow(t, "shutdown channel was not closed in time")
}
}
// requireOpen asserts that the given channel is not yet closed.
func requireOpen(t *testing.T, c <-chan struct{}) {
t.Helper()
select {
case <-c:
require.FailNow(t, "shutdown channel closed unexpectedly")
default:
}
}
// TestShutdownSourceRequestShutdown checks that RequestShutdown closes the
// shutdown channel and that calling it again is a safe no-op.
func TestShutdownSourceRequestShutdown(t *testing.T) {
t.Parallel()
s := newShutdownSource()
defer s.Stop()
// The channel should start open.
requireOpen(t, s.ShutdownChannel())
// Requesting a shutdown should close it.
s.RequestShutdown()
waitClosed(t, s.ShutdownChannel())
// A second call must not panic on a double close.
require.NotPanics(t, s.RequestShutdown)
waitClosed(t, s.ShutdownChannel())
}
// TestShutdownSourceChannelStable checks that ShutdownChannel returns the same
// channel across calls, before and after a shutdown is requested, so callers
// can select on it repeatedly without spawning a goroutine per call.
func TestShutdownSourceChannelStable(t *testing.T) {
t.Parallel()
s := newShutdownSource()
defer s.Stop()
first := s.ShutdownChannel()
require.Equal(t, first, s.ShutdownChannel())
s.RequestShutdown()
require.Equal(t, first, s.ShutdownChannel())
}

29
shutdown_unix_test.go Normal file
View file

@ -0,0 +1,29 @@
//go:build !windows
package terminal
import (
"syscall"
"testing"
"github.com/stretchr/testify/require"
)
// TestShutdownSourceSignal checks that an OS termination signal closes the
// shutdown channel. The signal is captured by signal.Notify, so it does not
// terminate the test process.
//
// This lives in a Unix-only file because syscall.Kill is not available on
// Windows.
func TestShutdownSourceSignal(t *testing.T) {
// Not parallel: this sends a process-wide signal, which every
// registered shutdownSource would observe.
s := newShutdownSource()
defer s.Stop()
requireOpen(t, s.ShutdownChannel())
require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGTERM))
waitClosed(t, s.ShutdownChannel())
}

View file

@ -191,6 +191,20 @@ type LightningTerminal struct {
wg sync.WaitGroup
errQueue *queue.ConcurrentQueue[error]
// shutdown is litd's own shutdown source. It is separate from the
// signal.Interceptor handed to lnd so that litd controls its own
// lifecycle and can keep running (to serve its status endpoint) after
// lnd alone has stopped.
shutdown *shutdownSource
// lndStopped is set to true when lnd stops while litd keeps running. It
// lets the final shutdown skip the graceful lnd client teardown, which
// would otherwise block waiting on subscription goroutines that can no
// longer reach the now-dead lnd. On a clean shutdown it stays false so
// the lnd client is closed gracefully. It is only ever accessed from
// the main run goroutine.
lndStopped bool
lndConnID string
lndConn *grpc.ClientConn
lndClient *lndclient.GrpcLndServices
@ -269,31 +283,42 @@ func (s *stores) close() error {
// Run starts everything and then blocks until either the application is shut
// down or a critical error happens.
func (g *LightningTerminal) Run(ctx context.Context) error {
// Hook interceptor for os signals.
// Hook interceptor for os signals. This interceptor is handed to lnd.
// We intentionally keep litd's own shutdown separate from it (see
// shutdownSource) so that lnd stopping does not force litd to stop.
shutdownInterceptor, err := signal.Intercept()
if err != nil {
return fmt.Errorf("could not intercept signals: %v", err)
}
// Create litd's own shutdown source. It listens for OS signals
// independently of lnd's interceptor and is passed to the loggers and
// the RPC proxy below so that a critical log or a litd StopDaemon call
// can bring litd down.
g.shutdown = newShutdownSource()
defer g.shutdown.Stop()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Make sure the context is canceled if the user requests shutdown and
// that the shutdown signal is requested if the context is canceled.
// Make sure the context is canceled if litd requests shutdown and that
// litd shutdown is requested if the context is canceled. Note that we
// watch litd's own shutdown source here, not lnd's interceptor, so that
// an lnd-only shutdown does not cancel litd's context.
go func() {
select {
// Client requests shutdown, cancel the wait.
case <-shutdownInterceptor.ShutdownChannel():
case <-g.shutdown.ShutdownChannel():
cancel()
// The check was completed and the above defer canceled the
// context. We can just exit the goroutine, nothing more to do.
case <-ctx.Done():
shutdownInterceptor.RequestShutdown()
g.shutdown.RequestShutdown()
}
}()
cfg, err := loadAndValidateConfig(ctx, shutdownInterceptor)
cfg, err := loadAndValidateConfig(ctx, shutdownInterceptor, g.shutdown)
if err != nil {
return fmt.Errorf("could not load config: %w", err)
}
@ -377,7 +402,7 @@ func (g *LightningTerminal) Run(ctx context.Context) error {
// server is started.
g.rpcProxy = newRpcProxy(
g.cfg, g, g.validateSuperMacaroon, g.permsMgr, g.subServerMgr,
g.statusMgr, g.basicLNDClient,
g.statusMgr, g.basicLNDClient, g.shutdown,
)
// Register any gRPC services that should be served using LiT's
@ -401,10 +426,16 @@ func (g *LightningTerminal) Run(ctx context.Context) error {
}
}
// lndQuit is closed by the lnd.Main goroutine (inside start) when lnd
// stops. We create it in Run so that both start, during startup, and
// waitForShutdown, at runtime, can observe it without it living on the
// struct.
lndQuit := make(chan struct{})
// Attempt to start Lit and all of its sub-servers. If an error is
// returned, it means that either one of Lit's internal sub-servers
// could not start or LND could not start or be connected to.
startErr := g.start(ctx)
startErr := g.start(ctx, lndQuit)
if startErr != nil {
g.statusMgr.SetErrored(
subservers.LIT, "could not start Lit: %v", startErr,
@ -424,10 +455,22 @@ func (g *LightningTerminal) Run(ctx context.Context) error {
}
}
// Now block until we receive an error or the main shutdown
// signal.
<-shutdownInterceptor.ShutdownChannel()
log.Infof("Shutdown signal received")
// We keep litd running so its status endpoint stays reachable only if
// it got far enough that the rpcProxy started; before that, litcli stop
// cannot reach litd, so there would be no way to stop it. If the proxy
// did start, waitForShutdown blocks until litd itself is asked to shut
// down, tearing down the lnd-dependent sub-servers if lnd stops
// meanwhile. Otherwise we fall through and shut down fully, so a
// startup failure is loud and a supervisor can restart us.
if g.rpcProxy.hasStarted() {
g.waitForShutdown(ctx, lndQuit)
}
// litd is going down, so bring lnd down with it. This is idempotent and
// a no-op if lnd already stopped (e.g. the shutdown was triggered by an
// OS signal that lnd's interceptor also received). It is required so
// that the lnd.Main goroutine returns and g.wg.Wait below can complete.
shutdownInterceptor.RequestShutdown()
err = g.shutdownSubServers()
if err != nil {
@ -443,8 +486,11 @@ func (g *LightningTerminal) Run(ctx context.Context) error {
// LND errors are considered fatal and will result in an error being returned.
// If any of the sub-servers managed by the subServerMgr error while starting
// up, these are considered non-fatal and will not result in an error being
// returned.
func (g *LightningTerminal) start(ctx context.Context) error {
// returned. lndQuit is closed by start's lnd.Main goroutine when lnd stops, so
// that the caller can keep litd running while reporting lnd as errored.
func (g *LightningTerminal) start(ctx context.Context,
lndQuit chan struct{}) error {
var err error
accountServiceErrCallback := func(err error) {
@ -524,9 +570,9 @@ func (g *LightningTerminal) start(ctx context.Context) error {
readyChan = make(chan struct{})
bufReadyChan = make(chan struct{})
unlockChan = make(chan struct{})
lndQuit = make(chan struct{})
macChan = make(chan []byte, 1)
)
if g.cfg.LndMode == ModeIntegrated {
lisCfg := lnd.ListenerCfg{
RPCListeners: []*lnd.ListenerWithSignal{{
@ -579,19 +625,33 @@ func (g *LightningTerminal) start(ctx context.Context) error {
// function during the execution of this, as `g` is
// referenced in the passed `implCfg`
err := lnd.Main(g.cfg.Lnd, lisCfg, implCfg, interceptor)
// Mark lnd as errored on the status server so the
// status endpoint reports why it stopped. On a real
// crash we also push the error into the shared error
// queue, the single source of runtime errors, so the
// startup-phase selects can abort before the other
// sub-servers come up.
if e, ok := err.(*flags.Error); err != nil &&
(!ok || e.Type != flags.ErrHelp) {
errStr := fmt.Sprintf("Error running main "+
"lnd: %v", err)
log.Errorf(errStr)
lndErr := fmt.Sprintf(
"Error running main lnd: %v", err,
)
log.Errorf(lndErr)
g.statusMgr.SetErrored(subservers.LND, errStr)
g.statusMgr.SetErrored(subservers.LND, lndErr)
g.errQueue.ChanIn() <- err
return
} else {
g.statusMgr.SetErrored(
subservers.LND, "lnd has stopped",
)
}
// Signal that lnd has stopped, whether it exited
// cleanly or with an error, so start can either fail
// startup or tear down the lnd-dependent sub-servers
// while keeping litd (and its status endpoint) alive.
close(lndQuit)
}()
} else {
@ -614,17 +674,12 @@ func (g *LightningTerminal) start(ctx context.Context) error {
case <-readyChan:
case err := <-g.errQueue.ChanOut():
g.statusMgr.SetErrored(
subservers.LND, "error from errQueue channel",
)
return fmt.Errorf("could not start LND: %v", err)
return fmt.Errorf("error from subsystem: %w", err)
case <-lndQuit:
g.statusMgr.SetErrored(
subservers.LND, "lndQuit channel closed",
)
// lnd has stopped during startup. The lnd.Main goroutine has
// already marked lnd errored on the status server, so we just
// abort startup here.
return fmt.Errorf("LND has stopped")
case <-ctx.Done():
@ -707,22 +762,20 @@ func (g *LightningTerminal) start(ctx context.Context) error {
}
// waitForSignal is a helper closure that can be used to wait on the
// given channel for a signal while also being responsive to an error
// from the error Queue, LND quiting or the interceptor receiving a
// shutdown signal.
// given channel for a signal while also being responsive to LND
// quitting or the context being canceled.
waitForSignal := func(c chan struct{}) error {
select {
case <-c:
return nil
case err := <-g.errQueue.ChanOut():
return err
return fmt.Errorf("error from subsystem: %w", err)
case <-lndQuit:
g.statusMgr.SetErrored(
subservers.LND, "lndQuit channel closed",
)
// lnd has stopped during startup. The lnd.Main
// goroutine has already marked lnd errored on the
// status server, so we just abort startup here.
return fmt.Errorf("LND has stopped")
case <-ctx.Done():
@ -822,31 +875,71 @@ func (g *LightningTerminal) start(ctx context.Context) error {
return fmt.Errorf("could not start litd sub-servers: %v", err)
}
// We can now set the status of LiT as running.
// We can now set the status of LiT as running. litd has finished
// starting up, so we return and let Run block on waitForShutdown.
g.statusMgr.SetRunning(subservers.LIT)
// Now block until we receive an error or the main shutdown signal.
select {
case err := <-g.errQueue.ChanOut():
if err != nil {
return fmt.Errorf("received critical error from "+
"subsystem, shutting down: %v", err)
}
case <-lndQuit:
g.statusMgr.SetErrored(
subservers.LND, "lndQuit channel closed",
)
return fmt.Errorf("LND is not running")
case <-ctx.Done():
log.Infof("Shutdown signal received")
}
return nil
}
// waitForShutdown blocks until litd itself is told to shut down. Unlike
// during startup, an lnd stop here does not bring litd down: we mark lnd as
// errored and LiT as stopped, tear down the lnd-dependent sub-servers, and
// keep litd running so its status endpoint stays available to report what
// happened. Runtime sub-server errors are logged only. Only litd's own
// shutdown (OS signal, litcli stop) or the context being canceled stops
// litd.
func (g *LightningTerminal) waitForShutdown(ctx context.Context,
lndQuit chan struct{}) {
for {
select {
case <-g.shutdown.ShutdownChannel():
log.Infof("Shutdown signal received")
return
case <-ctx.Done():
return
case err := <-g.errQueue.ChanOut():
// Once litd is up, a runtime sub-server error no
// longer brings it down: we keep litd (and its
// status endpoint) running so the failure stays
// observable. Such errors are commonly the fallout
// of lnd stopping (e.g. the RPC middleware losing
// its lnd connection); the lnd stop itself is
// handled via lndQuit below, so we only log here and
// keep waiting.
log.Errorf("Sub-server reported a runtime error; "+
"keeping litd running so it stays observable: "+
"%v", err)
case <-lndQuit:
// lnd has stopped while litd was running. The
// lnd.Main goroutine has already set the LND
// sub-server status (the real error, or a generic
// message on a clean stop). Record that lnd is gone
// so the final shutdown skips the graceful lnd client
// teardown, mark LiT as errored so the status endpoint
// reports why it is no longer running, and tear down
// the lnd-dependent sub-servers while keeping litd
// running.
g.lndStopped = true
g.statusMgr.SetErrored(
subservers.LIT, "lit was stopped as lnd was "+
"shut down",
)
g.shutdownLndDependentSubServers()
// Disable this case so the now-closed channel does not
// busy-loop the select while we wait for litd's own
// shutdown.
lndQuit = nil
}
}
}
// basicLNDClient provides access to LiT's basicClient if it has been set.
func (g *LightningTerminal) basicLNDClient() (lnrpc.LightningClient, error) {
if !g.basicClientSet.Load() {
@ -864,7 +957,7 @@ func (g *LightningTerminal) checkRunning(ctx context.Context,
select {
case err := <-g.errQueue.ChanOut():
return fmt.Errorf("error from subsystem: %v", err)
return fmt.Errorf("error from subsystem: %w", err)
case <-lndQuit:
return fmt.Errorf("LND has stopped")
@ -1538,6 +1631,47 @@ func (g *LightningTerminal) buildAuxComponents(
}, nil
}
// shutdownLndDependentSubServers stops the sub-servers and sub-systems that
// depend on lnd and cannot function once it has stopped, while leaving litd and
// its lnd-independent sub-servers (such as the status endpoint) running.
//
// It is called from waitForShutdown (which runs in Run) when lnd stops after
// litd has finished starting up, before the final shutdownSubServers, so it
// never races that full shutdown. Each underlying Stop is in any case
// idempotent.
func (g *LightningTerminal) shutdownLndDependentSubServers() {
log.Infof("lnd has stopped; shutting down lnd-dependent sub-servers " +
"while keeping litd's status endpoint available")
// Stop the daemon sub-servers (loop, pool, tapd, faraday).
// Manager.Stop is idempotent and marks each stopped sub-server in the
// status manager.
if err := g.subServerMgr.Stop(); err != nil {
log.Errorf("Error stopping sub-servers after lnd stopped: %v",
err)
}
// The autopilot client talks to lnd, so stop it too. Its Stop is
// guarded by a sync.Once and is safe to call again during the final
// shutdown.
if g.autopilotClient != nil {
g.autopilotClient.Stop()
}
// Stop the internal accounts service, which also depends on lnd.
// Guard it so the final shutdownSubServers does not stop it a second
// time (its Stop closes a channel and is not idempotent).
if g.accountServiceStarted {
if err := g.accountService.Stop(); err != nil {
log.Errorf("Error stopping account service after lnd "+
"stopped: %v", err)
}
g.accountServiceStarted = false
g.statusMgr.SetStopped(subservers.ACCOUNTS)
}
}
// shutdownSubServers stops all subservers that were started and attached to
// lnd.
func (g *LightningTerminal) shutdownSubServers() error {
@ -1605,7 +1739,11 @@ func (g *LightningTerminal) shutdownSubServers() error {
}
}
if g.lndClient != nil {
// Skip the graceful lnd client teardown if lnd has already stopped: its
// Close waits on subscription goroutines that can no longer reach lnd
// and would block shutdown indefinitely. The process is exiting, so the
// connection does not need an orderly close in that case.
if g.lndClient != nil && !g.lndStopped {
g.lndClient.Close()
}
@ -1634,7 +1772,7 @@ func (g *LightningTerminal) shutdownSubServers() error {
}
}
// Do we have any last errors to display? We use an anonymous function,
// Drain and log any last runtime errors. We use an anonymous function
// so we can use return instead of breaking to a label in the default
// case.
func() {