Commit c67b4044bc for frr
commit c67b4044bcc7d0e543ef61aa52bb2873ae8a3154
Author: Chirag Shah <chirag@nvidia.com>
Date: Fri Sep 11 09:02:04 2026 -0700
tools: capture vtysh stdout and stderr in frr-reload
Without this change, batch reload (vtysh -f / exec_file) piped both
streams but on failure dropped stdout and put only stderr in
VtyshException. Callers saw a non-zero status plus whatever vtysh
printed on stderr (line N: % Unknown command, connect errors). The
northbound reason on stdout never made it into the exception.
__call__ (vtysh -c) already kept stdout in stdouts, but the exception
string was only status + command, and stderr was not piped at all.
stdout (vty_out, the CLI session) — daemon / northbound rejections:
vtysh -c "config t" -c "no vrf vrf1"
% Configuration failed.
commit failed session-id 24 on Unknown-FD-21 req-id 3
source-ds: candidate target-ds: running validate-only: 0:
reason: 'Failed to create cfgdata: Only inactive VRFs can be deleted'
Both of those lines are stdout. The VRF string originates in zebra
(lib_vrf_destroy), is wrapped as "Failed to create cfgdata: ...", then
printed with vty_out next to "% Configuration failed.". Callers that
match "Only inactive VRFs can be deleted" must read stdout.
stderr (vtysh itself, fprintf) — process / file-mode errors:
Exiting: failed to connect to any daemons.
% Can't open config file ...
line N: % Unknown command: ... (vtysh -f / exec_file only)
Put both streams in stdouts / VtyshException. Drain them with
communicate() so a full pipe cannot deadlock. Decode with
errors="replace": echoed config may not be UTF-8; strict decode would
raise UnicodeDecodeError and hide the vtysh error. Undecodable bytes
become U+FFFD so the error text is still there. Success still returns
stdout only so "show" callers are unchanged.
__call__() (vtysh -c):
Case | Before capture commit | After capture commit | After this replay
------------------------------------------|--------------------------|--------------------------|------------------------------
Success, nothing on stderr | silence | silence | silence
Success + warning on stderr (reconnect) | warning on terminal | warning lost | warning on terminal again
Failure | stdout / exception; | both streams in | same as capture commit
| stderr already on term | the exception |
show running-config return value | stdout only | stdout only | stdout only
exec_file() (vtysh -f) already piped both streams. The capture
commit kept discarding them on success; it only changed the
failure exception to include stdout as well as stderr.
Case | Before capture commit | After capture commit | After this replay
-----------------------------|----------------------------|------------------------------|------------------------------------------
Success, silent | nothing | nothing | nothing
Success + text on stdout/err | discarded | discarded | printed to parent stdout/stderr
Failure | stderr in the exception | stdout + stderr in exception | stdout + stderr in the exception
Ticket: #5233072
Assisted-by: Cursor Grok 4.6
Signed-off-by: Chirag Shah <chirag@nvidia.com>
diff --git a/tools/frr-reload.py b/tools/frr-reload.py
index 0d4dc9dcca..7fcd9fb995 100755
--- a/tools/frr-reload.py
+++ b/tools/frr-reload.py
@@ -69,22 +69,45 @@ class Vtysh(object):
args = ["-c", command]
return self._call(args, stdin, stdout, stderr)
- def __call__(self, command, stdouts=None):
+ def __call__(self, command):
"""
Call a CLI command (e.g. "show running-config")
Output text is automatically redirected, decoded and returned.
Multiple commands may be passed as list.
"""
- proc = self._call_cmd(command, stdout=subprocess.PIPE)
+ # vtysh splits its diagnostics over both streams: it writes its own
+ # errors ("line N: % Unknown command: ...", "Failed to connect to
+ # ...") to stderr, while a rejection coming back from a daemon
+ # ("% Only inactive VRFs can be deleted") is echoed on stdout as part
+ # of the CLI session. A caller that only reads stdout therefore sees
+ # an exit status with no reason for half the failures, so capture
+ # both. communicate() reads the two pipes concurrently; a plain
+ # wait() would hang once either pipe buffer fills.
+ proc = self._call_cmd(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = proc.communicate()
- if proc.wait() != 0:
- if stdouts is not None:
- stdouts.append(stdout.decode("UTF-8"))
+ # errors="replace": vtysh echoes the running config back verbatim and
+ # those bytes are not guaranteed to be UTF-8 (interface descriptions,
+ # route-map and peer-group names are free-form). Strict decoding would
+ # raise UnicodeDecodeError here and hide the vtysh error. errors="replace"
+ # means keep going: turn those bytes into U+FFFD so we still get the
+ # error text.
+ out = (stdout or b"").decode("UTF-8", errors="replace")
+ err = (stderr or b"").decode("UTF-8", errors="replace")
+ combined = out + err
+ if proc.returncode != 0:
raise VtyshException(
- 'vtysh returned status %d for command "%s"' % (proc.returncode, command)
+ 'vtysh returned status %d for command "%s"\n%s'
+ % (proc.returncode, command, combined)
)
- return stdout.decode("UTF-8")
+ # Success returns stdout only: callers parse this as command output
+ # ("show running-config"), and stderr is not part of it. Before we
+ # piped stderr, those diagnostics inherited the parent process; a
+ # successful daemon reconnect still warns there, so replay it.
+ if err:
+ sys.stderr.write(err)
+ sys.stderr.flush()
+ return out
def is_config_available(self):
"""
@@ -102,11 +125,36 @@ class Vtysh(object):
return True
def exec_file(self, filename):
- child = self._call(["-f", filename])
- if child.wait() != 0:
+ child = self._call(
+ ["-f", filename], stdout=subprocess.PIPE, stderr=subprocess.PIPE
+ )
+ stdout, stderr = child.communicate()
+ # errors="replace" as in __call__(): echoed config may not be UTF-8;
+ # keep going and turn those bytes into U+FFFD so we still get the text.
+ out = (stdout or b"").decode("UTF-8", errors="replace")
+ err = (stderr or b"").decode("UTF-8", errors="replace")
+ if child.returncode != 0:
+ # --reload logs exec_file failures at WARNING (logfile only).
+ # After piping, the journal would otherwise miss vtysh's streams.
+ # Replay both to stderr (northbound reasons are stdout of the CLI
+ # session; keep them off frr-reload stdout), then raise so callers
+ # can log the same text.
+ combined = out + err
+ if combined:
+ sys.stderr.write(combined)
+ sys.stderr.flush()
raise VtyshException(
- f"vtysh (exec file) exited with status {child.returncode}"
+ "vtysh (exec file) exited with status %d:\n%s"
+ % (child.returncode, combined)
)
+ # Success: stdout is the CLI session, stderr is vtysh warnings
+ # (reconnect). Piping would otherwise drop both.
+ if out:
+ sys.stdout.write(out)
+ sys.stdout.flush()
+ if err:
+ sys.stderr.write(err)
+ sys.stderr.flush()
def mark_file(self, filename, stdin=None):
child = self._call(
@@ -2361,17 +2409,17 @@ def delete_line_with_vtysh(vtysh, ctx_keys, line):
cmd = lines_to_config(ctx_keys, line, True)
original_cmd = cmd
- stdouts = []
while True:
try:
- vtysh(["configure"] + cmd, stdouts)
+ vtysh(["configure"] + cmd)
- except VtyshException:
+ except VtyshException as e:
# - Pull the last entry from cmd (this would be
# 'no ip ospf authentication message-digest 1.1.1.1' in
# our example above
# - Split that last entry by whitespace and drop the last word
log.error("Failed to execute %s", " ".join(cmd))
+ log.error("%s", e)
last_arg = cmd[-1].split(" ")
if len(last_arg) <= 2:
@@ -2379,9 +2427,6 @@ def delete_line_with_vtysh(vtysh, ctx_keys, line):
'"%s" we failed to remove this command',
" -- ".join(original_cmd),
)
- # Log first error msg for original_cmd
- if stdouts:
- log.error(stdouts[0])
return False
new_last_arg = last_arg[0:-1]
@@ -2804,7 +2849,7 @@ if __name__ == "__main__":
except VtyshException as e:
log.warning(
"batch delete failed, falling back to per-line "
- "delete:\n%s" % (e.args,)
+ "delete:\n%s" % (e,)
)
for ctx_keys, line in vrf_lines_to_del:
if not delete_line_with_vtysh(vtysh, ctx_keys, line):
@@ -2855,7 +2900,7 @@ if __name__ == "__main__":
try:
vtysh.exec_file(filename)
except VtyshException as e:
- log.warning(f"frr-reload.py failed due to\n{e.args}")
+ log.warning(f"frr-reload.py failed due to\n{e}")
reload_ok = False
os.unlink(filename)