ext/curl: add CURLOPT_SEEKFUNCTION - #22230

Merged
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction
Jul 12, 2026
Merged

ext/curl: add CURLOPT_SEEKFUNCTION#22230
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction

Conversation

@GrahamCampbell

@GrahamCampbellGrahamCampbell commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Today PHP lets you stream a request body from userland through CURLOPT_READFUNCTION, but it never gives libcurl a matching seek callback for that body. That's fine right up until libcurl needs to rewind the upload and send it again, which happens more often than you'd think: on a 307 or 308 redirect, during multi-pass authentication like NTLM or Negotiate, or when a reused keep-alive connection dies after some bytes have already gone out. With no way to rewind, the transfer just fails with CURLE_SEND_FAIL_REWIND (curl error 65). This is the gap behind the very old https://bugs.php.net/bug.php?id=47204, open since 2009, and the more recent https://bugs.php.net/bug.php?id=80518.

libcurl has supported CURLOPT_SEEKFUNCTION since 7.18.0; we just never exposed it for the read-callback body. The only seek callback we register internally is the one on the curl_mime/CURLFile path. So every userland HTTP client has had to work around this itself. Guzzle catches error 65 (and the older errno-0 "silent" variant of the same problem) and rewinds the body in PHP before re-issuing the request, and Symfony's HttpClient forces CURLOPT_FORBID_REUSE for NTLM because, as its own comment puts it, reusing those connections needs seeking capability that only string bodies have. Letting people set a seek callback means libcurl can just rewind and resend on its own.

So this exposes CURLOPT_SEEKFUNCTION. You give it a callable that receives the CurlHandle, the offset and the origin (SEEK_SET, SEEK_CUR or SEEK_END), and returns CURL_SEEKFUNC_OK when it has repositioned the body, CURL_SEEKFUNC_FAIL to fail the transfer, or CURL_SEEKFUNC_CANTSEEK when it can't seek and wants to let libcurl deal with it. In practice it looks like this:

$ch = curl_init('https://example.com/upload');
curl_setopt($ch, CURLOPT_UPLOAD, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_READFUNCTION, fn($ch, $fd, $len) => fread($body, $len));
curl_setopt($ch, CURLOPT_SEEKFUNCTION, fn($ch, $offset, $origin) =>
fseek($body, $offset, $origin) === 0 ? CURL_SEEKFUNC_OK : CURL_SEEKFUNC_CANTSEEK);
curl_exec($ch);

The implementation follows the existing callback options as closely as I could. php_curl_handlers gets a seek fcc field next to the other callbacks, a curl_seek trampoline forwards the call into userland and validates the return value the same way curl_prereqfunction does, registration goes through the usual HANDLE_CURL_OPTION_CALLABLE macro (which also points CURLOPT_SEEKDATA at the handle), and the callback is duplicated in curl_copy_handle and released with everything else. When no callback is set, or a callback misbehaves, the trampoline defaults to CURL_SEEKFUNC_CANTSEEK so libcurl never ends up resending from the wrong offset. No version guards are needed, since the option and its return constants have all been around since libcurl 7.18.0, comfortably below our 7.61.0 floor.

Finally, I've implemented a proof of concept cleanup of the relevant Guzzle code that would benefit from this feature at guzzle/guzzle@c2690b5.

@dragoonis

Copy link
Copy Markdown
Contributor

Hey @adoy, do you have time to take a look at this? If not just say and we can find someone who does. Thanks

@AyeshAyesh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me. There is a merge conflict in the NEWS file, but I think I think a simple git rebase master would solve it.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

Rebased.

@Ayesh
Ayesh requested a review from devnexenJune 23, 2026 12:56
@GrahamCampbell
GrahamCampbellforce-pushed the curl-seekfunction branch 3 times, most recently from 62ebf56 to 021ff11CompareJuly 10, 2026 13:42
@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

@Ayesh@devnexen I've rebased this again. Are we good to merge this now, before there are more conflicts? ❤️

@Ayesh

Copy link
Copy Markdown
Member

Sorry I also probably caused a merge conflict, but thank you for unwrangling it.

I was wondering if @Girgias would get some time to review this, because she worked a lot on FCC macros that SEEKFUNCTION also uses.

I could also test this PR locally; I will merge tomorrow unless there's any further comments.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

I've re-tested the rebased version of this with some real-world testing beyond the phpt suite, integrating it into Guzzle and comparing behaviour A/B against stock PHP on the same libcurl (8.21.0), using an NTS debug build which stayed quiet throughout.

The two classic failure scenarios both come out as hoped. When a reused keep-alive connection dies mid-upload (bug #47204), libcurl now rewinds a streamed body through the callback and resends it within the same transfer, where stock PHP fails with CURLE_SEND_FAIL_REWIND. An auth challenge arriving after the body has been sent (bug #80518) goes from unrecoverable on stock PHP to completing in a single transfer.

The callback semantics also check out from userland: CURL_SEEKFUNC_OK resends correctly, CURL_SEEKFUNC_CANTSEEK degrades to exactly the pre-PR behaviour, CURL_SEEKFUNC_FAIL fails the transfer cleanly, and clearing the option, curl_reset(), and handle reuse across transfers all behave as expected.

@Ayesh
Ayesh merged commit 93fa5f3 into php:masterJul 12, 2026
18 checks passed
@Ayesh

Copy link
Copy Markdown
Member

Thank you @GrahamCampbell :)

@GrahamCampbell
GrahamCampbell deleted the curl-seekfunction branch July 12, 2026 17:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@GrahamCampbell@dragoonis@Ayesh@devnexen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

