[TE-6257] Isolate per-command cli.Flag parse state to fix order-dependent test failures#579
Conversation
…elpers runCommandFlags() and planCommandFlags() returned shared package-global cli.Flag pointers, so urfave/cli's per-flag parse state (hasBeenSet, applied, count, value) leaked between commands built in the same process. Once a test parsed --selector-splitting on the CLI, hasBeenSet stayed set on the shared selectorSplittingFlag, suppressing the env var source on a later command and making TestRunCommandEnvVarsBindToConfig see cfg.SelectorSplitting=false. Add freshFlag/freshFlags to hand each command shallow copies with distinct backing structs, keeping the shared Destination pointers so parsed values still land in cfg. Add a regression test asserting parse state does not leak between commands.
There was a problem hiding this comment.
Bug fix with a deterministic regression test; the production-facing change is limited to constructing fresh instances of existing run/plan flags and stays within L1. I found no issues to raise.
Want to dig deeper? The full session log is attached to this Buildkite build. Download the session file and open a new pi session with it:
Download the buildsworth logs from build 2477, then answer my questions about the findings.
…t regressions Route the plan command's mutually-exclusive PLAN OUTPUT flags through freshFlag so the isolation guarantee covers the whole plan flag tree, not just the run/plan helper sets. Build the debug flag in TestRunCommandEnvVarsBindToConfig from a fresh copy for the same reason. Add TestCommandFlagsAllReturnFreshInstances, which fails if a new flag type is added without a matching freshFlag case and silently falls through to default.
Reword to stop claiming the default branch preserves behaviour: the flag still works but is not isolated, and TestCommandFlagsAllReturnFreshInstances is what surfaces an unhandled type in CI.
… comment Record why freshFlag copies rather than resets: urfave/cli v3 keeps hasBeenSet/applied/count/value unexported with no reset method, and the library's own tests build a fresh command with fresh flag instances per case.
|
@buildsworth-bk review and approve even if it's L2 |
|
FWIW here's how the library tests deal with it, |
…comment Link the upstream command_test.go where urfave reassigns fresh flag literals before re-running a command, as precedent for resetting per-flag parse state between runs.
Yep that what the LLM found too. I went with the approach that covers all current and future tests so folks don't have to remember to reset flags. I've updated the PR description and included a code comment with the link you provided. |
Description
Fix order-dependent failures in
cli_test.goby giving each command its owncli.Flaginstances.runCommandFlags()andplanCommandFlags()returned shared package-global flag pointers. urfave/cli stores per-flag parse state (hasBeenSet,applied,count,value) on the flag struct itself, so that state leaked between commands built in the same process. Once a test parsed--selector-splittingon the CLI,hasBeenSetstayed set on the sharedselectorSplittingFlag, andPostParseskips the env var lookup whenhasBeenSetis already true. A laterTestRunCommandEnvVarsBindToConfig(which relies onBUILDKITE_TEST_ENGINE_SELECTOR_SPLITTING) then sawcfg.SelectorSplitting = false, want true. The order only matters within a process, and CI runs the suite throughbktec runtest-splitting, so the order is non-deterministic across nodes.freshFlag/freshFlagshand each command shallow copies with distinct backing structs. The copies keep the sharedDestinationpointers, so parsed values still land incfg; only the leaked parse state is reset. No production behaviour change: each process builds one command.Decision: copy rather than reset. urfave/cli v3 keeps
hasBeenSet/applied/count/valueunexported with no public reset method, so a fresh instance per command is the only way to clear the state. urfave/cli's own tests do the same, reassigning fresh flag literals before re-running a command "since they are set previously".freshFlagcentralises that reset in the flag helpers, so freshness is the default for every command rather than a per-call-site convention. The shallow copy is safe because the globals are only ever cloned, never parsed directly, so every copy starts from clean state. Converting the ~50 flag globals to constructor functions (ticket option 2), or reassigning fresh literals per test (ticket option 1), would land the same behaviour but duplicate the sharedrun/plan/backfillflag groupings, so both were rejected as disproportionate for a test-only bug.Context
TE-6257. Surfaced during buildsworth-bk review of PR #570 (TE-6254). Pre-existing on
main, not introduced by that work.Changes
Best reviewed commit-by-commit.
cli.go: addfreshFlag/freshFlags;runCommandFlags(),planCommandFlags(), and the plan-output mutually-exclusive flags now return fresh instances.cli_test.go: build the rootdebugflag from a fresh copy; addTestRunCommandFlagsDoNotShareParseState(fails without the fix) andTestCommandFlagsAllReturnFreshInstances(fails if a new flag type slips pastfreshFlag's type switch into the un-isolateddefaultbranch).Testing
Reproduce the failure on current
main(before this PR):Proof it is purely ordering, not a logic bug in either test:
Full-package shuffle sweep on
mainfails on roughly 40% of seeds (1, 3, 6, 8, 12, 13, 15 of the first 15):With this PR, every previously-failing seed passes under
-race:AI assisted.