Skip to content

dynamic_modules: persist module-owned filter state objects across stream recreates#45888

Open
basundhara-c wants to merge 2 commits into
envoyproxy:mainfrom
basundhara-c:persist-state-across-recreates
Open

dynamic_modules: persist module-owned filter state objects across stream recreates#45888
basundhara-c wants to merge 2 commits into
envoyproxy:mainfrom
basundhara-c:persist-state-across-recreates

Conversation

@basundhara-c

@basundhara-c basundhara-c commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Commit Message: persist module-owned filter state objects across stream recreates
Additional Description:

This adds a dynamic-modules HTTP-filter ABI that lets a module store an opaque, module-owned, non-serializable object in StreamInfo::FilterState at a lifespan that survives recreate_stream, with a module-supplied destructor. It is purely additive to the dynamic-modules extension.

Motivation: A dynamic-module HTTP filter sometimes needs to keep a live, per-stream object graph (channel handles, task handles for an in-flight async exchange) alive across an Envoy recreate_stream(). recreate_stream() destroys the current filter instance and builds a fresh one, so any state the instance held in native memory dies with it. The existing filter-state ABI can't carry it: it's byte-oriented (no way to serialize live handles) and hardcoded to FilterChain lifespan.

This adds two HTTP-filter ABI callbacks plus Rust SDK wrappers:

  • set_filter_state_object(key, obj, destructor, life_span): store an opaque, module-owned void* with a lifespan and a module destructor.
  • get_filter_state_object(key): borrow it back on any later hook, including the rebuilt instance after a recreate.

Objects stored at Request/Connection lifespan are carried into the new stream by the connection manager; FilterChain objects are not.

The module keys the object under a fixed, well-known name. On a fresh request the key is absent, so the filter creates the object and stores it; after recreate_stream the same key is present on the rebuilt instance, which is the signal to re-attach to the surviving object instead of starting over. The per-stream filter state is the entire handoff ; no request header, random id, or external registry needed.

Risk Level: Low
Testing: Unit and Integration Tests
Docs Changes: N.A
Release Notes: N.A
Platform Specific Features: N.A

@repokitteh-read-only

Copy link
Copy Markdown

As a reminder, PRs marked as draft will not be automatically assigned reviewers,
or be handled by maintainer-oncall triage.

Please mark your PR as ready when you want it to be reviewed!

🐱

Caused by: #45888 was opened by basundhara-c.

see: more, trace.

@basundhara-c basundhara-c force-pushed the persist-state-across-recreates branch 2 times, most recently from 6366cfa to 5753aaf Compare June 29, 2026 22:52
…reate_stream

Signed-off-by: Basundhara Chakrabarty <basundhara17061996@gmail.com>
@basundhara-c basundhara-c force-pushed the persist-state-across-recreates branch from 5753aaf to 063becf Compare June 29, 2026 23:42
@basundhara-c basundhara-c marked this pull request as ready for review June 30, 2026 03:37
@wbpcode

wbpcode commented Jun 30, 2026

Copy link
Copy Markdown
Member

From ABI's perspective, looks good to me. Could you take a check also cc @agrawroh

@RyanTheOptimist

Copy link
Copy Markdown
Contributor

/assign @wbpcode