ext/curl: add CURLOPT_SEEKFUNCTION - #22230

Merged
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction
Jul 12, 2026
Merged

ext/curl: add CURLOPT_SEEKFUNCTION#22230
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction

Conversation

@GrahamCampbell

@GrahamCampbellGrahamCampbell commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Today PHP lets you stream a request body from userland through CURLOPT_READFUNCTION, but it never gives libcurl a matching seek callback for that body. That's fine right up until libcurl needs to rewind the upload and send it again, which happens more often than you'd think: on a 307 or 308 redirect, during multi-pass authentication like NTLM or Negotiate, or when a reused keep-alive connection dies after some bytes have already gone out. With no way to rewind, the transfer just fails with CURLE_SEND_FAIL_REWIND (curl error 65). This is the gap behind the very old https://bugs.php.net/bug.php?id=47204, open since 2009, and the more recent https://bugs.php.net/bug.php?id=80518.

libcurl has supported CURLOPT_SEEKFUNCTION since 7.18.0; we just never exposed it for the read-callback body. The only seek callback we register internally is the one on the curl_mime/CURLFile path. So every userland HTTP client has had to work around this itself. Guzzle catches error 65 (and the older errno-0 "silent" variant of the same problem) and rewinds the body in PHP before re-issuing the request, and Symfony's HttpClient forces CURLOPT_FORBID_REUSE for NTLM because, as its own comment puts it, reusing those connections needs seeking capability that only string bodies have. Letting people set a seek callback means libcurl can just rewind and resend on its own.

So this exposes CURLOPT_SEEKFUNCTION. You give it a callable that receives the CurlHandle, the offset and the origin (SEEK_SET, SEEK_CUR or SEEK_END), and returns CURL_SEEKFUNC_OK when it has repositioned the body, CURL_SEEKFUNC_FAIL to fail the transfer, or CURL_SEEKFUNC_CANTSEEK when it can't seek and wants to let libcurl deal with it. In practice it looks like this:

$ch = curl_init('https://example.com/upload');
curl_setopt($ch, CURLOPT_UPLOAD, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_READFUNCTION, fn($ch, $fd, $len) => fread($body, $len));
curl_setopt($ch, CURLOPT_SEEKFUNCTION, fn($ch, $offset, $origin) =>
fseek($body, $offset, $origin) === 0 ? CURL_SEEKFUNC_OK : CURL_SEEKFUNC_CANTSEEK);
curl_exec($ch);

The implementation follows the existing callback options as closely as I could. php_curl_handlers gets a seek fcc field next to the other callbacks, a curl_seek trampoline forwards the call into userland and validates the return value the same way curl_prereqfunction does, registration goes through the usual HANDLE_CURL_OPTION_CALLABLE macro (which also points CURLOPT_SEEKDATA at the handle), and the callback is duplicated in curl_copy_handle and released with everything else. When no callback is set, or a callback misbehaves, the trampoline defaults to CURL_SEEKFUNC_CANTSEEK so libcurl never ends up resending from the wrong offset. No version guards are needed, since the option and its return constants have all been around since libcurl 7.18.0, comfortably below our 7.61.0 floor.

Finally, I've implemented a proof of concept cleanup of the relevant Guzzle code that would benefit from this feature at guzzle/guzzle@c2690b5.

@dragoonis

Copy link
Copy Markdown
Contributor

Hey @adoy, do you have time to take a look at this? If not just say and we can find someone who does. Thanks

@AyeshAyesh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me. There is a merge conflict in the NEWS file, but I think I think a simple git rebase master would solve it.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

Rebased.

@Ayesh
Ayesh requested a review from devnexenJune 23, 2026 12:56
@GrahamCampbell
GrahamCampbellforce-pushed the curl-seekfunction branch 3 times, most recently from 62ebf56 to 021ff11CompareJuly 10, 2026 13:42
@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

@Ayesh@devnexen I've rebased this again. Are we good to merge this now, before there are more conflicts? ❤️

@Ayesh

Copy link
Copy Markdown
Member

Sorry I also probably caused a merge conflict, but thank you for unwrangling it.

I was wondering if @Girgias would get some time to review this, because she worked a lot on FCC macros that SEEKFUNCTION also uses.

I could also test this PR locally; I will merge tomorrow unless there's any further comments.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

I've re-tested the rebased version of this with some real-world testing beyond the phpt suite, integrating it into Guzzle and comparing behaviour A/B against stock PHP on the same libcurl (8.21.0), using an NTS debug build which stayed quiet throughout.

The two classic failure scenarios both come out as hoped. When a reused keep-alive connection dies mid-upload (bug #47204), libcurl now rewinds a streamed body through the callback and resends it within the same transfer, where stock PHP fails with CURLE_SEND_FAIL_REWIND. An auth challenge arriving after the body has been sent (bug #80518) goes from unrecoverable on stock PHP to completing in a single transfer.

The callback semantics also check out from userland: CURL_SEEKFUNC_OK resends correctly, CURL_SEEKFUNC_CANTSEEK degrades to exactly the pre-PR behaviour, CURL_SEEKFUNC_FAIL fails the transfer cleanly, and clearing the option, curl_reset(), and handle reuse across transfers all behave as expected.

@Ayesh
Ayesh merged commit 93fa5f3 into php:masterJul 12, 2026
18 checks passed
@Ayesh

Copy link
Copy Markdown
Member

Thank you @GrahamCampbell :)

@GrahamCampbell
GrahamCampbell deleted the curl-seekfunction branch July 12, 2026 17:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@GrahamCampbell@dragoonis@Ayesh@devnexen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ext/curl: add CURLOPT_SEEKFUNCTION - #22230

