• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mendersoftware / mender / 1583599051

11 Dec 2024 09:04AM UTC coverage: 76.259% (-0.2%) from 76.43%
1583599051

push

gitlab-ci

vpodzime
fix: Cancel the previous request before scheduling a new one in HTTP resumer

The `http::Client()` class is designed to always have only one
HTTP request in progress. Thus, before scheduling a new request
using the same `http::Client` instance, cancel the previous
request to make sure everything is properly reset for the new
one.

Ticket: MEN-7810
Changelog: Fix download resuming to reset the HTTP state and
avoid repeatedly hitting the same error in case of a bad state

Signed-off-by: Vratislav Podzimek <vratislav.podzimek@northern.tech>

1 of 1 new or added line in 1 file covered. (100.0%)

55 existing lines in 11 files now uncovered.

7375 of 9671 relevant lines covered (76.26%)

11182.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.32
/src/common/http/http_resumer.cpp
1
// Copyright 2023 Northern.tech AS
2
//
3
//    Licensed under the Apache License, Version 2.0 (the "License");
4
//    you may not use this file except in compliance with the License.
5
//    You may obtain a copy of the License at
6
//
7
//        http://www.apache.org/licenses/LICENSE-2.0
8
//
9
//    Unless required by applicable law or agreed to in writing, software
10
//    distributed under the License is distributed on an "AS IS" BASIS,
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
//    See the License for the specific language governing permissions and
13
//    limitations under the License.
14

15
#include <common/http_resumer.hpp>
16

17
#include <regex>
18

19
#include <common/common.hpp>
20
#include <common/expected.hpp>
21

