Skip to main content

netget/server/memcached/
actions.rs

1//! Memcached protocol actions.
2//!
3//! **This server stores nothing.** There is no map, no table, no file. Every `get` is
4//! answered by the model; every `set` is a question put to the model about whether it would
5//! have stored the item. That is the project rule (protocols must not implement storage) and
6//! it is also the only reason a Memcached server under NetGet is interesting: a hash map is
7//! not worth emulating, but a model deciding what a key holds is.
8//!
9//! If real persistence is wanted, the sanctioned route is the generic SQLite facility
10//! (`src/state/sqlite.rs`) that the model opts into at runtime — never a cache welded into
11//! this protocol.
12
13use crate::llm::actions::{
14    protocol_trait::{ActionResult, Protocol, Server},
15    ActionDefinition, Parameter,
16};
17use crate::protocol::EventType;
18use crate::state::app_state::AppState;
19use anyhow::{Context, Result};
20use serde_json::json;
21use std::sync::LazyLock;
22
23use super::protocol::{self, ValueItem};
24
25pub struct MemcachedProtocol;
26
27impl Default for MemcachedProtocol {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl MemcachedProtocol {
34    pub fn new() -> Self {
35        Self
36    }
37}
38
39fn p(name: &str, type_hint: &str, description: &str) -> Parameter {
40    Parameter {
41        name: name.to_string(),
42        type_hint: type_hint.to_string(),
43        description: description.to_string(),
44        required: false,
45    }
46}
47
48fn required(name: &str, type_hint: &str, description: &str) -> Parameter {
49    Parameter {
50        name: name.to_string(),
51        type_hint: type_hint.to_string(),
52        description: description.to_string(),
53        required: true,
54    }
55}
56
57/// Decode a value documented as `utf8` or `hex`.
58///
59/// Explicit rather than sniffed, for the reason the TCP fix (`d70bb5b5`) established:
60/// `"48656c6c6f"` is simultaneously valid text and valid hex, and only the sender knows
61/// which it meant. Cache values are frequently binary (serialised objects, compressed
62/// blobs), so this is not a theoretical concern here.
63fn decode_value(entry: &serde_json::Value, key: &str, encoding_key: &str) -> Result<Vec<u8>> {
64    let raw = entry
65        .get(key)
66        .and_then(|v| v.as_str())
67        .with_context(|| format!("missing '{}'", key))?;
68    match entry
69        .get(encoding_key)
70        .and_then(|v| v.as_str())
71        .unwrap_or("utf8")
72    {
73        "utf8" => Ok(raw.as_bytes().to_vec()),
74        "hex" => Ok(hex::decode(raw)
75            .with_context(|| format!("'{}' is declared hex but is not valid hex", key))?),
76        other => Err(anyhow::anyhow!(
77            "Unknown {} '{}': use \"utf8\" or \"hex\"",
78            encoding_key,
79            other
80        )),
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Events — one per command shape, and every one of them is raised by mod.rs.
86// ---------------------------------------------------------------------------
87
88pub static MEMCACHED_GET_EVENT: LazyLock<EventType> = LazyLock::new(|| {
89    EventType::new(
90        "memcached_get",
91        "A client asked for one or more keys. Answer each requested key your instruction gives \
92         a value; a key it does not is absent and is left out. Returning an empty values list \
93         is a cache miss. answer_with says which keys may appear.",
94        json!({
95            "type": "send_memcached_values",
96            "values": [{"key": "example-key", "value": "example-value"}]
97        }),
98    )
99    .with_parameters(vec![
100        p(
101            "command",
102            "string",
103            "'get' or 'gets'; 'gets' also wants a cas_unique per value",
104        ),
105        p("keys", "array", "Keys requested, in order"),
106        p(
107            "answer_with",
108            "string",
109            "The action, and the only keys the answer may carry",
110        ),
111    ])
112    .with_actions(vec![
113        send_values_action(),
114        send_error_action(),
115        close_connection_action(),
116    ])
117});
118
119pub static MEMCACHED_STORE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
120    EventType::new(
121        "memcached_store",
122        "A client wants to store an item. Decide whether the store succeeds. This server \
123         keeps nothing, so 'succeeds' means whatever your instruction says it should mean.",
124        json!({ "type": "send_memcached_status", "status": "STORED" }),
125    )
126    .with_parameters(vec![
127        p(
128            "command",
129            "string",
130            "'set', 'add', 'replace', 'append', 'prepend' or 'cas'",
131        ),
132        p("key", "string", "Key being stored"),
133        p(
134            "flags",
135            "number",
136            "Opaque 32-bit flags the client asked to be stored with the item",
137        ),
138        p(
139            "exptime",
140            "number",
141            "Requested expiry: 0 = never, <0 = immediately expired",
142        ),
143        p(
144            "bytes",
145            "number",
146            "Length of the data block, as counted off the wire",
147        ),
148        p("cas_unique", "number", "Present only for 'cas'"),
149        p("value", "string", "The data block"),
150        p(
151            "value_encoding",
152            "string",
153            "'utf8' if the data block was valid UTF-8, else 'hex'",
154        ),
155    ])
156    .with_actions(vec![
157        send_status_action(),
158        send_error_action(),
159        close_connection_action(),
160    ])
161});
162
163pub static MEMCACHED_DELETE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
164    EventType::new(
165        "memcached_delete",
166        "A client asked to delete a key. Answer DELETED or NOT_FOUND.",
167        json!({ "type": "send_memcached_status", "status": "DELETED" }),
168    )
169    .with_parameters(vec![p("key", "string", "Key to delete")])
170    .with_actions(vec![
171        send_status_action(),
172        send_error_action(),
173        close_connection_action(),
174    ])
175});
176
177pub static MEMCACHED_ARITHMETIC_EVENT: LazyLock<EventType> = LazyLock::new(|| {
178    EventType::new(
179        "memcached_arithmetic",
180        "A client asked to increment or decrement a counter. Answer with the new value, \
181         NOT_FOUND, or a CLIENT_ERROR if the key does not hold a number.",
182        json!({ "type": "send_memcached_number", "value": 43 }),
183    )
184    .with_parameters(vec![
185        p("command", "string", "'incr' or 'decr'"),
186        p("key", "string", "Counter key"),
187        p("delta", "number", "Amount to add or subtract"),
188    ])
189    .with_actions(vec![
190        send_number_action(),
191        send_status_action(),
192        send_error_action(),
193        close_connection_action(),
194    ])
195});
196
197pub static MEMCACHED_TOUCH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
198    EventType::new(
199        "memcached_touch",
200        "A client asked to update a key's expiry without fetching it. Answer TOUCHED or \
201         NOT_FOUND.",
202        json!({ "type": "send_memcached_status", "status": "TOUCHED" }),
203    )
204    .with_parameters(vec![
205        p("key", "string", "Key to touch"),
206        p("exptime", "number", "New expiry"),
207    ])
208    .with_actions(vec![
209        send_status_action(),
210        send_error_action(),
211        close_connection_action(),
212    ])
213});
214
215pub static MEMCACHED_STATS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
216    EventType::new(
217        "memcached_stats",
218        "A client asked for server statistics. Report what your instruction says about this \
219         server - its version, how many items it holds - under memcached's stat names, and \
220         fill the rest plausibly.",
221        json!({
222            "type": "send_memcached_stats",
223            "stats": {"version": "1.6.0", "curr_items": "0", "uptime": "0"}
224        }),
225    )
226    .with_parameters(vec![
227        p(
228            "argument",
229            "string",
230            "Sub-command such as 'items', 'slabs', 'sizes', or null for the general stats",
231        ),
232        p(
233            "answer_with",
234            "string",
235            "The action, and which stat names carry what the instruction says",
236        ),
237    ])
238    .with_actions(vec![
239        send_stats_action(),
240        send_error_action(),
241        close_connection_action(),
242    ])
243});
244
245pub static MEMCACHED_VERSION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
246    EventType::new(
247        "memcached_version",
248        "A client asked which memcached version this is. Answer with whatever version you \
249         want to claim to be.",
250        json!({ "type": "send_memcached_version", "version": "1.6.45" }),
251    )
252    .with_parameters(vec![])
253    .with_actions(vec![
254        send_version_action(),
255        send_error_action(),
256        close_connection_action(),
257    ])
258});
259
260pub static MEMCACHED_FLUSH_ALL_EVENT: LazyLock<EventType> = LazyLock::new(|| {
261    EventType::new(
262        "memcached_flush_all",
263        "A client asked to invalidate everything. Answer OK, or refuse with an error.",
264        json!({ "type": "send_memcached_status", "status": "OK" }),
265    )
266    .with_parameters(vec![p(
267        "delay",
268        "number",
269        "Seconds until the flush takes effect; 0 means immediately",
270    )])
271    .with_actions(vec![
272        send_status_action(),
273        send_error_action(),
274        close_connection_action(),
275    ])
276});
277
278pub static MEMCACHED_UNKNOWN_COMMAND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
279    EventType::new(
280        "memcached_unknown_command",
281        "A command line this server does not recognise. Real memcached answers ERROR; you \
282         may instead answer as though the verb existed.",
283        json!({ "type": "send_memcached_error", "kind": "ERROR" }),
284    )
285    .with_parameters(vec![p("line", "string", "The command line as received")])
286    .with_actions(vec![
287        send_error_action(),
288        send_status_action(),
289        send_values_action(),
290        close_connection_action(),
291    ])
292});
293
294// ---------------------------------------------------------------------------
295// Actions
296// ---------------------------------------------------------------------------
297
298fn send_values_action() -> ActionDefinition {
299    ActionDefinition {
300        name: "send_memcached_values".to_string(),
301        description: "Answer a get/gets. Each entry becomes a VALUE block; an empty list is \
302                      a cache miss (END with no values). Only keys the client asked for may \
303                      appear - NetGet drops any other. The byte count in each VALUE header is \
304                      computed from the payload — do not supply one. The example's keys and \
305                      values are placeholders."
306            .to_string(),
307        parameters: vec![required(
308            "values",
309            "array",
310            "[{key, value, value_encoding ('utf8' default or 'hex'), flags (default 0), \
311             cas_unique (gets only)}]. Omit keys you want to report as missing.",
312        )],
313        example: json!({
314            "type": "send_memcached_values",
315            "values": [
316                {"key": "example-key", "value": "example-value", "flags": 0},
317                {"key": "example-blob", "value": "0001ff", "value_encoding": "hex"}
318            ]
319        }),
320        log_template: None,
321    }
322}
323
324fn send_status_action() -> ActionDefinition {
325    ActionDefinition {
326        name: "send_memcached_status".to_string(),
327        description: "Answer with one of the protocol's fixed status lines.".to_string(),
328        parameters: vec![required(
329            "status",
330            "string",
331            "One of STORED, NOT_STORED, EXISTS, NOT_FOUND, DELETED, TOUCHED, OK, ERROR",
332        )],
333        example: json!({ "type": "send_memcached_status", "status": "STORED" }),
334        log_template: None,
335    }
336}
337
338fn send_number_action() -> ActionDefinition {
339    ActionDefinition {
340        name: "send_memcached_number".to_string(),
341        description: "Answer an incr/decr with the counter's new value.".to_string(),
342        parameters: vec![required(
343            "value",
344            "number",
345            "New counter value (64-bit unsigned)",
346        )],
347        example: json!({ "type": "send_memcached_number", "value": 43 }),
348        log_template: None,
349    }
350}
351
352fn send_stats_action() -> ActionDefinition {
353    ActionDefinition {
354        name: "send_memcached_stats".to_string(),
355        description: "Answer a stats command. Each entry becomes a STAT line, terminated by \
356                      END. Use memcached's own stat names (version, curr_items, total_items, \
357                      bytes, uptime, pid); the example's values are placeholders for the ones \
358                      your instruction gives."
359            .to_string(),
360        parameters: vec![required(
361            "stats",
362            "object",
363            "Flat map of stat name to value; values are rendered as text",
364        )],
365        example: json!({
366            "type": "send_memcached_stats",
367            "stats": {"version": "1.6.0", "curr_items": "0", "uptime": "0"}
368        }),
369        log_template: None,
370    }
371}
372
373fn send_version_action() -> ActionDefinition {
374    ActionDefinition {
375        name: "send_memcached_version".to_string(),
376        description: "Answer a version command.".to_string(),
377        parameters: vec![required("version", "string", "Version string to claim")],
378        example: json!({ "type": "send_memcached_version", "version": "1.6.45" }),
379        log_template: None,
380    }
381}
382
383fn send_error_action() -> ActionDefinition {
384    ActionDefinition {
385        name: "send_memcached_error".to_string(),
386        description: "Answer with a protocol error. 'ERROR' means the command is not \
387                      understood; 'CLIENT_ERROR' means the client got the request wrong; \
388                      'SERVER_ERROR' means this server failed."
389            .to_string(),
390        parameters: vec![
391            required("kind", "string", "ERROR, CLIENT_ERROR or SERVER_ERROR"),
392            p(
393                "message",
394                "string",
395                "Explanation; required for CLIENT_ERROR and SERVER_ERROR, ignored for ERROR",
396            ),
397        ],
398        example: json!({
399            "type": "send_memcached_error",
400            "kind": "CLIENT_ERROR",
401            "message": "cannot increment or decrement non-numeric value"
402        }),
403        log_template: None,
404    }
405}
406
407fn close_connection_action() -> ActionDefinition {
408    ActionDefinition {
409        name: "close_memcached_connection".to_string(),
410        description: "Close the client connection without sending anything further.".to_string(),
411        parameters: vec![],
412        example: json!({ "type": "close_memcached_connection" }),
413        log_template: None,
414    }
415}
416
417// ---------------------------------------------------------------------------
418// Protocol / Server
419// ---------------------------------------------------------------------------
420
421impl Protocol for MemcachedProtocol {
422    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
423        Vec::new()
424    }
425
426    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
427        vec![
428            send_values_action(),
429            send_status_action(),
430            send_number_action(),
431            send_stats_action(),
432            send_version_action(),
433            send_error_action(),
434            close_connection_action(),
435        ]
436    }
437
438    fn protocol_name(&self) -> &'static str {
439        "Memcached"
440    }
441
442    fn get_event_types(&self) -> Vec<EventType> {
443        vec![
444            MEMCACHED_GET_EVENT.clone(),
445            MEMCACHED_STORE_EVENT.clone(),
446            MEMCACHED_DELETE_EVENT.clone(),
447            MEMCACHED_ARITHMETIC_EVENT.clone(),
448            MEMCACHED_TOUCH_EVENT.clone(),
449            MEMCACHED_STATS_EVENT.clone(),
450            MEMCACHED_VERSION_EVENT.clone(),
451            MEMCACHED_FLUSH_ALL_EVENT.clone(),
452            MEMCACHED_UNKNOWN_COMMAND_EVENT.clone(),
453        ]
454    }
455
456    fn stack_name(&self) -> &'static str {
457        "ETH>IP>TCP>Memcached"
458    }
459
460    fn keywords(&self) -> Vec<&'static str> {
461        // Narrow on purpose. A bare "cache" would hijack keyword resolution for every
462        // caching-adjacent protocol in the registry, which is how three BLE profiles
463        // claiming "file"/"transfer"/"stream" broke FTP and NFS.
464        vec!["memcached", "memcache", "memcached server"]
465    }
466
467    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
468        use crate::protocol::metadata::{
469            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
470        };
471
472        ProtocolMetadataV2::builder()
473            .state(DevelopmentState::Beta)
474            // 11211 is above 1023; PrivilegedPort here would be dead code.
475            .privilege_requirement(PrivilegeRequirement::None)
476            .well_known_port(11211)
477            // The one peer-chosen length in the text protocol is a storage command's declared
478            // `<bytes>`; it is refused against this before any of the data block is read.
479            // Tested from the wire in tests/server/memcached/inbound_limit_test.rs.
480            .max_inbound_bytes(crate::server::memcached::protocol::MAX_VALUE_LEN)
481            .implementation(
482                "Hand-rolled memcached *text* protocol (src/server/memcached/protocol.rs) \
483                 over TCP. get/gets/set/add/replace/append/prepend/cas/delete/incr/decr/\
484                 touch/stats/version/flush_all/quit. Storage-command data blocks are read by \
485                 the declared byte count, never by scanning for CRLF, so a payload \
486                 containing CRLF frames correctly. The binary protocol is not implemented \
487                 (deprecated upstream in 1.6) and neither are the meta commands (mg/ms/md).",
488            )
489            .llm_control(
490                "The model IS the cache. It decides what every key holds, whether a store \
491                 succeeds, what the counters read and what stats to report. No value is \
492                 stored anywhere in this server.",
493            )
494            .e2e_testing(
495                "libmemcached 1.0.18's C tools are the independent peer, in \
496                 tests/server/memcached/real_client_test.rs: memcat reads a model-invented \
497                 value (it parses the VALUE header and reads exactly that many bytes, so a \
498                 wrong count shows up as an empty or truncated result rather than a pass), \
499                 and memstat and memping accept our STAT/END and VERSION replies. They are a \
500                 separate C implementation invoked as subprocesses, never linked. Neither \
501                 test is #[ignore]d and both now FAIL rather than skip when the binaries are \
502                 absent; until September 2026 they printed SKIPPED and returned Ok(()), a \
503                 silent pass, which is what held this at Experimental. The always-running \
504                 raw-socket tests remain the byte-level check - exact VALUE/END framing, byte \
505                 counts, a payload containing CRLF, and a rejected storage header consuming \
506                 its data block instead of leaving it to be parsed as commands. Untested \
507                 against any client for the binary protocol or the meta commands, neither of \
508                 which is implemented.",
509            )
510            .notes(
511                "STORES NOTHING BY DESIGN: there is no map or table in the Rust code, so \
512                 two successive gets of the same key are two independent questions to the \
513                 model and may legitimately differ. Use a script handler or the generic \
514                 SQLite facility if you need a key to keep its value. Every command costs \
515                 one LLM call; `noreply` is honoured, so a noreply store still costs a call \
516                 but sends nothing.",
517            )
518            .build()
519    }
520
521    fn description(&self) -> &'static str {
522        "Memcached text-protocol server where the model answers every lookup"
523    }
524
525    fn example_prompt(&self) -> &'static str {
526        "pretend to be a memcached server on port 11211 holding session data for a web app"
527    }
528
529    fn group_name(&self) -> &'static str {
530        "Database"
531    }
532
533    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
534        use crate::llm::actions::StartupExamples;
535
536        StartupExamples::new(
537            json!({
538                "type": "open_server",
539                "port": 11211,
540                "base_stack": "memcached",
541                "instruction": "Act as a session cache. Keys look like session:<id> and hold \
542                                a JSON user object. Report STORED for any set."
543            }),
544            json!({
545                "type": "open_server",
546                "port": 11211,
547                "base_stack": "memcached",
548                "event_handlers": [{
549                    "event_pattern": "memcached_get",
550                    "handler": {
551                        "type": "script",
552                        "language": "python",
553                        "code": "respond([{'type': 'send_memcached_values', 'values': [{'key': k, 'value': 'v-' + k} for k in event['keys']]}])"
554                    }
555                }]
556            }),
557            json!({
558                "type": "open_server",
559                "port": 11211,
560                "base_stack": "memcached",
561                "event_handlers": [
562                    {
563                        "event_pattern": "memcached_get",
564                        "handler": {
565                            "type": "static",
566                            "actions": [{"type": "send_memcached_values", "values": []}]
567                        }
568                    },
569                    {
570                        "event_pattern": "memcached_store",
571                        "handler": {
572                            "type": "static",
573                            "actions": [{"type": "send_memcached_status", "status": "STORED"}]
574                        }
575                    }
576                ]
577            }),
578        )
579    }
580}
581
582impl Server for MemcachedProtocol {
583    fn spawn(
584        &self,
585        ctx: crate::protocol::SpawnContext,
586    ) -> std::pin::Pin<
587        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
588    > {
589        Box::pin(async move { super::MemcachedServer::spawn_with_llm_actions(ctx).await })
590    }
591
592    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
593        let action_type = action
594            .get("type")
595            .and_then(|v| v.as_str())
596            .context("Missing 'type' field in action")?;
597
598        match action_type {
599            "send_memcached_values" => {
600                let entries = action
601                    .get("values")
602                    .and_then(|v| v.as_array())
603                    .context("send_memcached_values requires a 'values' array")?;
604
605                let mut items = Vec::with_capacity(entries.len());
606                let mut any_cas = false;
607                for entry in entries {
608                    let key = entry
609                        .get("key")
610                        .and_then(|v| v.as_str())
611                        .context("each value needs a 'key'")?
612                        .to_string();
613                    if key.len() > protocol::MAX_KEY_LEN {
614                        return Err(anyhow::anyhow!(
615                            "key '{}' is {} bytes; memcached allows at most {}",
616                            key,
617                            key.len(),
618                            protocol::MAX_KEY_LEN
619                        ));
620                    }
621                    if key.bytes().any(|b| b <= b' ' || b == 127) {
622                        return Err(anyhow::anyhow!(
623                            "key '{}' contains whitespace or a control character, which the \
624                             text protocol cannot express",
625                            key
626                        ));
627                    }
628                    let data = decode_value(entry, "value", "value_encoding")?;
629                    if data.len() > protocol::MAX_VALUE_LEN {
630                        return Err(anyhow::anyhow!(
631                            "value for '{}' is {} bytes; memcached's item limit is {}",
632                            key,
633                            data.len(),
634                            protocol::MAX_VALUE_LEN
635                        ));
636                    }
637                    let flags = entry.get("flags").and_then(|v| v.as_u64()).unwrap_or(0);
638                    let cas_unique = entry.get("cas_unique").and_then(|v| v.as_u64());
639                    any_cas |= cas_unique.is_some();
640                    items.push(ValueItem {
641                        key,
642                        flags: u32::try_from(flags).context("flags exceed 32 bits")?,
643                        data,
644                        cas_unique,
645                    });
646                }
647
648                Ok(ActionResult::Output(protocol::encode_values(
649                    &items, any_cas,
650                )))
651            }
652
653            "send_memcached_status" => {
654                let status = action
655                    .get("status")
656                    .and_then(|v| v.as_str())
657                    .context("send_memcached_status requires 'status'")?;
658                let line =
659                    protocol::status_line(&status.to_ascii_uppercase()).ok_or_else(|| {
660                        anyhow::anyhow!(
661                            "Unknown memcached status '{}'. Use one of STORED, NOT_STORED, \
662                         EXISTS, NOT_FOUND, DELETED, TOUCHED, OK, ERROR",
663                            status
664                        )
665                    })?;
666                Ok(ActionResult::Output(line.as_bytes().to_vec()))
667            }
668
669            "send_memcached_number" => {
670                let value = action
671                    .get("value")
672                    .and_then(|v| v.as_u64())
673                    .context("send_memcached_number requires a non-negative integer 'value'")?;
674                Ok(ActionResult::Output(format!("{}\r\n", value).into_bytes()))
675            }
676
677            "send_memcached_stats" => {
678                let map = action
679                    .get("stats")
680                    .and_then(|v| v.as_object())
681                    .context("send_memcached_stats requires a 'stats' object")?;
682                let entries: Vec<(String, String)> = map
683                    .iter()
684                    .map(|(k, v)| {
685                        let rendered = match v {
686                            serde_json::Value::String(s) => s.clone(),
687                            other => other.to_string(),
688                        };
689                        (k.clone(), rendered)
690                    })
691                    .collect();
692                Ok(ActionResult::Output(protocol::encode_stats(&entries)))
693            }
694
695            "send_memcached_version" => {
696                let version = action
697                    .get("version")
698                    .and_then(|v| v.as_str())
699                    .context("send_memcached_version requires 'version'")?;
700                if version.contains(['\r', '\n']) {
701                    return Err(anyhow::anyhow!("version string must not contain CR or LF"));
702                }
703                Ok(ActionResult::Output(
704                    format!("VERSION {}\r\n", version).into_bytes(),
705                ))
706            }
707
708            "send_memcached_error" => {
709                let kind = action
710                    .get("kind")
711                    .and_then(|v| v.as_str())
712                    .context("send_memcached_error requires 'kind'")?
713                    .to_ascii_uppercase();
714                let message = action
715                    .get("message")
716                    .and_then(|v| v.as_str())
717                    .unwrap_or("")
718                    .replace(['\r', '\n'], " ");
719                let line = match kind.as_str() {
720                    "ERROR" => "ERROR\r\n".to_string(),
721                    "CLIENT_ERROR" | "SERVER_ERROR" => {
722                        if message.is_empty() {
723                            return Err(anyhow::anyhow!("{} requires a 'message'", kind));
724                        }
725                        format!("{} {}\r\n", kind, message)
726                    }
727                    other => {
728                        return Err(anyhow::anyhow!(
729                            "Unknown error kind '{}': use ERROR, CLIENT_ERROR or SERVER_ERROR",
730                            other
731                        ))
732                    }
733                };
734                Ok(ActionResult::Output(line.into_bytes()))
735            }
736
737            "close_memcached_connection" => Ok(ActionResult::CloseConnection),
738
739            // Not offered to the model, but the dashboard's "disconnect this peer" injects it
740            // through the peer command task, which half-closes the write side on this result.
741            "close_connection" => Ok(ActionResult::CloseConnection),
742
743            _ => Err(anyhow::anyhow!("Unknown Memcached action: {}", action_type)),
744        }
745    }
746}