Merged
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction
Jul 12, 2026
Merged

ext/curl: add CURLOPT_SEEKFUNCTION#22230
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction

Conversation

@GrahamCampbell

@GrahamCampbellGrahamCampbell commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Today PHP lets you stream a request body from userland through CURLOPT_READFUNCTION, but it never gives libcurl a matching seek callback for that body. That's fine right up until libcurl needs to rewind the upload and send it again, which happens more often than you'd think: on a 307 or 308 redirect, during multi-pass authentication like NTLM or Negotiate, or when a reused keep-alive connection dies after some bytes have already gone out. With no way to rewind, the transfer just fails with CURLE_SEND_FAIL_REWIND (curl error 65). This is the gap behind the very old https://bugs.php.net/bug.php?id=47204, open since 2009, and the more recent https://bugs.php.net/bug.php?id=80518.

libcurl has supported CURLOPT_SEEKFUNCTION since 7.18.0; we just never exposed it for the read-callback body. The only seek callback we register internally is the one on the curl_mime/CURLFile path. So every userland HTTP client has had to work around this itself. Guzzle catches error 65 (and the older errno-0 "silent" variant of the same problem) and rewinds the body in PHP before re-issuing the request, and Symfony's HttpClient forces CURLOPT_FORBID_REUSE for NTLM because, as its own comment puts it, reusing those connections needs seeking capability that only string bodies have. Letting people set a seek callback means libcurl can just rewind and resend on its own.

So this exposes CURLOPT_SEEKFUNCTION. You give it a callable that receives the CurlHandle, the offset and the origin (SEEK_SET, SEEK_CUR or SEEK_END), and returns CURL_SEEKFUNC_OK when it has repositioned the body, CURL_SEEKFUNC_FAIL to fail the transfer, or CURL_SEEKFUNC_CANTSEEK when it can't seek and wants to let libcurl deal with it. In practice it looks like this:

$ch = curl_init('https://example.com/upload');
curl_setopt($ch, CURLOPT_UPLOAD, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_READFUNCTION, fn($ch, $fd, $len) => fread($body, $len));
curl_setopt($ch, CURLOPT_SEEKFUNCTION, fn($ch, $offset, $origin) =>
fseek($body, $offset, $origin) === 0 ? CURL_SEEKFUNC_OK : CURL_SEEKFUNC_CANTSEEK);
curl_exec($ch);

The implementation follows the existing callback options as closely as I could. php_curl_handlers gets a seek fcc field next to the other callbacks, a curl_seek trampoline forwards the call into userland and validates the return value the same way curl_prereqfunction does, registration goes through the usual HANDLE_CURL_OPTION_CALLABLE macro (which also points CURLOPT_SEEKDATA at the handle), and the callback is duplicated in curl_copy_handle and released with everything else. When no callback is set, or a callback misbehaves, the trampoline defaults to CURL_SEEKFUNC_CANTSEEK so libcurl never ends up resending from the wrong offset. No version guards are needed, since the option and its return constants have all been around since libcurl 7.18.0, comfortably below our 7.61.0 floor.

Finally, I've implemented a proof of concept cleanup of the relevant Guzzle code that would benefit from this feature at guzzle/guzzle@c2690b5.

@dragoonis

Copy link
Copy Markdown
Contributor

Hey @adoy, do you have time to take a look at this? If not just say and we can find someone who does. Thanks

@AyeshAyesh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me. There is a merge conflict in the NEWS file, but I think I think a simple git rebase master would solve it.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

Rebased.

@Ayesh
Ayesh requested a review from devnexenJune 23, 2026 12:56
@GrahamCampbell
GrahamCampbellforce-pushed the curl-seekfunction branch 3 times, most recently from 62ebf56 to 021ff11CompareJuly 10, 2026 13:42
@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

@Ayesh@devnexen I've rebased this again. Are we good to merge this now, before there are more conflicts? ❤️

@Ayesh

Copy link
Copy Markdown
Member

Sorry I also probably caused a merge conflict, but thank you for unwrangling it.

I was wondering if @Girgias would get some time to review this, because she worked a lot on FCC macros that SEEKFUNCTION also uses.

I could also test this PR locally; I will merge tomorrow unless there's any further comments.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

I've re-tested the rebased version of this with some real-world testing beyond the phpt suite, integrating it into Guzzle and comparing behaviour A/B against stock PHP on the same libcurl (8.21.0), using an NTS debug build which stayed quiet throughout.

The two classic failure scenarios both come out as hoped. When a reused keep-alive connection dies mid-upload (bug #47204), libcurl now rewinds a streamed body through the callback and resends it within the same transfer, where stock PHP fails with CURLE_SEND_FAIL_REWIND. An auth challenge arriving after the body has been sent (bug #80518) goes from unrecoverable on stock PHP to completing in a single transfer.

The callback semantics also check out from userland: CURL_SEEKFUNC_OK resends correctly, CURL_SEEKFUNC_CANTSEEK degrades to exactly the pre-PR behaviour, CURL_SEEKFUNC_FAIL fails the transfer cleanly, and clearing the option, curl_reset(), and handle reuse across transfers all behave as expected.

@Ayesh
Ayesh merged commit 93fa5f3 into php:masterJul 12, 2026
18 checks passed
@Ayesh

Copy link
Copy Markdown
Member

Thank you @GrahamCampbell :)

@GrahamCampbell
GrahamCampbell deleted the curl-seekfunction branch July 12, 2026 17:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@GrahamCampbell@dragoonis@Ayesh@devnexen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ext/curl: add CURLOPT_SEEKFUNCTION - #22230

