Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4ca0426
fix: bound network worker termination to prevent shutdown hang
lodekeeper Jul 3, 2026
80e36d3
fix: return bool and unref worker instead of throwing on terminate ti…
lodekeeper Jul 3, 2026
c90a3d6
test(network): use fake timers in terminateWorkerThread tests
lodekeeper Jul 3, 2026
a8a7d4b
fix: bound network core close to protect state archival
nflaig Aug 7, 2026
faf2ae4
chore: log network worker shutdown fallbacks at debug level
nflaig Aug 7, 2026
8417a2c
chore: raise network core close timeout to 5s and trim comments
nflaig Aug 8, 2026
5cbeffb
chore: drop comments duplicating the log line below them
nflaig Aug 8, 2026
fc0f0da
chore: say blocked in a native call instead of wedged
nflaig Aug 8, 2026
47adfb1
chore: drop vague anyway from network core close error log
nflaig Aug 8, 2026
4f3f1c2
chore: correct network core close timeout rationale
nflaig Aug 8, 2026
fbde19e
chore: name the retry budget explicitly in terminate comment
nflaig Aug 8, 2026
e4a834b
fix: correct the described cause of the terminate hang
nflaig Aug 8, 2026
8775ad0
chore: raise network core close timeout to 10s
nflaig Aug 8, 2026
2438f1e
chore: drop the unref, it does not help
nflaig Aug 9, 2026
a30c154
chore: set network core close timeout to 5s
nflaig Aug 9, 2026
fa609e4
feat: log what keeps the network worker loop alive on close
nflaig Aug 9, 2026
289c102
chore: correct close timeout comment
nflaig Aug 9, 2026
2976ace
chore: drop false precision from close timeout comment
nflaig Aug 9, 2026
10d9049
chore: make the close timeout comment readable
nflaig Aug 9, 2026
3646b37
chore: fix two comments that predate the gdb capture
nflaig Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/beacon-node/src/network/core/networkCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ export type BaseNetworkInit = {
initialCustodyGroupCount: number;
};

/** Counts of what is still keeping this thread's event loop alive, e.g. `TCPWrap=3,Timeout=1` */
function formatActiveResources(): string {
const counts = new Map<string, number>();
for (const resource of process.getActiveResourcesInfo()) {
counts.set(resource, (counts.get(resource) ?? 0) + 1);
}
return Array.from(counts, ([name, count]) => `${name}=${count}`).join(",");
}

/**
* This class is meant to work both:
* - In a libp2p worker
Expand Down Expand Up @@ -289,7 +298,10 @@ export class NetworkCore implements INetworkCore {
this.attnetsService.close();
this.syncnetsService.close();
await this.libp2p.stop();
this.logger.debug("network lib2p closed");
// Diagnostic for the shutdown hang, this thread can spin in `Environment::CleanupHandles()` on
// a handle that never closes and `Worker.terminate()` then never resolves. Diffing this list
// between a clean and a stuck shutdown should narrow down which handle it is
this.logger.debug("network lib2p closed", {activeResources: formatActiveResources()});

this.closed = true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {BeaconConfig, chainConfigToJson} from "@lodestar/config";
import type {LoggerNode} from "@lodestar/logger/node";
import {ResponseIncoming, ResponseOutgoing} from "@lodestar/reqresp";
import {Status} from "@lodestar/types";
import {withTimeout} from "@lodestar/utils";
import {Metrics} from "../../metrics/index.js";
import {AsyncIterableBridgeCaller, AsyncIterableBridgeHandler} from "../../util/asyncIterableToEvents.js";
import {PeerIdStr, peerIdFromString} from "../../util/peerId.js";
Expand Down Expand Up @@ -58,6 +59,10 @@ type WorkerNetworkCoreModules = WorkerNetworkCoreInitModules & {

const NETWORK_WORKER_EXIT_TIMEOUT_MS = 1000;
const NETWORK_WORKER_EXIT_RETRY_COUNT = 3;
/** `getApi().close()` is an unbounded RPC into the worker and runs before `BeaconNode.close()`
* archives chain state to disk, so it is bounded here. Set well above the ~2-4s a close takes on
* mainnet, tripping it leaves libp2p half torn down */
const NETWORK_CORE_CLOSE_TIMEOUT_MS = 5000;

