Skip to content

Keep litd running after lnd stops#1329

Open
Vandit1604 wants to merge 6 commits into
lightninglabs:masterfrom
Vandit1604:1201-litd-survive-lnd-shutdown
Open

Keep litd running after lnd stops#1329
Vandit1604 wants to merge 6 commits into
lightninglabs:masterfrom
Vandit1604:1201-litd-survive-lnd-shutdown

Conversation

@Vandit1604

@Vandit1604 Vandit1604 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Closes #1201 (initial implementation).

Makes litd keep running after lnd stops in integrated mode, so the status endpoint stays up to report the failure instead of litd exiting along with lnd.

Based on #1356, which splits out the itest harness change. The bottom two commits here belong to that PR.

Why not the wrapper from the issue

The pinned lnd's signal.Intercept can only be called once, its channels are unexported, and lnd.Main takes the interceptor by value, so a wrapper type can't be passed in. Instead litd owns its own shutdown source and stops tying its lifecycle to lnd's interceptor.

What it does

  • litd waits on its own shutdown source (its own SIGINT/SIGTERM handler plus a programmatic channel). lnd still gets the real interceptor.
  • When lnd stops after startup, litd marks lnd as errored, tears down the lnd dependent sub servers (loop, pool, tapd, faraday, accounts, autopilot), and keeps running.
  • OS signals, litd StopDaemon, and critical litd errors still stop everything. litd StopDaemon also stops the embedded lnd.

Behaviour change to flag

Calling lnd's StopDaemon (for example lncli stop) no longer stops litd in integrated mode. That is the goal of the issue but it is user facing. The itest harness now stops litd through its own StopDaemon, and outside tooling that relied on the old cascade would need the same. I can add a config flag to keep the old behaviour if you prefer.

Testing

  • unit tests for the new shutdown source
  • new itest litd survives lnd shutdown: stop lnd, then check the status endpoint still answers and reports lnd not running
  • ran the integrated mode, stateless init mode and request logger itests locally to cover the harness change

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request decouples the lifecycle of litd from the embedded lnd instance. Previously, litd would exit whenever lnd stopped. With these changes, litd now maintains its own shutdown source, allowing it to continue running after lnd terminates. This ensures that the status endpoint remains available to report lnd's failure state, improving observability. The change also includes updates to the itest harness to handle the new shutdown behavior and ensures that lnd-dependent sub-servers are cleaned up gracefully when lnd stops.

Highlights

  • Decoupled Lifecycle: Implemented a dedicated shutdown source for litd, allowing it to remain running even if the embedded lnd process stops.
  • Graceful Teardown: Added logic to tear down lnd-dependent sub-servers (loop, pool, tapd, faraday, accounts, autopilot) when lnd stops, while keeping the status endpoint available.
  • Testing: Added a new integration test 'litd survives lnd shutdown' to verify that litd remains reachable after lnd is stopped via RPC.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request decouples the lifecycle of litd from lnd in integrated mode by introducing a dedicated shutdownSource for litd. This allows litd to remain running and serve its status endpoint even after lnd stops. The feedback highlights two key issues: first, the new signal test in shutdown_test.go uses Unix-specific syscalls that will break compilation on Windows, and should be moved to a build-constrained file; second, the integration test testLitdSurvivesLndShutdown incorrectly initializes the test node in remote mode instead of integrated mode, which misses the target behavior.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread shutdown_test.go Outdated
Comment on lines +70 to +84
// 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.
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())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test uses syscall.Kill and syscall.SIGTERM, which are not supported on Windows and will cause compilation failures on Windows platforms. To keep the test suite cross-platform, please move TestShutdownSourceSignal to a separate file (e.g., shutdown_unix_test.go) with the //go:build !windows build constraint.

