This includes a test netdev offload and a suite of unit tests to
ensure functionality.  To facilitate the testing, some special
offload APIs are added that force offload to true.  It is expected
that these are not called unless within a testing environment.

The dummy provider is automatically registered when the "dummy" dpif
offload class is active (hw-offload=true with a dummy datapath),
providing an end-to-end integration path through the dpif-offload
infrastructure.

The integration test covers both directions: a forward packet on p1
triggers conn_add (with p1's netdev as netdev_fwd_in), and a reply
packet on p2 triggers conn_established (with p2's netdev as
netdev_rev_in), verifying that the input netdev is correctly
propagated through the full conntrack offload path in each direction.

Assisted-by: Claude Sonnet 4.6 <[email protected]>
Signed-off-by: Aaron Conole <[email protected]>
---
 lib/automake.mk          |   2 +
 lib/ct-offload-dummy.c   | 244 +++++++++++++++++++++++++++++++++++
 lib/ct-offload-dummy.h   |  66 ++++++++++
 lib/ct-offload.c         |  31 ++++-
 lib/ct-offload.h         |  10 ++
 lib/dpif-offload-dummy.c |  21 +--
 tests/dpif-netdev.at     | 205 +++++++++++++++++++++++++++++
 tests/library.at         |  36 ++++++
 tests/test-conntrack.c   | 271 +++++++++++++++++++++++++++++++++++++++
 9 files changed, 872 insertions(+), 14 deletions(-)
 create mode 100644 lib/ct-offload-dummy.c
 create mode 100644 lib/ct-offload-dummy.h

diff --git a/lib/automake.mk b/lib/automake.mk
index dc165f4153..b22b69d01b 100644
--- a/lib/automake.mk
+++ b/lib/automake.mk
@@ -59,6 +59,8 @@ lib_libopenvswitch_la_SOURCES = \
        lib/conntrack.h \
        lib/ct-offload.c \
        lib/ct-offload.h \
+       lib/ct-offload-dummy.c \
+       lib/ct-offload-dummy.h \
        lib/cooperative-multitasking.c \
        lib/cooperative-multitasking.h \
        lib/cooperative-multitasking-private.h \
diff --git a/lib/ct-offload-dummy.c b/lib/ct-offload-dummy.c
new file mode 100644
index 0000000000..9456816d3c
--- /dev/null
+++ b/lib/ct-offload-dummy.c
@@ -0,0 +1,244 @@
+/*
+ * Copyright (c) 2026 Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at:
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <config.h>
+
+#include "ct-offload-dummy.h"
+#include "ct-offload.h"
+#include "openvswitch/list.h"
+#include "openvswitch/vlog.h"
+#include "ovs-thread.h"
+#include "timeval.h"
+#include "util.h"
+
+VLOG_DEFINE_THIS_MODULE(ct_offload_dummy);
+
+/* Per-connection tracking. */
+
+struct ct_dummy_entry {
+    struct ovs_list   list_node;
+    const struct conn *conn;
+    struct netdev     *netdev_fwd_in;
+    struct netdev     *netdev_rev_in;
+};
+
+/* Counters are plain ints that can be read from any thread; this mutex
+ * protects both the list and the counters for consistency. */
+static struct ovs_mutex dummy_mutex = OVS_MUTEX_INITIALIZER;
+
+/* List of offloaded connections, protected by dummy_mutex. */
+static struct ovs_list dummy_conns OVS_GUARDED_BY(dummy_mutex)
+    = OVS_LIST_INITIALIZER(&dummy_conns);
+
+static unsigned int n_added       = 0;
+static unsigned int n_deleted     = 0;
+static unsigned int n_updated     = 0;
+static unsigned int n_established = 0;
+
+/* Lookup must be called with dummy_mutex held. */
+static struct ct_dummy_entry *
+dummy_find__(const struct conn *conn)
+    OVS_REQUIRES(dummy_mutex)
+{
+    struct ct_dummy_entry *e;
+
+    LIST_FOR_EACH (e, list_node, &dummy_conns) {
+        if (e->conn == conn) {
+            return e;
+        }
+    }
+    return NULL;
+}
+
+static bool
+dummy_can_offload(const struct ct_offload_ctx *ctx OVS_UNUSED)
+{
+    return true;
+}
+
+static int
+dummy_conn_add(const struct ct_offload_ctx *ctx)
+{
+    struct ct_dummy_entry *e = xmalloc(sizeof *e);
+
+    e->conn = ctx->conn;
+    e->netdev_fwd_in = ctx->netdev_in;
+    e->netdev_rev_in = NULL;
+
+    ovs_mutex_lock(&dummy_mutex);
+    ovs_list_push_back(&dummy_conns, &e->list_node);
+    n_added++;
+    ovs_mutex_unlock(&dummy_mutex);
+
+    VLOG_DBG("ct_offload_dummy: conn add: conn=%p, netdev_fwd_in=%p, "
+             "zone=%"PRIu16" proto=%"PRIu8,
+             ctx->conn, ctx->netdev_in,
+             ctx->key->zone, ctx->key->nw_proto);
+    return 0;
+}
+
+static void
+dummy_conn_del(const struct ct_offload_ctx *ctx)
+{
+    ovs_mutex_lock(&dummy_mutex);
+    struct ct_dummy_entry *e = dummy_find__(ctx->conn);
+
+    if (e) {
+        ovs_list_remove(&e->list_node);
+        n_deleted++;
+        free(e);
+    }
+    ovs_mutex_unlock(&dummy_mutex);
+
+    VLOG_DBG("ct_offload_dummy: conn del: conn=%p", ctx->conn);
+}
+
+static bool
+dummy_conn_established(const struct ct_offload_ctx *ctx)
+{
+    ovs_mutex_lock(&dummy_mutex);
+    struct ct_dummy_entry *e = dummy_find__(ctx->conn);
+    bool established = false;
+
+    if (e && !e->netdev_rev_in) {
+        e->netdev_rev_in = ctx->netdev_in;
+        n_established++;
+        established = true;
+        VLOG_DBG("ct_offload_dummy: conn established: conn=%p "
+                 "netdev_fwd_in=%p netdev_rev_in=%p",
+                 ctx->conn, e->netdev_fwd_in, e->netdev_rev_in);
+    }
+    ovs_mutex_unlock(&dummy_mutex);
+    return established;
+}
+
+static long long
+dummy_conn_update(const struct ct_offload_ctx *ctx)
+{
+    ovs_mutex_lock(&dummy_mutex);
+    struct ct_dummy_entry *e = dummy_find__(ctx->conn);
+
+    if (!e) {
+        ovs_mutex_unlock(&dummy_mutex);
+        return 0;
+    }
+
+    n_updated++;
+    ovs_mutex_unlock(&dummy_mutex);
+
+    VLOG_DBG("ct_offload_dummy: conn update: conn=%p", ctx->conn);
+    return time_msec();
+}
+
+static void
+dummy_flush(void)
+{
+    ovs_mutex_lock(&dummy_mutex);
+    struct ct_dummy_entry *e;
+    LIST_FOR_EACH_POP (e, list_node, &dummy_conns) {
+        n_deleted++;
+        free(e);
+    }
+    ovs_mutex_unlock(&dummy_mutex);
+}
+
+/* Provider class. */
+
+const struct ct_offload_class ct_offload_dummy_class = {
+    .name             = "dummy",
+    .init             = NULL,
+    .batch_submit     = NULL,
+    .conn_add         = dummy_conn_add,
+    .conn_del         = dummy_conn_del,
+    .conn_update      = dummy_conn_update,
+    .conn_established = dummy_conn_established,
+    .can_offload      = dummy_can_offload,
+    .flush            = dummy_flush,
+};
+
+/* Public API. */
+
+void
+ct_offload_dummy_register(void)
+{
+    ct_offload_dummy_reset_counters();
+    ct_offload_register(&ct_offload_dummy_class);
+}
+
+void
+ct_offload_dummy_unregister(void)
+{
+    /* Flush any leftover entries before unregistering so we do not leak. */
+    dummy_flush();
+    ct_offload_unregister(&ct_offload_dummy_class);
+}
+
+unsigned int
+ct_offload_dummy_n_added(void)
+{
+    return n_added;
+}
+
+unsigned int
+ct_offload_dummy_n_deleted(void)
+{
+    return n_deleted;
+}
+
+unsigned int
+ct_offload_dummy_n_updated(void)
+{
+    return n_updated;
+}
+
+unsigned int
+ct_offload_dummy_n_established(void)
+{
+    return n_established;
+}
+
+void
+ct_offload_dummy_reset_counters(void)
+{
+    ovs_mutex_lock(&dummy_mutex);
+    n_added       = 0;
+    n_deleted     = 0;
+    n_updated     = 0;
+    n_established = 0;
+    ovs_mutex_unlock(&dummy_mutex);
+}
+
+bool
+ct_offload_dummy_contains(const struct conn *conn)
+{
+    ovs_mutex_lock(&dummy_mutex);
+    bool found = dummy_find__(conn) != NULL;
+    ovs_mutex_unlock(&dummy_mutex);
+    return found;
+}
+
+/* Returns true if the dummy provider has seen both the forward-direction
+ * input netdev (recorded at conn_add) and the reply-direction input netdev
+ * (recorded at conn_established) for 'conn'. */
+bool
+ct_offload_dummy_is_bidirectional(const struct conn *conn)
+{
+    ovs_mutex_lock(&dummy_mutex);
+    struct ct_dummy_entry *e = dummy_find__(conn);
+    bool bidi = e && e->netdev_fwd_in && e->netdev_rev_in;
+    ovs_mutex_unlock(&dummy_mutex);
+    return bidi;
+}
diff --git a/lib/ct-offload-dummy.h b/lib/ct-offload-dummy.h
new file mode 100644
index 0000000000..0fd8e2985a
--- /dev/null
+++ b/lib/ct-offload-dummy.h
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2026 Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at:
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef CT_OFFLOAD_DUMMY_H
+#define CT_OFFLOAD_DUMMY_H 1
+
+/* Dummy CT offload provider
+ * =========================
+ *
+ * A software-only implementation of the ct_offload_class interface used for
+ * unit testing.  It records every conn_ add/del/update/established call
+ * and exposes inspection helpers so tests can verify that the correct
+ * hooks are reached without requiring any hardware.
+ *
+ * Typical usage:
+ *
+ *   ct_offload_init_for_tests(); // allocate per-connection private slot
+ *   ct_offload_force_enable(true); // bypass hardware-offload gate
+ *   ct_offload_dummy_register();   // activate the provider
+ *   conntrack_execute(...);        // exercises conn_add
+ *   ovs_assert(ct_offload_dummy_n_added() == 1);
+ *   conntrack_flush(...);          // exercises conn_del
+ *   ovs_assert(ct_offload_dummy_n_deleted() == 1);
+ *   ct_offload_dummy_unregister(); // tear down after test
+ *   ct_offload_force_enable(false);
+ */
+
+#include <stdbool.h>
+
+struct conn;
+
+/* Register (or unregister) the dummy provider. */
+void ct_offload_dummy_register(void);
+void ct_offload_dummy_unregister(void);
+
+/* Counters.  Initialized to zero and can be reset. */
+unsigned int ct_offload_dummy_n_added(void);
+unsigned int ct_offload_dummy_n_deleted(void);
+unsigned int ct_offload_dummy_n_updated(void);
+unsigned int ct_offload_dummy_n_established(void);
+
+/* Reset all counters without changing registered state. */
+void ct_offload_dummy_reset_counters(void);
+
+/* Returns true if 'conn' is currently tracked by the dummy (was added but
+ * not yet deleted or flushed). */
+bool ct_offload_dummy_contains(const struct conn *conn);
+
+/* Returns true if 'conn' has been seen in both directions (forward netdev
+ * recorded at conn_add, reply netdev recorded at conn_established). */
+bool ct_offload_dummy_is_bidirectional(const struct conn *conn);
+
+#endif /* CT_OFFLOAD_DUMMY_H */
diff --git a/lib/ct-offload.c b/lib/ct-offload.c
index 8c9c98e805..65c8036426 100644
--- a/lib/ct-offload.c
+++ b/lib/ct-offload.c
@@ -22,6 +22,7 @@
 
 #include "conntrack.h"
 #include "conntrack-private.h"
+#include "ct-offload-dummy.h"
 #include "dpif-offload.h"
 #include "ovs-thread.h"
 #include "util.h"
@@ -33,9 +34,13 @@
 VLOG_DEFINE_THIS_MODULE(ct_offload);
 
 /* Built-in CT offload provider classes.  Only those whose name matches a
- * registered dpif offload class will be activated by ct_offload_module_init().
- * Populated by downstream patches as concrete providers are added. */
-static const struct ct_offload_class *base_ct_offload_classes[] OVS_UNUSED = {
+ * registered dpif offload class will be activated by
+ * ct_offload_module_init(). */
+static const struct ct_offload_class *base_ct_offload_classes[] = {
+    /* Dummy provider: activated whenever the "dummy" dpif offload class is
+     * registered (hw-offload=true with a dummy datapath).  Also used directly
+     * by unit tests via ct_offload_dummy_register(). */
+    &ct_offload_dummy_class,
 };
 
 /* Private slot for storing per-connection offload state. */
@@ -156,8 +161,13 @@ void
 ct_offload_module_init(void)
 {
     ct_offload_alloc_private_slot();
-    /* No built-in providers yet; base_ct_offload_classes[] is populated as
-     * concrete providers are added in downstream patches. */
+    for (size_t i = 0; i < ARRAY_SIZE(base_ct_offload_classes); i++) {
+        const struct ct_offload_class *class = base_ct_offload_classes[i];
+
+        if (dpif_offload_class_is_registered(class->name)) {
+            ct_offload_register(class);
+        }
+    }
 }
 
 /* ct_offload_init_for_tests() - allocate the internal private slot for tests.
@@ -170,6 +180,15 @@ ct_offload_init_for_tests(void)
     ct_offload_alloc_private_slot();
 }
 
+/* Used only in testing to bypass the hardware-offload gate. */
+static bool ct_offload_forced = false;
+
+void
+ct_offload_force_enable(bool value)
+{
+    ct_offload_forced = value;
+}
+
 /* ct_offload_enabled() - returns true when hardware offload is active.
  *
  * Delegates to dpif_offload_enabled() so CT offload shares the same global
@@ -177,7 +196,7 @@ ct_offload_init_for_tests(void)
 bool
 ct_offload_enabled(void)
 {
-    return dpif_offload_enabled();
+    return dpif_offload_enabled() || ct_offload_forced;
 }
 
 /* ct_offload_set_global_cfg() - configure CT offload from OVSDB.
diff --git a/lib/ct-offload.h b/lib/ct-offload.h
index 1496fc2b7e..1c2a63e06e 100644
--- a/lib/ct-offload.h
+++ b/lib/ct-offload.h
@@ -91,6 +91,12 @@ struct ct_offload_class {
     void (*flush)(void);
 };
 
+/* Dummy (software-only) CT offload provider, always compiled in.
+ * Registered automatically when the "dummy" dpif offload class is active
+ * (e.g. hw-offload=true with a dummy datapath), and available directly for
+ * unit tests via ct_offload_dummy_register() in ct-offload-dummy.h. */
+extern const struct ct_offload_class ct_offload_dummy_class;
+
 /* Register/unregister a provider.  Must be called at module init, before
  * any connections are created.  conn_add, conn_del, and can_offload must
  * be non-NULL. */
@@ -112,6 +118,10 @@ void ct_offload_set_global_cfg(const struct 
ovsrec_open_vswitch *);
  * dpif_offload_enabled()). */
 bool ct_offload_enabled(void);
 
+/* Used for testing.  Forces an additional parameter for the offload enable
+ * check.  Set to 'true' to always enable the offloads. */
+void ct_offload_force_enable(bool);
+
 /* Per-connection offload API that dispatches to all registered providers.
  * conn_add, conn_del, and conn_established require conn->lock to be held by
  * the caller; conn_update, can_offload, and flush do not. */
diff --git a/lib/dpif-offload-dummy.c b/lib/dpif-offload-dummy.c
index 878276a94b..24d9fb913e 100644
--- a/lib/dpif-offload-dummy.c
+++ b/lib/dpif-offload-dummy.c
@@ -649,14 +649,15 @@ dummy_offload_are_all_actions_supported(const struct 
dpif_offload *offload_,
     const struct nlattr *nla;
     size_t left;
 
-    /* Can we fully offload this flow? For now, only output actions are
-     * supported, and only to dummy-pmd netdevs where the egress port differs
-     * from the ingress port.  The latter restriction ensures that the partial
-     * offload test cases pass.
+    /* Can we fully offload this flow?  Output actions are supported for
+     * dummy-pmd netdevs where the egress port differs from the ingress port.
+     * CT actions are also accepted: the connection lifecycle is handled by the
+     * ct-offload provider (ct_offload_dummy_class when the dummy backend is
+     * active).  Recirc is not yet supported at the hardware offload layer.
      *
-     * The reason for supporting only dummy-pmd netdevs as output targets is
-     * that they provide full protection when calling netdev_send() from any
-     * thread, via a netdev-level mutex. */
+     * The reason for restricting output to dummy-pmd netdevs is that they
+     * provide full protection when calling netdev_send() from any thread,
+     * via a netdev-level mutex. */
     NL_ATTR_FOR_EACH (nla, left, actions, actions_len) {
         enum ovs_action_attr action = nl_attr_type(nla);
 
@@ -685,6 +686,11 @@ dummy_offload_are_all_actions_supported(const struct 
dpif_offload *offload_,
             break;
         }
 
+        case OVS_ACTION_ATTR_CT:
+            /* Handled by the ct-offload provider; accept without inspecting
+             * the nested attributes. */
+            break;
+
         case OVS_ACTION_ATTR_UNSPEC:
         case OVS_ACTION_ATTR_USERSPACE:
         case OVS_ACTION_ATTR_SET:
@@ -696,7 +702,6 @@ dummy_offload_are_all_actions_supported(const struct 
dpif_offload *offload_,
         case OVS_ACTION_ATTR_PUSH_MPLS:
         case OVS_ACTION_ATTR_POP_MPLS:
         case OVS_ACTION_ATTR_SET_MASKED:
-        case OVS_ACTION_ATTR_CT:
         case OVS_ACTION_ATTR_TRUNC:
         case OVS_ACTION_ATTR_PUSH_ETH:
         case OVS_ACTION_ATTR_POP_ETH:
diff --git a/tests/dpif-netdev.at b/tests/dpif-netdev.at
index 14f238e622..7a30f41425 100644
--- a/tests/dpif-netdev.at
+++ b/tests/dpif-netdev.at
@@ -64,6 +64,18 @@ filter_hw_packet_netdev_dummy () {
         | sort | uniq
 }
 
+filter_ct_offload_dummy_conn_add () {
+    grep 'ct_offload_dummy.*conn add:' | sed 's/.*|DBG|//' | sort | uniq
+}
+
+filter_ct_offload_dummy_conn_del () {
+    grep 'ct_offload_dummy.*conn del:' | sed 's/.*|DBG|//' | sort | uniq
+}
+
+filter_ct_offload_dummy_conn_established () {
+    grep 'ct_offload_dummy.*conn established:' | sed 's/.*|DBG|//' | sort | 
uniq
+}
+
 filter_flow_dump () {
     grep 'flow_dump ' | sed '
         s/.*flow_dump //
@@ -71,6 +83,18 @@ filter_flow_dump () {
     ' | sort | uniq
 }
 
+strip_ct_dump_flows () {
+    sed '
+    /^flow-dump from/d
+    s/ufid:[-0-9a-f]*, //
+    s/, packets:[0-9]*//
+    s/, bytes:[0-9]*//
+    s/, used:[^ ,]*//
+    s/[^,]*(0\/0),//g
+    s/, dp-extra-info.*//
+    s/^[[:space:]]*//' | sort
+}
+
 strip_metadata () {
     sed 's/metadata=0x[0-9a-f]*/metadata=0x0/'
 }
@@ -3774,3 +3798,184 @@ OVS_VSWITCHD_STOP(["dnl
   /.*failed to put.*$/d
   /.*failed to flow_del.*$/d"])
 AT_CLEANUP
+
+dnl Test that the CT offload dummy provider receives conn_add, 
conn_established,
+dnl and conn_del callbacks when packets traverse a conntrack commit flow in 
both
+dnl directions on a dummy datapath with hw-offload enabled.  Also verifies that
+dnl the input netdev is correctly propagated in each direction: netdev_fwd_in 
at
+dnl conn_add, netdev_rev_in at conn_established.
+AT_SETUP([dpif-netdev - conntrack offload dummy])
+AT_KEYWORDS([conntrack offload])
+OVS_VSWITCHD_START(
+  [add-port br0 p1 -- \
+   set interface p1 type=dummy ofport_request=1 \
+                    options:pstream=punix:$OVS_RUNDIR/p1.sock \
+                    options:ifindex=1100 -- \
+   add-port br0 p2 -- \
+   set interface p2 type=dummy ofport_request=2 \
+                    options:pstream=punix:$OVS_RUNDIR/p2.sock \
+                    options:ifindex=1101 -- \
+   set bridge br0 datapath-type=dummy \
+                  other-config:datapath-id=1234 fail-mode=secure], [], [], [])
+
+dnl Enable debug logging for the dpif offload and CT offload dummy modules so
+dnl the test can detect hook calls via log grep.
+AT_CHECK([ovs-appctl vlog/set dpif_offload_dummy:file:dbg 
ct_offload_dummy:file:dbg])
+
+dnl Enable hardware offload - this registers the "dummy" dpif offload class
+dnl and automatically activates the CT offload dummy provider.
+AT_CHECK([ovs-vsctl set Open_vSwitch . other_config:hw-offload=true])
+OVS_WAIT_UNTIL([grep "Flow HW offload is enabled" ovs-vswitchd.log])
+
+dnl Add conntrack flows for both directions:
+dnl  table 0: untracked forward  (p1->p2) -> ct(commit) recirculate to table 1
+dnl  table 1: tracked   forward  (p1->p2) -> output on p2
+dnl  table 0: untracked reply    (p2->p1) -> ct() recirculate to table 1
+dnl  table 1: tracked   reply    (p2->p1) -> output on p1
+AT_CHECK([ovs-ofctl add-flow br0 \
+  
'table=0,priority=100,in_port=p1,ip,ct_state=-trk,actions=ct(commit,table=1)'])
+AT_CHECK([ovs-ofctl add-flow br0 \
+  'table=1,priority=100,in_port=p1,ip,ct_state=+trk,actions=output:p2'])
+AT_CHECK([ovs-ofctl add-flow br0 \
+  'table=0,priority=100,in_port=p2,ip,ct_state=-trk,actions=ct(table=1)'])
+AT_CHECK([ovs-ofctl add-flow br0 \
+  'table=1,priority=100,in_port=p2,ip,ct_state=+trk,actions=output:p1'])
+
+dnl Compose and inject a UDP packet on p1.  The first packet misses the
+dnl datapath, causes an upcall, executes ct(commit) to create a conntrack
+dnl entry, and triggers the ct_offload_dummy conn_add callback with p1's netdev
+dnl as netdev_fwd_in.
+flow_s="eth_src=50:54:00:00:00:01,eth_dst=50:54:00:00:00:02,udp,ip_src=10.0.0.1,ip_dst=10.0.0.2,ip_frag=no,udp_src=1000,udp_dst=2000"
+pkt=$(ovs-ofctl compose-packet --bare "${flow_s}")
+AT_CHECK([ovs-appctl netdev-dummy/receive p1 "${pkt}"])
+
+dnl Wait for the CT offload dummy conn_add hook to fire.
+OVS_WAIT_UNTIL([grep 'ct_offload_dummy.*conn add:' ovs-vswitchd.log])
+
+dnl Verify exactly one connection was added.
+AT_CHECK([filter_ct_offload_dummy_conn_add < ovs-vswitchd.log | wc -l | tr -d 
' '],
+  [0], [1
+])
+
+dnl Verify the forward-direction input netdev was propagated (non-NULL).
+AT_CHECK([filter_ct_offload_dummy_conn_add < ovs-vswitchd.log \
+  | grep -cv 'netdev_fwd_in=(nil)'], [0], [1
+])
+
+dnl Inject a reply packet on p2 (src/dst swapped).  This causes an upcall,
+dnl processes the reply through ct(), finds the existing connection in the
+dnl reply direction (CS_ESTABLISHED | CS_REPLY_DIR), and triggers the
+dnl ct_offload_dummy conn_established callback with p2's netdev as 
netdev_rev_in.
+reply_flow_s="eth_src=50:54:00:00:00:02,eth_dst=50:54:00:00:00:01,udp,ip_src=10.0.0.2,ip_dst=10.0.0.1,ip_frag=no,udp_src=2000,udp_dst=1000"
+reply_pkt=$(ovs-ofctl compose-packet --bare "${reply_flow_s}")
+AT_CHECK([ovs-appctl netdev-dummy/receive p2 "${reply_pkt}"])
+
+dnl Wait for the CT offload dummy conn_established hook to fire.
+OVS_WAIT_UNTIL([grep 'ct_offload_dummy.*conn established:' ovs-vswitchd.log])
+
+dnl Verify exactly one connection was established.
+AT_CHECK([filter_ct_offload_dummy_conn_established < ovs-vswitchd.log | wc -l 
| tr -d ' '],
+  [0], [1
+])
+
+dnl Verify the reply-direction input netdev was propagated (non-NULL).
+AT_CHECK([filter_ct_offload_dummy_conn_established < ovs-vswitchd.log \
+  | grep -cv 'netdev_rev_in=(nil)'], [0], [1
+])
+
+dnl Flush all conntrack entries - conn_clean is called for every tracked
+dnl connection, which invokes ct_offload_conn_del on each registered provider.
+AT_CHECK([ovs-appctl dpctl/flush-conntrack])
+
+dnl Wait for the CT offload dummy conn_del hook to fire.
+OVS_WAIT_UNTIL([grep 'ct_offload_dummy.*conn del:' ovs-vswitchd.log])
+
+dnl Verify exactly one connection was deleted.
+AT_CHECK([filter_ct_offload_dummy_conn_del < ovs-vswitchd.log | wc -l | tr -d 
' '],
+  [0], [1
+])
+
+OVS_VSWITCHD_STOP
+AT_CLEANUP
+
+AT_SETUP([dpif-netdev - conntrack offload dummy dump-flows])
+AT_KEYWORDS([conntrack offload])
+OVS_VSWITCHD_START(
+  [add-port br0 p1 -- \
+   set interface p1 type=dummy-pmd ofport_request=1 \
+                    options:ifindex=1100 -- \
+   add-port br0 p2 -- \
+   set interface p2 type=dummy-pmd ofport_request=2 \
+                    options:ifindex=1101 -- \
+   set bridge br0 datapath-type=dummy \
+                  other-config:datapath-id=1234 fail-mode=secure], [], [], \
+  [--dummy-numa="0,0,0,0,1,1,1,1"])
+
+dnl Enable debug logging for the dummy dpif-offload and CT offload providers so
+dnl the test can detect "succeed to add netdev flow" events via log grep.
+AT_CHECK([ovs-appctl vlog/set dpif_offload_dummy:file:dbg 
ct_offload_dummy:file:dbg])
+
+dnl Enable hardware offload - this registers the "dummy" dpif offload class and
+dnl automatically activates the CT offload dummy provider.
+AT_CHECK([ovs-vsctl set Open_vSwitch . other_config:hw-offload=true])
+OVS_WAIT_UNTIL([grep "Flow HW offload is enabled" ovs-vswitchd.log])
+
+dnl Add conntrack flows for both directions:
+dnl  table 0: untracked forward  (p1->p2) -> ct(commit) recirculate to table 1
+dnl  table 1: tracked   forward  (p1->p2) -> output on p2
+dnl  table 0: untracked reply    (p2->p1) -> ct() recirculate to table 1
+dnl  table 1: tracked   reply    (p2->p1) -> output on p1
+AT_CHECK([ovs-ofctl add-flow br0 \
+  
'table=0,priority=100,in_port=p1,ip,ct_state=-trk,actions=ct(commit,table=1)'])
+AT_CHECK([ovs-ofctl add-flow br0 \
+  'table=1,priority=100,in_port=p1,ip,ct_state=+trk,actions=output:p2'])
+AT_CHECK([ovs-ofctl add-flow br0 \
+  'table=0,priority=100,in_port=p2,ip,ct_state=-trk,actions=ct(table=1)'])
+AT_CHECK([ovs-ofctl add-flow br0 \
+  'table=1,priority=100,in_port=p2,ip,ct_state=+trk,actions=output:p1'])
+
+dnl Compose test packets.
+fwd_flow="eth_src=50:54:00:00:00:01,eth_dst=50:54:00:00:00:02,udp,ip_src=10.0.0.1,ip_dst=10.0.0.2,ip_frag=no,udp_src=1000,udp_dst=2000"
+fwd_pkt=$(ovs-ofctl compose-packet --bare "${fwd_flow}")
+rpl_flow="eth_src=50:54:00:00:00:02,eth_dst=50:54:00:00:00:01,udp,ip_src=10.0.0.2,ip_dst=10.0.0.1,ip_frag=no,udp_src=2000,udp_dst=1000"
+rpl_pkt=$(ovs-ofctl compose-packet --bare "${rpl_flow}")
+
+dnl Inject the forward packet.  The first pass misses the datapath cache:
+dnl   Upcall 1 (recirc_id=0, ct_state=-trk): executes 
ct(commit,recirc_table=1).
+dnl   Upcall 2 (recirc_id=1, ct_state=+trk): executes output:p2.
+dnl Both upcalls install separate DP flows submitted to the dummy dpif-offload.
+AT_CHECK([ovs-appctl netdev-dummy/receive p1 "${fwd_pkt}"])
+OVS_WAIT_UNTIL([grep 'ct_offload_dummy.*conn add:' ovs-vswitchd.log])
+OVS_WAIT_UNTIL([test "$(grep -c 'succeed to add netdev flow' 
ovs-vswitchd.log)" -ge 2])
+
+dnl Verify the two forward-direction datapath flows are marked offloaded:
+dnl  - table 0 path (recirc_id=0,  ct_state=-trk): action ct(commit) + recirc
+dnl  - table 1 path (recirc_id=0x1, ct_state=+trk): action output:p2
+AT_CHECK([ovs-appctl dpctl/dump-flows -m | strip_ct_dump_flows | grep 
'in_port(p1)'],
+  [0], [dnl
+recirc_id(0),in_port(p1),ct_state(0/0x20),packet_type(ns=0,id=0),eth(src=50:54:00:00:00:01/00:00:00:00:00:00,dst=50:54:00:00:00:02/00:00:00:00:00:00),eth_type(0x0800),ipv4(src=10.0.0.1/0.0.0.0,dst=10.0.0.2/0.0.0.0,proto=17/0,tos=0/0,ttl=0/0,frag=no),udp(src=1000/0,dst=2000/0),
 offloaded:partial, dp:ovs, actions:ct(commit),recirc(0x1)
+recirc_id(0x1),in_port(p1),ct_state(0x21/0x20),ct_tuple4(src=10.0.0.1/0.0.0.0,dst=10.0.0.2/0.0.0.0,proto=17/0,tp_src=1000/0,tp_dst=2000/0),packet_type(ns=0,id=0),eth(src=50:54:00:00:00:01/00:00:00:00:00:00,dst=50:54:00:00:00:02/00:00:00:00:00:00),eth_type(0x0800),ipv4(src=10.0.0.1/0.0.0.0,dst=10.0.0.2/0.0.0.0,proto=17/0,tos=0/0,ttl=0/0,frag=no),udp(src=1000/0,dst=2000/0),
 offloaded:yes, dp:dummy, actions:p2
+])
+
+dnl Inject the reply packet.  This triggers conn_established on the CT offload
+dnl dummy provider and installs two more DP flows for the reply direction.
+AT_CHECK([ovs-appctl netdev-dummy/receive p2 "${rpl_pkt}"])
+OVS_WAIT_UNTIL([grep 'ct_offload_dummy.*conn established:' ovs-vswitchd.log])
+OVS_WAIT_UNTIL([test "$(grep -c 'succeed to add netdev flow' 
ovs-vswitchd.log)" -ge 4])
+
+dnl Verify all four datapath flows are marked offloaded - both CT table passes
+dnl (untracked table=0 and tracked table=1 via recirc) in each direction:
+dnl  - forward untracked  (recirc_id=0,   in_port=p1, ct_state=-trk)
+dnl  - forward tracked    (recirc_id=0x1, in_port=p1, ct_state=+new+trk)
+dnl  - reply   untracked  (recirc_id=0,   in_port=p2, ct_state=-trk)
+dnl  - reply   tracked    (recirc_id=0x2, in_port=p2, ct_state=+est+rpl+trk)
+AT_CHECK([ovs-appctl dpctl/dump-flows -m | strip_ct_dump_flows],
+  [0], [dnl
+recirc_id(0),in_port(p1),ct_state(0/0x20),packet_type(ns=0,id=0),eth(src=50:54:00:00:00:01/00:00:00:00:00:00,dst=50:54:00:00:00:02/00:00:00:00:00:00),eth_type(0x0800),ipv4(src=10.0.0.1/0.0.0.0,dst=10.0.0.2/0.0.0.0,proto=17/0,tos=0/0,ttl=0/0,frag=no),udp(src=1000/0,dst=2000/0),
 offloaded:partial, dp:ovs, actions:ct(commit),recirc(0x1)
+recirc_id(0),in_port(p2),ct_state(0/0x20),packet_type(ns=0,id=0),eth(src=50:54:00:00:00:02/00:00:00:00:00:00,dst=50:54:00:00:00:01/00:00:00:00:00:00),eth_type(0x0800),ipv4(src=10.0.0.2/0.0.0.0,dst=10.0.0.1/0.0.0.0,proto=17/0,tos=0/0,ttl=0/0,frag=no),udp(src=2000/0,dst=1000/0),
 offloaded:partial, dp:ovs, actions:ct,recirc(0x2)
+recirc_id(0x1),in_port(p1),ct_state(0x21/0x20),ct_tuple4(src=10.0.0.1/0.0.0.0,dst=10.0.0.2/0.0.0.0,proto=17/0,tp_src=1000/0,tp_dst=2000/0),packet_type(ns=0,id=0),eth(src=50:54:00:00:00:01/00:00:00:00:00:00,dst=50:54:00:00:00:02/00:00:00:00:00:00),eth_type(0x0800),ipv4(src=10.0.0.1/0.0.0.0,dst=10.0.0.2/0.0.0.0,proto=17/0,tos=0/0,ttl=0/0,frag=no),udp(src=1000/0,dst=2000/0),
 offloaded:yes, dp:dummy, actions:p2
+recirc_id(0x2),in_port(p2),ct_state(0x2a/0x20),ct_tuple4(src=10.0.0.1/0.0.0.0,dst=10.0.0.2/0.0.0.0,proto=17/0,tp_src=1000/0,tp_dst=2000/0),packet_type(ns=0,id=0),eth(src=50:54:00:00:00:02/00:00:00:00:00:00,dst=50:54:00:00:00:01/00:00:00:00:00:00),eth_type(0x0800),ipv4(src=10.0.0.2/0.0.0.0,dst=10.0.0.1/0.0.0.0,proto=17/0,tos=0/0,ttl=0/0,frag=no),udp(src=2000/0,dst=1000/0),
 offloaded:yes, dp:dummy, actions:p1
+])
+
+OVS_VSWITCHD_STOP
+AT_CLEANUP
diff --git a/tests/library.at b/tests/library.at
index df4b8d2886..53e97375b3 100644
--- a/tests/library.at
+++ b/tests/library.at
@@ -320,3 +320,39 @@ AT_KEYWORDS([conntrack])
 AT_CHECK([ovstest test-conntrack private-multi-slot], [0], [.
 ])
 AT_CLEANUP
+
+AT_SETUP([conntrack offload dummy - conn add hook])
+AT_KEYWORDS([conntrack offload])
+AT_CHECK([ovstest test-conntrack offload-conn-add], [0], [.
+])
+AT_CLEANUP
+
+AT_SETUP([conntrack offload dummy - conn del hook])
+AT_KEYWORDS([conntrack offload])
+AT_CHECK([ovstest test-conntrack offload-conn-del], [0], [.
+])
+AT_CLEANUP
+
+AT_SETUP([conntrack offload dummy - conn update hook])
+AT_KEYWORDS([conntrack offload])
+AT_CHECK([ovstest test-conntrack offload-conn-update], [0], [.
+])
+AT_CLEANUP
+
+AT_SETUP([conntrack offload dummy - multiple connections])
+AT_KEYWORDS([conntrack offload])
+AT_CHECK([ovstest test-conntrack offload-multi-conn], [0], [.
+])
+AT_CLEANUP
+
+AT_SETUP([conntrack offload dummy - conn established hook (end-to-end)])
+AT_KEYWORDS([conntrack offload])
+AT_CHECK([ovstest test-conntrack offload-conn-established], [0], [.
+])
+AT_CLEANUP
+
+AT_SETUP([conntrack offload dummy - conn established fires exactly once (API)])
+AT_KEYWORDS([conntrack offload])
+AT_CHECK([ovstest test-conntrack offload-conn-established-api], [0], [.
+])
+AT_CLEANUP
diff --git a/tests/test-conntrack.c b/tests/test-conntrack.c
index 14ac20f422..17915e3e4b 100644
--- a/tests/test-conntrack.c
+++ b/tests/test-conntrack.c
@@ -17,6 +17,8 @@
 #include <config.h>
 #include "conntrack.h"
 #include "conntrack-private.h"
+#include "ct-offload.h"
+#include "ct-offload-dummy.h"
 
 #include "dp-packet.h"
 #include "fatal-signal.h"
@@ -741,6 +743,261 @@ test_private_multi_slot(struct ovs_cmdl_context *ctx 
OVS_UNUSED)
     printf(".\n");
 }
 
+/* ===========================================================================
+ * CT offload dummy provider tests
+ * ===========================================================================
+ */
+
+/* The dummy only compares pointer addresses and never dereferences them, so a
+ * small integer cast is sufficient. */
+#define FAKE_CONN(n)   ((struct conn *)(uintptr_t)(n))
+#define FAKE_NETDEV(n) ((struct netdev *)(uintptr_t)(n))
+
+/* Test: offload-conn-add
+ * Register the dummy provider, call ct_offload_conn_add() directly, and
+ * verify that the conn_add hook was invoked and the connection is tracked. */
+static void
+test_offload_conn_add(struct ovs_cmdl_context *ctx OVS_UNUSED)
+    OVS_NO_THREAD_SAFETY_ANALYSIS
+{
+    ct_offload_force_enable(true);
+    ct_offload_dummy_register();
+
+    struct conn *fake = FAKE_CONN(1);
+    struct ct_offload_ctx offload_ctx = {
+        .conn = fake, .netdev_in = NULL,
+    };
+    ct_offload_conn_add(&offload_ctx);
+
+    ovs_assert(ct_offload_dummy_n_added() == 1);
+    ovs_assert(ct_offload_dummy_contains(fake));
+
+    ct_offload_dummy_unregister();
+    ct_offload_force_enable(false);
+    printf(".\n");
+}
+
+/* Test: offload-conn-del
+ * Register the dummy, add then delete a connection via the API, and verify
+ * that conn_del was called and the connection is no longer tracked. */
+static void
+test_offload_conn_del(struct ovs_cmdl_context *ctx OVS_UNUSED)
+    OVS_NO_THREAD_SAFETY_ANALYSIS
+{
+    ct_offload_force_enable(true);
+    ct_offload_dummy_register();
+
+    struct conn *fake = FAKE_CONN(1);
+    struct ct_offload_ctx offload_ctx = {
+        .conn = fake, .netdev_in = NULL,
+    };
+
+    ct_offload_conn_add(&offload_ctx);
+    ovs_assert(ct_offload_dummy_n_added() == 1);
+
+    ct_offload_conn_del(&offload_ctx);
+    ovs_assert(ct_offload_dummy_n_deleted() == 1);
+    ovs_assert(!ct_offload_dummy_contains(fake));
+
+    ct_offload_dummy_unregister();
+    ct_offload_force_enable(false);
+    printf(".\n");
+}
+
+/* Test: offload-conn-update
+ * Register the dummy, add a connection, call ct_offload_conn_update()
+ * directly, and verify that a non-zero last-used timestamp is returned. */
+static void
+test_offload_conn_update(struct ovs_cmdl_context *ctx OVS_UNUSED)
+    OVS_NO_THREAD_SAFETY_ANALYSIS
+{
+    ct_offload_force_enable(true);
+    ct_offload_dummy_register();
+
+    struct conn *fake = FAKE_CONN(1);
+    struct ct_offload_ctx offload_ctx = {
+        .conn = fake, .netdev_in = NULL,
+    };
+
+    ct_offload_conn_add(&offload_ctx);
+
+    long long ts = ct_offload_conn_update(&offload_ctx);
+    ovs_assert(ts != 0);
+    ovs_assert(ct_offload_dummy_n_updated() == 1);
+
+    ct_offload_dummy_unregister();
+    ct_offload_force_enable(false);
+    printf(".\n");
+}
+
+/* Test: offload-multi-conn
+ * Register the dummy, add N connections via the API, and verify that each
+ * is tracked independently. */
+#define OFFLOAD_MULTI_N 4
+
+static void
+test_offload_multi_conn(struct ovs_cmdl_context *ctx OVS_UNUSED)
+    OVS_NO_THREAD_SAFETY_ANALYSIS
+{
+    ct_offload_force_enable(true);
+    ct_offload_dummy_register();
+
+    for (unsigned i = 1; i <= OFFLOAD_MULTI_N; i++) {
+        struct ct_offload_ctx offload_ctx = {
+            .conn = FAKE_CONN(i), .netdev_in = NULL,
+        };
+        ct_offload_conn_add(&offload_ctx);
+    }
+
+    ovs_assert(ct_offload_dummy_n_added() == OFFLOAD_MULTI_N);
+    for (unsigned i = 1; i <= OFFLOAD_MULTI_N; i++) {
+        ovs_assert(ct_offload_dummy_contains(FAKE_CONN(i)));
+    }
+
+    ct_offload_dummy_unregister();
+    ct_offload_force_enable(false);
+    printf(".\n");
+}
+
+/* Test: offload-conn-established
+ * Drive a TCP three-way handshake through conntrack_execute() with the dummy
+ * offload provider registered.  Verifies:
+ *  (a) conn_add fires on the SYN; conn_established does NOT fire yet.
+ *  (b) conn_established fires exactly once on the first ESTABLISHED reply
+ *      (SYN-ACK), making the entry bidirectional.
+ *  (c) A subsequent ACK does NOT trigger conn_established again. */
+static void
+test_offload_conn_established(struct ovs_cmdl_context *ctx OVS_UNUSED)
+    OVS_NO_THREAD_SAFETY_ANALYSIS
+{
+    ct_offload_init_for_tests();
+    ct_offload_force_enable(true);
+    ct_offload_dummy_register();
+
+    struct conntrack *lct = conntrack_init();
+    conntrack_set_tcp_seq_chk(lct, false);
+
+    long long now = time_msec();
+
+    struct eth_addr eth_a = ETH_ADDR_C(00, 00, 00, 00, 00, 01);
+    struct eth_addr eth_b = ETH_ADDR_C(00, 00, 00, 00, 00, 02);
+    ovs_be32 ip_a = inet_addr("10.0.0.1");
+    ovs_be32 ip_b = inet_addr("10.0.0.2");
+    uint16_t sport = 1234;
+    uint16_t dport = 80;
+
+    /* (a) SYN: forward direction, creates the connection entry. */
+    struct dp_packet *syn = build_eth_ip_packet(NULL, eth_a, eth_b,
+                                                ip_a, ip_b,
+                                                IPPROTO_TCP, 0);
+    build_tcp_packet(syn, sport, dport, TCP_SYN, NULL, 0);
+
+    struct dp_packet_batch syn_batch;
+    dp_packet_batch_init_packet(&syn_batch, syn);
+    conntrack_execute(lct, &syn_batch, htons(ETH_TYPE_IP), false, true, 0,
+                      NULL, NULL, NULL, NULL, now, 0, FAKE_NETDEV(1));
+
+    ovs_assert(ct_offload_dummy_n_added() == 1);
+    ovs_assert(ct_offload_dummy_n_established() == 0);
+
+    struct conn *conn = syn->md.conn;
+    ovs_assert(conn != NULL);
+    ovs_assert(ct_offload_conn_is_offloaded(conn));
+    ovs_assert(!ct_offload_conn_is_established(conn));
+
+    dp_packet_delete_batch(&syn_batch, true);
+
+    /* (b) SYN-ACK: reply direction, transitions to ESTABLISHED. */
+    struct dp_packet *synack = build_eth_ip_packet(NULL, eth_b, eth_a,
+                                                   ip_b, ip_a,
+                                                   IPPROTO_TCP, 0);
+    build_tcp_packet(synack, dport, sport, TCP_SYN | TCP_ACK, NULL, 0);
+
+    struct dp_packet_batch synack_batch;
+    dp_packet_batch_init_packet(&synack_batch, synack);
+    conntrack_execute(lct, &synack_batch, htons(ETH_TYPE_IP), false, true, 0,
+                      NULL, NULL, NULL, NULL, now, 0, FAKE_NETDEV(2));
+
+    ovs_assert(ct_offload_dummy_n_established() == 1);
+    ovs_assert(ct_offload_conn_is_established(conn));
+    ovs_assert(ct_offload_dummy_is_bidirectional(conn));
+
+    dp_packet_delete_batch(&synack_batch, true);
+
+    /* (c) ACK: another reply packet must NOT trigger conn_established
+     * again. */
+    struct dp_packet *ack = build_eth_ip_packet(NULL, eth_b, eth_a,
+                                                ip_b, ip_a,
+                                                IPPROTO_TCP, 0);
+    build_tcp_packet(ack, dport, sport, TCP_ACK, NULL, 0);
+
+    struct dp_packet_batch ack_batch;
+    dp_packet_batch_init_packet(&ack_batch, ack);
+    conntrack_execute(lct, &ack_batch, htons(ETH_TYPE_IP), false, true, 0,
+                      NULL, NULL, NULL, NULL, now, 0, FAKE_NETDEV(2));
+
+    ovs_assert(ct_offload_dummy_n_established() == 1);
+
+    dp_packet_delete_batch(&ack_batch, true);
+
+    conntrack_destroy(lct);
+    ct_offload_dummy_unregister();
+    ct_offload_force_enable(false);
+    printf(".\n");
+}
+
+/* Test: offload-conn-established-api
+ * Exercise ct_offload_conn_established() directly (not through
+ * conntrack_execute) to verify that the "exactly once" guarantee holds:
+ *   1. conn_add() transitions the slot to CT_OFFLOAD_STATE_ADDED.
+ *   2. conn_established() dispatches to the provider exactly once and
+ *      advances the slot to CT_OFFLOAD_STATE_EST.
+ *   3. A second conn_established() call is a no-op. */
+static void
+test_offload_conn_established_api(struct ovs_cmdl_context *ctx OVS_UNUSED)
+    OVS_NO_THREAD_SAFETY_ANALYSIS
+{
+    ct_offload_init_for_tests();
+    ct_offload_force_enable(true);
+    ct_offload_dummy_register();
+
+    struct conntrack *lct = conntrack_init();
+    long long now = time_msec();
+
+    ovs_be16 dl_type;
+    struct dp_packet *pkt = build_packet(1, 2, &dl_type);
+    struct dp_packet_batch batch;
+    dp_packet_batch_init_packet(&batch, pkt);
+    conntrack_execute(lct, &batch, dl_type, false, true, 0,
+                      NULL, NULL, NULL, NULL, now, 0, FAKE_NETDEV(1));
+    struct conn *conn = pkt->md.conn;
+    ovs_assert(conn != NULL);
+    dp_packet_delete_batch(&batch, true);
+
+    ovs_assert(ct_offload_dummy_n_added() == 1);
+    ovs_assert(ct_offload_dummy_n_established() == 0);
+    ovs_assert(ct_offload_conn_is_offloaded(conn));
+    ovs_assert(!ct_offload_conn_is_established(conn));
+
+    /* First call: must dispatch to the provider. */
+    struct ct_offload_ctx ctx1 = {
+        .conn = conn, .netdev_in = FAKE_NETDEV(2),
+    };
+    ct_offload_conn_established(&ctx1);
+    ovs_assert(ct_offload_dummy_n_established() == 1);
+    ovs_assert(ct_offload_conn_is_established(conn));
+    ovs_assert(ct_offload_dummy_is_bidirectional(conn));
+
+    /* Second call with the same conn: must be a no-op. */
+    ct_offload_conn_established(&ctx1);
+    ovs_assert(ct_offload_dummy_n_established() == 1);
+
+    conntrack_destroy(lct);
+    ct_offload_dummy_unregister();
+    ct_offload_force_enable(false);
+    printf(".\n");
+}
+
 static const struct ovs_cmdl_command commands[] = {
     /* Connection tracker tests. */
     /* Starts 'n_threads' threads. Each thread will send 'n_pkts' packets to
@@ -776,6 +1033,20 @@ static const struct ovs_cmdl_command commands[] = {
      test_private_destructor, OVS_RO},
     {"private-multi-slot", "", 0, 0,
      test_private_multi_slot, OVS_RO},
+    /* CT offload dummy provider tests.
+     * Each must be run as a separate ovstest invocation. */
+    {"offload-conn-add", "", 0, 0,
+     test_offload_conn_add, OVS_RO},
+    {"offload-conn-del", "", 0, 0,
+     test_offload_conn_del, OVS_RO},
+    {"offload-conn-update", "", 0, 0,
+     test_offload_conn_update, OVS_RO},
+    {"offload-multi-conn", "", 0, 0,
+     test_offload_multi_conn, OVS_RO},
+    {"offload-conn-established", "", 0, 0,
+     test_offload_conn_established, OVS_RO},
+    {"offload-conn-established-api", "", 0, 0,
+     test_offload_conn_established_api, OVS_RO},
 
     {NULL, NULL, 0, 0, NULL, OVS_RO},
 };
-- 
2.51.0

_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev

Reply via email to