/**
* NetworkCore implementation using a Worker thread
Expand Down Expand Up @@ -165,15 +170,25 @@ export class WorkerNetworkCore implements INetworkCore {

async close(): Promise<void> {
this.modules.logger.debug("closing network core running in network worker");
await this.getApi().close();
try {
await withTimeout(async () => this.getApi().close(), NETWORK_CORE_CLOSE_TIMEOUT_MS);
} catch (e) {
this.modules.logger.debug("Error closing network core", {}, e as Error);
}
this.modules.logger.debug("terminating network worker");
await terminateWorkerThread({
const terminated = await terminateWorkerThread({
worker: this.getApi(),
retryCount: NETWORK_WORKER_EXIT_RETRY_COUNT,
retryMs: NETWORK_WORKER_EXIT_TIMEOUT_MS,
logger: this.modules.logger,
});
this.modules.logger.debug("terminated network worker");
if (terminated) {
this.modules.logger.debug("terminated network worker");
} else {
// Nothing more to do, `process.exit()` joins the thread regardless so the process still waits
// on it. What matters is returning, so the caller can archive state and close the db
this.modules.logger.debug("Network worker did not terminate in time, continuing shutdown without it");
}
}

async test(): Promise<void> {
Expand Down
24 changes: 18 additions & 6 deletions packages/beacon-node/src/util/workerEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ export function wireEventsOnMainThread<EventData>(
}
}

/**
* Terminate a worker thread, bounded to `retryCount * retryMs`.
*
* @returns `false` if it could not be terminated, the worker thread is then still running and the
* caller has to decide how to proceed without it.
*/
export async function terminateWorkerThread({
worker,
retryMs,
Expand All @@ -121,7 +127,7 @@ export async function terminateWorkerThread({
retryMs: number;
retryCount: number;
logger?: Logger;
}): Promise<void> {
}): Promise<boolean> {
const terminated = new Promise((resolve) => {
Thread.events(worker).subscribe((event) => {
if (event.type === "termination") {
Expand All @@ -131,13 +137,19 @@ export async function terminateWorkerThread({
});

for (let i = 0; i < retryCount; i++) {
await Thread.terminate(worker);
const result = await Promise.race([terminated, sleep(retryMs).then(() => false)]);
// `Worker.terminate()` resolves only once the worker thread exits, and the thread can spin
// forever in `Environment::CleanupHandles()` when a libuv handle on its loop never closes, so
// it has to be raced too, otherwise `retryCount * retryMs` never applies.
const result = await Promise.race([
Thread.terminate(worker).then(() => terminated),
sleep(retryMs).then(() => false),
]);

if (result) return;
if (result) return true;

logger?.warn("Worker thread failed to terminate, retrying...");
logger?.debug("Worker thread failed to terminate, retrying...");
}

throw new Error(`Worker thread failed to terminate in ${retryCount * retryMs}ms.`);
logger?.debug(`Worker thread failed to terminate in ${retryCount * retryMs}ms`);
return false;
}
54 changes: 54 additions & 0 deletions packages/beacon-node/test/unit/util/workerEvents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest";
import {Thread} from "@chainsafe/threads";
import {terminateWorkerThread} from "../../../src/util/workerEvents.js";

vi.mock("@chainsafe/threads", () => ({
Thread: {
terminate: vi.fn(),
events: vi.fn(),
},
}));

describe("util / workerEvents / terminateWorkerThread", () => {
const retryMs = 20;
const retryCount = 3;
const worker = {} as Thread;

function mockEvents(events: Array<{type: string}>): void {
vi.mocked(Thread.events).mockReturnValue({
subscribe: (cb: (event: {type: string}) => void) => {
for (const event of events) cb(event);
return {unsubscribe: () => {}};
},
} as unknown as ReturnType<typeof Thread.events>);
}

beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("returns true when the worker terminates and emits a termination event", async () => {
mockEvents([{type: "termination"}]);
vi.mocked(Thread.terminate).mockResolvedValue(undefined as never);

await expect(terminateWorkerThread({worker, retryMs, retryCount})).resolves.toBe(true);
expect(Thread.terminate).toHaveBeenCalledTimes(1);
});

it("returns false in bounded time when Thread.terminate() never resolves (does not hang)", async () => {
// Worker that never exits, terminate() never resolves
mockEvents([]);
vi.mocked(Thread.terminate).mockReturnValue(new Promise<void>(() => {}) as never);

const promise = terminateWorkerThread({worker, retryMs, retryCount});
await vi.advanceTimersByTimeAsync(retryMs * retryCount);

await expect(promise).resolves.toBe(false);
expect(Thread.terminate).toHaveBeenCalledTimes(retryCount);
});
});
Loading