Comment on lines +22 to +24
node, err := net.NewNode(
t.t, "lnd-stop", nil, false, true, "--autopilot.disable",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The 5th argument to net.NewNode is remoteMode. Passing true runs the test in remote mode, which does not test the integrated mode lifecycle changes introduced in this PR. This should be set to false to test the integrated mode behavior.

Suggested change
node, err := net.NewNode(
t.t, "lnd-stop", nil, false, true, "--autopilot.disable",
)
node, err := net.NewNode(
t.t, "lnd-stop", nil, false, false, "--autopilot.disable",
)

@Vandit1604

Copy link
Copy Markdown
Contributor Author

Thanks for the review.

  1. Good catch on the Windows build. syscall.Kill is not available there, so I moved TestShutdownSourceSignal into shutdown_unix_test.go with a //go:build !windows constraint. The other two tests stay cross platform.

  2. The node is already started in integrated mode. The signature is NewNode(t, name, extraArgs, remoteMode, wait, ...litArgs), so remoteMode is the 4th arg, which is false here. The true is the wait arg. So the test does exercise the integrated lnd shutdown, no change needed there.

@ViktorT-11

Copy link
Copy Markdown
Contributor

Sorry for taking some time with this one @Vandit1604, I'll review this tomorrow.

@ViktorT-11
ViktorT-11 self-requested a review June 16, 2026 23:25

@ViktorT-11 ViktorT-11 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for this contribution @Vandit1604! This actually works really well, and was a smaller change that I thought it would be. I've tested it locally, and it introduces the functionality that we'd like, so thanks for that 🔥.

Here are the things I've tested:

  • A log.Critical error in lnd shutsdown just lnd and + dependent sub-servers, and not litd. Something to note is that it however just leaves "lnd shutdown" in the litcli status error for lnd though, not the actual error. But I think that's good enough.
  • A runtime error in lnd does shutdown just lnd and the dependent sub-servers, but not litd. It does also print the real error in litcli status lnd error.
  • An error in litd does shut down lnd as well.

There is however some architectural feedback that I think needs to be resolved. I'm leaving those in my first 2 comments below. The rest of the feedback is just minor.

Great work 🔥

Comment thread terminal.go
// tear down the lnd-dependent sub-servers, and keep litd running so its
// status endpoint stays available. Only an OS signal, litd StopDaemon,
// a critical litd error or a fatal sub-system error stops litd.
for {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the change you've included here, I think this observing here that remains up until litd is placed incorrectly. It's currently placed in the start function, but the right place for this should be the Run function. I therefore suggest you just return nil once you've set LIT as running above, and make this the observing part the responsibility of the Run function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. start now returns nil once LIT is set to running, and the steady state wait lives in Run as waitForShutdown. Run blocks there, observes lndQuit to tear down the lnd-dependent sub-servers if lnd stops, and only returns on a real litd shutdown.

Comment thread terminal.go Outdated
// a critical litd error or a fatal sub-system error stops litd.
for {
select {
case err := <-g.errQueue.ChanOut():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm I don't really like here that this introduces different behaviour for what an error sent to the g.errQueue.ChanOut channel means depending on if it is during the startup of litd, or after all sub-servers have been started. I.e. with this change, an error during startup means that litd is shutdown, but is later changed to just mean that it's logged.

I think you need to separate that with two different channels or something similar, where sending over one of them means "fatal" errors, and the one which is used after this Start function has returned is non-fatal and logged only errors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The post-startup channel is now a dedicated non-fatal one (subServerErrs), read only in waitForShutdown after start has returned, and those errors are logged only. Fatal errors are no longer routed through it: startup failures are returned directly from start, and an lnd stop during startup comes in over lndQuit. The middleware is the only component that reports async errors and it only does so at runtime, so the queue now has a single, unambiguous meaning.

Comment thread shutdown.go Outdated
// 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, the same as before this change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commenting specifically regarding the "the same as before this change" part of this comment:
We usually don't mention changes from previous behaviour in the docs. This is because the previous version is irrelevant to people reviewing the current state of the code, unless knowledge about that previous version of the code is explicitly needed to understand why a specific code behaviour is implemented the way it is, which is not the case here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, dropped the comparison to the previous behaviour.

Comment thread rpc_proxy.go
if litdShutdown != nil {
litdShutdown.RequestShutdown()
}
interceptor.RequestShutdown()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: new line before this line.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread rpc_proxy.go Outdated
Comment on lines +232 to +233
// its interceptor. Both are needed now that litd no longer shuts down
// just because lnd's interceptor fired.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, reworded without referencing the old behaviour.

Comment thread itest/litd_node.go Outdated
// If start() failed before creating a client, we will just wait for the
// child process to die.
if !hn.Cfg.RemoteMode && hn.LightningClient != nil {
// Calling lnd's StopDaemon no longer shuts down litd (litd keeps running

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same point regarding mentioning previous behaviour here: "no longer shuts down litd".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, reworded.

Comment thread itest/litd_node.go Outdated
Comment on lines +1412 to +1413
// Don't watch for an error: the connection may already be
// closing (e.g. litd has begun shutting down), which is fine.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can just require that the error is that specific type if the err != nil, instead of not checking the error at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. It now returns the error unless it is an Unavailable status, which is the expected case when the connection is torn down as litd shuts down.

Comment thread itest/litd_lnd_shutdown_test.go Outdated
t *harnessTest) {

// Use a dedicated, throwaway node so that stopping its lnd cannot
// affect any other test. Disable autopilot to keep the node quiet.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what the disable autopilot to "keep the node quiet." is referring to hear. But I think you can just remove that part of the comment and keep the passed "--autopilot.disable" arg.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, removed that part of the comment and kept the --autopilot.disable arg.


return nil
}, defaultTimeout)
require.NoError(t.t, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should also test here that the lit sub-server remains up and running after this, i.e. that statusClient.SubServerStatus still responds and that the LIT sub-server the has "Running" set to true. Could be worth testing that this is the case for maybe 3 more seconds or something similar.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. After lnd is reported stopped, the test now checks that the LIT sub-server stays reachable and Running for 3 seconds.

Comment thread shutdown_unix_test.go
@@ -0,0 +1,29 @@
//go:build !windows

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This last commit can be squashed with the first one, instead of being a separate commit :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, folded into the first commit.

@Vandit1604
Vandit1604 force-pushed the 1201-litd-survive-lnd-shutdown branch from 1f1ca2f to dc882bb Compare June 18, 2026 19:39
@Vandit1604

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. I've pushed an update addressing all the points:

  • The steady state wait moved out of start and into Run as waitForShutdown. start now returns once LIT is running.
  • Post-startup sub-server errors now go through a dedicated non-fatal channel (subServerErrs) that is only read after start returns and is logged only. Fatal startup errors are returned directly or signalled via lndQuit.
  • Reworded the comments that referenced previous behaviour, fixed the spacing nit, tightened the harness error check, and the test now also asserts LIT stays up for a few seconds after lnd stops.
  • Squashed the unix test file into the first commit.

Replied inline on each thread too.

@ViktorT-11
ViktorT-11 self-requested a review June 22, 2026 10:27

@ViktorT-11 ViktorT-11 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates @Vandit1604. Unfortunately though, I think these changes were a bit more confusing than the previous version and still have the same behaviour. I've therefore left a comment below of an alternative solution building upon your previous version of the PR, which would solve the issue.

Let me know if you'd want some additional clarifications!

Comment thread terminal.go Outdated
Comment on lines +560 to +564
// lndQuit is observed both here (during startup, where an lnd stop is
// fatal) and by Run (after startup, where it tears down the lnd-dependent
// sub-servers but keeps litd alive). It lives on g so Run can reach it.
g.lndQuit = make(chan struct{})
lndQuit := g.lndQuit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm now we have the same behaviour where wanted to avoid but for your newly added g.lndQuit channel instead (i.e. that an error sent over the channel means different thing depending on wether we've started or starting)...

I think this new version is more confusing than your last version as well. I therefore suggest reverting to your previous version, and building upon that.

When looking through that version again, I noticed the following which would probably a clean way we could handle this instead:


As the lnd shutdown doesn't trigger the <-g.shutdown.ShutdownChannel() to be sent over, not even during the startup phase, we can just let it be the case that errors from the start function just triggers the lit sub-server to be stopped, but the status server keeps being up and running. This also applies during the startup phase of the start function.

That way we can keep g.errQueue as the main error source, and not have to add these additional channels i.e. keep it the way it was during your previous version of this PR.

However, due to the signaling change (i.e. that stopping lnd doesn't trigger <-g.shutdown.ShutdownChannel()), we need to also change one thing with the error checking of the start function in the Run function, i.e.:

	// 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)
	if startErr != nil {
		g.statusMgr.SetErrored(
			subservers.LIT, "could not start Lit: %v", startErr,
		)
	}

We can't let litd remain up and running if the lnd errors during the startup, when the following hasn't been run yet in start:

	// Now start the RPC proxy that will handle all incoming gRPC, grpc-web
	// and REST requests.
	if err := g.rpcProxy.Start(g.lndConn, bakeSuperMac); err != nil {
		return fmt.Errorf("error starting lnd gRPC proxy server: %v",
			err)
	}

The reason for that is that users won't be able to run litcli stop before that if we keep the status server up and running.

Therefore, in the startErr check in the Run function, you'd want to check if the rpcProxy has been started or not. If it hasn't litd should shutdown as well.

Finally, to keep the behaviour cooherent, you should also change so that the lit sub-server is always stopped when lnd is stopped, independently of wether lnd stops during the startup phase, or during runtime after its been started.

Let me know if you need any clarifications to the above!

@Vandit1604

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed write up. I reworked it close to what you suggested:

  • Back to a single g.errQueue as the only runtime error source, no extra channels.
  • start() returns nil once LIT is running, and the keep-alive wait is back in Run, in a small waitForShutdown helper, like you noted in your earlier comment.
  • The keep-alive is gated on g.rpcProxy.hasStarted(). If the proxy never started, litd shuts down fully, since litcli stop could not reach it anyway.
  • LIT is now always marked stopped when lnd stops, both during startup and at runtime.

There is one part I could not get working: having start() itself block and return the error on a runtime lnd stop. That structure consistently fails the lnd shutdown itest, so I kept the wait in Run.

Here is exactly what fails and why, in case you see something I missed.

When the wait blocks inside start(), a second node that runs at the same time (Bob in the harness) fails its graph topology subscription during teardown. The harness watcher gets:

rpc error: code = Unknown desc = unexpected HTTP status code received from server: 200 (OK); malformed header: missing HTTP content-type

So the proxied SubscribeChannelGraph stream gets answered by litd's static file handler (HTTP 200) instead of a clean gRPC EOF. The harness treats any non EOF stream error as fatal, so the test fails. This is deterministic with the wait inside start(), and never happens with the wait in Run. I ran it many times both ways.

I ruled out:

  • the lndStopped guard that skips the graceful lnd client Close
  • whether start returns via ctx.Done or via the shutdown channel
  • the LND status being marked errored (removing that did not help)

The litd shutdown log sequence is the same in both versions, so it looks like a teardown timing or scheduling difference between litd tearing down its proxy and lnd sending GOAWAY on that proxied stream. I could not pin the exact cause.

Net effect: lndQuit is still observed in two places, start during startup and waitForShutdown at runtime. If that is acceptable given the itest, I will keep it. If you would rather have the wait inside start(), any pointer on the teardown ordering would help, since I could not crack it.

@Vandit1604
Vandit1604 force-pushed the 1201-litd-survive-lnd-shutdown branch from 7741a9f to 684dd7e Compare June 23, 2026 02:16
@ViktorT-11
ViktorT-11 self-requested a review June 23, 2026 10:08
@Vandit1604
Vandit1604 force-pushed the 1201-litd-survive-lnd-shutdown branch from 684dd7e to a46f463 Compare June 23, 2026 18:22

@ViktorT-11 ViktorT-11 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update @Vandit1604. This looks better, but I still think there are some spots where you didn't revert back to using the errQueue as the source for errors.

You have also added the "revert" in an additional commit as the second last commit of the PR. Please squash it with the "terminal: keep litd running after lnd stops" commit, as the current commit structure makes this quite complicated to review.

Also responding to part of your comment above:

There is one part I could not get working: having start() itself block and return the error on a runtime lnd stop.

I'm sorry if I was unclear there, that was not the approach I intended to convey, so your idea of blocking in Run is the correct approach.

Comment thread terminal.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still want to pass the runtime errors into the errQueue, as we reverted to it being the "single source" of errors.

Comment thread terminal.go
Comment on lines 599 to 604

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've removed this check for the errQueue.ChanOut() here and in many other spots in this file. I don't think that is what you want after reverting back to it being the "single error source". We especially want it to return here if lnd fails during startup, so we don't proceed to start the other sub-servers (i.e. loop, tapd etc. if lnd startup failed)

It should be fine to return here now, as your other logic should have changed that the litd daemon doesn't shutdown when lnd has stopped, as the status server is kept up and running.

@Vandit1604
Vandit1604 force-pushed the 1201-litd-survive-lnd-shutdown branch 2 times, most recently from fdb8737 to 3f2475a Compare June 25, 2026 00:43
@Vandit1604

Copy link
Copy Markdown
Contributor Author

@ViktorT-11 I pushed the review changes (errQueue back as the single error source, and squashed the rework into the "keep litd running after lnd stops" commit).

Before I push a fix for the failing CI I wanted your call on it, since it touches the itest harness.

CI fails on terminal stateless init mode with "process did not exit". I traced it and it is a direct consequence of the feature, not a flake.

The tests stop a node by calling lnd's StopDaemon. On master that also brought litd down. With this PR, stopping lnd leaves litd running on purpose, so the process never exits and the harness times out. The Bob HTTP 200 error you might see is a side effect of the same hang: while the node's teardown drags on, a co-running node's graph subscription gets answered by litd's static file handler instead of a clean gRPC EOF.

The harness already switched to stopping nodes through litd's own StopDaemon to handle this, but that path needs an authenticated litd connection (litConn), which is only set up on the no-seed path in start. Seed based nodes (stateless init, seed restarts) return early before it is set, so they fall back to stopping lnd and hang.

Two harness-only ways to fix it, no production change:

  1. Send the litd process an OS interrupt in Stop instead of an RPC. litd's shutdown source already handles SIGINT/SIGTERM and brings down both litd and lnd. No macaroon needed, so it works for stateless too (where macaroons are intentionally not on disk). This is my preferred option.
  2. Set up litConn for seed nodes too. This does not work cleanly for stateless, since that mode keeps no macaroon files on disk, which is exactly what litConn needs.

I am leaning towards option 1. Does that sound right, or would you prefer a different approach?

@ViktorT-11

Copy link
Copy Markdown
Contributor

Thanks for the changes and the writeup @Vandit1604!

Regarding the issue with stateless init mode:

The tests stop a node by calling lnd's StopDaemon. On master that also brought litd down. With this PR, stopping lnd leaves litd running on purpose, so the process never exits and the harness times out. The Bob HTTP 200 error you might see is a side effect of the same hang: while the node's teardown drags on, a co-running node's graph subscription gets answered by litd's static file handler instead of a clean gRPC EOF.

The harness already switched to stopping nodes through litd's own StopDaemon to handle this, but that path needs an authenticated litd connection (litConn), which is only set up on the no-seed path in start. Seed based nodes (stateless init, seed restarts) return early before it is set, so they fall back to stopping lnd and hang.

I propose one alternative solution you could try first:
For the stateless init setups in the itest setup, we could try to actually create a lit macaroon that we could use within the test harness, so that the StopDaemon call for lit can actually be executed even in stateless init mode.

This is how you'd create such a macaroon in a real stateless init setup:

  1. Create a wallet in stateless init mode (be prepared to choose a password, and save your seed):
    lncli create --stateless_init --save_to /tmp/admin.macaroon

  2. Create a lit super macaroon that contains all permissions from all different subservers
    litcli --macaroonpath /tmp/admin.macaroon bakesupermacaroon --save_to /tmp/lit.macaroon

The outputted litd macaroon will then be saved to /tmp/lit.macaroon.

Obviously for the itest setup, this would need to be done a little bit differently, as for example the test harness has its own capabilities to create a temporary directory.


Test if you can get that working. Else I agree with you that option 1 is best of the 2 options you suggested.
If you cannot figure out how to get that working, I can look into creating a PR to add this capability to the itest harness in the stateless init setup, which you could then base this PR on.

@ViktorT-11 ViktorT-11 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just leaving a few minor comments after looking through the new changes. I haven't tested this again, so these are primarily quick feedback and a question for you.

Comment thread terminal.go
"Error running main lnd: %v", err,
)
log.Errorf(lndErr)
g.statusMgr.SetErrored(subservers.LND, lndErr)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: don't remove the new line above this line.

Comment thread terminal.go Outdated
select {
case err := <-g.errQueue.ChanOut():
return fmt.Errorf("error from subsystem: %v", err)
return fmt.Errorf("error while starting: %w", err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this necessarily only have to be startup issues, so I would not change this. Potentially you could keep the change to use %w though.

Comment thread terminal.go
@@ -1614,7 +1734,7 @@ func (g *LightningTerminal) shutdownSubServers() error {
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm in what scenario does this change lead to that the errQueue.ChanOut now contains an error that hasn't been handled prior to the shutting down litd and that we'd therefore only want to log and not assign to returnErr?

@Vandit1604
Vandit1604 force-pushed the 1201-litd-survive-lnd-shutdown branch 2 times, most recently from fd045b9 to 5f59b69 Compare June 27, 2026 00:30
@Vandit1604

Copy link
Copy Markdown
Contributor Author

Pushed the updates, thanks for the detailed review.

The three inline nits are done:

  • kept the blank line above the SetErrored call
  • reverted the message back to "error from subsystem" (kept the %w change)
  • restored returnErr = err in the final drain

I also squashed the rework into the "terminal: keep litd running after lnd stops" commit, so the history is clean now.

For the stateless init issue I went with your suggestion. In Init and InitChangePassword, for stateless nodes the harness now bakes a lit super macaroon from the admin macaroon it gets back, writes it to disk, and uses it to set up litConn. I also moved the litConn setup into initLightningClient so every node gets one, seed or not. The harness can now stop stateless nodes through litd's own StopDaemon.

Locally this passes: both the stateless init test and the litd survives lnd shutdown test pass, with no regressions. One small note: the "Bob 200" line is just a non fatal log from PrintErr, it does not fail the test, so it can be ignored.

CI looks like it is waiting on approval to run. Once it is kicked off and passes, this should be good to merge from my side.

@Vandit1604
Vandit1604 force-pushed the 1201-litd-survive-lnd-shutdown branch from 5f59b69 to f67c4e8 Compare June 27, 2026 20:49
@ViktorT-11
ViktorT-11 self-requested a review June 30, 2026 10:21
@lightninglabs-deploy

Copy link
Copy Markdown

@ViktorT-11: review reminder
@Vandit1604, remember to re-request review from reviewers when ready


### Functional Changes/Additions

* [Keep litd running after lnd

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay in the review here. I'll get this reviewed before the end of the week!

In the meantime, please update this to instead be part of the release notes for v0.17.1:
https://github.com/lightninglabs/lightning-terminal/blob/master/docs/release-notes/release-notes-0.17.1.md

@ViktorT-11 ViktorT-11 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again for this contribution @Vandit1604! Appologies for taking some time to review this again.

This looks very close from my point of view now. Just leaving a few minor comments below.

Additionally, I've also tested once more that the changes in this PR ensures the following:

  • A log.Critical error in lnd shutsdown just lnd and + dependent sub-servers, and not litd. Something to note is that it however just leaves "lnd shutdown" in the litcli status error for lnd though, not the actual error. But I think that's good enough.
  • A runtime error in lnd does shutdown just lnd and the dependent sub-servers, but not litd. It does also print the real error in litcli status lnd error.
  • An error in litd does shut down lnd as well.

Comment thread terminal.go Outdated
// listens for OS signals independently of lnd's interceptor.
g.shutdown = newShutdownSource()
defer g.shutdown.Stop()
litdShutdown = g.shutdown

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of letting litdShutdown be a global variable in log.go that is assigned here, I think it'd make more sense to pass a reference to it in the log.go SetupLoggers & rpc_proxy.go newRpcProxy functions.

The reason behind that is in that in general it's better to avoid global variables if possible, and especially here it makes code readability worse as it's hard to understand that the terminal.go Run function needs to run prior to the executing the dependent code in log.go & rpc_rpoxy.go.

Comment thread terminal.go Outdated
// reports coherently, and tear down the lnd-dependent
// sub-servers while keeping litd running.
g.lndStopped = true
g.statusMgr.SetStopped(subservers.LIT)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we shutdown the lit sub-server due to that lnd has stopped, we can set it as errored with an error explaining that it was stopped as lnd was shut down.

Comment thread itest/litd_node.go
// litConn is the underlying connection to Lit's grpc endpoint.
litConn *grpc.ClientConn

// litMacPath, when set, is the macaroon file used to authenticate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can break out these changes which are related to being able to shutdown the itest litd node in stateless init though the litcli StopDaemon into a separate PR, as it is quite separate to the rest of the changes in this PR.

We can then review that separately, and base this PR on that new PR which contains only those changes.

Comment thread itest/litd_lnd_shutdown_test.go Outdated
// 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 for a few seconds.
for i := 0; i < 3; i++ {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of doing this for loop with a time.Sleep, you can instead use the wait.Invariant() helper. That aligns better with how we typically test that a specific condition remains true over time (i.e. in this case that the litd status endpoint remains up).

// litd itself must keep running after lnd has stopped. Check that the
// LIT sub-server stays reachable and reports as running for a few
// seconds, confirming it does not shut down shortly after lnd does.
// litd's process itself must keep running after lnd has stopped: its

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This last commit can be squashed with the commit called:
"itest: add test for litd surviving lnd shutdown"

@Vandit1604
Vandit1604 force-pushed the 1201-litd-survive-lnd-shutdown branch from f67c4e8 to c62c684 Compare July 18, 2026 20:17
@Vandit1604
Vandit1604 force-pushed the 1201-litd-survive-lnd-shutdown branch from c62c684 to 9950a34 Compare July 18, 2026 20:20
@Vandit1604

Copy link
Copy Markdown
Contributor Author

Thanks for the review @ViktorT-11. All addressed, rebased on latest master.

litdShutdown is no longer a global. It goes through loadAndValidateConfig into SetupLoggers and genSubLogger, and is a field on rpcProxy. NewGrpcLogLogger also calls genSubLogger, so I gave it the param too rather than passing nil. It has no callers, but say the word if you'd rather leave its signature alone.

Lit is now set errored with "lit was stopped as lnd was shut down" instead of just stopped. The itest checks the error string too.

Also done: wait.InvariantNoError instead of the sleep loop, last commit squashed, release notes moved to 0.17.1.

The harness change is split out into #1356. The bottom two commits here are from it.

Locally litd survives lnd shutdown and terminal stateless init mode pass, and every commit builds on its own. UI index page fallback fails locally only because a locally built litd-itest has no UI bundle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decouple litd and lnd Shutdown Interceptors

3 participants