fix: stop FLIP_Session narrowing its NVFLARE base signature (#1032) - #1034
Conversation
`show_errors`, `show_stats` and `reset_errors` returned HTTP 500 for every call. All three route through NVFLARE's `_collect_info`, one of only two upstream sites that call `_do_command(..., enforce_meta=False)` — and `FLIP_Session` overrode that method as `_do_command(self, cmd)`, dropping the base's `enforce_meta` and `props`. Python dispatches to the subclass, so those three commands raised TypeError before reaching the wire, while every other caller — which passes the command positionally with defaults — kept working. Nothing in the platform called the three, so the fault stayed hidden. The override itself is worth keeping: the base raises `SessionClosed` the moment `api.closed` and never reconnects, so the retry is real added behaviour. It now forwards `*args`/`**kwargs` untouched instead of restating the base signature, which would need re-editing on every upstream bump. An audit of the rest of the subclass found the same defect in `__init__`, which is removed entirely. The base already stores `username`, `startup_path`, `secure_mode`, `_debug` and `_study`; the override only re-stashed three of them under private aliases, while dropping the base's `study` parameter and flipping the `secure_mode` default from True to False. `_reconnect` now reads the base attributes and passes `_study` through — previously every reconnect silently reset the session to the default study, changing which jobs subsequent commands could see. An `_error_buffer` attribute that nothing ever read goes with it. `get_system_info` and `get_connected_client_list` deliberately keep their base names, since the FL API routes serialise their results directly. They do shadow methods NVFLARE calls internally, so the four attributes it reads off the returned models are now documented on the methods and pinned by test rather than left as an implicit duck-typing contract. Tests: a guard pinning every override against its base for dropped parameters and changed defaults (verified to fail on both historical defects), and a regression driving the real show_errors -> _collect_info -> _do_command -> api.do_command chain with only `session.api` faked, so it reproduces the original TypeError from NVFLARE's own code rather than from a mock. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
|
✅ Acceptance criteria have been automatically imported from the linked issue(s) and added to the PR description. |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
✅ Acceptance criteria have been automatically imported from the linked issue(s) and added to the PR description. |
Six corrections, two of them substantive. **The models could not survive the substitution they document.** NVFLARE's `ServerInfo`/`ClientInfo` treat `start_time`/`last_connect_time` as optional — the admin meta is read with a bare `get`, upstream's `__str__` prints "unknown" for a missing one, and `_wait_for_clients_restart` has an explicit `if previous_time is None: continue` branch. FLIP's Pydantic models declared both as required floats, so `get_system_info()` raised ValidationError exactly where the base returns a usable object — reachable from `GET /get_system_info`, `/check_server_status`, `/check_client_status`, `/get_connected_client_list`, and from inside NVFLARE's own restart and client-shutdown waits. Both fields are now optional with None-safe `__str__`, so the four-attribute contract the previous commit wrote down actually holds. **The blast radius in that write-up was wrong.** NVFLARE 2.8.0 passes `enforce_meta=False` from eight sites, not two: `_shell_command_on_target`, `_collect_info`, `report_resources`, `report_version`, `get_job_logs`, `configure_job_log`, `configure_site_log` and `do_app_command`. Four are reachable from this service's routes — the three `_collect_info` commands plus `get_working_directory` via `_shell_command_on_target`, which was equally broken and went unmentioned. The docstring is corrected and the chain test now covers that fourth route. The override also still renamed the base's first parameter (`command` -> `cmd`), so `_do_command(command=...)` remained a TypeError — forwarding `**kwargs` does not repair a renamed positional, and the class docstring's own rule forbids it. Renamed to match. Test fixes, all re-verified by mutation: - the signature guard asserted nothing for `_do_command`: `accepts_var_kw` short-circuited the name check and the default check was gated on presence, so it passed for a renamed positional and for a default flipped behind `**kwargs`. Both mutations now fail with specific messages. - `_overridden_methods` used `callable()`, which is False for `classmethod` and `property` objects, so an override of either kind produced no test case at all. Uses `getattr_static` + `isroutine` now. - the `__init__` check rejected the forwarding style its own docstring permits, with a raw KeyError. - the chain test used bare strings where the routers pass a `TargetType` StrEnum and a comma-split list; both shapes are covered now. Also: `_reconnect` binds the base by keyword rather than five positionals (mirroring NVFLARE's own `_new_poll_session`), so an inserted upstream parameter cannot silently mis-bind; `get_connected_client_list` delegates to the base instead of duplicating its body; and the chain-test spy reaches the base by name rather than `__mro__[1]`. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
|
✅ Acceptance criteria have been automatically imported from the linked issue(s) and added to the PR description. |
|
Heads up @garciadias — scope widened since you were requested, sorry for the moving target. #1035 is now folded in here (was PR #1043, since closed). It is the same class, an adjacent failure, and keeping them stacked meant reading this diff twice. What it adds: restarting an fl-server left the FL API answering 500 to every request permanently, because Verified live on the dev stack, deterministically (stop the server, force a reconnect while it is down, bring it back): wedged 500/500 without the fix, healed 200/200 with it, guard-fired counter 0 → 1. Also note @ pushed Full title and description updated. |
|
@garciadias — converted back to draft, please hold. Apologies for the churn. A review pass on the reconnect work I folded in an hour ago found three regressions introduced by that change, one of which is serious:
Plus eight lower-severity findings (hardcoded 5s ignoring The #1032 signature work is unaffected and still sound. I'll fix these and re-request review. |
83419fa to
9759c26
Compare
|
Reconnect work removed — this PR is back to the signature fix only.
More fundamentally, that work was the wrong shape. NVFLARE has no mechanism for keeping a long-lived session alive across a server restart — Nothing here is affected: this branch is |
|
Ready for review.
|
garciadias
left a comment
There was a problem hiding this comment.
Correct, narrowly-scoped, unusually well self-reviewed fix for the FLIP_Session signature-narrowing defect, with a strong Liskov-substitutability test suite independently verified against upstream NVFLARE 2.8.0 source. Two minor, non-blocking nits left inline (a missing None-safety regression test for the two widened Pydantic fields, and a currently-tautological signature test). Approving.
The models' start_time / last_connect_time were widened to Optional in the review round, but every construction in the suite still passed floats, so re-requiring them left the suite green. Three tests now pin it: the models built with no times print "unknown" (server case compared against NVFLARE's own ServerInfo.__str__), a falsy-but-real 0.0 still prints as a time, and the real get_system_info() survives NVFLARE's SystemInfo with both fields unset. Also states in the __init__ signature test's docstring that, with no override, it resolves to Session.__init__ today and pins the base's study keyword and secure_mode default that _reconnect forwards by keyword. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Closes #1032.
The bug
show_errors,show_statsandreset_errorsonfl-api-basereturned HTTP 500 for every call:Not upstream drift — a narrowed override in our own code. NVFLARE's base declares
_do_command(self, command, enforce_meta=True, props=None);FLIP_Sessionoverrode it as_do_command(self, cmd). Python dispatches to the subclass, so any upstream call passing thosekeywords raises
TypeError.Which routes, exactly. NVFLARE 2.8.0 passes
enforce_meta=Falsefrom eight sites; four arereachable from this service:
_do_commandviaPOST /<job_id>/show_errors/<target_type>_collect_infoPOST /show_stats/<target_type>/<job_id>_collect_infoPOST /reset_errors_collect_infoGET /get_working_directory/<target>_shell_command_on_targetEverything else calls
_do_command(command)positionally with defaults, which the narrowedsignature happens to satisfy. That is why
list_jobs,abort_jobandsubmit_jobwere fine, andwhy nothing noticed: no code in the platform called the four broken ones.
Found while live-verifying #1003, which had started pointing operators at
show_errorsas theNVFLARE manual fallback.
What changed
_do_commandforwards instead of narrowing. The override earns its place — the base raisesSessionClosedthe instantapi.closedand never reconnects, so the retry is genuinely additive —it just takes
*args/**kwargsnow. Forwarding rather than restating the base signature means thenext NVFLARE bump that adds a parameter needs no edit here.
The
__init__override is deleted. An audit of the rest of the subclass found the same defectthere. The base already stores
username,startup_path,secure_mode,_debugand_study; theoverride re-stashed three of them under private aliases while dropping the base's
studyparameter and flipping the
secure_modedefault fromTruetoFalse. It also set an_error_bufferthat nothing in the repo ever reads._reconnectnow reads the base attributes and passes_studythrough. Previously everyreconnect silently reset the session to the default study — quietly changing which jobs subsequent
commands could see, on a code path that only runs after a connection drop.
get_system_info/get_connected_client_listkeep their names. They do shadow methods NVFLAREcalls internally (
_client_last_connect_times,_wait_for_clients_shutdown,_wait_for_clients_restart,restart, and its ownget_connected_client_list) and return Pydanticmodels rather than NVFLARE's types — which works today only because the models happen to expose the
four attributes those callers read. Renaming them is out of scope by agreement, since the FL API
routes serialise these results directly; instead the contract is written down on the methods and
pinned by test, so a future field rename fails in CI rather than inside upstream's restart path.
Tests
Two guards, both verified to fail on the historical code before being kept:
FLIP_Sessionoverride — no dropped base parameter, no changeddefault. Re-introducing the old
_do_commandfails the_do_commandcase; re-introducing the old__init__fails on both the missingstudyand the flippedsecure_mode.session.apiso the call travelsthrough NVFLARE's own
show_errors→_collect_info→_do_command→api.do_command. On theold code it reproduces the production error at the exact upstream line (
flare_api.py:1262),rather than a mock's idea of it.
Plus a pin on the four duck-typed attributes, and one asserting
_reconnectpreserves the study.Verified
make -C fl-services/nvflare/fl-api-base local_test— ruff + mypy clean, 187 passed, 0 failed(was 166; 21 new).
Verified live
Dev stack switched to the NVFLARE backend with
flare-fl-api:devrebuilt from this branch(job
5d27b9bd-…on net-2, while running):All three were a 500 before.
reset_errorswas not caught live (the job finished first) but routesthrough the identical
_collect_infopath asshow_statsand is covered by the chain test.An upstream constraint found while verifying
show_errorsonly works on a running job. Called against one that had already failed:That is upstream by design —
nvflare/private/fed/server/info_coll_cmd.pygates onjob_id in engine.run_processesand returnsJOB_NOT_RUNNINGotherwise, because theInfoCollectorlives inside the job's own process and dies with it.
So this route was never going to be the post-mortem fallback for a failed run, fixed signature or
not. PR #1003 has already repointed its operator hint at the fl-server container output; this
confirms that was correct rather than a workaround.
Separately: the FL API surfaces these semantic refusals as HTTP 500
Internal server error:when a not-running job is really a 409 (and an unknown job a 404). Left alone here — it is a
different concern from the signature bug and deserves its own change.
Out of scope
Renaming
get_system_info/get_connected_client_listso they stop shadowing base methodsaltogether. It is the more thorough fix — that shadowing fails silently where this bug failed
loudly — but it touches the FL API routers and deserves its own change.
Review round
A review pass caught six things, two substantive.
The models could not survive the substitution this PR documents. NVFLARE's
ServerInfo/ClientInfotreatstart_time/last_connect_timeas optional — the admin meta is read with abare
get, upstream's__str__prints "unknown" for a missing one, and_wait_for_clients_restartcarries an explicit
if previous_time is None: continue. FLIP's models declared both as requiredfloats, so
get_system_info()raisedValidationErrorprecisely where the base returns a usableobject — reachable from four FL API routes and from inside NVFLARE's own restart and client-shutdown
waits. Both fields are now optional with None-safe
__str__. Without this, the first commit'sfour-attribute "contract" was documentation of something that did not hold.
The blast radius above was wrong in the first commit — "exactly two call sites", three routes.
It is eight sites and four routes;
get_working_directorywas equally broken and went unmentioned.The grep behind that claim had been truncated. Corrected in the docstring, the issue and here.
Also fixed: the override still renamed the base's first positional (
command→cmd), so_do_command(command=...)remained a TypeError —**kwargsdoes not repair a renamed positional,and the class docstring's own rule forbids it.
Three of the six were defects in the new guards themselves, each re-verified by mutation:
_do_command—accepts_var_kwshort-circuited thename check and the default check was gated on presence, so it passed both for a renamed positional
and for a default flipped behind
**kwargs. Both now fail with specific messages._overridden_methodsfiltered oncallable(), which is False forclassmethodandpropertyobjects, so an override of either kind produced no test case at all.
__init__check rejected the forwarding style its own docstring permits, with a raw KeyError.Plus: the chain test used bare strings where the routers pass a
TargetTypeStrEnum and acomma-split list;
_reconnectnow binds the base by keyword rather than five positionals (mirroringNVFLARE's
_new_poll_session);get_connected_client_listdelegates instead of duplicating the basebody; and the spy reaches the base by name rather than
__mro__[1].Deliberately not fixed here — both pre-existing, both filed as #1035:
_reconnectabandons the oldAdminAPIwithout closing it, leaking a connection and threads perreconnect (
Session._close_ignore_errors()exists upstream for this).session_inactivebranch callstry_connect, which itself raisesSessionClosedwhen the APIis closed — and raised inside an
exceptblock it escapes uncaught rather than falling throughto the sibling handler that would do a full
_reconnect.Neither is a signature problem, and folding connection-lifecycle changes into this diff would blur
what it is for.
Second review round
One comment, on the signature guard itself, and it held up.
A base parameter demoted to keyword-only slipped through. The slot check read
name not in positionalas automatically acceptable, so an override that kept a parameter's name but moved itfrom positional-or-keyword to keyword-only passed — while breaking every positional caller,
which is how NVFLARE calls
_do_commandfrom all eight of its sites. Mutating the real method into(self, *args, command, **kwargs)passes the old guard 3/3 and raisesTypeError: missing 1 required keyword-only argument: 'command'at the first call.The same expression indexed the wrong list.
positional[index]used a base parameter indexagainst the override's positional list, so an override that dropped a middle parameter and kept a
later one positionally (
(self, command, props, **kwargs)) crashed the guard withIndexErrorrather than failing with its message — despite being a real narrowing, since a positional caller
binds
enforce_meta's value intoprops.Positional reachability is now asserted directly: a base parameter of positional kind must not be
keyword-only in the override, and must sit in the base's own slot or fall into the override's
*args(which is what carriesenforce_meta/propsfor the real(self, command, *args, **kwargs)). Name reachability and the default check are unchanged.The rule moved into
_assert_substitutableso it is pinned by test rather than by hand-mutatingFLIP_Session: three substitutable shapes that must pass, five narrowing shapes that must fail(keyword-only demotion, renamed first positional, dropped middle parameter, no slot for the base's
trailing positionals, flipped default). This guard has now shipped with a hole twice — the mutation
check belongs in CI, not in a reviewer's head.
Test-only; no production behaviour moves.
Third review round
Two non-blocking nits, both on the tests.
The widened fields had no regression test. Every model construction in the suite passed concrete
floats, so nothing proved
Noneis accepted or that the "unknown"__str__branch works -- revertingthe fields to required floats left the suite green. Three tests now pin it: the models built with no
times (
Nonethrough, "unknown" printed -- the server case compared against NVFLARE's ownServerInfo.__str__), a falsy-but-real0.0timestamp that must not print as "unknown", and thereal
get_system_info()fed NVFLARE's ownSystemInfoshape with both fields unset. Verified red byre-requiring the fields (2 of 3 fail, the
0.0case passing by design), then green.The
__init__signature test is a tautology today -- with no override, it resolves toSession.__init__itself. Left as the forward guard it was written to be, with the docstring nowsaying so; it is not idle meanwhile, since it pins the base's
studykeyword andsecure_mode=Truedefault that
_reconnectforwards by keyword, so an upstream rename fails here before it failsinside
_reconnect.local_test: ruff + mypy clean, 190 passed.Acceptance Criteria
Imported from issue #1032
FLIP_Session._do_commandis signature-transparent to its base (*args/**kwargsforwarded), so upstream keyword arguments pass through untouched.
show_errors,show_stats,reset_errorsandget_working_directoryreach thetransport — covered by a test driving the real chain down to
api.do_command, in the argumentshapes the routers actually pass (a
TargetTypeStrEnum, and a comma-splittargetslist).__init__is removed, restoring the base'sstudyparameter and itssecure_mode=Truedefault._reconnectreads the base attributes and now also preserves_study, which the old implementation silently reset to the default study on every reconnect.FLIP_Sessionoverride against its base: no dropped parameter andno changed default. Verified to fail on both historical defects before the fix.
get_system_info/get_connected_client_listkeep their names, with the four attributesNVFLARE reads off the returned models (
server_info.status,server_info.start_time,client_info[].name,client_info[].last_connect_time) documented and pinned by test — andstart_time/last_connect_timemade optional, matching NVFLARE's own types, so thesubstitution no longer raises
ValidationErroron theNoneupstream is written to expect.flare-fl-api:devrebuilt from the fix).Against a live net, on a running job:
show_errors/server200 with a real payload,show_errors/client200,show_stats/server200. Before the fix every one was a 500.