# Refactoring Plan & Architecture Ledger: Commands & Runtime Subsystems
Engineering design, historical context, and decision ledger for the `matroskin` refactor.
---
## 1. Flat Command Pattern (Отказ от матрешек)
### Исторический контекст (до рефакторинга)
Команды были спроектированы как кортежные структуры (newtype), оборачивающие искусственные типы параметров:
```rust
// Было (избыточная вложенность):
pub struct SetSystemHostnameParam {
pub hostname: String,
}
pub struct SetSystemHostname(pub SetSystemHostnameParam);
```
### Принятое решение
Поля полезной нагрузки (payload) перенесены непосредственно в структуру самой команды. Конструкция стала плоской с именованными полями и конструкторами `new`:
```rust
// Стало (плоская структура):
pub struct SetSystemHostname {
pub hostname: String,
}
impl SetSystemHostname {
pub fn new(hostname: impl Into<String>) -> Self {
Self { hostname: hostname.into() }
}
}
// Вызов:
actor.send(SetSystemHostname::new("WhatsMiner-01")).await?;
```
Для команд со скалярными параметрами:
```rust
pub struct SetFanTempOffset {
pub offset: i8,
}
impl SetFanTempOffset {
pub const fn new(offset: i8) -> Self {
Self { offset }
}
}
```
### Исключения: когда отдельные типы сохраняются
Отдельные типы сохраняются исключительно для самостоятельных доменных сущностей (например, `PoolConfig`), но не для искусственных оберток:
```rust
pub struct PoolConfig {
pub pool: String,
pub worker: String,
pub passwd: Redacted<String>,
}
pub struct SetMinerPools {
pub pools: [PoolConfig; 3],
}
```
---
## 2. Формирование параметров (`params`)
- Формирование параметров строго соответствует спецификации `.api-doc/` для каждой команды:
- строка передаётся строкой, число — числом, объекты и массивы — структурированным JSON;
- сложные параметры (JSON-объекты / массивы) сериализуются через `serde_json`;
- ключевые слова (`"enable"`, `"disable"`) формируются по значению флагов;
- составные строковые флаги (`"summary+pools"`) объединяются через `+`.
- **Шифрование AES-256-ECB**: для команд уровня `CommandLevel::Encrypted` (только `set.miner.pools` и `set.user.change_passwd`) параметр сериализуется в JSON-байты, затем шифруется AES-256-ECB с PKCS7 padding и кодируется в base64 ciphertext. Команды с параметрами общего вида (`set.user.permission`, `set.system.timezone`, `set.system.time_randomized` и др.) используют уровень `Secured` и типизированный JSON.
---
## 3. Очистка трейта `Command` (`command.rs`)
### 3.1. Удаление неиспользуемого `type Params`
- Ассоциированный тип `type Params: Serialize` удален из трейта `Command`. Команды напрямую сериализуют свои параметры через `params(&self) -> Result<Option<impl serde::Serialize>>`.
### 3.2. Удаление маркерных трейтов и `Sealed`
- Удалены служебные трейты `Sealed`, `NormalCommand`, `StreamingDownloadCommand` и `StreamingUploadCommand`. Обычные JSON-команды реализуют только базовый трейт `Command`. Стриминговые операции выполняются через публичные методы актора `download_log` и `upload_firmware`.
### 3.3. Разбор ответа: сканирование borrowed `RawValue` конверта + типизированный decode
- Вместо аллокации полного дерева `serde_json::Value` парсинг ответа выполняется в два этапа (это не является строго однопроходным парсингом, но исключает лишние аллокации):
1. Десериализация в промежуточный заемный конверт `Envelope<'a>`, где поле `msg` заимствуется как `&'a serde_json::value::RawValue`, а `desc` как `Cow<'a, str>`.
2. Валидация кода `code`: при успехе `envelope.msg.get()` декодируется непосредственно в типизированный `Self::Response`. При ошибке устройства формируется `Error::Device { code, msg, desc }`.
### 3.4. Сетевое кадрирование: `WhatsMinerFrame::Data(Bytes)`
- Входящие фреймы используют тип `Bytes`:
```rust
#[derive(Debug, Clone, PartialEq)]
pub enum WhatsMinerFrame {
Heartbeat,
Data(Bytes),
}
```
- **Чтение фрейма (`decode`)**: после чтения 4-байтового little-endian заголовка длины вызывается `src.advance(4); let payload = src.split_to(length).freeze();`, возвращая `Bytes` без промежуточного копирования полезной нагрузки кадра. Стриминг больших объемов данных (логи, прошивка) использует поблочный I/O без ложных заявлений о zero-copy на уровне всей передачи.
- **Инкрементальный рост буфера**: буфер растет по мере поступления данных без преждевременного резервирования памяти исключительно по заявленной длине заголовка.
- **Отсутствие выдуманного лимита 8192**: кодек не ограничивает размер framed JSON числом 8192 из демонстрационного C-примера.
- **Heartbeat**: фрейм с 4 нулевыми байтами (`0x00000000`) распознается как keep-alive и безопасно обрабатывается таймером.
- **Несогласованные отчеты**: незапрошенные кадры `get.miner.report` безопасно вычитываются и сбрасываются транспортным циклом (без доставки через callbacks).
---
## 4. Actor Lifecycle & FSM Architecture
### Исторический контекст и проблемы до рефакторинга
- Прежний воркер актора содержал избыточные промежуточные типы на сотни строк.
- Актор был одноразовым: любая сетевая ошибка или таймаут переводила его в терминальное состояние `Dead`.
- `Actor::new` совмещал инициализацию в памяти с реальным сетевым подключением через `await`.
### Целевой FSM и текущая архитектура
Актор управляется единственной фоновой задачей (`actor_loop`), которая владеет и исполняет конечный автомат `ActorState`. Публичная структура `Actor` хранит конфигурацию и каналы. Сетевые ресурсы и сессионные секреты (`Session`: TCP-сокет, сессионная `Salt` и `Permission`) принадлежат активному состоянию FSM.
```mermaid
stateDiagram-v2
[*] --> Created: construct actor
Created --> Connecting: connect intent
Closed --> Connecting: connect intent
Connecting --> Connected: TCP + handshake ok
Connecting --> Reconnecting: connection/handshake failure (retry policy)
Connecting --> Closed: connection failure (no retry / exhausted)
Connected --> Downloading: download mailbox operation
Downloading --> Connected: complete binary transfer
Downloading --> Reconnecting: I/O or protocol failure (retry policy)
Downloading --> Closed: failure (no retry / exhausted)
Connected --> Uploading: upload mailbox operation
Uploading --> Reconnecting: final confirmation (reboot) / retry policy
Uploading --> Closed: failure (no retry / exhausted)
Uploading --> Connected: device error keeps session
Connected --> Reconnecting: session loss / timeout / protocol error (retry policy)
Connected --> Closed: session loss (no retry / exhausted)
Reconnecting --> Connecting: retry deadline elapsed
Reconnecting --> Closed: retry limit reached or shutdown intent
Connected --> Disconnecting: shutdown intent
Disconnecting --> Closed: session dropped and mailboxes closed
Closed --> [*]
```
#### Состояния `ActorState`:
```rust
enum ActorState {
Created,
Connecting,
Connected(Session),
Downloading(Session),
Uploading(Session),
Reconnecting {
attempt: usize,
reason: ReconnectReason,
},
Disconnecting(Session),
Closed {
reason: CloseReason,
},
}
```
- Heartbeat — внутреннее действие `Connected`, а не состояние FSM.
- `Downloading` и `Uploading` — эксклюзивные streaming-состояния, монопольно владеющие сокетом.
- `Session` — некопируемая структура (`non-Clone`, `non-Copy`), владеющая транспортом, криптографической солью `Salt` и правами доступа `Permission`. Перемещается по значению между состояниями FSM.
#### Логическое generation-based закрытие и стабильные очереди
Для одновременной поддержки возврата ошибок закрытия и многоразовости `Actor` реализована схема логических поколений на базе трех очередей `mpsc(1)` и приватного канала `watch<u64>`:
1. **Стабильные очереди и отсутствие precheck**: используются 3 фиксированных канала `mpsc(1)` (`json_tx`, `download_tx`, `upload_tx`). Публичные методы `send()`, `download_log()` и `upload_firmware()` не делают предварительных проверок публичного статуса (`no public status prechecks`), а вызывают внутренний `reserve()`.
2. **Бронирование емкости (`reserve`)**: `reserve(&self.queue, &self.generation_rx)` ожидает емкость очереди через `tokio::select!` между `sender.reserve()` и `changes.changed()`. При смене поколения (закрытие актора) ожидающие операции немедленно возвращают `Error::ActorClosed`.
3. **Инкремент поколения ТОЛЬКО при закрытии**: счетчик поколений инкрементируется исключительно при закрытии операций (`close_operations` при переходе в `Closed` или `Disconnecting`). При повторном вызове `connect()` generation НЕ инкрементируется.
4. **Отсутствие AdmissionState и closed bool**: в архитектуре нет отдельной структуры `AdmissionState` или флага `closed: bool`. Логика закрытия определяется состоянием FSM и сменой поколения `watch<u64>`.
5. **Поведение состояния `Closed`**: актор в состоянии `Closed` отклоняет поступившую и новую работу с `Error::ActorClosed`.
6. **Приоритет подключения в `Closed`**: в состоянии `Closed` цикл актора использует `tokio::select! { biased; ... }`, отдавая приоритет каналу жизненного цикла `lifecycle.recv()`. Благодаря этому последовательность `actor.connect()?; actor.send(cmd).await?;` обрабатывает intent подключения раньше, чем команда будет отклонена.
7. **Выживание очередей при переподключении**: состояние `Reconnecting` не закрывает очереди и не инкрементирует generation. Операции, попавшие в очередь до или во время реконнекта, спокойно дожидаются восстановления сессии в `Connected`.
8. **Разделение владения**: `Actor::clone()` создает полностью независимый актор в состоянии `Created` со своей задачей и каналами. Для разделения одного и того же актора между задачами используется `Arc<Actor>`.
9. **Завершение**: фоновая задача `actor_loop` завершается при удалении всех клонов/дескрипторов `Actor` (закрытие каналов). Guard `ExitStatus` гарантирует публикацию `ActorStatus::Closed` при неожиданном выходе из цикла задачи.
#### Управление солью (`Salt`) и правами:
1. **Тип Newtype**: `Salt` реализован как приватный некопируемый newtype `pub(crate) struct Salt(String)` с `Zeroize` и `ZeroizeOnDrop`.
2. **Приватность**: `Salt` никогда не отдается наружу и не входит в публичную проекцию `ActorStatus`.
3. **Сохранение в сессии**: `Salt` принадлежит структуре `Session` и сохраняется при переходах между `Connected`, `Downloading` и `Uploading`. Соль сбрасывается и зануляется исключительно при дропе `Session` (разрыв соединения, переподключение, завершение сессии или актора).
4. **Права доступа (Permission)**: считываются из `get.device.info` строго один раз во время handshake (`connect_and_get_salt`). Непрерывная синхронизация прав во время работы сессии не выполняется.
#### Надежность TCP и операционные риски (Hardware & Network Realities):
1. **Защита от десинхронизации**: протокол работает строго в режиме Request-Response по единственному сокету. Любой сбой кадрирования или таймаут компрометирует сессию — актор разрывает TCP-соединение и переходит в `Reconnecting`/`Closed`.
2. **Парсинг вне цикла актора и private validation ack**: цикл актора вычитывает сырые байты ответа (`Bytes`) и передает их вызывающей стороне. Вызывающий код в `send()` выполняет разбор ответа и возвращает приватное подтверждение валидации (`validate.send(synchronized)`). Известные ошибки устройства (`Error::Device`) признаются синхронизированными и сохраняют сессию; сбои парсинга протокола или отмена future (`cancellation`) сбрасывают сессию.
3. **Игнорирование команд жизненного цикла при стриминге**: команды жизненного цикла (`connect`/`shutdown`), поступившие во время активного `Downloading` или `Uploading`, вычитываются и игнорируются (stream controls consumed/ignored).
4. **Единый I/O timeout 300 ms**: все сетевые операции используют внутреннюю константу `IO_TIMEOUT = Duration::from_millis(300)`.
5. **Неразрешенный операционный риск**: таймаут 300 мс на физическом оборудовании под нагрузкой или в медленных сетях может приводить к ложным разрывам сессии. В автоматическом CI физическое оборудование не тестировалось, поэтому поведение на реальных контрольных платах остается известным риском.
6. **Политика переподключения (`ReconnectPolicy`)**: детерминированный enum (`None`, `Fixed`, `FixedWithLimit`, `Exponent`, `ExponentWithLimit`).
7. **Размер очередей `mpsc`**: строго **1** для каждой из трех очередей (JSON, download, upload), что обеспечивает backpressure.
---
## 5. Замена `device-error-derive` на декларативный `macro_rules!`
### Исторический контекст
- Ранее использовался отдельный proc-macro крейт `crates/device-error-derive` с зависимостями `syn`, `quote`, `proc-macro2`.
### Решение
1. Внешний крейт полностью удален.
2. Локальный макрос `macro_rules! define_device_errors` размещен непосредственно в коде модуля ошибок `get_device_info/error_code.rs`.
3. Сохранена полная семантика и API: типизированный enum `DeviceErrorCode` со всеми 133 кодами ошибок, `from_code`, `code`, `reason`, `processing_method`, `Display` и `Debug`.
---
## 6. Логирование и обертка `Redacted<T>`
1. **Статическая метка:** Замена вычисления SHA256 в `Redacted::Display` и `Redacted::Debug` на статическую метку `[REDACTED]`.
2. **Уровни Tracing:** Рутинные команды instrumented на уровне `trace`, жизненный цикл — на уровне `info`.
3. **Безопасность памяти:** `AuthData` и `Password` используют зануление памяти `Zeroize` / `ZeroizeOnDrop`.
---
## 7. Историческая миграция: схлопывание workspace в корневой моно-крейт
Архитектура репозитория переведена из многокрейтового workspace (`crates/*`) в единый корневой Rust-пакет `matroskin`:
- Корневой `Cargo.toml` стал манифестом пакета.
- Все модули перенесены в `src/`, интеграционные тесты — в `tests/`.
- Мок-сервер перенесен в `tests/mock_device/` как исключительно тестовая инфраструктура.
- Крейты `matroskin-orchestrator` и `device-error-derive` полностью ликвидированы.
---
## 8. Управление пулом майнеров (Multi-miner strategy)
Библиотека придерживается принципа нулевых навязанных абстракций:
- `matroskin` предоставляет надежный автономный `Actor`.
- Организация пулов отдается пользователю (`Vec<Actor>`, `HashMap<String, Actor>`).
- Массовые операции выполняются стандартными средствами экосистемы Tokio (`JoinSet`, `futures::future::join_all`).
---
## 9. Стратегия тестирования
1. **Дефолтный тестовый набор (`cargo test`)**:
- Чистые unit-тесты команд, сериализации, кодека и логики FSM.
2. **Мок-майнер (`tests/mock_device/`)**:
- Является исключительно тестовой инфраструктурой.
- Эмулирует TCP loopback сервер, говорящий на wire-протоколе WhatsMiner.
- Проверяет переходы FSM, кадрирование, direct streaming и очереди команд.
3. **Тесты реального железа (`tests/real_device/`)**:
- Скомпилированы только под фичей `--features real-device`.
- В CI на физическом оборудовании не запускаются; служат контрактными целями для аппаратного развертывания.
---
## 10. Итоговые результаты рефакторинга
1. **Чистый корень:** единый моно-пакет без лишних зависимостей.
2. **Многоразовый и надежный Актор:** жизненный цикл формализован через FSM, generation-based очереди поддерживают fail-fast при закрытии и переоткрытие без переаллокации.
3. **Плоский API команд:** структуры с именованными полями и конструкторами `new` вместо вложенных кортежей.
4. **Безопасность данных:** гарантированное зануление соли и учетных данных через `ZeroizeOnDrop`, статическая метка `[REDACTED]`.
5. **Сохранение бизнес-логики:** спецификация WhatsMiner v3 API, криптография AES-256-ECB и контракт кадрирования соблюдаются.
---
## 11. Decision Ledger: Baseline vs. Agreed Refactor Direction
Authoritative architecture ledger establishing system-wide invariants:
### 11.1. Inherited baseline (Historical context)
- Workspace layout with separate crates.
- Single-use terminal actor after connection failure.
- Public `ActorState` exposing salt and internal states.
- Tuple command wrappers with unused associated types.
### 11.2. Agreed actor and FSM model
- `Actor` is a thin public handle storing flat initialization fields (`addr`, `username`, `password`, `reconnect_policy`). Requires active Tokio runtime context upon creation.
- `actor_loop` is a single background task that owns and executes the `ActorState` finite state machine.
- `Session` is non-`Clone`, non-`Copy`, owned by active FSM states, holding live transport, `Salt`, and permissions.
- Public state is projected via lightweight `ActorStatus` through `status()` and `watch_status()`. Secrets and transport are never exposed.
- No public status prechecks before enqueuing work; admission is bounded by queue reservation.
- `Actor::clone()` spawns an independent actor in `Created`. `Arc<Actor>` is used to share an actor instance.
- Public methods take `&self`.
### 11.3. Agreed lifecycle states & transitions
- States: `Created`, `Connecting`, `Connected(Session)`, `Downloading(Session)`, `Uploading(Session)`, `Reconnecting { attempt, reason }`, `Disconnecting(Session)`, `Closed { reason }`.
- Heartbeat is an internal action in `Connected`, not a public FSM state.
- `Downloading` and `Uploading` are exclusive streaming states owning the transport; `Salt` is preserved across them.
- Disconnecting only occurs from `Connected(Session)`. From `Reconnecting`, shutdown transitions directly to `Closed`.
- Disconnecting closes mailboxes and completes queued operations with `Error::ActorClosed`.
### 11.4. Lifecycle control
- `connect()` and `shutdown()` are non-blocking intents sent to `lifecycle_tx` (`mpsc(4)`).
- Intents are reduced via `PendingPlan` (capacity 4), dropping duplicate or opposite unstarted intents.
- Lifecycle intents received during active streaming are consumed and ignored.
### 11.5. Command mailboxes and execution
- Three dedicated `mpsc(1)` waiting mailboxes: JSON commands, log download, firmware upload.
- Private generation counter on `watch<u64>` increments ONLY on close (`close_operations`), not on reopen.
- `reserve` waits for capacity and invalidates immediately if closed.
- No `AdmissionState` struct and no `closed: bool` flag.
- Closed actor task loop rejects queued and new work. In `Closed`, `biased select!` prioritizes lifecycle intents over work mailboxes to support `connect(); send()` sequencing.
- Reconnection preserves queues without bumping generation.
- Response parsing parses raw `Bytes` outside the actor loop, returning a private validation ack. Known device errors (`Error::Device`) preserve `Session`; protocol errors or cancellation drop `Session`.
- No separate validator abstractions.
### 11.6. Streaming boundary
- `download_log(destination: impl AsyncWrite)` and `upload_firmware(source: impl AsyncRead, len: u64)` consume owned I/O descriptors.
- Exclusive transport ownership; other mailboxes wait.
- Lifecycle intents received during streaming are consumed/ignored without deferral.
- Successful firmware upload transitions directly to `Reconnecting { reason: FirmwareUpdate }`; a final device error keeps the session.
### 11.7. Explicitly unresolved items
- Concrete enum reason variants: `ReconnectReason` (`FirmwareUpdate`, `Io`, `ProtocolError`, `Timeout`, `ConnectionClosed`) and `CloseReason` (`Shutdown`, `ReconnectFailed`, `Disconnected`). Removed variants such as `HeartbeatTimeout` and `Reboot` are historical only.
- The `get.device.info` response payload is `DeviceInfo`, with optional protected `salt: Option<Redacted<String>>`.
- `PoolConfig` fields are `pool`, `worker`, and `passwd: Redacted<String>`, with 3 agreed fixed entries in `SetMinerPools`.
- Permissions are parsed strictly during the handshake; no continuous synchronization of mutated permissions is claimed.
- Hardware 300ms network timeouts remain an unverified operational risk on live hardware.
---
## 12. Implementation decisions (2026-09-09)
Summary of applied architecture:
- Single root `matroskin` package.
- Full 8-state FSM implemented in `actor_loop`.
- Three dedicated `mpsc(1)` mailboxes with generation-based admission control.
- `Actor::clone()` creates an independent actor; `Arc<Actor>` shares instances.
- Zeroizing `Salt` newtype, redacted secrets, AES-256-ECB encryption.
- Direct streaming via `download_log` and `upload_firmware` without handles.
- Codec with `Bytes`, incremental buffer growth, no fake 8192 limit.
- Unified `IO_TIMEOUT = 300 ms` across all network operations (unresolved hardware risk).
- Unsolicited report frames are safely drained and ignored.