@@ -174,7 +174,8 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) {
174174 V (DATAGRAMS_RECEIVED, datagrams_received) \
175175 V (DATAGRAMS_SENT, datagrams_sent) \
176176 V (DATAGRAMS_ACKNOWLEDGED, datagrams_acknowledged) \
177- V (DATAGRAMS_LOST, datagrams_lost)
177+ V (DATAGRAMS_LOST, datagrams_lost) \
178+ V (STREAMS_IDLE_TIMED_OUT, streams_idle_timed_out)
178179
179180#define NO_SIDE_EFFECT true
180181#define SIDE_EFFECT false
@@ -617,7 +618,8 @@ Maybe<Session::Options> Session::Options::From(Environment* env,
617618 !SET (keep_alive_timeout) || !SET (max_stream_window) || !SET (max_window) ||
618619 !SET (max_payload_size) || !SET (unacknowledged_packet_threshold) ||
619620 !SET (cc_algorithm) || !SET (draining_period_multiplier) ||
620- !SET (max_datagram_send_attempts)) {
621+ !SET (max_datagram_send_attempts) ||
622+ !SET (stream_idle_timeout)) {
621623 return Nothing<Options>();
622624 }
623625
@@ -2811,24 +2813,36 @@ void Session::ShutdownStream(stream_id id, QuicError error) {
28112813 DCHECK (!is_destroyed ());
28122814 Debug (this , " Shutting down stream %" PRIi64 " with error %s" , id, error);
28132815 SendPendingDataScope send_scope (this );
2814- ngtcp2_conn_shutdown_stream (*this ,
2815- 0 ,
2816- id,
2817- error.type () == QuicError::Type::APPLICATION
2818- ? error.code ()
2819- : application ().GetNoErrorCode ());
2816+ // STOP_SENDING and RESET_STREAM frames carry application-level error
2817+ // codes (RFC 9000 §19.4, §19.5). Map the QuicError to an appropriate
2818+ // application code: APPLICATION errors pass through directly; transport
2819+ // no-error maps to the application's no-error code; any other error
2820+ // maps to the application's internal error code.
2821+ error_code code;
2822+ if (error.type () == QuicError::Type::APPLICATION) {
2823+ code = error.code ();
2824+ } else if (error.code () == NGTCP2_NO_ERROR) {
2825+ code = application ().GetNoErrorCode ();
2826+ } else {
2827+ code = application ().GetInternalErrorCode ();
2828+ }
2829+ ngtcp2_conn_shutdown_stream (*this , 0 , id, code);
28202830}
28212831
2822- void Session::ShutdownStreamWrite (stream_id id, QuicError code ) {
2832+ void Session::ShutdownStreamWrite (stream_id id, QuicError error ) {
28232833 DCHECK (!is_destroyed ());
2824- Debug (this , " Shutting down stream %" PRIi64 " write with error %s" , id, code);
2834+ Debug (this , " Shutting down stream %" PRIi64 " write with error %s" ,
2835+ id, error);
28252836 SendPendingDataScope send_scope (this );
2826- ngtcp2_conn_shutdown_stream_write (*this ,
2827- 0 ,
2828- id,
2829- code.type () == QuicError::Type::APPLICATION
2830- ? code.code ()
2831- : application ().GetNoErrorCode ());
2837+ error_code code;
2838+ if (error.type () == QuicError::Type::APPLICATION) {
2839+ code = error.code ();
2840+ } else if (error.code () == NGTCP2_NO_ERROR) {
2841+ code = application ().GetNoErrorCode ();
2842+ } else {
2843+ code = application ().GetInternalErrorCode ();
2844+ }
2845+ ngtcp2_conn_shutdown_stream_write (*this , 0 , id, code);
28322846}
28332847
28342848void Session::StreamDataBlocked (stream_id id) {
@@ -3027,6 +3041,41 @@ void Session::UpdateDataStats() {
30273041 std::max (STAT_GET (Stats, max_bytes_in_flight), info.bytes_in_flight ));
30283042}
30293043
3044+ void Session::CheckStreamIdleTimeout (uint64_t now) {
3045+ if (is_destroyed ()) return ;
3046+ uint64_t timeout = options ().stream_idle_timeout ;
3047+ if (timeout == 0 ) return ;
3048+
3049+ uint64_t timeout_ns = timeout * NGTCP2_MILLISECONDS;
3050+ auto all_streams = streams ();
3051+
3052+ for (const auto & [id, stream] : all_streams) {
3053+ if (!stream) continue ;
3054+
3055+ // Only check peer-initiated streams. Locally-initiated streams
3056+ // that haven't been written to are the application's concern.
3057+ if (ngtcp2_conn_is_local_stream (*this , id)) continue ;
3058+
3059+ uint64_t last_activity = stream->last_activity_timestamp ();
3060+ if (last_activity > 0 && (now - last_activity) > timeout_ns) {
3061+ Debug (this ,
3062+ " Stream %" PRId64 " idle timeout exceeded, destroying" ,
3063+ id);
3064+ // Notify the peer before destroying. ShutdownStream sends both
3065+ // STOP_SENDING and RESET_STREAM as appropriate, using the
3066+ // application's no-error code for non-APPLICATION errors (since
3067+ // these frames carry application-level error codes per RFC 9000).
3068+ // Without this, the peer's stream sits orphaned until the
3069+ // session closes.
3070+ auto error = QuicError::ForTransport (NGTCP2_ERR_PROTO,
3071+ " stream idle timeout" );
3072+ ShutdownStream (id, error);
3073+ stream->Destroy (error);
3074+ STAT_INCREMENT (Stats, streams_idle_timed_out);
3075+ }
3076+ }
3077+ }
3078+
30303079void Session::SendConnectionClose () {
30313080 // Method is a non-op if the session is already destroyed or the
30323081 // endpoint cannot send. Note: we intentionally do NOT check
@@ -3111,6 +3160,8 @@ void Session::OnTimeout() {
31113160 if (is_destroyed ()) return ;
31123161 if (NGTCP2_OK (ret) && !is_in_closing_period () && !is_in_draining_period ()) {
31133162 application ().SendPendingData ();
3163+ if (is_destroyed ()) return ;
3164+ CheckStreamIdleTimeout (uv_hrtime ());
31143165 return ;
31153166 }
31163167 if (is_destroyed ()) return ;
@@ -3157,6 +3208,15 @@ void Session::UpdateTimer() {
31573208 auto timeout = (expiry - now) / NGTCP2_MILLISECONDS;
31583209 Debug (this , " Updating timeout to %zu milliseconds" , timeout);
31593210
3211+ // If a stream idle timeout is configured, ensure the timer fires at
3212+ // least that often so CheckStreamIdleTimeout runs. Without this, an
3213+ // idle session with idle streams might not fire the timer until the
3214+ // connection idle timeout, which could be much longer.
3215+ uint64_t stream_idle = options ().stream_idle_timeout ;
3216+ if (stream_idle > 0 && timeout > stream_idle) {
3217+ timeout = stream_idle;
3218+ }
3219+
31603220 // If timeout is zero here, it means our timer is less than a millisecond
31613221 // off from expiry. Let's bump the timer to 1.
31623222 impl_->timer_ .Update (timeout == 0 ? 1 : timeout);
0 commit comments