diff --git a/NEWS b/NEWS
index 27ef12d599d9..7875f6673e34 100644
--- a/NEWS
+++ b/NEWS
@@ -35,10 +35,8 @@ v2.10.0 - xx xxx xxxx
      * ACL match conditions can now match on Port_Groups as well as address
        sets that are automatically generated by Port_Groups.  ACLs can be
        applied directly to Port_Groups as well.
-     * ovn-nbctl can now run as a daemon (long-lived, background process)
-       running commands in response to JSON-RPC requests received over a UNIX
-       socket. Requests to run commands can be sent using ovs-appctl tool, same
-       as for any other OVS/OVN daemon. See ovn-nbctl(8) for details.
+     * ovn-nbctl can now run as a daemon (long-lived, background process).
+       See ovn-nbctl(8) for details.
    - DPDK:
      * New 'check-dpdk' Makefile target to run a new system testsuite.
        See Testing topic for the details.
diff --git a/lib/daemon.h b/lib/daemon.h
index bfa06408c390..f33e9df8df7a 100644
--- a/lib/daemon.h
+++ b/lib/daemon.h
@@ -84,6 +84,15 @@
             daemon_set_new_user(optarg);        \
             break;
 
+#define DAEMON_OPTION_CASES                     \
+        case OPT_DETACH:                        \
+        case OPT_NO_SELF_CONFINEMENT:           \
+        case OPT_NO_CHDIR:                      \
+        case OPT_PIDFILE:                       \
+        case OPT_OVERWRITE_PIDFILE:             \
+        case OPT_MONITOR:                       \
+        case OPT_USER_GROUP:
+
 void set_detach(void);
 void daemon_set_monitor(void);
 void set_no_chdir(void);
@@ -138,6 +147,16 @@ pid_t read_pidfile(const char *name);
         case OPT_USER_GROUP:                    \
             daemon_set_new_user(optarg);
 
+#define DAEMON_OPTION_CASES                     \
+        case OPT_DETACH:                        \
+        case OPT_NO_SELF_CONFINEMENT:           \
+        case OPT_NO_CHDIR:                      \
+        case OPT_PIDFILE:                       \
+        case OPT_PIPE_HANDLE:                   \
+        case OPT_SERVICE:                       \
+        case OPT_SERVICE_MONITOR:               \
+        case OPT_USER_GROUP:
+
 void control_handler(DWORD request);
 void set_pipe_handle(const char *pipe_handle);
 #endif /* _WIN32 */
diff --git a/lib/stream-ssl.h b/lib/stream-ssl.h
index 4bfe09b002c9..937f7c653ba9 100644
--- a/lib/stream-ssl.h
+++ b/lib/stream-ssl.h
@@ -58,6 +58,9 @@ void stream_ssl_set_ciphers(const char *arg);
                                                         \
         case OPT_SSL_CIPHERS:                           \
             stream_ssl_set_ciphers(optarg);             \
-            break;                                      
+            break;
+
+#define STREAM_SSL_CASES \
+    case 'p': case 'c': case 'C': case OPT_SSL_PROTOCOLS: case OPT_SSL_CIPHERS:
 
 #endif /* stream-ssl.h */
diff --git a/ovn/utilities/ovn-nbctl.8.xml b/ovn/utilities/ovn-nbctl.8.xml
index f09ef8767d2f..5e18fa7c06ee 100644
--- a/ovn/utilities/ovn-nbctl.8.xml
+++ b/ovn/utilities/ovn-nbctl.8.xml
@@ -971,15 +971,62 @@
     <h1>Daemon Mode</h1>
 
     <p>
-      If <code>ovn-nbctl</code> is invoked with the <code>--detach</code>
-      option (see <code>Daemon Options</code>, below), it runs in the
-      background as a daemon and accepts commands from <code>ovs-appctl</code>
-      (or another JSON-RPC client) indefinitely.  The currently supported
-      commands are described below.
+      When it is invoked in the most ordinary way, <code>ovn-nbctl</code>
+      connects to an OVSDB server that hosts the northbound database, retrieves
+      a partial copy of the database that is complete enough to do its work,
+      sends a transaction request to the server, and receives and processes the
+      server's reply.  In common interactive use, this is fine, but if the
+      database is large, the step in which <code>ovn-nbctl</code> retrieves a
+      partial copy of the database can take a long time, which yields poor
+      performance overall.
     </p>
 
     <p>
+      To improve performance in such a case, <code>ovn-nbctl</code> offers a
+      "daemon mode," in which the user first starts <code>ovn-nbctl</code>
+      running in the background and afterward uses the daemon to execute
+      operations.  Over several <code>ovn-nbctl</code> command invocations,
+      this performs better overall because it retrieves a copy of the database
+      only once at the beginning, not once per program run.
+    </p>
+
+    <p>
+      Use the <code>--detach</code> option to start an <code>ovn-nbctl</code>
+      daemon.  With this option, <code>ovn-nbctl</code> prints the name of a
+      control socket to stdout.  The client should save this name in
+      environment variable <env>OVN_NB_DAEMON</env>.  Under the Bourne shell
+      this might be done like this:
+    </p>
+
+    <pre fixed="yes">
+      export OVN_NB_DAEMON=$(ovn-nbctl --pidfile --detach)
+    </pre>
+
+    <p>
+      When <env>OVN_NB_DAEMON</env> is set, <code>ovn-nbctl</code>
+      automatically and transparently uses the daemon to execute its commands.
+    </p>
+
+    <p>
+      When the daemon is no longer needed, kill it and unset the environment
+      variable, e.g.:
+    </p>
+
+    <pre fixed="yes">
+      kill $(cat /var/run/ovn-nbctl.pid)
+      unset OVN_NB_DAEMON
+    </pre>
+
+    <p>
+      Daemon mode is experimental.
+    </p>
 
