Skip to content

lib: implement callout_when so TCP timers can fire - #1095

Open
lijunwangs wants to merge 1 commit into
F-Stack:devfrom
lijunwangs:fix/callout-when-tcp-timers
Open

lib: implement callout_when so TCP timers can fire#1095
lijunwangs wants to merge 1 commit into
F-Stack:devfrom
lijunwangs:fix/callout-when-tcp-timers

Conversation

@lijunwangs

@lijunwangs lijunwangs commented Sep 4, 2026

Copy link
Copy Markdown

callout_when() existed only as an empty body in ff_stub_14_extra.c, a file of
link-only stubs for FreeBSD 14 symbols whose defining sources this library does
not compile. kern_timeout.c is one of those: it is replaced by
ff_kern_timeout.c, which reimplements the callwheel but never carried
callout_when across.

That silently disabled every TCP timer. tcp_timer_activate() computes a
deadline into &tp->t_timers[which] by calling this function, then asks
tcp_timer_next() for the earliest pending one. With nothing ever written, the
entries kept their initial SBT_MAX, tcp_timer_next() reported that no timer was
pending, and the arming path fell through to callout_stop(). Every request to
start a retransmit, persist, delayed-ack or keepalive timer stopped it instead.
Because FreeBSD 14 drives all five from one callout per connection, a single
missing function disabled all of them at once.

Implement it in ff_kern_timeout.c, next to the callwheel it feeds, and drop the
stub. The stub file states that its contents are link-only, that reaching one
at runtime means an unsupported path, and that bodies must not be hand-edited
because the file is generated -- so a working implementation cannot live there.
A stub for callout_when must not be regenerated into it.

The implementation follows callout_when() in sys/kern/kern_timeout.c, less two
parts that depend on machinery this library does not build:

  • Upstream anchors a hardclock-driven callout to the last hardclock edge, read
    from per-CPU state maintained by kern_clocksource.c. That file is not
    compiled by this library, so there is nothing to read and sbinuptime() is used
    throughout. The deadline can therefore be up to one tick later than upstream
    would compute, and callouts armed within the same tick are not batched.
  • Upstream derives a precision floor from C_PRELGET(flags) so the scheduler
    can coalesce callouts with overlapping tolerance. This callwheel is
    tick-granular with no sub-tick slack to trade, and the only caller passes
    precision 0, so the caller's value is passed through unchanged.

A second fix is required with it, because the first one exposes it.
callout_reset_sbt_on() divided its sbintime by tick_sbt to index the
tick-based callwheel. That is correct for a duration and wrong for the absolute
deadline tcp_timer_next() passes with C_ABSOLUTE: dividing a deadline yields
uptime-in-ticks, scheduling the callout an uptime into the future, and past
roughly 24 days of uptime at hz=1000 the tick count exceeds INT_MAX and wraps
negative. ff_callout_delay_ticks() subtracts the current uptime when the
deadline is absolute, saturates SBT_MAX to INT_MAX, treats an already-past
deadline as one tick, and rounds up so a callout cannot fire early. Relative
callers keep the previous arithmetic exactly.

Impact before the fix: a connection died on its first lost segment. Nothing
retransmitted it, so snd_una never advanced, the congestion window stayed full
of unacknowledged data, tcp_output() computed len=0 indefinitely, and the send
buffer could never drain -- writes returned EAGAIN for as long as the process
lived. Small responses never exposed this, because a few hundred bytes never
put enough in flight to lose any.

Reproducer, using F-Stack's own bundled nginx:

  1. Build lib/ and app/nginx as usual.

  2. Serve a 1 MB file over plain HTTP with one worker:

    worker_processes 1;
    events { worker_connections 1024; use kqueue; }
    http {
    sendfile off;
    server { listen 80; root <directory containing a 1 MB file>; }
    }

  3. From a peer, drop a small fraction of what the server sends. Any loss
    injector works; this is the narrowest one, scoped to the test port:

    iptables -I INPUT -s -p tcp --sport 80
    -m statistic --mode random --probability 0.03 -j DROP

  4. curl http:///big

The baseline is this tree without the patch. Run the fetch several times
against each build: at 3% loss the outcome is probabilistic, and an unlucky-free
run completes even on the baseline.

Loss is essential to the reproducer. Without it the transfer completes either
way, because no segment is ever lost and no retransmit timer is needed. With
3% loss, five consecutive attempts:

before this patch: one completed, then the rest hung indefinitely and were
killed at a 40 s timeout. The connection is not reset and
no error is reported -- the transfer simply stops, with
snd_una frozen and the send buffer unable to drain.

after this patch: 5/5 completed, 1048576 bytes each, in 0.004 s to 0.50 s.
The spread is recovery: the fast ones lost nothing, the
slow ones lost segments and retransmitted them.

Also verified that retransmit, delayed-ack and keepalive callouts are entered
into the callwheel and fire; before the patch the callwheel stayed empty.

Environment: DPDK 24.11.6, vmxnet3 under VMware bound with uio_pci_generic, one
lcore. Testing was only possible on vmxnet3 in a virtual machine. The change is
in generic timer code rather than anything driver specific, but it has not been
exercised on a physical NIC.

