fix(cli): reject an unknown --info/--debug item the way upstream does - #7637
Merged
Conversation
oferchen
added a commit
that referenced
this pull request
Sep 3, 2026
`interop (Windows, best-effort)` hung for 25 minutes on PR #7637 and was killed by the workflow, not by any assertion. The job log ends at PASS: baseline upstream local copy and then emits nothing for 25 minutes until the cancel. Every setup step had succeeded, including the MSYS2 install. `wait_for_port` is where it sat, by elimination: the only thing between that PASS and the next output is `start_daemon`, whose sole unbounded operation is this readiness loop. The `deadline` is checked once per iteration, so a probe that never returns defeats it entirely - the loop never gets back to the test. Neither probe was individually bounded. On Windows that is not a corner case, it is the normal path. The function's own comment says `/dev/tcp` is not exposed by Cygwin's bash, so every iteration falls through to the rsync probe; and a SYN to a port nothing is listening on is DROPPED rather than refused when the runner's firewall is in the way, so `connect()` blocks instead of returning ECONNREFUSED. Measured against a real rsync 3.5.0, running the harness's exact invocation against a blackhole address (127.0.0.1 always refuses, so the loopback probe cannot reproduce the case that matters): WITH --contimeout=2: rc=35, 2s WITHOUT: still running at 41s, killed by `timeout` `--contimeout` bounds the connect (options.c:831 declares it; socket.c:422 arms a SIGALRM around it) and `--timeout` bounds everything after, so each iteration now costs at most a couple of seconds and the 15-second deadline means what it says. When the daemon genuinely cannot bind - the documented Windows condition this function exists to detect - the loop returns 1 in ~15s and the caller takes its existing SKIP path instead of hanging. Behavioural contract re-measured on the patched function: dead port rc=1 in 15s, live port rc=0 in 0s. `bash -n` clean; shellcheck finding count unchanged at 1 (a pre-existing SC2086 on an untouched line). ⚠ This is NOT what reddened #7637. That job's 10 real failures are the inherited master red; the Windows cell was CANCELLED, and the cancel came from this hang. The hang is in code that runs the UPSTREAM rsync binary before oc-rsync is invoked at all, so no oc-side diff can cause it.
upstream: options.c:443-490 `parse_output_words()`. Three parser decisions diverged from rsync 3.5.0, each measured against the pinned oracle binary at target/interop/upstream-src/rsync-3.5.0/rsync. - oc trimmed ASCII whitespace off every comma-separated item, so `--debug= flist` was accepted silently. Upstream splits on ',' and skips only zero-length segments (options.c:448-454), then compares the raw segment bytes with strncasecmp (options.c:475), so a padded word is the unknown item `" flist"` and exits RERR_SYNTAX. - oc echoed the raw token in the diagnostic. Upstream strips the trailing level digits before the table lookup (options.c:455-458) and prints `"%.*s"` with that shortened length (options.c:485-486), so `--debug=BOGUS2` is reported as `BOGUS`. - oc validated the whole list before honouring `help`. Upstream calls exit_cleanup(0) from inside the token loop (options.c:465-468), so `--debug=help,bogus` prints the word table and exits 0. The diagnostic text now matches upstream verbatim - `Unknown --debug item: "<tok>"` on stderr with RERR_SYNTAX = 1 (errcode.h:25) - instead of the invented `invalid --debug flag '<tok>': use --debug=help for supported flags`. The server-side `--info` decoder stays permissive, mirroring the `!am_server` half of the guard at options.c:484. Closes audit gaps I14 and G1.
oferchen
force-pushed
the
fix/cli-unknown-output-word
branch
from
September 3, 2026 05:47
9b68278 to
c6b2aff
Compare
oferchen
added a commit
that referenced
this pull request
Sep 3, 2026
…dline is real (#7640) * fix(ci): bound the interop smoke harness's readiness probe `interop (Windows, best-effort)` hung for 25 minutes on PR #7637 and was killed by the workflow, not by any assertion. The job log ends at PASS: baseline upstream local copy and then emits nothing for 25 minutes until the cancel. Every setup step had succeeded, including the MSYS2 install. `wait_for_port` is where it sat, by elimination: the only thing between that PASS and the next output is `start_daemon`, whose sole unbounded operation is this readiness loop. The `deadline` is checked once per iteration, so a probe that never returns defeats it entirely - the loop never gets back to the test. Neither probe was individually bounded. On Windows that is not a corner case, it is the normal path. The function's own comment says `/dev/tcp` is not exposed by Cygwin's bash, so every iteration falls through to the rsync probe; and a SYN to a port nothing is listening on is DROPPED rather than refused when the runner's firewall is in the way, so `connect()` blocks instead of returning ECONNREFUSED. Measured against a real rsync 3.5.0, running the harness's exact invocation against a blackhole address (127.0.0.1 always refuses, so the loopback probe cannot reproduce the case that matters): WITH --contimeout=2: rc=35, 2s WITHOUT: still running at 41s, killed by `timeout` `--contimeout` bounds the connect (options.c:831 declares it; socket.c:422 arms a SIGALRM around it) and `--timeout` bounds everything after, so each iteration now costs at most a couple of seconds and the 15-second deadline means what it says. When the daemon genuinely cannot bind - the documented Windows condition this function exists to detect - the loop returns 1 in ~15s and the caller takes its existing SKIP path instead of hanging. Behavioural contract re-measured on the patched function: dead port rc=1 in 15s, live port rc=0 in 0s. `bash -n` clean; shellcheck finding count unchanged at 1 (a pre-existing SC2086 on an untouched line). ⚠ This is NOT what reddened #7637. That job's 10 real failures are the inherited master red; the Windows cell was CANCELLED, and the cancel came from this hang. The hang is in code that runs the UPSTREAM rsync binary before oc-rsync is invoked at all, so no oc-side diff can cause it. * test(ci): backdate the 1 MiB delta seed so the mutation is always visible D10/F10 seed a 1 MiB file, pull it, mutate it MID-FILE with `dd ... conv=notrunc`, and pull again expecting a delta. The mutation changes neither the file's size nor - at proto 29's whole-second mtime granularity - necessarily its timestamp. Upstream's quick check reads size and mtime and nothing else (generator.c quick_check_ok), so when the seed write and the `dd` land in the same whole second the second pull transfers nothing, the destination keeps the pre-mutation bytes, and the cell's verify_diff fails on a file rsync was correct to skip. That is a wall-clock coin flip, not a behaviour. MEASURED on two CI runs of the same harness on 2026-09-03: master setup finished 06:14:05.309, dd at ~06:14:06.3 -> different seconds -> transfer -> F10.pull PASS (18/18) #7640 setup finished 06:04:18.005, dd at ~06:04:18.96 -> SAME second -> `sent 107 / received 119` -> FAIL (17/18) The two runs differ only in when they were scheduled; the failing job's diff cannot reach this script. Reproduced standalone against real rsync 3.5.0 with `--protocol=29`, same fixture shape, arms differing ONLY by the backdate, 6 runs each: seed stamped now pass=0 fail=6 seed backdated pass=6 fail=0 A fixed past timestamp makes the mutation unambiguously newer however fast the harness runs. Nothing asserts on either seed's mtime - the inode assertions are on f8/d8 - and `-a` carries the backdated stamp to the destination on the first pull, so the quick check still has a matched pair to compare. Verified by running the committed setup scripts themselves, not a hand copy: both payloads come out 1048576 bytes at 2001-09-09T01:46:40. `bash -n` clean; shellcheck reports zero findings on both files.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
--debug=" flist"exited 0 silently where upstream exits 1, and every rejection oc did emit used invented wording.Upstream
One tokenizer serves both options:
parse_output_words(words, levels, str, priority)at options.c:443-490, called fromOPT_INFO(options.c:1877-1880) andOPT_DEBUG(options.c:1882-1885).if (!len) continue;--debug=,,,,,name,. No whitespace trim anywhere.if (!isDigit(str)) while (len && isDigit(str+len-1)) len--;lev = …; if (lev > MAX_OUT_LEVEL || lev < 0) lev = MAX_OUT_LEVEL;options.c:261), never rejectedlen==4 && strncasecmp(str,"help",4)==0output_item_help(); exit_cleanup(0);— exits 0 from inside the loopnone/allstrncasecmp(str, words[j].name, len)withlen == namelenif (len && !words[j].name && !am_server)rprintf(FERROR, "Unknown %s item: \"%.*s\"\n", words[j].help, len, str); exit_cleanup(RERR_SYNTAX);At :484
words[j]is the NULL sentinel whosehelpis the literal"--info"(options.c:301) /"--debug"(options.c:333);lenis the digit-stripped length;RERR_SYNTAXis 1 (errcode.h:25). The!am_serverhalf means the server side stays silent on an unknown word.Measured against the real 3.5.0 binary
Every probed shape that already matched is omitted —
flist,flist2,all0,ALL2,none2,HELP,DeLtAsUm,,,,,,bogus,2flist,99999999999999999999,nobogus,-bogus,stats3, and the--info=twins all agreed on rc and message before this change.--debug= flistUnknown --debug item: " flist"--debug=flistUnknown --debug item: "flist "--info="name2, del1"Unknown --info item: " del"--debug=bogus2Unknown --debug item: "bogus"--debug=help,bogus--debug=bogusUnknown --debug item: "bogus"Four distinct defects: the trim, the un-stripped token in the message,
helpnot short-circuiting, and the invented text.oc's line keeps the repo-wide branding envelope (
oc-rsync error: … (code 1) at debug.rs(179)), which is the established convention for every option error —--chmod=zzzrenders identically. The diagnostic text, stream and exit code are ported; restructuringMessagerepo-wide is out of scope.Word-split trap avoided: each cell passed as one argv word via an array, and the non-empty upstream rc=1 rows prove the flags reached the binary.
Seven parser sites enumerated, three changed
flags/output_words.rs— shared tokenizerTokenFlowshort-circuit, digit-stripped case-preserving baseflags/debug.rsflags/info.rsspec_foralso made case-insensitive per options.c:475)server/run.rs:198-215!am_serverhalfserver/flags.rs:1160-1165--debug=captured, never parsed; matches upstream silencelogging/src/config.rs:201-303let _ =; only sees already-accepted tokensfrontend/info_output.rs:250-300parse_info_flagswith its own.trim()and its own name table. Publicly re-exported, but its only consumer istests/output_parity.rs— off the binary's path. Latent recurrence of this exact defect class if ever wired up.Mutation proof
Filter
test(/flags::tests|info_debug/), test files untouched, one predicate reverted per arm:HelpreturnsContinuepassed + failed == runin every arm. Companions stayed green under M1-M3 (an_unpadded_word_is_still_accepted,a_known_word_with_a_level_suffix_is_still_accepted,an_unknown_item_before_help_still_errors,the_server_side_decoder_still_ignores_an_unknown_item), so "reject everything" cannot satisfy the pins. The CLI companion deliberately avoids--versionas terminator —--versionshort-circuits before--debugis parsed and would make the control vacuous.Gates
Workspace
32627 run: 32622 passed, 5 failed. All 5 reproduce at pristine HEAD with the changed files reverted (5 run: 0 passed, 5 failed) — APFS sparse-block accounting plus one xtask fixture, unrelated.check_rustfmt_all.pyclean (3389 files);clippy -p cli --all-targetssilent on touched files; no--helpsnapshot moved and--debug=helpoutput is byte-identical.Reported, not fixed
--version/--helpprecedence is a separate divergence: upstream is strictly argv-ordered (popt handles each option where it sits), oc'smaybe_print_help_or_versionruns before option validation and wins regardless of position — so--debug=bogus --versionexits 0 on oc, 1 upstream. Fixing it means reordering global preflight against every validated option, with broad snapshot blast radius.Task 775's premise (an oc-invented
no…/-…negation prefix) was measured not to reproduce:--debug=nobogus,-bogus,noflist,--info=nodel,-skipall exit 1 matching upstream, already pinned by pre-existing tests, and no prefix stripping exists at any of the 7 sites.