') + ')', 'gi');
if (regex.test(text)) {
found = true;
var frag = document.createDocumentFragment();
var parts = text.split(regex);
parts.forEach(function(part, i) {
if (i % 2 === 0) {
frag.appendChild(document.createTextNode(part));
} else {
var span = document.createElement('span');
span.className = 'userscript-highlight';
span.textContent = part;
frag.appendChild(span);
}
});
node.parentNode.replaceChild(frag, node);
}
});
} else if (node.nodeType === 1 && node.childNodes) { // element
var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];
if (!skipTags.includes(node.tagName)) {
Array.from(node.childNodes).forEach(highlight);
}
}
}
highlight(document.body);
// Re-highlight on dynamic content
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
m.addedNodes.forEach(function(node) {
if (node.nodeType === 1 || node.nodeType === 3) highlight(node);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); }
})();
(function(){
try {
var __m = "*";
var __re = new RegExp('^' + ".*" + ', 'i');
if (__m === '*' || __re.test(location.href)) {
// Strip utm_, fbclid, gclid, etc. from all links on page
(function() {
var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',
'ref', 'ref_src', 'source', 'medium', 'campaign'];
function cleanUrl(url) {
try {
var u = new URL(url, window.location.origin);
var changed = false;
trackingParams.forEach(function(p) {
if (u.searchParams.has(p)) {
u.searchParams.delete(p);
changed = true;
}
});
return changed ? u.toString() : url;
} catch (e) {
return url;
}
}
function cleanLinks() {
document.querySelectorAll('a[href]').forEach(function(a) {
var clean = cleanUrl(a.href);
if (clean !== a.href) a.href = clean;
});
}
cleanLinks();
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
m.addedNodes.forEach(function(node) {
if (node.nodeType === 1) {
if (node.tagName === 'A') cleanLinks();
node.querySelectorAll('a[href]').forEach(function(a) {
var clean = cleanUrl(a.href);
if (clean !== a.href) a.href = clean;
});
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); }
})();
(function(){
try {
var __m = "youtube.com";
var __re = new RegExp('^' + "youtube\\.com" + ', 'i');
if (__m === '*' || __re.test(location.href)) {
// Auto-enable theater mode on YouTube
(function() {
function tryTheater() {
var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]');
if (btn && !btn.classList.contains('activated')) {
btn.click();
}
}
// Try immediately
tryTheater();
// Try after navigation (SPA)
var lastUrl = location.href;
setInterval(function() {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(tryTheater, 500);
}
}, 1000);
// Also try on player load
var observer = new MutationObserver(tryTheater);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); }
})();
(function(){
try {
var __m = "*";
var __re = new RegExp('^' + ".*" + ', 'i');
if (__m === '*' || __re.test(location.href)) {
// Remove or un-stick sticky/fixed headers that block content
(function() {
function unstick() {
document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) {
if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {
el.style.position = 'static';
el.style.top = 'auto';
el.style.zIndex = 'auto';
}
});
}
unstick();
var observer = new MutationObserver(unstick);
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });
})();
}
} catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); }
})();
})();
fix(client): reject stdio send() when the write fails instead of waiting for 'drain' by ondraulehla · Pull Request #2552 · modelcontextprotocol/typescript-sdk · GitHub
You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
StdioClientTransport.send() now settles from the write() callback, so a backpressured send can't stay pending forever when the pipe to the server dies.
Motivation and Context
I noticed this while reading the two stdio transports side by side. The server one rejects a failed write, the client one has no error path at all: its promise executor only takes resolve, and a backpressured write resolves on 'drain'. If the server process then exits, or close() escalates to SIGTERM and SIGKILL, the stream is destroyed, and a destroyed stream never drains. The promise just stays pending, and the 'drain' listener is never removed.
The symptom is that await client.notification(...) never comes back. The notification path awaits the send with no timeout, and the connection-closed teardown settles pending responses but not pending sends, so nothing rescues it. I tried it against a server that answers initialize, stops reading stdin and exits, with 8 MB in flight: the await is still pending after 8 seconds, and with this change it rejects in 0.4 seconds. Requests were already covered by the teardown, so for them the only difference is that they now report the actual EPIPE instead of a generic connection-closed error.
You need a write the pipe won't take in one go to get there, which on Linux is between 160 KB and 224 KB depending on the Node version. Base64 image payloads and file contents in tool arguments reach that routinely.
The server transport has rejected on a write failure since #1568, with a test pinning its listener cleanup, so this brings the client side in line. The fast path stays as it was, since a write() that returns true still resolves right away and only the failure path differs.
How Has This Been Tested?
New test in packages/client/test/client/stdio.test.ts, using a server that never reads stdin and then exits with 8 MB of params in flight. Without the change the send never settles and the test reports hung; with it the send rejects and no 'drain' listener is left behind, which is the same leak check the server transport test already makes. I bounded the wait so the old behaviour fails fast instead of timing out the suite, and I don't assert the rejection reason because it is platform specific.
On Ubuntu the client suite is green on Node 20, 22 and 24, and pnpm test:all is green on Node 24. I also ran the new test ten times to make sure it isn't flaky. Typecheck, eslint and prettier are clean, changeset included.
Breaking Changes
None. send() already returns Promise<void> and the sibling stdio transport rejects it on a write failure, so callers that awaited it keep working.
v1.x has the same pattern in src/client/stdio.ts, and in src/server/stdio.ts too since #1568 only landed on main. Happy to send that separately if you want it.
Types of changes
Bug fix (non-breaking change which fixes an issue)
New feature (non-breaking change which adds functionality)
Breaking change (fix or feature that would cause existing functionality to change)
A backpressured `StdioClientTransport.send()` waited for a `'drain'` event, but
a pipe destroyed by the server process exiting never drains, so the promise
stayed pending for the lifetime of the process and the `'drain'` listener
leaked. Settle from the `write()` callback instead, which Node invokes on flush
or on failure, so the send rejects with the underlying write error. This is what
`StdioServerTransport.send()` already does for its own stdout.
Now that 2.0.0 is out, this sits in a released package rather than in a prerelease. @modelcontextprotocol/client@2.0.0 ships the 'drain'-only send at dist/stdio.cjs:208, and the v1 line has the same shape in @modelcontextprotocol/sdk@1.30.0 at dist/cjs/client/stdio.js:196.
Nothing has changed on my side since I opened this. The branch is seven commits behind main but still mergeable, packages/client/src/client/stdio.ts has not been touched since #2514, and the changeset is a patch on the client. I re-checked against main at cc4b416: without the change the regression test still reports the hang, with it the client suite is green (33 files, 798 tests).
claudeBot
added
the
v2
Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
label
Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
1 participant
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
StdioClientTransport.send()now settles from thewrite()callback, so a backpressured send can't stay pending forever when the pipe to the server dies.Motivation and Context
I noticed this while reading the two stdio transports side by side. The server one rejects a failed write, the client one has no error path at all: its promise executor only takes
resolve, and a backpressured write resolves on'drain'. If the server process then exits, orclose()escalates to SIGTERM and SIGKILL, the stream is destroyed, and a destroyed stream never drains. The promise just stays pending, and the'drain'listener is never removed.The symptom is that
await client.notification(...)never comes back. The notification path awaits the send with no timeout, and the connection-closed teardown settles pending responses but not pending sends, so nothing rescues it. I tried it against a server that answersinitialize, stops reading stdin and exits, with 8 MB in flight: the await is still pending after 8 seconds, and with this change it rejects in 0.4 seconds. Requests were already covered by the teardown, so for them the only difference is that they now report the actualEPIPEinstead of a generic connection-closed error.You need a write the pipe won't take in one go to get there, which on Linux is between 160 KB and 224 KB depending on the Node version. Base64 image payloads and file contents in tool arguments reach that routinely.
The server transport has rejected on a write failure since #1568, with a test pinning its listener cleanup, so this brings the client side in line. The fast path stays as it was, since a
write()that returns true still resolves right away and only the failure path differs.How Has This Been Tested?
packages/client/test/client/stdio.test.ts, using a server that never reads stdin and then exits with 8 MB of params in flight. Without the change the send never settles and the test reportshung; with it the send rejects and no'drain'listener is left behind, which is the same leak check the server transport test already makes. I bounded the wait so the old behaviour fails fast instead of timing out the suite, and I don't assert the rejection reason because it is platform specific.pnpm test:allis green on Node 24. I also ran the new test ten times to make sure it isn't flaky. Typecheck, eslint and prettier are clean, changeset included.Breaking Changes
None.
send()already returnsPromise<void>and the sibling stdio transport rejects it on a write failure, so callers that awaited it keep working.v1.xhas the same pattern insrc/client/stdio.ts, and insrc/server/stdio.tstoo since #1568 only landed onmain. Happy to send that separately if you want it.Types of changes
Checklist