Skip to content

cherry-pick: feat(replication): gracefully drain in-flight blob sends before worker shutdown (#529 → v5.1)#552

Merged
kriszyp merged 2 commits into
v5.1from
cherry-pick/v5.1/pr-529
Jul 9, 2026
Merged

cherry-pick: feat(replication): gracefully drain in-flight blob sends before worker shutdown (#529 → v5.1)#552
kriszyp merged 2 commits into
v5.1from
cherry-pick/v5.1/pr-529

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 9, 2026

Copy link
Copy Markdown
Member

Manual cherry-pick of #529 to v5.1 — the automated bot crashed on the submodule conflict (core pointer + replication/replicator.ts + replication/replicationConnection.ts diverging from v5.1) before it could push anything.

Resolved by hand:

Verified: full build (dist emits fine despite this repo's pre-existing, CI-tolerated tsc baseline errors in analytics/profile.ts/replicationConnection.ts, unrelated to this change), unitTests/replication/** 278 passing, lint/format clean on all touched files (one pre-existing lint warning + one pre-existing format issue in replicationConnection.ts confirmed present on v5.1 before this cherry-pick, unrelated).

feat(replication): gracefully drain in-flight blob sends before worker shutdown
@kriszyp kriszyp requested a review from a team as a code owner July 9, 2026 03:05

@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 introduces a per-worker tracker (blobSendDrain.ts) to gracefully drain in-flight replication blob sends before a worker shuts down during a restart, preventing peer copy divergence. It integrates this tracking into the replication connection logic and registers the shutdown drain. The review feedback highlights two important issues: first, waitForDrainOrSocketEnd could hang indefinitely if the socket is already destroyed or not writable when called, leading to a leak of the drain token; second, a synchronous error in blob.stream() would bypass the finally block and permanently leak the blob ID in blobsBeingSent. Addressing these edge cases with defensive checks and error handling will make the implementation more robust.

Comment on lines +342 to +356
export function waitForDrainOrSocketEnd(socket: EventEmitter, ws: EventEmitter): Promise<void> {
return new Promise<void>((resolve) => {
const done = () => {
socket.off('drain', done);
socket.off('close', done);
socket.off('error', done);
ws.off('close', done);
resolve();
};
socket.once('drain', done);
socket.once('close', done);
socket.once('error', done);
ws.once('close', done);
});
}

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

If the underlying socket is already destroyed or no longer writable when waitForDrainOrSocketEnd is called, none of the registered event listeners ('drain', 'close', 'error') will ever fire. This will cause the returned promise to hang indefinitely, preventing the finally block in sendBlobs from running and leading to a permanent leak of the drain token and outstandingBlobsBeingSent counter.

Adding a defensive check to resolve immediately if the socket is already destroyed or not writable prevents this potential hang.

export function waitForDrainOrSocketEnd(socket: EventEmitter, ws: EventEmitter): Promise<void> {
	if ((socket as any).destroyed || !(socket as any).writable) {
		return Promise.resolve();
	}
	return new Promise<void>((resolve) => {
		const done = () => {
			socket.off('drain', done);
			socket.off('close', done);
			socket.off('error', done);
			ws.off('close', done);
			resolve();
		};
		socket.once('drain', done);
		socket.once('close', done);
		socket.once('error', done);
		ws.once('close', done);
	});
}

Comment on lines 3831 to +3834
const iterator = blob.stream()[Symbol.asyncIterator]();
// Track this send so a worker restart can gracefully drain it (finish it if it's still making
// progress) before shutting down, rather than tearing it down mid-stream. See blobSendDrain.ts.
const drainToken = registerBlobSend();

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

If blob.stream() throws an error synchronously (for example, if the blob is invalid or has been deleted/evicted before the stream is opened), the error will propagate immediately before entering the try...catch block. Because blobsBeingSent.add(id) has already been called, this synchronous throw will bypass the finally block's cleanup, permanently leaking the blob id in the blobsBeingSent set and preventing this blob from ever being re-sent on this connection.

Wrapping the stream creation in a local try...catch block to clean up blobsBeingSent on failure prevents this resource leak.

		let iterator;
		try {
			iterator = blob.stream()[Symbol.asyncIterator]();
		} catch (error) {
			blobsBeingSent.delete(id);
			throw error;
		}
		// Track this send so a worker restart can gracefully drain it (finish it if it's still making
		// progress) before shutting down, rather than tearing it down mid-stream. See blobSendDrain.ts.
		const drainToken = registerBlobSend();

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp kriszyp merged commit 8c91d86 into v5.1 Jul 9, 2026
30 checks passed
@kriszyp kriszyp deleted the cherry-pick/v5.1/pr-529 branch July 9, 2026 03:25
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.

1 participant