docs: clarify %b semantics, temper thread-safety claim, harden %M scan; add -m coverage
CI / lint (pull_request) Successful in 20s
CI / sanitizers (undefined) (pull_request) Successful in 41s
CI / sanitizers (address) (pull_request) Successful in 41s
CI / fuzz-build (pull_request) Successful in 16s
CI / coverage (pull_request) Successful in 35s
CI / build-and-test (pull_request) Successful in 1m22s
CI / valgrind (pull_request) Successful in 36s

Address c-review nits on the itemize/output feature:
- RSYNC_COMPAT.md: state that %b is the source length (always == %l) because
  no wire-byte counter exists; keep Summary equal to the matrix (recounted:
  54 implemented / 84 not-implemented, 147 rows total - four rows flipped).
- change_list.h/.c: document bytes_sent == size; note itemize/out-format lines
  never interleave with each other but may interleave with legacy log
  messages sharing the stream; mark the %M stat() path best-effort.
- change_render_format scan in format_uses_mtime now mirrors the tokenizer
  (skips '%%' and unknown '%X' pairs) so a literal '%%M' no longer triggers
  the stat() fallback.
- Integration tests: --list-only under -m; a changed file on a second
  --incremental run emits exactly one '>f' line while unchanged files print
  nothing; --log-file + --log-file-format under -m.