+    <h2>Daemon Commands</h2>
+
+    <p>
+      Daemon mode is internally implemented using the same mechanism used by
+      <code>ovs-appctl</code>.  One may also use <code>ovs-appctl</code>
+      directly with the following commands:
     </p>
 
     <dl>
@@ -1001,10 +1048,6 @@
       <dd>Causes <code>ovn-nbctl</code> to gracefully terminate.</dd>
     </dl>
 
-    <p>
-      Daemon mode is considered experimental.
-    </p>
-
     <h1>Options</h1>
 
     <dl>
diff --git a/ovn/utilities/ovn-nbctl.c b/ovn/utilities/ovn-nbctl.c
index 0659ced378ef..35a3af3d5ff3 100644
--- a/ovn/utilities/ovn-nbctl.c
+++ b/ovn/utilities/ovn-nbctl.c
@@ -24,6 +24,7 @@
 #include "db-ctl-base.h"
 #include "dirs.h"
 #include "fatal-signal.h"
+#include "jsonrpc.h"
 #include "openvswitch/json.h"
 #include "ovn/lib/acl-log.h"
 #include "ovn/lib/ovn-nb-idl.h"
@@ -91,7 +92,15 @@ static unixctl_cb_func server_cmd_run;
 
 static void nbctl_cmd_init(void);
 OVS_NO_RETURN static void usage(void);
-static void parse_options(int argc, char *argv[], struct shash *local_options);
+static struct option *get_all_options(void);
+static bool has_option(const struct ovs_cmdl_parsed_option *, size_t n,
+                       int option);
+static void nbctl_client(const char *socket_name,
+                         const struct ovs_cmdl_parsed_option *, size_t n,
+                         int argc, char *argv[]);
+static bool will_detach(const struct ovs_cmdl_parsed_option *, size_t n);
+static void apply_options_direct(const struct ovs_cmdl_parsed_option *,
+                                 size_t n, struct shash *local_options);
 static char * OVS_WARN_UNUSED_RESULT run_prerequisites(struct ctl_command[],
                                                        size_t n_commands,
                                                        struct ovsdb_idl *);
@@ -123,10 +132,47 @@ main(int argc, char *argv[])
 
     nbctl_cmd_init();
 
-    /* Parse command line. */
+    /* ovn-nbctl has three operation modes:
+     *
+     *    - Direct: Executes commands by contacting ovsdb-server directly.
+     *
+     *    - Server: Runs in the background as a daemon waiting for requests
+     *      from ovn-nbctl running in client mode.
+     *
+     *    - Client: Executes commands by passing them to an ovn-nbctl running
+     *      in the server mode.
+     *
+     * At this point we don't know what mode we're running in.  The mode partly
+     * depends on the command line.  So, for now we transform the command line
+     * into a parsed form, and figure out what to do with it later.
+     */
     char *args = process_escape_args(argv);
+    struct ovs_cmdl_parsed_option *parsed_options;
+    size_t n_parsed_options;
+    char *error_s = ovs_cmdl_parse_all(argc, argv, get_all_options(),
+                                       &parsed_options, &n_parsed_options);
+    if (error_s) {
+        ctl_fatal("%s", error_s);
+    }
+
+    /* Now figure out the operation mode:
+     *
+     *    - A --detach option implies server mode.
+     *
+     *    - An OVN_NB_DAEMON environment variable implies client mode.
+     *
+     *    - Otherwise, we're in direct mode. */
+    char *socket_name = getenv("OVN_NB_DAEMON");
+    if (socket_name && socket_name[0]
+        && !will_detach(parsed_options, n_parsed_options)) {
+        nbctl_client(socket_name, parsed_options, n_parsed_options,
+                     argc, argv);
+    }
+
+    /* Parse command line. */
     shash_init(&local_options);
-    parse_options(argc, argv, &local_options);
+    apply_options_direct(parsed_options, n_parsed_options, &local_options);
+    free(parsed_options);
     argc -= optind;
     argv += optind;
 
@@ -256,6 +302,8 @@ enum {
     OPT_LOCAL,
     OPT_COMMANDS,
     OPT_OPTIONS,
+    OPT_LEADER_ONLY,
+    OPT_NO_LEADER_ONLY,
     OPT_BOOTSTRAP_CA_CERT,
     MAIN_LOOP_OPTION_ENUMS,
     DAEMON_OPTION_ENUMS,
@@ -347,8 +395,8 @@ append_command_options(const struct option *options, int opt_val)
     return o;
 }
 