Merged
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction
Jul 12, 2026
Merged

ext/curl: add CURLOPT_SEEKFUNCTION#22230
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction

Conversation

@GrahamCampbell

@GrahamCampbellGrahamCampbell commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Today PHP lets you stream a request body from userland through CURLOPT_READFUNCTION, but it never gives libcurl a matching seek callback for that body. That's fine right up until libcurl needs to rewind the upload and send it again, which happens more often than you'd think: on a 307 or 308 redirect, during multi-pass authentication like NTLM or Negotiate, or when a reused keep-alive connection dies after some bytes have already gone out. With no way to rewind, the transfer just fails with CURLE_SEND_FAIL_REWIND (curl error 65). This is the gap behind the very old https://bugs.php.net/bug.php?id=47204, open since 2009, and the more recent https://bugs.php.net/bug.php?id=80518.

libcurl has supported CURLOPT_SEEKFUNCTION since 7.18.0; we just never exposed it for the read-callback body. The only seek callback we register internally is the one on the curl_mime/CURLFile path. So every userland HTTP client has had to work around this itself. Guzzle catches error 65 (and the older errno-0 "silent" variant of the same problem) and rewinds the body in PHP before re-issuing the request, and Symfony's HttpClient forces CURLOPT_FORBID_REUSE for NTLM because, as its own comment puts it, reusing those connections needs seeking capability that only string bodies have. Letting people set a seek callback means libcurl can just rewind and resend on its own.

So this exposes CURLOPT_SEEKFUNCTION. You give it a callable that receives the CurlHandle, the offset and the origin (SEEK_SET, SEEK_CUR or SEEK_END), and returns CURL_SEEKFUNC_OK when it has repositioned the body, CURL_SEEKFUNC_FAIL to fail the transfer, or CURL_SEEKFUNC_CANTSEEK when it can't seek and wants to let libcurl deal with it. In practice it looks like this:

$ch = curl_init('https://example.com/upload');
curl_setopt($ch, CURLOPT_UPLOAD, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_READFUNCTION, fn($ch, $fd, $len) => fread($body, $len));
curl_setopt($ch, CURLOPT_SEEKFUNCTION, fn($ch, $offset, $origin) =>
fseek($body, $offset, $origin) === 0 ? CURL_SEEKFUNC_OK : CURL_SEEKFUNC_CANTSEEK);
curl_exec($ch);

The implementation follows the existing callback options as closely as I could. php_curl_handlers gets a seek fcc field next to the other callbacks, a curl_seek trampoline forwards the call into userland and validates the return value the same way curl_prereqfunction does, registration goes through the usual HANDLE_CURL_OPTION_CALLABLE macro (which also points CURLOPT_SEEKDATA at the handle), and the callback is duplicated in curl_copy_handle and released with everything else. When no callback is set, or a callback misbehaves, the trampoline defaults to CURL_SEEKFUNC_CANTSEEK so libcurl never ends up resending from the wrong offset. No version guards are needed, since the option and its return constants have all been around since libcurl 7.18.0, comfortably below our 7.61.0 floor.

Finally, I've implemented a proof of concept cleanup of the relevant Guzzle code that would benefit from this feature at guzzle/guzzle@c2690b5.

@dragoonis

Copy link
Copy Markdown
Contributor

Hey @adoy, do you have time to take a look at this? If not just say and we can find someone who does. Thanks

@AyeshAyesh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me. There is a merge conflict in the NEWS file, but I think I think a simple git rebase master would solve it.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

Rebased.

@Ayesh
Ayesh requested a review from devnexenJune 23, 2026 12:56
@GrahamCampbell
GrahamCampbellforce-pushed the curl-seekfunction branch 3 times, most recently from 62ebf56 to 021ff11CompareJuly 10, 2026 13:42
@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

@Ayesh@devnexen I've rebased this again. Are we good to merge this now, before there are more conflicts? ❤️

@Ayesh

Copy link
Copy Markdown
Member

Sorry I also probably caused a merge conflict, but thank you for unwrangling it.

I was wondering if @Girgias would get some time to review this, because she worked a lot on FCC macros that SEEKFUNCTION also uses.

I could also test this PR locally; I will merge tomorrow unless there's any further comments.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

I've re-tested the rebased version of this with some real-world testing beyond the phpt suite, integrating it into Guzzle and comparing behaviour A/B against stock PHP on the same libcurl (8.21.0), using an NTS debug build which stayed quiet throughout.

The two classic failure scenarios both come out as hoped. When a reused keep-alive connection dies mid-upload (bug #47204), libcurl now rewinds a streamed body through the callback and resends it within the same transfer, where stock PHP fails with CURLE_SEND_FAIL_REWIND. An auth challenge arriving after the body has been sent (bug #80518) goes from unrecoverable on stock PHP to completing in a single transfer.

The callback semantics also check out from userland: CURL_SEEKFUNC_OK resends correctly, CURL_SEEKFUNC_CANTSEEK degrades to exactly the pre-PR behaviour, CURL_SEEKFUNC_FAIL fails the transfer cleanly, and clearing the option, curl_reset(), and handle reuse across transfers all behave as expected.

@Ayesh
Ayesh merged commit 93fa5f3 into php:masterJul 12, 2026
18 checks passed
@Ayesh

Copy link
Copy Markdown
Member

Thank you @GrahamCampbell :)

@GrahamCampbell
GrahamCampbell deleted the curl-seekfunction branch July 12, 2026 17:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@GrahamCampbell@dragoonis@Ayesh@devnexen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

ext/curl: add CURLOPT_SEEKFUNCTION - #22230

