From 3e14a4d7d00c1be5e7a63b8d5872f5fca999bc80 Mon Sep 17 00:00:00 2001 From: yeoncheol-kim Date: Tue, 12 Mar 2024 17:08:54 +0900 Subject: [PATCH] CLEANUP: Detach ASCII/Binary protocols from memcached --- Makefile.am | 4 + proto_ascii.c | 7351 +++++++++++++++++++++++++++++++++++++++++++++++++ proto_ascii.h | 9 + proto_bin.c | 5 + proto_bin.h | 5 + 5 files changed, 7374 insertions(+) create mode 100644 proto_ascii.c create mode 100644 proto_ascii.h create mode 100644 proto_bin.c create mode 100644 proto_bin.h diff --git a/Makefile.am b/Makefile.am index 474b62a8c..8d3293fad 100644 --- a/Makefile.am +++ b/Makefile.am @@ -78,6 +78,10 @@ memcached_SOURCES = \ hash.h \ memcached.c\ memcached.h \ + proto_ascii.c \ + proto_ascii.h \ + proto_bin.c \ + proto_bin.h \ sasl_defs.h \ stats.c stats.h \ thread.c \ diff --git a/proto_ascii.c b/proto_ascii.c new file mode 100644 index 000000000..4fb6879fb --- /dev/null +++ b/proto_ascii.c @@ -0,0 +1,7351 @@ +#include "config.h" +#include "memcached.h" +#include "proto_ascii.h" +#ifdef ENABLE_ZK_INTEGRATION +#include "arcus_zk.h" +#include "arcus_hb.h" +#endif + +#include +#include +#include +#include +#include + +#ifndef PROTO_ASCII_H // Remove later. + +#define ZK_CONNECTIONS 1 + +#define COMMAND_TOKEN 0 +#define SUBCOMMAND_TOKEN 1 +#define PREFIX_TOKEN 1 +#define KEY_TOKEN 1 +#define LOP_KEY_TOKEN 2 +#define SOP_KEY_TOKEN 2 +#define MOP_KEY_TOKEN 2 +#define BOP_KEY_TOKEN 2 + +#define MAX_TOKENS 30 + +#ifdef ENABLE_ZK_INTEGRATION +extern char *arcus_zk_cfg; +#endif + +#ifdef COMMAND_LOGGING +bool cmdlog_in_use; +#endif + +#ifdef DETECT_LONG_QUERY +bool lqdetect_in_use; +#endif + +extern struct settings settings; +extern struct mc_stats mc_stats; +extern EXTENSION_LOGGER_DESCRIPTOR *mc_logger; + +extern union { + ENGINE_HANDLE *v0; + ENGINE_HANDLE_V1 *v1; +} mc_engine; + +#if COMMON_PROCESS || 1 +/** error handling */ +static void handle_unexpected_errorcode_ascii(conn *c, const char *func_name, ENGINE_ERROR_CODE ret) +{ + if (ret == ENGINE_DISCONNECT) { + conn_set_state(c, conn_closing); + } else if (ret == ENGINE_ENOTSUP) { + out_string(c, "NOT_SUPPORTED"); + } else { + mc_logger->log(EXTENSION_LOG_WARNING, c, "[%s] Unexpected Error: %d\n", + func_name, (int)ret); + out_string(c, "SERVER_ERROR internal"); + } +} + +static void print_invalid_command(conn *c, token_t *tokens, const size_t ntokens) +{ + /* To understand this function's implementation, + * You must know how the command is tokenized. + * See tokenize_command(). + */ + if (ntokens >= 2) { + int i; + /* make single string */ + for (i = 0; i < (ntokens-2); i++) { + tokens[i].value[tokens[i].length] = ' '; + } + mc_logger->log(EXTENSION_LOG_INFO, c, "[%s] INVALID_COMMAND: %s\n", + c->client_ip, tokens[0].value); + /* restore the tokens */ + for (i = 0; i < (ntokens-2); i++) { + tokens[i].value[tokens[i].length] = '\0'; + } + } +} + +/** collection type common/util */ +static void pipe_state_clear(conn *c) +{ + c->pipe_state = PIPE_STATE_OFF; + c->pipe_count = 0; +} + +static inline void set_pipe_maybe(conn *c, token_t *tokens, size_t ntokens) +{ + char *token_value = tokens[ntokens-2].value; + + /* + NOTE: this function is not the first place where we are going to + send the reply. We could send it instead from process_command_ascii() + if the request line has wrong number of tokens. However parsing + malformed line for "pipe" option is not reliable anyway, + so it can't be helped. + */ + if (token_value && strcmp(token_value, "pipe") == 0) { + c->noreply = true; + if (c->pipe_state == PIPE_STATE_OFF) /* first pipe */ + c->pipe_state = PIPE_STATE_ON; + } else { + c->noreply = false; + } +} + +static inline void set_noreply_maybe(conn *c, token_t *tokens, size_t ntokens) +{ + char *token_value = tokens[ntokens-2].value; + + /* + NOTE: this function is not the first place where we are going to + send the reply. We could send it instead from process_command_ascii() + if the request line has wrong number of tokens. However parsing + malformed line for "noreply" option is not reliable anyway, so + it can't be helped. + */ + if (token_value && strcmp(token_value, "noreply") == 0) { + c->noreply = true; + } else { + c->noreply = false; + } +} + +static inline void set_pipe_noreply_maybe(conn *c, token_t *tokens, size_t ntokens) +{ + char *token_value = tokens[ntokens-2].value; + + /* + NOTE: this function is not the first place where we are going to + send the reply. We could send it instead from process_command_ascii() + if the request line has wrong number of tokens. However parsing + malformed line for "noreply" or "pipe" option is not reliable anyway, + so it can't be helped. + */ + if (token_value) { + if (strcmp(token_value, "noreply") == 0) { + c->noreply = true; + } else if (strcmp(token_value, "pipe") == 0) { + c->noreply = true; + if (c->pipe_state == PIPE_STATE_OFF) /* first pipe */ + c->pipe_state = PIPE_STATE_ON; + } else { + c->noreply = false; + } + } else { + c->noreply = false; + } +} + +static bool check_and_handle_pipe_state(conn *c) +{ + if (c->pipe_state == PIPE_STATE_OFF || c->pipe_state == PIPE_STATE_ON) { + return true; + } else { + assert(c->pipe_state == PIPE_STATE_ERR_CFULL || + c->pipe_state == PIPE_STATE_ERR_MFULL || + c->pipe_state == PIPE_STATE_ERR_BAD); + if (c->noreply) { + c->noreply = false; /* reset noreply */ + } else { + /* The last command of pipelining has come. */ + pipe_state_clear(c); + } + return false; + } +} + +static inline int get_coll_create_attr_from_tokens(token_t *tokens, const int ntokens, + int coll_type, item_attr *attrp) +{ + assert(coll_type==ITEM_TYPE_LIST || coll_type==ITEM_TYPE_SET || + coll_type==ITEM_TYPE_MAP || coll_type==ITEM_TYPE_BTREE); + int64_t exptime; + + /* create attributes: flags, exptime, maxcount, ovflaction, unreadable */ + /* support arcus 1.5 backward compatibility. */ + if (ntokens < 1 || ntokens > 5) return -1; + //if (ntokens < 3 || ntokens > 5) return -1; + + /* flags */ + if (! safe_strtoul(tokens[0].value, &attrp->flags)) return -1; + attrp->flags = htonl(attrp->flags); + + /* exptime */ + if (ntokens >= 2) { + if (! safe_strtoll(tokens[1].value, &exptime)) return -1; + } else { + exptime = 0; /* default value */ + } + attrp->exptime = realtime(exptime); + + /* maxcount */ + if (ntokens >= 3) { + if (! safe_strtol(tokens[2].value, &attrp->maxcount)) return -1; + } else { + attrp->maxcount = 0; /* default value */ + } + + attrp->ovflaction = 0; /* undefined : will be set to default later */ + attrp->readable = 1; /* readable = on */ + + if (ntokens >= 4) { + if (strcmp(tokens[3].value, "error") == 0) { + attrp->ovflaction = OVFL_ERROR; + } else { + if (coll_type == ITEM_TYPE_LIST) { + if (strcmp(tokens[3].value, "head_trim") == 0) + attrp->ovflaction = OVFL_HEAD_TRIM; + else if (strcmp(tokens[3].value, "tail_trim") == 0) + attrp->ovflaction = OVFL_TAIL_TRIM; + } + else if (coll_type == ITEM_TYPE_BTREE) { + if (strcmp(tokens[3].value, "smallest_trim") == 0) + attrp->ovflaction = OVFL_SMALLEST_TRIM; + else if (strcmp(tokens[3].value, "largest_trim") == 0) + attrp->ovflaction = OVFL_LARGEST_TRIM; + else if (strcmp(tokens[3].value, "smallest_silent_trim") == 0) + attrp->ovflaction = OVFL_SMALLEST_SILENT_TRIM; + else if (strcmp(tokens[3].value, "largest_silent_trim") == 0) + attrp->ovflaction = OVFL_LARGEST_SILENT_TRIM; + } + } + if (attrp->ovflaction != 0) { /* defined */ + if (ntokens == 5) { + if (strcmp(tokens[4].value, "unreadable") != 0) return -1; + attrp->readable = 0; + } + } else { /* undefined */ + if (ntokens == 5) return -1; /* ovflaction must be defined */ + else { /* ntokens == 4 */ + if (strcmp(tokens[3].value, "unreadable") != 0) return -1; + attrp->readable = 0; + } + } + } + return 0; +} + +static bool ascii_response_handler(const void *cookie, int nbytes, const char *dta) +{ + conn *c = (conn*)cookie; + if (!grow_dynamic_buffer(c, nbytes)) { + if (settings.verbose > 0) { + mc_logger->log(EXTENSION_LOG_INFO, c, + "<%d ERROR: Failed to allocate memory for response\n", c->sfd); + } + return false; + } + + char *buf = c->dynamic_buffer.buffer + c->dynamic_buffer.offset; + memcpy(buf, dta, nbytes); + c->dynamic_buffer.offset += nbytes; + return true; +} + +#endif + +#if KEY_VALUE_PROCESS || 1 +/** set */ +static void process_update_command(conn *c, token_t *tokens, const size_t ntokens, + ENGINE_STORE_OPERATION store_op, bool handle_cas) +{ + assert(c != NULL); + char *key; + size_t nkey; + unsigned int flags; + int64_t exptime=0; + int vlen; + uint64_t req_cas_id=0; + item *it; + + set_noreply_maybe(c, tokens, ntokens); + + key = tokens[KEY_TOKEN].value; + nkey = tokens[KEY_TOKEN].length; + if (nkey > KEY_MAX_LENGTH) { + out_string(c, "CLIENT_ERROR bad command line format"); + return; + } + + if ((! safe_strtoul(tokens[2].value, (uint32_t *)&flags)) || + (! safe_strtoll(tokens[3].value, &exptime)) || + (! safe_strtol(tokens[4].value, (int32_t *)&vlen)) || + (vlen < 0 || vlen > (INT_MAX-2))) + { + print_invalid_command(c, tokens, ntokens); + out_string(c, "CLIENT_ERROR bad command line format"); + return; + } + vlen += 2; + + // does cas value exist? + if (handle_cas) { + if (!safe_strtoull(tokens[5].value, &req_cas_id)) { + print_invalid_command(c, tokens, ntokens); + out_string(c, "CLIENT_ERROR bad command line format"); + return; + } + } + + if (settings.detail_enabled) { + stats_prefix_record_set(key, nkey); + } + + ENGINE_ERROR_CODE ret; + ret = mc_engine.v1->allocate(mc_engine.v0, c, &it, key, nkey, vlen, + htonl(flags), realtime(exptime), req_cas_id); + if (ret == ENGINE_SUCCESS) { + if (!mc_engine.v1->get_item_info(mc_engine.v0, c, it, &c->hinfo)) { + mc_engine.v1->release(mc_engine.v0, c, it); + out_string(c, "SERVER_ERROR error getting item data"); + ret = ENGINE_ENOMEM; + } else { + c->item = it; + ritem_set_first(c, CONN_RTYPE_HINFO, vlen); + c->store_op = store_op; + conn_set_state(c, conn_nread); + } + } else { + if (ret == ENGINE_E2BIG) { + out_string(c, "CLIENT_ERROR object too large for cache"); + } else if (ret == ENGINE_ENOMEM) { + out_string(c, "SERVER_ERROR out of memory storing object"); + } else { + handle_unexpected_errorcode_ascii(c, __func__, ret); + } + } + + if (ret != ENGINE_SUCCESS) { + /* Avoid stale data persisting in cache because we failed alloc. + * Unacceptable for SET. Anywhere else too? + */ + if (store_op == OPERATION_SET) { + /* set temporarily noreply for the ASYNC interface */ + /* noreply flag is cleared in out_string() if it was set */ + assert(c->noreply == false); + c->noreply = true; + mc_engine.v1->remove(mc_engine.v0, c, key, nkey, 0, 0); + c->noreply = false; + } + + if (ret != ENGINE_DISCONNECT) { + /* swallow the data line */ + c->sbytes = vlen; + if (c->state == conn_write) { + c->write_and_go = conn_swallow; + } else { /* conn_new_cmd (by noreply) */ + conn_set_state(c, conn_swallow); + } + } + } +} + +/** get */ +/** + * Get a suffix buffer and insert it into the list of used suffix buffers + * @param c the connection object + * @return a pointer to a new suffix buffer or NULL if allocation failed + */ +static char *get_suffix_buffer(conn *c) +{ + if (c->suffixleft == c->suffixsize) { + char **new_suffix_list; + size_t sz = sizeof(char*) * c->suffixsize * 2; + + new_suffix_list = realloc(c->suffixlist, sz); + if (new_suffix_list) { + c->suffixsize *= 2; + c->suffixlist = new_suffix_list; + } else { + if (settings.verbose > 1) { + mc_logger->log(EXTENSION_LOG_DEBUG, c, + "=%d Failed to resize suffix buffer\n", c->sfd); + } + return NULL; + } + } + + char *suffix = cache_alloc(c->thread->suffix_cache); + if (suffix != NULL) { + *(c->suffixlist + c->suffixleft) = suffix; + ++c->suffixleft; + } + return suffix; +} + +static ENGINE_ERROR_CODE +process_get_single(conn *c, char *key, size_t nkey, bool return_cas) +{ + item *it; + char *cas_val = NULL; + int cas_len = 0; + ENGINE_ERROR_CODE ret; + + ret = mc_engine.v1->get(mc_engine.v0, c, &it, key, nkey, 0); + if (ret != ENGINE_SUCCESS) { + it = NULL; + } + if (settings.detail_enabled) { + stats_prefix_record_get(key, nkey, (it != NULL)); + } + + if (it) { + if (!mc_engine.v1->get_item_info(mc_engine.v0, c, it, &c->hinfo)) { + mc_engine.v1->release(mc_engine.v0, c, it); + return ENGINE_ENOMEM; + } + assert(hinfo_check_ascii_tail_string(&c->hinfo) == 0); /* check "\r\n" */ + + /* Rebuild the suffix */ + char *suffix = get_suffix_buffer(c); + if (suffix == NULL) { + mc_engine.v1->release(mc_engine.v0, c, it); + return ENGINE_ENOMEM; + } + int suffix_len = snprintf(suffix, SUFFIX_SIZE, " %u %u\r\n", + htonl(c->hinfo.flags), c->hinfo.nbytes - 2); + /* suffix_len < SUFFIX_SIZE because the length of nbytes string is smaller than 10. */ + + /* rebuild cas value */ + if (return_cas) { + suffix_len -= 2; /* remove "\r\n" from suffix string */ + cas_val = get_suffix_buffer(c); + if (cas_val == NULL) { + mc_engine.v1->release(mc_engine.v0, c, it); + return ENGINE_ENOMEM; + } + cas_len = snprintf(cas_val, SUFFIX_SIZE, " %"PRIu64"\r\n", c->hinfo.cas); + } + + /* Construct the response. Each hit adds three elements to the + * outgoing data list: + * VALUE [ ]\r\n" + "\r\n" + */ + if (add_iov(c, "VALUE ", 6) != 0 || + add_iov(c, c->hinfo.key, c->hinfo.nkey) != 0 || + add_iov(c, suffix, suffix_len) != 0 || + (return_cas && add_iov(c, cas_val, cas_len) != 0) || + add_iov_hinfo_value(c, &c->hinfo) != 0) + { + mc_engine.v1->release(mc_engine.v0, c, it); + return ENGINE_ENOMEM; + } + + /* save the item */ + if (c->ileft >= c->isize) { + item **new_list = realloc(c->ilist, sizeof(item *) * c->isize * 2); + if (new_list) { + c->isize *= 2; + c->ilist = new_list; + } else { + mc_engine.v1->release(mc_engine.v0, c, it); + return ENGINE_ENOMEM;/* out of memory */ + } + } + *(c->ilist + c->ileft) = it; + c->ileft++; + + if (settings.verbose > 1) { + mc_logger->log(EXTENSION_LOG_DEBUG, c, ">%d sending key %s\n", + c->sfd, key); + } + /* item_get() has incremented it->refcount for us */ + STATS_HITS(c, get, key, nkey); + MEMCACHED_COMMAND_GET(c->sfd, key, nkey, c->hinfo.nbytes, c->hinfo.cas); + } else { + STATS_MISSES(c, get, key, nkey); + MEMCACHED_COMMAND_GET(c->sfd, key, nkey, -1, 0); + } + /* Even if the key is not found, return ENGINE_SUCCESS. */ + return ENGINE_SUCCESS; +} + +/* ntokens is overwritten here... shrug.. */ +static inline void process_get_command(conn *c, token_t *tokens, size_t ntokens, bool return_cas) +{ + assert(c != NULL); + token_t *key_token = &tokens[KEY_TOKEN]; + ENGINE_ERROR_CODE ret = ENGINE_SUCCESS; + + do { + while (key_token->length != 0) { + if (key_token->length > KEY_MAX_LENGTH) { + ret = ENGINE_EINVAL; break; + } + /* do get operation for each key */ + ret = process_get_single(c, key_token->value, key_token->length, + return_cas); + if (ret != ENGINE_SUCCESS) { + break; /* ret == ENGINE_ENOMEM */ + } + key_token++; + } + if (ret != ENGINE_SUCCESS) break; + + /* If the command string hasn't been fully processed, get the next set of tokens. */ + if (key_token->value != NULL) { + /* The next reserved token has the length of untokenized command. */ + ntokens = tokenize_command(key_token->value, (key_token+1)->length, + tokens, MAX_TOKENS); + key_token = tokens; + } + } while(key_token->value != NULL); + + /* Some items and suffixes might have saved in the above execution. + * To release the items and free the suffixes, the below code is needed. + */ + c->icurr = c->ilist; + c->suffixcurr = c->suffixlist; + + if (ret != ENGINE_SUCCESS) { + /* Releasing items on ilist and freeing suffixes will be + * performed later by calling out_string() function. + * See conn_write() and conn_mwrite() state. + */ + if (ret == ENGINE_EINVAL) + out_string(c, "CLIENT_ERROR bad command line format"); + else /* ret == ENGINE_ENOMEM */ + out_string(c, "SERVER_ERROR out of memory writing get response"); + return; + } + + if (settings.verbose > 1) { + mc_logger->log(EXTENSION_LOG_DEBUG, c, ">%d END\n", c->sfd); + } + + /* If the loop was terminated because of out-of-memory, it is not + * reliable to add END\r\n to the buffer, because it might not end + * in \r\n. So we send SERVER_ERROR instead. + */ + if ((add_iov(c, "END\r\n", 5) != 0) || + (IS_UDP(c->transport) && build_udp_headers(c) != 0)) { + out_string(c, "SERVER_ERROR out of memory writing get response"); + } else { + conn_set_state(c, conn_mwrite); + c->msgcurr = 0; + } +} + +/** mget */ +static void process_mget_complete(conn *c, bool return_cas) +{ + assert(return_cas ? (c->coll_op == OPERATION_MGETS) : (c->coll_op == OPERATION_MGET)); + assert(c->coll_strkeys != NULL); + + ENGINE_ERROR_CODE ret = ENGINE_SUCCESS; + token_t *key_tokens; + + do { + key_tokens = (token_t*)token_buff_get(&c->thread->token_buff, c->coll_numkeys); + if (key_tokens != NULL) { + bool must_backward_compatible = false; + ret = tokenize_sblocks(&c->memblist, c->coll_lenkeys, c->coll_numkeys, + KEY_MAX_LENGTH, must_backward_compatible, key_tokens); + if (ret != ENGINE_SUCCESS) { + break; /* ENGINE_EBADVALUE | ENGINE_ENOMEM */ + } + } else { + ret = ENGINE_ENOMEM; break; + } + + /* do get operation for each key */ + for (int k = 0; k < c->coll_numkeys; k++) { + ret = process_get_single(c, key_tokens[k].value, key_tokens[k].length, + return_cas); + if (ret != ENGINE_SUCCESS) { + break; /* ret == ENGINE_ENOMEM*/ + } + } + + /* Some items and suffixes might have saved in the above execution. + * To release the items and free the suffixes, the below code is needed. + */ + c->icurr = c->ilist; + c->suffixcurr = c->suffixlist; + + if (ret != ENGINE_SUCCESS) { + /* Releasing items on ilist and freeing suffixes will be + * performed later by calling out_string() function. + * See conn_write() and conn_mwrite() state. + */ + break; + } + + if (settings.verbose > 1) { + mc_logger->log(EXTENSION_LOG_DEBUG, c, ">%d END\n", c->sfd); + } + + /* If the loop was terminated because of out-of-memory, it is not + * reliable to add END\r\n to the buffer, because it might not end + * in \r\n. So we send SERVER_ERROR instead. + */ + if ((add_iov(c, "END\r\n", 5) != 0) || + (IS_UDP(c->transport) && build_udp_headers(c) != 0)) { + /* Releasing items on ilist and freeing suffixes will be + * performed later by calling out_string() function. + * See conn_write() and conn_mwrite() state. + */ + ret = ENGINE_ENOMEM; + } + } while(0); + + if (ret == ENGINE_SUCCESS) { + conn_set_state(c, conn_mwrite); + c->msgcurr = 0; + } else { + if (ret == ENGINE_EBADVALUE) out_string(c, "CLIENT_ERROR bad data chunk"); + else if (ret == ENGINE_ENOMEM) out_string(c, "SERVER_ERROR out of memory writing get response"); + else handle_unexpected_errorcode_ascii(c, __func__, ret); + } + + /* free token buffer */ + if (key_tokens != NULL) { + token_buff_release(&c->thread->token_buff, key_tokens); + } + if (ret != ENGINE_SUCCESS) { + /* free key string memory blocks */ + assert(c->coll_strkeys == (void*)&c->memblist); + mblck_list_free(&c->thread->mblck_pool, &c->memblist); + c->coll_strkeys = NULL; + } +} + +static void process_prepare_nread_keys(conn *c, uint32_t vlen, uint32_t kcnt, bool return_cas) +{ + ENGINE_ERROR_CODE ret = ENGINE_SUCCESS; + + /* allocate memory blocks needed */ + if (mblck_list_alloc(&c->thread->mblck_pool, 1, vlen, &c->memblist) < 0) { + ret = ENGINE_ENOMEM; + } + if (ret == ENGINE_SUCCESS) { + c->coll_strkeys = (void*)&c->memblist; + ritem_set_first(c, CONN_RTYPE_MBLCK, vlen); + c->coll_op = (return_cas ? OPERATION_MGETS : OPERATION_MGET); + conn_set_state(c, conn_nread); + } else { + out_string(c, "SERVER_ERROR out of memory"); + c->sbytes = vlen; + c->write_and_go = conn_swallow; + } +} + +static inline void process_mget_command(conn *c, token_t *tokens, const size_t ntokens, bool return_cas) +{ + uint32_t lenkeys, numkeys; + + if ((! safe_strtoul(tokens[COMMAND_TOKEN+1].value, &lenkeys)) || + (! safe_strtoul(tokens[COMMAND_TOKEN+2].value, &numkeys)) || + (lenkeys > (UINT_MAX-2)) || (lenkeys == 0) || (numkeys == 0)) { + print_invalid_command(c, tokens, ntokens); + out_string(c, "CLIENT_ERROR bad command line format"); + return; + } + + if (numkeys > MAX_MGET_KEY_COUNT || + numkeys > ((lenkeys/2) + 1) || + lenkeys > ((numkeys*KEY_MAX_LENGTH) + numkeys-1)) { + /* ENGINE_EBADVALUE */ + out_string(c, "CLIENT_ERROR bad value"); + c->sbytes = lenkeys + 2; + c->write_and_go = conn_swallow; + return; + } + lenkeys += 2; + + c->coll_numkeys = numkeys; + c->coll_lenkeys = lenkeys; + + process_prepare_nread_keys(c, lenkeys, numkeys, return_cas); +} + +/** delete */ +static void process_delete_command(conn *c, token_t *tokens, const size_t ntokens) +{ + assert(c->ewouldblock == false); + char *key; + size_t nkey; + + if (ntokens > 3) { + /* See "delete [