]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
runtests: add --rsync-bin2 / --expect-result for version-mixing tests
authorAndrew Tridgell <andrew@tridgell.net>
Sun, 31 May 2026 11:00:51 +0000 (21:00 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Mon, 1 Jun 2026 09:21:35 +0000 (19:21 +1000)
Let the suite run with two rsync binaries so the current build can be
tested against the actual old code of a previous release, rather than
only forcing the current binary to speak an old protocol (check29/30).

  --rsync-bin2 PATH  exports RSYNC_PEER, the binary used for the SERVER
                     side of two-sided transfers (the daemon process and
                     the remote-shell --rsync-path target). Defaults to
                     RSYNC, so single-binary runs are byte-for-byte
                     unchanged.
  --expect-result F  the manifest's listed tests ARE the run set; each
                     test's actual outcome (pass/skip/fail/xfail) is
                     compared to its expected one and any mismatch --
                     including an unexpected pass (xpass) -- fails the
                     run. --expect-skipped and the default exit logic
                     are untouched.

rsyncfns gains the RSYNC_PEER global and launches the daemon with it
(start_rsyncd / start_test_daemon, the latter with an optional rsync_cmd
override used by the reverse-direction test); the remote-shell tests
pass --rsync-path={RSYNC_PEER}. All no-ops when no peer is selected.

Direction is fixed: the current binary always drives (only it
understands the new test scripts); the old binary is only ever the
server/daemon side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
runtests.py
testsuite/00-hello_test.py
testsuite/alt-dest_test.py
testsuite/daemon_test.py
testsuite/exclude_test.py
testsuite/files-from_test.py
testsuite/hardlinks_test.py
testsuite/rsyncfns.py
testsuite/ssh-basic_test.py
testsuite/symlink-dirlink-basis_test.py

index 1bdf43988833c077cbf63eef4794b09935129ba8..dc19d87835777f9f39a03df00ada17b1d60dc79b 100755 (executable)
@@ -60,6 +60,11 @@ def parse_args():
                    help='Per-test timeout in seconds (default: 300)')
     p.add_argument('--rsync-bin', default=None, metavar='PATH',
                    help='Path to rsync binary (default: ./rsync)')
+    p.add_argument('--rsync-bin2', default=None, metavar='PATH',
+                   help='Path to a second ("peer") rsync binary used for the '
+                        'daemon side and remote-shell --rsync-path. Lets the '
+                        'suite mix two rsync versions over the wire. Default: '
+                        'same as --rsync-bin (no version mixing).')
     p.add_argument('--tooldir', default=None, metavar='DIR',
                    help='Tool/build directory (default: cwd)')
     p.add_argument('--srcdir', default=None, metavar='DIR',
@@ -68,6 +73,13 @@ def parse_args():
                    help='Force protocol version (adds --protocol=VER to rsync)')
     p.add_argument('--expect-skipped', default=None, metavar='LIST',
                    help='Comma-separated list of expected-skipped tests')