Merged
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction
Jul 12, 2026
Merged

ext/curl: add CURLOPT_SEEKFUNCTION#22230
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction

Conversation

@GrahamCampbell

@GrahamCampbellGrahamCampbell commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Today PHP lets you stream a request body from userland through CURLOPT_READFUNCTION, but it never gives libcurl a matching seek callback for that body. That's fine right up until libcurl needs to rewind the upload and send it again, which happens more often than you'd think: on a 307 or 308 redirect, during multi-pass authentication like NTLM or Negotiate, or when a reused keep-alive connection dies after some bytes have already gone out. With no way to rewind, the transfer just fails with CURLE_SEND_FAIL_REWIND (curl error 65). This is the gap behind the very old https://bugs.php.net/bug.php?id=47204, open since 2009, and the more recent https://bugs.php.net/bug.php?id=80518.

libcurl has supported CURLOPT_SEEKFUNCTION since 7.18.0; we just never exposed it for the read-callback body. The only seek callback we register internally is the one on the curl_mime/CURLFile path. So every userland HTTP client has had to work around this itself. Guzzle catches error 65 (and the older errno-0 "silent" variant of the same problem) and rewinds the body in PHP before re-issuing the request, and Symfony's HttpClient forces CURLOPT_FORBID_REUSE for NTLM because, as its own comment puts it, reusing those connections needs seeking capability that only string bodies have. Letting people set a seek callback means libcurl can just rewind and resend on its own.

So this exposes CURLOPT_SEEKFUNCTION. You give it a callable that receives the CurlHandle, the offset and the origin (SEEK_SET, SEEK_CUR or SEEK_END), and returns CURL_SEEKFUNC_OK when it has repositioned the body, CURL_SEEKFUNC_FAIL to fail the transfer, or CURL_SEEKFUNC_CANTSEEK when it can't seek and wants to let libcurl deal with it. In practice it looks like this:

$ch = curl_init('https://example.com/upload');
curl_setopt($ch, CURLOPT_UPLOAD, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_READFUNCTION, fn($ch, $fd, $len) => fread($body, $len));
curl_setopt($ch, CURLOPT_SEEKFUNCTION, fn($ch, $offset, $origin) =>
fseek($body, $offset, $origin) === 0 ? CURL_SEEKFUNC_OK : CURL_SEEKFUNC_CANTSEEK);
curl_exec($ch);

The implementation follows the existing callback options as closely as I could. php_curl_handlers gets a seek fcc field next to the other callbacks, a curl_seek trampoline forwards the call into userland and validates the return value the same way curl_prereqfunction does, registration goes through the usual HANDLE_CURL_OPTION_CALLABLE macro (which also points CURLOPT_SEEKDATA at the handle), and the callback is duplicated in curl_copy_handle and released with everything else. When no callback is set, or a callback misbehaves, the trampoline defaults to CURL_SEEKFUNC_CANTSEEK so libcurl never ends up resending from the wrong offset. No version guards are needed, since the option and its return constants have all been around since libcurl 7.18.0, comfortably below our 7.61.0 floor.

Finally, I've implemented a proof of concept cleanup of the relevant Guzzle code that would benefit from this feature at guzzle/guzzle@c2690b5.

@dragoonis

Copy link
Copy Markdown
Contributor

Hey @adoy, do you have time to take a look at this? If not just say and we can find someone who does. Thanks

@AyeshAyesh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me. There is a merge conflict in the NEWS file, but I think I think a simple git rebase master would solve it.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

Rebased.

@Ayesh
Ayesh requested a review from devnexenJune 23, 2026 12:56
@GrahamCampbell
GrahamCampbellforce-pushed the curl-seekfunction branch 3 times, most recently from 62ebf56 to 021ff11CompareJuly 10, 2026 13:42
@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

@Ayesh@devnexen I've rebased this again. Are we good to merge this now, before there are more conflicts? ❤️

@Ayesh

Copy link
Copy Markdown
Member

Sorry I also probably caused a merge conflict, but thank you for unwrangling it.

I was wondering if @Girgias would get some time to review this, because she worked a lot on FCC macros that SEEKFUNCTION also uses.

I could also test this PR locally; I will merge tomorrow unless there's any further comments.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

I've re-tested the rebased version of this with some real-world testing beyond the phpt suite, integrating it into Guzzle and comparing behaviour A/B against stock PHP on the same libcurl (8.21.0), using an NTS debug build which stayed quiet throughout.

The two classic failure scenarios both come out as hoped. When a reused keep-alive connection dies mid-upload (bug #47204), libcurl now rewinds a streamed body through the callback and resends it within the same transfer, where stock PHP fails with CURLE_SEND_FAIL_REWIND. An auth challenge arriving after the body has been sent (bug #80518) goes from unrecoverable on stock PHP to completing in a single transfer.

The callback semantics also check out from userland: CURL_SEEKFUNC_OK resends correctly, CURL_SEEKFUNC_CANTSEEK degrades to exactly the pre-PR behaviour, CURL_SEEKFUNC_FAIL fails the transfer cleanly, and clearing the option, curl_reset(), and handle reuse across transfers all behave as expected.

@Ayesh
Ayesh merged commit 93fa5f3 into php:masterJul 12, 2026
18 checks passed
@Ayesh

Copy link
Copy Markdown
Member

Thank you @GrahamCampbell :)

@GrahamCampbell
GrahamCampbell deleted the curl-seekfunction branch July 12, 2026 17:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@GrahamCampbell@dragoonis@Ayesh@devnexen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ext/curl: add CURLOPT_SEEKFUNCTION - #22230

Merged
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction
Jul 12, 2026
Merged