Comment thread lib/ff_stub_14_extra.c Outdated
@lijunwangs
lijunwangs force-pushed the fix/callout-when-tcp-timers branch 5 times, most recently from 9cf6838 to 1060569 Compare September 5, 2026 00:46
callout_when() existed only as an empty body in ff_stub_14_extra.c, a file of
link-only stubs for FreeBSD 14 symbols whose defining sources this library does
not compile. kern_timeout.c is one of those: it is replaced by
ff_kern_timeout.c, which reimplements the callwheel but never carried
callout_when across.

That silently disabled every TCP timer. tcp_timer_activate() computes a
deadline into &tp->t_timers[which] by calling this function, then asks
tcp_timer_next() for the earliest pending one. With nothing ever written, the
entries kept their initial SBT_MAX, tcp_timer_next() reported that no timer was
pending, and the arming path fell through to callout_stop(). Every request to
start a retransmit, persist, delayed-ack or keepalive timer stopped it instead.
Because FreeBSD 14 drives all five from one callout per connection, a single
missing function disabled all of them at once.

Implement it in ff_kern_timeout.c, next to the callwheel it feeds, and drop the
stub. The stub file states that its contents are link-only, that reaching one
at runtime means an unsupported path, and that bodies must not be hand-edited
because the file is generated -- so a working implementation cannot live there.
A stub for callout_when must not be regenerated into it.

The implementation follows callout_when() in sys/kern/kern_timeout.c, less two
parts that depend on machinery this library does not build:

 - Upstream anchors a hardclock-driven callout to the last hardclock edge, read
   from per-CPU state maintained by kern_clocksource.c. That file is not
   compiled by this library, so there is nothing to read and sbinuptime() is used
   throughout. The deadline can therefore be up to one tick later than upstream
   would compute, and callouts armed within the same tick are not batched.
 - Upstream derives a precision floor from C_PRELGET(flags) so the scheduler
   can coalesce callouts with overlapping tolerance. This callwheel is
   tick-granular with no sub-tick slack to trade, and the only caller passes
   precision 0, so the caller's value is passed through unchanged.

A second fix is required with it, because the first one exposes it.
callout_reset_sbt_on() divided its sbintime by tick_sbt to index the
tick-based callwheel. That is correct for a duration and wrong for the absolute
deadline tcp_timer_next() passes with C_ABSOLUTE: dividing a deadline yields
uptime-in-ticks, scheduling the callout an uptime into the future, and past
roughly 24 days of uptime at hz=1000 the tick count exceeds INT_MAX and wraps
negative. ff_callout_delay_ticks() subtracts the current uptime when the
deadline is absolute, saturates SBT_MAX to INT_MAX, treats an already-past
deadline as one tick, and rounds up so a callout cannot fire early. Relative
callers keep the previous arithmetic exactly.

Impact before the fix: a connection died on its first lost segment. Nothing
retransmitted it, so snd_una never advanced, the congestion window stayed full
of unacknowledged data, tcp_output() computed len=0 indefinitely, and the send
buffer could never drain -- writes returned EAGAIN for as long as the process
lived. Small responses never exposed this, because a few hundred bytes never
put enough in flight to lose any.

Reproducer, using F-Stack's own bundled nginx:

 1. Build lib/ and app/nginx as usual.

 2. Serve a 1 MB file over plain HTTP with one worker:

      worker_processes 1;
      events { worker_connections 1024; use kqueue; }
      http {
          sendfile off;
          server { listen 80; root <directory containing a 1 MB file>; }
      }

 3. From a peer, drop a small fraction of what the server sends. Any loss
    injector works; this is the narrowest one, scoped to the test port:

      iptables -I INPUT -s <fstack-ip> -p tcp --sport 80 \
          -m statistic --mode random --probability 0.03 -j DROP

 4. curl http://<fstack-ip>/big

The baseline is this tree without the patch. Run the fetch several times
against each build: at 3% loss the outcome is probabilistic, and an unlucky-free
run completes even on the baseline.

Loss is essential to the reproducer. Without it the transfer completes either
way, because no segment is ever lost and no retransmit timer is needed. With
3% loss, five consecutive attempts:

  before this patch:  one completed, then the rest hung indefinitely and were
                      killed at a 40 s timeout. The connection is not reset and
                      no error is reported -- the transfer simply stops, with
                      snd_una frozen and the send buffer unable to drain.

  after this patch:   5/5 completed, 1048576 bytes each, in 0.004 s to 0.50 s.
                      The spread is recovery: the fast ones lost nothing, the
                      slow ones lost segments and retransmitted them.

Also verified that retransmit, delayed-ack and keepalive callouts are entered
into the callwheel and fire; before the patch the callwheel stayed empty.

Environment: DPDK 24.11.6, vmxnet3 under VMware bound with uio_pci_generic, one
lcore. Testing was only possible on vmxnet3 in a virtual machine. The change is
in generic timer code rather than anything driver specific, but it has not been
exercised on a physical NIC.

Signed-off-by: Lijun Wang <83639177+lijunwangs@users.noreply.github.com>
@lijunwangs
lijunwangs force-pushed the fix/callout-when-tcp-timers branch from 1060569 to cd84c1a Compare September 5, 2026 00:47
@lijunwangs
lijunwangs marked this pull request as ready for review September 5, 2026 00:53
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