Skip to content
Open
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
19 changes: 18 additions & 1 deletion src/main/http-fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ async function sessionFetchStream(url, onChunk, { timeout = DEFAULT_TIMEOUT } =
try {
const response = await session.defaultSession.fetch(url, { signal: controller.signal });
if (!response.ok) {
// Release the connection — an abandoned body keeps the protocol
// handler pulling from the gateway until GC collects the stream.
response.body?.cancel().catch(() => {});
throw new Error(`Failed to download: HTTP ${response.status}`);
}
// Headers arriving is activity too — don't let a slow time-to-headers
Expand All @@ -69,6 +72,11 @@ async function sessionFetchStream(url, onChunk, { timeout = DEFAULT_TIMEOUT } =
touch();
await onChunk(Buffer.from(value));
}
} catch (err) {
// e.g. onChunk threw (disk full mid-write) — stop the transfer
// instead of letting the handler drain the rest in the background.
reader.cancel().catch(() => {});
throw err;
} finally {
controller.signal.removeEventListener('abort', onAbort);
}
Expand Down Expand Up @@ -107,7 +115,16 @@ async function sessionFetchToFile(url, destPath, opts) {
if (!handle) {
handle = await fs.promises.open(destPath, 'w');
}
await handle.write(chunk);
// FileHandle.write may perform a partial write without throwing;
// loop until the whole chunk is on disk.
let offset = 0;
while (offset < chunk.length) {
const { bytesWritten } = await handle.write(chunk, offset, chunk.length - offset);
if (bytesWritten === 0) {
throw new Error('File write made no progress');
}
offset += bytesWritten;
}
},
opts
);
Expand Down
63 changes: 63 additions & 0 deletions src/main/http-fetch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,67 @@ describe('http-fetch dweb schemes', () => {
fs.rmSync(dir, { recursive: true, force: true });
}
});

test('cancels the body stream on a non-OK response', async () => {
const cancel = jest.fn().mockResolvedValue(undefined);
session.defaultSession.fetch.mockResolvedValue({ ok: false, status: 503, body: { cancel } });

await expect(fetchBuffer('bzz://example.eth/missing.png')).rejects.toThrow(
'Failed to download: HTTP 503'
);
expect(cancel).toHaveBeenCalled();
});

test('cancels the transfer when writing a chunk fails', async () => {
const cancelled = jest.fn();
session.defaultSession.fetch.mockResolvedValue({
ok: true,
status: 200,
body: new ReadableStream({
pull(controller) {
controller.enqueue(Uint8Array.from(Buffer.from('chunk')));
},
cancel: cancelled,
}),
});
// Parent directory does not exist, so opening the destination fails on
// the first chunk.
const destPath = path.join(os.tmpdir(), 'http-fetch-no-such-dir', 'nested', 'pic.png');

await expect(fetchToFile('bzz://example.eth/pic.png', destPath)).rejects.toThrow();
expect(cancelled).toHaveBeenCalled();
});

test('fails loudly when a file write makes no progress', async () => {
session.defaultSession.fetch.mockResolvedValue(okResponse('abcdef'));
const write = jest.fn().mockResolvedValue({ bytesWritten: 0 });
const close = jest.fn().mockResolvedValue(undefined);
const openSpy = jest.spyOn(fs.promises, 'open').mockResolvedValue({ write, close });

try {
await expect(
fetchToFile('bzz://example.eth/pic.png', '/tmp/http-fetch-no-progress.png')
).rejects.toThrow('File write made no progress');
expect(write).toHaveBeenCalledTimes(1);
} finally {
openSpy.mockRestore();
}
});

test('retries partial file writes until the whole chunk is on disk', async () => {
session.defaultSession.fetch.mockResolvedValue(okResponse('abcdef'));
const write = jest
.fn()
.mockImplementation(async (chunk, offset, length) => ({ bytesWritten: Math.min(2, length) }));
const close = jest.fn().mockResolvedValue(undefined);
const openSpy = jest.spyOn(fs.promises, 'open').mockResolvedValue({ write, close });

try {
await fetchToFile('bzz://example.eth/pic.png', '/tmp/http-fetch-partial-write.png');
expect(write.mock.calls.map((call) => call[1])).toEqual([0, 2, 4]);
expect(close).toHaveBeenCalled();
} finally {
openSpy.mockRestore();
}
});
});
Loading