ext/curl: add CURLOPT_SEEKFUNCTION#22230
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction

Conversation

@GrahamCampbell

@GrahamCampbellGrahamCampbell commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Today PHP lets you stream a request body from userland through CURLOPT_READFUNCTION, but it never gives libcurl a matching seek callback for that body. That's fine right up until libcurl needs to rewind the upload and send it again, which happens more often than you'd think: on a 307 or 308 redirect, during multi-pass authentication like NTLM or Negotiate, or when a reused keep-alive connection dies after some bytes have already gone out. With no way to rewind, the transfer just fails with CURLE_SEND_FAIL_REWIND (curl error 65). This is the gap behind the very old https://bugs.php.net/bug.php?id=47204, open since 2009, and the more recent https://bugs.php.net/bug.php?id=80518.

libcurl has supported CURLOPT_SEEKFUNCTION since 7.18.0; we just never exposed it for the read-callback body. The only seek callback we register internally is the one on the curl_mime/CURLFile path. So every userland HTTP client has had to work around this itself. Guzzle catches error 65 (and the older errno-0 "silent" variant of the same problem) and rewinds the body in PHP before re-issuing the request, and Symfony's HttpClient forces CURLOPT_FORBID_REUSE for NTLM because, as its own comment puts it, reusing those connections needs seeking capability that only string bodies have. Letting people set a seek callback means libcurl can just rewind and resend on its own.

So this exposes CURLOPT_SEEKFUNCTION. You give it a callable that receives the CurlHandle, the offset and the origin (SEEK_SET, SEEK_CUR or SEEK_END), and returns CURL_SEEKFUNC_OK when it has repositioned the body, CURL_SEEKFUNC_FAIL to fail the transfer, or CURL_SEEKFUNC_CANTSEEK when it can't seek and wants to let libcurl deal with it. In practice it looks like this:

$ch = curl_init('https://example.com/upload');
curl_setopt($ch, CURLOPT_UPLOAD, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_READFUNCTION, fn($ch, $fd, $len) => fread($body, $len));
curl_setopt($ch, CURLOPT_SEEKFUNCTION, fn($ch, $offset, $origin) =>
fseek($body, $offset, $origin) === 0 ? CURL_SEEKFUNC_OK : CURL_SEEKFUNC_CANTSEEK);
curl_exec($ch);

The implementation follows the existing callback options as closely as I could. php_curl_handlers gets a seek fcc field next to the other callbacks, a curl_seek trampoline forwards the call into userland and validates the return value the same way curl_prereqfunction does, registration goes through the usual HANDLE_CURL_OPTION_CALLABLE macro (which also points CURLOPT_SEEKDATA at the handle), and the callback is duplicated in curl_copy_handle and released with everything else. When no callback is set, or a callback misbehaves, the trampoline defaults to CURL_SEEKFUNC_CANTSEEK so libcurl never ends up resending from the wrong offset. No version guards are needed, since the option and its return constants have all been around since libcurl 7.18.0, comfortably below our 7.61.0 floor.

Finally, I've implemented a proof of concept cleanup of the relevant Guzzle code that would benefit from this feature at guzzle/guzzle@c2690b5.

@dragoonis

Copy link
Copy Markdown
Contributor

Hey @adoy, do you have time to take a look at this? If not just say and we can find someone who does. Thanks

@AyeshAyesh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me. There is a merge conflict in the NEWS file, but I think I think a simple git rebase master would solve it.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

Rebased.

@Ayesh
Ayesh requested a review from devnexenJune 23, 2026 12:56
@GrahamCampbell
GrahamCampbellforce-pushed the curl-seekfunction branch 3 times, most recently from 62ebf56 to 021ff11CompareJuly 10, 2026 13:42
@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

@Ayesh@devnexen I've rebased this again. Are we good to merge this now, before there are more conflicts? ❤️

@Ayesh

Copy link
Copy Markdown
Member

Sorry I also probably caused a merge conflict, but thank you for unwrangling it.

I was wondering if @Girgias would get some time to review this, because she worked a lot on FCC macros that SEEKFUNCTION also uses.

I could also test this PR locally; I will merge tomorrow unless there's any further comments.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

I've re-tested the rebased version of this with some real-world testing beyond the phpt suite, integrating it into Guzzle and comparing behaviour A/B against stock PHP on the same libcurl (8.21.0), using an NTS debug build which stayed quiet throughout.

The two classic failure scenarios both come out as hoped. When a reused keep-alive connection dies mid-upload (bug #47204), libcurl now rewinds a streamed body through the callback and resends it within the same transfer, where stock PHP fails with CURLE_SEND_FAIL_REWIND. An auth challenge arriving after the body has been sent (bug #80518) goes from unrecoverable on stock PHP to completing in a single transfer.

The callback semantics also check out from userland: CURL_SEEKFUNC_OK resends correctly, CURL_SEEKFUNC_CANTSEEK degrades to exactly the pre-PR behaviour, CURL_SEEKFUNC_FAIL fails the transfer cleanly, and clearing the option, curl_reset(), and handle reuse across transfers all behave as expected.

@Ayesh
Ayesh merged commit 93fa5f3 into php:masterJul 12, 2026
18 checks passed
@Ayesh

Copy link
Copy Markdown
Member

Thank you @GrahamCampbell :)

@GrahamCampbell
GrahamCampbell deleted the curl-seekfunction branch July 12, 2026 17:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@GrahamCampbell@dragoonis@Ayesh@devnexen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ext/curl: add CURLOPT_SEEKFUNCTION - #22230

Merged
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction
Jul 12, 2026
Merged

