feat(p6-batch): residual-batch write/read driver + codec

Implements the client-only residual-batch feature end-to-end:
- src/shared/batch.{c,h}: self-contained single-file batch codec using the
  existing chunk_serialize/chunk_deserialize codec (byte-identical by
  construction).  Magic+format-version header (metadata mode is persisted into
  the header so a batch is self-describing across machines), length-prefixed
  chunk records, bounded reads that reject malformed/truncated/oversized
  records cleanly.
- src/client/client_send.c: write_batch_from_source (deterministic separate
  scan pass, loads every chunk's file images, emits header+records) and
  apply_batch_to_dest (local apply to a destination root via
  file_save_to_disk_full).  No wire change, no server involved.
- src/client/client_validation.c: --write-batch XOR --only-write-batch;
  --read-batch exclusive with both; --read-batch needs only a DEST,
  --only-write-batch only a SOURCE.
- src/client/client_cli.c: main() drives the three batch modes without
  connecting/transferring for read/only-write; --write-batch runs the live
  transfer (single-threaded so the config survives) then emits the batch.
- tests/test_batch.{c,h} (unit: byte-identical roundtrip with and without
  metadata; bad-magic/truncated/oversized rejection) + tests/integration/
  test_batch.py (only-write no-server, read-batch no-source roundtrip,
  --write-batch with a live transfer, conflict rejections).
- clang-format: realign PART-1 config.h comment block.

No PROTOCOL_VERSION bump, no config-frame field, no server flag.
This commit is contained in:
2026-09-10 21:40:07 +02:00
parent 4930127312
commit c026176bb3
11 changed files with 656 additions and 9 deletions
+49 -5
View File
@@ -1611,10 +1611,33 @@ int main(int argc, char* argv[]) {
}
config->save_to_disk = true;
} else if (positional_count == 1) {
log_message(LOG_LEVEL_ERROR, "missing destination argument");
print_usage();
exit_code = 1;
goto cleanup;
if (config->read_batch) {
/* --read-batch=<file> <dest>: the single positional is the destination
(there is no source). */
free(config->receive_root_directory);
config->receive_root_directory = str_dup(argv[positional_args[0]]);
if (!config->receive_root_directory) {
log_message(LOG_LEVEL_ERROR, "memory allocation failed");
exit_code = 1;
goto cleanup;
}
config->save_to_disk = true;
} else if (config->only_write_batch) {
/* --only-write-batch=<file> <source>: the single positional is the
source (there is no destination). */
free(config->send_directory);
config->send_directory = str_dup(argv[positional_args[0]]);
if (!config->send_directory) {
log_message(LOG_LEVEL_ERROR, "memory allocation failed");
exit_code = 1;
goto cleanup;
}
} else {
log_message(LOG_LEVEL_ERROR, "missing destination argument");
print_usage();
exit_code = 1;
goto cleanup;
}
} else {
if (!config->send_directory && env_source) {
config->send_directory = str_dup(env_source);
@@ -1680,8 +1703,29 @@ int main(int argc, char* argv[]) {
tcp_set_timeouts(config->timeout, config->contimeout);
/* Phase 6 residual-batch driver modes. --read-batch / --only-write-batch are
purely local (apply a batch file, or emit one from a scan): neither connects
to nor transfers to a server. --write-batch runs the normal live transfer
AND then emits the batch FILE from a separate deterministic scan pass. It
drives the single-threaded transfer so the config outlives the run for that
second pass (the -m path takes ownership of the config). */
if (config->read_batch) {
exit_code = apply_batch_to_dest(config, config->read_batch, config->receive_root_directory);
goto cleanup;
}
if (config->only_write_batch) {
exit_code = write_batch_from_source(config, config->only_write_batch);
goto cleanup;
}
/* Execute transfer */
if (config->use_multithreading) {
if (config->write_batch) {
exit_code = send_files(config);
if (exit_code == 0 && write_batch_from_source(config, config->write_batch) != 0) {
log_message(LOG_LEVEL_ERROR, "live transfer succeeded but batch emission failed");
exit_code = 1;
}
} else if (config->use_multithreading) {
exit_code = send_files_multithreaded(&config);
} else {
exit_code = send_files(config);