-static void
-parse_options(int argc, char *argv[], struct shash *local_options)
+static struct option *
+get_all_options(void)
 {
     static const struct option global_long_options[] = {
         {"db", required_argument, NULL, OPT_DB},
@@ -356,8 +404,8 @@ parse_options(int argc, char *argv[], struct shash *local_options)
         {"help", no_argument, NULL, 'h'},
         {"commands", no_argument, NULL, OPT_COMMANDS},
         {"options", no_argument, NULL, OPT_OPTIONS},
-        {"leader-only", no_argument, &leader_only, true},
-        {"no-leader-only", no_argument, &leader_only, false},
+        {"leader-only", no_argument, NULL, OPT_LEADER_ONLY},
+        {"no-leader-only", no_argument, NULL, OPT_NO_LEADER_ONLY},
         {"version", no_argument, NULL, 'V'},
         MAIN_LOOP_LONG_OPTIONS,
         DAEMON_LONG_OPTIONS,
@@ -367,24 +415,57 @@ parse_options(int argc, char *argv[], struct shash *local_options)
         TABLE_LONG_OPTIONS,
         {NULL, 0, NULL, 0},
     };
-    const int n_global_long_options = ARRAY_SIZE(global_long_options) - 1;
-    struct option *options;
-    size_t i;
 
-    options = append_command_options(global_long_options, OPT_LOCAL);
+    static struct option *options;
+    if (!options) {
+        options = append_command_options(global_long_options, OPT_LOCAL);
+    }
 
-    struct ovs_cmdl_parsed_option *parsed_options;
-    size_t n_po;
-    char *error = ovs_cmdl_parse_all(argc, argv, options,
-                                     &parsed_options, &n_po);
-    if (error) {
-        ctl_fatal("%s", error);
+    return options;
+}
+
+static bool
+has_option(const struct ovs_cmdl_parsed_option *parsed_options, size_t n,
+           int option)
+{
+    for (const struct ovs_cmdl_parsed_option *po = parsed_options;
+         po < &parsed_options[n]; po++) {
+        if (po->o->val == option) {
+            return true;
+        }
+    }
+    return false;
+}
+
+static bool
+will_detach(const struct ovs_cmdl_parsed_option *parsed_options, size_t n)
+{
+    return has_option(parsed_options, n, OPT_DETACH);
+}
+
+static char * OVS_WARN_UNUSED_RESULT
+add_local_option(const char *name, const char *arg,
+                 struct shash *local_options)
+{
+    char *full_name = xasprintf("--%s", name);
+    if (shash_find(local_options, full_name)) {
+        char *error = xasprintf("'%s' option specified multiple times",
+                                full_name);
+        free(full_name);
+        return error;
     }
+    shash_add_nocopy(local_options, full_name, nullable_xstrdup(arg));
+    return NULL;
+}
 
+static void
+apply_options_direct(const struct ovs_cmdl_parsed_option *parsed_options,
+                     size_t n, struct shash *local_options)
+{
     for (const struct ovs_cmdl_parsed_option *po = parsed_options;
-         po < &parsed_options[n_po]; po++) {
+         po < &parsed_options[n]; po++) {
         bool handled;
-        error = handle_main_loop_option(po->o->val, po->arg, &handled);
+        char *error = handle_main_loop_option(po->o->val, po->arg, &handled);
         if (error) {
             ctl_fatal("%s", error);
         }
@@ -403,13 +484,10 @@ parse_options(int argc, char *argv[], struct shash *local_options)
             break;
 
         case OPT_LOCAL:
-            if (shash_find(local_options, po->o->name)) {
-                ctl_fatal("'%s' option specified multiple times",
-                            po->o->name);
+            error = add_local_option(po->o->name, po->arg, local_options);
+            if (error) {
+                ctl_fatal("%s", error);
             }
-            shash_add_nocopy(local_options,
-                             xasprintf("--%s", po->o->name),
-                             nullable_xstrdup(po->arg));
             break;
 
         case 'h':
@@ -421,9 +499,17 @@ parse_options(int argc, char *argv[], struct shash *local_options)
             /* fall through */
 
         case OPT_OPTIONS:
-            ctl_print_options(global_long_options);
+            ctl_print_options(get_all_options());
             /* fall through */
 
+        case OPT_LEADER_ONLY:
+            leader_only = true;
+            break;
+
+        case OPT_NO_LEADER_ONLY:
+            leader_only = false;
+            break;
+
         case 'V':
             ovs_print_version(0, 0);
             printf("DB Schema %s\n", nbrec_get_db_version());
@@ -448,16 +534,10 @@ parse_options(int argc, char *argv[], struct shash *local_options)
             break;
         }
     }
-    free(parsed_options);
 
     if (!db) {
         db = default_nb_db();
     }
-
-    for (i = n_global_long_options; options[i].name; i++) {
-        free(CONST_CAST(char *, options[i].name));
-    }
-    free(options);
 }
 
 static void
@@ -641,6 +721,10 @@ Other options:\n\
     exit(EXIT_SUCCESS);
 }
 
+/* One should not use ctl_fatal() within commands because it will kill the
+ * daemon if we're in daemon mode.  Use ctl_error() instead and return
+ * gracefully.  */
+#define ctl_fatal dont_use_ctl_fatal_use_ctl_error_and_return
 
 /* Find a logical router given its id. */
 static char * OVS_WARN_UNUSED_RESULT