ext/curl: add CURLOPT_SEEKFUNCTION#22230
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction

Conversation

@GrahamCampbell

@GrahamCampbellGrahamCampbell commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Today PHP lets you stream a request body from userland through CURLOPT_READFUNCTION, but it never gives libcurl a matching seek callback for that body. That's fine right up until libcurl needs to rewind the upload and send it again, which happens more often than you'd think: on a 307 or 308 redirect, during multi-pass authentication like NTLM or Negotiate, or when a reused keep-alive connection dies after some bytes have already gone out. With no way to rewind, the transfer just fails with CURLE_SEND_FAIL_REWIND (curl error 65). This is the gap behind the very old https://bugs.php.net/bug.php?id=47204, open since 2009, and the more recent https://bugs.php.net/bug.php?id=80518.

libcurl has supported CURLOPT_SEEKFUNCTION since 7.18.0; we just never exposed it for the read-callback body. The only seek callback we register internally is the one on the curl_mime/CURLFile path. So every userland HTTP client has had to work around this itself. Guzzle catches error 65 (and the older errno-0 "silent" variant of the same problem) and rewinds the body in PHP before re-issuing the request, and Symfony's HttpClient forces CURLOPT_FORBID_REUSE for NTLM because, as its own comment puts it, reusing those connections needs seeking capability that only string bodies have. Letting people set a seek callback means libcurl can just rewind and resend on its own.

So this exposes CURLOPT_SEEKFUNCTION. You give it a callable that receives the CurlHandle, the offset and the origin (SEEK_SET, SEEK_CUR or SEEK_END), and returns CURL_SEEKFUNC_OK when it has repositioned the body, CURL_SEEKFUNC_FAIL to fail the transfer, or CURL_SEEKFUNC_CANTSEEK when it can't seek and wants to let libcurl deal with it. In practice it looks like this:

$ch = curl_init('https://example.com/upload');
curl_setopt($ch, CURLOPT_UPLOAD, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_READFUNCTION, fn($ch, $fd, $len) => fread($body, $len));
curl_setopt($ch, CURLOPT_SEEKFUNCTION, fn($ch, $offset, $origin) =>
fseek($body, $offset, $origin) === 0 ? CURL_SEEKFUNC_OK : CURL_SEEKFUNC_CANTSEEK);
curl_exec($ch);

The implementation follows the existing callback options as closely as I could. php_curl_handlers gets a seek fcc field next to the other callbacks, a curl_seek trampoline forwards the call into userland and validates the return value the same way curl_prereqfunction does, registration goes through the usual HANDLE_CURL_OPTION_CALLABLE macro (which also points CURLOPT_SEEKDATA at the handle), and the callback is duplicated in curl_copy_handle and released with everything else. When no callback is set, or a callback misbehaves, the trampoline defaults to CURL_SEEKFUNC_CANTSEEK so libcurl never ends up resending from the wrong offset. No version guards are needed, since the option and its return constants have all been around since libcurl 7.18.0, comfortably below our 7.61.0 floor.

Finally, I've implemented a proof of concept cleanup of the relevant Guzzle code that would benefit from this feature at guzzle/guzzle@c2690b5.

@dragoonis

Copy link
Copy Markdown
Contributor

Hey @adoy, do you have time to take a look at this? If not just say and we can find someone who does. Thanks

@AyeshAyesh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me. There is a merge conflict in the NEWS file, but I think I think a simple git rebase master would solve it.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

Rebased.

@Ayesh
Ayesh requested a review from devnexenJune 23, 2026 12:56
@GrahamCampbell
GrahamCampbellforce-pushed the curl-seekfunction branch 3 times, most recently from 62ebf56 to 021ff11CompareJuly 10, 2026 13:42
@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

@Ayesh@devnexen I've rebased this again. Are we good to merge this now, before there are more conflicts? ❤️

@Ayesh

Copy link
Copy Markdown
Member

Sorry I also probably caused a merge conflict, but thank you for unwrangling it.

I was wondering if @Girgias would get some time to review this, because she worked a lot on FCC macros that SEEKFUNCTION also uses.

I could also test this PR locally; I will merge tomorrow unless there's any further comments.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

I've re-tested the rebased version of this with some real-world testing beyond the phpt suite, integrating it into Guzzle and comparing behaviour A/B against stock PHP on the same libcurl (8.21.0), using an NTS debug build which stayed quiet throughout.

The two classic failure scenarios both come out as hoped. When a reused keep-alive connection dies mid-upload (bug #47204), libcurl now rewinds a streamed body through the callback and resends it within the same transfer, where stock PHP fails with CURLE_SEND_FAIL_REWIND. An auth challenge arriving after the body has been sent (bug #80518) goes from unrecoverable on stock PHP to completing in a single transfer.

The callback semantics also check out from userland: CURL_SEEKFUNC_OK resends correctly, CURL_SEEKFUNC_CANTSEEK degrades to exactly the pre-PR behaviour, CURL_SEEKFUNC_FAIL fails the transfer cleanly, and clearing the option, curl_reset(), and handle reuse across transfers all behave as expected.

@Ayesh
Ayesh merged commit 93fa5f3 into php:masterJul 12, 2026
18 checks passed
@Ayesh

Copy link
Copy Markdown
Member

Thank you @GrahamCampbell :)

@GrahamCampbell
GrahamCampbell deleted the curl-seekfunction branch July 12, 2026 17:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@GrahamCampbell@dragoonis@Ayesh@devnexen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

ext/curl: add CURLOPT_SEEKFUNCTION - #22230

Merged
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction
Jul 12, 2026
Merged