: object_(object), destructor_(destructor) {}
~DynamicModuleFilterStateObject() override {
if (destructor_ != nullptr) {
destructor_(object_);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module destructor runs here with no catch_unwind guard.

The SDK builds with panic = "unwind", and every inbound module callback wraps its body in catch_unwind for exactly this reason. This is the first module-supplied function Envoy stores and calls, and it skips that guard, so a panic in the stored object's drop path is undefined behavior during connection teardown.

Please wrap the destructor call in a catch_unwind trampoline in the SDK, or document that the destructor must never panic and runs on the teardown path.

stream_info->filterState()->setData(
key_view, std::make_shared<DynamicModuleFilterStateObject>(module_object, destructor),
toFilterStateLifeSpan(life_span));
return true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set returns true even when setData did not store. Storing the same key at a different lifespan than a prior entry makes setData hit IS_ENVOY_BUG and return without storing, which is a log-and-continue in release, not an abort.

On that path the wrapper is the only owner, so it is destroyed and the module destructor frees the object, yet the call reports success. The module then holds a freed pointer.

Please detect the non-store and return false. The object is already freed on that path, so the failure contract still holds.

* @param destructor is called exactly once with module_object when the entry is destroyed, or
* before returning false so a failed store never leaks the object.
* @param life_span is the lifespan of the entry.
* @return true if the operation is successful, false if the stream info is not available or the key

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here we document a false return "if the key already exists and is marked as read-only", but the setData overload used here has no read-only concept and returns void, so that false can never occur. The only real false today is unavailable stream info.

Please drop the read-only clause and have it describe the real failure modes, including the same-key-different-lifespan conflict. Also, mirror the change in the trait doc in sdk/rust/src/http.rs.

envoy_dynamic_module_type_filter_state_object_module_ptr object,
envoy_dynamic_module_type_filter_state_object_destructor destructor)
: object_(object), destructor_(destructor) {}
~DynamicModuleFilterStateObject() override {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wrapper holds a raw destructor pointer into the module .so but no reference to the config or module, and do_not_close defaults to false.

Connection-lifespan entries can live as long as the connection. This is safe today only because Envoy closes connections before it frees the filter-chain config and unloads the .so, which is an emergent ordering property rather than something this code enforces.

Please make it hold a shared_ptr to the config or module in the wrapper so the .so cannot unload while a live destructor pointer into it exists, or document that Connection lifespan requires do_not_close=true.

case envoy_dynamic_module_type_filter_state_life_span_Connection:
return StreamInfo::FilterState::LifeSpan::Connection;
default:
return StreamInfo::FilterState::LifeSpan::FilterChain;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use IS_ENVOY_BUG here?


/// Borrow the opaque object previously stored under `key`, or `None` if absent. Ownership stays
/// with Envoy; the module must not free the returned pointer.
fn get_filter_state_object(&self, key: &[u8]) -> Option<*mut c_void>;

@agrawroh agrawroh Jul 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hands back a *mut from a &self, and the example mutates through it. The pointer is shared, since a second filter or the rebuilt filter can hold it too.

Could you also document that the module must synchronize any interior mutability and that the pointer is valid only on the owning worker thread?

@agrawroh

agrawroh commented Jul 2, 2026

Copy link
Copy Markdown
Member

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for storing and retrieving opaque, module-owned objects in the Envoy filter state for dynamic modules, allowing live objects to survive stream recreation. The feedback identifies critical safety issues: potential null pointer dereferences when accessing stream_info->filterState() in C++ (which could cause crashes), and the need to mark the Rust SDK's set_filter_state_object API as unsafe since it handles raw pointers and destructors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1604 to +1626
bool envoy_dynamic_module_callback_http_set_filter_state_object(
envoy_dynamic_module_type_http_filter_envoy_ptr filter_envoy_ptr,
envoy_dynamic_module_type_module_buffer key,
envoy_dynamic_module_type_filter_state_object_module_ptr module_object,
envoy_dynamic_module_type_filter_state_object_destructor destructor,
envoy_dynamic_module_type_filter_state_life_span life_span) {
auto* filter = static_cast<DynamicModuleHttpFilter*>(filter_envoy_ptr);
auto* stream_info = filter->streamInfo();
if (!stream_info) {
ENVOY_LOG_TO_LOGGER(Envoy::Logger::Registry::getLog(Envoy::Logger::Id::dynamic_modules), debug,
"stream info is not available");
// Ownership transferred to Envoy; free the object rather than leak it.
if (destructor != nullptr) {
destructor(module_object);
}
return false;
}
absl::string_view key_view(key.ptr, key.length);
stream_info->filterState()->setData(
key_view, std::make_shared<DynamicModuleFilterStateObject>(module_object, destructor),
toFilterStateLifeSpan(life_span));
return true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The stream_info->filterState() pointer can be null in certain contexts. Dereferencing it directly without a null check will cause a crash. Additionally, if the filter state is not available, we must call the destructor on the module_object to prevent a memory leak, as ownership has already been transferred to Envoy.

bool envoy_dynamic_module_callback_http_set_filter_state_object(
    envoy_dynamic_module_type_http_filter_envoy_ptr filter_envoy_ptr,
    envoy_dynamic_module_type_module_buffer key,
    envoy_dynamic_module_type_filter_state_object_module_ptr module_object,
    envoy_dynamic_module_type_filter_state_object_destructor destructor,
    envoy_dynamic_module_type_filter_state_life_span life_span) {
  auto* filter = static_cast<DynamicModuleHttpFilter*>(filter_envoy_ptr);
  auto* stream_info = filter->streamInfo();
  if (!stream_info) {
    ENVOY_LOG_TO_LOGGER(Envoy::Logger::Registry::getLog(Envoy::Logger::Id::dynamic_modules), debug,
                        "stream info is not available");
    // Ownership transferred to Envoy; free the object rather than leak it.
    if (destructor != nullptr) {
      destructor(module_object);
    }
    return false;
  }
  auto filter_state = stream_info->filterState();
  if (!filter_state) {
    ENVOY_LOG_TO_LOGGER(Envoy::Logger::Registry::getLog(Envoy::Logger::Id::dynamic_modules), debug,
                        "filter state is not available");
    if (destructor != nullptr) {
      destructor(module_object);
    }
    return false;
  }
  absl::string_view key_view(key.ptr, key.length);
  filter_state->setData(
      key_view, std::make_shared<DynamicModuleFilterStateObject>(module_object, destructor),
      toFilterStateLifeSpan(life_span));
  return true;
}

Comment on lines +1628 to +1646
envoy_dynamic_module_type_filter_state_object_module_ptr
envoy_dynamic_module_callback_http_get_filter_state_object(
envoy_dynamic_module_type_http_filter_envoy_ptr filter_envoy_ptr,
envoy_dynamic_module_type_module_buffer key) {
auto* filter = static_cast<DynamicModuleHttpFilter*>(filter_envoy_ptr);
auto* stream_info = filter->streamInfo();
if (!stream_info) {
ENVOY_LOG_TO_LOGGER(Envoy::Logger::Registry::getLog(Envoy::Logger::Id::dynamic_modules), debug,
"stream info is not available");
return nullptr;
}
absl::string_view key_view(key.ptr, key.length);
auto* object =
stream_info->filterState()->getDataMutable<DynamicModuleFilterStateObject>(key_view);
if (object == nullptr) {
return nullptr;
}
return object->object();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The stream_info->filterState() pointer can be null in certain contexts. Dereferencing it directly without a null check will cause a crash.

envoy_dynamic_module_type_filter_state_object_module_ptr
envoy_dynamic_module_callback_http_get_filter_state_object(
    envoy_dynamic_module_type_http_filter_envoy_ptr filter_envoy_ptr,
    envoy_dynamic_module_type_module_buffer key) {
  auto* filter = static_cast<DynamicModuleHttpFilter*>(filter_envoy_ptr);
  auto* stream_info = filter->streamInfo();
  if (!stream_info) {
    ENVOY_LOG_TO_LOGGER(Envoy::Logger::Registry::getLog(Envoy::Logger::Id::dynamic_modules), debug,
                        "stream info is not available");
    return nullptr;
  }
  auto filter_state = stream_info->filterState();
  if (!filter_state) {
    ENVOY_LOG_TO_LOGGER(Envoy::Logger::Registry::getLog(Envoy::Logger::Id::dynamic_modules), debug,
                        "filter state is not available");
    return nullptr;
  }
  absl::string_view key_view(key.ptr, key.length);
  auto* object =
      filter_state->getDataMutable<DynamicModuleFilterStateObject>(key_view);
  if (object == nullptr) {
    return nullptr;
  }
  return object->object();
}

Comment on lines +1313 to +1332
/// Store an opaque, module-owned object in the filter state under `key`. Envoy never interprets
/// the object; it calls `destructor` exactly once when the entry is destroyed. Objects stored at
/// `Request` or `Connection` lifespan survive `recreate_stream`; `FilterChain` objects do not.
///
/// Typical use: `Box::into_raw(Box::new(state)) as *mut c_void`, with a destructor that calls
/// `drop(Box::from_raw(ptr as *mut State))`. The module must only recover the object via
/// [`EnvoyHttpFilter::get_filter_state_object`] and never free it itself.
///
/// Ownership transfers to Envoy on every path: the destructor runs exactly once, either when the
/// entry is destroyed or before this returns `false`, so a failed store never leaks the object.
///
/// Returns true if the operation is successful, false if the filter state is not accessible or
/// the key already exists and is read-only.
fn set_filter_state_object(
&mut self,
key: &[u8],
object: *mut c_void,
destructor: extern "C" fn(*mut c_void),
life_span: abi::envoy_dynamic_module_type_filter_state_life_span,
) -> bool;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Rust, passing raw pointers and raw function pointers to a safe function is unsafe because it can easily lead to undefined behavior if the pointer or destructor is invalid. The function should be marked unsafe fn to preserve Rust's safety guarantees and require callers to acknowledge the safety invariants.

  /// Store an opaque, module-owned object in the filter state under `key`. Envoy never interprets
  /// the object; it calls `destructor` exactly once when the entry is destroyed. Objects stored at
  /// `Request` or `Connection` lifespan survive `recreate_stream`; `FilterChain` objects do not.
  ///
  /// Typical use: `Box::into_raw(Box::new(state)) as *mut c_void`, with a destructor that calls
  /// `drop(Box::from_raw(ptr as *mut State))`. The module must only recover the object via
  /// [`EnvoyHttpFilter::get_filter_state_object`] and never free it itself.
  ///
  /// Ownership transfers to Envoy on every path: the destructor runs exactly once, either when the
  /// entry is destroyed or before this returns `false`, so a failed store never leaks the object.
  ///
  /// Returns true if the operation is successful, false if the filter state is not accessible or
  /// the key already exists and is read-only.
  ///
  /// # Safety
  ///
  /// The caller must ensure that `object` is a valid pointer and `destructor` is a valid
  /// function pointer that safely deallocates `object`.
  unsafe fn set_filter_state_object(
    &mut self,
    key: &[u8],
    object: *mut c_void,
    destructor: unsafe extern "C" fn(*mut c_void),
    life_span: abi::envoy_dynamic_module_type_filter_state_life_span,
  ) -> bool;

Comment on lines +2993 to +3011
#[allow(clippy::not_unsafe_ptr_arg_deref)]
fn set_filter_state_object(
&mut self,
key: &[u8],
object: *mut c_void,
destructor: extern "C" fn(*mut c_void),
life_span: abi::envoy_dynamic_module_type_filter_state_life_span,
) -> bool {
let destructor: unsafe extern "C" fn(*mut c_void) = destructor;
unsafe {
abi::envoy_dynamic_module_callback_http_set_filter_state_object(
self.raw_ptr,
bytes_to_module_buffer(key),
object,
Some(destructor),
life_span,
)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Mark the implementation of set_filter_state_object as unsafe fn to match the trait definition, and remove the #[allow(clippy::not_unsafe_ptr_arg_deref)] attribute.

  unsafe fn set_filter_state_object(
    &mut self,
    key: &[u8],
    object: *mut c_void,
    destructor: unsafe extern "C" fn(*mut c_void),
    life_span: abi::envoy_dynamic_module_type_filter_state_life_span,
  ) -> bool {
    abi::envoy_dynamic_module_callback_http_set_filter_state_object(
      self.raw_ptr,
      bytes_to_module_buffer(key),
      object,
      Some(destructor),
      life_span,
    )
  }

@agrawroh

agrawroh commented Jul 3, 2026

Copy link
Copy Markdown
Member

/wait

Signed-off-by: Basundhara Chakrabarty <basundhara17061996@gmail.com>
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.

4 participants