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

realm / realm-core / jonathan.reams_2947

01 Dec 2023 08:08PM UTC coverage: 91.739% (+0.04%) from 91.695%
jonathan.reams_2947

Pull #7160

Evergreen

jbreams
allow handle_error to decide resumability
Pull Request #7160: Prevent resuming a session that has not been fully shut down

92428 of 169414 branches covered (0.0%)

315 of 349 new or added lines in 14 files covered. (90.26%)

80 existing lines in 14 files now uncovered.

232137 of 253041 relevant lines covered (91.74%)

6882826.18 hits per line

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

71.13
/src/realm/sync/protocol.hpp
1
#ifndef REALM_SYNC_PROTOCOL_HPP
2
#define REALM_SYNC_PROTOCOL_HPP
3

4
#include <cstdint>
5
#include <system_error>
6

7
#include <realm/error_codes.h>
8
#include <realm/mixed.hpp>
9
#include <realm/replication.hpp>
10
#include <realm/util/tagged_bool.hpp>
11

12

13
// NOTE: The protocol specification is in `/doc/protocol.md`
14

15

16
namespace realm {
17
namespace sync {
18

19
// Protocol versions:
20
//
21
//   1 Initial version, matching io.realm.sync-30, but not including query-based
22
//     sync, serialized transactions, and state realms (async open).
23
//
24
//   2 Restored erase-always-wins OT behavior.
25
//
26
//   3 Support for Mixed, TypeLinks, Set, and Dictionary columns.
27
//
28
//   4 Error messaging format accepts a flexible JSON field in 'json_error'.
29
//     JSONErrorMessage.IsClientReset controls recovery mode.
30
//
31
//   5 Introduces compensating write errors.
32
//
33
//   6 Support for asymmetric tables.
34
//
35
//   7 Client takes the 'action' specified in the 'json_error' messages received
36
//     from server. Client sends 'json_error' messages to the server.
37
//
38
//   8 Websocket http errors are now sent as websocket close codes
39
//     FLX sync BIND message can include JSON data in place of server path string
40
//     Updated format for Sec-Websocket-Protocol strings
41
//
42
//   9 Support for PBS->FLX client migration
43
//     Client reset updated to not provide the local schema when creating frozen
44
//     realms - this informs the server to not send the schema before sending the
45
//     migrate to FLX server action
46
//
47
//   10 Update BIND message to send information to the server about the reason a
48
//      synchronization session is used for; add support for server log messages
49
//
50
//  XX Changes:
51
//     - TBD
52
//
53
constexpr int get_current_protocol_version() noexcept
54
{
24,572✔
55
    // Also update the current protocol version test in flx_sync.cpp when
12,066✔
56
    // updating this value
12,066✔
57
    return 10;
24,572✔
58
}
24,572✔
59

60
constexpr std::string_view get_pbs_websocket_protocol_prefix() noexcept
61
{
41,642✔
62
    return "com.mongodb.realm-sync#";
41,642✔
63
}
41,642✔
64

65
constexpr std::string_view get_flx_websocket_protocol_prefix() noexcept
66
{
1,180✔
67
    return "com.mongodb.realm-query-sync#";
1,180✔
68
}
1,180✔
69

70
enum class SyncServerMode { PBS, FLX };
71

72
/// Supported protocol envelopes:
73
///
74
///                                                             Alternative (*)
75
///      Name     Envelope          URL scheme   Default port   default port
76
///     ------------------------------------------------------------------------
77
///      realm    WebSocket         realm:       7800           80
78
///      realms   WebSocket + SSL   realms:      7801           443
79
///      ws       WebSocket         ws:          80
80
///      wss      WebSocket + SSL   wss:         443
81
///
82
///       *) When Client::Config::enable_default_port_hack is true
83
///
84
enum class ProtocolEnvelope { realm, realms, ws, wss };
85

86
inline bool is_ssl(ProtocolEnvelope protocol) noexcept
87
{
3,356✔
88
    switch (protocol) {
3,356✔
89
        case ProtocolEnvelope::realm:
2,388✔
90
        case ProtocolEnvelope::ws:
3,334✔
91
            break;
3,334✔
92
        case ProtocolEnvelope::realms:
1,624✔
93
        case ProtocolEnvelope::wss:
24✔
94
            return true;
24✔
95
    }
3,334✔
96
    return false;
3,334✔
97
}
3,334✔
98

99
inline std::string_view to_string(ProtocolEnvelope protocol) noexcept
100
{
3,362✔
101
    switch (protocol) {
3,362✔
102
        case ProtocolEnvelope::realm:
1,582✔
103
            return "realm://";
1,582✔
104
        case ProtocolEnvelope::realms:
20✔
105
            return "realms://";
20✔
106
        case ProtocolEnvelope::ws:
1,754✔
107
            return "ws://";
1,754✔
108
        case ProtocolEnvelope::wss:
4✔
109
            return "wss://";
4✔
110
    }
×
111
    return "";
×
112
}
×
113

114

115
// These integer types are selected so that they accomodate the requirements of
116
// the protocol specification (`/doc/protocol.md`).
117
//
118
// clang-format off
119
using file_ident_type    = std::uint_fast64_t;
120
using version_type       = Replication::version_type;
121
using salt_type          = std::int_fast64_t;
122
using timestamp_type     = std::uint_fast64_t;
123
using session_ident_type = std::uint_fast64_t;
124
using request_ident_type = std::uint_fast64_t;
125
using milliseconds_type  = std::int_fast64_t;
126
// clang-format on
127

128
constexpr file_ident_type get_max_file_ident()
129
{
1,714✔
130
    return 0x0'7FFF'FFFF'FFFF'FFFF;
1,714✔
131
}
1,714✔
132

133

134
struct SaltedFileIdent {
135
    file_ident_type ident;
136
    /// History divergence and identity spoofing protection.
137
    salt_type salt;
138
};
139

140
struct SaltedVersion {
141
    version_type version;
142
    /// History divergence protection.
143
    salt_type salt;
144
};
145

146

147
/// \brief A client's reference to a position in the server-side history.
148
///
149
/// A download cursor refers to a position in the server-side history. If
150
/// `server_version` is zero, the position is at the beginning of the history,
151
/// otherwise the position is after the entry whose changeset produced that
152
/// version. In general, positions are to be understood as places between two
153
/// adjacent history entries.
154
///
155
/// `last_integrated_client_version` is the version produced on the client by
156
/// the last changeset that was sent to the server and integrated into the
157
/// server-side Realm state at the time indicated by the history position
158
/// specified by `server_version`, or zero if no changesets from the client were
159
/// integrated by the server at that point in time.
160
struct DownloadCursor {
161
    version_type server_version;
162
    version_type last_integrated_client_version;
163
};
164

165
enum class DownloadBatchState {
166
    MoreToCome,
167
    LastInBatch,
168
    SteadyState,
169
};
170

171
/// Checks that `dc.last_integrated_client_version` is zero if
172
/// `dc.server_version` is zero.
173
bool is_consistent(DownloadCursor dc) noexcept;
174

175
/// Checks that `a.last_integrated_client_version` and
176
/// `b.last_integrated_client_version` are equal, if `a.server_version` and
177
/// `b.server_version` are equal. Otherwise checks that
178
/// `a.last_integrated_client_version` is less than, or equal to
179
/// `b.last_integrated_client_version`, if `a.server_version` is less than
180
/// `b.server_version`. Otherwise checks that `a.last_integrated_client_version`
181
/// is greater than, or equal to `b.last_integrated_client_version`.
182
bool are_mutually_consistent(DownloadCursor a, DownloadCursor b) noexcept;
183

184

185
/// \brief The server's reference to a position in the client-side history.
186
///
187
/// An upload cursor refers to a position in the client-side history. If
188
/// `client_version` is zero, the position is at the beginning of the history,
189
/// otherwise the position is after the entry whose changeset produced that
190
/// version. In general, positions are to be understood as places between two
191
/// adjacent history entries.
192
///
193
/// `last_integrated_server_version` is the version produced on the server by
194
/// the last changeset that was sent to the client and integrated into the
195
/// client-side Realm state at the time indicated by the history position
196
/// specified by `client_version`, or zero if no changesets from the server were
197
/// integrated by the client at that point in time.
198
struct UploadCursor {
199
    version_type client_version;
200
    version_type last_integrated_server_version;
201
};
202

203
/// Checks that `uc.last_integrated_server_version` is zero if
204
/// `uc.client_version` is zero.
205
bool is_consistent(UploadCursor uc) noexcept;
206

207
/// Checks that `a.last_integrated_server_version` and
208
/// `b.last_integrated_server_version` are equal, if `a.client_version` and
209
/// `b.client_version` are equal. Otherwise checks that
210
/// `a.last_integrated_server_version` is less than, or equal to
211
/// `b.last_integrated_server_version`, if `a.client_version` is less than
212
/// `b.client_version`. Otherwise checks that `a.last_integrated_server_version`
213
/// is greater than, or equal to `b.last_integrated_server_version`.
214
bool are_mutually_consistent(UploadCursor a, UploadCursor b) noexcept;
215

216

217
/// A client's record of the current point of progress of the synchronization
218
/// process. The client must store this persistently in the local Realm file.
219
struct SyncProgress {
220
    /// The last server version that the client has heard about.
221
    SaltedVersion latest_server_version = {0, 0};
222

223
    /// The last server version integrated, or about to be integrated by the
224
    /// client.
225
    DownloadCursor download = {0, 0};
226

227
    /// The last client version integrated by the server.
228
    UploadCursor upload = {0, 0};
229
};
230

231
struct CompensatingWriteErrorInfo {
232
    std::string object_name;
233
    OwnedMixed primary_key;
234
    std::string reason;
235
};
236

237
struct ResumptionDelayInfo {
238
    // This is the maximum delay between trying to resume a session/connection.
239
    std::chrono::milliseconds max_resumption_delay_interval = std::chrono::minutes{5};
240
    // The initial delay between trying to resume a session/connection.
241
    std::chrono::milliseconds resumption_delay_interval = std::chrono::seconds{1};
242
    // After each failure of the same type, the last delay will be multiplied by this value
243
    // until it is greater-than-or-equal to the max_resumption_delay_interval.
244
    int resumption_delay_backoff_multiplier = 2;
245
    // When calculating a new delay interval, a random value betwen zero and the result off
246
    // dividing the current delay value by the delay_jitter_divisor will be subtracted from the
247
    // delay interval. The default is to subtract up to 25% of the current delay interval.
248
    //
249
    // This is to reduce the likelyhood of a connection storm if the server goes down and
250
    // all clients attempt to reconnect at once.
251
    int delay_jitter_divisor = 4;
252
};
253

254
class IsFatalTag {};
255
using IsFatal = util::TaggedBool<class IsFatalTag>;
256

257
struct ProtocolErrorInfo {
258
    enum class Action {
259
        NoAction,
260
        ProtocolViolation,
261
        ApplicationBug,
262
        Warning,
263
        Transient,
264
        DeleteRealm,
265
        ClientReset,
266
        ClientResetNoRecovery,
267
        MigrateToFLX,
268
        RevertToPBS,
269
        // The RefreshUser/RefreshLocation/LogOutUser actions are currently generated internally when the
270
        // sync websocket is closed with specific error codes.
271
        RefreshUser,
272
        RefreshLocation,
273
        LogOutUser,
274
        BackupThenDeleteRealm,
275
    };
276

277
    ProtocolErrorInfo() = default;
976✔
278
    ProtocolErrorInfo(int error_code, const std::string& msg, IsFatal is_fatal, Action error_action)
279
        : raw_error_code(error_code)
280
        , message(msg)
281
        , is_fatal(is_fatal)
282
        , client_reset_recovery_is_disabled(false)
283
        , should_client_reset(util::none)
284
        , server_requests_action(error_action)
285
    {
3,456✔
286
    }
3,456✔
287
    int raw_error_code = 0;
288
    std::string message;
289
    IsFatal is_fatal = IsFatal{true};
290
    bool client_reset_recovery_is_disabled = false;
291
    std::optional<bool> should_client_reset;
292
    std::optional<std::string> log_url;
293
    std::optional<version_type> compensating_write_server_version;
294
    version_type compensating_write_rejected_client_version = 0;
295
    std::vector<CompensatingWriteErrorInfo> compensating_writes;
296
    std::optional<ResumptionDelayInfo> resumption_delay_interval;
297
    Action server_requests_action;
298
    std::optional<std::string> migration_query_string;
299
};
300

301

302
/// \brief Protocol errors discovered by the server, and reported to the client
303
/// by way of ERROR messages.
304
///
305
/// These errors will be reported to the client-side application via the error
306
/// handlers of the affected sessions.
307
///
308
/// ATTENTION: Please remember to update is_session_level_error() when
309
/// adding/removing error codes.
310
enum class ProtocolError {
311
    // clang-format off
312

313
    // Connection level and protocol errors
314
    connection_closed            = RLM_SYNC_ERR_CONNECTION_CONNECTION_CLOSED,       // Connection closed (no error)
315
    other_error                  = RLM_SYNC_ERR_CONNECTION_OTHER_ERROR,             // Other connection level error
316
    unknown_message              = RLM_SYNC_ERR_CONNECTION_UNKNOWN_MESSAGE,         // Unknown type of input message
317
    bad_syntax                   = RLM_SYNC_ERR_CONNECTION_BAD_SYNTAX,              // Bad syntax in input message head
318
    limits_exceeded              = RLM_SYNC_ERR_CONNECTION_LIMITS_EXCEEDED,         // Limits exceeded in input message
319
    wrong_protocol_version       = RLM_SYNC_ERR_CONNECTION_WRONG_PROTOCOL_VERSION,  // Wrong protocol version (CLIENT) (obsolete)
320
    bad_session_ident            = RLM_SYNC_ERR_CONNECTION_BAD_SESSION_IDENT,       // Bad session identifier in input message
321
    reuse_of_session_ident       = RLM_SYNC_ERR_CONNECTION_REUSE_OF_SESSION_IDENT,  // Overlapping reuse of session identifier (BIND)
322
    bound_in_other_session       = RLM_SYNC_ERR_CONNECTION_BOUND_IN_OTHER_SESSION,  // Client file bound in other session (IDENT)
323
    bad_message_order            = RLM_SYNC_ERR_CONNECTION_BAD_MESSAGE_ORDER,       // Bad input message order
324
    bad_decompression            = RLM_SYNC_ERR_CONNECTION_BAD_DECOMPRESSION,       // Error in decompression (UPLOAD)
325
    bad_changeset_header_syntax  = RLM_SYNC_ERR_CONNECTION_BAD_CHANGESET_HEADER_SYNTAX, // Bad syntax in a changeset header (UPLOAD)
326
    bad_changeset_size           = RLM_SYNC_ERR_CONNECTION_BAD_CHANGESET_SIZE,      // Bad size specified in changeset header (UPLOAD)
327
    switch_to_flx_sync           = RLM_SYNC_ERR_CONNECTION_SWITCH_TO_FLX_SYNC,      // Connected with wrong wire protocol - should switch to FLX sync
328
    switch_to_pbs                = RLM_SYNC_ERR_CONNECTION_SWITCH_TO_PBS,           // Connected with wrong wire protocol - should switch to PBS
329

330
    // Session level errors
331
    session_closed               = RLM_SYNC_ERR_SESSION_SESSION_CLOSED,             // Session closed (no error)
332
    other_session_error          = RLM_SYNC_ERR_SESSION_OTHER_SESSION_ERROR,        // Other session level error
333
    token_expired                = RLM_SYNC_ERR_SESSION_TOKEN_EXPIRED,              // Access token expired
334
    bad_authentication           = RLM_SYNC_ERR_SESSION_BAD_AUTHENTICATION,         // Bad user authentication (BIND)
335
    illegal_realm_path           = RLM_SYNC_ERR_SESSION_ILLEGAL_REALM_PATH,         // Illegal Realm path (BIND)
336
    no_such_realm                = RLM_SYNC_ERR_SESSION_NO_SUCH_REALM,              // No such Realm (BIND)
337
    permission_denied            = RLM_SYNC_ERR_SESSION_PERMISSION_DENIED,          // Permission denied (BIND)
338
    bad_server_file_ident        = RLM_SYNC_ERR_SESSION_BAD_SERVER_FILE_IDENT,      // Bad server file identifier (IDENT) (obsolete!)
339
    bad_client_file_ident        = RLM_SYNC_ERR_SESSION_BAD_CLIENT_FILE_IDENT,      // Bad client file identifier (IDENT)
340
    bad_server_version           = RLM_SYNC_ERR_SESSION_BAD_SERVER_VERSION,         // Bad server version (IDENT, UPLOAD, TRANSACT)
341
    bad_client_version           = RLM_SYNC_ERR_SESSION_BAD_CLIENT_VERSION,         // Bad client version (IDENT, UPLOAD)
342
    diverging_histories          = RLM_SYNC_ERR_SESSION_DIVERGING_HISTORIES,        // Diverging histories (IDENT)
343
    bad_changeset                = RLM_SYNC_ERR_SESSION_BAD_CHANGESET,              // Bad changeset (UPLOAD, ERROR)
344
    partial_sync_disabled        = RLM_SYNC_ERR_SESSION_PARTIAL_SYNC_DISABLED,      // Partial sync disabled (BIND)
345
    unsupported_session_feature  = RLM_SYNC_ERR_SESSION_UNSUPPORTED_SESSION_FEATURE, // Unsupported session-level feature
346
    bad_origin_file_ident        = RLM_SYNC_ERR_SESSION_BAD_ORIGIN_FILE_IDENT,      // Bad origin file identifier (UPLOAD)
347
    bad_client_file              = RLM_SYNC_ERR_SESSION_BAD_CLIENT_FILE,            // Synchronization no longer possible for client-side file
348
    server_file_deleted          = RLM_SYNC_ERR_SESSION_SERVER_FILE_DELETED,        // Server file was deleted while session was bound to it
349
    client_file_blacklisted      = RLM_SYNC_ERR_SESSION_CLIENT_FILE_BLACKLISTED,    // Client file has been blacklisted (IDENT)
350
    user_blacklisted             = RLM_SYNC_ERR_SESSION_USER_BLACKLISTED,           // User has been blacklisted (BIND)
351
    transact_before_upload       = RLM_SYNC_ERR_SESSION_TRANSACT_BEFORE_UPLOAD,     // Serialized transaction before upload completion
352
    client_file_expired          = RLM_SYNC_ERR_SESSION_CLIENT_FILE_EXPIRED,        // Client file has expired
353
    user_mismatch                = RLM_SYNC_ERR_SESSION_USER_MISMATCH,              // User mismatch for client file identifier (IDENT)
354
    too_many_sessions            = RLM_SYNC_ERR_SESSION_TOO_MANY_SESSIONS,          // Too many sessions in connection (BIND)
355
    invalid_schema_change        = RLM_SYNC_ERR_SESSION_INVALID_SCHEMA_CHANGE,      // Invalid schema change (UPLOAD)
356
    bad_query                    = RLM_SYNC_ERR_SESSION_BAD_QUERY,                  // Client query is invalid/malformed (IDENT, QUERY)
357
    object_already_exists        = RLM_SYNC_ERR_SESSION_OBJECT_ALREADY_EXISTS,      // Client tried to create an object that already exists outside their view (UPLOAD)
358
    server_permissions_changed   = RLM_SYNC_ERR_SESSION_SERVER_PERMISSIONS_CHANGED, // Server permissions for this file ident have changed since the last time it was used (IDENT)
359
    initial_sync_not_completed   = RLM_SYNC_ERR_SESSION_INITIAL_SYNC_NOT_COMPLETED, // Client tried to open a session before initial sync is complete (BIND)
360
    write_not_allowed            = RLM_SYNC_ERR_SESSION_WRITE_NOT_ALLOWED,          // Client attempted a write that is disallowed by permissions, or modifies an
361
                                                                                    // object outside the current query - requires client reset (UPLOAD)
362
    compensating_write           = RLM_SYNC_ERR_SESSION_COMPENSATING_WRITE,         // Client attempted a write that is disallowed by permissions, or modifies an
363
                                                                                    // object outside the current query, and the server undid the modification
364
                                                                                    // (UPLOAD)
365
    migrate_to_flx               = RLM_SYNC_ERR_SESSION_MIGRATE_TO_FLX,             // Server migrated from PBS to FLX - migrate client to FLX (BIND)
366
    bad_progress                 = RLM_SYNC_ERR_SESSION_BAD_PROGRESS,               // Bad progress information (ERROR)
367
    revert_to_pbs                = RLM_SYNC_ERR_SESSION_REVERT_TO_PBS,              // Server rolled back to PBS after FLX migration - revert FLX client migration (BIND)
368

369
    // clang-format on
370
};
371

372
Status protocol_error_to_status(ProtocolError raw_error_code, std::string_view msg);
373

374
constexpr bool is_session_level_error(ProtocolError);
375

376
/// Returns null if the specified protocol error code is not defined by
377
/// ProtocolError.
378
const char* get_protocol_error_message(int error_code) noexcept;
379
std::ostream& operator<<(std::ostream&, ProtocolError protocol_error);
380

381
// Implementation
382

383
inline bool is_consistent(DownloadCursor dc) noexcept
384
{
×
385
    return (dc.server_version != 0 || dc.last_integrated_client_version == 0);
×
386
}
×
387

388
inline bool are_mutually_consistent(DownloadCursor a, DownloadCursor b) noexcept
389
{
×
390
    if (a.server_version < b.server_version)
×
391
        return (a.last_integrated_client_version <= b.last_integrated_client_version);
×
392
    if (a.server_version > b.server_version)
×
393
        return (a.last_integrated_client_version >= b.last_integrated_client_version);
×
394
    return (a.last_integrated_client_version == b.last_integrated_client_version);
×
395
}
×
396

397
inline bool is_consistent(UploadCursor uc) noexcept
398
{
81,370✔
399
    return (uc.client_version != 0 || uc.last_integrated_server_version == 0);
81,370!
400
}
81,370✔
401

402
inline bool are_mutually_consistent(UploadCursor a, UploadCursor b) noexcept
403
{
303,280✔
404
    if (a.client_version < b.client_version)
303,280✔
405
        return (a.last_integrated_server_version <= b.last_integrated_server_version);
10,936✔
406
    if (a.client_version > b.client_version)
292,344✔
407
        return (a.last_integrated_server_version >= b.last_integrated_server_version);
277,356✔
408
    return (a.last_integrated_server_version == b.last_integrated_server_version);
14,988✔
409
}
14,988✔
410

411
constexpr bool is_session_level_error(ProtocolError error)
412
{
1,208✔
413
    return int(error) >= 200 && int(error) <= 299;
1,208✔
414
}
1,208✔
415

416
inline std::ostream& operator<<(std::ostream& o, ProtocolErrorInfo::Action action)
417
{
976✔
418
    switch (action) {
976✔
UNCOV
419
        case ProtocolErrorInfo::Action::NoAction:
✔
UNCOV
420
            return o << "NoAction";
×
421
        case ProtocolErrorInfo::Action::ProtocolViolation:
✔
422
            return o << "ProtocolViolation";
×
423
        case ProtocolErrorInfo::Action::ApplicationBug:
102✔
424
            return o << "ApplicationBug";
102✔
425
        case ProtocolErrorInfo::Action::Warning:
56✔
426
            return o << "Warning";
56✔
427
        case ProtocolErrorInfo::Action::Transient:
434✔
428
            return o << "Transient";
434✔
NEW
429
        case ProtocolErrorInfo::Action::BackupThenDeleteRealm:
✔
NEW
430
            return o << "BackupThenDeleteRealm";
×
431
        case ProtocolErrorInfo::Action::DeleteRealm:
✔
432
            return o << "DeleteRealm";
×
433
        case ProtocolErrorInfo::Action::ClientReset:
316✔
434
            return o << "ClientReset";
316✔
435
        case ProtocolErrorInfo::Action::ClientResetNoRecovery:
8✔
436
            return o << "ClientResetNoRecovery";
8✔
437
        case ProtocolErrorInfo::Action::MigrateToFLX:
36✔
438
            return o << "MigrateToFLX";
36✔
439
        case ProtocolErrorInfo::Action::RevertToPBS:
20✔
440
            return o << "RevertToPBS";
20✔
441
        case ProtocolErrorInfo::Action::RefreshUser:
✔
442
            return o << "RefreshUser";
×
443
        case ProtocolErrorInfo::Action::RefreshLocation:
✔
444
            return o << "RefreshLocation";
×
445
        case ProtocolErrorInfo::Action::LogOutUser:
4✔
446
            return o << "LogOutUser";
4✔
447
    }
×
448
    return o << "Invalid error action: " << int64_t(action);
×
449
}
×
450

451
} // namespace sync
452
} // namespace realm
453

454
#endif // REALM_SYNC_PROTOCOL_HPP
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