ext/curl: add CURLOPT_SEEKFUNCTION#22230
Ayesh merged 1 commit into
php:masterfrom
GrahamCampbell:curl-seekfunction

Conversation

@GrahamCampbell

@GrahamCampbellGrahamCampbell commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Today PHP lets you stream a request body from userland through CURLOPT_READFUNCTION, but it never gives libcurl a matching seek callback for that body. That's fine right up until libcurl needs to rewind the upload and send it again, which happens more often than you'd think: on a 307 or 308 redirect, during multi-pass authentication like NTLM or Negotiate, or when a reused keep-alive connection dies after some bytes have already gone out. With no way to rewind, the transfer just fails with CURLE_SEND_FAIL_REWIND (curl error 65). This is the gap behind the very old https://bugs.php.net/bug.php?id=47204, open since 2009, and the more recent https://bugs.php.net/bug.php?id=80518.

libcurl has supported CURLOPT_SEEKFUNCTION since 7.18.0; we just never exposed it for the read-callback body. The only seek callback we register internally is the one on the curl_mime/CURLFile path. So every userland HTTP client has had to work around this itself. Guzzle catches error 65 (and the older errno-0 "silent" variant of the same problem) and rewinds the body in PHP before re-issuing the request, and Symfony's HttpClient forces CURLOPT_FORBID_REUSE for NTLM because, as its own comment puts it, reusing those connections needs seeking capability that only string bodies have. Letting people set a seek callback means libcurl can just rewind and resend on its own.

So this exposes CURLOPT_SEEKFUNCTION. You give it a callable that receives the CurlHandle, the offset and the origin (SEEK_SET, SEEK_CUR or SEEK_END), and returns CURL_SEEKFUNC_OK when it has repositioned the body, CURL_SEEKFUNC_FAIL to fail the transfer, or CURL_SEEKFUNC_CANTSEEK when it can't seek and wants to let libcurl deal with it. In practice it looks like this:

$ch = curl_init('https://example.com/upload');
curl_setopt($ch, CURLOPT_UPLOAD, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_READFUNCTION, fn($ch, $fd, $len) => fread($body, $len));
curl_setopt($ch, CURLOPT_SEEKFUNCTION, fn($ch, $offset, $origin) =>
fseek($body, $offset, $origin) === 0 ? CURL_SEEKFUNC_OK : CURL_SEEKFUNC_CANTSEEK);
curl_exec($ch);

The implementation follows the existing callback options as closely as I could. php_curl_handlers gets a seek fcc field next to the other callbacks, a curl_seek trampoline forwards the call into userland and validates the return value the same way curl_prereqfunction does, registration goes through the usual HANDLE_CURL_OPTION_CALLABLE macro (which also points CURLOPT_SEEKDATA at the handle), and the callback is duplicated in curl_copy_handle and released with everything else. When no callback is set, or a callback misbehaves, the trampoline defaults to CURL_SEEKFUNC_CANTSEEK so libcurl never ends up resending from the wrong offset. No version guards are needed, since the option and its return constants have all been around since libcurl 7.18.0, comfortably below our 7.61.0 floor.

Finally, I've implemented a proof of concept cleanup of the relevant Guzzle code that would benefit from this feature at guzzle/guzzle@c2690b5.

@dragoonis

Copy link
Copy Markdown
Contributor

Hey @adoy, do you have time to take a look at this? If not just say and we can find someone who does. Thanks

@AyeshAyesh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me. There is a merge conflict in the NEWS file, but I think I think a simple git rebase master would solve it.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

Rebased.

@Ayesh
Ayesh requested a review from devnexenJune 23, 2026 12:56
@GrahamCampbell
GrahamCampbellforce-pushed the curl-seekfunction branch 3 times, most recently from 62ebf56 to 021ff11CompareJuly 10, 2026 13:42
@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

@Ayesh@devnexen I've rebased this again. Are we good to merge this now, before there are more conflicts? ❤️

@Ayesh

Copy link
Copy Markdown
Member

Sorry I also probably caused a merge conflict, but thank you for unwrangling it.

I was wondering if @Girgias would get some time to review this, because she worked a lot on FCC macros that SEEKFUNCTION also uses.

I could also test this PR locally; I will merge tomorrow unless there's any further comments.

@GrahamCampbell

Copy link
Copy Markdown
ContributorAuthor

I've re-tested the rebased version of this with some real-world testing beyond the phpt suite, integrating it into Guzzle and comparing behaviour A/B against stock PHP on the same libcurl (8.21.0), using an NTS debug build which stayed quiet throughout.

The two classic failure scenarios both come out as hoped. When a reused keep-alive connection dies mid-upload (bug #47204), libcurl now rewinds a streamed body through the callback and resends it within the same transfer, where stock PHP fails with CURLE_SEND_FAIL_REWIND. An auth challenge arriving after the body has been sent (bug #80518) goes from unrecoverable on stock PHP to completing in a single transfer.

The callback semantics also check out from userland: CURL_SEEKFUNC_OK resends correctly, CURL_SEEKFUNC_CANTSEEK degrades to exactly the pre-PR behaviour, CURL_SEEKFUNC_FAIL fails the transfer cleanly, and clearing the option, curl_reset(), and handle reuse across transfers all behave as expected.

@Ayesh
Ayesh merged commit 93fa5f3 into php:masterJul 12, 2026
18 checks passed
@Ayesh

Copy link
Copy Markdown
Member

Thank you @GrahamCampbell :)

@GrahamCampbell
GrahamCampbell deleted the curl-seekfunction branch July 12, 2026 17:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@GrahamCampbell@dragoonis@Ayesh@devnexen