+    p.add_argument('--expect-result', default=None, metavar='FILE',
+                   help='Path to an expected-outcome manifest (one '
+                        '"<testname> <pass|skip|fail|xfail>" per line). When '
+                        'set, ONLY the tests listed in FILE are run, and each '
+                        "test's actual outcome is compared against its "
+                        'expected one; any mismatch (including an unexpected '
+                        'pass) fails the run. Used for version-mixing CI.')
     p.add_argument('--use-tcp', action='store_true',
                    help='Run daemon tests against a real rsyncd bound to '
                         '127.0.0.1 (non-default). The default is the secure '
@@ -208,6 +220,44 @@ def collect_tests(suitedir, patterns):
     return tests
 
 
+_VALID_OUTCOMES = ('pass', 'skip', 'fail', 'xfail')
+
+
+def parse_expect_result(path):
+    """Parse an expected-outcome manifest into {testbase: outcome}.
+
+    One "<testname> <outcome>" entry per line; '#' comments and blank lines
+    are ignored. outcome is one of pass|skip|fail|xfail. The set of listed
+    tests doubles as the run set (see main()). Exits 2 on a malformed file.
+    """
+    expect = {}
+    with open(path) as f:
+        for lineno, raw in enumerate(f, 1):
+            line = raw.split('#', 1)[0].strip()
+            if not line:
+                continue
+            fields = line.split()
+            if len(fields) != 2 or fields[1] not in _VALID_OUTCOMES:
+                sys.stderr.write(
+                    f"{path}:{lineno}: expected '<testname> "
+                    f"<{'|'.join(_VALID_OUTCOMES)}>', got: {raw.rstrip()}\n"
+                )
+                sys.exit(2)
+            expect[fields[0]] = fields[1]
+    return expect
+
+
+def outcome_of(result):
+    """Map a per-test exit code to an outcome string."""
+    if result == 0:
+        return 'pass'
+    if result == 77:
+        return 'skip'
+    if result == 78:
+        return 'xfail'
+    return 'fail'
+
+
 def build_rsync_cmd(rsync_bin, args, scratchbase):
     """Build the RSYNC command string for tests."""
     parts = []
@@ -339,6 +389,12 @@ def main():
     if rsync_bin and not os.path.isabs(rsync_bin):
         rsync_bin = os.path.abspath(rsync_bin)
 
+    # Optional second ("peer") binary for the daemon / remote-shell side, so a
+    # run can mix two rsync versions. Defaults to rsync_bin -> no mixing.
+    rsync_bin2 = args.rsync_bin2 or os.environ.get('rsync_bin2') or rsync_bin
+    if rsync_bin2 and not os.path.isabs(rsync_bin2):
+        rsync_bin2 = os.path.abspath(rsync_bin2)
+
     suitedir = os.path.join(srcdir, 'testsuite')
     scratchbase = os.path.join(os.environ.get('scratchbase', tooldir), 'testtmp')
     os.makedirs(scratchbase, exist_ok=True)
@@ -347,10 +403,14 @@ def main():
     tls_args = get_tls_args(os.path.join(tooldir, 'config.h'))
     setfacl_nodef = find_setfacl_nodef(scratchbase)
     rsync_cmd = build_rsync_cmd(rsync_bin, args, scratchbase)
+    rsync_peer_cmd = build_rsync_cmd(rsync_bin2, args, scratchbase)
 
     if not os.path.isfile(rsync_bin):
         sys.stderr.write(f"rsync_bin {rsync_bin} is not a file\n")
         sys.exit(2)
+    if not os.path.isfile(rsync_bin2):
+        sys.stderr.write(f"rsync_bin2 {rsync_bin2} is not a file\n")
+        sys.exit(2)
     if not os.path.isdir(srcdir):
         sys.stderr.write(f"srcdir {srcdir} is not a directory\n")
         sys.exit(2)
@@ -378,6 +438,8 @@ def main():
     print('=' * 60)
     print(f'{sys.argv[0]} running in {tooldir}')
     print(f'    rsync_bin={rsync_cmd}')
+    if rsync_peer_cmd != rsync_cmd:
+        print(f'    rsync_peer={rsync_peer_cmd}')
     print(f'    srcdir={srcdir}')
     print(f'    TLS_ARGS={tls_args}')
     print(f'    testuser={testuser}')
@@ -407,6 +469,7 @@ def main():
         'TOOLDIR': tooldir,
         'srcdir': srcdir,
         'RSYNC': rsync_cmd,
+        'RSYNC_PEER': rsync_peer_cmd,
         'TLS_ARGS': tls_args,
         'RUNSHFLAGS': '-e',
         'scratchbase': scratchbase,
@@ -443,6 +506,29 @@ def main():
             print(f"Excluding {before - len(tests)} test(s) matching: "
                   f"{', '.join(excl)}")
 
+    # An expected-result manifest defines BOTH the run set (its keys) and the
+    # expected per-test outcome (its values). Used for version-mixing runs.
+    expect = parse_expect_result(args.expect_result) if args.expect_result else None
+    if expect is not None:
+        have = {_testbase(t) for t in tests}
+        unknown = sorted(k for k in expect if k not in have)
+        if unknown:
+            sys.stderr.write(
+                "runtests.py: --expect-result lists test(s) with no matching "
+                f"test file (ignored): {', '.join(unknown)}\n"
+            )
+        tests = [t for t in tests if _testbase(t) in expect]
+        full_run = False
+
+    def _cls(outcome):
+        """Equivalence class for outcome comparison: fail and xfail both just
+        mean 'broke', so a manifest 'fail' matches an actual fail OR xfail."""
+        return 'broken' if outcome in ('fail', 'xfail') else outcome
+
+    def mismatch(testbase, actual):
+        """True if actual outcome disagrees with the manifest expectation."""
+        return expect is not None and _cls(expect[testbase]) != _cls(actual)
+
     # Record test order for consistent skipped-list output
     test_order = {_testbase(t): i for i, t in enumerate(tests)}
 
@@ -450,31 +536,33 @@ def main():
     failed = 0
     skipped = 0
     skipped_list = []
+    outcomes = {}  # testbase -> actual outcome string ('pass'/'skip'/'fail'/'xfail')
 
     def process_result(tr):
-        """Process a TestResult and update counters. Returns True if test failed."""
+        """Process a TestResult and update counters. Returns True if the test
+        should count as a failure for --stop-on-fail purposes."""
         nonlocal passed, failed, skipped
         with _print_lock:
             if tr.output:
                 print(tr.output)
         scratchdir = os.path.join(scratchbase, tr.testbase)
+        oc = outcome_of(tr.result)
+        outcomes[tr.testbase] = oc
         if tr.result == 0:
             passed += 1
-            if not args.preserve_scratch and os.path.isdir(scratchdir):
-                subprocess.run(['rm', '-rf', scratchdir], capture_output=True)
-            return False
         elif tr.result == 77:
             skipped_list.append(tr.testbase)
             skipped += 1
-            if not args.preserve_scratch and os.path.isdir(scratchdir):
-                subprocess.run(['rm', '-rf', scratchdir], capture_output=True)
-            return False
-        elif tr.result == 78:
-            failed += 1
-            return True
         else:
             failed += 1
-            return True
+        if tr.result in (0, 77) and not args.preserve_scratch \
+                and os.path.isdir(scratchdir):
+            subprocess.run(['rm', '-rf', scratchdir], capture_output=True)
+        # With a manifest, only a mismatch is a "failure" (an expected fail is
+        # fine); without one, any non-pass/non-skip result is a failure.
+        if expect is not None:
+            return mismatch(tr.testbase, oc)
+        return tr.result not in (0, 77)
 
     if args.parallel > 1:
         # Parallel execution
@@ -541,6 +629,25 @@ def main():
     if vg_errors > 0:
         print(f'      {vg_errors} valgrind error(s) found (see logs in {scratchbase})')
 
+    if expect is not None:
+        # Version-mixing mode: the run is judged purely on whether each test's
+        # actual outcome matched its manifest expectation. An expected 'fail'
+        # is fine; an UNEXPECTED pass (xpass) or any other divergence is not.
+        mismatches = []
+        for tb in sorted(expect, key=lambda x: test_order.get(x, 1 << 30)):
+            actual = outcomes.get(tb, 'notrun')
+            if actual == 'notrun' or mismatch(tb, actual):
+                mismatches.append((tb, expect[tb], actual))
+        if mismatches:
+            print('----- expected-result mismatches:')
+            for tb, want, got in mismatches:
+                tag = ' (xpass)' if _cls(want) == 'broken' and got == 'pass' else ''
+                print(f'      {tb}: expected {want}, got {got}{tag}')
+        print('-' * 60)
+        exit_code = len(mismatches) + vg_errors
+        print(f'overall result is {exit_code}')
+        sys.exit(exit_code)
+
     skipped_str = ','.join(sorted(skipped_list, key=lambda x: test_order.get(x, 0)))
     if full_run and args.expect_skipped != 'IGNORE':
         print('----- skipped results:')
index 312f35394249bbd6ff9ab19de9928f75c0c159d2..9077ab977fac8fdd2d8aaa017b6e938f7f7020f9 100644 (file)
@@ -8,7 +8,7 @@
 import os
 
 from rsyncfns import (
-    FROMDIR, RSYNC, SRCDIR, TODIR,
+    FROMDIR, RSYNC, RSYNC_PEER, SRCDIR, TODIR,
     checkit, run_rsync, test_fail,
 )
 
@@ -39,7 +39,7 @@ def append_line(line: str) -> None:
 
 def copy_weird(args: list, src_host: str, dst_host: str) -> None:
     checkit(
-        [*args, f'--rsync-path={RSYNC}',
+        [*args, f'--rsync-path={RSYNC_PEER}',
          f'{src_host}{weird_dir}/',
          f'{dst_host}{TODIR / weird_name}'],
         FROMDIR, TODIR,
@@ -70,7 +70,7 @@ print('test6')
 saved = os.getcwd()
 os.chdir(FROMDIR)
 try:
-    run_rsync('-ai', '--old-args', f'--rsync-path={RSYNC}',
+    run_rsync('-ai', '--old-args', f'--rsync-path={RSYNC_PEER}',
               'lh:one two', f'{TODIR}/')
 finally:
     os.chdir(saved)
@@ -91,7 +91,7 @@ from rsyncfns import rsync_argv
 os.chdir(FROMDIR)
 try:
     subprocess.run(
-        rsync_argv('-ai', f'--rsync-path={RSYNC}',
+        rsync_argv('-ai', f'--rsync-path={RSYNC_PEER}',
                    'lh:one two', f'{TODIR}/'),
         env=env, check=True,
     )
index 07389d456efb9a9b05bbda1a76a550111a458d0c..d5bb37b2a0f5f4a6778e31e62c2369d423fc39b7 100644 (file)
@@ -11,7 +11,7 @@ import shutil
 import time
 
 from rsyncfns import (
-    CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
+    CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
     checkit, hands_setup, rmtree, run_rsync, test_fail,
 )
 
@@ -73,7 +73,7 @@ for maybe_inplace in ([], ['--inplace']):
     for srchost in ('', 'localhost:'):
         desthost = 'localhost:' if not srchost else ''
         rmtree(TODIR)
-        checkit(['-ave', SSH, f'--rsync-path={RSYNC}', *maybe_inplace,
+        checkit(['-ave', SSH, f'--rsync-path={RSYNC_PEER}', *maybe_inplace,
                  f'--copy-dest={alt3dir}',
                  f'{srchost}{FROMDIR}/', f'{desthost}{TODIR}/'],
                 FROMDIR, TODIR)
index 4e022fa5c0731676d1d9b6c2eb49701378637f89..8d9ab4b50a7e7a005c7e90ccefddac706266f4ee 100644 (file)
@@ -10,7 +10,7 @@ import os
 import subprocess
 
 from rsyncfns import (
-    CHKFILE, FROMDIR, OUTFILE, RSYNC, SCRATCHDIR, SRCDIR, TODIR,
+    CHKFILE, FROMDIR, OUTFILE, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TODIR,
     build_rsyncd_conf, get_rootuid, get_testuid, makepath,
     rsync_argv, run_rsync, start_test_daemon, test_fail,
 )
@@ -78,7 +78,7 @@ def run_and_check(args, label, capture_stderr=False):
 
 
 # Module list via the lsh.sh stand-in.
-rsync_path = f"{RSYNC}{(' ' + ' '.join(confopt)) if confopt else ''}"
+rsync_path = f"{RSYNC_PEER}{(' ' + ' '.join(confopt)) if confopt else ''}"
 out = run_and_check(
     ['-ve', SSH, f'--rsync-path={rsync_path}', 'localhost::'],
     "module list via lsh.sh",
index d4886bfc65dcc16fd1175acf2077f9e84cd43c01..bd528f618b57d36df177f64ae24f6b0abe2d49ca 100644 (file)
@@ -15,7 +15,7 @@ import subprocess
 import sys
 
 from rsyncfns import (
-    CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
+    CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TMPDIR, TODIR,
     all_plus, allspace, dots,
     checkdiff, checkit, makepath, rsync_argv, run_rsync, test_fail,
 )
@@ -26,7 +26,7 @@ os.environ['CVSIGNORE'] = '*.junk'
 script_name = os.path.basename(sys.argv[0] if sys.argv[0] else __file__)
 if 'lsh' in script_name:
     os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh')
-    rpath = [f'--rsync-path={RSYNC}']
+    rpath = [f'--rsync-path={RSYNC_PEER}']
     host = 'lh:'
 else:
     rpath = []
@@ -116,7 +116,7 @@ excl.write_text(
 # --- main checks ------------------------------------------------------------
 
 # Start with a check of --prune-empty-dirs.
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
           '-f', '-_foo/too/', '-f', '-_foo/down/',
           '-f', '-_foo/and/', '-f', '-_new/',
           f'{host}{FROMDIR}/', f'{CHKDIR}/')
@@ -162,7 +162,7 @@ for f in (CHKDIR / 'bar' / 'down' / 'to' / 'foo').glob('file[235-9]'):
 (up2 / 'dst-newness').touch()
 
 # Un-tweak the directory times in our first (weak) exclude test.
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
           '--existing', '--include=*/', '--exclude=*',
           f'{host}{FROMDIR}/', f'{CHKDIR}/')
 
@@ -181,7 +181,7 @@ for f in (CHKDIR / 'bar' / 'down' / 'to' / 'foo').glob('*.junk'):
 (CHKDIR / 'bar' / 'down' / 'to' / 'home-cvs-exclude').unlink()
 (CHKDIR / 'mid' / 'one-in-one-out').unlink()
 
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
           '--existing', '--filter=exclude,! */',
           f'{host}{FROMDIR}/', f'{CHKDIR}/')
 
@@ -205,7 +205,7 @@ from rsyncfns import cp_touch
 cp_touch(FROMDIR / 'bar' / 'down' / 'to' / 'foo' / 'to',
          CHKDIR / 'bar' / 'down' / 'to' / 'foo')
 
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
           '--existing', '-f', 'show .filt*', '-f', 'hide,! */', '--del',
           f'{host}{FROMDIR}/', f'{TODIR}/')
 
@@ -213,7 +213,7 @@ run_rsync('-av', f'--rsync-path={RSYNC}',
 cp_touch(TODIR / 'bar' / 'down' / 'to' / 'bar' / 'baz' / 'nodel.deep',
          CHKDIR / 'bar' / 'down' / 'to' / 'bar' / 'baz')
 
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
           '--existing', '--filter=-! */',
           f'{host}{FROMDIR}/', f'{CHKDIR}/')
 
@@ -255,7 +255,7 @@ verify_dirs(CHKDIR, TODIR, label="dir-merge + merge-from-stdin")
 (CHKDIR / 'bar' / 'down' / 'to' / 'bar' / '.filt2').unlink()
 (CHKDIR / 'mid' / '.filt').unlink()
 
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
           '--existing', '--include=*/', '--exclude=*',
           f'{host}{FROMDIR}/', f'{CHKDIR}/')
 
@@ -275,15 +275,15 @@ verify_dirs(CHKDIR, TODIR, label="delete-before with merge")
     "+ file3\n*.bak\n"
 )
 
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
           '--del', f'{host}{FROMDIR}/', f'{CHKDIR}/')
 (CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file1.bak').unlink()
 (CHKDIR / 'bar' / 'down' / 'to' / 'foo' / 'file3').unlink()
 (CHKDIR / 'bar' / 'down' / 'to' / 'foo' / '+ file3').unlink()
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
           '--existing', '--filter=-! */',
           f'{host}{FROMDIR}/', f'{CHKDIR}/')
-run_rsync('-av', f'--rsync-path={RSYNC}',
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}',
           '--delete-excluded', '--exclude=*',
           f'{host}{FROMDIR}/', f'{TODIR}/')
 
@@ -293,7 +293,7 @@ checkit(['-avv', *rpath, '-f', 'dir-merge,-_.excl',
 
 # Combine with --relative.
 relative_opts = ['--relative', '--chmod=Du+w', '--copy-unsafe-links']
-run_rsync('-av', f'--rsync-path={RSYNC}', *relative_opts,
+run_rsync('-av', f'--rsync-path={RSYNC_PEER}', *relative_opts,
           f'{host}{FROMDIR}/foo', f'{CHKDIR}/')
 shutil.rmtree(str(CHKDIR) + str(FROMDIR) + '/foo/down', ignore_errors=True)
 run_rsync('-av', *relative_opts, '--existing', '--filter=-! */',
index 855007e5708ce128e79dbc68f42ed6ef8bed4726..0d10d2d52fa74c4ec4c1b18c6c8a27518e1471fa 100644 (file)
@@ -6,7 +6,7 @@
 # files-host / src-host / dest-host placement combinations.
 
 from rsyncfns import (
-    CHKDIR, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TODIR,
+    CHKDIR, FROMDIR, RSYNC, RSYNC_PEER, SCRATCHDIR, SRCDIR, TODIR,
     checkit, hands_setup, rmtree, run_rsync,
 )
 
@@ -44,7 +44,7 @@ for filehost in ('', 'localhost:'):
 
         rmtree(TODIR)
         checkit(
-            ['-avse', SSH, f'--rsync-path={RSYNC}',
+            ['-avse', SSH, f'--rsync-path={RSYNC_PEER}',
              f'--files-from={filehost}{filelist}',
              f'{srchost}{SCRATCHDIR}', f'{desthost}{TODIR}/'],
             CHKDIR, TODIR,
index 9084899d7e84b84d84c965ffeb816c1ebdf32ed4..a578d63ce1a12ed86946d72e19c8b2785c8d4d3a 100644 (file)
@@ -12,7 +12,7 @@ import shutil
 import subprocess
 
 from rsyncfns import (
-    CHKDIR, FROMDIR, OUTFILE, RSYNC, SRCDIR, TODIR,
+    CHKDIR, FROMDIR, OUTFILE, RSYNC, RSYNC_PEER, SRCDIR, TODIR,
     checkit, makepath, rsync_argv, test_fail, test_skipped,
 )
 
@@ -64,7 +64,7 @@ for x in chars:
 os.link(name1, FROMDIR / 'subdir' / 'down' / 'deep' / 'new-file')
 (TODIR / 'text').unlink()
 
-checkit(['-aHivve', SSH, '--debug=HLINK5', f'--rsync-path={RSYNC}',
+checkit(['-aHivve', SSH, '--debug=HLINK5', f'--rsync-path={RSYNC_PEER}',
          f'{FROMDIR}/', f'localhost:{TODIR}/'], FROMDIR, TODIR)
 
 # --link-dest and --copy-dest should also keep hard-linked entries.
index 3a3b37b198df667eae619df94eaafe57165e9068..d4c06606030fd81f69db98dca45838c7334a0998 100644 (file)
@@ -64,6 +64,15 @@ os.umask(0o022)
 os.environ['HOME'] = str(SCRATCHDIR)
 RSYNC = _required('RSYNC')         # full command line, possibly with valgrind/protocol
 
+# The "peer" rsync command -- used for the SERVER side of two-sided transfers
+# (the daemon process; the remote-shell --rsync-path target). The runner sets
+# RSYNC_PEER to a second binary when invoked with --rsync-bin2, letting a run
+# mix two rsync versions over the wire. When no second binary was selected,
+# RSYNC_PEER == RSYNC, so every consumer below behaves exactly as before and
+# single-binary runs are unchanged. Use .get (not _required) so a test invoked
+# by hand without the runner still works.
+RSYNC_PEER = os.environ.get('RSYNC_PEER', RSYNC)
+
 # TLS_ARGS controls how the 'tls' helper formats listings (e.g. --atimes,
 # -l, -L). Tests that exercise non-default rsync features (atimes, etc.)
 # assign to rsyncfns.TLS_ARGS before calling checkit / rsync_ls_lR.
@@ -232,7 +241,7 @@ def _stop_rsyncd(proc) -> 'None':
             pass
 
 
-def start_rsyncd(conf_path, port: int) -> 'subprocess.Popen':
+def start_rsyncd(conf_path, port: int, rsync_cmd: str = None) -> 'subprocess.Popen':
     """Spawn `rsync --daemon --no-detach --address=127.0.0.1 --port=N
     --config=conf` and return the Popen handle after the port is accepting
     connections.
@@ -245,10 +254,16 @@ def start_rsyncd(conf_path, port: int) -> 'subprocess.Popen':
     the test process doesn't strand the daemon either. The caller is expected
     to have already claim_ports()'d `port`.
 
+    rsync_cmd selects the binary to run as the daemon; it defaults to
+    RSYNC_PEER (the peer side of a two-sided run), so ordinary daemon tests
+    get current-client <-> peer-daemon. The reverse-direction test passes
+    rsync_cmd=RSYNC to put the current build on the daemon side and drive with
+    the old client.
+
     This is only ever reached from start_test_daemon() in --use-tcp mode; the
     default (pipe) mode never starts a listening daemon.
     """
-    argv = shlex.split(RSYNC) + [
+    argv = shlex.split(rsync_cmd or RSYNC_PEER) + [
         '--daemon', '--no-detach',
         '--address=127.0.0.1',
         f'--port={port}',
@@ -282,9 +297,12 @@ def start_rsyncd(conf_path, port: int) -> 'subprocess.Popen':
     test_fail(f"rsyncd never listened on 127.0.0.1:{port}: {last_err}")
 
 
-def start_test_daemon(conf_path, port: int) -> str:
+def start_test_daemon(conf_path, port: int, rsync_cmd: str = None) -> str:
     """Bring up the test daemon and return a URL prefix for client commands.
 
+    rsync_cmd selects the daemon-side binary (default RSYNC_PEER); pass
+    rsync_cmd=RSYNC for the reverse-direction test (current daemon, old client).
+
     This is the single seam every daemon test uses. The transport depends on
     the mode the runner selected:
 
@@ -302,11 +320,12 @@ def start_test_daemon(conf_path, port: int) -> str:
     Build URLs as f"{prefix}module/path". `port` is only used (and claimed)
     in --use-tcp mode.
     """
+    daemon_cmd = rsync_cmd or RSYNC_PEER
     if USE_TCP:
         claim_ports(port)
-        start_rsyncd(conf_path, port)
+        start_rsyncd(conf_path, port, daemon_cmd)
         return f'rsync://localhost:{port}/'
-    os.environ['RSYNC_CONNECT_PROG'] = f'{RSYNC} --config={conf_path} --daemon'
+    os.environ['RSYNC_CONNECT_PROG'] = f'{daemon_cmd} --config={conf_path} --daemon'
     return 'rsync://localhost/'
 
 
index dbb9957a5890556b4e3a728aee9e35953286ab85..eb6581184d50f3455466bc4527bfdaa96ed0b93d 100644 (file)
@@ -38,17 +38,17 @@ print(f"Using remote shell: {SSH}")
 hands_setup()
 
 # RSYNC may be a multi-word command line; pass it through --rsync-path.
-from rsyncfns import RSYNC
+from rsyncfns import RSYNC, RSYNC_PEER
 
 
 def _basic():
-    checkit(['-avH', '-e', SSH, f'--rsync-path={RSYNC}',
+    checkit(['-avH', '-e', SSH, f'--rsync-path={RSYNC_PEER}',
              f'{FROMDIR}/', f'localhost:{TODIR}'], FROMDIR, TODIR)
 
 
 def _delete_after_rename():
     shutil.move(str(TODIR / 'text'), str(TODIR / 'ThisShouldGo'))
-    checkit(['--delete', '-avH', '-e', SSH, f'--rsync-path={RSYNC}',
+    checkit(['--delete', '-avH', '-e', SSH, f'--rsync-path={RSYNC_PEER}',
              f'{FROMDIR}/', f'localhost:{TODIR}'], FROMDIR, TODIR)
 
 
index 8c52e13f9cea7d6ed257798f0f5ea3b076a54fa2..cf026815317f890cfa376c217d2bbae93b1ee556 100644 (file)
@@ -18,7 +18,7 @@ import subprocess
 import time
 
 from rsyncfns import (
-    RSYNC, SCRATCHDIR, SRCDIR, TMPDIR,
+    RSYNC, SCRATCHDIR, RSYNC_PEER, SRCDIR, TMPDIR,
     make_data_file, resolve_beneath_supported, rsync_argv, test_fail,
     test_skipped,
 )
@@ -59,7 +59,7 @@ def push(*args, label: str) -> None:
     os.chdir(srcbase)
     try:
         proc = subprocess.run(
-            rsync_argv(f'--rsync-path={RSYNC}', *args),
+            rsync_argv(f'--rsync-path={RSYNC_PEER}', *args),
             stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
         )
         print(proc.stdout, end='')