This commit is contained in:
2026-09-06 12:49:49 +02:00
parent 7f5547e900
commit 1b67f5ffcf
4 changed files with 101 additions and 14 deletions
+2 -2
View File
@@ -41,9 +41,9 @@ This document maps rsync's full feature set to FastSync's current implementation
| `-i`, `--itemize-changes` | Per-file change summary | ✅ Implemented | Prints rsync-style `>f+++++++++` lines to stdout only for files actually sent (also under `-m`); unchanged files print nothing, matching single-`-i` behavior | | `-i`, `--itemize-changes` | Per-file change summary | ✅ Implemented | Prints rsync-style `>f+++++++++` lines to stdout only for files actually sent (also under `-m`); unchanged files print nothing, matching single-`-i` behavior |
| `--progress` | Show progress | ✅ Implemented | Progress callback in sender | | `--progress` | Show progress | ✅ Implemented | Progress callback in sender |
| `-P` | Same as --partial --progress | ⚠️ Partial | Parses and enables progress, but interrupted files are not retained for resumable transfers | | `-P` | Same as --partial --progress | ⚠️ Partial | Parses and enables progress, but interrupted files are not retained for resumable transfers |
| `--out-format=FORMAT` | Custom output format | ✅ Implemented | Per-transfer template on stdout; tokens `%f` `%n` `%l` `%b` `%M` `%%`; unknown escapes preserved | | `--out-format=FORMAT` | Custom output format | ✅ Implemented | Per-transfer template on stdout; tokens `%f` `%n` `%l` `%b` `%M` `%%` (`%b` is the source length, always `== %l`; post-compression/delta wire bytes are not counted); unknown escapes preserved |
| `--log-file=FILE` | Log to file | ✅ Implemented | `log_file` config field | | `--log-file=FILE` | Log to file | ✅ Implemented | `log_file` config field |
| `--log-file-format=FMT` | Log format | ✅ Implemented | Requires `--log-file`; writes one template line per transferred file using the same token set as `--out-format` | | `--log-file-format=FMT` | Log format | ✅ Implemented | Requires `--log-file`; writes one template line per transferred file using the same token set as `--out-format` (including `%b` `==` source length) |
| `--8-bit-output`, `-8` | Leave high-bit chars unescaped | ✅ Implemented | Applies to displayed paths and protocol debug output | | `--8-bit-output`, `-8` | Leave high-bit chars unescaped | ✅ Implemented | Applies to displayed paths and protocol debug output |
| `--list-only` | List files instead of copying | ✅ Implemented | `ls -l`-style listing of files that would be transferred; scans the source only, contacts no server, writes nothing; also works with `-n` | | `--list-only` | List files instead of copying | ✅ Implemented | `ls -l`-style listing of files that would be transferred; scans the source only, contacts no server, writes nothing; also works with `-n` |
+18 -4
View File
@@ -266,9 +266,20 @@ void change_emit(const Config* config, const ChangeEvent* event) {
static bool format_uses_mtime(const char* format) { static bool format_uses_mtime(const char* format) {
if (format == NULL) if (format == NULL)
return false; return false;
for (const char* p = format; *p != '\0'; p++) { /* Mirror change_render_format's tokenizer: "%%" is a literal percent (so
if (p[0] == '%' && p[1] == 'M') * "%%M" does NOT expand %M) and unknown "%X" escapes consume both chars.
* This keeps the optional stat() fallback below in step with the renderer. */
for (const char* p = format; *p != '\0';) {
if (*p != '%') {
p++;
continue;
}
char token = p[1];
if (token == '\0')
break;
if (token == 'M')
return true; return true;
p += 2;
} }
return false; return false;
} }
@@ -282,12 +293,15 @@ void change_emit_file_sent(const Config* config, const File* file) {
event.decision = CHANGE_SENT; event.decision = CHANGE_SENT;
event.is_directory = false; event.is_directory = false;
event.size = file->data != NULL ? file->data->size : 0; event.size = file->data != NULL ? file->data->size : 0;
/* FastSync does not currently count post-compression/delta wire bytes, so /* FastSync has no wire-byte counter yet, so %b reports the source length
* the reported value is the source length that had to be delivered. */ * that had to be delivered (always equal to %l); the actual bytes written
* to the socket (compressed/delta) are not measured. */
event.bytes_sent = event.size; event.bytes_sent = event.size;
if (file->metadata != NULL) { if (file->metadata != NULL) {
event.mtime_sec = file->metadata->mtime_sec; event.mtime_sec = file->metadata->mtime_sec;
} else if (format_uses_mtime(config->out_format) || format_uses_mtime(config->log_file_format)) { } else if (format_uses_mtime(config->out_format) || format_uses_mtime(config->log_file_format)) {
/* Best-effort fallback for %M when no metadata was captured (no -M): the
* path is stat()ed just to fill the field, and any failure leaves 0. */
struct stat st; struct stat st;
if (file->path != NULL && stat(file->path, &st) == 0) if (file->path != NULL && stat(file->path, &st) == 0)
event.mtime_sec = st.st_mtime; event.mtime_sec = st.st_mtime;
+14 -8
View File
@@ -14,8 +14,10 @@
* FastSync is a push-style tool: the client sends files from the source tree * FastSync is a push-style tool: the client sends files from the source tree
* to a server that writes them under the destination root. Events are * to a server that writes them under the destination root. Events are
* emitted by whichever code path decides a file's fate (the single-threaded * emitted by whichever code path decides a file's fate (the single-threaded
* send loop and the `-m` sender thread both call the same per-file sender, so * send loop and the `-m` sender thread both call the same per-file sender), so
* only that one thread ever reports events - no cross-thread printing races). * all change events are emitted by exactly one thread and itemize/out-format
* lines never interleave with each other. They may still interleave with
* legacy log messages (log.c) that share the same stdout/log-file stream.
*/ */
typedef enum { typedef enum {
@@ -27,9 +29,12 @@ typedef struct {
const char* path; /* full source path */ const char* path; /* full source path */
ChangeDecision decision; ChangeDecision decision;
bool is_directory; bool is_directory;
unsigned long long size; /* source file length in bytes */ unsigned long long size; /* source file length in bytes */
unsigned long long bytes_sent; /* payload bytes sent (best effort) */ /* The number of bytes reported for a sent file. FastSync has no wire-byte
time_t mtime_sec; /* 0 when unknown */ * counter, so this is always the source length (== size / %l); actual
* post-compression/delta bytes on the wire are not counted. */
unsigned long long bytes_sent;
time_t mtime_sec; /* 0 when unknown */
} ChangeEvent; } ChangeEvent;
/* True when any output mode is active and per-file events matter. */ /* True when any output mode is active and per-file events matter. */
@@ -46,9 +51,10 @@ bool change_list_enabled(const Config* config);
char* change_render_itemize(const ChangeEvent* event); char* change_render_itemize(const ChangeEvent* event);
/* Expand an --out-format/--log-file-format template. Tokens: /* Expand an --out-format/--log-file-format template. Tokens:
* %f full source path %b bytes sent (== %l for a whole file) * %f full source path %b "bytes sent" == the source length (%l);
* %n leaf (base) name %M mtime in whole seconds since the epoch * %n leaf (base) name actual post-compression/delta wire bytes
* %l file length in bytes %% a literal percent sign * %l file length in bytes are not counted
* %M mtime in whole seconds %% a literal percent sign
* Unknown %X sequences are preserved verbatim. Caller frees the result. */ * Unknown %X sequences are preserved verbatim. Caller frees the result. */
char* change_render_format(const char* format, const ChangeEvent* event); char* change_render_format(const char* format, const ChangeEvent* event);
+67
View File
@@ -1051,6 +1051,15 @@ class TestListOnly:
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
assert not os.path.exists(received) assert not os.path.exists(received)
def test_list_only_multithreaded(self):
clean_dir(DEST_DIR)
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--list-only", "-m"])
assert result.returncode == 0, f"list-only -m failed: {result.stderr[:200]}"
for full_path in _source_files():
assert full_path in result.stdout, f"list-only -m omitted {full_path}"
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
assert not os.path.exists(received), "list-only -m wrote to the destination"
class TestItemizeChanges: class TestItemizeChanges:
"""-i/--itemize-changes prints rsync-style lines only for files sent.""" """-i/--itemize-changes prints rsync-style lines only for files sent."""
@@ -1091,6 +1100,38 @@ class TestItemizeChanges:
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-i", "--dry-run"]) result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["-i", "--dry-run"])
assert result.returncode == 0, f"dry-run -i failed: {result.stderr[:200]}" assert result.returncode == 0, f"dry-run -i failed: {result.stderr[:200]}"
def test_changed_file_on_second_incremental_run_prints_exactly_one_line(self, shared_server):
"""A changed file itemizes exactly once on an incremental rerun while
unchanged files print nothing (no double emission)."""
source = os.path.join(TEST_DATA_DIR, "itemize_change_src")
dest = os.path.join(TEST_DATA_DIR, "itemize_change_dst")
clean_dir(source)
clean_dir(dest)
changed = os.path.join(source, "changed.txt")
untouched = os.path.join(source, "untouched.txt")
with open(changed, "wb") as fh:
fh.write(b"original\n")
with open(untouched, "wb") as fh:
fh.write(b"stable\n")
result, _ = run_client(source, dest, flags=["-M"], port=shared_server.port)
assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}"
with open(changed, "wb") as fh:
fh.write(b"edited payload\n")
result, _ = run_client(source, dest,
flags=["-M", "-i", "--incremental"],
port=shared_server.port)
assert result.returncode == 0, f"incremental itemize failed: {result.stderr[:200]}"
itemized = [line for line in result.stdout.splitlines() if line.startswith(">f")]
assert itemized == [">f+++++++++ " + changed], (
f"expected exactly one itemize line for {changed}, got {itemized}"
)
received = get_dest_received_dir(dest, source)
assert _read_file(os.path.join(received, "changed.txt")) == b"edited payload\n"
assert _read_file(os.path.join(received, "untouched.txt")) == b"stable\n"
class TestOutFormat: class TestOutFormat:
"""--out-format prints a line per transferred file using the template.""" """--out-format prints a line per transferred file using the template."""
@@ -1135,3 +1176,29 @@ class TestLogFileFormat:
for line in expected: for line in expected:
assert line in content, f"log file missing {line!r}" assert line in content, f"log file missing {line!r}"
def test_log_file_format_multithreaded_writes_transferred_files(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "itemize_log_mt_src")
dest = os.path.join(TEST_DATA_DIR, "itemize_log_mt_dst")
clean_dir(source)
clean_dir(dest)
files = {"a.txt": b"alpha\n", "b.txt": b"beta\n"}
for rel, data in files.items():
with open(os.path.join(source, rel), "wb") as fh:
fh.write(data)
log_path = os.path.join(TEST_DATA_DIR, "itemize_mt.log")
if os.path.exists(log_path):
os.unlink(log_path)
result, _ = run_client(
source,
dest,
flags=["--log-file", log_path, "--log-file-format=%f %l", "-m"],
port=shared_server.port,
)
assert result.returncode == 0, f"log-file -m sync failed: {result.stderr[:200]}"
assert os.path.exists(log_path), "--log-file created no log"
with open(log_path, encoding="utf-8", errors="replace") as fh:
content = fh.read()
expected = {f"{os.path.join(source, rel)} {len(data)}" for rel, data in files.items()}
for line in expected:
assert line in content, f"log file (-m) missing {line!r}"