@@ -2368,35 +2452,41 @@ nbctl_meter_add(struct ctl_context *ctx)
     const char *name = ctx->argv[1];
     NBREC_METER_FOR_EACH (meter, ctx->idl) {
         if (!strcmp(meter->name, name)) {
-            ctl_fatal("meter with name \"%s\" already exists", name);
+            ctl_error(ctx, "meter with name \"%s\" already exists", name);
+            return;
         }
     }
 
     if (!strncmp(name, "__", 2)) {
-        ctl_fatal("meter names that begin with \"__\" are reserved");
+        ctl_error(ctx, "meter names that begin with \"__\" are reserved");
+        return;
     }
 
     const char *action = ctx->argv[2];
     if (strcmp(action, "drop")) {
-        ctl_fatal("action must be \"drop\"");
+        ctl_error(ctx, "action must be \"drop\"");
+        return;
     }
 
     int64_t rate;
     if (!ovs_scan(ctx->argv[3], "%"SCNd64, &rate)
         || rate < 1 || rate > UINT32_MAX) {
-        ctl_fatal("rate must be in the range 1...4294967295");
+        ctl_error(ctx, "rate must be in the range 1...4294967295");
+        return;
     }
 
     const char *unit = ctx->argv[4];
     if (strcmp(unit, "kbps") && strcmp(unit, "pktps")) {
-        ctl_fatal("unit must be \"kbps\" or \"pktps\"");
+        ctl_error(ctx, "unit must be \"kbps\" or \"pktps\"");
+        return;
     }
 
     int64_t burst = 0;
     if (ctx->argc > 5) {
         if (!ovs_scan(ctx->argv[5], "%"SCNd64, &burst)
             || burst < 0 || burst > UINT32_MAX) {
-            ctl_fatal("burst must be in the range 0...4294967295");
+            ctl_error(ctx, "burst must be in the range 0...4294967295");
+            return;
         }
     }
 
@@ -4965,6 +5055,10 @@ nbctl_cmd_init(void)
     ctl_init(&nbrec_idl_class, nbrec_table_classes, tables, NULL, nbctl_exit);
     ctl_register_commands(nbctl_commands);
 }
+
+/* Server implementation. */
+
+#undef ctl_fatal
 
 static const struct option *
 find_option_by_value(const struct option *options, int value)
