Skip to content

Commit f26a77f

Browse files
authored
0.3.2 --- download_to_file reports the writes the disk refused (#19)
* fix(http): download_to_file reports the writes the disk refused download_to_file called ofs.write for each body chunk without looking at the stream, added each chunk's size to bytesWritten from what the network delivered, and set bytesWritten after ofs.close() without looking at that either. On a full disk (ENOSPC) the transfer therefore succeeded: a 420,831,054 byte download left 220,979,200 bytes on disk while bytesWritten said 420,831,054 and ok() was true, and the caller blamed the source (a checksum mismatch) for what was the local disk. Each chunk is now flushed and the stream checked. On the first failure the transfer stops reading, error becomes "write <path>: <reason>" with the reason taken from errno, and the connection is dropped instead of returned to the pool with the rest of the body still on it. Closing the file is checked the same way. Additive: DownloadToFileResult::writeFailed says the fault is the destination and not the source (a failed write, a failed close, or a file that could not be opened), and bytesReceived carries the network count. bytesWritten now counts bytes the file accepted; the two are equal for every transfer that did not fail to write. Tests: a download into /dev/full (Linux) against the in-process TLS server asserts not ok, writeFailed, bytesWritten == 0, the ENOSPC text, that reading stopped after the first refused chunk, and that the next request opens a new connection. Verified by mutation: with the stream checks removed it reports ok() and bytesWritten == 65536. * 0.3.2 --- download_to_file reports the writes the disk refused Version, CHANGELOG entry, and the README install lines name this release. * ci: bump mcpp to 2026.9.28.3 to satisfy the index floor The index now requires mcpp >= 2026.9.18.3, and MCPP_VERSION pinned 2026.8.29.1, so every `mcpp` invocation that needed the index ended at error: index requires mcpp >= 2026.9.18.3 but this is mcpp 2026.8.29.1 [E0006] That is the failure the "Smoke-test the project templates" step has reported on master since the scheduled run of 2026-09-21. MCPP_VERSION is the only place the version is named; both jobs' install steps and both cache keys read it. --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
1 parent c34f1ec commit f26a77f

6 files changed

Lines changed: 222 additions & 11 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ on:
3131
workflow_dispatch:
3232

3333
env:
34-
MCPP_VERSION: 2026.8.29.1
34+
MCPP_VERSION: 2026.9.28.3
3535
XLINGS_VERSION: v2026.8.17.2
3636
XLINGS_NON_INTERACTIVE: '1'
3737

‎CHANGELOG.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
# Changelog
22

3+
## 0.3.2
4+
5+
`download_to_file` no longer reports success for a file the disk did not keep.
6+
7+
It called `ofs.write` for each body chunk without looking at the stream, added
8+
each chunk's size to `bytesWritten` from what the network delivered, and set
9+
`bytesWritten` after `ofs.close()` without looking at that either. On a full disk
10+
(`ENOSPC`) the transfer therefore succeeded: a 420,831,054 byte download left
11+
220,979,200 bytes on disk while `bytesWritten` said 420,831,054 and `ok()` was
12+
true, and the caller blamed the source (a checksum mismatch) for what was the
13+
local disk.
14+
15+
* Each chunk is flushed and the stream checked. On the first failure the
16+
transfer stops reading, `error` becomes `write <path>: <reason>` (the reason
17+
is `errno` as `std::generic_category` words it, for example `No space left on
18+
device`), and the connection is dropped rather than returned to the pool with
19+
the rest of the body still on it. Closing the file is checked the same way.
20+
* `DownloadToFileResult::writeFailed` is new and is true when the fault is the
21+
destination rather than the source: the write failed, the close failed, or the
22+
file could not be opened (`error` is still `Cannot open file: <path>` for the
23+
last).
24+
* `DownloadToFileResult::bytesReceived` is new: the bytes of body the
25+
connection delivered.
26+
* `DownloadToFileResult::bytesWritten` changes meaning, and this is the one
27+
behaviour change: it now counts bytes the file accepted, where it used to
28+
count bytes the network delivered. The two are equal for every transfer that
29+
did not fail to write. When a write fails, the chunk that failed is not
30+
counted, though the file may hold part of it, so the value is a floor on the
31+
file's size.
32+
333
## 0.3.1
434

535
The socket interface is selected by the C library rather than by the operating

‎README.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,14 @@ cd examples/openkal && mcpp run
114114
### 添加依赖
115115

116116
```bash
117-
mcpp add tinyhttps@0.3.0
117+
mcpp add tinyhttps@0.3.2
118118
```
119119

120120
或在 `mcpp.toml` 中手动添加:
121121

122122
```toml
123123
[dependencies]
124-
tinyhttps = "0.3.1"
124+
tinyhttps = "0.3.2"
125125
```
126126

127127
### 构建
@@ -140,7 +140,10 @@ mcpplibs::tinyhttps::HttpClient client;
140140
auto result = client.download_to_file(
141141
"https://example.com/big.tar.gz", "out/big.tar.gz",
142142
[](std::int64_t total, std::int64_t done) { /* progress */ });
143-
if (!result.ok()) { /* result.error */ }
143+
if (!result.ok()) {
144+
// result.error says why; result.writeFailed says the local file is at
145+
// fault (a full disk, a refused write) and not the server.
146+
}
144147
```
145148

146149
## License

‎mcpp.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
namespace = "mcpplibs"
33
name = "tinyhttps"
4-
version = "0.3.1"
4+
version = "0.3.2"
55
description = "Minimal C++23 HTTP/HTTPS client with SSE streaming support"
66
license = "Apache-2.0"
77
repo = "https://github.com/mcpplibs/tinyhttps"

‎src/http.cppm‎

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
module;
2+
3+
// `import std` carries no `errno`: it is a macro, and a module does not export
4+
// macros. The download path reads it to say why the local file refused a write.
5+
#include <cerrno>
6+
17
export module mcpplibs.tinyhttps:http;
28

39
import :tls;
@@ -105,11 +111,39 @@ export using DownloadProgressFn = std::function<void(std::int64_t total, std::in
105111
export struct DownloadToFileResult {
106112
int statusCode { 0 };
107113
std::string error;
114+
115+
// Bytes that reached the destination file: the sum of the body chunks whose
116+
// write the stream accepted. It is a statement about the file and not about
117+
// the network, which is `bytesReceived`.
118+
//
119+
// It used to be the network count. On a full disk every write after the
120+
// first failure was discarded while this kept rising, so a 420,831,054 byte
121+
// download that left 220,979,200 bytes on disk reported 420,831,054 and
122+
// `ok()`. When a write fails, the chunk that failed is not counted, though
123+
// the file may hold part of it; the value is a floor on the file's size and
124+
// never above it.
108125
std::int64_t bytesWritten { 0 };
126+
109127
std::optional<std::int64_t> expectedBytes;
110128
std::string finalUrl;
111129
std::string etag;
112130
std::string lastModified;
131+
132+
// Bytes of body the connection delivered, whether or not the file took them.
133+
// Equal to `bytesWritten` unless `writeFailed`.
134+
std::int64_t bytesReceived { 0 };
135+
136+
// TRUE WHEN THE FAULT IS THE DESTINATION AND NOT THE SOURCE — the file could
137+
// not be opened, a write to it failed (a full disk, a quota, a device that
138+
// refuses), or closing it reported that buffered data did not reach it.
139+
//
140+
// `error` then names the path and the reason, and the transfer stops at the
141+
// first failure instead of reading on and discarding what it reads. It is
142+
// what lets a caller tell "the server sent something wrong" from "this
143+
// machine could not keep it" without matching text in `error`; a checksum
144+
// failure downstream of a truncated file otherwise blames the wrong party.
145+
bool writeFailed { false };
146+
113147
bool ok() const { return statusCode >= 200 && statusCode < 300 && error.empty(); }
114148
};
115149

@@ -1222,34 +1256,82 @@ private:
12221256
std::ofstream ofs(destFile, std::ios::binary);
12231257
if (!ofs) {
12241258
result.error = "Cannot open file: " + destFile.string();
1259+
result.writeFailed = true;
12251260
settle_without_body(exchange, hasBody, guard);
12261261
return result;
12271262
}
12281263

1229-
if (!hasBody) {
1264+
// What the file refused, in the words of the system that refused it.
1265+
// `errno` is cleared before each operation that can set it, because the
1266+
// socket reads between writes leave their own values behind and a stale
1267+
// EAGAIN reported as the reason a disk filled is worse than no reason.
1268+
int writeErrno = 0;
1269+
bool writeFailed = false;
1270+
auto close_file = [&] {
1271+
errno = 0;
12301272
ofs.close();
1273+
if (!writeFailed && ofs.fail()) {
1274+
writeFailed = true;
1275+
writeErrno = errno;
1276+
}
1277+
};
1278+
auto write_failure = [&] {
1279+
return "write " + destFile.string() + ": " +
1280+
(writeErrno != 0
1281+
? std::generic_category().message(writeErrno)
1282+
: std::string("stream error"));
1283+
};
1284+
1285+
if (!hasBody) {
1286+
close_file();
1287+
if (writeFailed) {
1288+
result.error = write_failure();
1289+
result.writeFailed = true;
1290+
return result;
1291+
}
12311292
if (config_.keepAlive && !exchange.head.connectionClose) guard.keep();
12321293
return result;
12331294
}
12341295

12351296
const std::int64_t totalBytes =
12361297
exchange.head.contentLength > 0 ? exchange.head.contentLength : 0;
1237-
std::int64_t downloaded = 0;
1298+
std::int64_t received = 0;
1299+
std::int64_t written = 0;
12381300
bool cancelled = false;
12391301

12401302
auto outcome = read_body(
12411303
*exchange.sock, exchange.head, config_.readTimeoutMs,
12421304
std::numeric_limits<std::int64_t>::max(),
12431305
[&](std::string_view data) -> bool {
1306+
const auto size = static_cast<std::int64_t>(data.size());
1307+
received += size;
1308+
1309+
// Flushed per chunk, and the stream checked after it. A write
1310+
// into the stream's buffer succeeds whatever the disk has left;
1311+
// the failure surfaces at the flush that hands the buffer to the
1312+
// operating system, and without one here it surfaced at
1313+
// `close()`, after every chunk had been counted as written.
1314+
// `read_body` hands over slices of at most 8 KiB, about the size
1315+
// of the stream's own buffer, so this costs no more system calls
1316+
// than the buffering it replaces.
1317+
errno = 0;
12441318
ofs.write(data.data(), static_cast<std::streamsize>(data.size()));
1245-
downloaded += static_cast<std::int64_t>(data.size());
1246-
if (onProgress) onProgress(totalBytes, downloaded);
1319+
ofs.flush();
1320+
if (!ofs) {
1321+
writeFailed = true;
1322+
writeErrno = errno;
1323+
return false; // reading on would only discard what it reads
1324+
}
1325+
1326+
written += size;
1327+
if (onProgress) onProgress(totalBytes, written);
12471328
if (isCancelled && isCancelled()) { cancelled = true; return false; }
12481329
return true;
12491330
});
12501331

1251-
ofs.close();
1252-
result.bytesWritten = downloaded;
1332+
close_file();
1333+
result.bytesWritten = written;
1334+
result.bytesReceived = received;
12531335

12541336
switch (outcome.end) {
12551337
case BodyEnd::Complete:
@@ -1264,6 +1346,14 @@ private:
12641346
result.error = outcome.error;
12651347
break;
12661348
}
1349+
1350+
// The destination's failure outranks whatever the switch recorded: a
1351+
// stop that the file asked for is not a cancellation, and a body that
1352+
// arrived whole into a file that lost it has not succeeded.
1353+
if (writeFailed) {
1354+
result.error = write_failure();
1355+
result.writeFailed = true;
1356+
}
12671357
return result;
12681358
}
12691359

‎tests/test_pool.cpp‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,94 @@ TEST_F(PoolTest, ADownloadDelimitedByTheCloseReportsSuccess) {
624624
std::filesystem::remove_all(dir, ec);
625625
}
626626

627+
// ── a destination that refuses the bytes ─────────────────────────────────────
628+
629+
// `/dev/full` accepts an open and answers every write with ENOSPC, which is what
630+
// a full disk does at the moment it fills — without needing one.
631+
//
632+
// Before 0.3.2 the transfer "succeeded": `ofs.write` was never checked, the
633+
// count came from the network, and `bytesWritten` said the whole body when the
634+
// file held none of it. The caller then blamed the source (a checksum mismatch)
635+
// for what was the local disk. Verified by mutation: with the stream check
636+
// removed, `ok()` is true and `bytesWritten` is the body's length.
637+
#ifdef __linux__
638+
TEST_F(PoolTest, ADownloadIntoAFullDeviceFailsAsALocalWriteAndCountsNothing) {
639+
if (!std::filesystem::exists("/dev/full")) GTEST_SKIP() << "no /dev/full";
640+
641+
const std::string payload(64 * 1024, 'F');
642+
tls_test::Server server([payload](tls_test::Conn& conn, int) {
643+
conn.write(tls_test::ok_response(payload));
644+
return true;
645+
});
646+
ASSERT_FALSE(server.failed());
647+
648+
https::HttpClient client(test_config());
649+
auto result = client.download_to_file(server.url("/big"), "/dev/full");
650+
651+
EXPECT_FALSE(result.ok()) << "a file that took nothing was reported as downloaded";
652+
EXPECT_TRUE(result.writeFailed)
653+
<< "the caller must be able to tell the disk from the source; error: "
654+
<< result.error;
655+
EXPECT_EQ(result.statusCode, 200);
656+
EXPECT_EQ(result.bytesWritten, 0);
657+
EXPECT_GT(result.bytesReceived, 0);
658+
EXPECT_LT(result.bytesReceived, static_cast<std::int64_t>(payload.size()))
659+
<< "the transfer read on after the first refused write";
660+
EXPECT_EQ(result.error.rfind("write /dev/full: ", 0), 0u) << result.error;
661+
EXPECT_NE(result.error.find(
662+
std::make_error_code(std::errc::no_space_on_device).message()),
663+
std::string::npos)
664+
<< result.error;
665+
666+
// The rest of the body was left on the socket, so the connection is not
667+
// handed to the next request. The next request also shows the client is
668+
// not left in a state where a good destination fails too.
669+
auto dir = std::filesystem::temp_directory_path() / "tinyhttps_pool_full";
670+
std::filesystem::create_directories(dir);
671+
auto dest = dir / "ok.bin";
672+
auto good = client.download_to_file(server.url("/big"), dest);
673+
EXPECT_TRUE(good.ok()) << "error: " << good.error;
674+
EXPECT_FALSE(good.writeFailed);
675+
EXPECT_EQ(good.bytesWritten, static_cast<std::int64_t>(payload.size()));
676+
EXPECT_EQ(good.bytesReceived, good.bytesWritten);
677+
EXPECT_EQ(server.accepts(), 2)
678+
<< "the connection with an unread body was reused";
679+
680+
std::error_code ec;
681+
std::filesystem::remove_all(dir, ec);
682+
}
683+
#endif
684+
685+
// A destination that cannot be opened is the same kind of failure: nothing was
686+
// wrong with what the server sent.
687+
TEST_F(PoolTest, ADestinationThatCannotBeOpenedIsALocalFailure) {
688+
tls_test::Server server([](tls_test::Conn& conn, int) {
689+
conn.write(tls_test::ok_response("payload"));
690+
return true;
691+
});
692+
ASSERT_FALSE(server.failed());
693+
694+
auto dir = std::filesystem::temp_directory_path() / "tinyhttps_pool_noopen";
695+
std::filesystem::create_directories(dir);
696+
{
697+
// A regular file where a directory is needed: no parent can be made.
698+
std::ofstream blocker(dir / "blocker", std::ios::binary);
699+
blocker << "not a directory";
700+
}
701+
702+
https::HttpClient client(test_config());
703+
auto result = client.download_to_file(server.url("/x"),
704+
dir / "blocker" / "out.bin");
705+
706+
EXPECT_FALSE(result.ok());
707+
EXPECT_TRUE(result.writeFailed) << "error: " << result.error;
708+
EXPECT_EQ(result.error.rfind("Cannot open file: ", 0), 0u) << result.error;
709+
EXPECT_EQ(result.bytesWritten, 0);
710+
711+
std::error_code ec;
712+
std::filesystem::remove_all(dir, ec);
713+
}
714+
627715
// ── interim responses ────────────────────────────────────────────────────────
628716

629717
// A 1xx is not the answer. RFC 9112 §2.1 requires a client to read past one or

0 commit comments

Comments
 (0)