Skip to content

ioloop: reject zero/negative timedelta in PeriodicCallback constructor#3683

Draft
HrachShah wants to merge 4 commits into
tornadoweb:masterfrom
HrachShah:fix/periodic-callback-timedelta-validation
Draft

ioloop: reject zero/negative timedelta in PeriodicCallback constructor#3683
HrachShah wants to merge 4 commits into
tornadoweb:masterfrom
HrachShah:fix/periodic-callback-timedelta-validation

Conversation

@HrachShah

Copy link
Copy Markdown

What do these changes do?

PeriodicCallback validated that a numeric callback_time was positive, but the
datetime.timedelta branch only did the conversion and skipped the check. As a
result, PeriodicCallback(lambda: None, datetime.timedelta(0)) and
PeriodicCallback(lambda: None, datetime.timedelta(microseconds=-1)) were
accepted, leaving a zero/negative callback_time that the scheduling loop
would either re-schedule immediately or silently break.

Restructure the constructor to always convert to milliseconds first, then
validate the resulting value is positive. The new code handles both branches
through a single callback_time_ms variable.

Are there changes in behavior for the user?

Yes, on the error path only. Pre-fix, malformed timedelta(0) or negative
timedelta values produced a PeriodicCallback whose start() would behave
unexpectedly. Post-fix, the constructor raises ValueError with the same
message as the numeric branch.

No other callers in the tornado tree use these inputs, and the public signature
is unchanged.

Is it a substantial burden for the maintainers to support this?

No. The change is internal to one constructor; the public signature is
unchanged; the new error is a stricter version of the prior behavior.

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Tests pass locally (python3 -m pytest tornado/test/ioloop_test.py -> 56 passed)
  • Pre-fix: new tests fail with AssertionError: ValueError not raised; post-fix: pass
  • Documentation reflects the changes (the new behavior matches the existing
    docstring for the numeric branch)

Zo Bot added 4 commits July 5, 2026 12:06
These two raise statements pass extra positional arguments to
`ValueError` but use only the format string as the first argument,
so the result is a tuple of (format_string, arg) instead of the
formatted message. str(exc) then shows the literal format string
rather than the intended text.

Use string formatting to produce a single formatted message, matching
the pattern used everywhere else in the codebase.

Affected paths:
- web.RequestHandler.xsrf_token: unknown xsrf cookie version %d
- WebSocketProtocol13._process_server_headers: unsupported extension %r
bind_unix_socket raised ValueError('File %s exists and is not a socket', file)
and _HTTPConnection.run raised ValueError('unsupported auth_mode %s', mode).
Both pass a literal format string and the value as separate arguments, so
str(exc) renders the tuple repr with the format string and the value tucked
into e.args[1] rather than producing a usable error message. Replace with
the formatted single-argument form so the offending file path or mode name
is actually visible to a user reading the traceback or a log line.

Two regression tests:
- tornado/test/netutil_test.py::TestBindUnixSocket::
  test_existing_non_socket_file_message_includes_path creates a regular file
  where bind_unix_socket expects a socket, captures the leaked fd from the
  error path, and asserts the path and 'exists and is not a socket' appear
  in str(exc) with a single-arg ValueError.
- tornado/test/httpclient_test.py::HTTPClientCommonTestCase::
  test_unsupported_auth_mode_message_format fetches /auth with auth_mode=
  'asdf' against the simple client and asserts the 'asdf' literal appears in
  the raised ValueError and '%s' does not.
stream_request_body and _has_stream_request_body used the two-argument form

    raise TypeError("expected subclass of RequestHandler, got %r", cls)

which stores the format string and class as a tuple in e.args. str(e)
became the tuple repr

    "('expected subclass of RequestHandler, got %r', <class '...'>)"

with the literal "%r" placeholder visible and the class tucked into
e.args[1]. The user-visible message did not mention RequestHandler or the
class, and any assertRaisesRegex over the formatted string failed.

Format the message before raising so str(e) is a single, formatted string
that names both the expected type and the offending class.

Add StreamRequestBodyTypeErrorTest with two regression guards that assert
the formatted message contains the class name, does not contain the
"%r" placeholder, and that e.args[0] is the same string as str(e). On
pre-fix code both fail with the literal "%r" placeholder visible.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant