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
Browser.setDownloadBehavior is dispatched but its implementation is a noop — every parameter is commented out and it returns a bare success. As a result Lightpanda has no file-download path at all: a response carrying Content-Disposition: attachment is treated as a normal navigation (and for a non-displayable MIME its body bytes are discarded), the configured downloadPath is ignored, nothing is written to disk, and neither Page.downloadWillBegin nor Browser.downloadProgress is ever emitted. A CDP client therefore cannot trigger, await, or locate a download.
Today's behavior
setDownloadBehavior at src/cdp/domains/browser.zig:73-82 parses none of its params and just calls sendResult(null, …). On navigation, Frame.frameHeaderDoneCallback (src/browser/Frame.zig:1045) commits the response as a page regardless of Content-Disposition; frameDataCallback/frameDoneCallback then route a non-HTML/-text/-image body into _parse_state = .raw (Frame.zig:1171) whose bytes are appended (:1193) and then discarded when an empty document is parsed in their place (Frame.zig:1259-1264). There is no download_* notification type and no Page.downloadWillBegin / Browser.downloadProgress emission anywhere in src/.
sequenceDiagram
participant Client as CDP Client
participant LP as Lightpanda
participant Frame
Client->>LP: Browser.setDownloadBehavior allow, downloadPath
LP-->>Client: success but params are ignored
Client->>LP: Page.navigate to attachment URL
LP->>Frame: response with Content-Disposition attachment
Frame->>Frame: treat as page load, buffer then discard body
Frame-->>LP: empty document committed
LP--xClient: no Page.downloadWillBegin
LP--xClient: no Browser.downloadProgress
Note over LP,Frame: nothing written to downloadPath
Loading
Expected behavior
Per the CDP protocol, opting in via Browser.setDownloadBehavior should make a download response land on disk and surface progress events:
Page.downloadWillBegin — { frameId, guid, url, suggestedFilename }, fired when a download starts.
Browser.downloadProgress — { guid, totalBytes, receivedBytes, state } where state advances inProgress → completed.
A response is a download when it carries Content-Disposition: attachment (and, in Chrome, when its MIME type is one the browser will not render). Clicking an <a download> anchor is the same operation initiated from script. No rendering or layout is required for any of this.
sequenceDiagram
participant Client as CDP Client
participant LP as Lightpanda
participant Frame
participant Disk
Client->>LP: Browser.setDownloadBehavior allow, downloadPath
LP->>LP: store behavior and downloadPath on the connection
LP-->>Client: success
Client->>LP: Page.navigate to attachment URL
LP->>Frame: response with Content-Disposition attachment
LP-->>Client: Page.downloadWillBegin guid, suggestedFilename
Frame->>Disk: write body bytes under downloadPath
LP-->>Client: Browser.downloadProgress state inProgress
LP-->>Client: Browser.downloadProgress state completed
Loading
Reproducer
Four files in one directory. No Chrome, no framework. Requires node >= 18.
npm install --no-save ws # the only dependency
bash repro.sh # or: LIGHTPANDA_BIN=/path/to/lightpanda bash repro.sh
server.js — serves the attachment:
consthttp=require('http');constPORT=process.env.HTTP_PORT||9580;constBODY='col1,col2\nhello,world\n42,1337\n';constserver=http.createServer((req,res)=>{if(req.url==='/report.csv'){res.writeHead(200,{'Content-Type': 'text/csv','Content-Disposition': 'attachment; filename="report.csv"','Content-Length': Buffer.byteLength(BODY),});res.end(BODY);return;}res.writeHead(404,{'Content-Type': 'text/plain'});res.end('not found');});server.listen(PORT,'127.0.0.1',()=>console.error(`http server on 127.0.0.1:${PORT}`));
probe.js — set download behavior, navigate at the attachment, check four signals:
constfs=require('fs');constpath=require('path');constos=require('os');const{ connect, sleep }=require('./cdp.js');constHTTP_PORT=process.env.HTTP_PORT||9580;constEXPECTED='col1,col2\nhello,world\n42,1337\n';(async()=>{constdlDir=fs.mkdtempSync(path.join(os.tmpdir(),'lp-dl-'));constc=awaitconnect();constevents=[];c.on('Page.downloadWillBegin',(p)=>events.push(['downloadWillBegin',p]));c.on('Browser.downloadProgress',(p)=>events.push(['downloadProgress',p]));awaitc.send('Browser.setDownloadBehavior',{behavior: 'allow',downloadPath: dlDir,eventsEnabled: true,});awaitc.send('Page.navigate',{url: `http://127.0.0.1:${HTTP_PORT}/report.csv`});letfile=null;for(leti=0;i<20;i++){constcompleted=events.some(([n,p])=>n==='downloadProgress'&&p&&p.state==='completed');constfiles=fs.readdirSync(dlDir).filter((f)=>!f.startsWith('.')&&!f.endsWith('.crdownload'));if(files.length)file=path.join(dlDir,files[0]);if(completed&&file)break;awaitsleep(100);}c.close();constbeginFired=events.some(([n])=>n==='downloadWillBegin');constcompletedFired=events.some(([n,p])=>n==='downloadProgress'&&p&&p.state==='completed');constfileExists=!!file&&fs.existsSync(file);constcontentOk=fileExists&&fs.readFileSync(file,'utf8')===EXPECTED;console.log(`Page.downloadWillBegin fired: ${beginFired}`);console.log(`Browser.downloadProgress {state:completed}: ${completedFired}`);console.log(`file written to downloadPath: ${fileExists}${file ? ' ('+path.basename(file)+')' : ''}`);console.log(`file content matches response body: ${contentOk}`);try{fs.rmSync(dlDir,{recursive: true,force: true});}catch{}constok=beginFired&&completedFired&&fileExists&&contentOk;console.log(ok
? 'PASS: setDownloadBehavior wrote the attachment to disk and emitted download events.'
: 'FAIL: attachment response produced no download — no events fired, nothing written to disk.');process.exit(ok ? 0 : 1);})().catch((e)=>{console.error(e);process.exit(2);});
Page.downloadWillBegin fired: false
Browser.downloadProgress {state:completed}: false
file written to downloadPath: false
file content matches response body: false
FAIL: attachment response produced no download — no events fired, nothing written to disk.
Exit code 1. After the fix, all four lines read true, it prints PASS, and exits 0. Reproduced identically against the current public nightly and a local build of main (d695ce10).
Likely fix location
src/cdp/domains/browser.zig — setDownloadBehavior: parse and store behavior / downloadPath / eventsEnabled.
src/cdp/CDP.zig (BrowserContext) — hold the per-connection download policy.
src/browser/Frame.zig — frameHeaderDoneCallback (recognize Content-Disposition: attachment), frameDataCallback / frameDoneCallback (stream the body to disk instead of discarding the .raw buffer), and handleClick (the a.download branch at Frame.zig:4099, which currently only logs a warning).
src/cdp/domains/page.zig + src/cdp/domains/browser.zig — subscribe and emit Page.downloadWillBegin / Browser.downloadProgress.
Proposed approach
Smallest correct slice:
Store the policy. Parse behavior / downloadPath / eventsEnabled in setDownloadBehavior and stash them on the per-connection BrowserContext.
Detect the download. In the navigation response path, a response whose Content-Disposition is attachment is a download rather than a document. (Optionally also a non-displayable MIME with no explicit disposition, matching Chrome.)
Divert the body to disk. Stream the bytes to <downloadPath>/<suggestedFilename> — filename from the Content-Dispositionfilename= parameter, else the URL basename — instead of buffering-then-discarding them as the .raw path does today.
Emit the events. Add the two notification types and have the CDP layer emit Page.downloadWillBegin and a terminal Browser.downloadProgress { state: "completed" }.
The <a download> click trigger (Frame.handleClick) reuses the same machinery and behavior: deny could land as a follow-up, keeping the first PR focused on the Content-Disposition navigation path.
Important
One design question before I open a PR. In Chrome, navigating to an attachment response does not change the current document — the page stays put and the download runs in the background. Lightpanda commits the pending page in frameHeaderDoneCallback as soon as headers arrive, and the Content-Disposition header is available there, so the navigation can be recognized as a download before commit. Which behavior do you prefer?
(A) Preserve the current page (Chrome-faithful): detect the attachment in the header callback, skip the page commit, keep the previous document live, run the download as a side effect. More correct; touches the pending/active commit path.
(B) Let the navigation commit to a blank document and run the download as a side effect. Simpler and contained to the response-body path; since there is no rendering the post-download page state is rarely observed, but it diverges from Chrome's "page unchanged" semantics.
I'm happy to implement either — (A) is more faithful, (B) is a smaller, lower-risk first PR. Glad to follow your preference before writing the navigation-lifecycle code.
Environment
Lightpanda: current public nightly + local build of main at d695ce10 (both reproduce identically)
OS: macOS (aarch64); the download path is platform-independent
Summary
Browser.setDownloadBehavioris dispatched but its implementation is a noop — every parameter is commented out and it returns a bare success. As a result Lightpanda has no file-download path at all: a response carryingContent-Disposition: attachmentis treated as a normal navigation (and for a non-displayable MIME its body bytes are discarded), the configureddownloadPathis ignored, nothing is written to disk, and neitherPage.downloadWillBeginnorBrowser.downloadProgressis ever emitted. A CDP client therefore cannot trigger, await, or locate a download.Today's behavior
setDownloadBehavioratsrc/cdp/domains/browser.zig:73-82parses none of its params and just callssendResult(null, …). On navigation,Frame.frameHeaderDoneCallback(src/browser/Frame.zig:1045) commits the response as a page regardless ofContent-Disposition;frameDataCallback/frameDoneCallbackthen route a non-HTML/-text/-image body into_parse_state = .raw(Frame.zig:1171) whose bytes are appended (:1193) and then discarded when an empty document is parsed in their place (Frame.zig:1259-1264). There is nodownload_*notification type and noPage.downloadWillBegin/Browser.downloadProgressemission anywhere insrc/.sequenceDiagram participant Client as CDP Client participant LP as Lightpanda participant Frame Client->>LP: Browser.setDownloadBehavior allow, downloadPath LP-->>Client: success but params are ignored Client->>LP: Page.navigate to attachment URL LP->>Frame: response with Content-Disposition attachment Frame->>Frame: treat as page load, buffer then discard body Frame-->>LP: empty document committed LP--xClient: no Page.downloadWillBegin LP--xClient: no Browser.downloadProgress Note over LP,Frame: nothing written to downloadPathExpected behavior
Per the CDP protocol, opting in via
Browser.setDownloadBehaviorshould make a download response land on disk and surface progress events:Browser.setDownloadBehavior— honorbehavior(allow/allowAndName/deny/default), write downloaded files underdownloadPath, and (wheneventsEnabled: true) emit download events.Page.downloadWillBegin—{ frameId, guid, url, suggestedFilename }, fired when a download starts.Browser.downloadProgress—{ guid, totalBytes, receivedBytes, state }wherestateadvancesinProgress→completed.A response is a download when it carries
Content-Disposition: attachment(and, in Chrome, when its MIME type is one the browser will not render). Clicking an<a download>anchor is the same operation initiated from script. No rendering or layout is required for any of this.sequenceDiagram participant Client as CDP Client participant LP as Lightpanda participant Frame participant Disk Client->>LP: Browser.setDownloadBehavior allow, downloadPath LP->>LP: store behavior and downloadPath on the connection LP-->>Client: success Client->>LP: Page.navigate to attachment URL LP->>Frame: response with Content-Disposition attachment LP-->>Client: Page.downloadWillBegin guid, suggestedFilename Frame->>Disk: write body bytes under downloadPath LP-->>Client: Browser.downloadProgress state inProgress LP-->>Client: Browser.downloadProgress state completedReproducer
Four files in one directory. No Chrome, no framework. Requires
node >= 18.server.js— serves the attachment:probe.js— set download behavior, navigate at the attachment, check four signals:repro.sh— orchestration:cdp.js— raw-wsCDP helper (Target.createTarget + attachToTarget boilerplate)Run output today
Exit code
1. After the fix, all four lines readtrue, it printsPASS, and exits0. Reproduced identically against the current public nightly and a local build ofmain(d695ce10).Likely fix location
src/cdp/domains/browser.zig—setDownloadBehavior: parse and storebehavior/downloadPath/eventsEnabled.src/cdp/CDP.zig(BrowserContext) — hold the per-connection download policy.src/browser/Frame.zig—frameHeaderDoneCallback(recognizeContent-Disposition: attachment),frameDataCallback/frameDoneCallback(stream the body to disk instead of discarding the.rawbuffer), andhandleClick(thea.downloadbranch atFrame.zig:4099, which currently only logs a warning).src/Notification.zig— adddownload_will_begin/download_progressevent types.src/cdp/domains/page.zig+src/cdp/domains/browser.zig— subscribe and emitPage.downloadWillBegin/Browser.downloadProgress.Proposed approach
Smallest correct slice:
behavior/downloadPath/eventsEnabledinsetDownloadBehaviorand stash them on the per-connectionBrowserContext.Content-Dispositionisattachmentis a download rather than a document. (Optionally also a non-displayable MIME with no explicit disposition, matching Chrome.)<downloadPath>/<suggestedFilename>— filename from theContent-Dispositionfilename=parameter, else the URL basename — instead of buffering-then-discarding them as the.rawpath does today.Page.downloadWillBeginand a terminalBrowser.downloadProgress { state: "completed" }.The
<a download>click trigger (Frame.handleClick) reuses the same machinery andbehavior: denycould land as a follow-up, keeping the first PR focused on theContent-Dispositionnavigation path.Important
One design question before I open a PR. In Chrome, navigating to an
attachmentresponse does not change the current document — the page stays put and the download runs in the background. Lightpanda commits the pending page inframeHeaderDoneCallbackas soon as headers arrive, and theContent-Dispositionheader is available there, so the navigation can be recognized as a download before commit. Which behavior do you prefer?I'm happy to implement either — (A) is more faithful, (B) is a smaller, lower-risk first PR. Glad to follow your preference before writing the navigation-lifecycle code.
Environment
mainatd695ce10(both reproduce identically)ws(raw WebSocket; seecdp.jsabove)