Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,8 @@ Port of map-downloader's service-worker streaming download
```ts
registerDownloadWorker(): void // at startup; also startMessages()
downloadReady(): Promise<void> // resolves once a worker controls the page
startDownload(opts: { filename: string; contentType: string }): Promise<{ writable: WritableStream<Uint8Array>; cleanup(): void }>
startDownload(opts: { filename: string; contentType: string }): Promise<{ writable: WritableStream<Uint8Array>; complete: Promise<void>; cleanup(): void }>
// complete: the worker has seen the browser read the last chunk (protocol 2)
```

`downloadReady()` rejects if registering the worker failed, so the UI is not
Expand Down
20 changes: 20 additions & 0 deletions e2e/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,4 +321,24 @@ describeEngines((engine) => {
if (download) expect(await download.failure()).not.toBeNull();
},
);

test("startDownload's complete resolves once the browser has read the file", async () => {
const [download, waitedMs] = await Promise.all([
page.waitForEvent("download", { timeout: 60_000 }),
page.evaluate(async () => {
const { writable, complete } = await window.mapPrinter.startDownload({
filename: "complete.bin",
contentType: "application/octet-stream",
});
const writer = writable.getWriter();
for (let i = 0; i < 16; i++) await writer.write(new Uint8Array(65536));
await writer.close();
const t0 = performance.now();
await complete;
return performance.now() - t0;
}),
]);
expect(waitedMs).toBeLessThan(10_000);
expect(await download.failure()).toBeNull();
});
});
39 changes: 33 additions & 6 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class MessagePortSource {
// path existed ignores them — the page checks DOWNLOAD_PROTOCOL to notice.
const DOWNLOAD_PATH = "/_download/";
const DOWNLOAD_TIMEOUT = 30_000;
const DOWNLOAD_PROTOCOL = 1;
const DOWNLOAD_PROTOCOL = 2;

/** Streams whose fetch has not arrived yet, keyed by URL */
const pending = new Map();
Expand All @@ -68,7 +68,7 @@ self.addEventListener("message", (evt) => {
new MessagePortSource(data.readablePort),
new CountQueuingStrategy({ highWaterMark: 4 }),
);
const download = { rs, headers: data.headers };
const download = { rs: announceWhenRead(rs, data.url), headers: data.headers };
const waiter = waiting.get(data.url);
if (waiter) {
waiting.delete(data.url);
Expand All @@ -80,11 +80,38 @@ self.addEventListener("message", (evt) => {

// A navigation does not always reach the service worker, and the page can only
// tell by being told the request arrived.
async function announceDownload(url) {
function announceDownload(url) {
return announce({ type: "downloadStarted", url });
}

async function announce(message) {
const clients = await self.clients.matchAll({ includeUncontrolled: true });
for (const client of clients) {
client.postMessage({ type: "downloadStarted", url });
}
for (const client of clients) client.postMessage(message);
}

// The page's writes complete when this worker has queued them, long before
// the browser has read them out of the response, so the browser's read of the
// last chunk is announced separately. pull() only runs once the browser has
// taken the previous chunk, so `done` here means it has read everything.
function announceWhenRead(rs, url) {
const reader = rs.getReader();
return new ReadableStream(
{
async pull(controller) {
const { value, done } = await reader.read();
if (done) {
controller.close();
await announce({ type: "downloadComplete", url });
} else {
controller.enqueue(value);
}
},
cancel(reason) {
return reader.cancel(reason);
},
},
new CountQueuingStrategy({ highWaterMark: 1 }),
);
}

self.addEventListener("fetch", (event) => {
Expand Down
18 changes: 12 additions & 6 deletions src/lib/download/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ verbatim, and this module is the page side of the same handshake.
registerDownloadWorker(): void
downloadReady(): Promise<void>
startDownload(opts: { filename: string; contentType: string }):
Promise<{ writable: WritableStream<Uint8Array>; cleanup(): void }>
Promise<{ writable: WritableStream<Uint8Array>; complete: Promise<void>; cleanup(): void }>
```

## How it works
Expand All @@ -25,11 +25,17 @@ activation from the click is still live. The worker cannot see a navigation
that never reached it, so the page waits for its `downloadStarted` message
before handing over the `MessagePort` and returning the writable.

The iframe outlives the writable. The worker's `PULL`s only mean the
browser's network layer has read the bytes, not that they are on disk, and
iOS Safari finalises a download the moment its initiating frame is removed,
saving only what had been flushed. So `cleanup()` is for failures; after
success the iframe stays until the next `startDownload` replaces it.
The iframe outlives the writable. The worker's `PULL`s only mean the worker
has queued the bytes, not that they are on disk, and iOS Safari finalises a
download the moment its initiating frame is removed, saving only what had
been flushed. So `cleanup()` is for failures; after success the iframe stays
until the next `startDownload` replaces it.

`complete` (protocol 2) resolves when the worker announces
`downloadComplete`, which it does once the browser has read the last chunk
out of the response. That is later than the writable closing, though still
short of the bytes being on disk. It never rejects, so callers race it with
a timeout in case an older worker is still active.

## Backpressure and errors

Expand Down
35 changes: 32 additions & 3 deletions src/lib/download/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const CLOSE = 2;

// Kept in step with public/sw.js: an older worker ignores /_download/ requests.
const DOWNLOAD_PATH = "/_download/";
const DOWNLOAD_PROTOCOL = 1;
const DOWNLOAD_PROTOCOL = 2;
const DOWNLOAD_START_TIMEOUT_MS = 5_000;

const NO_WORKER_MESSAGE =
Expand All @@ -24,6 +24,10 @@ export interface DownloadOptions {

export interface Download {
writable: WritableStream<Uint8Array>;
/** Resolves once the browser has read the last byte out of the worker's
* response, which is later than the writable closing: writes complete when
* the worker has queued them. Never rejects. */
complete: Promise<void>;
/** Removes the iframe that started the download. Call it only after a
* failure: iOS Safari finalises a download when its initiating frame goes
* away, even while bytes it has already accepted are still being flushed
Expand Down Expand Up @@ -71,16 +75,38 @@ export function downloadReady(): Promise<void> {
export async function startDownload(opts: DownloadOptions): Promise<Download> {
// Nothing may be awaited before this call: it navigates an iframe, which
// Safari only turns into a download while the user activation is still live.
const { workerPort, cleanup } = await prepareSwDownload(
const { workerPort, complete, cleanup } = await prepareSwDownload(
opts.filename,
opts.contentType,
);
return {
writable: new WritableStream(new MessagePortSink(workerPort)),
complete,
cleanup,
};
}

/** Resolves when the worker announces that the browser has read the whole
* response for `url`; `stop` gives up listening (the promise then never
* settles, which is fine for a download that failed). */
function waitForDownloadComplete(url: string): {
complete: Promise<void>;
stop: () => void;
} {
let stop = () => {};
const complete = new Promise<void>((resolve) => {
const onMessage = (event: MessageEvent) => {
if (event.data?.type === "downloadComplete" && event.data.url === url) {
stop();
resolve();
}
};
stop = () => navigator.serviceWorker.removeEventListener("message", onMessage);
navigator.serviceWorker.addEventListener("message", onMessage);
});
return { complete, stop };
}

/** Ask the active service worker which download protocol it speaks (0 = none) */
function downloadProtocol(registration: ServiceWorkerRegistration) {
return new Promise<number>((resolve) => {
Expand Down Expand Up @@ -126,6 +152,7 @@ function waitForDownloadStart(url: string, timeout: number): Promise<boolean> {

interface SwDownloadChannel {
workerPort: MessagePort;
complete: Promise<void>;
cleanup: () => void;
}

Expand Down Expand Up @@ -154,7 +181,9 @@ async function prepareSwDownload(
iframe.src = url;
document.body.appendChild(iframe);
activeIframe = iframe;
const { complete, stop } = waitForDownloadComplete(url);
const cleanup = () => {
stop();
iframe.remove();
if (activeIframe === iframe) activeIframe = undefined;
};
Expand Down Expand Up @@ -185,7 +214,7 @@ async function prepareSwDownload(
const channel = new MessageChannel();
sw.postMessage({ url, headers, readablePort: channel.port1 }, [channel.port1]);

return { workerPort: channel.port2, cleanup };
return { workerPort: channel.port2, complete, cleanup };
}

class MessagePortSink implements UnderlyingSink<Uint8Array> {
Expand Down
8 changes: 5 additions & 3 deletions src/lib/export/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ exportMap(opts: {
}): Promise<{ bbox: Bbox; zoom: number }>
```

Resolves once the last byte has been written to the download, with the bbox
the exported image actually covers (grown from `bbox` on the non-limiting
axis) and the zoom it was rendered at.
Resolves once the browser has read the last byte of the download out of the
service worker (or after 60 s if a pre-protocol-2 worker never says so), with
the bbox the exported image actually covers (grown from `bbox` on the
non-limiting axis) and the zoom it was rendered at. On iOS the file may still
be flushing to disk for a moment after that.

## Sizes

Expand Down
10 changes: 9 additions & 1 deletion src/lib/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ const CHANNELS = 3;
* tile the renderer just returned and its RGBA readPixels scratch. */
const MEMORY_BUDGET_BYTES = 64 * 1024 * 1024;

const COMPLETE_TIMEOUT_MS = 60_000;

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

type ExportTile = {
col: number;
row: number;
Expand Down Expand Up @@ -87,7 +93,7 @@ export async function exportMap(opts: ExportOptions): Promise<ExportResult> {
css,
}));

const { writable, cleanup } = await startDownload({
const { writable, complete, cleanup } = await startDownload({
filename: opts.filename,
contentType: "image/png",
});
Expand Down Expand Up @@ -121,6 +127,8 @@ export async function exportMap(opts: ExportOptions): Promise<ExportResult> {
}),
)
.pipeTo(writable, { signal });
// A worker from before protocol 2 never announces, so do not wait forever.
await Promise.race([complete, sleep(COMPLETE_TIMEOUT_MS)]);
} catch (err) {
// The response only errors if the worker is told; a failure before or
// outside `pipeTo` would otherwise leave a truncated file on disk.
Expand Down
5 changes: 5 additions & 0 deletions src/settings-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,11 @@ export class SettingsForm extends LightElement {
style="width: ${Math.round(this.progress * 100)}%"
></div>
</div>
${this.progress >= 1
? html`<p class="mt-1 text-sm text-gray-600" data-status="writing">
Writing file…
</p>`
: nothing}
<button
id="cancel"
type="button"
Expand Down