@@ -5021,14 +5115,10 @@ server_parse_options(int argc, char *argv[], struct shash *local_options,
 
         switch (c) {
         case OPT_LOCAL:
-            if (shash_find(local_options, options[idx].name)) {
-                error = xasprintf("'%s' option specified multiple times",
-                                  options[idx].name);
+            error = add_local_option(options[idx].name, optarg, local_options);
+            if (error) {
                 goto out;
             }
-            shash_add_nocopy(local_options,
-                             xasprintf("--%s", options[idx].name),
-                             nullable_xstrdup(optarg));
             break;
 
         VLOG_OPTION_HANDLERS
@@ -5177,7 +5267,7 @@ static void
 server_cmd_init(struct ovsdb_idl *idl, bool *exiting)
 {
     unixctl_command_register("exit", "", 0, 0, server_cmd_exit, exiting);
-    unixctl_command_register("run", "", 1, INT_MAX, server_cmd_run, idl);
+    unixctl_command_register("run", "", 0, INT_MAX, server_cmd_run, idl);
 }
 
 static void
@@ -5192,6 +5282,8 @@ server_loop(struct ovsdb_idl *idl)
         ctl_fatal("failed to create unixctl server (%s)",
                   ovs_retval_to_string(error));
     }
+    puts(unixctl_server_get_path(server));
+    fflush(stdout);
     server_cmd_init(idl, &exiting);
 
     for (;;) {
@@ -5217,3 +5309,113 @@ server_loop(struct ovsdb_idl *idl)
 
     unixctl_server_destroy(server);
 }
+
+static void
+nbctl_client(const char *socket_name,
+             const struct ovs_cmdl_parsed_option *parsed_options, size_t n,
+             int argc, char *argv[])
+{
+    struct svec args = SVEC_EMPTY_INITIALIZER;
+
+    for (const struct ovs_cmdl_parsed_option *po = parsed_options;
+         po < &parsed_options[n]; po++) {
+        optarg = po->arg;
+        switch (po->o->val) {
+        case OPT_DB:
+            VLOG_WARN("not using ovn-nbctl daemon because of %s option",
+                      po->o->name);
+            svec_destroy(&args);
+            return;
+
+        case OPT_NO_SYSLOG:
+            vlog_set_levels(&this_module, VLF_SYSLOG, VLL_WARN);
+            break;
+
+        case 'h':
+            usage();
+            exit(EXIT_SUCCESS);
+
+        case OPT_COMMANDS:
+            ctl_print_commands();
+            /* fall through */
+
+        case OPT_OPTIONS:
+            ctl_print_options(get_all_options());
+            /* fall through */
+
+        case OPT_LEADER_ONLY:
+        case OPT_NO_LEADER_ONLY:
+        case OPT_BOOTSTRAP_CA_CERT:
+        STREAM_SSL_CASES
+        DAEMON_OPTION_CASES
+            VLOG_INFO("using ovn-nbctl daemon, ignoring %s option",
+                      po->o->name);
+            break;
+
+        case 'V':
+            ovs_print_version(0, 0);
+            printf("DB Schema %s\n", nbrec_get_db_version());
+            exit(EXIT_SUCCESS);
+
+        case 't':
+            timeout = strtoul(po->arg, NULL, 10);
+            if (timeout < 0) {
+                ctl_fatal("value %s on -t or --timeout is invalid", po->arg);
+            }
+            break;
+
+        VLOG_OPTION_HANDLERS
+        TABLE_OPTION_HANDLERS(&table_style)
+
+        case OPT_LOCAL:
+        default:
+            if (po->arg) {
+                svec_add_nocopy(&args,
+                                xasprintf("--%s=%s", po->o->name, po->arg));
+            } else {
+                svec_add_nocopy(&args, xasprintf("--%s", po->o->name));
+            }
+            break;
+        }
+    }
+    svec_add(&args, "--");
+    for (int i = optind; i < argc; i++) {
+        svec_add(&args, argv[i]);
+    }
+
+    if (timeout) {
+        time_alarm(timeout);
+    }
+
+    struct jsonrpc *client;
+    int error = unixctl_client_create(socket_name, &client);
+    if (error) {
+        ctl_fatal("%s: could not connect to ovn-nb daemon (%s); "
+                  "unset OVN_NB_DAEMON to avoid using daemon",
+                  socket_name, ovs_strerror(error));
+    }
+
+    char *cmd_result;
+    char *cmd_error;
+    error = unixctl_client_transact(client, "run",
+                                    args.n, args.names,
+                                    &cmd_result, &cmd_error);
+    if (error) {
+        ctl_fatal("%s: transaction error (%s)",
+                  socket_name, ovs_strerror(error));
+    }
+    svec_destroy(&args);
+
+    int exit_status;
+    if (cmd_error) {
+        exit_status = EXIT_FAILURE;
+        fprintf(stderr, "%s: %s", program_name, cmd_error);
+    } else {
+        exit_status = EXIT_SUCCESS;
+        fputs(cmd_result, stdout);
+    }
+    free(cmd_result);
+    free(cmd_error);
+    jsonrpc_close(client);
+    exit(exit_status);
+}
diff --git a/tests/ovn-nbctl.at b/tests/ovn-nbctl.at
index 88e37191ee0a..725665b76d3f 100644
--- a/tests/ovn-nbctl.at
+++ b/tests/ovn-nbctl.at
@@ -1,30 +1,59 @@
 AT_BANNER([ovn-nbctl])
 
+OVS_START_SHELL_HELPERS
 # OVN_NBCTL_TEST_START
 m4_define([OVN_NBCTL_TEST_START],
-  [dnl Create ovn-nb database.
-   AT_KEYWORDS([ovn])
+  [AT_KEYWORDS([ovn])
+   AT_CAPTURE_FILE([ovsdb-server.log])
+   ovn_nbctl_test_start $1])
+ovn_nbctl_test_start() {
+   dnl Create ovn-nb database.
    AT_CHECK([ovsdb-tool create ovn-nb.db $abs_top_srcdir/ovn/ovn-nb.ovsschema])
 
    dnl Start ovsdb-server.
    AT_CHECK([ovsdb-server --detach --no-chdir --pidfile --log-file --remote=punix:$OVS_RUNDIR/ovnnb_db.sock ovn-nb.db], [0], [], [stderr])
    on_exit "kill `cat ovsdb-server.pid`"
+   AS_CASE([$1],
+     [daemon],
+       [export OVN_NB_DAEMON=$(ovn-nbctl --pidfile --detach --log-file -vsocket_util:off)
+        on_exit "kill `cat ovn-nbctl.pid`"],
+     [direct], [],
+     [*], [AT_FAIL_IF(:)])
    AT_CHECK([ovn-nbctl init])
    AT_CHECK([[sed < stderr '
 /vlog|INFO|opened log file/d
 /ovsdb_server|INFO|ovsdb-server (Open vSwitch)/d']])
-   AT_CAPTURE_FILE([ovsdb-server.log])
-])
+}
 
 # OVN_NBCTL_TEST_STOP
-m4_define([OVN_NBCTL_TEST_STOP],
-  [AT_CHECK([check_logs "$1"])
-   OVS_APP_EXIT_AND_WAIT([ovsdb-server])])
-
-
-AT_SETUP([ovn-nbctl - basic switch commands])
-OVN_NBCTL_TEST_START
-
+m4_define([OVN_NBCTL_TEST_STOP], [ovn_nbctl_test_stop])
+ovn_nbctl_test_stop() {
+   AT_CHECK([check_logs "$1"])
+   OVS_APP_EXIT_AND_WAIT([ovsdb-server])
+}
+OVS_END_SHELL_HELPERS
+
+# OVN_NBCTL_TEST(NAME, TITLE, COMMANDS)
+m4_define([OVN_NBCTL_TEST],
+   [OVS_START_SHELL_HELPERS
+    $1() {
+      $3
+    }
+    OVS_END_SHELL_HELPERS
+
+    AT_SETUP([ovn-nbctl - $2 - direct])
+    OVN_NBCTL_TEST_START direct
+    $1
+    OVN_NBCTL_TEST_STOP
+    AT_CLEANUP
+
+    AT_SETUP([ovn-nbctl - $2 - daemon])
+    OVN_NBCTL_TEST_START daemon
+    $1
+    OVN_NBCTL_TEST_STOP
+    AT_CLEANUP])
+
+OVN_NBCTL_TEST([ovn_nbctl_basic_switch], [basic switch commands], [
 AT_CHECK([ovn-nbctl ls-add ls0])
 AT_CHECK([ovn-nbctl ls-list | uuidfilt], [0], [dnl
 <0> (ls0)
@@ -73,16 +102,11 @@ AT_CHECK([ovn-nbctl --add-duplicate ls-add], [1], [],
 ])
 AT_CHECK([ovn-nbctl --may-exist ls-add], [1], [],
   [ovn-nbctl: --may-exist requires specifying a name
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - basic logical switch port commands])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_basic_lsp], [basic logical switch port commands], [
 AT_CHECK([ovn-nbctl ls-add ls0])
 AT_CHECK([ovn-nbctl lsp-add ls0 lp0])
 AT_CHECK([ovn-nbctl lsp-add ls0 lp0], [1], [],
@@ -125,16 +149,11 @@ AT_CHECK([ovn-nbctl --may-exist lsp-add ls0 lp2 lp3 10], [1], [],
 AT_CHECK([ovn-nbctl clear Logical_Switch_Port lp2 tag_request])
 AT_CHECK([ovn-nbctl --may-exist lsp-add ls0 lp2 lp3 5], [1], [],
   [ovn-nbctl: lp2: port already exists but has no tag_request
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - lport addresses])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_lport_addresses], [lport addresses], [
 AT_CHECK([ovn-nbctl ls-add ls0])
 AT_CHECK([ovn-nbctl lsp-add ls0 lp0])
 AT_CHECK([ovn-nbctl lsp-get-addresses lp0], [0], [dnl
@@ -148,16 +167,11 @@ unknown
 
 AT_CHECK([ovn-nbctl lsp-set-addresses lp0])
 AT_CHECK([ovn-nbctl lsp-get-addresses lp0], [0], [dnl
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - port security])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_port_security], [port security], [
 AT_CHECK([ovn-nbctl ls-add ls0])
 AT_CHECK([ovn-nbctl lsp-add ls0 lp0])
 AT_CHECK([ovn-nbctl lsp-get-addresses lp0], [0], [dnl
@@ -171,15 +185,13 @@ aa:bb:cc:dd:ee:ff
 
 AT_CHECK([ovn-nbctl lsp-set-port-security lp0])
 AT_CHECK([ovn-nbctl lsp-get-port-security lp0], [0], [dnl
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-m4_define([OVN_NBCTL_TEST_ACL],
-  [AT_CHECK([ovn-nbctl $2 --log acl-add $1 from-lport 600 udp drop])
+OVN_NBCTL_TEST([ovn_nbctl_acls], [ACLs], [
+ovn_nbctl_test_acl() {
+   AT_CHECK([ovn-nbctl $2 --log acl-add $1 from-lport 600 udp drop])
    AT_CHECK([ovn-nbctl $2 --log --name=test --severity=info acl-add $1 to-lport 500 udp drop])
    AT_CHECK([ovn-nbctl $2 acl-add $1 from-lport 400 tcp drop])
    AT_CHECK([ovn-nbctl $2 acl-add $1 to-lport 300 tcp drop])
@@ -222,17 +234,14 @@ from-lport   200 (ip) drop
 from-lport   600 (udp) drop
 from-lport   200 (ip) drop
 ])
-])
-
-AT_SETUP([ovn-nbctl - ACLs])
-OVN_NBCTL_TEST_START
+}
 
 AT_CHECK([ovn-nbctl ls-add ls0])
-OVN_NBCTL_TEST_ACL([ls0])
+ovn_nbctl_test_acl ls0
 AT_CHECK([ovn-nbctl ls-add ls1])
-OVN_NBCTL_TEST_ACL([ls1], [--type=switch])
+ovn_nbctl_test_acl ls1 --type=switch
 AT_CHECK([ovn-nbctl create port_group name=pg0], [0], [ignore])
-OVN_NBCTL_TEST_ACL([pg0], [--type=port-group])
+ovn_nbctl_test_acl pg0 --type=port-group
 
 dnl Test when port group doesn't exist
 AT_CHECK([ovn-nbctl --type=port-group acl-add pg1 to-lport 100 ip drop], [1], [], [dnl
@@ -243,16 +252,11 @@ dnl Test when same name exists in logical switches and portgroups
 AT_CHECK([ovn-nbctl create port_group name=ls0], [0], [ignore])
 AT_CHECK([ovn-nbctl acl-add ls0 to-lport 100 ip drop], [1], [], [stderr])
 AT_CHECK([grep 'exists in both' stderr], [0], [ignore])
-AT_CHECK([ovn-nbctl --type=port-group acl-add ls0 to-lport 100 ip drop], [0], [ignore])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+AT_CHECK([ovn-nbctl --type=port-group acl-add ls0 to-lport 100 ip drop], [0], [ignore])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - QoS])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_qos], [QoS], [
 AT_CHECK([ovn-nbctl ls-add ls0])
 AT_CHECK([ovn-nbctl qos-add ls0 from-lport 600 tcp dscp=63])
 AT_CHECK([ovn-nbctl qos-add ls0 from-lport 500 udp rate=100 burst=1000])
@@ -317,16 +321,11 @@ AT_CHECK([ovn-nbctl qos-add ls0 from-lport 600 ip dscpa=-1], [1], [],
 
 AT_CHECK([ovn-nbctl qos-add ls0 from-lport 600 ip burst=123], [1], [],
 [ovn-nbctl: Either "rate" and/or "dscp" must be specified
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - Meters])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_meters], [meters], [
 AT_CHECK([ovn-nbctl meter-add meter1 drop 10 kbps])
 AT_CHECK([ovn-nbctl meter-add meter2 drop 3 kbps 2])
 AT_CHECK([ovn-nbctl meter-add meter3 drop 100 kbps 200])
@@ -384,15 +383,11 @@ meter4: bands:
 dnl Delete all meters.
 AT_CHECK([ovn-nbctl meter-del])
 AT_CHECK([ovn-nbctl meter-list], [0], [dnl
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - NATs])
-OVN_NBCTL_TEST_START
+OVN_NBCTL_TEST([ovn_nbctl_nats], [NATs], [
 AT_CHECK([ovn-nbctl lr-add lr0])
 AT_CHECK([ovn-nbctl lr-nat-add lr0 snatt 30.0.0.2 192.168.1.2], [1], [],
 [ovn-nbctl: snatt: type must be one of "dnat", "snat" and "dnat_and_snat".
@@ -537,15 +532,11 @@ snat             30.0.0.1           192.168.1.0/24
 AT_CHECK([ovn-nbctl lr-nat-del lr0])
 AT_CHECK([ovn-nbctl lr-nat-list lr0], [0], [])
 AT_CHECK([ovn-nbctl lr-nat-del lr0])
-AT_CHECK([ovn-nbctl lr-nat-del lr0 dnat])
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+AT_CHECK([ovn-nbctl lr-nat-del lr0 dnat])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - LBs])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_lbs], [LBs], [
 dnl Add two LBs.
 AT_CHECK([ovn-nbctl -vsocket_util:off lb-add lb0 30.0.0.10:80a 192.168.10.10:80,192.168.10.20:80 tcp], [1], [],
 [ovn-nbctl: 30.0.0.10:80a: should be an IP address (or an IP address and a port number with : as a separator).
@@ -761,16 +752,11 @@ AT_CHECK([ovn-nbctl lr-lb-add lr0 lb0])
 AT_CHECK([ovn-nbctl lr-lb-add lr0 lb1])
 AT_CHECK([ovn-nbctl lr-lb-add lr0 lb3])
 AT_CHECK([ovn-nbctl lr-lb-del lr0])
-AT_CHECK([ovn-nbctl lr-lb-list lr0 | uuidfilt], [0], [])
+AT_CHECK([ovn-nbctl lr-lb-list lr0 | uuidfilt], [0], [])])
 
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
-dnl
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - LBs IPv6])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_lbs_ipv6], [LBs IPv6], [
 dnl A bunch of commands that should fail
 AT_CHECK([ovn-nbctl -vsocket_util:off lb-add lb0 [[ae0f::10]]:80a [[fd0f::10]]:80,[[fd0f::20]]:80 tcp], [1], [],
 [ovn-nbctl: [[ae0f::10]]:80a: should be an IP address (or an IP address and a port number with : as a separator).
@@ -1025,15 +1011,11 @@ AT_CHECK([ovn-nbctl lr-lb-add lr0 lb0])
 AT_CHECK([ovn-nbctl lr-lb-add lr0 lb1])
 AT_CHECK([ovn-nbctl lr-lb-add lr0 lb3])
 AT_CHECK([ovn-nbctl lr-lb-del lr0])
-AT_CHECK([ovn-nbctl lr-lb-list lr0 | uuidfilt], [0], [])
+AT_CHECK([ovn-nbctl lr-lb-list lr0 | uuidfilt], [0], [])])
 
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - basic logical router commands])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_basic_lr], [basic logical router commands], [
 AT_CHECK([ovn-nbctl lr-add lr0])
 AT_CHECK([ovn-nbctl lr-list | uuidfilt], [0], [dnl
 <0> (lr0)
@@ -1082,16 +1064,11 @@ AT_CHECK([ovn-nbctl --add-duplicate lr-add], [1], [],
 ])
 AT_CHECK([ovn-nbctl --may-exist lr-add], [1], [],
   [ovn-nbctl: --may-exist requires specifying a name
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - basic logical router port commands])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_basic_lrp], [basic logical router port commands], [
 AT_CHECK([ovn-nbctl lr-add lr0])
 AT_CHECK([ovn-nbctl lrp-add lr0 lrp0 00:00:00:01:02 192.168.1.1/24], [1], [],
   [ovn-nbctl: lrp0: invalid mac address 00:00:00:01:02
@@ -1157,15 +1134,11 @@ AT_CHECK([ovn-nbctl --may-exist lrp-add lr0 lrp1 00:00:00:01:02:03 192.168.1.1/2
   [ovn-nbctl: lrp1: port already exists with different network
 ])
 
-AT_CHECK([ovn-nbctl --may-exist lrp-add lr0 lrp1 00:00:00:01:02:03 10.0.0.1/24 192.168.1.1/24 peer=lrp1-peer])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+AT_CHECK([ovn-nbctl --may-exist lrp-add lr0 lrp1 00:00:00:01:02:03 10.0.0.1/24 192.168.1.1/24 peer=lrp1-peer])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - logical router port gateway chassis])
-OVN_NBCTL_TEST_START
+OVN_NBCTL_TEST([ovn_nbctl_lrp_gw_chassi], [logical router port gateway chassis], [
 AT_CHECK([ovn-nbctl lr-add lr0])
 AT_CHECK([ovn-nbctl lrp-add lr0 lrp0 00:00:00:01:02:03 192.168.1.1/24])
 AT_CHECK([ovn-nbctl lrp-get-gateway-chassis lrp0], [0], [])
@@ -1221,16 +1194,11 @@ AT_CHECK([ovn-nbctl lrp-get-gateway-chassis lrp0], [0], [dnl
 lrp0-chassis2    10
 lrp0-chassis3     5
 lrp0-chassis1     1
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - logical router port enable and disable])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_lrp_enable], [logical router port enable and disable], [
 AT_CHECK([ovn-nbctl lr-add lr0])
 AT_CHECK([ovn-nbctl lrp-add lr0 lrp0 00:00:00:01:02:03 192.168.1.1/24])
 AT_CHECK([ovn-nbctl lrp-get-enabled lrp0], [0], [enabled
@@ -1246,16 +1214,11 @@ AT_CHECK([ovn-nbctl lrp-get-enabled lrp0], [0], [enabled
 
 AT_CHECK([ovn-nbctl lrp-set-enabled lrp0 xyzzy], [1], [],
   [ovn-nbctl: xyzzy: state must be "enabled" or "disabled"
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - routes])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_routes], [routes], [
 AT_CHECK([ovn-nbctl lr-add lr0])
 
 dnl Check IPv4 routes
@@ -1364,16 +1327,11 @@ IPv6 Routes
             2001:db8::/64        2001:db8:0:f102::1 dst-ip lp0
           2001:db8:1::/64        2001:db8:0:f103::1 dst-ip
                      ::/0        2001:db8:0:f101::1 dst-ip
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - lsp types])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_lsp_types], [lsp types], [
 AT_CHECK([ovn-nbctl ls-add ls0])
 AT_CHECK([ovn-nbctl lsp-add ls0 lp0])
 
@@ -1437,30 +1395,20 @@ dnl Empty string should work too
 AT_CHECK([ovn-nbctl lsp-set-type lp0 ""])
 AT_CHECK([ovn-nbctl lsp-get-type lp0], [0], [dnl
 
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - connection])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_connection], [connection], [
 AT_CHECK([ovn-nbctl --inactivity-probe=30000 set-connection ptcp:6641:127.0.0.1 punix:$OVS_RUNDIR/ovnnb_db.sock])
 AT_CHECK([ovn-nbctl list connection | grep inactivity_probe], [0], [dnl
 inactivity_probe    : 30000
 inactivity_probe    : 30000
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - dry run mode])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_dry_run_mode], [dry run mode], [
 dnl Check that dry run has no permanent effect.
 AT_CHECK([ovn-nbctl --dry-run ls-add ls0 -- ls-list | uuidfilt], [0], [dnl
 <0> (ls0)
@@ -1472,16 +1420,11 @@ dnl Check that dry-run mode is not sticky.
 AT_CHECK([ovn-nbctl ls-add ls0])
 AT_CHECK([ovn-nbctl ls-list | uuidfilt], [0], [dnl
 <0> (ls0)
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - oneline output])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_oneline_output], [oneline output], [
 AT_CHECK([ovn-nbctl ls-add ls0 -- ls-add ls1])
 
 dnl Expect one line for one command.
@@ -1493,16 +1436,11 @@ dnl Expect lines for two commands.
 AT_CHECK([ovn-nbctl --oneline ls-list -- ls-list | uuidfilt], [0], [dnl
 <0> (ls0)\n<1> (ls1)
 <0> (ls0)\n<1> (ls1)
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - commands parser error paths])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_error_paths], [commands parser error paths], [
 dnl FIXME: Duplicate options are allowed when passed with global options.
 dnl        For example: ovn-nbctl --if-exists --if-exists list Logical_Switch
 
@@ -1569,21 +1507,13 @@ AT_CHECK([ovn-nbctl show foo --bar], [1], [], [stderr])
 AT_CHECK([grep 'command takes at most .* arguments' stderr], [0], [ignore])
 
 AT_CHECK([ovn-nbctl -- show foo --bar], [1], [], [stderr])
-AT_CHECK([grep 'command takes at most .* arguments' stderr], [0], [ignore])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+AT_CHECK([grep 'command takes at most .* arguments' stderr], [0], [ignore])])
 
 dnl ---------------------------------------------------------------------
 
-AT_SETUP([ovn-nbctl - Port Groups])
-OVN_NBCTL_TEST_START
-
+OVN_NBCTL_TEST([ovn_nbctl_port_groups], [port groups], [
 dnl Check that port group can be looked up by name
 AT_CHECK([ovn-nbctl create Port_Group name=pg0], [0], [ignore])
 AT_CHECK([ovn-nbctl get Port_Group pg0 name], [0], [dnl
 "pg0"
-])
-
-OVN_NBCTL_TEST_STOP
-AT_CLEANUP
+])])
