@@ -236,6 +236,35 @@ int db_expect_min (sqlite3 *db, const char *sql, int expect_min) {
236236 return SQLITE_OK ;
237237}
238238
239+ // Run a send that is expected to succeed and assert it didn't fail at the protocol
240+ // level: status is not "error" and the server reported no per-chunk failure
241+ // (send.lastFailure is omitted on success). Catches a server-reported apply/check
242+ // failure that doesn't raise a SQL error — invisible to a plain db_exec of the send.
243+ int db_send_ok (sqlite3 * db ) {
244+ return db_expect_int (db ,
245+ "SELECT (j ->> '$.send.status') <> 'error' AND (j ->> '$.send.lastFailure') IS NULL "
246+ "FROM (SELECT cloudsync_network_send_changes() AS j);" , 1 );
247+ }
248+
249+ // Send, then poll until the server's optimistic version (send.serverVersion) catches
250+ // up to send.localVersion — the change is durably covered with no gap. Robust to the
251+ // server's asynchronous apply: the first call sends, later calls are no-ops that
252+ // re-read status. Fails if it has not converged within max_attempts.
253+ int db_send_await_converge (sqlite3 * db , int max_attempts , int delay_ms ) {
254+ const char * sql =
255+ "SELECT (j ->> '$.send.serverVersion') >= (j ->> '$.send.localVersion') "
256+ "FROM (SELECT cloudsync_network_send_changes() AS j);" ;
257+ for (int i = 0 ; i < max_attempts ; i ++ ) {
258+ int converged = 0 ;
259+ int rc = db_select_int (db , sql , & converged );
260+ if (rc != SQLITE_OK ) return rc ;
261+ if (converged ) return SQLITE_OK ;
262+ if (i + 1 < max_attempts ) sqlite3_sleep (delay_ms );
263+ }
264+ printf ("Error: send did not converge (serverVersion < localVersion) after %d attempts\n" , max_attempts );
265+ return SQLITE_ERROR ;
266+ }
267+
239268int integration_network_init (sqlite3 * db , const char * database_id , char * network_init , size_t network_init_len ) {
240269 if (!database_id ) {
241270 fprintf (stderr , "Error: integration database ID not set.\n" );
@@ -528,7 +557,7 @@ int test_enable_disable(const char *db_path) {
528557 rc = db_exec (db , set_apikey ); RCHECK
529558 }
530559
531- rc = db_exec (db , "SELECT cloudsync_network_send_changes();" ); RCHECK
560+ rc = db_send_ok (db ); RCHECK
532561 rc = db_exec (db , "SELECT cloudsync_cleanup('users');" ); RCHECK
533562 rc = db_exec (db , "SELECT cloudsync_cleanup('activities');" ); RCHECK
534563 rc = db_exec (db , "SELECT cloudsync_cleanup('workouts');" ); RCHECK
@@ -624,14 +653,11 @@ int test_send_gap_from_clock_hole(const char *db_path) {
624653 rc = db_exec (db , set_apikey ); RCHECK
625654 }
626655
627- // Send once. The server applies the change and computes lastOptimisticVersion
628- // (serverVersion) synchronously from its per-site applied ranges. With contiguous
629- // coverage it reaches localVersion (10); with the gap bug it stays at 0 because
630- // db_versions 1..9 are reported missing.
631- rc = db_expect_int (db ,
632- "SELECT (j ->> '$.send.serverVersion') = (j ->> '$.send.localVersion') "
633- " AND (j ->> '$.send.localVersion') = 10 "
634- "FROM (SELECT cloudsync_network_send_changes() AS j);" , 1 ); RCHECK
656+ // Send, then poll until the server's optimistic version (serverVersion) reaches
657+ // localVersion (10). With contiguous coverage it converges; with the gap bug it
658+ // never does — serverVersion stays at 0 because db_versions 1..9 are reported
659+ // missing. Polling absorbs the server's asynchronous apply.
660+ rc = db_send_await_converge (db , 8 , 1000 ); RCHECK
635661
636662 rc = db_exec (db , "SELECT cloudsync_cleanup('users');" ); RCHECK
637663 rc = db_exec (db , "SELECT cloudsync_cleanup('activities');" ); RCHECK
@@ -665,7 +691,7 @@ int test_chunked_payload_paths(void) {
665691 rc = db_expect_min (sender , "SELECT COUNT(*) FROM cloudsync_payload_chunks();" , 2 ); if (rc != SQLITE_OK ) goto cleanup ;
666692 rc = db_expect_min (sender , "SELECT COUNT(*) FROM cloudsync_payload_chunks() WHERE hex(substr(payload,5,1))='03';" , 2 ); if (rc != SQLITE_OK ) goto cleanup ;
667693
668- rc = db_exec (sender , "SELECT cloudsync_network_send_changes();" ); if (rc != SQLITE_OK ) goto cleanup ;
694+ rc = db_send_ok (sender ); if (rc != SQLITE_OK ) goto cleanup ;
669695 cleanup_remote_row = true;
670696
671697 for (int attempt = 0 ; attempt < 30 ; ++ attempt ) {
@@ -738,7 +764,7 @@ int test_chunked_payload_rowset_path(void) {
738764 rc = db_expect_min (sender , "SELECT COUNT(*) FROM cloudsync_payload_chunks();" , 2 ); if (rc != SQLITE_OK ) goto cleanup ;
739765 rc = db_expect_int (sender , "SELECT COUNT(*) FROM cloudsync_payload_chunks() WHERE hex(substr(payload,5,1))='03';" , 0 ); if (rc != SQLITE_OK ) goto cleanup ;
740766
741- rc = db_exec (sender , "SELECT cloudsync_network_send_changes();" ); if (rc != SQLITE_OK ) goto cleanup ;
767+ rc = db_send_ok (sender ); if (rc != SQLITE_OK ) goto cleanup ;
742768 cleanup_remote_rows = true;
743769
744770 for (int attempt = 0 ; attempt < 30 ; ++ attempt ) {
@@ -822,7 +848,7 @@ int test_chunked_payload_single_sync_drain(void) {
822848 // Sender splits into multiple non-fragment chunks.
823849 rc = db_expect_min (sender , "SELECT COUNT(*) FROM cloudsync_payload_chunks();" , 2 ); if (rc != SQLITE_OK ) goto cleanup ;
824850
825- rc = db_exec (sender , "SELECT cloudsync_network_send_changes();" ); if (rc != SQLITE_OK ) goto cleanup ;
851+ rc = db_send_ok (sender ); if (rc != SQLITE_OK ) goto cleanup ;
826852 cleanup_remote_rows = true;
827853
828854 for (int attempt = 0 ; attempt < 40 ; ++ attempt ) {
@@ -921,7 +947,7 @@ int test_chunked_payload_capped_receive(void) {
921947
922948 rc = db_expect_min (sender , "SELECT COUNT(*) FROM cloudsync_payload_chunks();" , 2 ); if (rc != SQLITE_OK ) goto cleanup ;
923949
924- rc = db_exec (sender , "SELECT cloudsync_network_send_changes();" ); if (rc != SQLITE_OK ) goto cleanup ;
950+ rc = db_send_ok (sender ); if (rc != SQLITE_OK ) goto cleanup ;
925951 cleanup_remote_rows = true;
926952
927953 for (int attempt = 0 ; attempt < 80 ; ++ attempt ) {
@@ -1017,7 +1043,7 @@ int test_chunked_payload_batched_receive(void) {
10171043
10181044 rc = db_expect_min (sender , "SELECT COUNT(*) FROM cloudsync_payload_chunks();" , 3 ); if (rc != SQLITE_OK ) goto cleanup ;
10191045
1020- rc = db_exec (sender , "SELECT cloudsync_network_send_changes();" ); if (rc != SQLITE_OK ) goto cleanup ;
1046+ rc = db_send_ok (sender ); if (rc != SQLITE_OK ) goto cleanup ;
10211047 cleanup_remote_rows = true;
10221048
10231049 for (int attempt = 0 ; attempt < 80 ; ++ attempt ) {
@@ -1189,7 +1215,7 @@ int test_chunked_negative_cache_invalidation(void) {
11891215 "INSERT INTO chunked_payload_items (id, body) VALUES ('%s', 'negative-cache-sentinel');" ,
11901216 sentinel_id );
11911217 rc = db_exec (sender , sql ); if (rc != SQLITE_OK ) goto cleanup ;
1192- rc = db_exec (sender , "SELECT cloudsync_network_send_changes();" ); if (rc != SQLITE_OK ) goto cleanup ;
1218+ rc = db_send_ok (sender ); if (rc != SQLITE_OK ) goto cleanup ;
11931219 cleanup_sentinel = true;
11941220
11951221 // Phase 1: drain the receiver until it is provably caught up. Each bare
@@ -1267,7 +1293,7 @@ int test_chunked_negative_cache_invalidation(void) {
12671293 "INSERT INTO chunked_payload_items (id, body) VALUES ('%s', 'negative-cache');" ,
12681294 row_id );
12691295 rc = db_exec (sender , sql ); if (rc != SQLITE_OK ) goto cleanup ;
1270- rc = db_exec (sender , "SELECT cloudsync_network_send_changes();" ); if (rc != SQLITE_OK ) goto cleanup ;
1296+ rc = db_send_ok (sender ); if (rc != SQLITE_OK ) goto cleanup ;
12711297 cleanup_remote_row = true;
12721298
12731299 // Phase 4: the receiver must now pick up the change on a subsequent receive.
0 commit comments