diff --git a/src/quic/application.cc b/src/quic/application.cc index ce5d5e12154d8a..438fbdee4fe390 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -760,6 +760,11 @@ class DefaultApplication final : public Session::Application { // during the onstream callback (via MakeCallback re-entrancy). return false; } + + // The stream was created, but was immediately destroyed because there's no onstream handler. + if (stream->is_destroyed()) [[unlikely]] { + return true; + } } else { stream = BaseObjectPtr(Stream::From(stream_user_data)); if (!stream) { diff --git a/src/quic/session.cc b/src/quic/session.cc index 8380e477c01e80..db237bdc00c16b 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -860,11 +860,11 @@ struct Session::Impl final : public MemoryRetainer { } } - endpoint->RemoveSession(config_.scid, remote_address_); - auto& binding = BindingData::Get(env()); if (stats_slot_) GetSessionStatsArena(binding).ReleaseSlot(stats_slot_); if (state_slot_) GetSessionStateArena(binding).ReleaseSlot(state_slot_); + + endpoint->RemoveSession(config_.scid, remote_address_); } void MemoryInfo(MemoryTracker* tracker) const override { diff --git a/src/quic/streams.cc b/src/quic/streams.cc index e838392361f946..d11f1e9f3c9d08 100644 --- a/src/quic/streams.cc +++ b/src/quic/streams.cc @@ -1325,6 +1325,10 @@ bool Stream::is_pending() const { return state()->pending; } +bool Stream::is_destroyed() const { + return stats()->destroyed_at != 0; +} + stream_id Stream::id() const { return state()->id; } diff --git a/src/quic/streams.h b/src/quic/streams.h index 86cb36b2668985..5f2e6cd8aba6e0 100644 --- a/src/quic/streams.h +++ b/src/quic/streams.h @@ -267,6 +267,9 @@ class Stream final : public AsyncWrap, // to be created. bool is_pending() const; + // True if the stream is already destroyed. + bool is_destroyed() const; + // True if we've completely sent all outbound data for this stream. // Importantly, this does not necessarily mean that we are completely // done with the outbound data. We may still be waiting on outbound diff --git a/test/parallel/test-quic-stream-uni-no-onstream.mjs b/test/parallel/test-quic-stream-uni-no-onstream.mjs new file mode 100644 index 00000000000000..6dd3e13bccc43f --- /dev/null +++ b/test/parallel/test-quic-stream-uni-no-onstream.mjs @@ -0,0 +1,47 @@ +// Flags: --experimental-quic --no-warnings +// Test: client-initiated unidirectional stream with no onstream +// The client creates a uni stream with no onstream. +// Verify that this causes no crash. + +import {hasQuic, skip, mustCall} from '../common/index.mjs'; +import {createPrivateKey} from 'node:crypto'; +import * as fixtures from '../common/fixtures.mjs'; + +const {readKey} = fixtures; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const {listen, connect} = await import('../common/quic.mjs'); + +const serverKey = createPrivateKey(readKey('agent1-key.pem')); +const serverCert = readKey('agent1-cert.pem'); + +const done = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall(async (session) => { + await session.opened; + done.resolve(); +}), { + sni: { '*': { keys: [serverKey], certs: [serverCert] } }, + alpn: ['repro'], +}); + +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + alpn: 'repro', + ca: serverCert, +}); + +await clientSession.opened; + +await clientSession.createUnidirectionalStream({ + body: 'something something darkside', +}); + +await done.promise; + +await clientSession.close(); + +await serverEndpoint.close();