Merge feat/p6-stop: --stop-after/--stop-at deadline stop

This commit is contained in:
2026-09-10 18:16:55 +02:00
15 changed files with 846 additions and 70 deletions
+43
View File
@@ -12,12 +12,14 @@
#include "identity.h"
#include "log.h"
#include "protocol.h"
#include "stop_condition.h"
#include "transport_tcp.h"
#include "transport_tls.h"
#include "usage.h"
#include "utils.h"
#include <errno.h>
#include <limits.h>
#include <time.h>
#include <signal.h>
#include <stdbool.h>
#include <stddef.h>
@@ -853,6 +855,47 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
return -1;
continue;
}
/* --stop-after/--stop-at are client-only sender-side stop deadlines. They
* are parsed by stop_condition (so the unit tests exercise the same validate
* that production uses) and never serialized into the config frame. */
if (strncmp(argv[i], "--stop-after=", 13) == 0) {
if (!stop_parse_after_minutes(argv[i] + 13, &config->stop_after_mins)) {
log_message(LOG_LEVEL_ERROR, "--stop-after must be a positive number of minutes");
return -1;
}
continue;
}
if (strcmp(argv[i], "--stop-after") == 0) {
if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for --stop-after");
return -1;
}
if (!stop_parse_after_minutes(argv[++i], &config->stop_after_mins)) {
log_message(LOG_LEVEL_ERROR, "--stop-after must be a positive number of minutes");
return -1;
}
continue;
}
if (strncmp(argv[i], "--stop-at=", 10) == 0) {
if (!stop_parse_at_time(argv[i] + 10, time(NULL), &config->stop_at)) {
log_message(LOG_LEVEL_ERROR, "--stop-at must be HH:MM[:SS] or now+N[smhd]");
return -1;
}
config->stop_at_set = true;
continue;
}
if (strcmp(argv[i], "--stop-at") == 0) {
if (i + 1 >= argc) {
log_message(LOG_LEVEL_ERROR, "missing argument for --stop-at");
return -1;
}
if (!stop_parse_at_time(argv[++i], time(NULL), &config->stop_at)) {
log_message(LOG_LEVEL_ERROR, "--stop-at must be HH:MM[:SS] or now+N[smhd]");
return -1;
}
config->stop_at_set = true;
continue;
}
const char* threads_prefix = "--compress-threads=";
if (strncmp(argv[i], threads_prefix, strlen(threads_prefix)) == 0) {
if (set_compression_threads_option(&config->compression_threads,
+147 -70
View File
@@ -18,6 +18,7 @@
#include "protocol.h"
#include "queue.h"
#include "scanner.h"
#include "stop_condition.h"
#include "transport_tcp.h"
#include "transport_ssh.h"
#include "transport_tls.h"
@@ -1397,6 +1398,16 @@ static int send_chunks_multithreaded(void* pipeline_context) {
}
while (true) {
/* Phase 6: stop-elegantly at the next chunk boundary once the --stop-after
/ --stop-at deadline has passed. Everything already sent is finalized by
the completion tail below; the run still returns success. */
if (stop_condition_reached(&context->stop_condition)) {
log_info_message(LOG_INFO_MISC,
"Stop deadline reached; stopping transfer at the next chunk boundary");
context->scan_stopped_early = true;
pipeline_cancel(context);
break;
}
Chunk* current_chunk = queue_dequeue_multithreaded(
context->queue_loader, &context->mutex_loader, &context->condition_not_empty_loader,
&context->condition_not_full_loader, &context->loader_done);
@@ -1408,55 +1419,7 @@ static int send_chunks_multithreaded(void* pipeline_context) {
protocol_session_unbind();
return thrd_error;
}
if (context->config->use_delete && !context->early_delete) {
/* Empty keep-set + scan I/O error must not delete the whole destination
(the source may not be genuinely empty -- see send_files). */
bool empty_io;
mtx_lock(&context->mutex_scanner);
empty_io = context->scan_had_io_error && context->manifest && context->manifest->size == 0;
mtx_unlock(&context->mutex_scanner);
if (empty_io) {
log_message(LOG_LEVEL_ERROR,
"source scan hit an I/O error before finding any file; refusing to delete "
"with an empty keep-set (--delete)");
goto send_fail;
}
if (send_delete_manifest(client->file_descriptor, context->manifest,
context->excluded_paths, context->missing_args) != 0)
goto send_fail;
} else if (context->config->delete_missing_args && !context->early_delete) {
/* --delete-missing-args without --delete: no keep-set is built, but the
exact-delete paths still ride the same manifest frame (commit once the
transfer succeeded). */
if (send_delete_manifest(client->file_descriptor, NULL, NULL, context->missing_args) != 0)
goto send_fail;
}
bool ok = finalize_transfer(client, context->config, context->remove_source_files);
if (!ok && context->config->use_delete)
log_message(LOG_LEVEL_ERROR,
"server reported a deletion failure (--delete); see the server log for the "
"reason (a --max-delete limit that the run would exceed deletes nothing)");
if (ok)
remove_transferred_sources(context->config, context->remove_source_files);
mtx_lock(&context->mutex_progress);
int total_files = context->total_files;
unsigned long long total_bytes = context->total_bytes;
mtx_unlock(&context->mutex_progress);
if (context->config->stats)
fprintf(stderr, "Stats: %d files, %.1f MB\n", total_files, total_bytes / 1048576.0);
log_info_message(LOG_INFO_STATS, "Transfer summary: %d files, %.1f MB", total_files,
total_bytes / 1048576.0);
disconnect_transfer_client(client);
mark_sender_done(context);
protocol_session_unbind();
return ok ? thrd_success : thrd_error;
send_fail:
pipeline_cancel(context);
disconnect_transfer_client(client);
mark_sender_done(context);
protocol_session_unbind();
return thrd_error;
break;
}
if (send_chunk_with_removal(client, current_chunk, context->config,
context->remove_source_files) != 0) {
@@ -1483,6 +1446,72 @@ static int send_chunks_multithreaded(void* pipeline_context) {
mtx_unlock(&context->mutex_progress);
chunk_destroy(current_chunk);
}
/* Completion tail: reached on natural exhaustion or an early stop deadline.
A deadline that cut the scan short leaves an incomplete keep-set manifest;
transmitting it would make the receiver --delete the unscanned source
mirrors (data loss), so it is deliberately suppressed. Suppressing it also
means the manifest (which the scanner thread may still be appending) is
never read here on the early-stop path, so no scanner synchronization is
required to enter the tail. */
context->scan_stopped_early =
context->scan_stopped_early || stop_condition_reached(&context->stop_condition);
if (context->scan_stopped_early) {
if (context->config->use_delete || context->config->delete_missing_args)
log_message(LOG_LEVEL_WARNING,
"transfer stopped early (stop deadline); skipping --delete keep-set so "
"unscanned source mirrors are not deleted");
else
log_message(LOG_LEVEL_WARNING, "transfer stopped early (stop deadline)");
} else if (context->config->use_delete && !context->early_delete) {
/* Empty keep-set + scan I/O error must not delete the whole destination
(the source may not be genuinely empty -- see send_files). */
bool empty_io;
mtx_lock(&context->mutex_scanner);
empty_io = context->scan_had_io_error && context->manifest && context->manifest->size == 0;
mtx_unlock(&context->mutex_scanner);
if (empty_io) {
log_message(LOG_LEVEL_ERROR,
"source scan hit an I/O error before finding any file; refusing to delete "
"with an empty keep-set (--delete)");
goto send_fail;
}
if (send_delete_manifest(client->file_descriptor, context->manifest, context->excluded_paths,
context->missing_args) != 0)
goto send_fail;
} else if (context->config->delete_missing_args && !context->early_delete) {
/* --delete-missing-args without --delete: no keep-set is built, but the
exact-delete paths still ride the same manifest frame (commit once the
transfer succeeded). */
if (send_delete_manifest(client->file_descriptor, NULL, NULL, context->missing_args) != 0)
goto send_fail;
}
bool ok = finalize_transfer(client, context->config, context->remove_source_files);
if (!ok && context->config->use_delete)
log_message(LOG_LEVEL_ERROR,
"server reported a deletion failure (--delete); see the server log for the "
"reason (a --max-delete limit that the run would exceed deletes nothing)");
if (ok)
remove_transferred_sources(context->config, context->remove_source_files);
mtx_lock(&context->mutex_progress);
int total_files = context->total_files;
unsigned long long total_bytes = context->total_bytes;
mtx_unlock(&context->mutex_progress);
if (context->config->stats)
fprintf(stderr, "Stats: %d files, %.1f MB\n", total_files, total_bytes / 1048576.0);
log_info_message(LOG_INFO_STATS, "Transfer summary: %d files, %.1f MB", total_files,
total_bytes / 1048576.0);
disconnect_transfer_client(client);
mark_sender_done(context);
protocol_session_unbind();
return ok ? thrd_success : thrd_error;
send_fail:
pipeline_cancel(context);
disconnect_transfer_client(client);
mark_sender_done(context);
protocol_session_unbind();
return thrd_error;
}
/* Scan thread of the -m pipeline. --dirs disables recursive traversal (the
@@ -1497,6 +1526,7 @@ static int scan_directory_multithreaded(void* pipeline_context) {
protocol_session_unbind();
return thrd_error;
}
prepared.options.stop_condition = &context->stop_condition;
/* The keep-set manifest for the late modes is built from this data pass, so
the parallel scanner records the protected excluded prefixes here. The
early modes already transmitted the pre-scan keep-set and its protected
@@ -1791,6 +1821,17 @@ int send_files(Config* config) {
if (!manifest)
goto send_fail;
}
/* Phase 6: compute the client-only stop deadline once at transfer start. The
early-delete pre-scan above deliberately ignores it so the keep-set (and
its committed deletion) is always complete and correct. */
struct timespec now_mono;
if (clock_gettime(CLOCK_MONOTONIC, &now_mono) != 0) {
now_mono.tv_sec = 0;
now_mono.tv_nsec = 0;
}
StopCondition stop = stop_condition_make(config->stop_after_mins > 0, config->stop_after_mins,
config->stop_at_set, config->stop_at, now_mono);
prepared.options.stop_condition = &stop;
scanner = directory_scanner_create_with_options(config->send_directory, &prepared.options);
if (!scanner)
goto send_fail;
@@ -1800,7 +1841,20 @@ int send_files(Config* config) {
int total_files = 0;
time_t last_progress = 0;
time_t start = time(NULL);
/* True when the stop deadline cut the scan short so the keep-set manifest is
only a prefix of the source. */
bool scan_stopped_early = false;
while ((current_chunk = directory_scanner_next(scanner)) != NULL) {
/* Phase 6: stop-elegantly at the next chunk boundary once the deadline has
passed. The scanner may also have stopped early itself; either way the
completion tail below keeps everything already sent. */
if (stop_condition_reached(&stop)) {
chunk_destroy(current_chunk);
log_info_message(LOG_INFO_MISC,
"Stop deadline reached; stopping transfer at the next chunk boundary");
scan_stopped_early = true;
break;
}
unsigned long long chunk_bytes = 0;
for (int i = 0; i < current_chunk->element_count; i++) {
chunk_bytes += current_chunk->items[i]->data->size;
@@ -1854,31 +1908,46 @@ int send_files(Config* config) {
goto send_fail;
if (directory_scanner_had_io_error(scanner))
had_scan_io = true;
if (had_scan_io && manifest && manifest->size == 0) {
/* A scan that hit an I/O error and produced no keep entries is ambiguous;
an empty keep-set would delete the whole destination. Refuse to delete
(see the early-timing comment above). */
log_message(LOG_LEVEL_ERROR,
"source scan hit an I/O error before finding any file; refusing to delete with "
"an empty keep-set (--delete)");
goto send_fail;
}
if ((manifest || config->delete_missing_args) && !delete_early) {
/* Late (commit) ordering: all file data is out; transmit the manifest so
the receiver commits the extras walk (--delete) and/or the
--delete-missing-args exact-path deletions only after the transfer
succeeds. In the early modes (--delete-before/--delete-during) the
manifest already went out up front, so nothing is re-sent here. */
if (send_delete_manifest(client->file_descriptor, manifest, excluded, missing_args) != 0) {
/* Phase 6: the scanner may have stopped early (returning NULL without a
failure) as soon as the deadline passed, so reflect that here too. A
deadline that cut the scan short leaves an incomplete keep-set; transmitting
it would make the receiver --delete the unscanned source mirrors (data
loss), so the late delete manifest is suppressed below. */
scan_stopped_early = scan_stopped_early || stop_condition_reached(&stop);
if (scan_stopped_early) {
if (config->use_delete || config->delete_missing_args)
log_message(LOG_LEVEL_WARNING,
"transfer stopped early (stop deadline); skipping --delete keep-set so "
"unscanned source mirrors are not deleted");
else
log_message(LOG_LEVEL_WARNING, "transfer stopped early (stop deadline)");
} else {
if (had_scan_io && manifest && manifest->size == 0) {
/* A scan that hit an I/O error and produced no keep entries is ambiguous;
an empty keep-set would delete the whole destination. Refuse to delete
(see the early-timing comment above). */
log_message(LOG_LEVEL_ERROR,
"source scan hit an I/O error before finding any file; refusing to delete with "
"an empty keep-set (--delete)");
goto send_fail;
}
if ((manifest || config->delete_missing_args) && !delete_early) {
/* Late (commit) ordering: all file data is out; transmit the manifest so
the receiver commits the extras walk (--delete) and/or the
--delete-missing-args exact-path deletions only after the transfer
succeeds. In the early modes (--delete-before/--delete-during) the
manifest already went out up front, so nothing is re-sent here. */
if (send_delete_manifest(client->file_descriptor, manifest, excluded, missing_args) != 0) {
if (manifest) {
array_list_delete(manifest);
manifest = NULL;
}
goto send_fail;
}
if (manifest) {
array_list_delete(manifest);
manifest = NULL;
}
goto send_fail;
}
if (manifest) {
array_list_delete(manifest);
manifest = NULL;
}
}
bool ok = finalize_transfer(client, config, remove_sources);
@@ -1988,6 +2057,14 @@ int send_files_multithreaded(Config** config_ptr) {
context->missing_args = missing_args;
missing_args = NULL; /* owned by the context from here on */
*config_ptr = NULL; /* context now owns config through all remaining paths */
struct timespec now_mono;
if (clock_gettime(CLOCK_MONOTONIC, &now_mono) != 0) {
now_mono.tv_sec = 0;
now_mono.tv_nsec = 0;
}
context->stop_condition =
stop_condition_make(config->stop_after_mins > 0, config->stop_after_mins, config->stop_at_set,
config->stop_at, now_mono);
bool collect_excluded = config->use_delete && !config->delete_excluded;
if (config->use_delete) {
context->manifest = array_list_create(free);
+11
View File
@@ -487,6 +487,7 @@ DirectoryScanner* directory_scanner_create_with_options(const char* root_directo
scanner->relative_mode = options->relative && options->file_list != NULL;
scanner->hardlinks = options->hardlinks;
scanner->prune_empty_dirs = options->prune_empty_dirs;
scanner->stop_condition = options->stop_condition;
scanner->dirs_root_emitted = false;
scanner->list_index = 0;
scanner->dirs_batch = NULL;
@@ -843,6 +844,12 @@ static Chunk* dirs_flush_batch(DirectoryScanner* scanner) {
static Chunk* directory_scanner_next_dirs(DirectoryScanner* scanner) {
while (scanner->dirs_batch == NULL || scanner->dirs_batch_size <= scanner->chunk_size) {
if (scanner->stop_condition && stop_condition_reached(scanner->stop_condition)) {
Chunk* leftover = dirs_flush_batch(scanner);
if (leftover)
chunk_destroy(leftover);
return NULL;
}
if (!scanner->dirs_batch) {
scanner->dirs_batch = array_list_create(file_destroy);
if (!scanner->dirs_batch) {
@@ -886,6 +893,10 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
unsigned long long chunk_data_size = 0;
while (1) {
if (scanner->stop_condition && stop_condition_reached(scanner->stop_condition)) {
array_list_delete(chunk_data);
return NULL;
}
if (scanner->current_dir == NULL) {
int ret = open_next_directory(scanner);
if (ret == 0)
+8
View File
@@ -7,6 +7,7 @@
#include "hardlink.h"
#include "protocol.h"
#include "queue.h"
#include "stop_condition.h"
#include <dirent.h>
#include <stdbool.h>
#include <stdatomic.h>
@@ -92,6 +93,11 @@ typedef struct {
* read-only here; the parallel scanner passes it unchanged to every worker so
* one table detects every group across all subdirectories. */
HardLinkTable* hardlinks;
/* Phase 6: optional sender stop deadline. When non-NULL the scanner checks
* it at natural loop boundaries and stops emitting chunks once reached
* (without marking the scan as failed), so a busy scan itself stops early.
* Client-only, never serialized to the wire. */
const StopCondition* stop_condition;
} ScannerOptions;
/* Internal per-scanner filter state. FilterNode chains represent the ordered
@@ -165,6 +171,8 @@ typedef struct {
/* --hard-links (-H): shared link-group detection table (see ScannerOptions).
NULL when -H is off. */
HardLinkTable* hardlinks;
/* Phase 6: sender stop deadline (from ScannerOptions). */
const StopCondition* stop_condition;
} DirectoryScanner;
typedef struct {
+7
View File
@@ -190,6 +190,13 @@ void print_usage(void) {
printf(" --timeout <sec> I/O timeout in seconds (default: 30)\n");
printf(" -T <sec> Alias for --timeout\n");
printf(" --contimeout <sec> Connection timeout in seconds (default: 10)\n");
printf(" --stop-after=MINS Stop the transfer after MINS minutes (a positive\n");
printf(" integer); whatever was already transferred is kept\n");
printf(" --stop-at=TIME Stop at an absolute time: HH:MM, HH:MM:SS, or\n");
printf(" now+N[smhd] (a time already in the past stops the\n");
printf(" transfer immediately; client-only). An early stop\n");
printf(" skips the late --delete keep-set so it cannot delete\n");
printf(" source mirrors that were not yet scanned\n");
printf(" --address <ip> Bind the outgoing client socket to this source address\n");
printf(" -4, --ipv4 Force IPv4 for destination resolution\n");
printf(" -6, --ipv6 Force IPv6 for destination resolution\n");
+3
View File
@@ -178,6 +178,9 @@ static void config_set_defaults(Config* config) {
config->use_xattrs = false;
config->fake_super = false;
config->trust_sender = false;
config->stop_after_mins = 0;
config->stop_at = 0;
config->stop_at_set = false;
}
static bool valid_wire_bool(int value) {
+13
View File
@@ -6,6 +6,7 @@
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <time.h>
typedef enum { TRANSPORT_TCP, TRANSPORT_SSH } TransportType;
@@ -457,6 +458,18 @@ typedef struct Config {
* authorized root (see the phase-5 notes in RSYNC_COMPAT.md). Off by
* default; only relaxes validation when explicitly requested. */
bool trust_sender;
// Phase 6: --stop-after / --stop-at
/* Client-only sender-side transfer stop deadlines. --stop-after=MINS stops
* the transfer after a number of elapsed minutes (checked against
* CLOCK_MONOTONIC so clock changes do not skew it); --stop-at=TIME stops at
* an absolute wall-clock time (HH:MM, HH:MM:SS, or now+N[smhd]). At the
* deadline the run stops elegantly at the next chunk/file boundary and the
* completion tail still runs (exit 0). Both are LOCAL to the sending
* process and are NEVER serialized into the config frame. */
int stop_after_mins; /* --stop-after=MINS minutes; 0 when unset */
time_t stop_at; /* --stop-at=... absolute wall-clock deadline */
bool stop_at_set; /* true when --stop-at was given */
} Config;
/* Phase 5 (remote-option wave): 2.13.0 -> 2.14.0.
+1
View File
@@ -31,6 +31,7 @@ PipelineContextSender* pipeline_context_sender_create(Config* config, Queue* que
context->scan_had_io_error = false;
context->remove_source_files = NULL;
context->early_delete = false;
context->scan_stopped_early = false;
context->total_files = 0;
context->progress_bytes = 0;
context->total_bytes = 0;
+11
View File
@@ -10,6 +10,7 @@
#include "protocol.h"
#include "queue.h"
#include "receiver.h"
#include "stop_condition.h"
#include <openssl/ssl.h>
typedef struct {
@@ -56,6 +57,16 @@ typedef struct {
bool sender_done;
atomic_bool cancelled;
ProtocolSession allocation_session;
/* Phase 6: client-only sender stop deadline, computed once before the worker
* threads start and shared read-only by the scanner and the sender thread. */
StopCondition stop_condition;
/* Phase 6: set when the scanner/sender reached the stop deadline before the
* scan (and thus the keep-set manifest) completed naturally. When true the
* completion tail must NOT transmit the partial manifest, or the receiver
* would delete unscanned source mirrors. Written by the sender thread
* before it reads the manifest, so no additional synchronization is needed
* to suppress the manifest. */
bool scan_stopped_early;
} PipelineContextSender;
typedef struct PipelineContextReceiver {
+157
View File
@@ -0,0 +1,157 @@
#include "stop_condition.h"
#include <errno.h>
#include <limits.h>
#include <stdlib.h>
#include <string.h>
/* Parse a strictly positive decimal integer: only ASCII digits, no leading
* whitespace, sign or trailing garbage. */
static bool parse_positive_minutes(const char* value, long* out) {
if (!value || *value == '\0')
return false;
if (*value < '0' || *value > '9')
return false;
long v = 0;
for (const char* p = value; *p != '\0'; p++) {
if (*p < '0' || *p > '9')
return false;
int digit = *p - '0';
if (v > (LONG_MAX - digit) / 10)
return false;
v = v * 10 + digit;
}
if (v <= 0 || v > INT_MAX)
return false;
*out = v;
return true;
}
bool stop_parse_after_minutes(const char* value, int* out_minutes) {
if (!out_minutes)
return false;
long minutes = 0;
if (!parse_positive_minutes(value, &minutes))
return false;
*out_minutes = (int)minutes;
return true;
}
/* Two consecutive ASCII digits -> 0..99. */
static bool parse_two_digits(const char* s, int* out) {
if (s[0] < '0' || s[0] > '9' || s[1] < '0' || s[1] > '9')
return false;
*out = (s[0] - '0') * 10 + (s[1] - '0');
return true;
}
bool stop_parse_at_time(const char* value, time_t now, time_t* out_deadline) {
if (!value || !out_deadline)
return false;
/* now+N[smhd]: N whole units from the current wall clock. */
if (strncmp(value, "now+", 4) == 0) {
const char* p = value + 4;
/* The count must be a bare non-negative digit run: reject leading
whitespace ('now+ 5s') and a leading sign ('now++5s'). */
if (*p < '0' || *p > '9')
return false;
errno = 0;
char* end = NULL;
long amount = strtol(p, &end, 10);
if (errno != 0 || end == p || amount < 0)
return false;
long unit_seconds;
switch (*end) {
case 's':
unit_seconds = 1;
break;
case 'm':
unit_seconds = 60;
break;
case 'h':
unit_seconds = 3600;
break;
case 'd':
unit_seconds = 86400;
break;
default:
return false;
}
if (end[1] != '\0')
return false;
if (amount > LONG_MAX / unit_seconds)
return false;
long long delta = (long long)amount * unit_seconds;
/* Guard against signed overflow of now + delta. */
if ((long long)now > 0 && delta > (long long)LLONG_MAX - (long long)now)
return false;
if ((long long)now < 0 && delta < (long long)LLONG_MIN - (long long)now)
return false;
*out_deadline = now + (time_t)delta;
return true;
}
/* HH:MM or HH:MM:SS on the current local day. */
size_t len = strlen(value);
if (len != 5 && len != 8)
return false;
if (value[2] != ':' || (len == 8 && value[5] != ':'))
return false;
int hh, mm, ss = 0;
if (!parse_two_digits(value, &hh) || !parse_two_digits(value + 3, &mm))
return false;
if (len == 8 && !parse_two_digits(value + 6, &ss))
return false;
if (hh > 23 || mm > 59 || ss > 59)
return false;
struct tm today;
if (!localtime_r(&now, &today))
return false;
today.tm_hour = hh;
today.tm_min = mm;
today.tm_sec = ss;
today.tm_isdst = -1;
time_t deadline = mktime(&today);
if (deadline == (time_t)-1)
return false;
*out_deadline = deadline;
return true;
}
StopCondition stop_condition_make(bool has_after, int after_minutes, bool has_at, time_t at_time,
struct timespec now_mono) {
StopCondition condition;
condition.has_monotonic = false;
condition.monotonic_deadline.tv_sec = 0;
condition.monotonic_deadline.tv_nsec = 0;
condition.has_wall = false;
condition.wall_deadline = 0;
if (has_after && after_minutes > 0) {
condition.has_monotonic = true;
condition.monotonic_deadline.tv_sec = now_mono.tv_sec + (time_t)after_minutes * 60;
condition.monotonic_deadline.tv_nsec = now_mono.tv_nsec;
}
if (has_at) {
condition.has_wall = true;
condition.wall_deadline = at_time;
}
return condition;
}
bool stop_condition_reached(const StopCondition* condition) {
if (!condition)
return false;
if (condition->has_wall && time(NULL) >= condition->wall_deadline)
return true;
if (condition->has_monotonic) {
struct timespec now;
if (clock_gettime(CLOCK_MONOTONIC, &now) != 0)
return false;
if (now.tv_sec > condition->monotonic_deadline.tv_sec ||
(now.tv_sec == condition->monotonic_deadline.tv_sec &&
now.tv_nsec >= condition->monotonic_deadline.tv_nsec))
return true;
}
return false;
}
+46
View File
@@ -0,0 +1,46 @@
#ifndef STOP_CONDITION_H
#define STOP_CONDITION_H
#include <stdbool.h>
#include <time.h>
/* Client-only transfer stop conditions (--stop-after=MINS / --stop-at=TIME).
* Both are local sender-side deadlines: they are never serialized into the
* config frame and never bump PROTOCOL_VERSION. A transfer checks the
* condition at natural chunk/file boundaries and, once reached, stops
* elegantly (everything already sent is finalized normally, exit 0).
*
* A condition combines an optional CLOCK_MONOTONIC instant (the relative
* --stop-after duration, immune to wall-clock changes) with an optional
* wall-clock instant (the absolute --stop-at form). Either one being reached
* ends the transfer. */
typedef struct StopCondition {
bool has_monotonic;
struct timespec monotonic_deadline;
bool has_wall;
time_t wall_deadline;
} StopCondition;
/* Parse --stop-after=MINS: a positive integer count of minutes. Zero,
* negative, empty and non-numeric values are rejected. Returns true when
* accepted and stores the value in *out_minutes. */
bool stop_parse_after_minutes(const char* value, int* out_minutes);
/* Parse --stop-at=TIME. Accepted forms are HH:MM, HH:MM:SS and
* now+N[smhd] (seconds/minutes/hours/days from now). The absolute forms are
* resolved against `now` (local wall clock) and written to *out_deadline; a
* time already in the past yields a deadline <= now ("stop immediately").
* Returns false on any malformed value. */
bool stop_parse_at_time(const char* value, time_t now, time_t* out_deadline);
/* Build the runtime condition at transfer start. after_minutes is the
* relative --stop-after duration (<= 0 disables it); at_time is the absolute
* --stop-at deadline (only consulted when has_at is true); now_mono is the
* CLOCK_MONOTONIC reading at start. */
StopCondition stop_condition_make(bool has_after, int after_minutes, bool has_at, time_t at_time,
struct timespec now_mono);
/* True once either deadline has passed (wall clock first, then monotonic). */
bool stop_condition_reached(const StopCondition* condition);
#endif
+241
View File
@@ -0,0 +1,241 @@
"""--stop-after / --stop-at deadline-stop integration tests.
These cover the client-only sender stop conditions: --stop-after=MINS stops
after N elapsed minutes, --stop-at=HH:MM[:SS] or now+N[smhd] stops at an
absolute (or relative) wall-clock time. A reached deadline ends the transfer
elegantly at the next chunk/file boundary -- whatever was already transferred is
kept, the completion tail still runs, and the exit code is 0 (like rsync's
clean "stopped early" behavior). Malformed values are rejected up front.
"""
import filecmp
import os
import shutil
import time
import pytest
from common import (
TEST_DATA_DIR,
run_client,
clean_dir,
get_dest_received_dir,
verify_transfer,
)
def _make(self_prefix):
source = os.path.join(TEST_DATA_DIR, f"stop_{self_prefix}_src")
dest = os.path.join(TEST_DATA_DIR, f"stop_{self_prefix}_dst")
clean_dir(source)
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
return source, dest
def _received_files(root):
"""All files under `root`, relative paths."""
if not os.path.isdir(root):
return []
return [
os.path.relpath(os.path.join(dirpath, name), root)
for dirpath, _, names in os.walk(root)
for name in names
]
def _seed_source(source):
"""Create a handful of regular and nested files."""
files = {
"small.txt": b"hello world\n",
"medium.txt": b"the quick brown fox jumps over the lazy dog\n" * 400,
"binary.bin": bytes(range(256)) * 100,
"nested/deep.txt": b"deeply nested file\n",
"nested/another.txt": b"another nested file\n" * 40,
}
for rel, content in files.items():
path = os.path.join(source, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(content)
def _seed_many(source, count=40, size=32 * 1024):
"""Create `count` same-size regular files (enough to span several chunks)."""
blob = os.urandom(size)
for i in range(count):
with open(os.path.join(source, f"f{i:04d}.dat"), "wb") as fh:
fh.write(blob)
def _seed_dest_by_transfer(source, dest, port, extra=None):
"""Do a plain full transfer source->dest so dest exactly mirrors source."""
run_client(source, dest, flags=(extra or []), port=port)
def _received_subset_matches(source, received):
"""Every file under `received` exists under `source` with identical bytes."""
if not os.path.isdir(received):
return not _received_files(received)
rels = _received_files(received)
for rel in rels:
src = os.path.join(source, rel)
dst = os.path.join(received, rel)
if not os.path.isfile(src) or not filecmp.cmp(src, dst, shallow=False):
return False
return True
class TestStopAfter:
@pytest.mark.ci
def test_stop_after_within_window(self, shared_server):
"""A --stop-after set well past the run's duration lets it finish fully."""
source, dest = _make("within")
_seed_source(source)
result, _ = run_client(source, dest, flags=["--stop-after=60"],
port=shared_server.port)
assert result.returncode == 0, \
f"--stop-after full run failed: {(result.stderr or result.stdout)[:400]}"
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not mismatches and not missing, \
f"full transfer mismatch: missing={missing} mismatches={mismatches}"
@pytest.mark.ci
def test_stop_after_rejects_nonpositive(self, shared_server):
"""0 and negative minutes are invalid (must be a positive integer)."""
source, dest = _make("reject")
_seed_source(source)
for bad in ("0", "-1"):
result, _ = run_client(source, dest, flags=[f"--stop-after={bad}"],
port=shared_server.port)
assert result.returncode != 0, f"--stop-after={bad} should be rejected"
class TestStopAt:
@pytest.mark.ci
def test_stop_at_past(self, shared_server):
"""A --stop-at already in the past stops the transfer immediately but
cleanly (exit 0, nothing transferred)."""
source, dest = _make("past")
_seed_source(source)
# Use a same-day HH:MM two minutes in the past when that cannot roll
# over into the previous day (which would parse as a FUTURE time today);
# otherwise fall back to now+0s which is deterministically immediate.
lt = time.localtime()
if lt.tm_hour * 60 + lt.tm_min >= 3:
past = time.localtime(time.time() - 120)
stop_value = f"{past.tm_hour:02d}:{past.tm_min:02d}"
else:
stop_value = "now+0s"
result, _ = run_client(source, dest, flags=[f"--stop-at={stop_value}"],
port=shared_server.port)
assert result.returncode == 0, \
f"--stop-at past run failed (rc {result.returncode}): " \
f"{(result.stderr or result.stdout)[:400]}"
received = get_dest_received_dir(dest, source)
assert _received_files(received) == [], \
f"expected nothing transferred, got {_received_files(received)}"
@pytest.mark.ci
def test_stop_at_now_plus_stops_immediately(self, shared_server):
"""now+0s resolves to the current instant, so the transfer stops at once."""
source, dest = _make("nowplus")
_seed_source(source)
result, _ = run_client(source, dest, flags=["--stop-at=now+0s"],
port=shared_server.port)
assert result.returncode == 0, \
f"--stop-at=now+0s should stop cleanly: " \
f"{(result.stderr or result.stdout)[:400]}"
received = get_dest_received_dir(dest, source)
assert _received_files(received) == [], \
f"expected nothing transferred, got {_received_files(received)}"
@pytest.mark.ci
def test_stop_rejects_garbage(self, shared_server):
"""Malformed --stop-at/--stop-after values are rejected up front."""
source, dest = _make("garbage")
_seed_source(source)
for flag in ("--stop-after=abc", "--stop-at=12:99", "--stop-at=12",
"--stop-at=now+5x", "--stop-at=now-5s"):
result, _ = run_client(source, dest, flags=[flag],
port=shared_server.port)
assert result.returncode != 0, f"{flag} should be rejected"
class TestStopPartial:
"""A genuine mid-transfer stop leaves a valid, strict non-empty prefix."""
@pytest.mark.ci
def test_stop_mid_transfer_leaves_valid_partial(self, shared_server):
"""With --bwlimit a real deadline cuts the transfer mid-way: what WAS
transferred is byte-identical, not everything is transferred, and the
run returns 0 without corrupting any file."""
source, dest = _make("partial")
_seed_many(source, count=60, size=32 * 1024)
flags = ["--chunk-size", "262144", "--bwlimit", "100", "--stop-at=now+3s"]
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, \
f"mid-transfer stop failed (rc {result.returncode}): " \
f"{(result.stderr or result.stdout)[:400]}"
received = get_dest_received_dir(dest, source)
got = _received_files(received)
assert len(got) > 0, "expected an early stop to still transfer a prefix"
assert len(got) < 60, \
f"expected a PARTIAL transfer (all 60 arrived): stopped too late"
assert _received_subset_matches(source, received), \
f"received files are not a byte-identical subset of the source"
class TestStopDelete:
"""--delete must never wipe the destination when the scan is cut short."""
def _seed(self, prefix, port, many=False):
source, dest = _make(prefix)
if many:
_seed_many(source, count=40, size=96 * 1024)
else:
_seed_source(source)
_seed_dest_by_transfer(source, dest, port)
return source, dest
@pytest.mark.ci
def test_stop_delete_immediate_preserves_source_mirrors(self, shared_server):
"""Immediate stop + --delete: the incomplete/empty keep-set must NOT
delete the seeded source mirrors (returncode 0, files survive)."""
source, dest = self._seed("del_imm", shared_server.port)
result, _ = run_client(source, dest, flags=["--delete", "--stop-at=now+0s"],
port=shared_server.port)
assert result.returncode == 0, \
f"--delete immediate stop failed: {(result.stderr or result.stdout)[:400]}"
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not mismatches and not missing, \
f"--delete wiped source mirrors: missing={missing} mismatches={mismatches}"
@pytest.mark.ci
def test_stop_delete_midscan_preserves_source_mirrors(self, shared_server):
"""A mid-scan stop + --delete must suppress the partial keep-set so all
seeded source mirrors survive."""
source, dest = self._seed("del_mid", shared_server.port, many=True)
flags = ["--delete", "--chunk-size", "262144", "--bwlimit", "300", "--stop-at=now+3s"]
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, \
f"--delete mid-scan stop failed: {(result.stderr or result.stdout)[:400]}"
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not mismatches and not missing, \
f"--delete mid-scan wiped source mirrors: missing={missing} mismatches={mismatches}"
@pytest.mark.ci
def test_stop_delete_multithreaded_preserves_source_mirrors(self, shared_server):
"""-m immediate stop + --delete: the completion tail must not read the
still-appendable manifest (no race) and must not delete the mirrors."""
source, dest = self._seed("del_mt", shared_server.port, many=True)
result, _ = run_client(source, dest, flags=["-m", "--delete", "--stop-at=now+0s"],
port=shared_server.port)
assert result.returncode == 0, \
f"-m --delete immediate stop failed: {(result.stderr or result.stdout)[:400]}"
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not mismatches and not missing, \
f"-m --delete wiped source mirrors: missing={missing} mismatches={mismatches}"
+2
View File
@@ -28,6 +28,7 @@
#include "test_server_cli.h"
#include "test_shared_utils.h"
#include "test_stress.h"
#include "test_stop.h"
#include "test_transport_tcp.h"
#include "test_transport_ssh.h"
#include "test_transport_tls.h"
@@ -69,6 +70,7 @@ int main() {
RUN_TEST(test_log);
RUN_TEST(test_robustness);
RUN_TEST(test_stress);
RUN_TEST(test_stop);
RUN_TEST(test_property);
RUN_TEST(test_transport_tcp);
RUN_TEST(test_transport_ssh);
+150
View File
@@ -0,0 +1,150 @@
#include "test_stop.h"
#include "stop_condition.h"
#include "test_utils.h"
#include <limits.h>
#include <time.h>
static void test_stop_after_parse_valid() {
int minutes = 0;
EXPECT_TRUE(stop_parse_after_minutes("5", &minutes));
EXPECT_EQ_INT(minutes, 5);
EXPECT_TRUE(stop_parse_after_minutes("1", &minutes));
EXPECT_EQ_INT(minutes, 1);
EXPECT_TRUE(stop_parse_after_minutes("1440", &minutes));
EXPECT_EQ_INT(minutes, 1440);
EXPECT_TRUE(stop_parse_after_minutes("2147483647", &minutes));
EXPECT_EQ_INT(minutes, INT_MAX);
}
static void test_stop_after_parse_invalid() {
int minutes = 0;
EXPECT_FALSE(stop_parse_after_minutes("0", &minutes));
EXPECT_FALSE(stop_parse_after_minutes("-1", &minutes));
EXPECT_FALSE(stop_parse_after_minutes("abc", &minutes));
EXPECT_FALSE(stop_parse_after_minutes("", &minutes));
EXPECT_FALSE(stop_parse_after_minutes("5x", &minutes));
EXPECT_FALSE(stop_parse_after_minutes("1.5", &minutes));
EXPECT_FALSE(stop_parse_after_minutes(" 5", &minutes));
EXPECT_FALSE(stop_parse_after_minutes(" 5 ", &minutes));
EXPECT_FALSE(stop_parse_after_minutes("+5", &minutes));
EXPECT_FALSE(stop_parse_after_minutes("2147483648", &minutes));
EXPECT_FALSE(stop_parse_after_minutes(NULL, &minutes));
}
static void test_stop_at_parse_hhmm() {
time_t now = 1700000000;
time_t deadline = 0;
EXPECT_TRUE(stop_parse_at_time("12:30", now, &deadline));
struct tm t;
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_hour, 12);
EXPECT_EQ_INT(t.tm_min, 30);
EXPECT_EQ_INT(t.tm_sec, 0);
EXPECT_TRUE(stop_parse_at_time("12:30:59", now, &deadline));
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_hour, 12);
EXPECT_EQ_INT(t.tm_min, 30);
EXPECT_EQ_INT(t.tm_sec, 59);
EXPECT_TRUE(stop_parse_at_time("00:00", now, &deadline));
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_hour, 0);
EXPECT_EQ_INT(t.tm_min, 0);
EXPECT_EQ_INT(t.tm_sec, 0);
}
static void test_stop_at_parse_now_plus() {
time_t now = 1700000000;
time_t deadline = 0;
EXPECT_TRUE(stop_parse_at_time("now+90s", now, &deadline));
EXPECT_EQ_INT(deadline, now + 90);
EXPECT_TRUE(stop_parse_at_time("now+5m", now, &deadline));
EXPECT_EQ_INT(deadline, now + 300);
EXPECT_TRUE(stop_parse_at_time("now+2h", now, &deadline));
EXPECT_EQ_INT(deadline, now + 7200);
EXPECT_TRUE(stop_parse_at_time("now+1d", now, &deadline));
EXPECT_EQ_INT(deadline, now + 86400);
EXPECT_TRUE(stop_parse_at_time("now+0s", now, &deadline));
EXPECT_EQ_INT(deadline, now);
}
static void test_stop_at_parse_invalid() {
time_t now = 1700000000;
time_t deadline = 0;
EXPECT_FALSE(stop_parse_at_time("12", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12:3", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("1234", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12:30:5", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12:30:5x", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("24:00", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12:60", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12:30:61", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12;00", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("now", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("now+", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("now+5", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("now+5x", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("now-5m", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("now+1w", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("now+ 5s", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("now++5s", now, &deadline));
/* Signed overflow of the destination deadline must be rejected, not wrap. */
EXPECT_FALSE(stop_parse_at_time("now+9223372036854775807s", now, &deadline));
/* 10^15 days is well beyond LONG_MAX/86400, so the amount itself is rejected. */
EXPECT_FALSE(stop_parse_at_time("now+1000000000000000d", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("abc", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("", now, &deadline));
EXPECT_FALSE(stop_parse_at_time(NULL, now, &deadline));
}
static void test_stop_deadline_latency() {
struct timespec now;
EXPECT_EQ_INT(clock_gettime(CLOCK_MONOTONIC, &now), 0);
StopCondition future = stop_condition_make(true, 60, false, 0, now);
EXPECT_TRUE(future.has_monotonic);
EXPECT_EQ_INT(future.monotonic_deadline.tv_sec, now.tv_sec + 3600);
EXPECT_EQ_INT(future.monotonic_deadline.tv_nsec, now.tv_nsec);
EXPECT_FALSE(future.has_wall);
EXPECT_FALSE(stop_condition_reached(&future));
/* Move the 60-minute deadline into the past: the check now reports reached. */
StopCondition past = stop_condition_make(true, 60, false, 0, now);
past.monotonic_deadline.tv_sec -= 7200;
EXPECT_TRUE(stop_condition_reached(&past));
StopCondition no_after = stop_condition_make(false, 0, false, 0, now);
EXPECT_FALSE(no_after.has_monotonic);
EXPECT_FALSE(no_after.has_wall);
EXPECT_FALSE(stop_condition_reached(&no_after));
/* An invalid (non-positive) after_minutes never arms the monotonic half. */
StopCondition zero_after = stop_condition_make(true, 0, false, 0, now);
EXPECT_FALSE(zero_after.has_monotonic);
StopCondition neg_after = stop_condition_make(true, -5, false, 0, now);
EXPECT_FALSE(neg_after.has_monotonic);
/* --stop-at: a wall-clock deadline in the past/now is reached; one in the
future is not, and it stays independent of the monotonic half. */
StopCondition wall_future = stop_condition_make(false, 0, true, time(NULL) + 3600, now);
EXPECT_TRUE(wall_future.has_wall);
EXPECT_FALSE(wall_future.has_monotonic);
EXPECT_FALSE(stop_condition_reached(&wall_future));
StopCondition wall_past = stop_condition_make(false, 0, true, time(NULL) - 1, now);
EXPECT_TRUE(stop_condition_reached(&wall_past));
EXPECT_FALSE(stop_condition_reached(NULL));
}
void test_stop(void) {
test_stop_after_parse_valid();
test_stop_after_parse_invalid();
test_stop_at_parse_hhmm();
test_stop_at_parse_now_plus();
test_stop_at_parse_invalid();
test_stop_deadline_latency();
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef TEST_STOP_H
#define TEST_STOP_H
void test_stop(void);
#endif