22
namespace mender {
23
namespace common {
24
namespace http {
25
namespace resumer {
26

27
namespace common = mender::common;
28
namespace expected = mender::common::expected;
29
namespace http = mender::common::http;
30

31
// Represents the parts of a Content-Range HTTP header
32
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range
33
struct RangeHeader {
34
        long long int range_start {0};
35
        long long int range_end {0};
36
        long long int size {0};
37
};
38
using ExpectedRangeHeader = expected::expected<RangeHeader, error::Error>;
39

40
// Parses the HTTP Content-Range header
41
// For an alternative implementation without regex dependency see:
42
// https://github.com/mendersoftware/mender/pull/1372/commits/ea711fc4dafa943266e9013fd6704da3d4518a27
43
ExpectedRangeHeader ParseRangeHeader(string header) {
41✔
44
        RangeHeader range_header {};
45

46
        std::regex content_range_regexp {R"(bytes\s+(\d+)\s?-\s?(\d+)\s?\/?\s?(\d+|\*)?)"};
82✔
47

48
        std::smatch range_matches;
49
        if (!regex_match(header, range_matches, content_range_regexp)) {
41✔
50
                return expected::unexpected(http::MakeError(
10✔
51
                        http::NoSuchHeaderError, "Invalid Content-Range returned from server: " + header));
30✔
52
        }
53

54
        auto exp_range_start = common::StringToLongLong(range_matches[1].str());
31✔
55
        auto exp_range_end = common::StringToLongLong(range_matches[2].str());
62✔
56
        if (!exp_range_start || !exp_range_end) {
31✔
57
                return expected::unexpected(http::MakeError(
×
58
                        http::NoSuchHeaderError, "Content-Range contains invalid number: " + header));
×
59
        }
60
        range_header.range_start = exp_range_start.value();
31✔
61
        range_header.range_end = exp_range_end.value();
31✔
62

63
        if (range_header.range_start > range_header.range_end) {
31✔
64
                return expected::unexpected(http::MakeError(
1✔
65
                        http::NoSuchHeaderError, "Invalid Content-Range returned from server: " + header));
3✔
66
        }
67

68
        if ((range_matches[3].matched) && (range_matches[3].str() != "*")) {
55✔
69
                auto exp_size = common::StringToLongLong(range_matches[3].str());
40✔
70
                if (!exp_size) {
20✔
71
                        return expected::unexpected(http::MakeError(
×
72
                                http::NoSuchHeaderError, "Content-Range contains invalid number: " + header));
×
73
                }
74
                range_header.size = exp_size.value();
20✔
75
        }
76

77
        return range_header;
78
}
79

80
class HeaderHandlerFunctor {
581✔
81
public:
82
        HeaderHandlerFunctor(weak_ptr<DownloadResumerClient> resumer) :
83
                resumer_client_ {resumer} {};
84

85
        void operator()(http::ExpectedIncomingResponsePtr exp_resp);
86

87
private:
88
        void HandleFirstResponse(
89
                const shared_ptr<DownloadResumerClient> &resumer_client,
90
                http::ExpectedIncomingResponsePtr exp_resp);
91
        void HandleNextResponse(
92
                const shared_ptr<DownloadResumerClient> &resumer_client,
93
                http::ExpectedIncomingResponsePtr exp_resp);
94

95
        weak_ptr<DownloadResumerClient> resumer_client_;
96
};
97

98
class BodyHandlerFunctor {
599✔
99
public:
100
        BodyHandlerFunctor(weak_ptr<DownloadResumerClient> resumer) :
101
                resumer_client_ {resumer} {};
102

103
        void operator()(http::ExpectedIncomingResponsePtr exp_resp);
104

105
private:
106
        weak_ptr<DownloadResumerClient> resumer_client_;
107
};
108

109
void HeaderHandlerFunctor::operator()(http::ExpectedIncomingResponsePtr exp_resp) {
142✔
110
        auto resumer_client = resumer_client_.lock();
142✔
111
        if (resumer_client) {
142✔
112
                // If an error has already occurred, schedule the next AsyncCall directly
113
                if (!exp_resp) {
140✔
114
                        resumer_client->logger_.Warning(exp_resp.error().String());
24✔
115
                        auto err = resumer_client->ScheduleNextResumeRequest();
12✔
116
                        if (err != error::NoError) {
12✔
117
                                resumer_client->logger_.Error(err.String());
2✔
118
                                resumer_client->CallUserHandler(expected::unexpected(err));
2✔
119
                        }
120
                        return;
121
                }
122

123
                if (resumer_client->resumer_state_->active_state == DownloadResumerActiveStatus::Resuming) {
128✔
124
                        HandleNextResponse(resumer_client, exp_resp);
84✔
125
                } else {
126
                        HandleFirstResponse(resumer_client, exp_resp);
172✔
127
                }
128
        }
129
}
130

131
void HeaderHandlerFunctor::HandleFirstResponse(
86✔
132
        const shared_ptr<DownloadResumerClient> &resumer_client,
133
        http::ExpectedIncomingResponsePtr exp_resp) {
134
        // The first response shall always call the user header callback. On resumable responses, we
135
        // create a our own incoming response and call the user header handler. On errors, we log a
136
        // warning and call the user handler with the original response
137

138
        auto resp = exp_resp.value();
86✔
139
        if (resp->GetStatusCode() != mender::common::http::StatusOK) {
86✔
140
                // Non-resumable response
141
                resumer_client->CallUserHandler(exp_resp);
2✔
142
                return;
2✔
143
        }
144

145
        auto exp_header = resp->GetHeader("Content-Length");
168✔
146
        if (!exp_header || exp_header.value() == "0") {
84✔
147
                resumer_client->logger_.Warning("Response does not contain Content-Length header");
2✔
148
                resumer_client->CallUserHandler(exp_resp);
1✔
149
                return;
1✔
150
        }
151

152
        auto exp_length = common::StringToLongLong(exp_header.value());
83✔
153
        if (!exp_length || exp_length.value() < 0) {
83✔
154
                resumer_client->logger_.Warning(
×
155
                        "Content-Length contains invalid number: " + exp_header.value());
×
156
                resumer_client->CallUserHandler(exp_resp);
×
157
                return;
158
        }
159

160
        // Resumable response
161
        resumer_client->resumer_state_->active_state = DownloadResumerActiveStatus::Resuming;
83✔
162
        resumer_client->resumer_state_->offset = 0;
83✔
163
        resumer_client->resumer_state_->content_length = exp_length.value();
83✔
164

165
        // Prepare a modified response and call user handler
166
        resumer_client->response_.reset(new http::IncomingResponse(*resumer_client, resp->cancelled_));
166✔
167
        resumer_client->response_->status_code_ = resp->GetStatusCode();
83✔
168
        resumer_client->response_->status_message_ = resp->GetStatusMessage();
166✔
169
        resumer_client->response_->headers_ = resp->GetHeaders();
170
        resumer_client->CallUserHandler(resumer_client->response_);
166✔
171
}
172

173
void HeaderHandlerFunctor::HandleNextResponse(
42✔
174
        const shared_ptr<DownloadResumerClient> &resumer_client,
175
        http::ExpectedIncomingResponsePtr exp_resp) {
176
        // If an error occurs during handling here, cancel the resuming and call the user handler.
177

178
        auto resp = exp_resp.value();
42✔
179
        auto resumer_reader = resumer_client->resumer_reader_.lock();
42✔
180
        if (!resumer_reader) {
42✔
181
                // Errors should already have been handled as part of the Cancel() inside the
182
                // destructor of the reader.
183
                return;
184
        }
185

186
        auto exp_content_range = resp->GetHeader("Content-Range").and_then(ParseRangeHeader);
84✔
187
        if (!exp_content_range) {
42✔
188
                resumer_client->logger_.Error(exp_content_range.error().String());
24✔
189
                resumer_client->CallUserHandler(expected::unexpected(exp_content_range.error()));
24✔
190
                return;
12✔
191
        }
192

193
        auto content_range = exp_content_range.value();
30✔
194
        if (content_range.size != 0
195
                && content_range.size != resumer_client->resumer_state_->content_length) {
30✔
196
                auto size_changed_err = http::MakeError(
197
                        http::DownloadResumerError,
198
                        "Size of artifact changed after download was resumed (expected "
199
                                + to_string(resumer_client->resumer_state_->content_length) + ", got "
4✔
200
                                + to_string(content_range.size) + ")");
8✔
201
                resumer_client->logger_.Error(size_changed_err.String());
4✔
202
                resumer_client->CallUserHandler(expected::unexpected(size_changed_err));
4✔
203
                return;
204
        }
205

206
        if ((content_range.range_end != resumer_client->resumer_state_->content_length - 1)
28✔
207
                || (content_range.range_start != resumer_client->resumer_state_->offset)) {
28✔
208
                auto bad_range_err = http::MakeError(
209
                        http::DownloadResumerError,
210
                        "HTTP server returned an different range than requested. Requested "
211
                                + to_string(resumer_client->resumer_state_->offset) + "-"
4✔
212
                                + to_string(resumer_client->resumer_state_->content_length - 1) + ", got "
8✔
213
                                + to_string(content_range.range_start) + "-" + to_string(content_range.range_end));
8✔
214
                resumer_client->logger_.Error(bad_range_err.String());
4✔
215
                resumer_client->CallUserHandler(expected::unexpected(bad_range_err));
4✔
216
                return;
217
        }
218

219
        // Get the reader for the new response
220
        auto exp_reader = resumer_client->client_.MakeBodyAsyncReader(resp);
52✔
221
        if (!exp_reader) {
26✔
222
                auto bad_range_err = exp_reader.error().WithContext("cannot get the reader after resume");
×
223
                resumer_client->logger_.Error(bad_range_err.String());
×
224
                resumer_client->CallUserHandler(expected::unexpected(bad_range_err));
×
225
                return;
226
        }
227
        // Update the inner reader of the user reader
228
        resumer_reader->inner_reader_ = exp_reader.value();
26✔
229

230
        // Resume reading reusing last user data (start, end, handler)
231
        auto err = resumer_reader->AsyncReadResume();
26✔
232
        if (err != error::NoError) {
26✔
233
                auto bad_read_err = err.WithContext("error reading after resume");
×
234
                resumer_client->logger_.Error(bad_read_err.String());
×
235
                resumer_client->CallUserHandler(expected::unexpected(bad_read_err));
×
236
                return;
237
        }
238
}
239

240
void BodyHandlerFunctor::operator()(http::ExpectedIncomingResponsePtr exp_resp) {
128✔
241
        auto resumer_client = resumer_client_.lock();
128✔
242
        if (!resumer_client) {
128✔
243
                return;
244
        }
245

246
        if (*resumer_client->cancelled_) {
128✔
247
                resumer_client->CallUserHandler(exp_resp);
25✔
248
                return;
25✔
249
        }
250

251
        if (resumer_client->resumer_state_->active_state == DownloadResumerActiveStatus::Inactive) {
103✔
252
                resumer_client->CallUserHandler(exp_resp);
3✔
253
                return;
3✔
254
        }
255

256
        // We resume the download if either:
257
        // * there is any error or
258
        // * successful read with status code Partial Content and there is still data missing
259
        const bool is_range_response =
260
                exp_resp && exp_resp.value()->GetStatusCode() == mender::common::http::StatusPartialContent;
100✔
261
        const bool is_data_missing =
262
                resumer_client->resumer_state_->offset < resumer_client->resumer_state_->content_length;
100✔
263
        if (!exp_resp || (is_range_response && is_data_missing)) {
100✔
264
                if (!exp_resp) {
44✔
265
                        auto resumer_reader = resumer_client->resumer_reader_.lock();
44✔
266
                        if (resumer_reader) {
44✔
267
                                resumer_reader->inner_reader_.reset();
44✔
268
                        }
269
                        if (exp_resp.error().code == make_error_condition(errc::operation_canceled)) {
44✔
270
                                // We don't want to resume cancelled requests, as these were
271
                                // cancelled for a reason.
272
                                resumer_client->CallUserHandler(exp_resp);
×
273
                                return;
274
                        }
275
                        resumer_client->logger_.Info(
88✔
276
                                "Will try to resume after error " + exp_resp.error().String());
88✔
277
                }
278

279
                auto err = resumer_client->ScheduleNextResumeRequest();
44✔
280
                if (err != error::NoError) {
44✔
281
                        resumer_client->logger_.Error(err.String());
×
282
                        resumer_client->CallUserHandler(expected::unexpected(err));
×
283
                        return;
284
                }
285
        } else {
286
                // Update headers with the last received server response. When resuming has taken place,
287
                // the user will get different headers on header and body handlers, representing (somehow)
288
                // what the resumer has been doing in its behalf.
289
                auto resp = exp_resp.value();
56✔
290
                resumer_client->response_->status_code_ = resp->GetStatusCode();
56✔
291
                resumer_client->response_->status_message_ = resp->GetStatusMessage();
112✔
292
                resumer_client->response_->headers_ = resp->GetHeaders();
293

294
                // Finished, call the user handler \o/
295
                resumer_client->logger_.Debug("Download resumed and completed successfully");
112✔
296
                resumer_client->CallUserHandler(resumer_client->response_);
112✔
297
        }
298
}
299

300
DownloadResumerAsyncReader::~DownloadResumerAsyncReader() {
246✔
301
        Cancel();
82✔
302
}
82✔
303

304
void DownloadResumerAsyncReader::Cancel() {
82✔
305
        auto resumer_client = resumer_client_.lock();
82✔
306
        if (!*cancelled_ && resumer_client) {
82✔
307
                resumer_client->Cancel();
3✔
308
        }
309
}
82✔
310

311
error::Error DownloadResumerAsyncReader::AsyncRead(
2,198✔
312
        vector<uint8_t>::iterator start, vector<uint8_t>::iterator end, io::AsyncIoHandler handler) {
313
        if (eof_) {
2,198✔
314
                handler(0);
×
315
                return error::NoError;
×
316
        }
317

318
        auto resumer_client = resumer_client_.lock();
2,198✔
319
        if (!resumer_client || *cancelled_) {
2,198✔
320
                return error::MakeError(
321
                        error::ProgrammingError,
322
                        "DownloadResumerAsyncReader::AsyncRead called after stream is destroyed");
×
323
        }
324
        // Save user parameters for further resumes of the body read
325
        resumer_client->last_read_ = {.start = start, .end = end, .handler = handler};
2,198✔
326
        return AsyncReadResume();
2,198✔
327
}
328

329
error::Error DownloadResumerAsyncReader::AsyncReadResume() {
2,224✔
330
        auto resumer_client = resumer_client_.lock();
2,224✔
331
        if (!resumer_client) {
2,224✔
332
                return error::MakeError(
333
                        error::ProgrammingError,
334
                        "DownloadResumerAsyncReader::AsyncReadResume called after client is destroyed");
×
335
        }
336
        return inner_reader_->AsyncRead(
337
                resumer_client->last_read_.start,
338
                resumer_client->last_read_.end,
339
                [this](io::ExpectedSize result) {
2,223✔
340
                        if (!result) {
2,223✔
341
                                logger_.Warning(
88✔
342
                                        "Reading error, a new request will be re-scheduled. "
343
                                        + result.error().String());
88✔
344
                        } else {
345
                                if (result.value() == 0) {
2,179✔
346
                                        eof_ = true;
56✔
347
                                }
348
                                resumer_state_->offset += result.value();
2,179✔
349
                                logger_.Debug("read " + to_string(result.value()) + " bytes");
4,358✔
350
                                auto resumer_client = resumer_client_.lock();
2,179✔
351
                                if (resumer_client) {
2,179✔
352
                                        resumer_client->last_read_.handler(result);
4,358✔
353
                                } else {
354
                                        logger_.Error(
×
355
                                                "AsyncRead finish handler called after resumer client has been destroyed.");
×
356
                                }
357
                        }
358
                });
6,671✔
359
}
360

361
DownloadResumerClient::DownloadResumerClient(
126✔
362
        const http::ClientConfig &config, events::EventLoop &event_loop) :
363
        resumer_state_ {make_shared<DownloadResumerClientState>()},
364
        client_(config, event_loop, "http_resumer:client"),
365
        logger_ {"http_resumer:client"},
366
        cancelled_ {make_shared<bool>(true)},
126✔
367
        retry_ {
368
                .backoff = http::ExponentialBackoff(chrono::minutes(1), 10),
369
                .wait_timer = events::Timer(event_loop)} {
504✔
370
}
126✔
371

372
DownloadResumerClient::~DownloadResumerClient() {
252✔
373
        if (!*cancelled_) {
126✔
374
                logger_.Warning("DownloadResumerClient destroyed while request is still active!");
4✔
375
        }
376
        client_.Cancel();
126✔
377
}
126✔
378

379
error::Error DownloadResumerClient::AsyncCall(
88✔
380
        http::OutgoingRequestPtr req,
381
        http::ResponseHandler user_header_handler,
382
        http::ResponseHandler user_body_handler) {
383
        HeaderHandlerFunctor resumer_header_handler {shared_from_this()};
88✔
384
        BodyHandlerFunctor resumer_body_handler {shared_from_this()};
88✔
385

386
        user_request_ = req;
387
        user_header_handler_ = user_header_handler;
88✔
388
        user_body_handler_ = user_body_handler;
88✔
389

390
        if (!*cancelled_) {
88✔
391
                return error::Error(
392
                        make_error_condition(errc::operation_in_progress), "HTTP resumer call already ongoing");
×
393
        }
394

395
        *cancelled_ = false;
88✔
396
        retry_.backoff.Reset();
397
        resumer_state_->active_state = DownloadResumerActiveStatus::Inactive;
88✔
398
        resumer_state_->user_handlers_state = DownloadResumerUserHandlersStatus::None;
88✔
399
        return client_.AsyncCall(req, resumer_header_handler, resumer_body_handler);
352✔
400
}
401

402
io::ExpectedAsyncReaderPtr DownloadResumerClient::MakeBodyAsyncReader(
83✔
403
        http::IncomingResponsePtr resp) {
404
        auto exp_reader = client_.MakeBodyAsyncReader(resp);
166✔
405
        if (!exp_reader) {
83✔
406
                return exp_reader;
407
        }
408
        auto resumer_reader = make_shared<DownloadResumerAsyncReader>(
409
                exp_reader.value(), resumer_state_, cancelled_, shared_from_this());
164✔
410
        resumer_reader_ = resumer_reader;
411
        return resumer_reader;
82✔
412
}
413

414
http::OutgoingRequestPtr DownloadResumerClient::RemainingRangeRequest() const {
54✔
415
        auto range_req = make_shared<http::OutgoingRequest>(*user_request_);
54✔
416
        if (resumer_state_->content_length > 0) {
54✔
417
                range_req->SetHeader(
53✔
418
                        "Range",
419
                        "bytes=" + to_string(resumer_state_->offset) + "-"
106✔
420
                                + to_string(resumer_state_->content_length - 1));
212✔
421
        }
422
        return range_req;
54✔
423
};
424

425
error::Error DownloadResumerClient::ScheduleNextResumeRequest() {
56✔
426
        // In any case, make sure the previous HTTP request is cancelled.
427
        client_.Cancel();
56✔
428

429
        auto exp_interval = retry_.backoff.NextInterval();
56✔
430
        if (!exp_interval) {
56✔
431
                return http::MakeError(
432
                        http::DownloadResumerError,
433
                        "Giving up on resuming the download: " + exp_interval.error().String());
2✔
434
        }
435

436
        auto interval = exp_interval.value();
55✔
437
        logger_.Info(
110✔
438
                "Resuming download after "
439
                + to_string(chrono::duration_cast<chrono::seconds>(interval).count()) + " seconds");
110✔
440

441
        HeaderHandlerFunctor resumer_next_header_handler {shared_from_this()};
55✔
442
        BodyHandlerFunctor resumer_next_body_handler {shared_from_this()};
55✔
443

444
        retry_.wait_timer.AsyncWait(
55✔
445
                interval, [this, resumer_next_header_handler, resumer_next_body_handler](error::Error err) {
162✔
446
                        if (err != error::NoError) {
54✔
447
                                auto err_user = http::MakeError(
UNCOV
448
                                        http::DownloadResumerError, "Unexpected error in wait timer: " + err.String());
×
UNCOV
449
                                logger_.Error(err_user.String());
×
UNCOV
450
                                CallUserHandler(expected::unexpected(err_user));
×
451
                                return;
452
                        }
453

454
                        auto next_call_err = client_.AsyncCall(
455
                                RemainingRangeRequest(), resumer_next_header_handler, resumer_next_body_handler);
162✔
456
                        if (next_call_err != error::NoError) {
54✔
457
                                // Schedule once more
UNCOV
458
                                auto err = ScheduleNextResumeRequest();
×
UNCOV
459
                                if (err != error::NoError) {
×
UNCOV
460
                                        logger_.Error(err.String());
×
461
                                        CallUserHandler(expected::unexpected(err));
×
462
                                }
463
                        }
464
                });
165✔
465

466
        return error::NoError;
55✔
467
}
468

469
void DownloadResumerClient::CallUserHandler(http::ExpectedIncomingResponsePtr exp_resp) {
187✔
470
        if (!exp_resp) {
187✔
471
                DoCancel();
42✔
472
        }
473
        if (resumer_state_->user_handlers_state == DownloadResumerUserHandlersStatus::None) {
187✔
474
                resumer_state_->user_handlers_state =
86✔
475
                        DownloadResumerUserHandlersStatus::HeaderHandlerCalled;
476
                user_header_handler_(exp_resp);
172✔
477
        } else if (
101✔
478
                resumer_state_->user_handlers_state
479
                == DownloadResumerUserHandlersStatus::HeaderHandlerCalled) {
480
                resumer_state_->user_handlers_state = DownloadResumerUserHandlersStatus::BodyHandlerCalled;
85✔
481
                DoCancel();
85✔
482
                user_body_handler_(exp_resp);
170✔
483
        } else {
484
                string msg;
485
                if (!exp_resp) {
16✔
486
                        msg = "error: " + exp_resp.error().String();
32✔
487
                } else {
UNCOV
488
                        auto &resp = exp_resp.value();
×
UNCOV
489
                        msg = "response: " + to_string(resp->GetStatusCode()) + " " + resp->GetStatusMessage();
×
490
                }
491
                logger_.Warning("Cannot call any user handler with " + msg);
32✔
492
        }
493
}
187✔
494

495
void DownloadResumerClient::Cancel() {
11✔
496
        DoCancel();
11✔
497
        client_.Cancel();
11✔
498
};
11✔
499

500
void DownloadResumerClient::DoCancel() {
138✔
501
        // Set cancel state and then make a new one. Those who are interested should have their own
502
        // pointer to the old one.
503
        *cancelled_ = true;
138✔
504
        cancelled_ = make_shared<bool>(true);
138✔
505
};
138✔
506

507
} // namespace resumer
508
} // namespace http
509
} // namespace common
510
} // namespace mender
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc