Compare commits

..
1 Commits
Author SHA1 Message Date
syakovlev b8b55cd7ab Add sem2 summary 2026-09-24 01:53:15 +03:00
234 changed files with 3 additions and 23889 deletions

No files matched your search

-113
View File
@@ -1,113 +0,0 @@
# Коллоквиум
Коллоквиум по распределённым системам пройдёт **23–25 октября 2026 года онлайн**. Расписание и способ записи объявим отдельно.
- [Формат и подготовка](#формат-и-подготовка)
- [Оценивание](#оценивание)
- [Если не получается прийти](#если-не-получается-прийти)
- [Примеры вопросов](#примеры-вопросов)
- [Примеры задач](#примеры-задач)
## Формат и подготовка
Коллоквиум состоит из двух частей: вопроса по материалам курса и задачи. Обе части охватывают материалы лекций и семинаров **недель 1–7**. За каждую часть можно получить до **4 баллов**, ещё до **2 баллов** — за усложнение задачи.
За **30 минут** до назначенного времени вы получите в Telegram конкретный вопрос с указанием того, что нужно раскрыть в ответе. Во время подготовки можно пользоваться материалами курса, интернетом и LLM.
Вопрос проверяет понимание основных понятий, механизмов и компромиссов: может потребоваться объяснить принцип работы, сравнить подходы или разобрать небольшой пример. Ориентир для ответа вместе с уточнениями преподавателя — **3–4 минуты**. Уточнения остаются в пределах выданного вопроса. Во время ответа на первую часть можно пользоваться своими краткими заметками; объяснять ответ и отвечать на уточнения нужно самостоятельно.
Условие задачи вы получите непосредственно на защите, без предварительной подготовки. Нужно предложить решение, объяснить его и разобрать указанные ситуации. Ориентир для основной части — **6–7 минут**, включая чтение, обдумывание и уточнения. Достаточно схемы, последовательности действий или короткого псевдокода; нужно объяснить, что хранят участники и как обрабатывают сообщения.
Полные банки вопросов и задач не публикуются. Ниже приведены [примеры вопросов](#примеры-вопросов) и [примеры задач](#примеры-задач), которые показывают формат и ожидаемую глубину ответа, но не исчерпывают темы коллоквиума.
В назначенное время подключитесь по указанной ссылке **со включённой камерой**. На всю защиту выделено **15 минут**, включая переходы между частями. Указанные времена — примерные ориентиры: время между частями распределяется в пределах общего лимита. Основную часть задачи можно продолжать до конца защиты; превышение ориентировочных 6–7 минут само по себе не снижает оценку. Во время защиты пользоваться LLM нельзя: отвечать на вопросы и рассуждать нужно самостоятельно.
## Оценивание
За подготовленный вопрос и основную часть задачи выставляются целые оценки от 0 до 4:
| Балл | Подготовленный вопрос | Основная задача |
| --- | --- | --- |
| 0 | Нет содержательного ответа или понимание не продемонстрировано. | Нет содержательного продвижения к решению. |
| 1 | Есть отдельные правильные факты, но нет связного объяснения. | Есть отдельные правильные идеи, но они не складываются в решение. |
| 2 | Основная идея понятна, но есть существенные пробелы или ошибки. | Предложен разумный подход, но решение не выполняет одно из основных требований. |
| 3 | Ответ в основном правильный, но существенный аспект раскрыт недостаточно. | Решение в основном корректно, но остаётся пробел в требуемом разборе или обосновании. |
| 4 | Существенные аспекты раскрыты, студент объясняет их и отвечает на уточнения. | Основные требования выполнены, предусмотренные ситуации разобраны, решения обоснованы. |
Для полного ответа нужно раскрыть перечисленные в вопросе или задаче аспекты, объяснить механизмы и разобрать все указанные примеры и ситуации. Простого перечисления терминов или названий технологий недостаточно. Каждая часть оценивается целиком; отдельные баллы за подпункты не начисляются. Самостоятельно исправленная оговорка сама по себе не снижает оценку.
**Если за обе части получено по 4 балла и осталось время, проверяющий сразу даёт усложнение задачи.** Уровни усложнения последовательны: **0 баллов**, если первый уровень не выполнен, **1 балл** — за выполненный первый уровень, **2 балла** — за выполненные оба уровня. Конкретные требования приведены в [примерах задач](#примеры-задач).
Ориентир для обоих уровней вместе — **4–5 минут**, включая чтение, обдумывание и уточнения. Это не гарантированное время: на усложнения используется остаток общего времени защиты. На первом уровне достаточно одного изменения решения с объяснением. На втором нужно разобрать предложенную ситуацию или схему, показать её ограничение и обосновать исправление. Новые условия дополняют или явно изменяют предыдущие.
Итоговая оценка — сумма двух основных оценок и баллов за выполненные уровни усложнения. Если времени на усложнение не осталось, итог определяется двумя основными оценками и не превышает **8 баллов**. Максимум с усложнениями — **10 баллов**.
Вес коллоквиума в оценке за курс — **0,2**. Подробнее — в [правилах оценивания курса](https://distsys.ru/course/hse-2026/src/branch/main/README.md).
## Если не получается прийти
Если вы не подключитесь в назначенное время, за коллоквиум выставляется 0. Заранее проверьте, что сможете прийти и вас не будут отвлекать.
Если не можете прийти по уважительной причине, предупредите преподавателей курса. Если вы пропустите коллоквиум без предупреждения, вам могут отказать в досдаче.
## Примеры вопросов
### Пример 1. Производительность
Чем задержка отличается от пропускной способности? Почему средней задержки недостаточно для оценки сервиса? Что означает p99 времени ответа, равный 200 мс? Приведите пример решения, которое повышает пропускную способность, но увеличивает время ожидания отдельного запроса.
### Пример 2. JSON и Protocol Buffers
Сравните JSON и Protocol Buffers по представлению данных, роли схемы и удобству разработки и диагностики. Можно ли по меньшему размеру сериализованного сообщения сделать вывод, что клиент быстрее получит ответ? Какие ещё затраты нужно учитывать? Приведите ситуацию, в которой вы выбрали бы каждый вариант.
### Пример 3. Общий порядок доставки
Почему репликам сервиса бронирования может понадобиться одинаковый порядок обработки конкурентных заявок? Объясните реализацию Total Order через секвенсор и одно её ограничение. Гарантирует ли само свойство Total Order причинный порядок? Рассмотрите два сообщения, последовательно отправленные одним процессом: могут ли все получатели доставить их в обратном порядке?
## Примеры задач
### Пример 1. Два сервиса обрабатывают события
#### Основная часть — до 4 баллов
Сервис заказов публикует события о создании и изменении заказов. У сервиса уведомлений и сервиса аналитики по несколько обработчиков. Каждый сервис должен обработать все события, распределяя работу между своими обработчиками. События уже надёжно приняты брокером. Обработчики могут аварийно завершаться; после сбоя обработку продолжает другой или перезапущенный обработчик. Повторная обработка одного события безопасна. Иногда аналитика обрабатывает события медленнее, чем они поступают. В основной части места в брокере достаточно.
Предложите схему очередей или подписок и выберите момент подтверждения обработки. Разберите два случая: обработчик получил событие, но завершился до начала обработки; обработал событие, но завершился до отправки подтверждения. Объясните, что происходит с накоплением событий и обработкой уведомлений при замедлении аналитики.
#### Первый уровень усложнения — 1 дополнительный балл
Далее рассмотрим обработку событий внутри одного из сервисов. При каждой попытке обработать одно из событий обработчик аварийно завершается. Брокер умеет надёжно перемещать событие в отдельную очередь для разбора. Предложите правило ограничения числа автоматических попыток. Объясните, где и когда учитывать попытку, чтобы правило работало и при падении обработчика, а остальные события продолжали обрабатываться.
**Критерий выполнения:** Обосновано, что правило ограничивает число автоматических попыток даже при падениях обработчика, сохраняет событие для разбора и позволяет продолжать обработку остальных.
#### Второй уровень усложнения — 2 дополнительных балла за оба уровня
Теперь добавим требование к порядку обработки. Для каждого заказа события пронумерованы последовательно, начиная с 1. Их нужно применять без пропусков, в порядке номеров. Задержка одного заказа не должна останавливать обработку других заказов. Предлагается схема: отправив проблемное событие на разбор, обработчик продвигает номер последнего обработанного события, как после успеха, и продолжает работу. Доступно надёжное хранилище ожидающих событий.
Постройте контрпример к этой схеме и предложите исправленную схему: какое состояние хранить и как обрабатывать события. Объясните, что происходит с последующими событиями этого заказа, с другими заказами и после устранения причины сбоя.
**Критерий выполнения:** Самостоятельно построена история нарушения. Обоснованы сохранение порядка в исправленной схеме, независимая обработка других заказов и продолжение работы после устранения причины сбоя.
### Пример 2. Переназначение задания
#### Основная часть — до 4 баллов
Координатор выдаёт задания исполнителям A и B, хранит состояние и не падает. Исполнители возвращают результаты вычислений без внешних побочных эффектов. Сообщения задерживаются и повторяются; исполнители могут остановиться. Повторное вычисление допустимо, но координатор должен сохранить не более одного результата каждого задания. Если исполнитель перестал отвечать, задание переназначают; при работающем исполнителе и восстановлении связи оно должно завершиться.
Опишите, как координатор проверяет состояние исполнителей, когда переназначает задание и как принимает результаты разных попыток. Объясните, как ваше решение предотвращает сохранение нескольких результатов и позволяет завершить задание после отказа исполнителя.
Рассмотрите ситуацию: A перестал отвечать, и координатор переназначил задание B. Может ли A продолжать работу? Как поступить, если затем пришёл его результат? Что делать, если после завершения задания приходят повторное сообщение с результатом или запоздалое сообщение о ходе выполнения?
#### Первый уровень усложнения — 1 дополнительный балл
Задание состоит из последовательно обрабатываемых элементов. Исполнитель продолжает отправлять heartbeat, но больше не завершает обработку новых элементов. Предложите показатель прогресса и правило, по которому координатор заподозрит зависание текущей попытки. Как учитывать повторные и задержанные сообщения? Может ли такая проверка ошибочно заподозрить исправного исполнителя? Объясните почему.
**Критерий выполнения:** Предложены показатель полезного прогресса и правило его проверки. На примере повторных или задержанных сведений объяснены поведение проверки и ограничения выводов об отказе.
#### Второй уровень усложнения — 2 дополнительных балла за оба уровня
Теперь у вычислений появляются внешние побочные эффекты: исполнители записывают данные задания в общее хранилище. Для каждого задания координатор выдаёт возрастающие номера полномочий; каждая запись содержит такой номер. Для каждого задания хранилище запоминает наибольший увиденный номер. Проверка номера и изменение данных выполняются атомарно: запись с меньшим номером отклоняется. Хранилище доступно и допускает отдельную установку номера координатором.
Координатор выдал B новый номер и объявил передачу права завершённой, но хранилище узнает этот номер только из первой записи B. Может ли A после этого ещё записать данные? Покажите нарушение и исправьте порядок передачи так, чтобы после её завершения записи A отклонялись.
**Критерий выполнения:** Приведена допустимая история нарушения и исправлен порядок передачи полномочий. Объяснено, почему после завершения передачи прежний исполнитель не может записать данные, в том числе при задержках и повторах сообщений.
-236
View File
@@ -1,236 +0,0 @@
# HTTP-сервер
В этом задании вам предстоит реализовать простой HTTP-сервер для хранения файлов.
На практике вы, скорее всего, будете использовать готовые библиотеки, в которых работа с HTTP уже реализована. Но полезно хотя бы раз сделать это «руками», чтобы лучше понять протокол: как клиент передаёт запрос, как сервер его читает и формирует ответ.
Формат сообщений и работу соединений описывает [RFC 9112](https://www.rfc-editor.org/rfc/rfc9112.html), семантику методов и заголовков — [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html). В задании используется ограниченное подмножество HTTP/1.1, описанное ниже. Прочитайте условие целиком перед началом работы.
## Запуск сервера
При запуске сервер получает адрес и порт для приёма соединений, рабочую директорию и ожидаемое доменное имя. Эти настройки можно задать параметрами командной строки или переменными окружения:
| Параметр | Переменная окружения | По умолчанию | Назначение |
|---|---|---|---|
| `--host` | `SERVER_HOST` | `0.0.0.0` | Адрес прослушивания TCP-соединений |
| `--port` | `SERVER_PORT` | `8080` | Порт сервера |
| `--working-directory` | `SERVER_WORKING_DIRECTORY` | Нет | Абсолютный путь к существующей рабочей директории |
| `--server-domain` | `SERVER_DOMAIN` | `localhost` | Ожидаемое доменное имя в заголовке `Host` |
Параметр командной строки имеет приоритет над переменной окружения, а переменная окружения — над значением по умолчанию. Если рабочая директория не указана ни одним способом или задана пустой строкой, сервер завершается с кодом 1. В Python-заготовке эта настройка уже реализована.
Параметры `host` и `port` определяют, где сервер принимает TCP-соединения. Параметр `server-domain` используется для проверки заголовка `Host` в запросах; он не меняет адрес прослушивания и не требует настройки DNS.
### Проверка готовности
Тестер ждёт готовности сервера не более **10 секунд**, отправляя запросы `GET / HTTP/1.1` с заголовком `Host`, равным `server-domain`. Готовность подтверждает любой корректный HTTP-ответ, включая ответ об ошибке.
**Сервер должен отвечать на GET `/` во всех группах тестов.** В G1 и G2 можно вернуть код 200 с коротким текстом в теле ответа. Начиная с G3, этот запрос должен возвращать список файлов и директорий в рабочей директории.
Если сервер не начнёт отвечать вовремя, тестер завершит запуск с ошибкой `server did not start in 10 seconds`. В этом случае проверьте вывод сервера в журнале тестирования: проблема может быть как в запуске, так и в обработке GET `/`.
## Работа с файлами
Сервер поддерживает четыре метода: GET читает файл или содержимое директории, POST создаёт файл или директорию, PUT заменяет содержимое существующего файла, а DELETE удаляет файл или директорию. Реализовывать HEAD и OPTIONS не требуется.
Метод задаёт действие, а путь в запросе — файл или директорию, к которым это действие применяется. Все четыре метода используют одну схему путей: путь отсчитывается от рабочей директории сервера, заданной при запуске. Например, при `--working-directory /srv/files` запросы к `/docs/file.txt` работают с `/srv/files/docs/file.txt`. Начальный `/` в запросе обозначает рабочую директорию сервера, а не корень файловой системы компьютера. Отдельные адреса вроде `/get` или `/upload` создавать не нужно.
Ниже записи вида `GET /docs/file.txt` обозначают метод и путь запроса. В передаваемом по TCP HTTP-сообщении к ним добавляются версия протокола, заголовки и, при необходимости, тело.
### GET: чтение файла или директории
Если путь указывает на файл, сервер возвращает его содержимое в теле ответа. Например, `GET /docs/file.txt` возвращает байты файла `/srv/files/docs/file.txt`.
Если путь указывает на директорию, сервер возвращает список её непосредственных дочерних файлов и директорий — листинг. Так, `GET /docs` возвращает имена объектов внутри `/srv/files/docs`, а `GET /` — внутри самой рабочей директории. Вложенные директории обходить не нужно.
В списке должны присутствовать все имена, включая скрытые файлы, но без `.` и `..`. Порядок и оформление произвольные: можно вернуть текст или HTML. Размеры, даты и права доступа не требуются; дополнительные сведения допустимы.
### POST: создание файла или директории
Путь в POST-запросе задаёт имя создаваемого объекта целиком. Например, `POST /docs/new.txt` создаёт файл `/srv/files/docs/new.txt` и записывает в него тело запроса. Директория `/srv/files/docs` к этому моменту должна существовать. Если тело пустое, создаётся пустой файл.
Чтобы создать директорию, клиент передаёт заголовок `Create-Directory: True`. Например, `POST /docs/archive` с этим заголовком создаёт пустую директорию `/srv/files/docs/archive`. Сервер создаёт только указанный объект: промежуточные директории автоматически не создаются. Если по этому пути уже есть файл или директория, POST завершается ошибкой и не заменяет их.
### PUT: замена содержимого файла
PUT записывает тело запроса в существующий файл по указанному пути. Например, `PUT /docs/file.txt` полностью заменяет содержимое `/srv/files/docs/file.txt`. Если новые данные короче прежних, остаток прежнего содержимого должен исчезнуть; пустое тело делает файл пустым.
В этом задании PUT применяется только к существующим файлам. Создавать новый файл или заменять директорию этим методом нельзя.
### DELETE: удаление файла или директории
Запрос `DELETE /docs/file.txt` удаляет файл `/srv/files/docs/file.txt`. Для удаления директории клиент должен явно передать `Remove-Directory: True`. Например, `DELETE /docs/archive` с этим заголовком удаляет `/srv/files/docs/archive` вместе со всеми вложенными файлами и директориями. Без разрешающего заголовка сервер должен отказать в удалении директории, даже если она пустая.
### Коды ответов и общие правила
В таблице собраны коды ответов для всех перечисленных операций. Там, где указаны два кода, можно выбрать любой из них.
| Запрос и состояние пути | Действие и ответ |
|---|---|
| GET существующего файла | Вернуть содержимое файла с кодом 200 |
| GET существующей директории | Вернуть список её непосредственных дочерних файлов и директорий с кодом 200 |
| GET отсутствующего пути | Вернуть 404 |
| POST нового объекта, родительская директория существует | Создать объект и вернуть 200 или 201 |
| POST существующего файла или директории | Вернуть 409 |
| POST без родительской директории | Вернуть 404, не создавать промежуточные директории |
| PUT существующего файла | Полностью заменить содержимое телом запроса и вернуть 200 или 204 |
| PUT директории | Вернуть 409 |
| PUT отсутствующего пути | Вернуть 404, не создавать файл |
| DELETE существующего файла | Удалить файл и вернуть 200 |
| DELETE `/` | Вернуть 403: удаление рабочей директории запрещено независимо от заголовка `Remove-Directory` |
| DELETE директории с `Remove-Directory: True` | Удалить директорию со всем содержимым и вернуть 200 |
| DELETE директории без `Remove-Directory: True` | Вернуть 406 |
| DELETE отсутствующего пути | Вернуть 404 |
Заголовки `Create-Directory` и `Remove-Directory` включают соответствующее действие только со значением `True`. Значение `False` равнозначно отсутствию заголовка; других значений в запросах не будет.
Для упрощения работы с путями в тестах используются только ASCII-имена из букв, цифр, дефиса, подчёркивания и точки. Компоненты `.` и `..`, параметры после `?`, percent-encoding и символические ссылки исключены. Если один из промежуточных компонентов пути является файлом, такой путь обрабатывается как отсутствующий.
При ошибке файловая система должна остаться без изменений. Ответ об ошибке содержит непустой понятный текст; точная формулировка не задана. Успешные ответы на POST, PUT и DELETE могут иметь пустое тело. Ответ 204 всегда передаётся без тела.
## HTTP и заголовки
Каждое соединение содержит один запрос. Сервер отправляет ответ с `Connection: close` и закрывает соединение. Одновременная обработка нескольких соединений не требуется.
Запросы синтаксически корректны и используют HTTP/1.1. В них нет повторяющихся заголовков, `Transfer-Encoding` и `Expect`. Длина тела задаётся корректным `Content-Length`; если заголовка нет, тела нет. Имена заголовков сравниваются без учёта регистра. Неизвестные заголовки можно игнорировать.
TCP передаёт поток байтов: результат одного чтения из сокета может содержать часть заголовка либо конец заголовков вместе с началом тела. Сервер должен работать при любом таком разбиении и не ждать закрытия соединения клиентом, чтобы определить конец запроса. Файлы могут содержать произвольные байты.
| Заголовок | Требование |
|---|---|
| `Host` в запросе | В функциональных тестах содержит доменное имя без порта. Сравнивается с `server-domain` без учёта регистра; при несовпадении вернуть 400, не выполняя файловую операцию |
| `Content-Length` в ответе | Обязателен, кроме ответа 204. Равен размеру передаваемого тела в байтах; для пустого тела — 0, для gzip — размеру сжатых данных. В ответе 204 этот заголовок запрещён |
| `Connection` в ответе | `close` |
| `Content-Type` в ответе | Описывает тип содержимого тела. Для непустого тела нужен корректный MIME-тип. Для листинга допустимы `text/plain` и `text/html`. Для любых файлов, включая текстовые, разрешено использовать `application/octet-stream`; определять MIME-тип по расширению не требуется |
| `Server` в ответе | Непустое название сервера |
| `Accept-Encoding` в запросе | Сообщает, что клиент готов принять сжатые данные. В задании поддерживается только значение `gzip` |
| `Content-Encoding` в ответе | Сообщает, каким алгоритмом сжато тело ответа. При сжатии имеет значение `gzip`; в несжатом ответе отсутствует |
Проверка `Host`, `Content-Type` и `Server` входит в G5 и G7. Коды ответа, длина и границы тела, закрытие соединения и заданное условием содержимое проверяются во всех группах.
В G7 успешные GET-ответы на запрос с `Accept-Encoding: gzip` должны содержать сжатые данные. Это относится и к файлам, и к листингам. Без этого заголовка ответ не сжимается и не содержит `Content-Encoding`. Сжатие ошибок не требуется. Готовые библиотеки gzip использовать можно. `Transfer-Encoding` в ответах не используется.
## Компоненты и оценивание
Автоматические тесты определяют предварительную оценку. Можно реализовать часть задания: каждая группа проверяет только перечисленные для неё возможности. Например, в G1 достаточно чтения существующих текстовых файлов и ответа на служебный GET `/`, а создание и удаление файлов появляются в G4.
Для получения баллов за группу нужно пройти все её проверки. Группы оцениваются независимо: ошибка в одной группе не лишает баллов за успешно пройденные другие. За все семь групп можно получить 10 баллов.
| Группа | Баллы | Что проверяется |
|---|---|---|
| G1 | 3 | GET существующих текстовых файлов в ASCII, размером ≤ 8 МБ; настройка и запуск сервера |
| G2 | 1 | GET существующих файлов с произвольными байтами, размером ≤ 8 МБ |
| G3 | 1 | GET файлов и директорий, ошибки отсутствующих путей; файлы в ASCII, размером ≤ 8 МБ |
| G4 | 2 | GET, POST, PUT и DELETE, листинг и ошибки; произвольные файлы размером ≤ 8 МБ |
| G5 | 1 | Возможности G4 и проверка `Host`, `Content-Type`, `Server` |
| G6 | 1 | Возможности G4 и работа с большими файлами |
| G7 | 1 | Возможности G6, дополнительные заголовки из G5 и gzip |
В G1 и G3 используются текстовые файлы в кодировке ASCII. В остальных группах файлы могут содержать произвольные байты, поэтому их содержимое нельзя считать текстом.
В G1–G5 размер каждого файла не превышает 8 МБ. Это ограничение действует и на содержимое файлов, передаваемое в запросах POST и PUT. В G6 и G7 файлы могут быть больше 8 МБ. В этом задании 1 МБ = 1024 × 1024 байта.
В тестирующей системе решение работает в Docker с лимитом памяти **128 МБ**. В G6 и G7 файлы могут превышать объём доступной памяти: нужно уметь и отдавать их клиенту, и принимать при POST и PUT, не загружая целиком в память. Проверить соблюдение лимита можно локально с Docker по инструкции ниже. Запуск без Docker этот лимит не проверяет.
Для передачи большого файла с gzip и корректным `Content-Length` можно предварительно сжать его во временный файл, определить размер результата и затем передать его клиенту по частям. Само сжатие также должно укладываться в ограничение памяти.
### Отчёт и защита
Вместе с кодом сдайте краткий отчёт `solution/readme.md`. Опишите устройство решения и используемые библиотеки, а также объясните следующие механизмы, указав соответствующие файлы и функции в своём коде:
- как определяются границы заголовков и тела;
- как обрабатываются большие файлы и от чего зависит расход памяти, если реализованы G6/G7;
- как определяется длина сжатого ответа, если реализована G7.
Без отчёта автоматические тесты запускаются, но решение не засчитывается по [общим правилам сдачи](../readme.md). Использование LLM укажите согласно [политике курса](../../llm-policy.md).
На защите нужно объяснить сданную реализацию, разобрать предложенный сценарий и при необходимости внести небольшую правку либо проследить выполнение кода. Ответы подтверждают баллы соответствующих компонентов; прохождение тестов само по себе не гарантирует итоговую оценку.
Если вы используете заготовку, на защите нужно понимать, как ваш код взаимодействует с ней: как запускается сервер, откуда берутся параметры и как соединение передаётся обработчику запроса.
## Заготовка и ограничения реализации
Нельзя использовать готовые библиотеки HTTP или парсеры HTTP-сообщений, в том числе из стандартной библиотеки языка. Можно использовать TCP-сокеты, `socketserver`, библиотеки для файлов, командной строки и gzip.
В `solution` находится Python-заготовка с настройкой параметров, TCP-сервером, логированием и структурами сообщений. Вам предстоит реализовать чтение и разбор запроса, обработку методов и формирование ответа. Методы в `http_messages.py` предназначены для стартовой строки и заголовков; тело обрабатывается отдельно. Наличие констант других методов не означает, что их нужно поддерживать.
Можно изменить структуру заготовки или выбрать другой язык; использовать именно её классы необязательно. Решение должно поддерживать описанные параметры командной строки и переменные окружения. В `solution/Dockerfile` должно быть описание сборки образа и запуска вашего сервера. Если вы меняете язык или способ запуска, обновите этот файл.
Весь код решения, необходимые для сборки файлы и отчёт разместите в папке `solution`. При сдаче отправляется только эта папка; изменения в тестах, `tests/launch.tmpl` и других файлах вне неё в тестирующую систему не попадут.
## Тестирование
Тесты написаны на Go и находятся в папке `tests`; точка входа — [TestHW](./tests/hw_test.go). Во время разработки удобно запускать их без Docker для быстрой проверки после изменений. Перед сдачей проверьте решение и с Docker: этот режим воспроизводит окружение тестирующей системы и ограничение памяти.
### Как устроены тесты
Внутри каждой группы есть несколько запусков. Для каждого запуска тестер создаёт набор файлов и директорий, запускает ваш сервер с соответствующей рабочей директорией и отправляет ему последовательность HTTP-запросов. Сервер работает до конца этого запуска, поэтому изменения после POST, PUT и DELETE влияют на последующие запросы. Запуск считается успешным, если сервер правильно обработал все запросы; для получения баллов нужно пройти все запуски группы.
Наборы файлов и запросов генерируются случайным образом, но воспроизводятся при повторном запуске той же версии тестов. Числовые идентификаторы в журнале позволяют повторить нужную последовательность. Помимо сгенерированных запросов, есть отдельные проверки конкретных случаев, например пустого файла или разбиения запроса между несколькими чтениями TCP-потока. Тесты помогают находить ошибки, но не заменяют проверку соответствия всему условию.
### Без Docker
Установите Go. В `tests/launch.tmpl` задана команда, которой тестер запускает сервер. При необходимости измените её для своей системы или языка; в Windows вместо `python3` может потребоваться `python`. Сохраните в шаблоне `{{.CommandLineArgs}}`: на это место тестер подставляет параметры запуска.
Если используете Python-заготовку, установите её зависимости. Затем запустите тесты следующими командами из папки задания:
```sh
python3 -m pip install -r solution/requirements.txt
cd tests
go test
```
Для отдельной группы или запуска используйте фильтр:
```sh
go test -run 'TestHW/G1'
go test -run 'TestHW/G1/1337'
```
Имена проверок видны в журнале. Например, `TestHW/G2/93/42300` обозначает группу G2, запуск 93 и запрос 42300. В G1 и G2 запросы только читают файлы, поэтому можно повторить отдельный запрос, указав его полный идентификатор: `go test -run 'TestHW/G2/93/42300'`.
В группах с POST, PUT и DELETE запросы используют общее изменяемое состояние файлов. Поэтому ошибку в такой группе воспроизводите всем запуском: отдельный запрос может получить другое начальное состояние.
### С Docker
Перед первым запуском и после объявления об обновлении тестов загрузите образ:
```sh
docker pull distsys.ru/course/http-server:latest
```
Запускайте из папки задания:
```sh
docker run --privileged --rm -v ./solution:/hw/solution distsys.ru/course/http-server:latest
```
Для отдельной группы добавьте `-run 'TestHW/G1'` после имени образа. Тестер собирает контейнер вашего сервера и запускает его с лимитом памяти 128 МБ. Общий лимит запуска образа тестов — 10 минут. Скорость зависит от компьютера и Docker-окружения.
Можно собрать образ сервера самостоятельно и проверить его локальным тестером:
```sh
cd solution
docker build -t hw3img .
cd ../tests
go test --docker -timeout 10m
```
Для сборки самого образа тестов выполните из папки задания:
```sh
docker build -t hw3tests ./tests
docker run --privileged --rm -v ./solution:/hw/solution hw3tests
```
### Как читать результаты
В журнале видны команда запуска с параметрами и переменными окружения, весь вывод сервера в stdout/stderr и причины ошибок. При отладке начните с первого неудачного запроса: его идентификатор позволяет повторить соответствующий запуск с помощью фильтра `-run`.
После ошибки оставшиеся запросы этого запуска и оставшиеся запуски группы пропускаются, а за группу начисляется 0 баллов. Тестирование других групп продолжается. В конце выводятся баллы по группам и строка `SCORE: N`. Если использовался фильтр, эта оценка учитывает только выбранные проверки; оценку за всё задание показывает полный прогон.
После локальной проверки отправьте решение по [общей инструкции](../readme.md).
### Бонус за пробелы в тестах
Если найдёте ошибку в тестах или требование, нарушение которого они не обнаруживают, опишите ситуацию в отчёте; при необходимости приложите пример ошибочного решения. За подтверждённую проблему можно получить 1 бонусный балл, а за тест, обнаруживающий её, или описание его логики — ещё 1 балл.
@@ -1,10 +0,0 @@
FROM python:3.12-slim
WORKDIR /http/server
COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt
COPY . .
ENTRYPOINT ["python3", "server.py"]
@@ -1,124 +0,0 @@
import dataclasses
import typing as t
@dataclasses.dataclass
class HTTPRequest:
method: str
path: str
version: str
parameters: t.Dict[str, str]
headers: t.Dict[str, str]
@staticmethod
def from_bytes(data: bytes) -> "HTTPRequest":
# data contains the request line and headers, including the final CRLF.
# Read the body separately in HTTPHandler; it may exceed available RAM.
# Query parameters are not required: parameters can be an empty dict.
# TODO: Parse the request line and headers.
pass
@dataclasses.dataclass
class HTTPResponse:
version: str
status: str
headers: t.Dict[str, str]
def to_bytes(self) -> bytes:
# Return the status line and headers, ending with an empty line.
# HTTPHandler sends the body separately.
# TODO: Serialize the response headers.
pass
# Common HTTP strings and constants
CR = b'\r'
LF = b'\n'
CRLF = CR + LF
HTTP_VERSION = "1.1"
OPTIONS = 'OPTIONS'
GET = 'GET'
HEAD = 'HEAD'
POST = 'POST'
PUT = 'PUT'
DELETE = 'DELETE'
METHODS = [
OPTIONS,
GET,
HEAD,
POST,
PUT,
DELETE,
]
# Only GET, POST, PUT and DELETE are required in this assignment.
HEADER_HOST = "Host"
HEADER_CONTENT_LENGTH = "Content-Length"
HEADER_CONTENT_TYPE = "Content-Type"
HEADER_CONTENT_ENCODING = "Content-Encoding"
HEADER_ACCEPT_ENCODING = "Accept-Encoding"
HEADER_CREATE_DIRECTORY = "Create-Directory"
HEADER_SERVER = "Server"
HEADER_REMOVE_DIRECTORY = "Remove-Directory"
GZIP = "gzip"
TEXT_PLAIN = "text/plain"
APPLICATION_OCTET_STREAM = "application/octet-stream"
APPLICATION_GZIP = "application/gzip"
OK = "200"
BAD_REQUEST = "400"
NOT_FOUND = "404"
METHOD_NOT_ALLOWED = "405"
NOT_ACCEPTABLE = "406"
CONFLICT = "409"
HTTP_REASON_BY_STATUS = {
"100": "Continue",
"101": "Switching Protocols",
"200": "OK",
"201": "Created",
"202": "Accepted",
"203": "Non-Authoritative Information",
"204": "No Content",
"205": "Reset Content",
"206": "Partial Content",
"300": "Multiple Choices",
"301": "Moved Permanently",
"302": "Found",
"303": "See Other",
"304": "Not Modified",
"305": "Use Proxy",
"307": "Temporary Redirect",
"400": "Bad Request",
"401": "Unauthorized",
"402": "Payment Required",
"403": "Forbidden",
"404": "Not Found",
"405": "Method Not Allowed",
"406": "Not Acceptable",
"407": "Proxy Authentication Required",
"408": "Request Time-out",
"409": "Conflict",
"410": "Gone",
"411": "Length Required",
"412": "Precondition Failed",
"413": "Request Entity Too Large",
"414": "Request-URI Too Large",
"415": "Unsupported Media Type",
"416": "Requested range not satisfiable",
"417": "Expectation Failed",
"500": "Internal Server Error",
"501": "Not Implemented",
"502": "Bad Gateway",
"503": "Service Unavailable",
"504": "Gateway Time-out",
"505": "HTTP Version not supported",
}
@@ -1 +0,0 @@
click==8.3.0
@@ -1,86 +0,0 @@
import logging
import pathlib
from dataclasses import dataclass
from socketserver import StreamRequestHandler
import typing as t
import click
import socket
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
@dataclass
class HTTPServer:
server_address: t.Tuple[str, int]
socket: socket.socket
server_domain: str
working_directory: pathlib.Path
class HTTPHandler(StreamRequestHandler):
server: HTTPServer
# Use self.rfile and self.wfile to interact with the client
# Access domain and working directory with self.server.{attr}
def handle(self) -> None:
first_line = self.rfile.readline()
logger.info(f"Handle connection from {self.client_address}, first_line {first_line}")
# TODO: Read the remaining headers, handle the request and send a response.
# Read the body separately using its Content-Length, not until EOF.
# Keep memory bounded when processing large files.
pass
@click.command()
@click.option("--host", envvar="SERVER_HOST", default="0.0.0.0", type=str)
@click.option("--port", envvar="SERVER_PORT", default=8080, type=int)
@click.option("--server-domain", envvar="SERVER_DOMAIN", default="localhost", type=str)
@click.option("--working-directory", envvar="SERVER_WORKING_DIRECTORY", type=str)
def main(host, port, server_domain, working_directory):
if not working_directory:
raise SystemExit(1)
working_directory_path = pathlib.Path(working_directory)
logger.info(
f"Starting server on {host}:{port}, domain {server_domain}, working directory {working_directory}"
)
# Create a server socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Set SO_REUSEADDR option
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Bind the socket object to the address and port
s.bind((host, port))
# Start listening for incoming connections
s.listen()
logger.info(f"Listening at {s.getsockname()}")
server = HTTPServer((host, port), s, server_domain, working_directory_path)
while True:
# Accept any new connection (request, client_address)
try:
conn, addr = s.accept()
except OSError:
break
try:
# Handle the request
HTTPHandler(conn, addr, server)
# Close the connection
conn.shutdown(socket.SHUT_WR)
conn.close()
except Exception as e:
logger.error(e)
conn.close()
if __name__ == "__main__":
main()
@@ -1,7 +0,0 @@
target
**/target
**/__pycache__
**/.pytest_cache
*.pyc
tmp/
tests.log
-1
View File
@@ -1 +0,0 @@
tmp
-18
View File
@@ -1,18 +0,0 @@
# syntax=docker/dockerfile:1
FROM docker:dind
RUN apk add --no-cache bash ca-certificates go wget
WORKDIR /hw
COPY go.mod go.sum tests/
RUN --mount=type=cache,id=distsys-course-go-mod,target=/go/pkg/mod,sharing=locked \
cd tests && go mod download
COPY . tests
RUN --mount=type=cache,id=distsys-course-go-mod,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=distsys-course-go-build,target=/root/.cache/go-build,sharing=locked \
cd tests && go test -c -o /usr/local/bin/hw3test .
COPY entrypoint.sh /usr/local/bin/
COPY verify-zram-scratch.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/verify-zram-scratch.sh
ENTRYPOINT ["timeout", "-k", "10", "600", "entrypoint.sh"]
-124
View File
@@ -1,124 +0,0 @@
package hw3test
import (
"bytes"
"fmt"
"github.com/stretchr/testify/require"
"io"
"math/rand"
"os"
"path"
)
// RequireFileContent requires that the contents of the file match the contents of the reader.
func RequireFileContent(t *TC, workdir, p string, f *EnvFile) {
fullpath := path.Join(workdir, p)
file, err := os.Open(fullpath)
require.NoError(t, err, "failed to open file %s, expected it exists on disk", p)
defer file.Close()
stat, err := file.Stat()
require.NoError(t, err, "failed to stat file %s", p)
require.Equal(t, f.Size, stat.Size(), "file %s has wrong size", p)
require.NoError(t, CompareFileContent(t, file, f), "file %s has wrong content", p)
}
// CompareFileContent compares first f.Size bytes of the reader with generated content.
func CompareFileContent(t *TC, r io.Reader, f *EnvFile) error {
actual := make([]byte, 64*1024)
expected := make([]byte, len(actual))
var generator io.Reader
if f.TextOnly {
generator = f.Open()
} else {
generator = newComparisonReader(f.GenSeed)
}
for offset := int64(0); offset < f.Size; {
count := int64(len(actual))
if remaining := f.Size - offset; remaining < count {
count = remaining
}
if _, err := io.ReadFull(r, actual[:count]); err != nil {
return fmt.Errorf("unexpected file error at position %d: %w", offset, err)
}
if _, err := io.ReadFull(generator, expected[:count]); err != nil {
return fmt.Errorf("unexpected generator error at position %d: %w", offset, err)
}
if !bytes.Equal(actual[:count], expected[:count]) {
for i := int64(0); i < count; i++ {
if actual[i] != expected[i] {
return fmt.Errorf("position %d, expected byte %d, got %d", offset+i, expected[i], actual[i])
}
}
}
offset += count
}
return nil
}
// comparisonReader emits the same bytes as rand.New(source).Read, seven bytes
// from each Int63 value. It fills complete seven-byte groups without the
// per-byte branch in math/rand.Read. Request bodies still use EnvFile.Open.
type comparisonReader struct {
source rand.Source
value uint64
remaining uint8
}
func newComparisonReader(seed int64) *comparisonReader {
return &comparisonReader{source: rand.NewSource(seed)}
}
func (r *comparisonReader) Read(p []byte) (int, error) {
n := len(p)
for len(p) > 0 && r.remaining > 0 {
p[0] = byte(r.value)
r.value >>= 8
r.remaining--
p = p[1:]
}
for len(p) >= 7 {
value := uint64(r.source.Int63())
p[0] = byte(value)
p[1] = byte(value >> 8)
p[2] = byte(value >> 16)
p[3] = byte(value >> 24)
p[4] = byte(value >> 32)
p[5] = byte(value >> 40)
p[6] = byte(value >> 48)
p = p[7:]
}
if len(p) > 0 {
r.value = uint64(r.source.Int63())
r.remaining = 7
for i := range p {
p[i] = byte(r.value)
r.value >>= 8
r.remaining--
}
}
return n, nil
}
// RequireDir ensures that the directory exists on disk.
func RequireDir(t *TC, workdir string, p string, dir *EnvDir) {
fullpath := path.Join(workdir, p)
stat, err := os.Stat(fullpath)
require.NoError(t, err, "failed to stat directory %s, expected it exists on disk", p)
require.True(t, stat.IsDir(), "expected %s to be a directory", p)
}
// RequireNotExists ensures that the file/directory does not exist on disk.
func RequireNotExists(t *TC, workdir string, p string) {
fullpath := path.Join(workdir, p)
_, err := os.Stat(fullpath)
require.True(t, os.IsNotExist(err), "expected %s to not exist on disk", p)
}
// RequireExists ensures that the file/directory exists on disk.
func RequireExists(t *TC, workdir string, p string) {
fullpath := path.Join(workdir, p)
_, err := os.Stat(fullpath)
require.NoError(t, err, "failed to stat %s, expected it exists on disk", p)
}
@@ -1,60 +0,0 @@
package hw3test
import (
"bytes"
"io"
"math/rand"
"testing"
)
func TestComparisonReaderMatchesEnvFile(t *testing.T) {
for _, seed := range []int64{-7824, 0, 1, 7824, 9223372036854775807} {
for _, chunkSize := range []int{1, 6, 7, 8, 13, 64 * 1024, 131071} {
const total = 1024*1024 + 19
expected := make([]byte, total)
actual := make([]byte, total)
_, err := io.ReadFull(rand.New(rand.NewSource(seed)), expected)
if err != nil {
t.Fatal(err)
}
reader := newComparisonReader(seed)
for offset := 0; offset < total; {
end := offset + chunkSize
if end > total {
end = total
}
if _, err := io.ReadFull(reader, actual[offset:end]); err != nil {
t.Fatal(err)
}
offset = end
}
if !bytes.Equal(actual, expected) {
t.Fatalf("different bytes for seed %d and chunk size %d", seed, chunkSize)
}
}
}
}
func BenchmarkComparisonReaders(b *testing.B) {
const size = 128 * 1024 * 1024
buffer := make([]byte, 64*1024)
for _, candidate := range []struct {
name string
open func() io.Reader
}{
{"math/rand", func() io.Reader { return rand.New(rand.NewSource(7824)) }},
{"comparison", func() io.Reader { return newComparisonReader(7824) }},
} {
b.Run(candidate.name, func(b *testing.B) {
b.SetBytes(size)
for i := 0; i < b.N; i++ {
reader := candidate.open()
for remaining := size; remaining > 0; remaining -= len(buffer) {
if _, err := io.ReadFull(reader, buffer); err != nil {
b.Fatal(err)
}
}
}
})
}
}
-301
View File
@@ -1,301 +0,0 @@
package hw3test
import (
"bufio"
"bytes"
"compress/gzip"
"crypto/sha256"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"time"
"github.com/stretchr/testify/require"
)
// Contract cases use a small separate tree, so snapshots never scan G6/G7 files.
func RunContractTests(t *TC, runner Runner, tmpRoot, group string) {
t.RunByName("contract", func(t *TC) {
workdir, err := os.MkdirTemp(tmpRoot, "contract-")
require.NoError(t, err)
defer os.RemoveAll(workdir)
require.NoError(t, os.Chmod(workdir, 0755))
require.NoError(t, os.MkdirAll(filepath.Join(workdir, "dir", "nested"), 0755))
ascii := []byte("hello\r\nworld\n")
binary := []byte{0, 255, 13, 10, 13, 10, 128, 1}
if group == "G1" || group == "G3" {
binary = ascii
}
for name, body := range map[string][]byte{
"alpha.txt": ascii, "binary": binary, "empty": {},
".hidden": []byte("hidden"), "dir/nested/child": []byte("child"),
} {
require.NoError(t, os.WriteFile(filepath.Join(workdir, filepath.FromSlash(name)), body, 0644))
}
port, err := GetFreePort()
require.NoError(t, err)
opts := RunOpts{Port: port, WorkingDirectory: workdir, ListenAddr: "0.0.0.0", ServerDomain: "localhost"}
opts.GenerateRunConfig(t, rand.New(rand.NewSource(9103)), &EnvGen{AllowEnv: true})
stop, err := runner.Run(t, opts)
require.NoError(t, err)
defer stop()
require.NoError(t, WaitForServer(t, opts))
extra := group == "G5" || group == "G7"
request := func(method, path, headers string, body []byte) string {
return fmt.Sprintf("%s %s HTTP/1.1\r\nhOsT: LOCALHOST\r\ncOnTeNt-LeNgTh: %d\r\n%s\r\n%s", method, path, len(body), headers, body)
}
run := func(name, raw string, split bool, codes []int, expected []byte, unchanged bool) {
t.RunByName(name, func(t *TC) {
before, err := snapshotTree(workdir)
require.NoError(t, err)
parts := [][]byte{[]byte(raw)}
if split {
// Split inside a header and inside CRLFCRLF; coalesce its end with body bytes.
boundary := strings.Index(raw, "\r\n\r\n")
parts = [][]byte{[]byte(raw[:9]), []byte(raw[9 : boundary+3]), []byte(raw[boundary+3:])}
}
resp, body := contractExchange(t, opts, parts)
require.Contains(t, codes, resp.StatusCode)
if resp.StatusCode >= 400 {
require.NotEmpty(t, bytes.TrimSpace(body), "error explanation is empty")
}
if extra {
require.NotEmpty(t, resp.Header.Get("Server"))
if len(body) > 0 {
mediaType, err := parseContentType(resp.Header.Get("Content-Type"))
require.NoError(t, err)
if methodIsDirectoryGet(raw) && resp.StatusCode == 200 {
require.Contains(t, []string{"text/plain", "text/html"}, mediaType)
}
}
}
if expected != nil {
require.Empty(t, resp.Header.Get("Content-Encoding"))
require.Equal(t, expected, body)
}
if unchanged {
after, err := snapshotTree(workdir)
require.NoError(t, err)
require.NoError(t, compareSnapshots(before, after))
}
})
}
if group == "G4" || group == "G5" || group == "G6" || group == "G7" {
run("post-root-file", request("POST", "/", "", binary), false, []int{409}, nil, true)
run("post-root-dir", request("POST", "/", "Create-Directory: True\r\n", nil), false, []int{409}, nil, true)
run("put-root", request("PUT", "/", "", binary), false, []int{409}, nil, true)
for _, value := range []string{"absent", "False", "True"} {
header := ""
if value != "absent" {
header = "Remove-Directory: " + value + "\r\n"
}
run("delete-root-"+value, request("DELETE", "/", header, nil), false, []int{403}, nil, true)
}
if extra {
raw := strings.Replace(request("DELETE", "/", "Remove-Directory: True\r\n", nil), "LOCALHOST", "wrong.example", 1)
run("wrong-host-delete-root", raw, false, []int{400}, nil, true)
}
}
switch group {
case "G1":
run("split-headers", request("GET", "/alpha.txt", "", nil), true, []int{200}, ascii, true)
// No Content-Length is also valid for an empty request.
run("no-request-body", "GET /alpha.txt HTTP/1.1\r\nHost: localhost\r\n\r\n", false, []int{200}, ascii, true)
run("empty-file", request("GET", "/empty", "", nil), false, []int{200}, []byte{}, true)
case "G2":
run("binary-body", request("GET", "/binary", "", nil), true, []int{200}, binary, true)
case "G3":
run("missing", request("GET", "/missing", "", nil), false, []int{404}, nil, true)
t.RunByName("root-listing", func(t *TC) {
resp, body := contractExchange(t, opts, [][]byte{[]byte(request("GET", "/", "", nil))})
require.Equal(t, 200, resp.StatusCode)
require.Empty(t, resp.Header.Get("Content-Encoding"))
for _, name := range []string{"alpha.txt", "binary", "empty", ".hidden", "dir"} {
require.Contains(t, string(body), name)
}
})
case "G4", "G5":
for _, tc := range []struct {
name, method, path, headers string
code int
}{
{"post-existing-file", "POST", "/alpha.txt", "", 409},
{"post-existing-dir", "POST", "/dir", "Create-Directory: True\r\n", 409},
{"post-missing-parent", "POST", "/missing/new", "", 404},
{"post-dir-missing-parent", "POST", "/missing/new", "Create-Directory: True\r\n", 404},
{"post-file-as-parent", "POST", "/alpha.txt/child", "", 404},
{"put-missing", "PUT", "/missing", "", 404},
{"put-dir", "PUT", "/dir", "", 409},
{"delete-missing", "DELETE", "/missing", "", 404},
{"delete-dir-absent", "DELETE", "/dir", "", 406},
{"delete-dir-false", "DELETE", "/dir", "rEmOvE-dIrEcToRy: False\r\n", 406},
} {
body := []byte(nil)
if tc.method == "PUT" || (tc.method == "POST" && tc.headers == "") {
body = binary
}
run(tc.name, request(tc.method, tc.path, tc.headers, body), false, []int{tc.code}, nil, true)
}
run("create-file-false", request("POST", "/new", "cReAtE-dIrEcToRy: False\r\n", binary), true, []int{200, 201}, nil, false)
run("read-created", request("GET", "/new", "", nil), false, []int{200}, binary, true)
run("create-empty", request("POST", "/new-empty", "", nil), false, []int{200, 201}, nil, false)
run("read-empty", request("GET", "/new-empty", "", nil), false, []int{200}, []byte{}, true)
run("replace-shorter", request("PUT", "/alpha.txt", "", []byte("x")), true, []int{200, 204}, nil, false)
run("read-shorter", request("GET", "/alpha.txt", "", nil), false, []int{200}, []byte("x"), true)
run("replace-empty", request("PUT", "/alpha.txt", "", nil), false, []int{200, 204}, nil, false)
run("read-replaced-empty", request("GET", "/alpha.txt", "", nil), false, []int{200}, []byte{}, true)
run("create-dir", request("POST", "/created-dir", "cReAtE-dIrEcToRy: True\r\n", nil), false, []int{200, 201}, nil, false)
t.RunByName("created-dir-on-disk", func(t *TC) {
entries, err := os.ReadDir(filepath.Join(workdir, "created-dir"))
require.NoError(t, err)
require.Empty(t, entries)
})
run("recursive-delete", request("DELETE", "/dir", "rEmOvE-dIrEcToRy: True\r\n", nil), false, []int{200}, nil, false)
t.RunByName("deleted-tree-on-disk", func(t *TC) {
_, err := os.Stat(filepath.Join(workdir, "dir"))
require.True(t, os.IsNotExist(err), "directory must be removed")
})
if extra {
for _, method := range []string{"GET", "POST", "PUT", "DELETE"} {
raw := strings.Replace(request(method, "/alpha.txt", "", []byte("changed")), "LOCALHOST", "wrong.example", 1)
run("wrong-host-"+method, raw, false, []int{400}, nil, true)
}
}
case "G7":
for _, path := range []string{"/binary", "/dir", "/empty"} {
t.RunByName("gzip-"+strings.TrimPrefix(path, "/"), func(t *TC) {
before, err := snapshotTree(workdir)
require.NoError(t, err)
resp, body := contractExchange(t, opts, [][]byte{[]byte(request("GET", path, "aCcEpT-eNcOdInG: gzip\r\n", nil))})
require.Equal(t, 200, resp.StatusCode)
require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"))
require.NotEmpty(t, resp.Header.Get("Server"))
mediaType, err := parseContentType(resp.Header.Get("Content-Type"))
require.NoError(t, err)
gz, err := gzip.NewReader(bytes.NewReader(body))
require.NoError(t, err)
decoded, err := io.ReadAll(gz)
require.NoError(t, err)
require.NoError(t, gz.Close())
if path == "/binary" {
require.Equal(t, binary, decoded)
}
if path == "/empty" {
require.Empty(t, decoded)
}
if path == "/dir" {
require.Contains(t, string(decoded), "nested")
require.Contains(t, []string{"text/plain", "text/html"}, mediaType)
}
after, err := snapshotTree(workdir)
require.NoError(t, err)
require.NoError(t, compareSnapshots(before, after))
})
}
}
})
}
func methodIsDirectoryGet(raw string) bool { return strings.HasPrefix(raw, "GET / HTTP/") }
// The client leaves its sending side open: a server reading to EOF must time out.
func contractExchange(t *TC, opts RunOpts, parts [][]byte) (*http.Response, []byte) {
req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(bytes.Join(parts, nil))))
require.NoError(t, err)
defer req.Body.Close()
address, err := url.Parse(opts.Address())
require.NoError(t, err)
conn, err := net.DialTimeout("tcp", address.Host, 10*time.Second)
require.NoError(t, err)
defer conn.Close()
require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second)))
for i, part := range parts {
_, err := io.Copy(conn, bytes.NewReader(part))
require.NoError(t, err)
if i+1 < len(parts) {
time.Sleep(20 * time.Millisecond)
}
}
resp, body, err := readContractResponse(bufio.NewReader(conn), req.ContentLength > 0)
require.NoError(t, err)
return resp, body
}
func readContractResponse(reader *bufio.Reader, requestHasBody bool) (*http.Response, []byte, error) {
resp, err := http.ReadResponse(reader, nil)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if err := validateResponseFraming(resp); err != nil {
return resp, nil, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return resp, nil, err
}
if resp.StatusCode != 204 && int64(len(body)) != resp.ContentLength {
return resp, nil, fmt.Errorf("response length mismatch")
}
if err := checkResponseEnd(reader, requestHasBody); err != nil {
return resp, nil, err
}
return resp, body, nil
}
type treeEntry struct {
Mode os.FileMode
Size int64
Digest [32]byte
}
func snapshotTree(root string) (map[string]treeEntry, error) {
result := make(map[string]treeEntry)
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
entry := treeEntry{Mode: info.Mode()}
if info.Mode().IsRegular() {
entry.Size = info.Size()
f, err := os.Open(p)
if err != nil {
return err
}
h := sha256.New()
_, copyErr := io.Copy(h, f)
closeErr := f.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
copy(entry.Digest[:], h.Sum(nil))
}
result[rel] = entry
return nil
})
return result, err
}
func compareSnapshots(before, after map[string]treeEntry) error {
if !reflect.DeepEqual(before, after) {
return fmt.Errorf("request changed the file system unexpectedly")
}
return nil
}
@@ -1,144 +0,0 @@
package hw3test
import (
"bufio"
"errors"
"io"
"net"
"os"
"path/filepath"
"strings"
"testing"
)
func TestContractResponseRejectsBadFraming(t *testing.T) {
for _, tc := range []struct {
name, response string
valid bool
}{
{"ok", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 4\r\n\r\ndata", true},
{"short", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 5\r\n\r\ndata", false},
{"long", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 3\r\n\r\ndata", false},
{"no-close", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", false},
{"no-length", "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", false},
{"204", "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n", true},
{"204-length", "HTTP/1.1 204 No Content\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", false},
{"204-body", "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\nx", false},
{"chunked", "HTTP/1.1 200 OK\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", false},
} {
t.Run(tc.name, func(t *testing.T) {
_, _, err := readContractResponse(bufio.NewReader(strings.NewReader(tc.response)), false)
if (err == nil) != tc.valid {
t.Fatalf("valid=%v, error=%v", tc.valid, err)
}
})
}
}
type resetReader struct{}
func (resetReader) Read([]byte) (int, error) {
return 0, &net.OpError{Op: "read", Net: "tcp", Err: errors.New("connection reset")}
}
func TestContractResponseAfterEarlyRejection(t *testing.T) {
for _, tc := range []struct {
name, body string
requestHasBody bool
valid bool
}{
{"complete error then reset", "error", true, true},
{"reset without request body", "error", false, false},
{"truncated error", "err", true, false},
{"extra response byte", "error!", true, false},
} {
t.Run(tc.name, func(t *testing.T) {
wire := "HTTP/1.1 409 Conflict\r\nConnection: close\r\nContent-Length: 5\r\n\r\n" + tc.body
reader := bufio.NewReader(io.MultiReader(strings.NewReader(wire), resetReader{}))
_, _, err := readContractResponse(reader, tc.requestHasBody)
if (err == nil) != tc.valid {
t.Fatalf("valid=%v, error=%v", tc.valid, err)
}
})
}
}
func TestSnapshotDetectsErrorSideEffects(t *testing.T) {
for _, mutation := range []string{"overwrite", "create", "delete", "mkdir"} {
t.Run(mutation, func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "file")
if err := os.WriteFile(file, []byte("before"), 0644); err != nil {
t.Fatal(err)
}
before, err := snapshotTree(root)
if err != nil {
t.Fatal(err)
}
after, err := snapshotTree(root)
if err != nil {
t.Fatal(err)
}
if err := compareSnapshots(before, after); err != nil {
t.Fatal(err)
}
switch mutation {
case "overwrite":
err = os.WriteFile(file, []byte("after!"), 0644)
case "create":
err = os.WriteFile(filepath.Join(root, "new"), nil, 0644)
case "delete":
err = os.Remove(file)
case "mkdir":
err = os.Mkdir(filepath.Join(root, "new"), 0755)
}
if err != nil {
t.Fatal(err)
}
after, err = snapshotTree(root)
if err != nil {
t.Fatal(err)
}
if err := compareSnapshots(before, after); err == nil {
t.Fatal("side effect was accepted")
}
})
}
}
func TestMissingPathActionsAndRoot(t *testing.T) {
env := &Env{RootDir: &EnvDir{Listing: map[string]EnvNode{"file": &EnvFile{}, "dir": &EnvDir{Listing: map[string]EnvNode{}}}}}
opts := &RunOpts{ServerDomain: "localhost"}
for _, tc := range []struct {
method, path string
status int
}{
{"POST", "missing/new", 404}, {"POST", "file/new", 404},
{"POST", "dir", 409}, {"PUT", "missing", 404}, {"PUT", "dir", 409}, {"DELETE", "missing", 404},
{"POST", "/", 409}, {"PUT", "/", 409}, {"DELETE", "/", 403},
} {
q := Query{Method: tc.method, Path: tc.path, HostHeader: "LOCALHOST"}
action, ok := q.Action(env, opts).(HttpErrorAction)
if !ok || action.Status != tc.status {
t.Errorf("%s %s: got %#v", tc.method, tc.path, q.Action(env, opts))
}
}
q := Query{Method: "GET", Path: "", HostHeader: "LOCALHOST"}
if _, ok := q.Action(env, opts).(GetDirAction); !ok {
t.Fatal("root GET is not a listing")
}
for _, root := range []string{"", "/"} {
for _, remove := range []bool{false, true} {
q := Query{Method: "DELETE", Path: root, HostHeader: "localhost", RemoveDirectory: remove}
action, ok := q.Action(env, opts).(HttpErrorAction)
if !ok || action.Status != 403 {
t.Fatalf("root DELETE with RemoveDirectory=%v: got %#v", remove, action)
}
q.HostHeader = "wrong.example"
action, ok = q.Action(env, opts).(HttpErrorAction)
if !ok || action.Status != 400 {
t.Fatalf("Host must be checked before root DELETE: got %#v", action)
}
}
}
}
-128
View File
@@ -1,128 +0,0 @@
#!/bin/bash
set -u
set -o pipefail
fatal() {
printf 'harness: %s\n' "$*" >&2
exit 1
}
wait_for_docker() {
attempts=30
while [ "$attempts" -gt 0 ]; do
docker info >/dev/null 2>&1 && return 0
attempts=$((attempts - 1))
sleep 1
done
return 1
}
cleanup() {
docker ps -q --filter ancestor=hw3img | while IFS= read -r container_id; do
[ -n "$container_id" ] || continue
docker rm --force "$container_id" >/dev/null 2>&1 || true
done
}
run_tests() {
local arg
local -a original_args=("$@")
local -a test_args=(-docker -test.timeout=10m)
while (($#)); do
arg="$1"
shift
case "$arg" in
-run|-count|-timeout|-parallel|-skip)
if (($# == 0)); then
fatal "$arg requires a value"
fi
test_args+=("-test.${arg#-}" "$1")
shift
;;
-run=*|-count=*|-timeout=*|-parallel=*|-skip=*)
test_args+=("-test.${arg#-}")
;;
-v|-short|-failfast|-v=*|-short=*|-failfast=*)
test_args+=("-test.${arg#-}")
;;
-test.*|-docker)
test_args+=("$arg")
;;
*)
# Keep Go's flag handling for less common test and build options.
go test --docker -timeout 10m "${original_args[@]}"
return $?
;;
esac
done
/usr/local/bin/hw3test "${test_args[@]}"
}
validate_score_file() {
score_lines="$(grep -c '^SCORE:' "$1" || true)"
valid_score_lines="$(grep -Ec '^SCORE: [0-9]+([.][0-9]{1,2})?$' "$1" || true)"
[ "$score_lines" -eq 1 ] && [ "$valid_score_lines" -eq 1 ] \
|| fatal "test command must report exactly one valid SCORE line"
}
configure_registry_proxy() {
proxy_url="${DISTSYS_REGISTRY_PROXY_URL:-}"
ca_file="/run/distsys-registry-proxy/ca.crt"
[ -n "$proxy_url" ] || return 1
printf '%s' "$proxy_url" | grep -Eq '^http://[A-Za-z0-9.-]+(:[0-9]+)?$' || {
printf 'warning: invalid registry proxy URL; using direct Docker pulls\n' >&2
return 1
}
[ -f "$ca_file" ] && [ ! -L "$ca_file" ] || {
printf 'warning: registry proxy CA is unavailable; using direct Docker pulls\n' >&2
return 1
}
downloaded_ca="$(mktemp)" || return 1
if ! wget -q -T 5 -O "$downloaded_ca" "${proxy_url}/ca.crt" \
|| ! cmp -s "$ca_file" "$downloaded_ca"; then
rm -f "$downloaded_ca"
printf 'warning: registry proxy is unavailable or its CA differs; using direct Docker pulls\n' >&2
return 1
fi
rm -f "$downloaded_ca"
cp "$ca_file" /usr/local/share/ca-certificates/distsys-registry-proxy.crt
update-ca-certificates >/dev/null
proxy_no_proxy='localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16'
export HTTP_PROXY="$proxy_url" HTTPS_PROXY="$proxy_url" NO_PROXY="$proxy_no_proxy"
export http_proxy="$proxy_url" https_proxy="$proxy_url" no_proxy="$proxy_no_proxy"
}
configure_registry_proxy || unset HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy
if [ "${HW3_REQUIRE_ZRAM:-0}" = 1 ]; then
/usr/local/bin/verify-zram-scratch.sh || fatal "HW3_REQUIRE_ZRAM=1 but /hw/tests/tmp is not mounted from a zram ext4 device"
fi
# Start docker daemon
trap cleanup EXIT
dockerd-entrypoint.sh --storage-driver=overlay2 >/var/log/dockerd-entrypoint.log 2>&1 &
if ! wait_for_docker; then
tail -n 200 /var/log/dockerd-entrypoint.log >&2 || true
fatal "inner Docker daemon did not become ready"
fi
# Build server image
docker build solution -t hw3img || fatal "could not build solution image"
# Run tests
cd tests
export NO_COLOR=1
test_status=0
run_tests "$@" 2>&1 | tee tests.log || test_status=$?
validate_score_file tests.log
# Go reports a non-zero status when some grading groups fail. A valid score is
# the grading result; absence of a score remains an infrastructure failure.
if [ "$test_status" -ne 0 ]; then
printf 'harness: grading tests exited with status %s after reporting a score\n' \
"$test_status" >&2
fi
-233
View File
@@ -1,233 +0,0 @@
package hw3test
import (
"fmt"
"io"
"math/rand"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
// Env contains information about all files in the test environment.
// Can be written to disk.
type Env struct {
RootDir *EnvDir
}
// Lookup returns the node for the given path.
func (e *Env) Lookup(p string) (*EnvDir, EnvNode) {
p = path.Clean(strings.TrimPrefix(p, "/"))
if p == "." {
return nil, e.RootDir
}
dir, file := path.Split(p)
dirs := strings.Split(dir, "/")
dirs = dirs[:len(dirs)-1]
parent := e.RootDir
for _, d := range dirs {
nxt, ok := parent.Listing[d]
if !ok {
return nil, nil
}
parent, ok = nxt.(*EnvDir)
if !ok {
// not a directory
return nil, nil
}
}
nxt := parent.Listing[file]
return parent, nxt
}
// Clone returns deep clone of an Env.
func (e *Env) Clone() *Env {
return &Env{
RootDir: e.RootDir.Clone().(*EnvDir),
}
}
// EnvNode is a file/dir.
type EnvNode interface {
// WriteToDisk persists node and its children to disk.
WriteToDisk(path string) error
// Stats aggregates stats of the node and its children.
Stats(path string, stats *Stats)
// Clone creates a deep copy of the node.
Clone() EnvNode
}
// EnvDir is a virtual directory, that can be written to disk.
type EnvDir struct {
Listing map[string]EnvNode
Depth int
}
func (d *EnvDir) WriteToDisk(p string) error {
err := os.Mkdir(p, 0777)
if err != nil {
return err
}
for name, writter := range d.Listing {
nxt := path.Join(p, name)
err := writter.WriteToDisk(nxt)
if err != nil {
return err
}
}
return nil
}
// WriteToDiskSelected keeps the size and directory structure of every initial
// file, but only materializes contents for paths that a query may read.
func (d *EnvDir) WriteToDiskSelected(p string, needed map[string]bool) error {
return d.writeToDiskSelected(p, "", needed)
}
func (d *EnvDir) writeToDiskSelected(p, relative string, needed map[string]bool) error {
if err := os.Mkdir(p, 0777); err != nil {
return err
}
for name, node := range d.Listing {
childPath := filepath.Join(p, name)
childRelative := path.Join(relative, name)
switch child := node.(type) {
case *EnvDir:
if err := child.writeToDiskSelected(childPath, childRelative, needed); err != nil {
return err
}
case *EnvFile:
if needed[childRelative] {
if err := child.WriteToDisk(childPath); err != nil {
return err
}
} else if err := child.WriteSparseToDisk(childPath); err != nil {
return err
}
default:
return fmt.Errorf("unsupported environment node %T", child)
}
}
return nil
}
func (d *EnvDir) Stats(p string, stats *Stats) {
stats.Dirs++
stats.DirPaths = append(stats.DirPaths, p)
for name, node := range d.Listing {
node.Stats(path.Join(p, name), stats)
}
}
func (d *EnvDir) Clone() EnvNode {
newDir := &EnvDir{
Listing: map[string]EnvNode{},
Depth: d.Depth,
}
for name, node := range d.Listing {
newDir.Listing[name] = node.Clone()
}
return newDir
}
func (d *EnvDir) CreateDir(name string) (dir *EnvDir, exist bool) {
_, exist = d.Listing[name]
if exist {
return nil, exist
}
newDir := &EnvDir{
Listing: map[string]EnvNode{},
Depth: d.Depth + 1,
}
d.Listing[name] = newDir
return newDir, false
}
// EnvFile is a virtual file, that can be written to disk.
type EnvFile struct {
GenSeed int64
Size int64
TextOnly bool
}
func (f *EnvFile) Open() io.Reader {
var gen io.Reader = rand.New(rand.NewSource(f.GenSeed))
if f.TextOnly {
gen = &TextReader{gen}
}
gen = io.LimitReader(gen, f.Size)
return gen
}
func (f *EnvFile) WriteToDisk(p string) error {
file, err := os.OpenFile(p, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
return err
}
defer file.Close()
reader := f.Open()
_, err = io.CopyBuffer(file, reader, make([]byte, 256*1024))
return err
}
func (f *EnvFile) WriteSparseToDisk(p string) error {
file, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
if err != nil {
return err
}
defer file.Close()
return file.Truncate(f.Size)
}
func (f *EnvFile) Stats(p string, stats *Stats) {
stats.Files++
stats.Size += f.Size
stats.FilePaths = append(stats.FilePaths, p)
}
func (f *EnvFile) Clone() EnvNode {
return &EnvFile{
GenSeed: f.GenSeed,
Size: f.Size,
TextOnly: f.TextOnly,
}
}
// Stats is a helper for listing all files/dirs in environment.
type Stats struct {
// Number of directories in the environment.
Files int
// Count of directories in the environment.
Dirs int
// Size of all files in the environment.
Size int64
// FilePaths to all files in the environment.
FilePaths []string
// DirPaths to all directories in the environment.
DirPaths []string
}
// Normalize will fix stats to be deterministic and don't contain root dir.
func (s *Stats) Normalize() {
sort.Strings(s.FilePaths)
sort.Strings(s.DirPaths)
if len(s.DirPaths) > 0 && s.DirPaths[0] == "" {
s.DirPaths = s.DirPaths[1:]
}
}
-73
View File
@@ -1,73 +0,0 @@
package hw3test
import "math/rand"
// EnvGen contains config for generating test environment.
// Test environment is a directory with files and subdirectories.
type EnvGen struct {
// Generated file tree will not contain subdirectories deeper than this depth.
MaxDepth int
// Generated file tree will not contain more than this number of subdirs.
MaxDirs int
// Generated file tree will not contain more than this number of files.
MaxFiles int
// Generated files will be text files.
TextOnly bool
// Maximum file size in KB
MaxFileSizeKB int
// TempDirectory will contain subdirectory for every new environment.
TempDirectory string
// SparseUnusedFiles avoids writing initial file contents that no query can read.
SparseUnusedFiles bool
// Allow to use env for configuration.
AllowEnv bool
// Filename generator.
FilenameGen func(r *rand.Rand) string
}
// GenerateFile returns file with random contents.
func (g *EnvGen) GenerateFile(r *rand.Rand) *EnvFile {
return &EnvFile{
GenSeed: r.Int63(),
Size: r.Int63n(1024 * int64(g.MaxFileSizeKB)),
TextOnly: g.TextOnly,
}
}
// Generate generates whole file tree.
func (g *EnvGen) Generate(seed int64) (*Env, error) {
r := rand.New(rand.NewSource(seed))
root := &EnvDir{Listing: map[string]EnvNode{}}
dirs := []*EnvDir{root}
for i := 0; i < g.MaxDirs; i++ {
parent := dirs[r.Intn(len(dirs))]
if parent.Depth >= g.MaxDepth {
continue
}
name := g.FilenameGen(r)
newDir, exist := parent.CreateDir(name)
if exist {
continue
}
dirs = append(dirs, newDir)
}
for i := 0; i < g.MaxFiles; i++ {
parent := dirs[r.Intn(len(dirs))]
name := g.FilenameGen(r)
_, exist := parent.Listing[name]
if exist {
continue
}
parent.Listing[name] = g.GenerateFile(r)
}
return &Env{
RootDir: root,
}, nil
}
@@ -1,44 +0,0 @@
package hw3test
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
)
func TestWriteToDiskSelected(t *testing.T) {
selected := &EnvFile{GenSeed: 42, Size: 4096}
unused := &EnvFile{GenSeed: 43, Size: 4096}
root := &EnvDir{Listing: map[string]EnvNode{
"nested": &EnvDir{Listing: map[string]EnvNode{
"selected": selected,
"unused": unused,
}},
}}
location := filepath.Join(t.TempDir(), "environment")
if err := root.WriteToDiskSelected(location, map[string]bool{"nested/selected": true}); err != nil {
t.Fatal(err)
}
gotSelected, err := os.ReadFile(filepath.Join(location, "nested", "selected"))
if err != nil {
t.Fatal(err)
}
wantSelected, err := io.ReadAll(selected.Open())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(gotSelected, wantSelected) {
t.Fatal("selected file contents differ from generated contents")
}
gotUnused, err := os.ReadFile(filepath.Join(location, "nested", "unused"))
if err != nil {
t.Fatal(err)
}
if len(gotUnused) != int(unused.Size) || !bytes.Equal(gotUnused, make([]byte, unused.Size)) {
t.Fatal("unused file must retain its size without generated contents")
}
}
-49
View File
@@ -1,49 +0,0 @@
package hw3test
import (
"fmt"
"net"
"net/http"
"strings"
"time"
)
// validateResponseFraming checks headers before reading the response body.
func validateResponseFraming(resp *http.Response) error {
// net/http removes Connection: close from Header and records it in Close.
// With HTTP/1.1 and explicit framing, Close reflects that header.
closeFound := resp.ProtoMajor == 1 && resp.ProtoMinor == 1 && resp.Close
for _, value := range resp.Header.Values("Connection") {
for _, token := range strings.Split(value, ",") {
closeFound = closeFound || strings.EqualFold(strings.TrimSpace(token), "close")
}
}
if !closeFound {
return fmt.Errorf("expected Connection: close")
}
if len(resp.TransferEncoding) != 0 || len(resp.Header.Values("Transfer-Encoding")) != 0 {
return fmt.Errorf("Transfer-Encoding is not supported")
}
lengths := resp.Header.Values("Content-Length")
if resp.StatusCode == http.StatusNoContent {
if len(lengths) != 0 {
return fmt.Errorf("204 must not contain Content-Length")
}
} else if len(lengths) != 1 || resp.ContentLength < 0 {
return fmt.Errorf("expected exactly one valid Content-Length")
}
return nil
}
// Bound a stalled peer, without imposing a total duration on large transfers.
type idleDeadlineReader struct {
net.Conn
Timeout time.Duration
}
func (r *idleDeadlineReader) Read(p []byte) (int, error) {
if err := r.Conn.SetReadDeadline(time.Now().Add(r.Timeout)); err != nil {
return 0, err
}
return r.Conn.Read(p)
}
@@ -1,49 +0,0 @@
package hw3test
import (
"io"
"math/rand"
)
func SimpleFilenameGenerator(maxLen int) func(r *rand.Rand) string {
return func(r *rand.Rand) string {
return GenFilename(r, maxLen)
}
}
func GenFilename(r *rand.Rand, maxLen int) string {
// TODO: better filename generation, right now it's only uppercase english letters
n := r.Intn(maxLen) + 1
b := make([]byte, n)
for i := 0; i < n; i++ {
b[i] = byte(r.Intn(26) + 65)
}
return string(b)
}
// TextReader is io.Reader filtering out non-text characters.
type TextReader struct {
r io.Reader
}
func (t *TextReader) Read(p []byte) (n int, err error) {
for {
n, err = t.r.Read(p)
if n == 0 || err != nil {
return
}
m := 0
for i := 0; i < n; i++ {
// p[i] is a plain text character
if p[i] == '\n' || (p[i] >= 32 && p[i] <= 126) {
p[m] = p[i]
m++
}
}
if m == 0 {
continue
}
return m, nil
}
}
-16
View File
@@ -1,16 +0,0 @@
module hw3test
go 1.18
require (
github.com/stretchr/testify v1.8.0
go.uber.org/zap v1.23.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-28
View File
@@ -1,28 +0,0 @@
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-309
View File
@@ -1,309 +0,0 @@
package hw3test
import (
"flag"
"fmt"
"os"
"path/filepath"
"testing"
"text/template"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
var (
useDocker bool
)
func init() {
flag.BoolVar(&useDocker, "docker", false, "Run solution in Docker")
}
func TestHW(tt *testing.T) {
if !flag.Parsed() {
flag.Parse()
}
t := NewTestContext(tt)
Info(t, "Starting tests")
// loading commandline args template from disk, to run solution with it
launchTmpl := os.Getenv("LAUNCH_TMPL")
if launchTmpl == "" {
launchTmpl = "./launch.tmpl"
if useDocker {
launchTmpl = "./launch_docker.tmpl"
}
}
tmplContent, err := os.ReadFile(launchTmpl)
require.NoError(t, err, "failed to read template file")
launchTemplate := template.Must(template.New("launch").Parse(string(tmplContent)))
runner := NewCmdRunner(launchTemplate, useDocker)
workdir, err := os.Getwd()
require.NoError(t, err, "failed to get current directory")
tmpRoot := filepath.Join(workdir, "tmp")
require.NoError(t, os.MkdirAll(tmpRoot, 0777), "failed to create tmp dir")
workspace, err := filepath.EvalSymlinks(workdir)
require.NoError(t, err, "failed to resolve test directory")
tmpRoot, err = filepath.EvalSymlinks(tmpRoot)
require.NoError(t, err, "failed to resolve tmp dir")
rel, err := filepath.Rel(workspace, tmpRoot)
require.NoError(t, err, "failed to verify tmp dir")
require.Equal(t, "tmp", rel, "tmp dir must stay inside the test directory")
tmpDir, err := os.MkdirTemp(tmpRoot, "run-")
require.NoError(t, err, "failed to create run-specific tmp dir")
require.NoError(t, os.Chmod(tmpDir, 0755), "failed to make tmp dir accessible to the server")
t.Cleanup(func() {
resolved, err := filepath.EvalSymlinks(tmpDir)
if os.IsNotExist(err) {
return
}
require.NoError(t, err, "failed to resolve run-specific tmp dir")
rel, err := filepath.Rel(tmpRoot, resolved)
require.NoError(t, err, "failed to verify run-specific tmp dir")
require.Equal(t, filepath.Base(tmpDir), rel, "run-specific tmp dir must stay inside tmp dir")
require.NoError(t, os.RemoveAll(tmpDir), "failed to remove run-specific tmp dir")
})
score := 0
type RunResult struct {
Name string
Scored int
Max int
}
runResults := []RunResult{}
runGroup := func(name string, points int, f func(t *TC)) {
t.RunByName(name, func(t *TC) {
Info(t, "Starting tests group", zap.Int("points", points))
t.Cleanup(func() {
r := RunResult{
Name: name,
Max: points,
}
ok := !t.Failed()
if ok {
score += points
r.Scored = points
Info(t, "Tests group passed", zap.String("name", name), zap.Int("score", score))
} else {
Warn(t, "Tests group failed", zap.String("name", name), zap.Int("score", score))
}
runResults = append(runResults, r)
})
f(t)
})
}
textEnv := &EnvGen{
MaxDepth: 1,
MaxDirs: 3,
MaxFiles: 5,
TextOnly: true,
MaxFileSizeKB: 64,
TempDirectory: tmpDir,
FilenameGen: SimpleFilenameGenerator(8),
AllowEnv: false,
}
binaryEnv := &EnvGen{
MaxDepth: 4,
MaxDirs: 16,
MaxFiles: 25,
TextOnly: false,
MaxFileSizeKB: 1024,
TempDirectory: tmpDir,
FilenameGen: SimpleFilenameGenerator(16),
AllowEnv: true,
}
largeEnv := &EnvGen{
MaxDepth: 1,
MaxDirs: 6,
MaxFiles: 6,
TextOnly: false,
MaxFileSizeKB: 192 * 1024, // 192 MB
TempDirectory: tmpDir,
SparseUnusedFiles: true,
FilenameGen: SimpleFilenameGenerator(16),
AllowEnv: true,
}
// Simple GET queries for existing text files, 3 points.
runGroup("G1", 3, func(t *TC) {
RunContractTests(t, runner, tmpDir, "G1")
RunTestEmptyWorkDir(t, 42, runner)
env := textEnv
queries := &QueriesGen{
Count: 20,
GetFile: true,
GetFileNoErrors: true,
GetDirectory: false,
Post: false,
Put: false,
Delete: false,
Compression: false,
AllHeaders: false,
}
RunTests(t, 1337, runner, env, queries)
RunTests(t, 1338, runner, env, queries)
RunTests(t, 1339, runner, env, queries)
})
// Simple GET queries for existing binary files, 1 point.
runGroup("G2", 1, func(t *TC) {
RunContractTests(t, runner, tmpDir, "G2")
env := binaryEnv
queries := &QueriesGen{
Count: 40,
GetFile: true,
GetFileNoErrors: true,
GetDirectory: false,
Post: false,
Put: false,
Delete: false,
Compression: false,
AllHeaders: false,
}
RunTests(t, 93, runner, env, queries)
RunTests(t, 2945, runner, env, queries)
RunTests(t, 3110, runner, env, queries)
})
// Any GET queries, 1 point.
runGroup("G3", 1, func(t *TC) {
RunContractTests(t, runner, tmpDir, "G3")
env := textEnv
queries := &QueriesGen{
Count: 30,
GetFile: true,
GetFileNoErrors: false,
GetDirectory: true,
Post: false,
Put: false,
Delete: false,
Compression: false,
AllHeaders: false,
}
RunTests(t, 5311, runner, env, queries)
RunTests(t, 2863, runner, env, queries)
RunTests(t, 6712, runner, env, queries)
RunTests(t, 7233, runner, env, queries)
RunTests(t, 7067, runner, env, queries)
RunTests(t, 3930, runner, env, queries)
})
// Simple file server, 2 points.
runGroup("G4", 2, func(t *TC) {
RunContractTests(t, runner, tmpDir, "G4")
env := binaryEnv
queries := &QueriesGen{
Count: 30,
GetFile: true,
GetDirectory: true,
Post: true,
Put: true,
Delete: true,
Compression: false,
AllHeaders: false,
}
RunTests(t, 3152, runner, env, queries)
RunTests(t, 2929, runner, env, queries)
RunTests(t, 6554, runner, env, queries)
RunTests(t, 1388, runner, env, queries)
RunTests(t, 1672, runner, env, queries)
RunTests(t, 1769, runner, env, queries)
})
// Extra headers, 1 point.
runGroup("G5", 1, func(t *TC) {
RunContractTests(t, runner, tmpDir, "G5")
env := binaryEnv
queries := &QueriesGen{
Count: 30,
GetFile: true,
GetDirectory: true,
Post: true,
Put: true,
Delete: true,
Compression: false,
AllHeaders: true,
}
RunTests(t, 7942, runner, env, queries)
RunTests(t, 1479, runner, env, queries)
RunTests(t, 3324, runner, env, queries)
RunTests(t, 6519, runner, env, queries)
RunTests(t, 3746, runner, env, queries)
RunTests(t, 1961, runner, env, queries)
})
// Large files, 1 point.
runGroup("G6", 1, func(t *TC) {
RunContractTests(t, runner, tmpDir, "G6")
env := largeEnv
queries := &QueriesGen{
Count: 25,
GetFile: true,
GetDirectory: true,
LargePuts: &LargePutPolicy{
RunSeed: 3793,
LargeSizes: []int64{160 << 20, 191 << 20},
OtherMaxBytes: 8 << 20,
},
Post: true,
Put: true,
Delete: true,
Compression: false,
AllHeaders: false,
}
RunTests(t, 7824, runner, env, queries)
RunTests(t, 1671, runner, env, queries)
RunTests(t, 3793, runner, env, queries)
RunTests(t, 272, runner, env, queries)
RunTests(t, 2715, runner, env, queries)
RunTests(t, 1436, runner, env, queries)
})
// Large files and gzip, 1 point.
runGroup("G7", 1, func(t *TC) {
RunContractTests(t, runner, tmpDir, "G7")
env := largeEnv
queries := &QueriesGen{
Count: 30,
GetFile: true,
GetDirectory: true,
LargePuts: &LargePutPolicy{
RunSeed: 3224,
LargeSizes: []int64{160 << 20, 191 << 20},
OtherMaxBytes: 8 << 20,
},
Post: true,
Put: true,
Delete: true,
Compression: true,
AllHeaders: true,
}
RunTests(t, 3224, runner, env, queries)
RunTests(t, 7507, runner, env, queries)
RunTests(t, 4172, runner, env, queries)
RunTests(t, 7777, runner, env, queries)
RunTests(t, 6666, runner, env, queries)
RunTests(t, 6094, runner, env, queries)
RunTests(t, 6442, runner, env, queries)
})
for _, r := range runResults {
Info(t, fmt.Sprintf("Score for group [%s]: %d / %d", r.Name, r.Scored, r.Max))
}
Info(t, "Tests finished", zap.Int("score", score))
fmt.Println("==================================================================================")
fmt.Printf("SCORE: %d\n", score)
fmt.Println("==================================================================================")
}
@@ -1,119 +0,0 @@
package hw3test
import (
"math/rand"
"reflect"
"testing"
)
func TestLargePutPlansKeepBoundaryWritesAndQueries(t *testing.T) {
tests := []struct {
name string
runSeeds []int64
count int
largeSeed int64
allHeaders bool
compression bool
}{
{"G6", []int64{7824, 1671, 3793, 272, 2715, 1436}, 25, 3793, false, false},
{"G7", []int64{3224, 7507, 4172, 7777, 6666, 6094, 6442}, 30, 3224, true, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
context := NewTestContext(t)
envGen := &EnvGen{
MaxDepth: 1,
MaxDirs: 6,
MaxFiles: 6,
MaxFileSizeKB: 192 * 1024,
FilenameGen: SimpleFilenameGenerator(16),
}
policy := &LargePutPolicy{
RunSeed: test.largeSeed,
LargeSizes: []int64{160 << 20, 191 << 20},
OtherMaxBytes: 8 << 20,
}
options := RunOpts{ServerDomain: "localhost"}
largePosts := 0
largeGets := 0
largeGzipGets := 0
for _, runSeed := range test.runSeeds {
env, err := envGen.Generate(runSeed)
if err != nil {
t.Fatal(err)
}
r := rand.New(rand.NewSource(runSeed))
seeds := make([]int64, test.count)
for i := range seeds {
seeds[i] = r.Int63n(100000)
}
generator := &QueriesGen{
Count: test.count, GetFile: true, GetDirectory: true,
Post: true, Put: true, Delete: true,
Compression: test.compression, AllHeaders: test.allHeaders,
LargePuts: policy,
}
queries := generator.Generate(context, env, envGen, seeds, options, runSeed)
generator.LargePuts = nil
baseline := generator.Generate(context, env, envGen, seeds, options, runSeed)
if len(queries) != len(baseline) {
t.Fatalf("run %d changed query count", runSeed)
}
state := env.Clone()
var largePutSizes []int64
for i, query := range queries {
if query.Method == "PUT" {
baseline[i].FileContent.Size = query.FileContent.Size
}
if !reflect.DeepEqual(query, baseline[i]) {
t.Fatalf("run %d changed query %d beyond PUT body size", runSeed, i)
}
action := query.Action(state, &options)
if query.Path == "" && query.Method != "GET" {
t.Fatal("generated a modifying request to the working directory")
}
if get, ok := action.(GetFileAction); ok && get.File.Size > 128<<20 {
largeGets++
if get.Compression {
largeGzipGets++
}
}
if query.Method == "PUT" {
if query.FileContent.Size > 128<<20 {
if _, ok := action.(ReplaceFileAction); !ok {
t.Fatalf("run %d has a large PUT that cannot update a file", runSeed)
}
largePutSizes = append(largePutSizes, query.FileContent.Size)
} else if query.FileContent.Size > policy.OtherMaxBytes {
t.Fatalf("run %d has an unbounded PUT", runSeed)
}
}
if query.Method == "POST" && query.FileContent != nil && query.FileContent.Size > 128<<20 {
if _, ok := action.(CreateFileAction); ok {
largePosts++
}
}
if action != nil {
action.ApplyEnv(context, state)
}
}
if runSeed == test.largeSeed {
if !reflect.DeepEqual(largePutSizes, policy.LargeSizes) {
t.Fatalf("run %d large PUT sizes: got %v, want %v", runSeed, largePutSizes, policy.LargeSizes)
}
} else if len(largePutSizes) != 0 {
t.Fatalf("run %d has unexpected large PUTs", runSeed)
}
}
if largePosts == 0 {
t.Fatal("no successful POST larger than the solution memory limit")
}
if largeGets == 0 {
t.Fatal("no successful GET larger than the solution memory limit")
}
if test.compression && largeGzipGets == 0 {
t.Fatal("no gzip GET larger than the solution memory limit")
}
})
}
}
@@ -1,2 +0,0 @@
{{- /*gotype: hw3test.RunOpts */ -}}
python3 ../solution/server.py {{.CommandLineArgs}}
@@ -1,2 +0,0 @@
{{- /*gotype: hw3test.RunOpts */ -}}
docker run --memory=128m --memory-swap=128m --memory-swappiness=0 --rm {{.DockerVolumeArgs}} {{.DockerPortArgs}} {{.DockerEnvArgs}} hw3img {{.DockerCommandLineArgs}}
-94
View File
@@ -1,94 +0,0 @@
package hw3test
import (
"fmt"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"os"
"sync"
)
func logLevelFromEnv() zap.AtomicLevel {
levelStr := os.Getenv("LOG_LEVEL")
if levelStr == "" {
return zap.NewAtomicLevelAt(zap.DebugLevel)
}
level, err := zap.ParseAtomicLevel(levelStr)
if err != nil {
panic(fmt.Errorf("failed to parse log level: %w", err))
}
return level
}
var global *zap.Logger
var globalOnce sync.Once
func Global() *zap.Logger {
if global == nil {
globalOnce.Do(func() {
encoder := zapcore.EncoderConfig{
// Keys can be anything except the empty string.
TimeKey: "",
LevelKey: "L",
NameKey: "_",
CallerKey: "C",
FunctionKey: "F",
MessageKey: "M",
StacktraceKey: "S",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.CapitalColorLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
cfg := zap.Config{
Level: logLevelFromEnv(),
Development: true,
DisableCaller: true,
DisableStacktrace: true,
Encoding: "console",
EncoderConfig: encoder,
OutputPaths: []string{"stderr"},
ErrorOutputPaths: []string{"stderr"},
}
options := []zap.Option{
zap.AddCallerSkip(1),
}
var err error
global, err = cfg.Build(options...)
if err != nil {
panic(fmt.Errorf("failed to initialize global logger: %w", err))
}
})
}
return global
}
func Logger(t *TC) *zap.Logger {
logger := Global()
logger = logger.Named(t.Name())
return logger
}
func Debug(t *TC, msg string, args ...zap.Field) {
Logger(t).Debug(msg, args...)
_ = Logger(t).Sync()
}
func Info(t *TC, msg string, args ...zap.Field) {
Logger(t).Info(msg, args...)
_ = Logger(t).Sync()
}
func Warn(t *TC, msg string, args ...zap.Field) {
Logger(t).Warn(msg, args...)
_ = Logger(t).Sync()
}
func Error(t *TC, msg string, args ...zap.Field) {
Logger(t).Error(msg, args...)
_ = Logger(t).Sync()
}
-424
View File
@@ -1,424 +0,0 @@
package hw3test
import (
"compress/gzip"
"fmt"
"github.com/stretchr/testify/require"
"io"
"mime"
"net/http"
"os"
"path"
"strings"
)
func parseContentType(value string) (string, error) {
mediaType, _, err := mime.ParseMediaType(value)
if err != nil {
return "", err
}
if !strings.Contains(mediaType, "/") {
return "", fmt.Errorf("content type %q has no subtype", value)
}
return mediaType, nil
}
// Query represents a query to the solution server.
type Query struct {
// Seed is used as a query ID.
Seed int64
// Method of a request, e.g. "GET", "POST", "PUT", "DELETE".
Method string
// Simple file path, splitted by slashes. E.g. "foo/bar/baz".
Path string
// If true, will use `Accept-Encoding` for GET query.
Gzip bool
// If true, will pass `Create-Directory: True` in POST query.
CreateDirectory bool
// If true, will pass `Remove-Directory: True` in DELETE query.
RemoveDirectory bool
// Pass the Host header.
HostHeader string
// Verify all server headers: `Content-Length`, `Content-Type`, `Server`.
VerifyHeaders bool
// FileContent when creating a file.
FileContent *EnvFile
}
func (q *Query) CreateRequest(t *TC, queryURL string) *http.Request {
var body io.Reader
if q.FileContent != nil {
body = q.FileContent.Open()
if q.FileContent.Size == 0 {
body = http.NoBody
}
}
req, err := http.NewRequest(q.Method, queryURL, body)
require.NoError(t, err, "failed to create go request")
if q.FileContent != nil {
req.ContentLength = q.FileContent.Size
}
if q.CreateDirectory {
req.Header.Set("Create-Directory", "True")
}
if q.RemoveDirectory {
req.Header.Set("Remove-Directory", "True")
}
if q.HostHeader != "" {
req.Host = q.HostHeader
}
if q.Gzip {
req.Header.Set("Accept-Encoding", "gzip")
}
return req
}
func (q *Query) CommonValidate(t *TC, r *http.Request, resp *http.Response) {
require.NoError(t, validateResponseFraming(resp))
if q.VerifyHeaders {
require.NotEmpty(t, resp.Header.Get("Server"), "missing Server header")
}
}
type Action interface {
// VerifyBefore verifies file content on disk before sending query to the server.
VerifyBefore(t *TC, workdir string)
// VerifyAfter verifies file content on disk after sending query to the server.
VerifyAfter(t *TC, workdir string)
// ApplyEnv applies the action to the environment.
ApplyEnv(t *TC, env *Env) (changed bool)
// VerifyResponse verifies the response from the server.
VerifyResponse(t *TC, r *http.Request, resp *http.Response)
}
// HttpErrorAction is an Action, which expect specific status code in response.
type HttpErrorAction struct {
Status int
}
func (h HttpErrorAction) VerifyBefore(t *TC, workdir string) {
return
}
func (h HttpErrorAction) VerifyAfter(t *TC, workdir string) {
return
}
func (h HttpErrorAction) ApplyEnv(t *TC, env *Env) bool {
return false
}
func (h HttpErrorAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
require.Equal(t, h.Status, resp.StatusCode, "expected http status code")
require.Positive(t, resp.ContentLength, "error response must contain an explanation")
}
// GetFileAction is an Action, which returns file content in response.
type GetFileAction struct {
Path string
File *EnvFile
Compression bool
VerifyHeaders bool
}
func (g GetFileAction) VerifyBefore(t *TC, workdir string) {
RequireFileContent(t, workdir, g.Path, g.File)
}
func (g GetFileAction) VerifyAfter(t *TC, workdir string) {
RequireFileContent(t, workdir, g.Path, g.File)
}
func (g GetFileAction) ApplyEnv(t *TC, env *Env) bool {
return false
}
func (g GetFileAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
require.Equal(t, 200, resp.StatusCode, "expected OK")
var body io.Reader = resp.Body
if g.Compression {
require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"), "expected gzip response")
gz, err := gzip.NewReader(resp.Body)
require.NoError(t, err, "expected valid gzip response")
defer gz.Close()
body = gz
} else {
require.Empty(t, resp.Header.Get("Content-Encoding"), "unexpected content encoding")
require.Equal(t, g.File.Size, resp.ContentLength, "expected content length")
}
if g.VerifyHeaders && g.File.Size > 0 {
_, err := parseContentType(resp.Header.Get("Content-Type"))
require.NoError(t, err, "expected valid content type")
}
require.NoError(t, CompareFileContent(t, body, g.File), "file content mismatch")
remaining, err := io.Copy(io.Discard, body)
require.NoError(t, err, "failed to read remaining response body")
require.Zero(t, remaining, "response contains extra file content")
}
// GetDirAction is an Action, which returns directory listing in response.
type GetDirAction struct {
Path string
Dir *EnvDir
Compression bool
VerifyHeaders bool
}
func (g GetDirAction) VerifyBefore(t *TC, workdir string) {
RequireDir(t, workdir, g.Path, g.Dir)
}
func (g GetDirAction) VerifyAfter(t *TC, workdir string) {
RequireDir(t, workdir, g.Path, g.Dir)
}
func (g GetDirAction) ApplyEnv(t *TC, env *Env) bool {
return false
}
func (g GetDirAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
require.Equal(t, 200, resp.StatusCode, "expected OK")
var body io.Reader = resp.Body
if g.Compression {
require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"), "expected gzip response")
gz, err := gzip.NewReader(resp.Body)
require.NoError(t, err, "expected valid gzip response")
defer gz.Close()
body = gz
} else {
require.Empty(t, resp.Header.Get("Content-Encoding"), "unexpected content encoding")
}
content, err := io.ReadAll(body)
require.NoError(t, err, "expected response to be read without errors")
if g.VerifyHeaders && len(content) > 0 {
mediaType, err := parseContentType(resp.Header.Get("Content-Type"))
require.NoError(t, err, "expected valid content type")
require.Contains(t, []string{"text/plain", "text/html"}, mediaType, "expected directory listing content type")
}
strlist := string(content)
for name := range g.Dir.Listing {
require.Contains(t, strlist, name, "expected dir listing to contain child %s", name)
}
}
// CreateDirAction is an Action, which creates a directory.
type CreateDirAction struct {
Path string
Name string
Parent *EnvDir
PathOnDisk string
}
func (c CreateDirAction) VerifyBefore(t *TC, workdir string) {
RequireNotExists(t, workdir, c.Path)
}
func (c CreateDirAction) VerifyAfter(t *TC, workdir string) {
RequireDir(t, workdir, c.Path, c.Parent)
entries, err := os.ReadDir(c.PathOnDisk)
require.NoError(t, err, "expected to read dir %s without errors", c.PathOnDisk)
require.Empty(t, entries, "expected dir %s to be empty", c.PathOnDisk)
}
func (c CreateDirAction) ApplyEnv(t *TC, env *Env) bool {
_, exist := c.Parent.CreateDir(c.Name)
require.False(t, exist)
return true
}
func (c CreateDirAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, resp.StatusCode, "expected successful creation")
}
// CreateFileAction is an Action, which creates a file.
type CreateFileAction struct {
Path string
Name string
Parent *EnvDir
Content *EnvFile
}
func (c CreateFileAction) VerifyBefore(t *TC, workdir string) {
RequireNotExists(t, workdir, c.Path)
}
func (c CreateFileAction) VerifyAfter(t *TC, workdir string) {
RequireFileContent(t, workdir, c.Path, c.Content)
}
func (c CreateFileAction) ApplyEnv(t *TC, env *Env) bool {
c.Parent.Listing[c.Name] = c.Content
return true
}
func (c CreateFileAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, resp.StatusCode, "expected successful creation")
}
// ReplaceFileAction is an Action, which replaces file content.
type ReplaceFileAction struct {
Path string
Name string
Parent *EnvDir
OldContent *EnvFile
NewContent *EnvFile
}
func (r ReplaceFileAction) VerifyBefore(t *TC, workdir string) {
RequireFileContent(t, workdir, r.Path, r.OldContent)
}
func (r ReplaceFileAction) VerifyAfter(t *TC, workdir string) {
RequireFileContent(t, workdir, r.Path, r.NewContent)
}
func (r ReplaceFileAction) ApplyEnv(t *TC, env *Env) bool {
r.Parent.Listing[r.Name] = r.NewContent
return true
}
func (r ReplaceFileAction) VerifyResponse(t *TC, req *http.Request, resp *http.Response) {
require.Contains(t, []int{http.StatusOK, http.StatusNoContent}, resp.StatusCode, "expected successful update")
}
// DeleteAction is an Action, which deletes a file or directory.
type DeleteAction struct {
Path string
Parent *EnvDir
Name string
}
func (d DeleteAction) VerifyBefore(t *TC, workdir string) {
RequireExists(t, workdir, d.Path)
}
func (d DeleteAction) VerifyAfter(t *TC, workdir string) {
RequireNotExists(t, workdir, d.Path)
}
func (d DeleteAction) ApplyEnv(t *TC, env *Env) bool {
delete(d.Parent.Listing, d.Name)
return true
}
func (d DeleteAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
require.Equal(t, 200, resp.StatusCode, "expected OK")
}
// Action returns action for the query, or nil if query shouldn't do anything.
func (q *Query) Action(env *Env, opts *RunOpts) Action {
if !strings.EqualFold(q.HostHeader, opts.ServerDomain) {
return HttpErrorAction{
Status: 400,
}
}
parent, child := env.Lookup(q.Path)
switch q.Method {
case "GET":
if child == nil {
return HttpErrorAction{
Status: 404,
}
}
if f, ok := child.(*EnvFile); ok {
return GetFileAction{
Path: q.Path,
File: f,
Compression: q.Gzip,
VerifyHeaders: q.VerifyHeaders,
}
}
if d, ok := child.(*EnvDir); ok {
return GetDirAction{
Path: q.Path,
Dir: d,
Compression: q.Gzip,
VerifyHeaders: q.VerifyHeaders,
}
}
// shouldn't get here
return nil
case "POST":
if child != nil {
return HttpErrorAction{
Status: 409,
}
}
if parent == nil {
return HttpErrorAction{Status: http.StatusNotFound}
}
_, name := path.Split(q.Path)
if q.CreateDirectory {
return CreateDirAction{
Path: q.Path,
Name: name,
Parent: parent,
PathOnDisk: path.Join(opts.WorkingDirectory, q.Path),
}
}
return CreateFileAction{
Path: q.Path,
Name: name,
Parent: parent,
Content: q.FileContent,
}
case "PUT":
if child == nil {
return HttpErrorAction{Status: http.StatusNotFound}
}
f, ok := child.(*EnvFile)
if !ok {
return HttpErrorAction{
Status: 409,
}
}
_, name := path.Split(q.Path)
return ReplaceFileAction{
Path: q.Path,
Name: name,
Parent: parent,
OldContent: f,
NewContent: q.FileContent,
}
case "DELETE":
if child == env.RootDir {
return HttpErrorAction{Status: http.StatusForbidden}
}
if child == nil {
return HttpErrorAction{Status: http.StatusNotFound}
}
_, ok := child.(*EnvDir)
if ok && !q.RemoveDirectory {
return HttpErrorAction{
Status: 406,
}
}
_, name := path.Split(q.Path)
return DeleteAction{
Path: q.Path,
Parent: parent,
Name: name,
}
}
return nil
}
-146
View File
@@ -1,146 +0,0 @@
package hw3test
import (
"math/rand"
"strings"
"github.com/stretchr/testify/require"
)
// LargePutPolicy keeps a small number of guaranteed successful writes above
// the solution's memory limit, while bounding the remaining PUT bodies.
type LargePutPolicy struct {
RunSeed int64
LargeSizes []int64
OtherMaxBytes int64
}
// QueriesGen contains config for generating queries.
type QueriesGen struct {
// Number of queries to generate.
Count int
GetFile bool // Allow GET requests for files.
GetFileNoErrors bool // Disable requests for non-existing files.
GetDirectory bool // Allow GET requests for directories.
Compression bool // Allow compression in GET requests.
Post bool // Allow POST requests.
Put bool // Allow PUT requests.
Delete bool // Allow DELETE requests.
AllHeaders bool // Allow extra headers.
LargePuts *LargePutPolicy
}
func (g *QueriesGen) Generate(t *TC, env *Env, gen *EnvGen, seeds []int64, opts RunOpts, runSeed int64) []Query {
env = env.Clone()
largePuts := 0
stats := &Stats{}
env.RootDir.Stats("", stats)
stats.Normalize()
var methods []string
if g.GetFile || g.GetDirectory {
methods = append(methods, "GET")
}
if g.Post {
methods = append(methods, "POST")
}
if g.Put {
methods = append(methods, "PUT")
}
if g.Delete {
methods = append(methods, "DELETE")
}
var queries []Query
for _, seed := range seeds {
r := rand.New(rand.NewSource(seed))
method := methods[r.Intn(len(methods))]
var genPaths []string
if len(stats.DirPaths) > 0 && !(method == "GET" && !g.GetDirectory) {
genPaths = append(genPaths, stats.DirPaths[r.Intn(len(stats.DirPaths))])
genPaths = append(genPaths, stats.DirPaths[r.Intn(len(stats.DirPaths))])
}
if len(stats.FilePaths) > 0 && !(method == "GET" && !g.GetFile) {
genPaths = append(genPaths, stats.FilePaths[r.Intn(len(stats.FilePaths))])
genPaths = append(genPaths, stats.FilePaths[r.Intn(len(stats.FilePaths))])
}
if !(method == "GET" && g.GetFileNoErrors) {
// TODO: better non-existing path generator
randomPath := gen.FilenameGen(r) + "/" + gen.FilenameGen(r)
genPaths = append(genPaths, randomPath)
if len(stats.DirPaths) > 0 {
randomDir := stats.DirPaths[r.Intn(len(stats.DirPaths))]
num := 2
if method == "POST" {
num = 4
}
for i := 0; i < num; i++ {
genPaths = append(genPaths, randomDir+"/"+gen.FilenameGen(r))
}
}
}
if len(genPaths) == 0 {
continue
}
hostHeader := opts.ServerDomain
if g.AllHeaders && r.Intn(5) == 0 {
// to get 400
hostHeader = "hse.ru"
}
path := strings.TrimPrefix(genPaths[r.Intn(len(genPaths))], "/")
if path == "" && method != "GET" {
// Keep the existing large-file scenarios and RNG sequence.
// Requests targeting the root are covered on a small tree in contract.go.
path = "MISSING_ROOT_TARGET"
}
gzipRequested := g.Compression && r.Intn(2) == 1
query := Query{
Seed: seed,
Method: method,
Path: path,
Gzip: method == "GET" && gzipRequested,
CreateDirectory: method == "POST" && (r.Intn(2) == 1),
RemoveDirectory: method == "DELETE" && (r.Intn(2) == 1),
HostHeader: hostHeader,
VerifyHeaders: g.AllHeaders,
}
if (method == "POST" && !query.CreateDirectory) || method == "PUT" {
query.FileContent = gen.GenerateFile(r)
}
action := query.Action(env, &opts)
if query.Method == "PUT" && g.LargePuts != nil {
policy := g.LargePuts
if _, ok := action.(ReplaceFileAction); ok && runSeed == policy.RunSeed && largePuts < len(policy.LargeSizes) {
query.FileContent.Size = policy.LargeSizes[largePuts]
largePuts++
} else if query.FileContent.Size > policy.OtherMaxBytes {
query.FileContent.Size = policy.OtherMaxBytes
}
}
queries = append(queries, query)
if action != nil {
changed := action.ApplyEnv(t, env)
if changed {
stats = &Stats{}
env.RootDir.Stats("", stats)
stats.Normalize()
}
}
}
if g.LargePuts != nil && runSeed == g.LargePuts.RunSeed {
require.Equal(t, len(g.LargePuts.LargeSizes), largePuts, "not enough successful PUT queries for large-file coverage")
}
return queries
}
@@ -1,56 +0,0 @@
package hw3test
import (
"net"
"net/http"
"net/http/httptest"
"testing"
)
func TestWaitForServerUsesConfiguredHost(t *testing.T) {
t.Setenv("SOLUTION_HOST", "127.0.0.1")
for _, tc := range []struct {
domain string
status int
}{
{"localhost", http.StatusOK},
{"files.example.com", http.StatusNotFound},
{"redirect.example.com", http.StatusFound},
} {
t.Run(tc.domain, func(t *testing.T) {
type probe struct{ method, target, host string }
received := make(chan probe, 16)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case received <- probe{r.Method, r.RequestURI, r.Host}:
default:
}
if tc.status == http.StatusFound {
w.Header().Set("Location", "/redirected")
}
w.WriteHeader(tc.status)
}))
defer server.Close()
opts := RunOpts{
Port: server.Listener.Addr().(*net.TCPAddr).Port,
ServerDomain: tc.domain,
}
if err := WaitForServer(NewTestContext(t), opts); err != nil {
t.Fatal(err)
}
select {
case got := <-received:
if want := (probe{"GET", "/", tc.domain}); got != want {
t.Fatalf("readiness request = %+v, want %+v", got, want)
}
default:
t.Fatal("server was marked ready without receiving a request")
}
select {
case extra := <-received:
t.Fatalf("unexpected extra readiness request: %+v", extra)
default:
}
})
}
}
@@ -1,269 +0,0 @@
package hw3test
import (
"bufio"
"bytes"
"compress/gzip"
"errors"
"io"
"net"
"net/http"
"strconv"
"testing"
"time"
)
func TestResponseEndChecksWireBytes(t *testing.T) {
for _, tc := range []struct {
name string
response string
valid bool
}{
{"valid body", "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndata", true},
{"extra body bytes", "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndataextra", false},
{"valid empty body", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", true},
{"body after zero length", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\nextra", false},
{"valid no content", "HTTP/1.1 204 No Content\r\n\r\n", true},
{"body after no content", "HTTP/1.1 204 No Content\r\n\r\nextra", false},
} {
t.Run(tc.name, func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
server, err := listener.Accept()
if err != nil {
return
}
defer server.Close()
_, _ = io.WriteString(server, tc.response)
}()
client, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer client.Close()
reader := bufio.NewReader(client)
response, err := http.ReadResponse(reader, nil)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if _, err := io.Copy(io.Discard, response.Body); err != nil {
t.Fatal(err)
}
if err := checkResponseEnd(reader, false); (err == nil) != tc.valid {
t.Fatalf("valid=%v, checkResponseEnd error: %v", tc.valid, err)
}
})
}
}
func TestParseContentTypeRequiresSubtype(t *testing.T) {
for _, tc := range []struct {
value string
valid bool
}{
{"application/octet-stream", true},
{"text/plain; charset=utf-8", true},
{"nonsense", false},
{"text/", false},
{"", false},
} {
_, err := parseContentType(tc.value)
if (err == nil) != tc.valid {
t.Errorf("content type %q: valid=%v, error=%v", tc.value, tc.valid, err)
}
}
}
func TestRunQueryAcceptsEarlyErrorResponse(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
server, err := listener.Accept()
if err != nil {
return
}
defer server.Close()
reader := bufio.NewReader(server)
for {
line, err := reader.ReadString('\n')
if err != nil || line == "\r\n" {
break
}
}
_, _ = io.WriteString(server, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 5\r\n\r\nerror")
}()
t.Setenv("SOLUTION_HOST", "127.0.0.1")
RunQuery(NewTestContext(t), &Env{}, t.TempDir(), RunOpts{
Port: listener.Addr().(*net.TCPAddr).Port,
ServerDomain: "localhost",
}, Query{
Method: "PUT",
Path: "item",
HostHeader: "other.example",
FileContent: &EnvFile{GenSeed: 42, Size: 8 * 1024 * 1024},
})
}
func TestRunQueryWaitsForLargeRequestBeforeCloseTimeout(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
done := make(chan error, 1)
go func() {
server, err := listener.Accept()
if err != nil {
done <- err
return
}
defer server.Close()
request, err := http.ReadRequest(bufio.NewReader(server))
if err != nil {
done <- err
return
}
if _, err := io.WriteString(server, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 5\r\n\r\nerror"); err != nil {
done <- err
return
}
time.Sleep(2500 * time.Millisecond)
_, err = io.Copy(io.Discard, request.Body)
done <- err
}()
t.Setenv("SOLUTION_HOST", "127.0.0.1")
RunQuery(NewTestContext(t), &Env{}, t.TempDir(), RunOpts{
Port: listener.Addr().(*net.TCPAddr).Port,
ServerDomain: "localhost",
}, Query{
Method: "POST",
Path: "item",
HostHeader: "other.example",
FileContent: &EnvFile{GenSeed: 42, Size: 160 << 20},
})
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestIdleDeadlineWriter(t *testing.T) {
t.Run("stalled write", func(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
writer := &idleDeadlineWriter{Conn: client, Timeout: 50 * time.Millisecond}
_, err := writer.Write([]byte("x"))
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("expected write timeout, got %v", err)
}
})
t.Run("request body timeout", func(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
release := make(chan struct{})
defer close(release)
go func() {
_, _ = http.ReadRequest(bufio.NewReader(server))
<-release
}()
writer := &idleDeadlineWriter{Conn: client, Timeout: 50 * time.Millisecond}
request := (&Query{Method: "POST", FileContent: &EnvFile{GenSeed: 42, Size: 1 << 20}}).
CreateRequest(NewTestContext(t), "http://localhost/item")
if err := request.Write(writer); err == nil {
t.Fatal("expected request write to time out")
}
var netErr net.Error
if !errors.As(writer.lastError, &netErr) || !netErr.Timeout() {
t.Fatalf("expected underlying write timeout, got %v", writer.lastError)
}
})
}
func TestCompressedGetResponseValidation(t *testing.T) {
file := &EnvFile{GenSeed: 42, Size: 1024}
directory := &EnvDir{Listing: map[string]EnvNode{"item": file}}
for _, tc := range []struct {
name string
body io.Reader
validate func(*TC, *http.Response)
}{
{
name: "file",
body: file.Open(),
validate: func(t *TC, resp *http.Response) {
GetFileAction{File: file, Compression: true, VerifyHeaders: true}.VerifyResponse(t, nil, resp)
},
},
{
name: "directory",
body: bytes.NewBufferString("item\n"),
validate: func(t *TC, resp *http.Response) {
GetDirAction{Dir: directory, Compression: true, VerifyHeaders: true}.VerifyResponse(t, nil, resp)
},
},
} {
t.Run(tc.name, func(tt *testing.T) {
testContext := NewTestContext(tt)
var encoded bytes.Buffer
writer := gzip.NewWriter(&encoded)
_, err := io.Copy(writer, tc.body)
if err != nil {
tt.Fatal(err)
}
if err := writer.Close(); err != nil {
tt.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusOK,
ContentLength: int64(encoded.Len()),
Header: http.Header{
"Content-Length": {strconv.Itoa(encoded.Len())},
"Content-Encoding": {"gzip"},
"Content-Type": {"text/plain"},
"Server": {"test-server"},
"Connection": {"close"},
},
Body: io.NopCloser(bytes.NewReader(encoded.Bytes())),
}
defer resp.Body.Close()
q := Query{Gzip: true, VerifyHeaders: true}
req := q.CreateRequest(testContext, "http://localhost/item")
if got := req.Header.Get("Accept-Encoding"); got != "gzip" {
tt.Fatalf("expected Accept-Encoding: gzip, got %q", got)
}
q.CommonValidate(testContext, req, resp)
tc.validate(testContext, resp)
})
}
}
func TestCompareFileContentBlocks(t *testing.T) {
file := &EnvFile{GenSeed: 17, Size: 64*1024 + 17}
context := NewTestContext(t)
if err := CompareFileContent(context, file.Open(), file); err != nil {
t.Fatalf("valid file was rejected: %v", err)
}
if err := CompareFileContent(context, io.LimitReader(file.Open(), file.Size-1), file); err == nil {
t.Fatal("truncated file was accepted")
}
content, err := io.ReadAll(file.Open())
if err != nil {
t.Fatal(err)
}
content[len(content)-1] ^= 1
if err := CompareFileContent(context, bytes.NewReader(content), file); err == nil {
t.Fatal("changed final byte was accepted")
}
}
-303
View File
@@ -1,303 +0,0 @@
package hw3test
import (
"bytes"
"errors"
"fmt"
"io"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"unicode"
"go.uber.org/zap"
)
// RunOpts contains command line arguments for the solution.
// Those are passed to the template.
type RunOpts struct {
// Solution options
Port int
WorkingDirectory string
ListenAddr string // Host
ServerDomain string
// Full run config that will be provided to the solution.
CommandLineArgs string
Env []string
// Full run config if solution will be running in docker.
DockerCommandLineArgs string
DockerEnvArgs string
DockerPortArgs string
DockerVolumeArgs string
// Hack to get exitcode of the solution.
ExitCode chan int
}
func (o *RunOpts) Address() string {
host := os.Getenv("SOLUTION_HOST")
if host == "" {
host = "localhost"
}
return fmt.Sprintf("http://%s:%d", host, o.Port)
}
func (o *RunOpts) GenerateRunConfig(t *TC, r *rand.Rand, gen *EnvGen) {
o.CommandLineArgs, o.Env = o.BuildConfig(r, gen)
dirsInDocker := []string{
"/files",
"/files0",
"/files1",
"/files2",
"/files3",
}
dirInDocker := dirsInDocker[r.Intn(len(dirsInDocker))]
if o.WorkingDirectory == "" {
dirInDocker = ""
} else {
o.DockerVolumeArgs = fmt.Sprintf(`-v "%s:%s"`, o.WorkingDirectory, dirInDocker)
}
o.DockerPortArgs = fmt.Sprintf("-p %d:%d", o.Port, o.Port)
dockerArgs, dockerEnv := RunOpts{
ListenAddr: o.ListenAddr,
Port: o.Port,
WorkingDirectory: dirInDocker,
ServerDomain: o.ServerDomain,
}.BuildConfig(r, gen)
o.DockerEnvArgs = ""
for _, env := range dockerEnv {
o.DockerEnvArgs += "--env \"" + env + "\" "
}
o.DockerCommandLineArgs = dockerArgs
}
// BuildConfig uses ListenAddr, Port, WorkingDirectory, ServerDomain.
func (o RunOpts) BuildConfig(r *rand.Rand, gen *EnvGen) (args string, env []string) {
if o.ListenAddr == "0.0.0.0" && r.Intn(2) == 1 {
// can omit default value
} else if gen.AllowEnv && r.Intn(3) == 1 {
// use env
env = append(env, fmt.Sprintf("SERVER_HOST=%s", o.ListenAddr))
} else if o.ListenAddr != "" {
// use plain cmdline args
args += fmt.Sprintf(" \"--host=%s\"", o.ListenAddr)
if r.Intn(2) == 1 {
// pass dummy env
env = append(env, fmt.Sprintf("SERVER_HOST=%s", "8.8.8.8"))
}
}
if o.Port == 8080 && r.Intn(2) == 1 {
// can omit default value
} else if gen.AllowEnv && r.Intn(3) == 1 {
// use env
env = append(env, fmt.Sprintf("SERVER_PORT=%d", o.Port))
} else {
// use plain cmdline args
args += fmt.Sprintf(" \"--port=%d\"", o.Port)
if r.Intn(2) == 1 {
// pass dummy env
env = append(env, fmt.Sprintf("SERVER_PORT=%d", 80))
}
}
if o.WorkingDirectory == "" && r.Intn(2) == 1 {
// can omit default value
} else if gen.AllowEnv && r.Intn(3) == 1 {
// use env
env = append(env, fmt.Sprintf("SERVER_WORKING_DIRECTORY=%s", o.WorkingDirectory))
} else if o.WorkingDirectory != "" {
// use plain cmdline args
args += fmt.Sprintf(" \"--working-directory=%s\"", o.WorkingDirectory)
if r.Intn(2) == 1 {
// pass dummy env
env = append(env, fmt.Sprintf("SERVER_WORKING_DIRECTORY=%s", "/"))
}
}
if o.ServerDomain == "localhost" && r.Intn(2) == 1 {
// can omit default value
} else if gen.AllowEnv && r.Intn(3) == 1 {
// use env
env = append(env, fmt.Sprintf("SERVER_DOMAIN=%s", o.ServerDomain))
} else if o.ServerDomain != "" {
// use plain cmdline args
args += fmt.Sprintf(" \"--server-domain=%s\"", o.ServerDomain)
if r.Intn(2) == 1 {
// pass dummy env
env = append(env, fmt.Sprintf("SERVER_DOMAIN=%s", "example.com"))
}
}
return args, env
}
// Runner is a helper for running HTTP server solution.
type Runner interface {
// Run the solution with the given options.
// Returns a function that can be used to stop the solution.
Run(t *TC, opts RunOpts) (stop func(), err error)
}
// CmdRunner runs command based on template.
type CmdRunner struct {
tmpl template.Template
useDocker bool
}
func NewCmdRunner(tmpl *template.Template, useDocker bool) *CmdRunner {
return &CmdRunner{
tmpl: *tmpl,
useDocker: useDocker,
}
}
// splitCommand preserves quoted arguments without invoking a shell.
func splitCommand(command string) ([]string, error) {
var args []string
var arg strings.Builder
quoted := false
started := false
for _, char := range command {
switch {
case char == '"':
quoted = !quoted
started = true
case unicode.IsSpace(char) && !quoted:
if started {
args = append(args, arg.String())
arg.Reset()
started = false
}
default:
arg.WriteRune(char)
started = true
}
}
if quoted {
return nil, fmt.Errorf("unclosed quote in launch command")
}
if started {
args = append(args, arg.String())
}
if len(args) == 0 {
return nil, fmt.Errorf("empty launch command")
}
return args, nil
}
func (r *CmdRunner) Run(t *TC, opts RunOpts) (stop func(), err error) {
var b bytes.Buffer
err = r.tmpl.Execute(&b, opts)
if err != nil {
return nil, fmt.Errorf("failed to execute template: %v", err)
}
envOpts := opts.Env
if r.useDocker {
envOpts = nil
}
cmdParts, err := splitCommand(b.String())
if err != nil {
return nil, err
}
var cidDir, cidFile string
if r.useDocker {
if len(cmdParts) < 2 || !strings.EqualFold(filepath.Base(cmdParts[0]), "docker") || cmdParts[1] != "run" {
return nil, fmt.Errorf("Docker launch template must start with docker run")
}
cidDir, err = os.MkdirTemp("", "hw3-container-")
if err != nil {
return nil, fmt.Errorf("failed to create container ID directory: %w", err)
}
cidFile = filepath.Join(cidDir, "id")
cmdParts = append(append([]string{}, cmdParts[:2]...), append([]string{"--cidfile", cidFile}, cmdParts[2:]...)...)
}
Info(t, "Running command", zap.Strings("command", cmdParts), zap.Strings("env", envOpts))
cmd := exec.Command(cmdParts[0], cmdParts[1:]...)
cmd.Env = append(os.Environ(), envOpts...)
cmd.Stdout = NewProxyWriter(os.Stderr)
cmd.Stderr = NewProxyWriter(os.Stderr)
err = cmd.Start()
if err != nil {
if cidDir != "" {
_ = os.Remove(cidDir)
}
return nil, fmt.Errorf("failed to run command: %v", err)
}
go func() {
err := cmd.Wait()
if err != nil && err.Error() != "signal: killed" && err.Error() != "exit status 1" {
Warn(t, "Command finished with error", zap.Error(err))
}
if opts.ExitCode != nil {
if e, ok := err.(*exec.ExitError); ok {
opts.ExitCode <- e.ExitCode()
} else {
opts.ExitCode <- 0
}
}
}()
return func() {
if cmd.Process != nil {
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
Error(t, "Failed to kill command", zap.Error(err))
}
}
if r.useDocker {
id, err := os.ReadFile(cidFile)
if err == nil && len(strings.TrimSpace(string(id))) > 0 {
output, removeErr := exec.Command("docker", "rm", "-f", strings.TrimSpace(string(id))).CombinedOutput()
if removeErr != nil && !strings.Contains(string(output), "No such container") && !strings.Contains(string(output), "is already in progress") {
Error(t, "Failed to remove Docker container", zap.Error(removeErr), zap.ByteString("output", output))
}
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
Error(t, "Failed to read container ID", zap.Error(err))
}
_ = os.Remove(cidFile)
_ = os.Remove(cidDir)
}
}, nil
}
// ProxyWriter is used to forward solution output to standard output.
type ProxyWriter struct {
w io.Writer
disable bool
}
func NewProxyWriter(w io.Writer) *ProxyWriter {
return &ProxyWriter{
w: w,
disable: boolFromEnv("DISABLE_SOLUTION_OUTPUT", false),
}
}
func (w *ProxyWriter) Write(b []byte) (n int, err error) {
if w.disable {
return len(b), nil
}
// TODO: if there will be sync problems, we can take a global lock
// and read until \n, then flush and apply color
return w.w.Write(b)
}
@@ -1,38 +0,0 @@
package hw3test
import (
"reflect"
"testing"
)
func TestSplitCommand(t *testing.T) {
for _, test := range []struct {
name string
command string
want []string
wantErr bool
}{
{
name: "working directory with spaces",
command: `python3 "../solution with spaces/server.py" "--working-directory=C:\Users\student name\files"`,
want: []string{"python3", "../solution with spaces/server.py", `--working-directory=C:\Users\student name\files`},
},
{
name: "Docker volume with spaces",
command: `docker run -v "C:\Users\student name\files:/files" hw3img`,
want: []string{"docker", "run", "-v", `C:\Users\student name\files:/files`, "hw3img"},
},
{name: "empty command", command: " ", wantErr: true},
{name: "unclosed quote", command: `python3 "unterminated`, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
got, err := splitCommand(test.command)
if (err != nil) != test.wantErr {
t.Fatalf("splitCommand(%q) error = %v, wantErr %v", test.command, err, test.wantErr)
}
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("splitCommand(%q) = %#v, want %#v", test.command, got, test.want)
}
})
}
}
@@ -1,92 +0,0 @@
"""Run from the assignment directory: python -m unittest discover -s tests -p test_starter.py."""
import importlib.util
import os
from pathlib import Path
import socket
import subprocess
import sys
import tempfile
import time
import unittest
from unittest.mock import patch
from click.testing import CliRunner
STARTER = Path(__file__).resolve().parents[1] / "solution" / "server.py"
spec = importlib.util.spec_from_file_location("hw3_starter", STARTER)
starter = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = starter
spec.loader.exec_module(starter)
ENV_NAMES = ("SERVER_HOST", "SERVER_PORT", "SERVER_DOMAIN", "SERVER_WORKING_DIRECTORY")
class StarterTests(unittest.TestCase):
def run_config(self, args, env):
clean_env = {k: v for k, v in os.environ.items() if k not in ENV_NAMES}
clean_env.update(env)
with patch.dict(os.environ, clean_env, clear=True), patch.object(starter.socket, "socket") as factory, patch.object(starter, "HTTPServer") as server:
factory.return_value.accept.side_effect = OSError("stop after bind")
result = CliRunner().invoke(starter.main, args)
return result, factory, server
def test_defaults(self):
result, factory, server = self.run_config(["--working-directory", str(STARTER.parent)], {})
self.assertEqual(result.exit_code, 0, result.output)
factory.return_value.bind.assert_called_once_with(("0.0.0.0", 8080))
self.assertEqual(server.call_args.args[2:], ("localhost", STARTER.parent))
def test_environment(self):
env = dict(zip(ENV_NAMES, ("127.0.0.1", "9090", "example.com", str(STARTER.parent))))
result, factory, server = self.run_config([], env)
self.assertEqual(result.exit_code, 0, result.output)
factory.return_value.bind.assert_called_once_with(("127.0.0.1", 9090))
self.assertEqual(server.call_args.args[2:], ("example.com", STARTER.parent))
def test_cli_overrides_environment(self):
env = dict(zip(ENV_NAMES, ("8.8.8.8", "80", "wrong.example", "wrong-directory")))
result, factory, server = self.run_config(
["--host", "127.0.0.1", "--port", "9091", "--server-domain", "example.com",
"--working-directory", str(STARTER.parent)], env)
self.assertEqual(result.exit_code, 0, result.output)
factory.return_value.bind.assert_called_once_with(("127.0.0.1", 9091))
self.assertEqual(server.call_args.args[2:], ("example.com", STARTER.parent))
def test_missing_directory(self):
for args, env in [([], {}), ([], {"SERVER_WORKING_DIRECTORY": ""}),
(["--working-directory", ""], {"SERVER_WORKING_DIRECTORY": str(STARTER.parent)})]:
with self.subTest(args=args, env=env):
result, factory, _ = self.run_config(args, env)
self.assertEqual(result.exit_code, 1, result.output)
factory.assert_not_called()
def test_tcp_startup(self):
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
with tempfile.TemporaryDirectory() as workdir:
process = subprocess.Popen(
[sys.executable, str(STARTER), "--host", "127.0.0.1", "--port", str(port),
"--working-directory", workdir],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
deadline = time.monotonic() + 5
while True:
self.assertIsNone(process.poll(), "starter exited before accepting a connection")
try:
client = socket.create_connection(("127.0.0.1", port), timeout=0.5)
break
except OSError:
if time.monotonic() >= deadline:
self.fail("starter did not bind within 5 seconds")
time.sleep(0.05)
with client:
client.sendall(b"GET / HTTP/1.1\r\n")
# The starter has no HTTP handler yet, but its TCP lifecycle works.
self.assertEqual(client.recv(1), b"")
finally:
process.terminate()
process.wait(timeout=5)
if __name__ == "__main__":
unittest.main()
-103
View File
@@ -1,103 +0,0 @@
package hw3test
import (
"context"
"fmt"
"net"
"os"
"strconv"
"strings"
"testing"
)
// TC implements *testing.T and context.Context at the same time.
// Also has some helper methods.
type TC struct {
*testing.T
ctx context.Context
}
func NewTestContext(t *testing.T) *TC {
return &TC{
ctx: context.Background(),
T: t,
}
}
func (tc *TC) Errorf(format string, args ...any) {
str := fmt.Sprintf(format, args...)
if strings.HasPrefix(str, "\n\tError Trace:") {
_, after, ok := strings.Cut(str, "\tError:")
if ok {
str = "\n\tError:" + after
}
}
Error(tc, str)
tc.T.Errorf(str)
}
func (tc *TC) RunByName(name string, f func(*TC)) bool {
return tc.Run(name, func(t *testing.T) {
f(tc.derive(t))
})
}
func (tc *TC) RunBySeed(seed int64, f func(*TC)) bool {
return tc.Run(strconv.FormatInt(seed, 10), func(t *testing.T) {
f(tc.derive(t))
})
}
func (tc *TC) Done() <-chan struct{} {
return tc.ctx.Done()
}
func (tc *TC) Err() error {
return tc.ctx.Err()
}
func (tc *TC) Value(key any) any {
return tc.ctx.Value(key)
}
// derive copies *TC and replaces *testing.T
func (tc TC) derive(t *testing.T) *TC {
tc.T = t
return &tc
}
// Get bool from envvars
func boolFromEnv(key string, def bool) bool {
value := os.Getenv(key)
if value == "" {
return def
}
value = strings.ToLower(value)
switch value {
case "t":
return true
case "true":
return true
case "y":
return true
case "yes":
return true
}
return false
}
// GetFreePort asks the kernel for a free open port that is ready to use.
func GetFreePort() (int, error) {
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
if err != nil {
return 0, err
}
l, err := net.ListenTCP("tcp", addr)
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
-308
View File
@@ -1,308 +0,0 @@
package hw3test
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"os"
"path"
"path/filepath"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// RunTestEmptyWorkDir will check that server does exit(1) if working directory is empty.
func RunTestEmptyWorkDir(t *TC, seed int64, runner Runner) {
t.RunBySeed(seed, func(t *TC) {
r := rand.New(rand.NewSource(seed))
port, err := GetFreePort()
require.NoError(t, err, "failed to get free port for the server")
runOpts := RunOpts{
Port: port,
WorkingDirectory: "",
ServerDomain: "localhost",
ListenAddr: "0.0.0.0",
ExitCode: make(chan int),
}
// Start the solution.
runOpts.GenerateRunConfig(t, r, &EnvGen{})
stop, err := runner.Run(t, runOpts)
require.NoError(t, err, "failed to start solution")
defer stop()
select {
case <-time.After(time.Second * 10):
require.FailNow(t, "Server didn't exit(1) in 10 seconds")
case ec := <-runOpts.ExitCode:
require.Equal(t, 1, ec, "Server exited with wrong code")
}
})
}
func RunTests(t *TC, seed int64, runner Runner, envGen *EnvGen, queriesGen *QueriesGen) {
failed := !t.RunBySeed(seed, func(t *TC) {
env, err := envGen.Generate(seed)
require.NoError(t, err, "failed to generate environment")
r := rand.New(rand.NewSource(seed))
queriesSeeds := make([]int64, queriesGen.Count)
for i := range queriesSeeds {
queriesSeeds[i] = r.Int63n(100000)
}
envDirName := GenFilename(r, 16)
envDir := filepath.Join(envGen.TempDirectory, envDirName)
runOpts := RunOpts{
WorkingDirectory: envDir,
ServerDomain: "localhost",
ListenAddr: "0.0.0.0",
}
if queriesGen.AllHeaders {
possibleDomains := []string{
"localhost",
"cs.hse.ru",
"example.com",
"z0r.de",
"distsys-course.homework.net",
}
runOpts.ServerDomain = possibleDomains[r.Intn(len(possibleDomains))]
}
queries := queriesGen.Generate(t, env, envGen, queriesSeeds, runOpts, seed)
if envGen.SparseUnusedFiles {
needed := make(map[string]bool)
for _, query := range queries {
if query.Method == http.MethodGet || query.Method == http.MethodPut {
needed[path.Clean(query.Path)] = true
}
}
err = env.RootDir.WriteToDiskSelected(envDir, needed)
} else {
err = env.RootDir.WriteToDisk(envDir)
}
require.NoError(t, err, "failed to write environment to disk")
defer os.RemoveAll(envDir)
port, err := GetFreePort()
require.NoError(t, err, "failed to get free port for the server")
runOpts.Port = port
// Start the solution.
runOpts.GenerateRunConfig(t, r, envGen)
stop, err := runner.Run(t, runOpts)
require.NoError(t, err, "failed to start solution")
defer stop()
// Await server to bind to port.
err = WaitForServer(t, runOpts)
require.NoError(t, err, "failed to wait for server")
// Run the queries.
for i, query := range queries {
query := query
ok := t.RunBySeed(query.Seed, func(t *TC) {
RunQuery(t, env, envDir, runOpts, query)
})
shouldAbort := !ok
if shouldAbort {
Warn(t, "Skipping next queries because of the failed query", zap.Int("skipped", len(queries)-1-i), zap.String("failed", fmt.Sprintf("%s/%v", t.Name(), query.Seed)))
break
}
}
})
shouldAbort := failed
if shouldAbort {
Warn(t, "Skipping next tests in a group because last test has failed")
t.FailNow()
}
}
func WaitForServer(t *TC, opts RunOpts) error {
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
deadline := time.Now().Add(10 * time.Second)
var lastErr error
for attempt := 0; time.Until(deadline) > 0; attempt++ {
probeTimeout := time.Until(deadline)
if probeTimeout > time.Second {
probeTimeout = time.Second
}
ctx, cancel := context.WithTimeout(t, probeTimeout)
req, err := http.NewRequestWithContext(ctx, "GET", opts.Address(), nil)
if err != nil {
cancel()
return fmt.Errorf("failed to create context request: %w", err)
}
req.Host = opts.ServerDomain
resp, err := client.Do(req)
if err == nil {
if resp != nil {
resp.Body.Close()
}
cancel()
return nil
}
cancel()
lastErr = err
if attempt == 0 || attempt%10 == 0 {
Debug(t, "Waiting for server startup", zap.Int("attempt", attempt), zap.Error(err))
}
if remaining := time.Until(deadline); remaining > 0 {
if remaining > 100*time.Millisecond {
remaining = 100 * time.Millisecond
}
time.Sleep(remaining)
}
}
Warn(t, "Server didn't get up in time, aborting", zap.Error(lastErr))
return fmt.Errorf("server did not start in 10 seconds: %w", lastErr)
}
// RunQuery runs a single query. Server address and its configuration is taken from runOpts.
// Initial environment is described by env, but it may be changed with queries. Actual
// environment is located in envDir. Query describes the query itself.
func RunQuery(t *TC, env *Env, envDir string, opts RunOpts, query Query) {
queryURL := fmt.Sprintf("%s/%s", opts.Address(), query.Path)
Debug(
t,
"Sending query",
zap.String("method", query.Method),
zap.String("path", query.Path),
zap.String("url", queryURL),
)
action := query.Action(env, &opts)
if action != nil {
action.VerifyBefore(t, envDir)
}
req := query.CreateRequest(t, queryURL)
req.Close = true
conn, err := net.DialTimeout("tcp", req.URL.Host, 10*time.Second)
require.NoError(t, err, "failed to connect to server")
defer conn.Close()
writeDone := make(chan error, 1)
writer := &idleDeadlineWriter{Conn: conn, Timeout: 15 * time.Second}
go func() { writeDone <- req.Write(writer) }()
reader := bufio.NewReader(&idleDeadlineReader{Conn: conn, Timeout: 30 * time.Second})
resp, err := http.ReadResponse(reader, req)
require.NoError(t, err, "failed to run query on server")
defer resp.Body.Close()
body := &countingBody{ReadCloser: resp.Body}
resp.Body = body
query.CommonValidate(t, req, resp)
if action != nil {
action.VerifyResponse(t, req, resp)
}
_, err = io.Copy(io.Discard, resp.Body)
require.NoError(t, err, "failed to read response body")
if resp.StatusCode != http.StatusNoContent {
require.Equal(t, resp.ContentLength, body.Size, "Content-Length differs from response body size")
}
endDone := make(chan error, 1)
go func() { endDone <- checkResponseEnd(reader, req.Body != nil) }()
var endErr, writeErr error
writeCompleted := false
select {
case endErr = <-endDone:
// The server closed the connection before the request writer finished.
case writeErr = <-writeDone:
writeCompleted = true
var netErr net.Error
// http.Request.Write wraps body write errors without preserving Unwrap.
// Inspect the original socket error captured by the writer instead.
if errors.As(writer.lastError, &netErr) && netErr.Timeout() {
require.NoError(t, writer.lastError, "request upload stalled")
}
// A server may finish reading a large request after sending its response.
// Start the close timeout only after the request writer has finished.
closeTimeout := 2 * time.Second
if req.Body != nil {
closeTimeout = 10 * time.Second
}
require.NoError(t, conn.SetReadDeadline(time.Now().Add(closeTimeout)))
endErr = <-endDone
}
require.NoError(t, endErr, "response contains bytes beyond Content-Length or connection was not closed")
// The server may reply without consuming the whole request body. In that
// case the response and the file-system checks determine correctness.
if req.Body == nil {
if !writeCompleted {
writeErr = <-writeDone
}
require.NoError(t, writeErr, "failed to send query to server")
}
if query.VerifyHeaders && body.Size > 0 {
_, err = parseContentType(resp.Header.Get("Content-Type"))
require.NoError(t, err, "expected valid content type")
}
if action != nil {
action.VerifyAfter(t, envDir)
action.ApplyEnv(t, env)
}
}
// idleDeadlineWriter bounds pauses in sending a request without limiting the
// total time for a large request that continues to make progress.
type idleDeadlineWriter struct {
net.Conn
Timeout time.Duration
lastError error
}
func (w *idleDeadlineWriter) Write(p []byte) (int, error) {
if err := w.Conn.SetWriteDeadline(time.Now().Add(w.Timeout)); err != nil {
w.lastError = err
return 0, err
}
n, err := w.Conn.Write(p)
if err != nil {
w.lastError = err
}
return n, err
}
func checkResponseEnd(reader *bufio.Reader, requestHasBody bool) error {
_, err := reader.ReadByte()
if err == io.EOF {
return nil
}
// Closing a connection with an unread request body can produce a TCP reset
// after a complete error response. Its error code differs by platform.
var netErr *net.OpError
if requestHasBody && errors.As(err, &netErr) && netErr.Op == "read" && !netErr.Timeout() {
return nil
}
if err != nil {
return err
}
return fmt.Errorf("unexpected byte after response body")
}
type countingBody struct {
io.ReadCloser
Size int64
}
func (b *countingBody) Read(p []byte) (int, error) {
n, err := b.ReadCloser.Read(p)
b.Size += int64(n)
return n, err
}
@@ -1,15 +0,0 @@
#!/bin/sh
set -eu
# Docker exposes the filesystem source after the separator in mountinfo.
# A bind mount whose host source is an ext4 filesystem on zram retains that
# source; a plain host directory on the HDD must not pass this check.
awk '
$5 == "/hw/tests/tmp" {
for (i = 6; i <= NF - 2; i++) {
if ($i == "-" && $(i + 1) == "ext4" && $(i + 2) ~ /^\/dev\/zram[0-9]+$/)
found = 1
}
}
END { exit !found }
' /proc/self/mountinfo
-126
View File
@@ -1,126 +0,0 @@
# Надежная упорядоченная рассылка
В этом задании вам предстоит реализовать механизм рассылки сообщений с описанными далее гарантиями по надежности и порядку доставки. Выполнение и тестирование решения будет происходить в фреймворке AnySystem, уже знакомом вам по первому заданию.
Пусть имеется распределенное приложение типа чата, пользователи которого общаются путем обмена сообщениями. Для простоты будем считать, что число пользователей фиксировано, чат один, и сообщения в нём доставляются всем пользователям, включая отправителя. С каждым пользователем связан процесс, который принимает его сообщения, рассылает их остальным процессам и доставляет сообщения чата своему пользователю. Процессы выполняются на разных узлах системы, связанных сетью. Как обычно, взаимодействие с пользователем (прием и доставка сообщений) реализуется в AnySystem с помощью локальных сообщений.
Процессы взаимодействуют путем обмена сообщениями по сети. Изначально вам доступна только отправка сообщения одному процессу (unicast), поверх чего вам надо реализовать рассылку сообщения всем процессам (broadcast).
Узлы и процессы на них могут внезапно остановиться, например во время рассылки. Отказавший процесс останавливается навсегда и не возвращается в систему. Процесс называется _корректным_, если он не отказал за всё рассматриваемое выполнение системы. Процесс, который сейчас работает, но позднее упадёт, корректным не является. Это определение используется для анализа выполнения: алгоритм не знает заранее, какие процессы откажут. Корректные процессы всегда составляют _большинство_: например, из пяти процессов могут отказать не более двух. (Подсказка: это требуется для реализации свойства 4 ниже.)
Вам предоставлен надежный транспорт: каждое сетевое сообщение между корректными процессами в конце концов будет получено ровно один раз (exactly once). Транспорт не создаёт и не дублирует сообщения, но порядок их получения не гарантируется. Однако если отправитель упал до получения сообщения адресатом, доставка этого сообщения уже _не гарантируется_, даже если оно было отправлено задолго до отказа. Обеспечивать надежность отдельных сетевых передач в решении не нужно. При этом ваш алгоритм рассылки может использовать собственные подтверждения и другие служебные сообщения.
Различайте три события: пользователь отправляет сообщение своему процессу (`SEND`), процесс получает сетевое сообщение от другого процесса, процесс доставляет сообщение своему пользователю (`DELIVER`). Получение по сети само по себе не является доставкой пользователю. В свойствах ниже под доставкой понимается именно `DELIVER`.
Ваша реализация рассылки должна удовлетворять следующим свойствам (см. лекцию 4):
1. **No Duplication:** Сообщения доставляются пользователю не более одного раза (нет повторов).
2. **No Creation:** Если пользователю доставлено сообщение _m_ от пользователя _u_, то _m_ было ранее отправлено _u_.
3. **Validity:** Если пользователь корректного процесса _n_ отправил сообщение _m_, то _n_ должен в конце концов доставить _m_ пользователю.
4. **Uniform Agreement:** Если сообщение _m_ было доставлено некоторым (необязательно корректным) процессом, то _m_ будет в конце концов доставлено каждым корректным процессом.
5. **Causal Order:** Сообщения доставляются с сохранением причинного порядка - если процесс _n_ доставил сообщение _m_ от пользователя _u_, то перед этим _n_ должен доставить все сообщения, которые могли повлиять на _m_ (сообщения, отправленные или доставленные _u_ до отправки им _m_).
Дополнительная часть задания на 9-10 баллов посвящена масштабируемой рассылке.
## Реализация
Для реализации и тестирования решения используется фреймворк AnySystem (см. материалы первого семинара). В папке `solution` размещена заготовка для решения [broadcast.py](solution/broadcast.py). Доработайте класс `BroadcastProcess`, чтобы рассылка удовлетворяла перечисленным свойствам.
Ваши реализации процессов AnySystem должны соблюдать правило [изоляции процессов](../readme.md#изоляция-процессов-anysystem): изменяемое состояние каждого процесса должно храниться только в `self`, а общая изменяемая память между процессами запрещена. Все взаимодействия между процессами должны происходить через сообщения, как и в реальной распределённой системе.
При инициализации процессу передается его уникальный id (он же является id локального пользователя), а также список id всех процессов в системе. Отправка сообщения пользователем реализована с помощью локального сообщения `SEND` со строковым полем `text`. Все пользовательские сообщения в пределах одного выполнения системы имеют разные значения `text`. Для доставки сообщения пользователю используйте локальное сообщение `DELIVER` с исходным значением `text` (см. заготовку). Для взаимодействия между процессами вы можете использовать любые собственные типы сообщений.
Основная реализация не должна делать предположений о максимальных временах доставки сообщений или пытаться определить жив ли некоторый процесс или упал. Позже в курсе мы научимся реализовывать детектор отказов, в основной части этого задания вам надо обойтись без него. Не используйте бесконечно повторяющиеся таймеры: обычные тесты ожидают опустошения очереди событий. Разовые таймеры допустимы, но для решения этой задачи они не нужны.
## Дополнительная часть: масштабируемая рассылка
Реализуйте отдельный вариант рассылки из лекции 4: например, gossip, рассылку по дереву или flooding по разреженному графу. Для дерева обязателен механизм восстановления доставки при отказе посредника. Можно предложить другую сопоставимую схему. Новизна идеи не требуется. Экономия памяти или уменьшение константы без изменения схемы распространения сами по себе эту часть не заменяют.
Сдайте дополнительный вариант в `solution/broadcast_scalable.py`: класс `BroadcastProcess` с теми же аргументами конструктора и сообщениями `SEND`/`DELIVER`, что и в основном решении. Основное решение остаётся в `solution/broadcast.py`, отчёт для обеих версий - в `solution/readme.md`.
В дополнительном варианте можно ослабить гарантии или использовать дополнительные предположения, включая тайм-ауты и периодические таймеры. В отчёте опишите механизм рассылки и восстановления, сохраняемые и ослабленные свойства, условия применимости и ограничения. Изоляция процессов обязательна для обеих версий. Ошибка, противоречащая заявленным свойствам протокола, не считается допустимым компромиссом.
Первый дополнительный балл даётся за работающий масштабируемый протокол с таким описанием. Второй - за воспроизводимое сравнение с основным решением на предоставленном тесте `OPTIMIZATION`: приведите команды и параметры запуска, таблицу или график результатов и объясните, как рост системы, отказы и задержки влияют на затраты и доставку. Сокращение трафика рассматривайте вместе с охватом: меньше отправленных сообщений за счёт недоставленных сообщений ещё не означает улучшение. Единого порога выигрыша в процентах нет.
## Оценивание
| Компонент | Баллы |
| --- | ---: |
| Основная реализация удовлетворяет свойствам 1–4 | 5 |
| Дополнительно обеспечен Causal Order | до +2 |
| В отчёте обосновано достижение заявленных свойств | +1 |
| Реализован масштабируемый вариант рассылки | +1 |
| Проведено экспериментальное сравнение и объяснены компромиссы | +1 |
За Causal Order начисляются 2 балла при прохождении всех тестов. Если нарушений не найдено, но `MODEL CHECKING CAUSAL ORDER` не завершён из-за ресурсного лимита (`INCOMPLETE`), начисляется 1 балл из 2. Обнаруженное нарушение Causal Order в любом тесте означает 0 баллов за этот компонент. Эти баллы доступны при выполнении свойств 1–4.
Отчёт с описанием вашего решения в `solution/readme.md` обязателен, см. [общие правила](../readme.md#отчёт). Для балла за обоснование отдельно объясните в отчёте, почему алгоритм обеспечивает каждое заявленное свойство. Строгих доказательств не требуется, достаточно понятного и убедительного объяснения, связанного с вашим кодом и учитывающего отказы процессов.
Полное основное решение с обоснованием получает 8 баллов. Баллы за дополнительную часть доступны только после получения всех 8 основных; балл за эксперимент требует реализации масштабируемого варианта. Автоматический `SCORE` равен 0, 5, 6 или 7 и учитывает только основную реализацию. Остальные баллы выставляются по коду и отчёту.
Бонусы за пробелы в тестах начисляются по [общим правилам](../readme.md#бонусы-за-пробелы-в-тестах).
## Тестирование
### Локальное тестирование
Тесты находятся в папке `tests`. Есть два варианта их запуска.
Рекомендуемый вариант запуска тестов - через готовый Docker-образ. В этом случае используемое окружение будет аналогично тестирующей системе. Для запуска тестов выполните команду:
```commandline
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/broadcast:latest [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
```
Для запуска полного набора тестов с теми же параметрами, что и в тестирующей системе, выполните команду:
```commandline
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/broadcast:latest -m 100
```
Вы также можете запустить тесты, скомпилировав их локально с помощью компилятора Rust. Такой вариант может быть удобен, если вы хотите лучше изучить или доработать тесты. Скомпилируйте тесты с помощью команды `cargo install --locked --path tests`. Для запуска тестов выполните команду:
```commandline
distsys-broadcast [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
```
Тестер выводит пояснения к сценариям, найденные нарушения и финальную сводку. Подробную трассу событий и истории `SEND`/`DELIVER` можно включить флагом `-d`, например: `distsys-broadcast -t "TWO CRASHES" -d`. Все доступные опции показаны в справке `-h`. Основные тесты по умолчанию запускаются на системе из пяти процессов; это число можно изменить опцией `-p`. При тестировании в системе число прогонов `CHAOS MONKEY` увеличено до 100 (опция `-m 100`).
Если найдено нарушение свойств 1–4 или превышен ресурсный лимит до причинного model checking, основной результат уже равен нулю: тестер прекращает проверку и помечает оставшиеся тесты как `SKIPPED`. При нарушении только Causal Order тестер продолжает проверять остальные свойства, а причинный MC пропускает. Опция `--keep-going` позволяет собрать больше ошибок, в том числе запустить причинный MC после найденного нарушения. Превышение лимита или аварийное завершение процесса останавливает проверку и с этой опцией.
Обычный тест ограничен 15 секундами, вся серия `CHAOS MONKEY` - 30 секундами, `SCALABILITY` с системами до 50 процессов - 60 секундами. На основные тесты без `OPTIMIZATION` отводится суммарно 300 секунд. Общий лимит запуска в Docker - 420 секунд. Лимиты защищают от бесконечных обработчиков, таймеров и обмена сообщениями.
В тестах сообщения процессов, которые позднее упадут, могут теряться ещё до их остановки. Так моделируется, например, ситуация, когда сообщение потерялось в сети, а отправитель упал до повторной передачи на уровне транспорта. До отказа отправитель может продолжать работать, а другие его сообщения - доходить. Реализовывать транспортные повторные передачи в решении не нужно.
В `CHAOS MONKEY` перед каждым прогоном указаны два процесса, которые позднее упадут: потери исходящих сообщений включаются только для них. Для повторения прогона с номером _k_ используйте указанный базовый seed через `-s` и запустите первые _k_ прогонов через `-m k`.
В тесте `TWO CRASHES` процессы 0 и 1 могут общаться друг с другом, но изолированы от остальных; связь внутри оставшейся группы сохранена. Процесс 0 начинает рассылку, затем 0 и 1 падают. Если никто не доставил сообщение пользователю, свойства не нарушены: отправитель отказал, а предпосылка Uniform Agreement не выполнена. Если же 0 или 1 доставил сообщение, все корректные процессы тоже должны его доставить. Поэтому ошибка Uniform Agreement показывает обе стороны нарушения: кто уже доставил сообщение и какие корректные процессы его не доставили.
Тест `QUORUM` проверяет системы из 7 и 10 процессов в двух независимых сценариях. В первом отправитель находится в изолированном меньшинстве: процессы этой группы могут общаться только между собой, а затем падают. Тест проверяет, что до отказа ни один из них не доставляет сообщение пользователю. Во втором меньшинство падает до начала рассылки, а корректный отправитель и остальные процессы должны завершить доставку. Нарушения требуемых свойств в этом тесте учитываются при оценивании.
Тесты `MODEL CHECKING ...` используют три процесса независимо от `-p`. В `MODEL CHECKING CAUSAL ORDER` два пользователя последовательно отправляют по одному сообщению. Тест перебирает возможные порядки доставки сообщений и останавливается, если при этом требуется проверить больше 100 000 состояний системы. Этот лимит общий для обоих этапов теста. На каждый этап поиска отводится до 55 секунд, на весь тест - до 60 секунд. Превышение одного из этих пределов помечается `INCOMPLETE`: причинная проверка не завершилась, но контрпример не найден. Если предыдущие тесты прошли, автоматический результат равен 6 баллам: 5 за свойства 1–4 и 1 из 2 за Causal Order.
Частая причина `INCOMPLETE` - избыточный сетевой обмен: model checking перебирает разные порядки сообщений, и число состояний быстро растёт. Проверьте, не повторяет ли инициатор свою первоначальную рассылку после получения сетевой копии и не вызывает ли каждая новая копия сообщения повторную рассылку без новой информации. Сократите лишний обмен, сохранив требуемые свойства рассылки. Для ориентира: известные нам решения проходят этот тест за несколько десятков секунд или быстрее.
Тест `SCALABILITY` проверяет корректность основной реализации при разных размерах системы и измеряет число сетевых сообщений. Это число само по себе не влияет на оценку. Нарушения требуемых свойств учитываются так же, как в остальных обычных тестах.
Время выполнения тестов при локальном запуске зависит от компьютера и способа запуска. Подробнее см. в [общей инструкции](../readme.md#локальное-тестирование-решения).
### Сравнение масштабируемого варианта
При полном запуске тестер автоматически выполняет `OPTIMIZATION`, если основной результат равен `SCORE: 7` и рядом с основным файлом есть `broadcast_scalable.py`. На сравнение обеих версий отводится общий бюджет 90 секунд. Результаты сохраняются в журнале тестирующей системы. Ошибка или превышение лимита в измерениях не меняет основной `SCORE`. Для отдельного запуска используйте:
```commandline
distsys-broadcast -t OPTIMIZATION
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/broadcast:latest -t OPTIMIZATION
```
Основной файл можно указать через `-i`, дополнительный - через `--optimized-impl`. Тест сравнивает версии на одинаковой нагрузке при разных размерах системы: без отказов, с отказами и с большими сетевыми задержками. Рядом выводятся число и объём сетевых сообщений, включая служебные, максимальное число отправок одним процессом, охват корректных процессов, задержки и ошибки доставки.
Наблюдение ограничено по модельному времени, поэтому периодические таймеры допустимы. При сравнении задержек и сетевых затрат учитывайте долю доставленных сообщений: быстрая доставка части сообщений с небольшими затратами может сопровождаться недоставкой остальных. При этом сообщения, не доставленные за время теста, могут быть доставлены позже — тест этого не проверяет. Превышение лимитов помечается как `INCOMPLETE`, невыполненные измерения - `NOT MEASURED`.
Параметры сценариев и расчёт метрик можно изучить в [коде теста](tests/src/optimization.rs), опции запуска - в справке `-h`. Результаты используются для анализа в отчёте; автоматического порога для получения дополнительных баллов нет.
### Проверка в тестирующей системе
Отправьте ваше решение в тестирующую систему следуя [инструкции](../readme.md) и дождитесь результатов.
@@ -1,31 +0,0 @@
from anysystem import Context, Message, Process
from typing import List
class BroadcastProcess(Process):
def __init__(self, proc_id: str, processes: List[str]):
self._id = proc_id
self._processes = processes
def on_local_message(self, msg: Message, ctx: Context):
if msg.type == 'SEND':
bcast_msg = Message('BCAST', {
'text': msg['text']
})
# best-effort broadcast
for proc in self._processes:
ctx.send(bcast_msg, proc)
def on_start(self, ctx: Context):
pass
def on_message(self, msg: Message, sender: str, ctx: Context):
if msg.type == 'BCAST':
# deliver message to the local user
deliver_msg = Message('DELIVER', {
'text': msg['text']
})
ctx.send_local(deliver_msg)
def on_timer(self, timer_name: str, ctx: Context):
pass
@@ -1,5 +0,0 @@
target
**/target
**/__pycache__
**/.pytest_cache
*.pyc
-985
View File
@@ -1,985 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "anysystem"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01badd3bc0d042e4399f8f685c394a79a72e7f233b3d3b5eabb3b94734f4dbe"
dependencies = [
"colored",
"downcast-rs",
"dyn-clone",
"indexmap 2.14.0",
"lazy_static",
"log",
"ordered-float",
"pyo3",
"rand",
"rand_pcg",
"regex",
"rstest",
"serde",
"serde_json",
"simcore",
"sugars",
]
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cfg-if"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "clap"
version = "3.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
dependencies = [
"atty",
"bitflags",
"clap_derive",
"clap_lex",
"indexmap 1.9.3",
"once_cell",
"strsim",
"termcolor",
"textwrap",
]
[[package]]
name = "clap_derive"
version = "3.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008"
dependencies = [
"heck 0.4.1",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "clap_lex"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
dependencies = [
"os_str_bytes",
]
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "distsys-broadcast"
version = "0.1.0"
dependencies = [
"anysystem",
"clap",
"env_logger",
"indexmap 2.14.0",
"log",
"pyo3",
"rand",
"rand_pcg",
"serde",
"serde_json",
"sugars",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "env_logger"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7"
dependencies = [
"atty",
"humantime",
"log",
"regex",
"termcolor",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
dependencies = [
"serde",
"typeid",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "memchr"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "ordered-float"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
dependencies = [
"num-traits",
"rand",
"serde",
]
[[package]]
name = "os_str_bytes"
version = "6.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro-crate"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
name = "proc-macro2"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pyo3"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
dependencies = [
"libc",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
]
[[package]]
name = "pyo3-build-config"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9"
dependencies = [
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn 2.0.106",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"serde",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"serde",
]
[[package]]
name = "rand_pcg"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e"
dependencies = [
"rand_core",
]
[[package]]
name = "regex"
version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
[[package]]
name = "relative-path"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "rstest"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682"
dependencies = [
"futures",
"futures-timer",
"rstest_macros",
"rustc_version",
]
[[package]]
name = "rstest_macros"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d"
dependencies = [
"cfg-if",
"glob",
"proc-macro-crate",
"proc-macro2",
"quote",
"regex",
"relative-path",
"rustc_version",
"syn 2.0.106",
"unicode-ident",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "semver"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "serde_json"
version = "1.0.143"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
dependencies = [
"indexmap 2.14.0",
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "serde_type_name"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92acc4cd6ae20767c54d6cf1a075624e7f4d9e99d7ebc685398ff243144d8714"
dependencies = [
"serde",
]
[[package]]
name = "simcore"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a20dfec85e94e569fa5b810b2d992c68b361f31bfba471447427536488c9c292"
dependencies = [
"colored",
"downcast-rs",
"dyn-clone",
"erased-serde",
"futures",
"log",
"rand",
"rand_pcg",
"rustc-hash",
"serde",
"serde_json",
"serde_type_name",
]
[[package]]
name = "slab"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "sugars"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc0db74f9ee706e039d031a560bd7d110c7022f016051b3d33eeff9583e3e67a"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.14.0",
"toml_datetime",
"winnow",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "unicode-ident"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.0",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
dependencies = [
"memchr",
]
[[package]]
name = "zerocopy"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "distsys-broadcast"
version = "0.1.0"
edition = "2021"
resolver = "3"
[dependencies]
anysystem = "=0.3.0"
indexmap = "2.12"
clap = { version = "3.2.17", features = ["cargo", "derive"] }
env_logger = "0.9.0"
log = "0.4.17"
pyo3 = { version = "=0.29.2", features = ["auto-initialize"] }
rand = "0.8.5"
rand_pcg = "0.3.1"
serde = { version = "1.0.142", features = ["derive"] }
serde_json = "1.0.83"
sugars = "3.0.1"
-20
View File
@@ -1,20 +0,0 @@
# syntax=docker/dockerfile:1
FROM rust:1.97.1-bookworm@sha256:0e2bcaef56d041a486784e54104a81aebe0da44bd03019bd70bc0401e42e4a97 AS builder
RUN apt-get update && apt-get install -y build-essential python3-dev
WORKDIR /tests
COPY . .
RUN --mount=type=cache,id=distsys-course-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=distsys-course-cargo-target-rust-1-97-1,target=/tests/target,sharing=locked \
cargo install --locked --path . --root /opt/course
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y python3-dev && rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/course/bin/distsys-broadcast /usr/local/bin/distsys-broadcast
WORKDIR /solution
# 300s for correctness tests, 90s shared by both measurements, plus cleanup.
ENTRYPOINT ["timeout", "-k", "10", "420", "distsys-broadcast", "-i", "broadcast.py"]
File diff suppressed because it is too large. Load diff
-36
View File
@@ -1,36 +0,0 @@
use serde::Serialize;
use sugars::boxed;
use anysystem::python::PyProcessFactory;
use anysystem::System;
#[derive(Copy, Clone)]
pub struct TestConfig<'a> {
pub proc_factory: &'a PyProcessFactory,
pub proc_count: u64,
pub seed: u64,
pub monkeys: u32,
pub debug: bool,
}
#[derive(Serialize)]
pub struct BroadcastMessage<'a> {
pub text: &'a str,
}
pub fn build_system(config: &TestConfig) -> System {
let mut sys = System::new(config.seed);
let mut proc_names = Vec::new();
for n in 0..config.proc_count {
proc_names.push(format!("{n}"));
}
for proc_name in &proc_names {
let proc = config
.proc_factory
.build((proc_name, proc_names.clone()), config.seed);
// process and node on which it runs have the same name
sys.add_node(proc_name);
sys.add_process(proc_name, boxed!(proc), proc_name);
}
sys
}
-219
View File
@@ -1,219 +0,0 @@
mod common;
mod optimization;
mod runner;
mod shared_state;
mod tests;
mod tests_mc;
use anysystem::test::TestResult;
use clap::Parser;
use indexmap::IndexMap;
use std::collections::HashSet;
use std::path::PathBuf;
/// Broadcast Homework Tests
#[derive(Parser, Debug)]
#[clap(about, long_about = None)]
struct Args {
/// Path to Python file with solution
#[clap(long = "impl", short = 'i', default_value = "solution/broadcast.py")]
solution_path: String,
/// Test to run (optional)
#[clap(long = "test", short)]
test: Option<String>,
/// Print execution trace
#[clap(long, short)]
debug: bool,
/// Random seed used in tests
#[clap(long, short, default_value = "2023")]
seed: u64,
/// Number of processes
#[clap(long, short, default_value = "5")]
proc_count: u64,
/// Number of chaos monkey runs
#[clap(long, short, default_value = "10")]
monkeys: u32,
/// Do not run model checking tests
#[clap(long)]
disable_mc_tests: bool,
/// Optional scalable solution (defaults to broadcast_scalable.py beside --impl)
#[clap(long)]
optimized_impl: Option<String>,
/// Repetitions per OPTIMIZATION scenario and system size
#[clap(long, default_value = "10", value_parser = clap::value_parser!(u32).range(1..=100))]
optimization_runs: u32,
/// Shared wall-clock budget in seconds for both implementations in OPTIMIZATION
#[clap(long, default_value = "90", value_parser = clap::value_parser!(u64).range(1..=90))]
optimization_timeout: u64,
#[clap(long, hide = true)]
optimization_worker: bool,
/// Wall-clock limit in seconds per ordinary test
#[clap(long, default_value = "15", value_parser = clap::value_parser!(u64).range(1..=300))]
test_timeout: u64,
/// Wall-clock limit in seconds for SCALABILITY (up to 50 processes)
#[clap(long, default_value = "60", value_parser = clap::value_parser!(u64).range(1..=300))]
scalability_timeout: u64,
/// Wall-clock limit in seconds for all CHAOS MONKEY runs
#[clap(long, default_value = "30", value_parser = clap::value_parser!(u64).range(1..=300))]
chaos_timeout: u64,
/// External MC test limit (NORMAL DELIVERY is capped at 110s, CAUSAL ORDER at 60s)
#[clap(long, default_value = "210", value_parser = clap::value_parser!(u64).range(1..=210))]
mc_timeout: u64,
/// Total wall-clock budget in seconds for correctness tests
#[clap(long, default_value = "300", value_parser = clap::value_parser!(u64).range(1..=300))]
suite_timeout: u64,
/// Continue after failures for local diagnostics; resource limits and crashes still stop
#[clap(long)]
keep_going: bool,
#[clap(long, hide = true, requires = "test-result")]
test_worker: Option<String>,
#[clap(long, hide = true)]
test_result: Option<PathBuf>,
}
fn main() {
let args = Args::parse();
if args.test_worker.is_some() {
runner::worker(&args);
return;
}
if args.optimization_worker {
optimization::worker(&args);
return;
}
if args.test.as_deref() == Some("OPTIMIZATION") {
optimization::run(&args);
return;
}
if !shared_state::validate_or_report(&args.solution_path, &["BroadcastProcess"]) {
return;
}
let results = runner::run(&args);
if args.test.is_none() {
let score = score(results);
optimization::run_if_present(&args, score);
println!("SCORE: {score}\n");
}
}
fn score(results: IndexMap<String, TestResult>) -> f32 {
let mut violated = HashSet::new();
let mut causal_incomplete = false;
for (name, result) in results {
if let Err(e) = result {
if e.starts_with("Violated") {
for prop in e.replace("Violated ", "").split(", ") {
violated.insert(prop.to_string());
}
} else if runner::causal_mc_incomplete(&name, &e) {
// Keep partial credit separate from a discovered violation.
causal_incomplete = true;
} else {
return 0.;
}
}
}
let violated: HashSet<&str> = violated.iter().map(|s| s.as_str()).collect();
let base_props = HashSet::from([
"NO DUPLICATION",
"NO CREATION",
"VALIDITY",
"UNIFORM AGREEMENT",
]);
if violated.is_disjoint(&base_props) {
if !violated.is_empty() {
5.
} else if causal_incomplete {
6.
} else {
7.
}
} else {
0.
}
}
#[cfg(test)]
mod score_tests {
use super::score;
use indexmap::IndexMap;
#[test]
fn incomplete_causal_mc_gets_partial_credit() {
for error in [
"time limit of 55s exceeded",
"test time limit of 60s exceeded",
"suite time limit of 300s exceeded",
"causal model-checking state limit of 100000 exceeded",
] {
let mut results = IndexMap::new();
results.insert("MODEL CHECKING CAUSAL ORDER".to_string(), Err(error.into()));
assert_eq!(score(results), 6., "{error}");
}
}
#[test]
fn discovered_violation_takes_precedence_over_incomplete_search() {
for (error, expected) in [
("Violated CAUSAL ORDER", 5.),
("Violated VALIDITY", 0.),
("Violated VALIDITY, CAUSAL ORDER", 0.),
] {
for incomplete_first in [false, true] {
let mut entries = vec![
("CAUSAL ORDER", error),
("MODEL CHECKING CAUSAL ORDER", "time limit of 55s exceeded"),
];
if incomplete_first {
entries.reverse();
}
let results = entries
.into_iter()
.map(|(name, error)| (name.to_string(), Err(error.to_string())))
.collect();
assert_eq!(score(results), expected, "{error}");
}
}
}
#[test]
fn other_failures_do_not_get_partial_credit() {
for (name, error) in [
("NORMAL", "test time limit of 15s exceeded"),
(
"MODEL CHECKING NORMAL DELIVERY",
"time limit of 100s exceeded",
),
(
"MODEL CHECKING CAUSAL ORDER",
"test worker exited with exit code: 1",
),
(
"MODEL CHECKING CAUSAL ORDER",
"nothing left to do to reach the goal",
),
] {
let mut results = IndexMap::new();
results.insert(name.to_string(), Err(error.to_string()));
assert_eq!(score(results), 0., "{name}: {error}");
}
}
}
@@ -1,516 +0,0 @@
//! Bounded measurements, deliberately separate from correctness tests and SCORE.
use std::cell::Cell;
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::rc::Rc;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread;
use std::time::{Duration, Instant};
use anysystem::logger::LogEntry;
use anysystem::python::PyProcessFactory;
use anysystem::{Context, Message, Process, System};
use serde::{Deserialize, Serialize};
use crate::common::{BroadcastMessage, TestConfig};
use crate::{shared_state, Args};
const SIZES: [u64; 4] = [5, 10, 20, 50];
const SCENARIOS: [&str; 3] = ["NORMAL", "CRASHES", "DELAYED"];
const MESSAGES: usize = 10;
const HORIZON: f64 = 109.; // 100 model-time units after the last SEND at t=9.
const EVENT_LIMIT: u64 = 100_000;
const OUTPUT_LIMIT: u64 = 2 * 1024 * 1024;
const RESULT_PREFIX: &str = "OPTIMIZATION RESULT ";
// Coverage counts first valid DELIVERs to correct processes, including the
// sender. Missing deliveries at the horizon do not prove a liveness violation.
// Latencies use model time and only those deliveries. Network counters include
// service traffic; duplicate/unexpected/malformed outputs count all processes.
#[derive(Debug, Deserialize, Serialize)]
struct Measurement {
scenario: String,
n: u64,
seed: u64,
status: String,
observed_until: f64,
sent: usize,
messages: u64,
bytes: u64,
max_sent: u64,
delivered: usize,
expected: usize,
latency_mean: Option<f64>,
latency_max: Option<f64>,
duplicates: usize,
unexpected: usize,
malformed: usize,
}
// A native test driver schedules external actions independently of the student's
// traffic. It has no network traffic and is excluded from all measurements.
#[derive(Clone)]
struct Workload {
action: Rc<Cell<usize>>,
crashes: bool,
}
impl Process for Workload {
fn on_local_message(&mut self, _: Message, ctx: &mut Context) -> Result<(), String> {
for i in 1..MESSAGES {
ctx.set_timer(&i.to_string(), i as f64);
}
if self.crashes {
ctx.set_timer("10", 4.5);
}
ctx.set_timer("11", HORIZON);
Ok(())
}
fn on_message(&mut self, _: Message, _: String, _: &mut Context) -> Result<(), String> {
Ok(())
}
fn on_timer(&mut self, timer: String, _: &mut Context) -> Result<(), String> {
self.action.set(timer.parse().unwrap());
Ok(())
}
}
fn send(sys: &mut System, index: usize) {
let text = format!("{index:02}:{}", "x".repeat(61)); // fixed 64-byte payload
sys.send_local_message(
"0",
Message::json("SEND", &BroadcastMessage { text: &text }),
);
}
fn measure(config: &TestConfig, scenario: &str) -> Measurement {
let mut sys = System::new(config.seed);
if scenario == "DELAYED" {
sys.network().set_delays(1., 10.);
} else {
sys.network().set_delays(1., 3.);
}
let processes: Vec<String> = (0..config.proc_count).map(|i| i.to_string()).collect();
for proc in &processes {
let implementation = config
.proc_factory
.build((proc, processes.clone()), config.seed);
sys.add_node(proc);
sys.add_process(proc, Box::new(implementation), proc);
}
let action = Rc::new(Cell::new(0));
sys.add_node("_workload");
sys.add_process(
"_workload",
Box::new(Workload {
action: action.clone(),
crashes: scenario == "CRASHES",
}),
"_workload",
);
sys.send_local_message("_workload", Message::new("START", "{}"));
send(&mut sys, 0);
let mut status = "event limit";
for _ in 0..EVENT_LIMIT {
if !sys.step() {
status = "queue exhausted";
break;
}
match action.replace(0) {
0 => {}
index @ 1..=9 => send(&mut sys, index),
10 => {
sys.crash_node("1");
sys.crash_node("2");
}
11 => {
status = "horizon";
break;
}
_ => unreachable!(),
}
}
summarize(&sys, config, scenario, status)
}
fn summarize(sys: &System, config: &TestConfig, scenario: &str, status: &str) -> Measurement {
let mut latencies = Vec::new();
let mut duplicates = 0;
let mut unexpected = 0;
let mut malformed = 0;
let mut network_sends = BTreeMap::<String, u64>::new();
let mut sent = BTreeMap::new();
let mut seen = BTreeSet::new();
// Trace order matters: a guessed DELIVER before SEND is unexpected even
// when the two events have the same model timestamp.
for entry in sys.logger().trace() {
match entry {
LogEntry::MessageSent {
src_proc, dst_proc, ..
} if src_proc != dst_proc => {
*network_sends.entry(src_proc.clone()).or_default() += 1;
}
LogEntry::LocalMessageReceived {
time, proc, msg, ..
} if proc == "0" && msg.tip == "SEND" => {
let data: serde_json::Value = serde_json::from_str(&msg.data).unwrap();
sent.insert(data["text"].as_str().unwrap().to_string(), *time);
}
LogEntry::LocalMessageSent {
time, proc, msg, ..
} => {
let data = serde_json::from_str::<serde_json::Value>(&msg.data).ok();
let text = data.as_ref().and_then(|v| v["text"].as_str());
if msg.tip != "DELIVER" || text.is_none() {
malformed += 1;
continue;
}
let text = text.unwrap();
if !seen.insert((proc.clone(), text.to_string())) {
duplicates += 1;
continue;
}
if let Some(sent_at) = sent.get(text) {
if scenario != "CRASHES" || (proc != "1" && proc != "2") {
latencies.push(time - sent_at);
}
} else {
unexpected += 1;
}
}
_ => {}
}
}
let correct_count = config.proc_count - if scenario == "CRASHES" { 2 } else { 0 };
let network = sys.network();
Measurement {
scenario: scenario.to_string(),
n: config.proc_count,
seed: config.seed,
status: status.to_string(),
observed_until: sys.time(),
sent: sent.len(),
messages: network.network_message_count(),
bytes: network.traffic(),
max_sent: network_sends.into_values().max().unwrap_or(0),
delivered: latencies.len(),
expected: MESSAGES * correct_count as usize,
latency_mean: (!latencies.is_empty())
.then(|| latencies.iter().sum::<f64>() / latencies.len() as f64),
latency_max: latencies.into_iter().reduce(f64::max),
duplicates,
unexpected,
malformed,
}
}
pub fn worker(args: &Args) {
// This process may panic, hang in Python, or print SCORE from the validator.
// The supervisor captures its output and never forwards it verbatim.
if !shared_state::validate_or_report(&args.solution_path, &["BroadcastProcess"]) {
return;
}
let factory = PyProcessFactory::new(&args.solution_path, "BroadcastProcess");
let mut rows = Vec::new();
'measurements: for scenario in SCENARIOS {
for n in SIZES {
for run in 0..args.optimization_runs {
let config = TestConfig {
proc_factory: &factory,
proc_count: n,
seed: args.seed.wrapping_add(run as u64),
monkeys: 0,
debug: false,
};
let result = measure(&config, scenario);
let incomplete = result.status == "event limit";
rows.push(result);
if incomplete {
break 'measurements;
}
}
}
}
drop(factory);
// Emit one final record only after all simulator callbacks and cleanup.
// Earlier stdout belongs to the solution and is diagnostic text, even if it
// resembles this record. This framing is not a sandbox for hostile Python.
println!("\n{RESULT_PREFIX}{}", serde_json::to_string(&rows).unwrap());
std::io::stdout().flush().unwrap();
}
struct WorkerResult {
rows: Vec<Measurement>,
problem: Option<String>,
diagnostics: Vec<String>,
}
fn read_bounded(
reader: impl Read + Send + 'static,
exceeded: Arc<AtomicBool>,
) -> thread::JoinHandle<Vec<u8>> {
thread::spawn(move || {
let mut bytes = Vec::new();
let _ = reader.take(OUTPUT_LIMIT + 1).read_to_end(&mut bytes);
if bytes.len() as u64 > OUTPUT_LIMIT {
exceeded.store(true, Ordering::Relaxed);
}
bytes
})
}
fn run_worker(args: &Args, path: &str, deadline: Instant) -> std::io::Result<WorkerResult> {
if Instant::now() >= deadline {
return Ok(WorkerResult {
rows: Vec::new(),
problem: Some("shared wall-clock budget exhausted".to_string()),
diagnostics: Vec::new(),
});
}
let mut child = Command::new(std::env::current_exe()?)
.args([
"--optimization-worker",
"-i",
path,
"-s",
&args.seed.to_string(),
"--optimization-runs",
&args.optimization_runs.to_string(),
])
.env("PYTHONUNBUFFERED", "1")
// The validator also initializes Python, so set the seed before spawn.
.env("PYTHONHASHSEED", args.seed.to_string())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let exceeded = Arc::new(AtomicBool::new(false));
let out = read_bounded(child.stdout.take().unwrap(), exceeded.clone());
let err = read_bounded(child.stderr.take().unwrap(), exceeded.clone());
let mut problem = None;
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
problem = Some(format!("worker exited with {status}"));
}
break;
}
Ok(None) => {}
Err(error) => {
problem = Some(error.to_string());
let _ = child.kill();
let _ = child.wait();
break;
}
}
if exceeded.load(Ordering::Relaxed) {
problem = Some("output limit".to_string());
let _ = child.kill();
let _ = child.wait();
break;
}
if Instant::now() >= deadline {
problem = Some(format!(
"shared wall-clock limit ({}s)",
args.optimization_timeout
));
let _ = child.kill();
let _ = child.wait();
break;
}
thread::sleep(Duration::from_millis(10));
}
let stdout = out.join().unwrap_or_default();
let stderr = err.join().unwrap_or_default();
if stdout.len() as u64 > OUTPUT_LIMIT || stderr.len() as u64 > OUTPUT_LIMIT {
problem = Some("output limit".to_string());
}
let output = String::from_utf8_lossy(&stdout);
let (diagnostic_output, result_line) = output
.strip_suffix('\n')
.unwrap_or(&output)
.rsplit_once('\n')
.unwrap_or(("", output.as_ref()));
let mut rows = Vec::new();
if problem.is_none() {
match parse_measurements(result_line.trim_end_matches('\r'), args) {
Ok(measurements) => rows = measurements,
Err(error) => problem = Some(error),
}
}
let mut diagnostics = Vec::new();
for line in diagnostic_output.lines() {
if !line.is_empty() && diagnostics.len() < 4 {
diagnostics.push(line.chars().take(240).collect());
}
}
// A missing or malformed final record is also useful diagnostic output.
if rows.is_empty() && !result_line.is_empty() && diagnostics.len() < 4 {
diagnostics.push(result_line.chars().take(240).collect());
}
for line in String::from_utf8_lossy(&stderr).lines().take(4) {
diagnostics.push(line.chars().take(240).collect());
}
Ok(WorkerResult {
rows,
problem,
diagnostics,
})
}
fn parse_measurements(line: &str, args: &Args) -> Result<Vec<Measurement>, String> {
let json = line
.strip_prefix(RESULT_PREFIX)
.ok_or("worker did not return measurements")?;
let rows: Vec<Measurement> =
serde_json::from_str(json).map_err(|e| format!("invalid measurements: {e}"))?;
let expected = SCENARIOS.iter().flat_map(|scenario| {
SIZES.iter().flat_map(move |n| {
(0..args.optimization_runs)
.map(move |run| (*scenario, *n, args.seed.wrapping_add(run as u64)))
})
});
let total = SCENARIOS.len() * SIZES.len() * args.optimization_runs as usize;
if rows.is_empty()
|| rows.len() > total
|| rows.iter().zip(expected).any(|(row, (scenario, n, seed))| {
row.scenario != scenario || row.n != n || row.seed != seed
})
|| rows[..rows.len() - 1]
.iter()
.any(|row| row.status == "event limit")
|| (rows.len() < total && rows.last().unwrap().status != "event limit")
{
return Err("worker returned an incomplete or unexpected measurement sequence".to_string());
}
Ok(rows)
}
fn optimized_path(args: &Args) -> PathBuf {
args.optimized_impl
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(&args.solution_path).with_file_name("broadcast_scalable.py")
})
}
pub fn run_if_present(args: &Args, score: f32) {
if args.optimized_impl.is_some() || optimized_path(args).exists() {
if score == 7. {
run(args);
} else {
println!("SKIPPED: OPTIMIZATION (requires main SCORE: 7)");
}
}
}
fn cell(row: Option<&Measurement>) -> String {
let Some(r) = row else {
return "NOT MEASURED".to_string();
};
let latency = |value: Option<f64>| {
value
.map(|v| format!("{v:.3}"))
.unwrap_or_else(|| "n/a".to_string())
};
format!(
"{} {} {} {}/{} {} {} {} {} {} [{}; t={:.3}; sent={}]",
r.messages,
r.bytes,
r.max_sent,
r.delivered,
r.expected,
latency(r.latency_mean),
latency(r.latency_max),
r.duplicates,
r.unexpected,
r.malformed,
if r.status == "event limit" {
"INCOMPLETE: event limit"
} else {
&r.status
},
r.observed_until,
r.sent
)
}
pub fn run(args: &Args) {
println!("\n--- OPTIMIZATION ---\n");
let optimized = optimized_path(args);
if !optimized.is_file() {
println!(
"SKIPPED: scalable implementation not found: {}",
optimized.display()
);
return;
}
// Both implementations receive the same external workload: ten unique
// 64-byte messages from process 0 at t=0..9, observed until HORIZON.
// Run indices start at zero; internal traffic depends on the implementation.
// Measurements have no performance threshold or automatic optimization score.
// Event/time/output limits mark incomplete measurements without changing SCORE.
println!("Workload: N={SIZES:?}, messages={MESSAGES} x 64B, sender=0, send_t=0..9, horizon={HORIZON}");
println!("Delays: NORMAL/CRASHES=[1,3], DELAYED=[1,10]; CRASHES: [1,2] at t=4.5");
println!("Runs={}, base_seed={}; limits: {EVENT_LIMIT} events/case, {}s/comparison, {OUTPUT_LIMIT} bytes/stream", args.optimization_runs, args.seed, args.optimization_timeout);
let deadline = Instant::now() + Duration::from_secs(args.optimization_timeout);
let mut results = Vec::new();
for (label, path) in [
("BASE", args.solution_path.as_str()),
("SCALABLE", optimized.to_str().unwrap_or("")),
] {
if label == "SCALABLE"
&& results.first().is_some_and(|r: &WorkerResult| {
r.problem.is_some() || r.rows.iter().any(|row| row.status == "event limit")
})
{
println!("SKIPPED: SCALABLE (BASE measurement incomplete)");
results.push(WorkerResult {
rows: Vec::new(),
problem: None,
diagnostics: Vec::new(),
});
continue;
}
println!("Measuring {label}: {path}");
let result = run_worker(args, path, deadline).unwrap_or_else(|e| WorkerResult {
rows: Vec::new(),
problem: Some(e.to_string()),
diagnostics: Vec::new(),
});
if let Some(problem) = &result.problem {
println!("{label}: INCOMPLETE: {problem}");
}
for line in &result.diagnostics {
println!("{label} diagnostic: {line}");
}
results.push(result);
}
println!("scenario N seed | BASE: messages bytes max_sent delivered/expected latency_mean latency_max dup unexpected malformed | SCALABLE: same columns");
for scenario in SCENARIOS {
for n in SIZES {
for run in 0..args.optimization_runs {
let seed = args.seed.wrapping_add(run as u64);
let find = |side: usize| {
results[side]
.rows
.iter()
.find(|r| r.scenario == scenario && r.n == n && r.seed == seed)
};
println!(
"{scenario} {n} {seed} | {} | {}",
cell(find(0)),
cell(find(1))
);
}
}
}
}
-336
View File
@@ -1,336 +0,0 @@
//! Run each correctness test in a killable process. Student callbacks can block
//! inside Python, so an in-simulator deadline alone cannot enforce wall time.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anysystem::python::PyProcessFactory;
use anysystem::test::TestResult;
use env_logger::Builder;
use indexmap::IndexMap;
use log::LevelFilter;
use crate::common::TestConfig;
use crate::tests::*;
use crate::tests_mc::*;
use crate::Args;
struct Test {
name: &'static str,
run: fn(&TestConfig) -> TestResult,
}
const TESTS: &[Test] = &[
Test {
name: "NORMAL",
run: test_normal,
},
Test {
name: "SENDER CRASH",
run: test_sender_crash,
},
Test {
name: "SENDER CRASH 2",
run: test_sender_crash2,
},
Test {
name: "TWO CRASHES",
run: test_two_crashes,
},
Test {
name: "TWO CRASHES 2",
run: test_two_crashes2,
},
Test {
name: "TWO CRASHES AFTER DELIVERY",
run: test_two_crashes_after_delivery,
},
Test {
name: "CAUSAL ORDER",
run: test_causal_order,
},
Test {
name: "QUORUM",
run: test_quorum,
},
Test {
name: "CHAOS MONKEY",
run: test_chaos_monkey,
},
Test {
name: "SCALABILITY",
run: test_scalability,
},
Test {
name: "MODEL CHECKING NORMAL DELIVERY",
run: test_mc_normal_delivery,
},
Test {
name: "MODEL CHECKING SENDER CRASH",
run: test_mc_sender_crash,
},
Test {
name: "MODEL CHECKING CAUSAL ORDER",
run: test_mc_causal_order,
},
];
pub fn worker(args: &Args) {
let test = TESTS
.iter()
.find(|test| Some(test.name) == args.test_worker.as_deref())
.unwrap();
if args.debug {
Builder::new()
.filter(Some("anysystem"), LevelFilter::Debug)
.format(|buf, record| writeln!(buf, "{}", record.args()))
.init();
}
let factory = PyProcessFactory::new(&args.solution_path, "BroadcastProcess");
let config = TestConfig {
proc_factory: &factory,
proc_count: if test.name.starts_with("MODEL CHECKING") {
3
} else {
args.proc_count
},
seed: args.seed,
monkeys: args.monkeys,
debug: args.debug,
};
let result = (test.run)(&config);
// Keep the structured result separate from student prints and debug traces.
fs::write(
args.test_result.as_ref().unwrap(),
serde_json::to_vec(&result).unwrap(),
)
.unwrap();
}
struct ResultFile(PathBuf);
impl ResultFile {
fn new() -> std::io::Result<Self> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"broadcast-test-{}-{nonce}.json",
std::process::id()
));
OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)?;
Ok(Self(path))
}
}
impl Drop for ResultFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
fn run_one(args: &Args, test: &Test, suite_deadline: Instant) -> TestResult {
let seconds = if test.name == "CHAOS MONKEY" {
args.chaos_timeout
} else if test.name == "SCALABILITY" {
args.scalability_timeout
} else if test.name.starts_with("MODEL CHECKING") {
// The process deadline also catches a callback hanging inside the MC.
args.mc_timeout
.min(if test.name == "MODEL CHECKING NORMAL DELIVERY" {
110
} else if test.name == "MODEL CHECKING CAUSAL ORDER" {
60
} else {
210
})
} else {
args.test_timeout
};
let test_deadline = Instant::now() + Duration::from_secs(seconds);
let deadline = test_deadline.min(suite_deadline);
let timeout = if suite_deadline <= test_deadline {
format!("suite time limit of {}s exceeded", args.suite_timeout)
} else {
format!("test time limit of {seconds}s exceeded")
};
if Instant::now() >= deadline {
return Err(timeout);
}
let result_file = ResultFile::new().map_err(|e| format!("test worker error: {e}"))?;
let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?);
command
.args([
"--test-worker",
test.name,
"-i",
&args.solution_path,
"-s",
&args.seed.to_string(),
"-p",
&args.proc_count.to_string(),
"-m",
&args.monkeys.to_string(),
"--test-result",
])
.arg(&result_file.0)
.env("PYTHONUNBUFFERED", "1")
// Python must see the seed before interpreter initialization.
.env("PYTHONHASHSEED", args.seed.to_string());
if args.debug {
command.arg("-d");
}
let mut child = command
.spawn()
.map_err(|e| format!("test worker error: {e}"))?;
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
return Err(format!("test worker exited with {status}"));
}
return serde_json::from_slice(
&fs::read(&result_file.0).map_err(|e| format!("test worker result: {e}"))?,
)
.map_err(|e| format!("test worker result: {e}"))?;
}
Ok(None) => {}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("test worker error: {error}"));
}
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(timeout);
}
thread::sleep(Duration::from_millis(10));
}
}
fn must_stop(result: &TestResult, keep_going: bool) -> bool {
let Err(error) = result else {
return false;
};
// Resource exhaustion and process failures always stop, including in local
// diagnostic mode. A pure causal violation must not hide a later base error.
if error.contains("time limit")
|| error.starts_with("causal model-checking ")
|| error.starts_with("test worker")
{
return true;
}
if keep_going {
return false;
}
!error.strip_prefix("Violated ").is_some_and(|properties| {
properties
.split(", ")
.all(|property| property == "CAUSAL ORDER")
})
}
pub(crate) fn causal_mc_incomplete(name: &str, error: &str) -> bool {
name == "MODEL CHECKING CAUSAL ORDER"
&& (error.contains("time limit") || error.starts_with("causal model-checking "))
}
pub fn run(args: &Args) -> IndexMap<String, TestResult> {
let tests: Vec<_> = TESTS
.iter()
.filter(|test| {
if let Some(name) = &args.test {
test.name == name
} else {
!args.disable_mc_tests || !test.name.starts_with("MODEL CHECKING")
}
})
.collect();
let deadline = Instant::now() + Duration::from_secs(args.suite_timeout);
let mut results = IndexMap::new();
let mut stopped = None;
let mut passed = 0;
let mut causal_violated = false;
for test in &tests {
if let Some(failed_test) = stopped {
println!("SKIPPED: {} (stopped after {failed_test})", test.name);
continue;
}
if test.name == "MODEL CHECKING CAUSAL ORDER" && causal_violated && !args.keep_going {
println!("SKIPPED: {} (CAUSAL ORDER already violated)", test.name);
continue;
}
println!("\n--- {} ---\n", test.name);
std::io::stdout().flush().unwrap();
let result = run_one(args, test, deadline);
match &result {
Ok(_) => {
println!("\nPASSED\n");
passed += 1;
}
Err(error) => {
causal_violated |= error.strip_prefix("Violated ").is_some_and(|properties| {
properties
.split(", ")
.any(|property| property == "CAUSAL ORDER")
});
println!("\nFAILED: {error}");
if causal_mc_incomplete(test.name, error) {
println!("INCOMPLETE: causal model checking did not finish within its resource budget");
}
println!();
}
}
if must_stop(&result, args.keep_going) {
stopped = Some(test.name);
}
results.insert(test.name.to_string(), result);
}
if let Some(name) = &args.test {
if tests.is_empty() {
println!("FAILED: unknown test {name}");
}
} else {
println!("-------------------------------------------------------------------------------");
println!("\nPassed {passed} from {} tests\n", tests.len());
if results.len() != tests.len() {
println!("Skipped {} tests\n", tests.len() - results.len());
}
if results.values().any(Result::is_err) {
println!("Failed tests:");
for (name, result) in &results {
if let Err(error) = result {
println!("- {name}: {error}");
}
}
println!();
}
}
results
}
#[cfg(test)]
mod tests {
use super::must_stop;
#[test]
fn mc_timeout_always_stops_but_causal_violation_does_not() {
assert!(must_stop(&Err("time limit of 100s exceeded".into()), false));
assert!(must_stop(&Err("time limit of 100s exceeded".into()), true));
assert!(!must_stop(&Err("Violated CAUSAL ORDER".into()), false));
assert!(must_stop(
&Err("Violated VALIDITY, CAUSAL ORDER".into()),
false
));
assert!(!must_stop(&Err("Violated VALIDITY".into()), true));
}
}
@@ -1,78 +0,0 @@
use std::ffi::CString;
use pyo3::prelude::*;
use pyo3::types::{PyList, PyModule};
const VALIDATOR_CODE: &str = include_str!("../shared_state_validator.py");
fn validator_module<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyModule>> {
let code = CString::new(VALIDATOR_CODE).expect("validator source contains a null byte");
PyModule::from_code(
py,
&code,
c"shared_state_validator.py",
c"shared_state_validator",
)
}
#[cfg(test)]
fn validate_source(
source: &str,
filename: &str,
class_names: &[&str],
) -> Result<Vec<String>, String> {
Python::attach(|py| {
let module = validator_module(py).map_err(|error| error.to_string())?;
let class_names = PyList::new(py, class_names).map_err(|error| error.to_string())?;
module
.getattr("validate_source")
.and_then(|function| function.call1((source, filename, class_names)))
.and_then(|result| result.extract::<Vec<String>>())
.map_err(|error| error.to_string())
})
}
fn validate_solution(path: &str, class_names: &[&str]) -> Result<Vec<String>, String> {
Python::attach(|py| {
let module = validator_module(py).map_err(|error| error.to_string())?;
let class_names = PyList::new(py, class_names).map_err(|error| error.to_string())?;
module
.getattr("validate_solution")
.and_then(|function| function.call1((path, class_names)))
.and_then(|result| result.extract::<Vec<String>>())
.map_err(|error| error.to_string())
})
}
pub fn validate_or_report(path: &str, class_names: &[&str]) -> bool {
match validate_solution(path, class_names) {
Ok(violations) if violations.is_empty() => true,
Ok(violations) => {
println!("ERROR: shared state between AnySystem processes is forbidden");
for violation in violations {
println!("{violation}");
}
println!("\nSCORE: 0\n");
false
}
Err(error) => panic!("failed to validate solution for shared state: {error}"),
}
}
#[cfg(test)]
mod tests {
use super::validate_source;
#[test]
fn embeds_validator_and_rejects_shared_state() {
let violations = validate_source(
"class ProcessImpl:\n shared = {}\n",
"solution.py",
&["ProcessImpl"],
)
.unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("ProcessImpl.shared"));
}
}
-495
View File
@@ -1,495 +0,0 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
use rand::prelude::*;
use rand_pcg::Pcg64;
use serde_json::Value;
use anysystem::test::TestResult;
use anysystem::{Message, ProcessEvent, System};
use crate::common::{build_system, BroadcastMessage, TestConfig};
pub fn test_normal(config: &TestConfig) -> TestResult {
let mut sys = build_system(config);
let text = "0:Hello";
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
sys.step_until_no_events();
check(&sys, config, HashSet::from([text.to_string()]))
}
pub fn test_sender_crash(config: &TestConfig) -> TestResult {
println!("Process 0 will crash after another process receives a network message.");
let mut sys = build_system(config);
let text = "0:Hello";
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
// run until the message is received by one other process
let mut received = false;
while !received {
if !sys.step() {
return Err("no events left before another process received the message".to_string());
}
for n in 1..config.proc_count {
if sys.received_message_count(&n.to_string()) == 1 {
received = true;
break;
}
}
}
// crash source node
sys.crash_node("0");
sys.step_until_no_events();
check(&sys, config, HashSet::from([text.to_string()]))
}
pub fn test_sender_crash2(config: &TestConfig) -> TestResult {
println!("Process 0 is isolated and will crash; its messages cannot reach other processes.");
let mut sys = build_system(config);
let text = "0:Hello";
// let the message to be received only by the sender itself by disconnecting it
sys.network().disconnect_node("0");
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
sys.step();
sys.crash_node("0");
sys.step_until_no_events();
check(&sys, config, HashSet::from([text.to_string()]))
}
pub fn test_two_crashes(config: &TestConfig) -> TestResult {
if config.proc_count < 5 {
return Err(
"There are 2 crashes in this test, so at least 5 processes are required.".to_string(),
);
};
let mut sys = build_system(config);
let survivors: Vec<String> = (2..config.proc_count).map(|n| n.to_string()).collect();
println!(
"Processes [0, 1] are isolated from [{}] and will crash; links within each group remain enabled.",
survivors.join(", ")
);
sys.network().make_partition(
&["0", "1"],
&survivors.iter().map(String::as_str).collect::<Vec<_>>(),
);
let text = "0:Hello";
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
sys.steps(config.proc_count.pow(2));
sys.crash_node("0");
sys.crash_node("1");
sys.step_until_no_events();
check(&sys, config, HashSet::from([text.to_string()]))
}
pub fn test_two_crashes2(config: &TestConfig) -> TestResult {
if config.proc_count < 5 {
return Err(
"There are 2 crashes in this test, so at least 5 processes are required.".to_string(),
);
};
let mut sys = build_system(config);
println!(
"Outgoing messages from processes [1, 2] are dropped; both processes will later crash."
);
// Model messages that remain undelivered until their senders crash.
sys.network().drop_outgoing("1");
sys.network().drop_outgoing("2");
let text = "0:Hello";
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
sys.steps(config.proc_count.pow(2));
sys.crash_node("1");
sys.crash_node("2");
sys.step_until_no_events();
check(&sys, config, HashSet::from([text.to_string()]))
}
pub fn test_two_crashes_after_delivery(config: &TestConfig) -> TestResult {
if config.proc_count < 5 {
return Err(
"There are 2 crashes in this test, so at least 5 processes are required.".to_string(),
);
}
println!("Process 0 and the first other process to deliver to its local user will crash immediately after that delivery.");
let mut sys = build_system(config);
let text = "0:Hello";
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
// Crash the sender and the first other process to deliver to its user.
// Uniform agreement must still hold even though that recipient has crashed.
while sys.step() {
let recipient = (1..config.proc_count)
.map(|n| n.to_string())
.find(|proc| !sys.local_outbox(proc).is_empty());
if let Some(recipient) = recipient {
println!("Process {recipient} delivered to its local user; crashing processes [0, {recipient}].");
sys.crash_node("0");
sys.crash_node(&recipient);
break;
}
}
sys.step_until_no_events();
check(&sys, config, HashSet::from([text.to_string()]))
}
pub fn test_quorum(config: &TestConfig) -> TestResult {
for node_count in [7, 10] {
let mut run_config = *config;
run_config.proc_count = node_count;
let failures = (node_count - 1) / 2;
let text = "0:Hello";
println!("- N={node_count}: {failures} isolated processes crash");
let mut sys = build_system(&run_config);
let minority: Vec<String> = (0..failures).map(|n| n.to_string()).collect();
let majority: Vec<String> = (failures..node_count).map(|n| n.to_string()).collect();
println!(
"Processes [{}] are isolated from [{}] and will crash; links within each group remain enabled.",
minority.join(", "),
majority.join(", ")
);
sys.network().make_partition(
&minority.iter().map(String::as_str).collect::<Vec<_>>(),
&majority.iter().map(String::as_str).collect::<Vec<_>>(),
);
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
sys.step_until_no_events();
for n in 0..failures {
sys.crash_node(&n.to_string());
}
sys.step_until_no_events();
check(&sys, &run_config, HashSet::from([text.to_string()]))?;
println!("- N={node_count}: {failures} processes crash before broadcast");
println!(
"Processes [{}] will crash; the remaining majority must deliver to their local users.",
((node_count - failures)..node_count)
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(", ")
);
let mut sys = build_system(&run_config);
// The surviving majority, including the sender, must complete delivery.
for n in (node_count - failures)..node_count {
sys.crash_node(&n.to_string());
}
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
sys.step_until_no_events();
check(&sys, &run_config, HashSet::from([text.to_string()]))?;
}
Ok(true)
}
pub fn test_causal_order(config: &TestConfig) -> TestResult {
let mut sys = build_system(config);
sys.network().set_delays(100., 200.);
let texts = ["0:Hello", "1:How?", "0:Fine!"];
sys.send_local_message(
"0",
Message::json("SEND", &BroadcastMessage { text: texts[0] }),
);
while sys.event_log("1").is_empty() {
if !sys.step() {
return Err("no events left before the second SEND".to_string());
}
}
sys.network().set_delays(10., 20.);
sys.send_local_message(
"1",
Message::json("SEND", &BroadcastMessage { text: texts[1] }),
);
while sys.event_log("0").len() < 3 {
if !sys.step() {
return Err("no events left before the third SEND".to_string());
}
}
sys.network().set_delay(1.);
sys.send_local_message(
"0",
Message::json("SEND", &BroadcastMessage { text: texts[2] }),
);
sys.step_until_no_events();
let sent_messages = HashSet::from_iter(texts.into_iter().map(String::from));
check(&sys, config, sent_messages)
}
pub fn test_chaos_monkey(config: &TestConfig) -> TestResult {
if config.proc_count < 5 {
return Err(
"There are 2 crashes in this test, so at least 5 processes are required.".to_string(),
);
};
let mut rand = Pcg64::seed_from_u64(config.seed);
println!("Only outgoing messages from processes that will later crash may be dropped.");
for i in 1..=config.monkeys {
// Preserve the random sequence: this draw was previously printed as an unused seed.
let _ = rand.next_u64();
let mut sys = build_system(config);
let victim1 = format!("{}", rand.gen_range(0..config.proc_count));
let mut victim2 = format!("{}", rand.gen_range(0..config.proc_count));
while victim2 == victim1 {
victim2 = format!("{}", rand.gen_range(0..config.proc_count));
}
println!(
"- Run {i} (base seed: {}; processes that will crash: [{victim1}, {victim2}])",
config.seed
);
let mut sent_messages = HashSet::new();
for i in 0..10 {
let sender = format!("{}", rand.gen_range(0..config.proc_count));
let text = format!("{sender}:{i}");
sent_messages.insert(text.clone());
if i % 2 == 0 {
sys.network().set_delays(10., 20.);
} else {
sys.network().set_delays(1., 2.);
}
for j in 0..8 {
if rand.gen_range(0.0..1.0) > 0.3 {
sys.network().drop_outgoing(&victim1);
} else {
sys.network().pass_outgoing(&victim1);
}
if rand.gen_range(0.0..1.0) > 0.3 {
sys.network().drop_outgoing(&victim2);
} else {
sys.network().pass_outgoing(&victim2);
}
if j == 0 {
sys.send_local_message(
&sender,
Message::json("SEND", &BroadcastMessage { text: &text }),
);
} else {
sys.step();
}
}
}
sys.crash_node(&victim1);
sys.crash_node(&victim2);
sys.step_until_no_events();
check(&sys, config, sent_messages)?;
}
Ok(true)
}
pub fn test_scalability(config: &TestConfig) -> TestResult {
let sys_sizes = [
config.proc_count,
config.proc_count * 2,
config.proc_count * 4,
config.proc_count * 10,
];
let mut msg_counts = Vec::new();
for node_count in sys_sizes {
let mut run_config = *config;
run_config.proc_count = node_count;
let mut sys = build_system(&run_config);
let text = "0:Hello!";
sys.send_local_message("0", Message::json("SEND", &BroadcastMessage { text }));
sys.step_until_no_events();
check(&sys, &run_config, HashSet::from([text.to_string()]))?;
msg_counts.push(sys.network().network_message_count());
}
println!("\nMessage count:");
for i in 0..sys_sizes.len() {
let baseline = sys_sizes[i] * (sys_sizes[i] - 1);
println!(
"- N={}: {} (baseline {})",
sys_sizes[i], msg_counts[i], baseline
);
}
Ok(true)
}
fn check(sys: &System, config: &TestConfig, all_sent_messages: HashSet<String>) -> TestResult {
let mut sent = BTreeMap::new();
let mut delivered = BTreeMap::new();
let mut all_delivered = BTreeSet::new();
let mut histories = BTreeMap::new();
let mut debug_histories = BTreeMap::new();
for proc in sys.process_names() {
let mut history = Vec::new();
let mut debug_history = Vec::new();
let mut sent_msgs = Vec::new();
let mut delivered_msgs = Vec::new();
for e in sys.event_log(&proc) {
match e.event {
ProcessEvent::LocalMessageReceived { msg: m } => {
let data: Value = serde_json::from_str(&m.data).unwrap();
let message = data["text"].as_str().unwrap().to_string();
sent_msgs.push(message.clone());
if config.debug {
debug_history.push(format!("SEND {message:?}"));
}
history.push(message);
}
ProcessEvent::LocalMessageSent { msg: m } => {
let data: Value = serde_json::from_str(&m.data).unwrap();
let message = data["text"].as_str().unwrap().to_string();
delivered_msgs.push(message.clone());
all_delivered.insert(message.clone());
if config.debug {
debug_history.push(format!("DELIVER {message:?}"));
}
history.push(message);
}
_ => {}
}
}
sent.insert(proc.clone(), sent_msgs);
delivered.insert(proc.clone(), delivered_msgs);
debug_histories.insert(proc.clone(), debug_history);
histories.insert(proc, history);
}
if config.debug {
println!(
"Messages sent across network: {}",
sys.network().network_message_count()
);
println!("Process histories:");
for proc in sys.process_names() {
println!(
"- [{}] {}",
proc,
debug_histories.get(&proc).unwrap().join(", ")
);
}
}
// NO DUPLICATION
let mut no_duplication = true;
for (proc, delivered_msgs) in &delivered {
let mut uniq = HashSet::new();
for msg in delivered_msgs {
if uniq.contains(msg) {
println!("NO DUPLICATION: process {proc} delivered message {msg:?} to its local user more than once.");
no_duplication = false;
};
uniq.insert(msg);
}
}
// NO CREATION
let mut no_creation = true;
for (proc, delivered_msgs) in &delivered {
for msg in delivered_msgs {
if !all_sent_messages.contains(msg) {
println!("NO CREATION: process {proc} delivered message {msg:?} to its local user, but no user sent it.");
no_creation = false;
}
}
}
// VALIDITY
let mut validity = true;
for (proc, sent_msgs) in &sent {
if sys.proc_node_is_crashed(proc) {
continue;
}
let delivered_msgs = delivered.get(proc).unwrap();
for msg in sent_msgs {
if !delivered_msgs.contains(msg) {
println!("VALIDITY: correct process {proc} received SEND {msg:?} from its local user, but did not deliver it back with DELIVER.");
validity = false;
}
}
}
// UNIFORM AGREEMENT
let mut uniform_agreement = true;
for msg in all_delivered.iter() {
let mut delivered_by = Vec::new();
let mut missing = Vec::new();
for (proc, delivered_msgs) in &delivered {
if delivered_msgs.contains(msg) {
delivered_by.push(if sys.proc_node_is_crashed(proc) {
format!("{proc} (later crashed)")
} else {
proc.clone()
});
} else if !sys.proc_node_is_crashed(proc) {
missing.push(proc.clone());
}
}
if !missing.is_empty() {
println!("UNIFORM AGREEMENT violated for message {msg:?}:");
println!(
" Delivered to local users by processes: {}.",
delivered_by.join(", ")
);
println!(
" Not delivered by correct processes: {}.",
missing.join(", ")
);
uniform_agreement = false;
}
}
// CAUSAL ORDER
let mut causal_order = true;
for (src, sent_msgs) in &sent {
for msg in sent_msgs.iter() {
if !all_delivered.contains(msg) {
continue;
}
// build sender past for send message event
let mut src_past = HashSet::new();
for e in histories.get(src).unwrap() {
if e != msg {
src_past.insert(e.clone());
} else {
break;
}
}
// check that other correct processes have delivered all past events before delivering the message
for (dst, delivered_msgs) in &delivered {
if sys.proc_node_is_crashed(dst) {
continue;
}
let mut dst_past = HashSet::new();
for e in delivered_msgs {
if e != msg {
dst_past.insert(e.clone());
} else {
break;
}
}
if !dst_past.is_superset(&src_past) {
let mut missing = src_past
.difference(&dst_past)
.cloned()
.collect::<Vec<String>>();
missing.sort();
println!(
"CAUSAL ORDER: process {} did not deliver messages {:?} before message {:?} from process {}.",
dst,
missing,
msg,
src
);
causal_order = false;
}
}
}
}
if no_duplication & no_creation & validity & uniform_agreement & causal_order {
Ok(true)
} else {
let mut violated = Vec::new();
if !no_duplication {
violated.push("NO DUPLICATION")
}
if !no_creation {
violated.push("NO CREATION")
}
if !validity {
violated.push("VALIDITY")
}
if !uniform_agreement {
violated.push("UNIFORM AGREEMENT")
}
if !causal_order {
violated.push("CAUSAL ORDER")
}
Err(format!("Violated {}", violated.join(", ")))
}
}
-289
View File
@@ -1,289 +0,0 @@
use std::cell::Cell;
use std::collections::HashSet;
use std::rc::Rc;
use std::time::Duration;
use serde_json::Value;
use sugars::boxed;
use anysystem::logger::LogEntry;
use anysystem::test::TestResult;
use anysystem::Message;
use anysystem::mc::predicates::{collects, goals, invariants, prunes};
use anysystem::mc::strategies::Bfs;
use anysystem::mc::strategy::{GoalFn, InvariantFn, PruneFn, StrategyConfig};
use anysystem::mc::{McState, ModelChecker};
use crate::common::{build_system, BroadcastMessage, TestConfig};
// The causal test explores two broadcasts on three processes. The state budget
// is checked as an invariant, so exhausting it cannot silently pass a pruned search.
const CAUSAL_MC_STATE_LIMIT: usize = 100_000;
pub fn test_mc_normal_delivery(config: &TestConfig) -> TestResult {
let mut sys = build_system(config);
let proc_names = sys.process_names();
let text = "0:Hello";
sys.send_local_message(
proc_names[0].as_str(),
Message::json("SEND", &BroadcastMessage { text }),
);
let goal = goals::all_goals(
proc_names
.iter()
.map(|name| goals::got_n_local_messages(name, name, 1))
.collect::<Vec<GoalFn>>(),
);
let strategy_config = StrategyConfig::default()
.goal(goal)
.prune(prunes::any_prune(vec![
prunes::state_depth(10),
mc_prune_proc_permutations(&proc_names[1..]),
// Prune states with more than 2 messages received from any process
mc_prune_msg_per_proc_limit(&proc_names, 2),
]))
.invariant(invariants::all_invariants(vec![
mc_invariant(proc_names.clone(), text.to_string()),
invariants::time_limit(Duration::from_secs(100)),
]));
let mut mc = ModelChecker::new(&sys);
let res = mc.run::<Bfs>(strategy_config);
if let Err(err) = res {
err.print_trace();
Err(err.message())
} else {
Ok(true)
}
}
pub fn test_mc_sender_crash(config: &TestConfig) -> TestResult {
let mut sys = build_system(config);
let proc_names = sys.process_names();
let text = "0:Hello";
sys.send_local_message(
proc_names[0].as_str(),
Message::json("SEND", &BroadcastMessage { text }),
);
let goal = goals::all_goals(
proc_names
.iter()
.map(|name| goals::got_n_local_messages(name, name, 1))
.collect::<Vec<GoalFn>>(),
);
let strategy_config = StrategyConfig::default()
.prune(prunes::any_prune(vec![
prunes::state_depth(4),
mc_prune_proc_permutations(&proc_names[1..]),
]))
.goal(goal)
.invariant(invariants::all_invariants(vec![
mc_invariant(proc_names.clone(), text.to_string()),
invariants::time_limit(Duration::from_secs(100)),
]))
.collect(collects::any_collect(
proc_names[1..]
.iter()
.map(|proc| collects::got_n_local_messages(proc, proc, 1))
.collect(),
));
let mut mc = ModelChecker::new(&sys);
let res = mc.run::<Bfs>(strategy_config);
let intermediate_states = res
.map_err(|err| {
err.print_trace();
err.message()
})?
.collected_states;
if intermediate_states.is_empty() {
return Err("no states collected after first stage".to_string());
}
// Crash first node in the list
let left_proc_names = proc_names[1..].to_vec();
let goal = goals::all_goals(
left_proc_names
.iter()
.map(|name| goals::got_n_local_messages(name, name, 1))
.collect::<Vec<GoalFn>>(),
);
let strategy_config = StrategyConfig::default()
.goal(goal)
.invariant(invariants::all_invariants(vec![
mc_invariant(left_proc_names, text.to_string()),
invariants::time_limit(Duration::from_secs(100)),
]))
.prune(prunes::any_prune(vec![
prunes::state_depth(6),
mc_prune_proc_permutations(&proc_names[1..]),
// Prune states with more than 4 messages received from any process
mc_prune_msg_per_proc_limit(&proc_names, 4),
]));
let res = mc.run_from_states_with_change::<Bfs>(strategy_config, intermediate_states, |sys| {
sys.crash_node(proc_names[0].clone());
});
if let Err(err) = res {
err.print_trace();
Err(err.message())
} else {
Ok(true)
}
}
// Send "a" from P2, then send "b" from P1 after P1 delivers "a",
// which gives the causal order "a" -> "b", then check that all processes
// deliver both messages and deliver "a" before "b".
pub fn test_mc_causal_order(config: &TestConfig) -> TestResult {
let mut sys = build_system(config);
sys.send_local_message("2", Message::json("SEND", &BroadcastMessage { text: "a" }));
let proc_names = sys.process_names();
let report_error = |err: anysystem::mc::McError| {
err.print_trace();
err.message()
};
let checked = Rc::new(Cell::new(0));
let make_strategy = |texts: Vec<&'static str>| {
let goal_procs = proc_names.clone();
let invariant_procs = proc_names.clone();
let checked = checked.clone();
StrategyConfig::default()
// Stop this branch when everyone has delivered the messages
// sent in this stage. If events end before that, the test fails.
.goal(boxed!(move |state: &McState| {
goal_procs
.iter()
.all(|proc| {
texts
.iter()
.all(|text| mc_delivery_index(state, proc, text).is_some())
})
.then(|| "all messages have been delivered by every process".to_string())
}))
// Since P1 sends "b" after it delivers "a",
// every process must also deliver "a" before "b".
.invariant(invariants::all_invariants(vec![
boxed!(move |state: &McState| {
for proc in &invariant_procs {
if let Some(b) = mc_delivery_index(state, proc, "b") {
if !mc_delivery_index(state, proc, "a").is_some_and(|a| a < b) {
return Err("Violated CAUSAL ORDER".to_string());
}
}
}
Ok(())
}),
boxed!(move |_: &McState| {
let next = checked.get() + 1;
checked.set(next);
if next > CAUSAL_MC_STATE_LIMIT {
Err(format!(
"causal model-checking state limit of {CAUSAL_MC_STATE_LIMIT} exceeded"
))
} else {
Ok(())
}
}),
invariants::time_limit(Duration::from_secs(55)),
]))
};
let mut mc = ModelChecker::new(&sys);
// Explore all schedules of "a" before P1 sends "b". The first stage
// checks delivery of "a" on every branch and retains every state where
// P1 can send "b" after delivering "a".
let states = mc
.run::<Bfs>(make_strategy(vec!["a"]).collect(boxed!(|state: &McState| {
mc_delivery_index(state, "1", "a").is_some()
})))
.map_err(&report_error)?
.collected_states;
if states.is_empty() {
return Err("No state with delivery of a on process 1 was reached".to_string());
}
// Explore every continuation after sending "b" from each collected state.
// Resource exhaustion is reported as incomplete rather than pruning paths.
mc.run_from_states_with_change::<Bfs>(make_strategy(vec!["a", "b"]), states, |sys| {
sys.send_local_message(
"1",
"1",
Message::json("SEND", &BroadcastMessage { text: "b" }),
);
})
.map(|_| true)
.map_err(report_error)
}
fn mc_delivery_index(state: &McState, proc: &str, text: &str) -> Option<usize> {
state.node_states[proc].proc_states[proc]
.local_outbox
.iter()
.position(|msg| {
msg.tip == "DELIVER"
&& serde_json::from_str::<Value>(&msg.data).unwrap()["text"] == text
})
}
fn mc_prune_proc_permutations(equivalent_procs: &[String]) -> PruneFn {
let equivalent_procs = equivalent_procs.to_vec();
boxed!(move |state| {
let proc_names = HashSet::<String>::from_iter(equivalent_procs.clone().into_iter());
let mut used_proc_names = HashSet::<String>::new();
let mut waiting_for_proc = 0;
for entry in &state.trace {
match entry {
LogEntry::McMessageReceived { src: proc, .. }
| LogEntry::McTimerFired { proc, .. } => {
if used_proc_names.contains(proc) || !proc_names.contains(proc) {
continue;
}
if equivalent_procs[waiting_for_proc] != *proc {
return Some(
"state is the same as another state with renumerated processes"
.to_owned(),
);
}
used_proc_names.insert(proc.clone());
waiting_for_proc += 1;
}
_ => {}
}
}
None
})
}
fn mc_prune_msg_per_proc_limit(proc_names: &[String], limit: usize) -> PruneFn {
prunes::events_limit_per_proc(
|entry: &LogEntry, proc: &String| match entry {
LogEntry::McMessageReceived { src, .. } => src == proc,
_ => false,
},
proc_names.to_owned(),
limit,
)
}
fn mc_invariant(proc_names: Vec<String>, sent_message: String) -> InvariantFn {
boxed!(move |state: &McState| {
for name in &proc_names {
let outbox = &state.node_states[name].proc_states[name].local_outbox;
let mut message_data = HashSet::new();
for message in outbox {
let data: Value = serde_json::from_str(&message.data).unwrap();
let message = data["text"].as_str().unwrap().to_string();
if message_data.contains(&message) {
return Err("Violated NO DUPLICATION".to_owned());
}
message_data.insert(message.clone());
if message != sent_message {
return Err("Violated NO CREATION".to_owned());
}
}
}
Ok(())
})
}
-5
View File
@@ -1,5 +0,0 @@
target
**/target
**/__pycache__
**/.pytest_cache
*.pyc
@@ -1,48 +0,0 @@
name: distsys-mq
services:
rabbitmq:
image: rabbitmq:4.3.6-management-alpine
volumes:
- ./tests/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
ports:
- 15672:15672
- 5672:5672
server:
image: distsys-mq-server
build:
context: ./solution/server
volumes:
- data-volume:/data
ports:
- 5000:5000
server-fdv:
image: distsys-mq-server
build:
context: ./solution/server
volumes:
- fake-data-volume:/data
ports:
- 5000:5000
worker:
image: distsys-mq-worker
build:
context: ./solution/worker
volumes:
- data-volume:/data
deploy:
replicas: 2
network-fault:
image: distsys-mq-network-fault
build:
context: ./tests/network-fault
network_mode: service:server
cap_add:
- NET_ADMIN
volumes:
data-volume:
fake-data-volume:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

@@ -1,74 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="434" viewBox="0 96 1000 434" role="img" aria-labelledby="title description">
<title id="title">Архитектура сервиса генерации описаний</title>
<desc id="description">Пользователь обращается к REST API. Сервер передаёт задачи через RabbitMQ воркерам. Воркеры сохраняют описания в общем Docker volume, откуда сервер читает их. Для компонента CALLBACKS воркеры также отправляют уведомления серверу через RabbitMQ.</desc>
<defs>
<marker id="message-arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 1 1 L 9 5 L 1 9" fill="none" stroke="#2563eb" stroke-width="2" stroke-linejoin="round"/>
</marker>
<marker id="notification-arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 1 1 L 9 5 L 1 9" fill="none" stroke="#7c3aed" stroke-width="2" stroke-linejoin="round"/>
</marker>
<marker id="file-arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 1 1 L 9 5 L 1 9" fill="none" stroke="#0f766e" stroke-width="2" stroke-linejoin="round"/>
</marker>
<style>
text { font-family: Arial, sans-serif; fill: #1e293b; }
.heading { font-size: 23px; font-weight: 700; }
.label { font-size: 20px; }
.detail { font-size: 17px; fill: #475569; }
.message { fill: none; stroke: #2563eb; stroke-width: 2.5; marker-end: url(#message-arrow); }
.notification { fill: none; stroke: #7c3aed; stroke-width: 2.5; stroke-dasharray: 7 5; marker-end: url(#notification-arrow); }
.file { fill: none; stroke: #0f766e; stroke-width: 2.5; marker-end: url(#file-arrow); }
</style>
</defs>
<rect width="1000" height="530" fill="#ffffff"/>
<!-- HTTP client and API server. -->
<rect x="20" y="185" width="140" height="100" rx="12" fill="#f8fafc" stroke="#94a3b8" stroke-width="2"/>
<text x="90" y="228" text-anchor="middle" class="label">Пользователь</text>
<text x="90" y="255" text-anchor="middle" class="detail">HTTP-клиент</text>
<rect x="220" y="140" width="180" height="180" rx="14" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
<text x="310" y="221" text-anchor="middle" class="heading">Сервер</text>
<text x="310" y="251" text-anchor="middle" class="label">REST API</text>
<path d="M 160 214 H 218" class="message"/>
<path d="M 220 265 H 162" class="message"/>
<text x="190" y="245" text-anchor="middle" class="detail">HTTP</text>
<!-- RabbitMQ and its two message flows. -->
<rect x="460" y="116" width="200" height="228" rx="14" fill="#fff7ed" stroke="#ea580c" stroke-width="2"/>
<text x="560" y="149" text-anchor="middle" class="heading">RabbitMQ</text>
<rect x="480" y="170" width="160" height="48" rx="8" fill="#ffffff" stroke="#fb923c" stroke-width="1.5"/>
<text x="560" y="201" text-anchor="middle" class="label">Задачи</text>
<rect x="480" y="260" width="160" height="48" rx="8" fill="#ffffff" stroke="#a78bfa" stroke-width="1.5" stroke-dasharray="6 4"/>
<text x="560" y="291" text-anchor="middle" class="label">Уведомления</text>
<!-- A worker pool: the service can use several workers. -->
<rect x="740" y="140" width="240" height="180" rx="14" fill="#f8fafc" stroke="#64748b" stroke-width="2"/>
<text x="860" y="171" text-anchor="middle" class="heading">Воркеры</text>
<rect x="760" y="188" width="200" height="45" rx="8" fill="#ffffff" stroke="#94a3b8" stroke-width="1.5"/>
<text x="860" y="217" text-anchor="middle" class="label">Воркер 1</text>
<rect x="760" y="249" width="200" height="45" rx="8" fill="#ffffff" stroke="#94a3b8" stroke-width="1.5"/>
<text x="860" y="278" text-anchor="middle" class="label">Воркер 2</text>
<path d="M 400 194 H 458" class="message"/>
<path d="M 660 194 H 738" class="message"/>
<path d="M 740 284 H 662" class="notification"/>
<path d="M 460 284 H 402" class="notification"/>
<!-- Files are stored independently of messages. -->
<rect x="220" y="410" width="760" height="82" rx="14" fill="#f0fdfa" stroke="#0f766e" stroke-width="2"/>
<text x="600" y="444" text-anchor="middle" class="heading">Общий Docker volume</text>
<text x="600" y="473" text-anchor="middle" class="label">Файлы с описаниями</text>
<path d="M 310 410 V 322" class="file"/>
<text x="325" y="372" class="detail" style="fill:#0f766e">Чтение описаний</text>
<path d="M 860 320 V 408" class="file"/>
<text x="845" y="372" text-anchor="end" class="detail" style="fill:#0f766e">Запись описаний</text>
<path d="M 220 518 H 261" class="notification"/>
<text x="273" y="524" class="detail">Уведомления для компонента CALLBACKS</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

-152
View File
@@ -1,152 +0,0 @@
# Практика с RabbitMQ
В этом задании вы научитесь работать с брокером сообщений [RabbitMQ](https://rabbitmq.com/getstarted.html). Вам предстоит реализовать сервис, который принимает URL изображения, асинхронно генерирует описание, сохраняет его в файл и возвращает по запросу. Генерация описания имитируется готовой функцией из заготовки.
## Архитектура и интерфейс сервиса
Очередь сообщений позволяет передать длительную задачу на асинхронную обработку. Она помогает распределять работу между несколькими воркерами и сглаживать всплески нагрузки. В этом задании вам также предстоит обеспечить обработку задач при временных отказах.
<img src="media/architecture.svg" alt="Архитектура сервиса" width="800">
1. Сервер с REST API принимает запросы пользователей и передаёт задачи на генерацию описаний через RabbitMQ.
2. Воркеры получают задачи, генерируют описания и записывают их в общее хранилище, откуда сервер читает результаты. В задании хранилище — это [Docker volume](https://docs.docker.com/engine/storage/volumes/), см. [docker-compose.yml](docker-compose.yml).
Версия RabbitMQ для проверки задана в [docker-compose.yml](docker-compose.yml). Используйте это окружение для локального тестирования.
### REST API
Тела запросов и успешных ответов передаются в JSON. Успешный запрос возвращает код `200`; формат тела ответа при ошибке не регламентируется. Идентификатор изображения может быть строкой или целым числом; в ответах POST и GET он должен иметь одинаковый тип.
```
POST /api/v1.0/images
Принимает URL изображения для обработки и возвращает id изображения.
Каждый запрос получает новый уникальный id, даже при повторной отправке того же URL.
Если JSON некорректен или image_url отсутствует, пуст или не является строкой,
верните код 400.
Тело запроса:
{
"image_url": str
}
Тело ответа:
{
"image_id": str | int
}
```
```
GET /api/v1.0/images
Возвращает id всех обработанных изображений без повторов. Порядок не важен.
Тело ответа:
{
"image_ids": List[str | int]
}
```
```
GET /api/v1.0/images/<image_id>
Возвращает описание для данного изображения, если оно уже было обработано,
и код 404, если id неизвестен или обработка ещё не завершена.
Тело ответа:
{
"caption": str
}
```
## Компоненты задания и оценивание
За выполнение задания можно получить 10 баллов:
| Группа тестов | Баллы | Что проверяется |
| --- | ---: | --- |
| `BASIC_API` | 1 | Пустой список результатов, отклонение некорректных запросов и ответ `404` для неизвестного ID. |
| `BASIC_PROCESSING` | 3 | Передача задач через RabbitMQ, обработка воркерами и получение результатов через REST API без отказов. |
| `CALLBACKS` | 2 | Уведомления о завершении обработки через RabbitMQ: сервер отвечает на `GET .../images` без чтения списка файлов в общей директории. |
| `FAULT_TOLERANCE_1` | 1 | Работа сервиса после простоя соединений с брокером. |
| `FAULT_TOLERANCE_2` | 1 | Сохранность запросов при недоступности и перезапуске брокера во время отправки задач. |
| `FAULT_TOLERANCE_3` | 1 | Завершение задач после отказа одного или обоих воркеров. |
| `FAULT_TOLERANCE_4` | 1 | Восстановление после отказов брокера и воркеров, сохранность уведомлений и обработка повторных доставок. |
Баллы за каждую группу начисляются при прохождении всех её тестов. Провал одной группы не обнуляет баллы за остальные. Группа `FAULT_TOLERANCE_4` предполагает реализацию уведомлений через RabbitMQ; передавать в них сами описания изображений не требуется.
При проверке отказоустойчивости уже запущенный REST API должен принимать корректные запросы с кодом `200` и возвращать готовые результаты, даже когда брокер или воркеры недоступны. Запрос считается принятым, если POST вернул `200`. После восстановления брокера, связи и хотя бы одного воркера каждый принятый запрос должен быть обработан, а его описание — доступно через API. Таймаут каждого HTTP-запроса в тестах — 3 секунды.
Сервер и общее хранилище в этих сценариях не отказывают: восстановление после их перезапуска или потери данных не требуется. Повторная доставка задач и уведомлений возможна. Повторная обработка задачи допустима, но её ID должен появляться в списке результатов только один раз.
Приложите отчёт `solution/readme.md` с описанием устройства решения и выполненных компонентов. Для отказоустойчивости обоснуйте, почему принятые запросы не теряются. На защите нужно объяснить работу реализованных компонентов и подтвердить это обоснование. Общие требования к отчёту приведены в [правилах курса](../readme.md#отчёт).
## Заготовки для решения
В папке `solution/server` находится заготовка для сервера с реализацией REST API на Flask.
В папке `solution/worker` содержится заготовка для воркера. Для генерации описания используйте функцию `produce_image_caption`, передавая ей строку `image_url`. Скачивать и сохранять само изображение не нужно: функция имитирует его обработку. В файл сохраняется только полученное описание в кодировке UTF-8.
Очередь задач должна называться `task_queue`, а описания должны сохраняться в файлы `/data/{image_id}.txt`. Эти соглашения используются в тестах.
Для взаимодействия с RabbitMQ на Python предлагается использовать библиотеку [Pika](https://github.com/pika/pika) (см. семинар 5).
Весь код решения должен размещаться в папке `solution`. При сдаче решения в тестирующую систему отправляется только эта папка, изменения вне неё учитываться не будут.
## Порядок выполнения задания
Начните с [материалов семинара 5](../../materials/05-indirect-comm/seminar/) и примера [work_queues](../../materials/05-indirect-comm/seminar/work_queues/). Дополнительно можно обращаться к официальным [туториалам RabbitMQ](https://www.rabbitmq.com/tutorials).
1. Реализуйте передачу задач от REST API через RabbitMQ, обработку воркерами и получение результатов. Проверьте группы `BASIC_API` и `BASIC_PROCESSING`. При необходимости измените Dockerfile сервера и воркера.
2. Добавьте уведомления сервера о завершении обработки и проверьте группу `CALLBACKS`. Полезный пример обмена запросами и ответами есть в [туториале по RPC](https://www.rabbitmq.com/tutorials/tutorial-six-python). При использовании Pika учитывайте [ограничения потокобезопасности](https://pika.github.io/pika/latest/faq/).
3. Работу над отказоустойчивостью начните с `test_heartbeats_timeout` (`FAULT_TOLERANCE_1`). Изучите [документацию по heartbeat](https://www.rabbitmq.com/docs/heartbeats) и настройки в [rabbitmq.conf](tests/rabbitmq.conf). Для `BlockingConnection` полезно [обсуждение работы соединения при редкой отправке сообщений](https://github.com/pika/pika/discussions/1382).
4. Подумайте, в каких случаях ваше решение может потерять принятый запрос. Изучите [подтверждения доставки в RabbitMQ](https://www.rabbitmq.com/docs/confirms); для Pika также полезен [пример асинхронного отправителя](https://github.com/pika/pika/blob/main/examples/asynchronous_publisher_example.py). Проверьте отдельно отказы брокера при отправке задач (`FAULT_TOLERANCE_2`) и отказы воркеров (`FAULT_TOLERANCE_3`).
5. Перейдите к комплексным сценариям `FAULT_TOLERANCE_4`: перезапуску брокера, сохранности уведомлений и повторной доставке. Сопоставьте поведение решения с [руководством RabbitMQ по надёжности](https://www.rabbitmq.com/docs/reliability). Подумайте, какие моменты отказа ещё стоит проверить самостоятельно.
6. Подготовьте отчёт с обоснованием отказоустойчивости реализованных компонентов, запустите все тесты и сдайте решение с отчётом в тестирующую систему.
При отладке сопоставляйте логи компонентов с состоянием очередей в веб-интерфейсе RabbitMQ: `http://localhost:15672`, логин и пароль — `guest`. Как на семинаре, обращайте внимание на сообщения Ready и Unacked.
## Тестирование решения
Тесты, проверяющие решение, находятся в папке `tests`.
Бонусы за пробелы в тестах начисляются по [общим правилам](../readme.md#бонусы-за-пробелы-в-тестах).
### Локальное тестирование
Команды ниже выполняйте из папки задания. Для локального запуска тестов установите зависимости из `tests/requirements.txt`.
Перед запуском тестов соберите образы:
```
docker compose build
```
Запуск всех тестов выполняется с помощью команды:
```
python3 tests/main.py
```
Отдельный тест можно запустить так:
```
pytest -vs --tb=short tests/test_server.py::test_single_image
```
Для тестирования в окружении, аналогичном тестирующей системе, выполните из папки задания:
```bash
docker run --privileged --pull always --rm -v ./solution:/hw/solution distsys.ru/course/message-queue:latest
```
### Проверка в тестирующей системе
Отправьте ваше решение в тестирующую систему следуя [инструкции](../readme.md) и дождитесь результатов.
## ЧаВо
**Можно ли реализовать решение не на Python?**
Да. Замените заготовки и Dockerfile сервера и воркера. Реализуйте аналог функции-заглушки, возвращающий строку по `image_url`; точное совпадение результата с Python-версией не требуется.
**Можно ли обрабатывать изображения локально на сервере?**
Нет. Описания должны генерироваться только воркерами. Решение, которое генерирует их на сервере, не засчитывается (0 баллов).
@@ -1,13 +0,0 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /server
COPY requirements.txt .
RUN --mount=type=cache,id=distsys-course-pip,target=/root/.cache/pip,sharing=locked \
pip install -r requirements.txt
COPY . .
CMD ["python3", "-u", "server.py"]
@@ -1,2 +0,0 @@
flask==3.1.2
pika==1.3.2
@@ -1,51 +0,0 @@
from flask import Flask, request
from typing import List, Optional
class Server:
# TODO: Implement API server
def __init__(self, mq_host, mq_port, data_dir):
pass
def add_image(self, image_url: str) -> str:
raise NotImplementedError
def get_processed_images(self) -> List[str]:
raise NotImplementedError
def get_image_caption(self, image_id: str) -> Optional[str]:
raise NotImplementedError
def create_app() -> Flask:
app = Flask(__name__)
server = Server('rabbitmq', 5672, '/data')
@app.route('/api/v1.0/images', methods=['POST'])
def add_image():
body = request.get_json(force=True)
if not isinstance(body, dict) or not isinstance(body.get('image_url'), str) or not body['image_url']:
return 'image_url must be a nonempty string', 400
image_id = server.add_image(body['image_url'])
return {"image_id": image_id}
@app.route('/api/v1.0/images', methods=['GET'])
def get_processed_images():
image_ids = server.get_processed_images()
return {"image_ids": image_ids}
@app.route('/api/v1.0/images/<string:image_id>', methods=['GET'])
def get_image_caption(image_id):
result = server.get_image_caption(image_id)
if result is None:
return "Image not found.", 404
else:
return {'caption': result}
return app
if __name__ == '__main__':
app = create_app()
app.run(host='0.0.0.0', port=5000)
@@ -1,13 +0,0 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /worker
COPY requirements.txt .
RUN --mount=type=cache,id=distsys-course-pip,target=/root/.cache/pip,sharing=locked \
pip install -r requirements.txt
COPY . .
CMD ["python3", "-u", "worker.py"]
@@ -1 +0,0 @@
pika==1.3.2
@@ -1,15 +0,0 @@
class Worker:
# TODO: Implement server
def __init__(self, mq_host, mq_port, data_dir):
pass
def produce_image_caption(self, image_url):
return str(abs(hash(image_url)) % (10 ** 8))
def run(self):
raise NotImplementedError
if __name__ == '__main__':
worker = Worker('rabbitmq', 5672, '/data')
worker.run()
-107
View File
@@ -1,107 +0,0 @@
import argparse
import pathlib
import pytest
from collections import defaultdict
SCRIPT_DIR = pathlib.Path(__file__).parent.resolve()
TEST_GROUPS = {
'BASIC_API': {
'tests': [
'test_empty_data_dir',
'test_bad_request',
'test_nonexistent_image',
],
'points': 1
},
'BASIC_PROCESSING': {
'tests': [
'test_task_queue',
'test_single_image',
'test_multiple_images',
'test_captions_generated_on_workers'
],
'points': 3
},
'CALLBACKS': {
'tests': [
'test_multiple_images_no_listdir'
],
'points': 2
},
'FAULT_TOLERANCE_1': {
'tests': [
'test_heartbeats_timeout'
],
'points': 1
},
'FAULT_TOLERANCE_2': {
'tests': [
'test_publisher_confirms'
],
'points': 1
},
'FAULT_TOLERANCE_3': {
'tests': [
'test_faulty_worker',
'test_two_faulty_workers',
],
'points': 1
},
'FAULT_TOLERANCE_4': {
'tests': [
'test_faulty_worker_and_rabbit_restart',
'test_total_eclipse_of_the_heart',
'test_notifications_survive_rabbit_restart',
'test_duplicate_deliveries'
],
'points': 1
}
}
class PassedCounter:
def __init__(self):
self.test_to_group = {}
for group_name, group in TEST_GROUPS.items():
for test in group['tests']:
self.test_to_group[test] = group_name
self.passed_by_group = defaultdict(int)
def pytest_report_teststatus(self, report, config):
if report.when == 'call' and report.passed:
test = report.nodeid.split('::')[1].split('[')[0]
group_name = self.test_to_group[test]
self.passed_by_group[group_name] += 1
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument('--ci', action='store_true',
help='Fail unless all reference solution tests pass')
args = parser.parse_args(argv)
counter = PassedCounter()
test_status = pytest.main(
['-vs', '--tb=short', str(SCRIPT_DIR / 'test_server.py')], plugins=[counter])
score = 0
print()
for group_name, group in TEST_GROUPS.items():
total = len(group['tests'])
passed = counter.passed_by_group[group_name]
print(f'Test group {group_name}: passed {passed} of {total} tests')
if passed == total:
score += group['points']
print(f"\nSCORE: {score}")
if args.ci:
max_score = sum(group['points'] for group in TEST_GROUPS.values())
return int(test_status) or int(score != max_score)
# A failed student test still produces a valid partial score.
if test_status == pytest.ExitCode.TESTS_FAILED:
return 0
return int(test_status)
if __name__ == '__main__':
raise SystemExit(main())
@@ -1,4 +0,0 @@
FROM docker:dind
ENTRYPOINT ["sleep"]
CMD ["infinity"]
@@ -1,6 +0,0 @@
heartbeat = 5
# Refresh queue state before paused workers reach the heartbeat timeout.
collect_statistics_interval = 1000
# Allow basic solutions to use transient queues before adding fault tolerance.
# https://www.rabbitmq.com/docs/queues#durability
deprecated_features.permit.transient_nonexcl_queues = true
@@ -1,4 +0,0 @@
pytest==8.4.2
requests==2.32.5
docker==7.1.0
loguru==0.7.3
@@ -1,583 +0,0 @@
import io
import sys
import tarfile
import docker
import pytest
import requests
import subprocess
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import quote
from loguru import logger
IMAGES_ENDPOINT = 'http://localhost:5000/api/v1.0/images'
BROKER_ENDPOINT = 'http://guest:guest@localhost:15672/api'
TASK_QUEUE_ENDPOINT = f'{BROKER_ENDPOINT}/queues/%2F/task_queue'
logger.remove()
logger.add(sys.stderr, colorize=False, format="=== TEST ===| {time:YYYY-MM-DD HH:mm:ss.SSS} {level} {message}")
# Tests ===============================================================================================================
@pytest.mark.parametrize("services", [['rabbitmq', 'server']])
def test_empty_data_dir(docker_tester):
logger.info(f"Sending GET {IMAGES_ENDPOINT}")
try:
response = requests.get(IMAGES_ENDPOINT, timeout=3)
except Exception as e:
logger.error(f"Request failed: {e}")
pytest.fail(f"Failed to get images: {e}")
logger.info(f"Got response: {response.status_code} {response.text.rstrip()}")
assert response.status_code == 200
assert 'image_ids' in response.json()
assert len(response.json()['image_ids']) == 0
@pytest.mark.parametrize("services", [['rabbitmq', 'server']])
def test_bad_request(docker_tester):
invalid_bodies = [{}, {'image_url': None}, {'image_url': 1},
{'image_url': ''}, [], None, 'image-url']
for body in invalid_bodies:
logger.info(f"Sending invalid POST: {body!r}")
response = requests.post(IMAGES_ENDPOINT, json=body, timeout=3)
assert response.status_code == 400, response.text
response = requests.post(IMAGES_ENDPOINT, data='{',
headers={'Content-Type': 'application/json'}, timeout=3)
assert response.status_code == 400, response.text
@pytest.mark.parametrize("services", [['rabbitmq', 'server']])
def test_nonexistent_image(docker_tester):
nonexistent_image_id = str(uuid.uuid4())
logger.info(f"Sending GET {IMAGES_ENDPOINT}/{nonexistent_image_id}")
try:
response = requests.get(f'{IMAGES_ENDPOINT}/{nonexistent_image_id}', timeout=3)
except Exception as e:
logger.error(f"Request failed: {e}")
pytest.fail(f"Failed to get images: {e}")
logger.info(f"Got response: {response.status_code} {response.text.rstrip()}")
assert response.status_code == 404
@pytest.mark.parametrize("services", [['rabbitmq', 'server']])
def test_task_queue(docker_tester):
time.sleep(5)
check_task_queue(0, 10)
pending_ids = post_images(10)
check_unprocessed_images(pending_ids, docker_tester)
check_task_queue(10, 10)
time.sleep(5)
check_task_queue(10, 10)
check_unprocessed_images(pending_ids, docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_single_image(docker_tester):
pending_ids = post_images(1)
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_multiple_images(docker_tester):
pending_ids = post_images(10)
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_captions_generated_on_workers(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
wait_for_worker_ready(worker1)
wait_for_worker_ready(worker2)
worker1.pause()
worker2.pause()
pending_ids = post_images(10)
time.sleep(5)
check_unprocessed_images(pending_ids, docker_tester)
worker1.unpause()
worker2.unpause()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server-fdv', 'worker']])
def test_multiple_images_no_listdir(docker_tester):
pending_ids = post_images(10)
# This server has no access to the workers' result volume.
wait_and_check_results(pending_ids, 10, check_captions=False, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_heartbeats_timeout(docker_tester):
# With heartbeat=5, closing a connection without heartbeats can take 15s.
time.sleep(20)
pending_ids = post_images(10)
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_publisher_confirms(docker_tester):
rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1")
rabbit.pause()
pending_ids = post_images(10)
time.sleep(5)
rabbit.kill()
time.sleep(1)
rabbit.start()
wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20)
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_faulty_worker(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
# Prevent the healthy worker from completing the whole batch before the fault.
worker2.kill()
subscribed = wait_for_worker_ready(worker1)
worker1.pause()
pending_ids = post_images(10)
wait_for_pending_tasks(subscribed)
worker1.kill()
worker2.start()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_two_faulty_workers(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
subscribed1 = wait_for_worker_ready(worker1)
subscribed2 = wait_for_worker_ready(worker2)
worker1.pause()
worker2.pause()
pending_ids = post_images(10)
wait_for_pending_tasks(subscribed1 or subscribed2)
worker1.kill()
worker2.kill()
worker1.start()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_faulty_worker_and_rabbit_restart(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1")
# No worker may finish tasks before the broker loses its in-memory state.
worker1.kill()
worker2.kill()
pending_ids = post_images(10)
check_task_queue(10, 10)
rabbit.kill()
rabbit.start()
wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20)
worker2.start()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']])
def test_total_eclipse_of_the_heart(docker_tester):
completed_ids = post_images(10)
wait_and_check_results(completed_ids, 10, docker_client=docker_tester)
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1")
worker1.kill()
worker2.kill()
rabbit.kill()
# These URLs repeat the first batch, but every request needs a new ID.
pending_ids = post_images(10, known_ids=completed_ids)
# Also accept requests after the client has detected the broken connection.
time.sleep(20)
later_ids = post_images(10, known_ids=completed_ids | pending_ids)
wait_and_check_results(completed_ids, 10, docker_client=docker_tester)
rabbit.start()
wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20)
worker1.start()
worker2.start()
wait_and_check_results(completed_ids | pending_ids | later_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker', 'network-fault']])
def test_notifications_survive_rabbit_restart(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1")
fault = docker_tester.containers.get("distsys-mq-network-fault-1")
worker1.kill()
worker2.kill()
pending_ids = post_images(10)
check_task_queue(10, 10)
rabbit.reload()
address = next(network['IPAddress'] for network in
rabbit.attrs['NetworkSettings']['Networks'].values() if network['IPAddress'])
with block_amqp_link(fault, address):
worker1.start()
worker2.start()
wait_for_queued_notifications()
# Notifications cannot reach the API; tasks may still await acknowledgement.
wait_and_check_results(set(), 1, docker_client=docker_tester)
worker1.kill()
worker2.kill()
rabbit.kill()
rabbit.start()
wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20)
worker1.start()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker', 'network-fault']])
def test_duplicate_deliveries(docker_tester):
worker1 = docker_tester.containers.get("distsys-mq-worker-1")
worker2 = docker_tester.containers.get("distsys-mq-worker-2")
server = docker_tester.containers.get("distsys-mq-server-1")
rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1")
fault = docker_tester.containers.get("distsys-mq-network-fault-1")
worker1.kill()
worker2.kill()
pending_ids = post_images(10)
check_task_queue(10, 10)
task_copies = duplicate_queued_messages('task_queue')
assert task_copies == 20
check_task_queue(task_copies, 10)
rabbit.reload()
address = next(network['IPAddress'] for network in
rabbit.attrs['NetworkSettings']['Networks'].values() if network['IPAddress'])
with block_amqp_link(fault, address):
# Close the blocked connection so in-flight notifications become ready.
disconnect_from_broker(server)
worker1.start()
worker2.start()
queues = wait_for_queued_notifications(ready_only=True)
wait_and_check_results(set(), 1, docker_client=docker_tester)
worker1.kill()
worker2.kill()
notification_copies = {
queue['name']: duplicate_queued_messages(queue['name'])
for queue in queues if queue['name'] != 'task_queue' and queue.get('messages', 0) > 0
}
assert notification_copies
wait_for_queue_counts(notification_copies)
worker1.start()
wait_and_check_results(pending_ids, 10, docker_client=docker_tester)
# The first complete result list may precede processing of later copies.
wait_for_queue_counts({name: 0 for name in notification_copies})
wait_and_check_results(pending_ids, 1, docker_client=docker_tester)
# Utils ===============================================================================================================
@pytest.fixture
def docker_tester(services):
print()
try:
run_docker_compose_up(services)
check_server_endpoint()
client = docker.from_env()
yield client
finally:
print()
run_docker_compose_down()
def run_docker_compose_up(services):
command = ["docker", "compose", "--ansi", "never", "up", "--force-recreate"]
for service in services:
command.append(service)
subprocess.Popen(command, cwd=Path(__file__).parent.parent.absolute(), stdout=None, stderr=None)
def run_docker_compose_down():
command = ["docker", "compose", "down", "--volumes"]
subprocess.run(command, cwd=Path(__file__).parent.parent.absolute(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def check_server_endpoint(max_attempts=20):
attempt = 0
while True:
attempt += 1
try:
logger.info(f"Sending GET {IMAGES_ENDPOINT}")
response = requests.get(IMAGES_ENDPOINT, timeout=3)
assert response.status_code == 200, response.text
logger.info(f"Attempt {attempt} succeeded: got response, server endpoint is ready")
return
except Exception as e:
logger.error(f"Attempt {attempt} failed: {e}")
if attempt == max_attempts:
logger.error(f"Max attempts reached, give up")
pytest.fail("Server endpoint is not ready")
logger.info(f"Retry in 3 seconds...")
time.sleep(3)
def post_images(num_requests, known_ids=None):
known_ids = set() if known_ids is None else known_ids
pending_ids = set()
for i in range(num_requests):
input_data = {"image_url": f"https://somehost.com/some-image-{i}.jpg"}
logger.info(f"Sending POST {IMAGES_ENDPOINT} {input_data}")
try:
response = requests.post(IMAGES_ENDPOINT, json=input_data, timeout=3)
except Exception as e:
logger.error(f"Request failed: {e}")
pytest.fail(f"Failed to post image: {e}")
logger.info(f"Got response: {response.status_code} {response.text.rstrip()}")
assert response.status_code == 200
assert 'image_id' in response.json()
image_id = response.json()['image_id']
assert type(image_id) in (str, int), 'image_id must be a string or integer'
assert image_id not in known_ids, f'Reused image_id: {image_id}'
assert image_id not in pending_ids
pending_ids.add(image_id)
return pending_ids
def wait_and_check_results(pending_ids, max_attempts, docker_client, check_captions=True):
expected_count = len(pending_ids)
attempt = 0
while True:
attempt += 1
try:
logger.info(f"Sending GET {IMAGES_ENDPOINT}")
response = requests.get(IMAGES_ENDPOINT, timeout=3)
logger.info(f"Got response: {response.status_code} {response.text.rstrip()}")
assert response.status_code == 200
assert 'image_ids' in response.json()
ready_ids = response.json()['image_ids']
assert isinstance(ready_ids, list), 'image_ids must be a list'
assert all(type(image_id) in (str, int) for image_id in ready_ids)
ready_set = set(ready_ids)
assert len(ready_ids) == len(ready_set), 'image_ids contains duplicates'
assert ready_set <= pending_ids, 'image_ids contains unexpected IDs'
count = len(ready_set)
if ready_set == pending_ids:
if check_captions:
for image_id in pending_ids:
check_image_caption(image_id, docker_client)
logger.info(f"Attempt {attempt} succeeded: got {count} results as expected")
return
else:
logger.info(f"Attempt {attempt} not succeeded: got {count} results, expect {expected_count}")
except Exception as e:
logger.error(f"Attempt {attempt} failed: {e}")
if attempt == max_attempts:
logger.error(f"Max attempts reached, give up")
pytest.fail("Server didn't return expected results")
logger.info(f"Retry in 3 seconds...")
time.sleep(3)
def check_image_caption(image_id, docker_client):
logger.info(f"Sending GET {IMAGES_ENDPOINT}/{image_id}")
try:
response = requests.get(f'{IMAGES_ENDPOINT}/{image_id}', timeout=3)
except Exception as e:
logger.error(f"Request failed: {e}")
pytest.fail(f"Failed to get image caption: {e}")
logger.info(f"Got response: {response.status_code} {response.text.rstrip()}")
assert response.status_code == 200
assert 'caption' in response.json()
assert isinstance(response.json()['caption'], str)
saved_caption = read_saved_caption(image_id, docker_client)
assert response.json()['caption'] == saved_caption, 'API caption differs from the saved result'
def read_saved_caption(image_id, docker_client):
# Docker's archive API works even if the solution image has no shell or Python.
server = docker_client.containers.get('distsys-mq-server-1')
stream, _ = server.get_archive(f'/data/{image_id}.txt')
with tarfile.open(fileobj=io.BytesIO(b''.join(stream))) as archive:
files = [member for member in archive.getmembers() if member.isfile()]
assert len(files) == 1, 'Expected one regular caption file'
with archive.extractfile(files[0]) as result:
return result.read().decode('utf-8')
def check_unprocessed_images(image_ids, docker_client):
wait_and_check_results(set(), 1, docker_client)
for image_id in image_ids:
response = requests.get(f'{IMAGES_ENDPOINT}/{image_id}', timeout=3)
assert response.status_code == 404, f'Result available without workers: {response.text}'
def wait_for_task_queue(predicate, description, max_attempts=10, interval=3):
last_state = None
for attempt in range(1, max_attempts + 1):
try:
response = requests.get(TASK_QUEUE_ENDPOINT, timeout=3)
assert response.status_code == 200, response.text
queue = response.json()
last_state = {key: queue.get(key) for key in
('messages_ready', 'messages_unacknowledged', 'consumers', 'message_stats')}
if predicate(queue):
logger.info(f"Task queue is ready: {description}")
return queue
except Exception as e:
last_state = str(e)
logger.info(f"Waiting for {description}: {last_state}")
if attempt < max_attempts:
time.sleep(interval)
pytest.fail(f"Task queue did not reach {description}; last state: {last_state}")
def check_task_queue(expected_count, max_attempts):
return wait_for_task_queue(lambda queue: queue['messages_ready'] == expected_count,
f'{expected_count} ready messages', max_attempts)
def wait_for_worker_ready(worker, max_attempts=10):
last_state = None
for attempt in range(1, max_attempts + 1):
try:
worker.reload()
addresses = {network['IPAddress'] for network in
worker.attrs['NetworkSettings']['Networks'].values() if network['IPAddress']}
response = requests.get(TASK_QUEUE_ENDPOINT, timeout=3)
assert response.status_code == 200, response.text
consumers = response.json().get('consumer_details', [])
if any(consumer['channel_details']['peer_host'] in addresses for consumer in consumers):
return True
# Polling with basic.get is also valid; it has no registered consumer.
response = requests.get(f'{BROKER_ENDPOINT}/channels', timeout=3)
assert response.status_code == 200, response.text
channels = response.json()
last_state = [{'peer_host': channel['connection_details']['peer_host'],
'message_stats': channel.get('message_stats')} for channel in channels
if channel['connection_details']['peer_host'] in addresses]
for channel in channels:
if channel['connection_details']['peer_host'] in addresses:
stats = channel.get('message_stats') or {}
if any(stats.get(name, 0) > 0 for name in ('get', 'get_no_ack', 'get_empty')):
return False
except Exception as e:
last_state = str(e)
if attempt < max_attempts:
time.sleep(3)
pytest.fail(f"Worker {worker.name} did not start consuming tasks; last state: {last_state}")
def wait_for_pending_tasks(subscribed):
if subscribed:
# Delivery statistics can arrive before the unacknowledged count.
# Automatic ACK is deliberately allowed through this barrier: after the
# kill, the missing results must expose the lost deliveries instead.
predicate = lambda queue: (queue['messages_unacknowledged'] > 0 or
any((queue.get('message_stats') or {}).get(name, 0) > 0
for name in ('deliver', 'deliver_no_ack')))
description = 'a task delivered to a paused worker'
else:
predicate = lambda queue: queue['messages_ready'] == 10
description = '10 tasks waiting for a polling worker'
# Statistics refresh every 1s in rabbitmq.conf.
return wait_for_task_queue(predicate, description, max_attempts=10, interval=1)
def wait_for_queued_notifications(max_attempts=20, ready_only=False):
last_state = None
for attempt in range(1, max_attempts + 1):
try:
response = requests.get(f'{BROKER_ENDPOINT}/queues/%2F', timeout=3)
assert response.status_code == 200, response.text
queues = response.json()
last_state = {queue['name']: queue.get('messages') for queue in queues}
# Notifications may be batched or spread across multiple queues.
# Workers may keep tasks unacknowledged until the API receives a result.
notification_count = sum(count or 0 for name, count in last_state.items()
if name != 'task_queue')
ready = sum(queue.get('messages_ready', 0) or 0 for queue in queues
if queue['name'] != 'task_queue')
if 'task_queue' in last_state and notification_count > 0 and (not ready_only or ready == notification_count):
return queues
except Exception as e:
last_state = str(e)
if attempt < max_attempts:
time.sleep(1)
pytest.fail(f'No queued notifications reached the required state; last state: {last_state}')
@contextmanager
def block_amqp_link(fault, address):
rules = [('INPUT', '-s', address, '--sport'), ('OUTPUT', '-d', address, '--dport')]
installed = []
try:
for chain, direction, host, port in rules:
rule = [chain, direction, host, '-p', 'tcp', port, '5672', '-j', 'DROP']
result = fault.exec_run(['iptables', '-I', *rule])
assert result.exit_code == 0, result.output.decode(errors='replace')
installed.append(rule)
yield
finally:
for rule in reversed(installed):
result = fault.exec_run(['iptables', '-D', *rule])
assert result.exit_code == 0, result.output.decode(errors='replace')
def duplicate_queued_messages(queue_name):
# Consumers/producers of this queue are stopped or disconnected by the test.
messages = []
while True:
response = requests.post(
f'{BROKER_ENDPOINT}/queues/%2F/{quote(queue_name, safe="")}/get',
json={'count': 100, 'ackmode': 'ack_requeue_false', 'encoding': 'base64'},
timeout=3)
assert response.status_code == 200, response.text
batch = response.json()
if not batch:
break
messages.extend(batch)
assert messages, f'No messages to duplicate in {queue_name}'
for message in messages:
for _ in range(2):
response = requests.post(f'{BROKER_ENDPOINT}/exchanges/%2F/amq.default/publish',
json={'routing_key': queue_name, 'properties': message['properties'],
'payload': message['payload'], 'payload_encoding': 'base64'}, timeout=3)
assert response.status_code == 200, response.text
assert response.json()['routed'] is True, f'Copy was not routed to {queue_name}'
logger.info(f'Queued {len(messages) * 2} copies in {queue_name}')
return len(messages) * 2
def disconnect_from_broker(container):
container.reload()
addresses = {network['IPAddress'] for network in
container.attrs['NetworkSettings']['Networks'].values() if network['IPAddress']}
response = requests.get(f'{BROKER_ENDPOINT}/connections', timeout=3)
assert response.status_code == 200, response.text
for connection in response.json():
if connection['peer_host'] in addresses:
response = requests.delete(
f'{BROKER_ENDPOINT}/connections/{quote(connection["name"], safe="")}', timeout=3)
assert response.status_code in (204, 404), response.text
def wait_for_queue_counts(expected, max_attempts=30):
last_state = None
for attempt in range(1, max_attempts + 1):
try:
last_state = {}
for name in expected:
response = requests.get(f'{BROKER_ENDPOINT}/queues/%2F/{quote(name, safe="")}', timeout=3)
assert response.status_code == 200, response.text
last_state[name] = response.json().get('messages')
if last_state == expected:
return
except Exception as e:
last_state = str(e)
if attempt < max_attempts:
time.sleep(1)
pytest.fail(f'Queues did not reach {expected}; last state: {last_state}')
-138
View File
@@ -1,138 +0,0 @@
# Group Membership
В этом задании вам предстоит реализовать распределённый сервис для отслеживания состава группы. Он должен позволять процессам вступать в группу, покидать её и узнавать её текущий состав.
В системе _N_ узлов, на каждом из которых запущен процесс с уникальным идентификатором _id_. Процессы вступают в _группу_ по команде `JOIN` и покидают её по команде `LEAVE`. Сам по себе запуск процесса не делает его участником группы.
Помимо добровольного выхода, состав группы может меняться из-за отказов. В нашей системе возможны:
- _Падение процесса или всего узла_. Процесс прекращает выполнение и теряет состояние в памяти. Позднее он может быть перезапущен с тем же _id_ и чистым состоянием. Это модель _crash-recovery_ из лекции 6. Работающий процесс будем называть _живым_.
- _Потери сообщений_ между некоторыми парами узлов, в обоих или только в одном направлении. Живые процессы могут оказаться полностью или частично изолированы от остальных. Сетевые сбои могут быть временными, после чего связь восстанавливается.
Для обнаружения недоступных участников вам потребуется _детектор отказов_ (см. лекцию 6). Он сообщает о подозрении: отсутствие ответа может быть вызвано как падением процесса, так и проблемами сети. От детектора требуется _полнота_: падение участника должно в конечном счёте обнаруживаться всеми оставшимися участниками. Подберите баланс между скоростью обнаружения, числом ложных подозрений и расходами на обмен сообщениями.
Списки участников на разных процессах могут временно различаться после входа, выхода, отказа или восстановления. Требуется _согласованность в конечном счёте_: когда условия работы перестают меняться, списки должны стабилизироваться. Каждый участник должен видеть в своём списке себя и всех живых участников, с которыми возможен обмен сообщениями в обе стороны, в том числе через других участников.
Упавшие и добровольно вышедшие процессы должны автоматически исключаться из списков остальных участников. То же относится к живому процессу, который не может обмениваться сообщениями в обе стороны ни с одним другим участником.
Если сеть распалась на подгруппы, между которыми нет двусторонней связи, происходит _разделение сети_. В этом случае списки должны совпадать внутри каждой подгруппы, включая меньшую, и содержать всех её участников, без процессов из других подгрупп. В списке участников изолированного процесса должен остаться только он сам. Односторонней связи между подгруппами недостаточно для сохранения общей группы.
После перезапуска процесс снова вступает в группу по команде `JOIN`, и остальные участники должны вернуть его в свои списки. При восстановлении связи с временно недоступным живым участником они должны сделать это автоматически, без новой команды `JOIN`. Это нужно учитывать и при использовании идей SWIM из лекции: исключение из локального списка не должно навсегда препятствовать возвращению участника.
Чтобы получить максимальный балл, нужно также обеспечить масштабируемость решения. При увеличении числа узлов в _X_ раз сетевой трафик и число сообщений в единицу времени должны увеличиваться не более чем в _2X_ раз. Нагрузка на процессы измеряется суммой отправленных и полученных сообщений; отношение максимальной нагрузки к минимальной не должно превышать 5.
Подробные сценарии проверок приведены в тестах — они являются частью условия.
## Реализация
Для реализации и тестирования решения используется фреймворк AnySystem (см. материалы первого семинара). В папке `solution` размещена заготовка для решения [member.py](solution/member.py). Вам надо доработать реализацию процесса в классе `GroupMember` так, чтобы проходили все тесты.
Из библиотек разрешены только AnySystem и стандартная библиотека Python. Можно добавлять собственные вспомогательные модули. Сжимать сетевые сообщения запрещено, в том числе с помощью `zlib`, `gzip`, `bz2`, `lzma` или собственного алгоритма сжатия. Снижайте сетевые расходы за счёт устройства протокола: например, передавайте только изменения состава группы вместо полного списка.
Ваши реализации процессов AnySystem должны соблюдать правило [изоляции процессов](../readme.md#изоляция-процессов-anysystem): изменяемое состояние каждого процесса должно храниться только в `self`, а общая изменяемая память между процессами запрещена. Все взаимодействия между процессами должны происходить через сообщения, как и в реальной распределённой системе.
При инициализации процессу передается его уникальный _id_. Процесс должен поддерживать обработку следующих локальных сообщений:
- _JOIN_ - команда присоединиться к группе. В результате обработки этой команды процесс должен стать участником группы. В поле `seed` передается идентификатор одного из живых участников группы. Если в `seed` находится идентификатор самого процесса, то он должен создать новую пустую группу и добавить в неё себя. Ответ на это сообщение не нужен.
- _LEAVE_ - команда покинуть группу. В результате обработки этой команды процесс должен перестать быть участником группы. Ответ на это сообщение не нужен.
- _GET_MEMBERS_ - запрос локального списка участников группы. Процесс должен сразу ответить одним локальным сообщением _MEMBERS_ с хранимым списком идентификаторов (см. заготовку). Обращаться к другим процессам или ждать таймера для ответа нельзя. Эта семантика проверяется отдельным тестом.
Для взаимодействия между процессами вы можете использовать любые собственные типы сообщений.
**Важно!** Для измерения прошедшего времени в коде процесса используйте метод `ctx.time()`, возвращающий локальное время "внутри симуляции". Использовать обычные способы, например `time.time()`, некорректно, так как скорость течения времени в симуляции отличается от реального времени. Также не стоит опираться на синхронизацию часов в системе - у каждого узла свои локальные часы, и показания `ctx.time()` у разных процессов могут расходиться. После перезапуска узла смещение его часов может измениться.
В коде процесса можно использовать генератор случайных чисел из `random`, но не следует его явно инициализировать с помощью `random.seed()`. Это уже делается в тестах на основе переданного random seed, а дополнительная инициализация может нарушить воспроизводимость результатов.
## Тестирование
### Локальное тестирование
Тесты находятся в папке `tests`. Есть два варианта их запуска.
Рекомендуемый вариант запуска тестов - через готовый Docker-образ. В этом случае используемое окружение будет аналогично тестирующей системе. Для запуска тестов выполните команду:
```commandline
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/membership:latest [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
```
Для запуска полного набора тестов с теми же параметрами, что и в тестирующей системе, выполните команду:
```commandline
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/membership:latest -m 1000
```
Вы также можете собрать и запустить тесты непосредственно в Linux с помощью компилятора Rust. На Windows можно использовать WSL или Docker, на macOS — Docker. Сборка из исходников может быть удобна, если вы хотите лучше изучить или доработать тесты. Скомпилируйте тесты с помощью команды `cargo install --locked --path tests`. Для запуска тестов выполните команду:
```commandline
distsys-membership [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
```
Запустить один тест можно с помощью опции `-t` (например, `... -t "PROCESS CRASH"`). По умолчанию тестер выводит результаты проверок, диагностику долгих запусков и итоговую сводку. Флаг `-d` добавляет трассу событий, а `-h` показывает все доступные опции. При отдельном запуске теста масштабируемости через `-t` предварительное прохождение функциональных тестов не требуется.
### Что проверяют тесты
Сначала выполняются обычные функциональные тесты, затем `CHAOS MONKEY`, `MODEL CHECKING` и тесты масштабируемости.
На каждом этапе обычных тестов списки участников должны сойтись к ожидаемому составу за 300 секунд симуляции. В тестах изоляции проверяются и оставшаяся группа, и изолированный участник: в его списке должен остаться только он сам.
Тесты `NETWORK PARTITION UNEQUAL` и `NETWORK PARTITION UNEQUAL RECOVER` разделяют сеть на две непустые подгруппы разного размера: 4 и 6 при десяти процессах. Состав группы должен стабилизироваться в обеих подгруппах, включая меньшую. Второй тест затем восстанавливает связь и проверяет, что все процессы снова видят общую группу.
Тесты `TWO PROCESSES CANNOT COMMUNICATE` и `TWO PROCESSES CANNOT COMMUNICATE IN TRIANGLE` отключают связь между парой узлов, сохраняя связь через остальных участников. Во втором тесте всегда три узла. Все участники должны остаться в группе.
В этих двух тестах, а также после восстановления в `PROCESS CRASH RECOVER`, `PROCESS OFFLINE RECOVER`, `NETWORK PARTITION RECOVER` и `NETWORK PARTITION UNEQUAL RECOVER` проверяется непрерывная стабильность состава группы. За 300 секунд симуляции должен найтись интервал длительностью 30 секунд, в котором списки участников остаются корректными. Внутри этого интервала списки проверяются после каждого события. Переходные ошибки до стабилизации допускаются.
`CHAOS MONKEY` проверяет последовательности отказов. По умолчанию выполняются 100 прогонов, а в тестирующей системе — 1000 (`-m 1000`). Каждый прогон включает до шести шагов с отказами; при стандартных десяти процессах в проверяемой группе может остаться четыре участника.
Для отладки `CHAOS MONKEY` используйте флаг `-v` (`--verbose`): он выводит параметры запуска, порядок входа процессов и отказы с номерами процессов и временем в симуляции. Этот вывод также включается с `-d`. Чтобы повторить запуск, укажите то же значение `-s` (`--seed`) и то же число процессов `-p`, а в `-m` — число не меньше номера нужного прогона. Например, `... -t "CHAOS MONKEY (run 850)" -m 1000 -s 123 -p 10 -v`. В тестах выбор процессов воспроизводим при одинаковых параметрах; случайность внутри вашего решения также должна быть воспроизводимой.
`MODEL CHECKING` (MC) проверяет сборку группы из трёх процессов без отказов и ответы на `GET_MEMBERS` при разных порядках доставки сообщений. Полный состав ожидается в состояниях, где каждый процесс получил не менее трёх сетевых сообщений или больше нет событий. В тестирующей системе MC включён; для быстрой локальной проверки его можно отключить флагом `--disable-mc-tests`.
Тесты `SCALABILITY NORMAL` и `SCALABILITY CRASH` измеряют расходы на поддержание группы. В первом тесте измерение длится 10 секунд симуляции после сборки группы, во втором — от падения одного процесса до стабилизации состава. Расходы на первоначальную сборку группы не учитываются; во втором тесте равномерность нагрузки проверяется среди оставшихся живых процессов.
При стандартных параметрах оба теста масштабируемости запускаются для групп из 10, 20, 50 и 100 узлов. Сетевой трафик и число сообщений в единицу времени сравниваются между последовательными запусками: 10 → 20, 20 → 50 и 50 → 100. По правилу _2X_ допустимый рост расходов составляет соответственно 4, 5 и 4 раза.
### Лимиты и диагностика
Тесты выполняются в отдельных процессах; прогоны `CHAOS MONKEY` объединены в серию с сохранением результата каждого прогона. При зависании, исчерпании памяти или аварийном завершении исполнителя проверка продолжается, пока остаётся общий бюджет времени. Баллы за пройденные функциональные компоненты и сетевые подгруппы сохраняются. Непроверенные тесты отмечаются в сводке и не считаются пройденными.
По умолчанию и в тестирующей системе действуют следующие ограничения реального времени. Все проверки делят общий бюджет 540 секунд:
| Проверка | Лимит | Опция |
| --- | --- | --- |
| Обычный тест или один прогон CHAOS MONKEY | 15 секунд | `--test-timeout` |
| Все прогоны CHAOS MONKEY вместе | 300 секунд | `--chaos-timeout` |
| Обе фазы MODEL CHECKING вместе | 450 секунд | `--mc-timeout` |
| Каждый тест SCALABILITY | 60 секунд | `--scalability-timeout` |
| Полный набор тестов | 540 секунд | `--suite-timeout` |
Адресное пространство исполнителя ограничено 4 ГиБ для обычных тестов, CHAOS MONKEY и SCALABILITY (`--memory-limit 4096`) и 14 ГиБ для MODEL CHECKING (`--mc-memory-limit 14336`). В тестирующей системе эти пределы фиксированы. Окружение должно также иметь запас 256 МиБ для управляющего процесса.
Если памяти окружения недостаточно для заданного предела и запаса для управляющего процесса, тестер завершится с сообщением об ошибке окружения без оценки. Для локального запуска в меньшем объёме памяти можно явно уменьшить соответствующий предел, но результат может отличаться от проверки на сервере.
При остановке тестер сообщает причину и последний этап проверки: например, размер собираемой группы или фазу model checking. Долгие проверки также периодически выводят текущий этап и расход памяти.
Бонусы за пробелы в тестах начисляются по [общим правилам](../readme.md#бонусы-за-пробелы-в-тестах).
### Проверка в тестирующей системе
Отправьте решение в тестирующую систему по [инструкции](../readme.md) и дождитесь результатов.
## Оценивание
Это задание имеет повышенный вес 1.5, то есть максимум можно набрать **15 баллов**.
Базовый функционал, отказы процессов и четыре сетевые подгруппы оцениваются независимо друг от друга. Внутри каждого компонента или подгруппы нужно пройти все перечисленные обычные тесты; исключения для MC описаны ниже. Масштабируемость даёт баллы только при зачёте всей функциональной части.
- **Базовый функционал без отказов — до 4 баллов:**
- **3 балла** за прохождение всех пяти тестов: `SIMPLE`, `GET MEMBERS SEMANTICS`, `RANDOM SEED`, `PROCESS JOIN`, `PROCESS LEAVE`.
- **Ещё 1 балл**, если пройдены и все пять обычных тестов, и `MODEL CHECKING`. Отключение MC при локальном запуске или остановка уже запущенного MC по лимиту времени (в том числе общего бюджета) или памяти сохраняет первые 3 балла.
- **0 баллов за весь компонент**, если MC обнаружил ошибку, не запустился из-за исчерпания общего бюджета или его исполнитель аварийно завершился с неизвестной причиной.
- **Отказы процессов — 3 балла:** `PROCESS CRASH`, `SEED PROCESS CRASH`, `PROCESS CRASH RECOVER`.
- **Сетевые и комбинированные отказы — до 4 баллов, по 1 баллу за каждую подгруппу:**
- **Отключения и разделения сети с восстановлением:** `PROCESS OFFLINE`, `SEED PROCESS OFFLINE`, `PROCESS OFFLINE RECOVER`, `NETWORK PARTITION`, `NETWORK PARTITION RECOVER`, `NETWORK PARTITION UNEQUAL`, `NETWORK PARTITION UNEQUAL RECOVER`.
- **Односторонняя и частичная связность:** `PROCESS CANNOT RECEIVE`, `PROCESS JOIN CANNOT RECEIVE`, `PROCESS CANNOT SEND`, `TWO PROCESSES CANNOT COMMUNICATE`, `TWO PROCESSES CANNOT COMMUNICATE IN TRIANGLE`.
- **Задержки и потери сообщений:** `SLOW NETWORK`, `FLAKY NETWORK`, `FLAKY NETWORK ON START`, `FLAKY NETWORK AND CRASH`.
- **Последовательности отказов:** все 1000 прогонов `CHAOS MONKEY`.
- **Масштабируемость — 4 балла:** `SCALABILITY NORMAL`, `SCALABILITY CRASH`. Эти тесты запускаются в полном наборе только после зачёта всех функциональных компонентов: базовой группы (не менее 3 баллов), отказов процессов и всех четырёх сетевых подгрупп. Остановка уже запущенного MC по времени или памяти и его отключение при локальном запуске не блокируют масштабируемость. Если функциональные компоненты не зачтены, тесты масштабируемости пропускаются с указанием причины. Для получения 4 баллов нужно пройти оба теста; при исчерпании общего бюджета незапущенные тесты не считаются пройденными.
К решению приложите отчёт в файле `solution/readme.md` по [общим требованиям](../readme.md#отчёт). Без отчёта автоматические тесты запускаются, но защита не проводится и решение не засчитывается.
-34
View File
@@ -1,34 +0,0 @@
from anysystem import Context, Message, Process
class GroupMember(Process):
def __init__(self, proc_id: str):
self._id = proc_id
def on_local_message(self, msg: Message, ctx: Context):
if msg.type == 'JOIN':
# Add process to the group
seed = msg['seed']
if seed == self._id:
# create new empty group and add process to it
pass
else:
# join existing group
pass
elif msg.type == 'LEAVE':
# Remove process from the group
pass
elif msg.type == 'GET_MEMBERS':
# Get a list of group members
# - return the list of all known alive processes in MEMBERS message
ctx.send_local(Message('MEMBERS', {'members': [self._id]}))
def on_start(self, ctx: Context):
pass
def on_message(self, msg: Message, sender: str, ctx: Context):
# Implement communication between processes using any message types
pass
def on_timer(self, timer_name: str, ctx: Context):
pass
@@ -1,5 +0,0 @@
target
**/target
**/__pycache__
**/.pytest_cache
*.pyc
-993
View File
@@ -1,993 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "anysystem"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01badd3bc0d042e4399f8f685c394a79a72e7f233b3d3b5eabb3b94734f4dbe"
dependencies = [
"colored",
"downcast-rs",
"dyn-clone",
"indexmap 2.14.0",
"lazy_static",
"log",
"ordered-float",
"pyo3",
"rand",
"rand_pcg",
"regex",
"rstest",
"serde",
"serde_json",
"simcore",
"sugars",
]
[[package]]
name = "assertables"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d84b91d63c67e6c0b1a74728dad0057c4a6124b74e671ff91b7fe9f8bbd671aa"
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cfg-if"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "clap"
version = "3.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
dependencies = [
"atty",
"bitflags",
"clap_derive",
"clap_lex",
"indexmap 1.9.3",
"once_cell",
"strsim",
"termcolor",
"textwrap",
]
[[package]]
name = "clap_derive"
version = "3.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008"
dependencies = [
"heck 0.4.1",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "clap_lex"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
dependencies = [
"os_str_bytes",
]
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "distsys-membership"
version = "0.1.0"
dependencies = [
"anysystem",
"assertables",
"clap",
"env_logger",
"indexmap 2.14.0",
"libc",
"log",
"pyo3",
"rand",
"rand_pcg",
"serde",
"serde_json",
"sugars",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "env_logger"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7"
dependencies = [
"atty",
"humantime",
"log",
"regex",
"termcolor",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
dependencies = [
"serde",
"typeid",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "memchr"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "ordered-float"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
dependencies = [
"num-traits",
"rand",
"serde",
]
[[package]]
name = "os_str_bytes"
version = "6.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro-crate"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
name = "proc-macro2"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pyo3"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
dependencies = [
"libc",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
]
[[package]]
name = "pyo3-build-config"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9"
dependencies = [
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn 2.0.106",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"serde",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"serde",
]
[[package]]
name = "rand_pcg"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e"
dependencies = [
"rand_core",
]
[[package]]
name = "regex"
version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
[[package]]
name = "relative-path"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "rstest"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682"
dependencies = [
"futures",
"futures-timer",
"rstest_macros",
"rustc_version",
]
[[package]]
name = "rstest_macros"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d"
dependencies = [
"cfg-if",
"glob",
"proc-macro-crate",
"proc-macro2",
"quote",
"regex",
"relative-path",
"rustc_version",
"syn 2.0.106",
"unicode-ident",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "semver"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "serde_json"
version = "1.0.143"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
dependencies = [
"indexmap 2.14.0",
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "serde_type_name"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92acc4cd6ae20767c54d6cf1a075624e7f4d9e99d7ebc685398ff243144d8714"
dependencies = [
"serde",
]
[[package]]
name = "simcore"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a20dfec85e94e569fa5b810b2d992c68b361f31bfba471447427536488c9c292"
dependencies = [
"colored",
"downcast-rs",
"dyn-clone",
"erased-serde",
"futures",
"log",
"rand",
"rand_pcg",
"rustc-hash",
"serde",
"serde_json",
"serde_type_name",
]
[[package]]
name = "slab"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "sugars"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc0db74f9ee706e039d031a560bd7d110c7022f016051b3d33eeff9583e3e67a"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.14.0",
"toml_datetime",
"winnow",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "unicode-ident"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.0",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
dependencies = [
"memchr",
]
[[package]]
name = "zerocopy"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
-22
View File
@@ -1,22 +0,0 @@
[package]
name = "distsys-membership"
version = "0.1.0"
edition = "2021"
resolver = "3"
[dependencies]
anysystem = "=0.3.0"
indexmap = "2.12"
assertables = "3.2.2"
clap = { version = "3.2.17", features = ["cargo", "derive"] }
env_logger = "0.9.0"
log = "0.4.17"
pyo3 = { version = "=0.29.2", features = ["auto-initialize"] }
rand = "0.8.5"
rand_pcg = "0.3.1"
serde = { version = "1.0.142", features = ["derive"] }
serde_json = "1.0.83"
sugars = "3.0.1"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2.175"
-19
View File
@@ -1,19 +0,0 @@
# syntax=docker/dockerfile:1
FROM rust:1.97.1-bookworm@sha256:0e2bcaef56d041a486784e54104a81aebe0da44bd03019bd70bc0401e42e4a97 AS builder
RUN apt-get update && apt-get install -y build-essential python3-dev
WORKDIR /tests
COPY . .
RUN --mount=type=cache,id=distsys-course-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=distsys-course-cargo-target-rust-1-97-1,target=/tests/target,sharing=locked \
cargo install --locked --path . --root /opt/course
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y python3-dev && rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/course/bin/distsys-membership /usr/local/bin/distsys-membership
WORKDIR /solution
ENTRYPOINT ["timeout", "--verbose", "-k", "10", "600", "distsys-membership", "-i", "member.py"]
@@ -1,134 +0,0 @@
"""HW6 library rules; inspect student source without importing it.
The general shared-state checker is passed in by the embedded Rust wrapper so
its import resolution is shared without changing the policy of other homework.
This checks common compression APIs, not arbitrary compression algorithms.
"""
import ast
from pathlib import Path
import sys
COMPRESSION_APIS = {
"zlib": {"compress", "compressobj", "decompress", "decompressobj"},
"gzip": {"compress", "decompress", "open", "GzipFile"},
"bz2": {"compress", "decompress", "open", "BZ2File", "BZ2Compressor", "BZ2Decompressor"},
"lzma": {"compress", "decompress", "open", "LZMAFile", "LZMACompressor", "LZMADecompressor"},
}
def _check_file(tree, current, root, checker):
violations = set()
pending = checker._local_imports(tree, current, root)
modules, functions = checker._dynamic_import_names(tree)
strings = checker._static_string_values(tree)
symbols = {}
def report(node, message):
violations.add(f"{current}:{node.lineno}:{node.col_offset + 1}: {message}")
def check_module(name, node, base=root):
parts = name.split(".")
local = checker._candidate_module(base, parts)
# Inspect package initializers as well as the explicitly imported file.
for index in range(1, len(parts) + 1):
candidate = checker._candidate_module(base, parts[:index])
if candidate is not None:
pending.add(candidate)
namespace = base.joinpath(*parts).resolve()
local_namespace = namespace.is_relative_to(root) and namespace.is_dir()
if local is None and not local_namespace and parts[0] not in sys.stdlib_module_names and parts[0] != "anysystem":
report(node, f"library '{name}' is not allowed: use AnySystem and the Python standard library")
def dynamic_name(node):
argument = node.args[0] if node.args else next(
(item.value for item in node.keywords if item.arg == "name"), None
)
return checker._static_string(argument, strings) if argument is not None else None
def symbol(node):
if isinstance(node, ast.Name):
return symbols.get(node.id)
if isinstance(node, ast.Call) and checker._is_dynamic_import_call(node, modules, functions):
return dynamic_name(node)
resolved = checker._resolved_attribute(node)
if resolved is not None:
parent = symbol(resolved[0])
if parent is not None:
return f"{parent}.{resolved[1]}"
return None
def check_compression(name, node):
parts = name.split(".")
if len(parts) >= 2 and parts[1] in COMPRESSION_APIS.get(parts[0], set()):
report(node, f"message compression is forbidden in HW6: '{parts[0]}.{parts[1]}'")
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
check_module(alias.name, node)
symbols[alias.asname or alias.name.split(".")[0]] = alias.name if alias.asname else alias.name.split(".")[0]
elif isinstance(node, ast.ImportFrom):
base = root
if node.level:
base = current.parent
for _ in range(node.level - 1):
base = base.parent
if not base.is_relative_to(root):
report(node, "local imports must stay inside the solution directory")
continue
if node.module:
check_module(node.module, node, base)
for alias in node.names:
name = f"{node.module}.{alias.name}" if node.module else alias.name
if checker._candidate_module(base, name.split(".")) is not None:
check_module(name, node, base)
if alias.name == "*" and node.module in COMPRESSION_APIS:
report(node, f"message compression is forbidden in HW6: star import from '{node.module}'")
else:
check_compression(name, node)
symbols[alias.asname or alias.name] = name
elif isinstance(node, ast.Call) and checker._is_dynamic_import_call(node, modules, functions):
name = dynamic_name(node)
if name is None or name.startswith("."):
report(node, "dynamic imports must use a statically known absolute module name")
else:
check_module(name, node)
# Follow aliases, including compressor functions saved for later use.
assignments = [parts for node in ast.walk(tree) if (parts := checker._assignment_parts(node)) is not None]
for _ in range(len(assignments) + 1):
changed = False
for value, targets in assignments:
name = symbol(value)
if name is None:
continue
for target in targets:
if isinstance(target, ast.Name) and target.id not in symbols:
symbols[target.id] = name
changed = True
if not changed:
break
for node in ast.walk(tree):
name = symbol(node)
if name is not None:
check_compression(name, node)
return violations, pending
def validate_solution(path, checker):
entrypoint = Path(path).resolve()
root = entrypoint.parent
pending = {entrypoint}
visited = set()
violations = set()
while pending:
current = pending.pop()
if current in visited:
continue
visited.add(current)
tree = ast.parse(current.read_text(encoding="utf-8-sig"), filename=str(current))
errors, imports = _check_file(tree, current, root, checker)
violations.update(errors)
pending.update(imports - visited)
return sorted(violations)
File diff suppressed because it is too large. Load diff
@@ -1,44 +0,0 @@
use serde::{Deserialize, Serialize};
use sugars::boxed;
use anysystem::python::PyProcessFactory;
use anysystem::System;
#[derive(Serialize)]
pub struct JoinMessage<'a> {
pub seed: &'a str,
}
#[derive(Serialize)]
pub struct LeaveMessage {}
#[derive(Serialize)]
pub struct GetMembersMessage {}
#[derive(Deserialize)]
pub struct MembersMessage {
pub members: Vec<String>,
}
#[derive(Clone, Copy)]
pub struct TestConfig<'a> {
pub process_factory: &'a PyProcessFactory,
pub process_count: u32,
pub seed: u64,
}
pub fn build_system(config: &TestConfig) -> System {
crate::runner::progress(format!("creating simulation: N={}", config.process_count));
let mut sys = System::new(config.seed);
sys.network().set_delays(0.01, 0.1);
for n in 0..config.process_count {
// process and node on which it runs have the same name
let name = n.to_string();
sys.add_node(&name);
let clock_skew = sys.gen_range(0.0..10.0);
sys.set_node_clock_skew(&name, clock_skew);
let process = config.process_factory.build((&name,), config.seed);
sys.add_process(&name, boxed!(process), &name);
}
sys
}
@@ -1,36 +0,0 @@
use std::ffi::CString;
use pyo3::prelude::*;
use pyo3::types::PyModule;
const POLICY_CODE: &str = include_str!("../library_policy.py");
fn validate_solution(path: &str) -> Result<Vec<String>, String> {
Python::attach(|py| {
let checker =
crate::shared_state::validator_module(py).map_err(|error| error.to_string())?;
let code = CString::new(POLICY_CODE).expect("library policy contains a null byte");
let policy = PyModule::from_code(py, &code, c"library_policy.py", c"library_policy")
.map_err(|error| error.to_string())?;
policy
.getattr("validate_solution")
.and_then(|function| function.call1((path, checker)))
.and_then(|result| result.extract::<Vec<String>>())
.map_err(|error| error.to_string())
})
}
pub fn validate_or_report(path: &str) -> bool {
match validate_solution(path) {
Ok(violations) if violations.is_empty() => true,
Ok(violations) => {
println!("ERROR: HW6 library policy violation");
for violation in violations {
println!("{violation}");
}
println!("\nSCORE: 0\n");
false
}
Err(error) => panic!("failed to validate solution libraries: {error}"),
}
}
-466
View File
@@ -1,466 +0,0 @@
mod common;
mod library_policy;
mod python_memory;
mod resources;
mod runner;
mod shared_state;
mod tests;
mod tests_mc;
use indexmap::IndexMap;
use std::collections::HashSet;
use clap::Parser;
use runner::Outcome;
/// Membership Homework Tests
#[derive(Parser, Debug)]
#[clap(about, long_about = None)]
struct Args {
/// Path to Python file with solution
#[clap(long = "impl", short = 'i', default_value = "solution/member.py")]
solution_path: String,
/// Test to run (optional)
#[clap(long = "test", short)]
test: Option<String>,
/// Print execution trace
#[clap(long, short)]
debug: bool,
/// Print test parameters and chaos monkey failures without the execution trace
#[clap(long, short = 'v')]
verbose: bool,
/// Random seed used in tests
#[clap(long, short, default_value = "123")]
seed: u64,
/// Number of processes
#[clap(long, short, default_value = "10")]
process_count: u32,
/// Number of chaos monkey runs
#[clap(long, short, default_value = "100")]
monkeys: u32,
/// Do not run model checking tests
#[clap(long)]
disable_mc_tests: bool,
/// Wall-clock limit in seconds per ordinary test and per chaos run
#[clap(long, default_value = "15", value_parser = clap::value_parser!(u64).range(1..=300))]
test_timeout: u64,
/// Shared wall-clock budget in seconds for all chaos runs
#[clap(long, default_value = "300", value_parser = clap::value_parser!(u64).range(1..=540))]
chaos_timeout: u64,
/// Wall-clock limit in seconds per scalability test
#[clap(long, default_value = "60", value_parser = clap::value_parser!(u64).range(1..=300))]
scalability_timeout: u64,
/// Wall-clock limit in seconds for both model-checking phases
#[clap(long, default_value_t = tests_mc::MC_TIME_LIMIT_SECONDS, value_parser = clap::value_parser!(u64).range(1..=tests_mc::MC_TIME_LIMIT_SECONDS))]
mc_timeout: u64,
/// Total wall-clock budget in seconds (the Docker watchdog is 600s)
#[clap(long, default_value = "540", value_parser = clap::value_parser!(u64).range(1..=540))]
suite_timeout: u64,
/// Fixed address-space limit in MiB for ordinary, chaos and scalability workers
#[clap(long, default_value = "4096", value_parser = clap::value_parser!(u64).range(128..=65536))]
memory_limit: u64,
/// Fixed address-space limit in MiB for the model-checking worker
#[clap(long, default_value = "14336", value_parser = clap::value_parser!(u64).range(128..=65536))]
mc_memory_limit: u64,
#[clap(long, hide = true, requires = "test-result")]
test_worker: Option<String>,
#[clap(long, hide = true)]
test_result: Option<String>,
#[clap(long, hide = true)]
test_progress: Option<String>,
#[clap(long, hide = true, default_value = "1")]
chaos_start: u32,
}
fn main() {
let args = Args::parse();
if args.test_worker.is_some() {
runner::worker(&args);
return;
}
if !library_policy::validate_or_report(&args.solution_path) {
return;
}
if !shared_state::validate_or_report(&args.solution_path, &["GroupMember"]) {
return;
}
let results = match runner::run(&args) {
Ok(results) => results,
Err(error) => {
eprintln!("ERROR: test environment: {error}");
std::process::exit(2);
}
};
if args.test.is_none() {
let score = score(results, args.monkeys, args.disable_mc_tests);
println!("\nSCORE: {score}\n");
}
}
macro_rules! string_set {
($($x:expr),+ $(,)?) => (
HashSet::from([$($x.to_string()),+])
);
}
fn score(results: IndexMap<String, Outcome>, monkeys: u32, disable_mc_tests: bool) -> f32 {
let mut basic_group = string_set! {
"SIMPLE",
"GET MEMBERS SEMANTICS",
"RANDOM SEED",
"PROCESS JOIN",
"PROCESS LEAVE",
"MODEL CHECKING",
};
let process_crash_group = string_set! {
"PROCESS CRASH",
"SEED PROCESS CRASH",
"PROCESS CRASH RECOVER",
};
let network_groups = [
(
"Network partitions tests",
string_set! {
"PROCESS OFFLINE",
"SEED PROCESS OFFLINE",
"PROCESS OFFLINE RECOVER",
"NETWORK PARTITION",
"NETWORK PARTITION RECOVER",
"NETWORK PARTITION UNEQUAL",
"NETWORK PARTITION UNEQUAL RECOVER",
},
),
(
"Network connectivity tests",
string_set! {
"PROCESS CANNOT RECEIVE",
"PROCESS JOIN CANNOT RECEIVE",
"PROCESS CANNOT SEND",
"TWO PROCESSES CANNOT COMMUNICATE",
"TWO PROCESSES CANNOT COMMUNICATE IN TRIANGLE",
},
),
(
"Network delays and losses tests",
string_set! {
"SLOW NETWORK",
"FLAKY NETWORK",
"FLAKY NETWORK ON START",
"FLAKY NETWORK AND CRASH",
},
),
(
"Chaos monkey tests",
(1..=monkeys)
.map(|run| format!("CHAOS MONKEY (run {run})"))
.collect(),
),
];
let scalability_group = string_set! {"SCALABILITY NORMAL", "SCALABILITY CRASH"};
// Explicit local opt-out changes the selected basic tests. An MC skipped
// because the suite ran out of time remains required during grading.
if disable_mc_tests {
basic_group.remove("MODEL CHECKING");
}
let mut all_groups = &(&basic_group | &process_crash_group) | &scalability_group;
let mut test_count = basic_group.len() + process_crash_group.len() + scalability_group.len();
for (_, group) in &network_groups {
all_groups.extend(group.iter().cloned());
test_count += group.len();
}
assert_eq!(all_groups.len(), test_count, "overlapping scoring groups");
assert_eq!(all_groups, results.keys().cloned().collect());
let scalability_skipped = scalability_group
.iter()
.all(|name| matches!(results.get(name), Some(Outcome::SkippedPrerequisites(_))));
let mut passed = HashSet::new();
let mut model_checking_ok = false;
for (test, result) in results {
if test == "MODEL CHECKING" && matches!(result, Outcome::Passed) {
model_checking_ok = true;
}
if result.satisfies_functional_requirement(&test) {
passed.insert(test);
}
}
let basic_group_ok = basic_group.is_subset(&passed);
let mut score = 0.;
print!("Basic tests: ");
if basic_group_ok {
score += 3.;
println!("passed");
} else {
let mut failed: Vec<&String> = basic_group.difference(&passed).collect();
failed.sort();
println!("not passed {failed:?}");
}
print!("Process crashes tests: ");
if process_crash_group.is_subset(&passed) {
score += 3.;
println!("passed");
} else {
let mut failed: Vec<&String> = process_crash_group.difference(&passed).collect();
failed.sort();
println!("not passed {failed:?}");
}
for (name, group) in &network_groups {
print!("{name}: ");
if group.is_subset(&passed) {
score += 1.;
println!("passed");
} else {
let mut failed: Vec<&String> = group.difference(&passed).collect();
failed.sort();
println!("not passed {failed:?}");
}
}
// The basic-group eligibility already includes the resource-limit and
// explicit local opt-out exceptions for MC.
let functional_tests_ok = basic_group_ok
&& process_crash_group.is_subset(&passed)
&& network_groups
.iter()
.all(|(_, group)| group.is_subset(&passed));
print!("Scalability tests: ");
if scalability_skipped {
println!("skipped (functional tests not passed)");
} else if scalability_group.is_subset(&passed) {
if functional_tests_ok {
score += 4.;
println!("passed");
} else {
println!("passed (no points: functional tests not passed)");
}
} else {
let mut failed: Vec<&String> = scalability_group.difference(&passed).collect();
failed.sort();
println!("not passed {failed:?}");
}
print!("Model checking test: ");
if model_checking_ok && basic_group_ok {
score += 1.;
println!("passed");
} else if model_checking_ok {
println!("passed (no bonus: basic tests not passed)");
} else {
println!("not passed");
}
score
}
#[cfg(test)]
mod score_tests {
use super::*;
#[test]
fn functional_groups_keep_their_points_after_resource_failures() {
assert_eq!(score(runner::passed_results(2), 2, false), 15.);
for (name, expected) in [
("SCALABILITY NORMAL", 11.),
("PROCESS CRASH", 8.),
("CHAOS MONKEY (run 2)", 10.),
] {
for failure in [
Outcome::TimeLimit("test budget".into()),
Outcome::MemoryLimit("256 MiB".into()),
Outcome::WorkerError("SIGKILL".into()),
Outcome::Skipped("not started".into()),
] {
let mut results = runner::passed_results(2);
results.insert(name.into(), failure);
assert_eq!(score(results, 2, false), expected, "{name}");
}
}
}
#[test]
fn each_network_test_only_blocks_its_subgroup_and_scalability() {
for name in [
"PROCESS OFFLINE",
"SEED PROCESS OFFLINE",
"PROCESS OFFLINE RECOVER",
"NETWORK PARTITION",
"NETWORK PARTITION RECOVER",
"NETWORK PARTITION UNEQUAL",
"NETWORK PARTITION UNEQUAL RECOVER",
"PROCESS CANNOT RECEIVE",
"PROCESS JOIN CANNOT RECEIVE",
"PROCESS CANNOT SEND",
"TWO PROCESSES CANNOT COMMUNICATE",
"TWO PROCESSES CANNOT COMMUNICATE IN TRIANGLE",
"SLOW NETWORK",
"FLAKY NETWORK",
"FLAKY NETWORK ON START",
"FLAKY NETWORK AND CRASH",
"CHAOS MONKEY (run 1)",
"CHAOS MONKEY (run 2)",
] {
for failure in [
Outcome::Failed("wrong members".into()),
Outcome::TimeLimit("test budget".into()),
Outcome::MemoryLimit("4096 MiB".into()),
Outcome::WorkerError("SIGKILL".into()),
Outcome::Skipped("not started".into()),
] {
let mut results = runner::passed_results(2);
results.insert(name.into(), failure.clone());
assert_eq!(score(results, 2, false), 10., "{name}: {failure:?}");
}
}
}
#[test]
fn multiple_failures_in_one_network_subgroup_have_the_same_penalty() {
for names in [
vec!["PROCESS OFFLINE", "NETWORK PARTITION RECOVER"],
vec!["PROCESS CANNOT RECEIVE", "TWO PROCESSES CANNOT COMMUNICATE"],
vec!["SLOW NETWORK", "FLAKY NETWORK AND CRASH"],
vec!["CHAOS MONKEY (run 1)", "CHAOS MONKEY (run 2)"],
] {
let mut results = runner::passed_results(2);
for name in &names {
results.insert((*name).into(), Outcome::Failed("wrong members".into()));
}
assert_eq!(score(results, 2, false), 10., "{names:?}");
}
}
#[test]
fn unsuccessful_ordinary_basic_tests_block_mc_bonus_and_scalability() {
for name in [
"SIMPLE",
"GET MEMBERS SEMANTICS",
"RANDOM SEED",
"PROCESS JOIN",
"PROCESS LEAVE",
] {
for failure in [
Outcome::Failed("wrong members".into()),
Outcome::TimeLimit("test budget".into()),
Outcome::MemoryLimit("4096 MiB".into()),
Outcome::WorkerError("SIGKILL".into()),
Outcome::Skipped("not started".into()),
] {
let mut results = runner::passed_results(2);
results.insert(name.into(), failure.clone());
assert_eq!(score(results, 2, false), 7., "{name}: {failure:?}");
}
}
}
#[test]
fn mc_outcomes_match_the_scoring_table_for_all_other_group_combinations() {
// outcome (None = explicit opt-out), basic eligibility, bonus if all basic tests pass.
let cases = [
(Some(Outcome::Passed), true, 1.),
(None, true, 0.),
(Some(Outcome::Failed("counterexample".into())), false, 0.),
(
Some(Outcome::WorkerError("Python exception".into())),
false,
0.,
),
(Some(Outcome::WorkerError("SIGKILL".into())), false, 0.),
(
Some(Outcome::TimeLimit("test budget of 450s".into())),
true,
0.,
),
(
Some(Outcome::TimeLimit("suite budget after MC started".into())),
true,
0.,
),
(Some(Outcome::MemoryLimit("14336 MiB".into())), true, 0.),
(
Some(Outcome::Skipped("suite budget before MC started".into())),
false,
0.,
),
];
let basic = [
"SIMPLE",
"GET MEMBERS SEMANTICS",
"RANDOM SEED",
"PROCESS JOIN",
"PROCESS LEAVE",
];
let other = [
("PROCESS CRASH", 3.),
("PROCESS OFFLINE", 1.),
("PROCESS CANNOT RECEIVE", 1.),
("SLOW NETWORK", 1.),
("CHAOS MONKEY (run 2)", 1.),
("SCALABILITY NORMAL", 4.),
];
for (mc, basic_eligible, bonus) in cases {
// All 32 combinations of the five ordinary basic tests, and all
// 64 combinations of crash, four network subgroups, and scalability.
for basic_failures in 0..32 {
for other_failures in 0..64 {
let mut results = runner::passed_results(2);
if let Some(outcome) = &mc {
results.insert("MODEL CHECKING".into(), outcome.clone());
} else {
results.shift_remove("MODEL CHECKING");
}
for (index, name) in basic.iter().enumerate() {
if basic_failures & (1 << index) != 0 {
results.insert((*name).into(), Outcome::Failed("wrong members".into()));
}
}
let mut expected = 0.;
if basic_eligible && basic_failures == 0 {
expected += 3. + bonus;
}
for (index, (name, points)) in other.iter().enumerate() {
if other_failures & (1 << index) != 0 {
results.insert((*name).into(), Outcome::Failed("wrong members".into()));
} else if *name != "SCALABILITY NORMAL" {
expected += points;
}
}
// Scalability needs both its own tests and every functional group.
if basic_eligible && basic_failures == 0 && other_failures == 0 {
expected += 4.;
}
let blockers = runner::scalability_blockers(&results, 2, mc.is_none());
let eligible =
basic_eligible && basic_failures == 0 && other_failures & 31 == 0;
assert_eq!(blockers.is_empty(), eligible,
"MC={mc:?}, basic failures={basic_failures:05b}, other failures={other_failures:06b}");
if !eligible {
for name in ["SCALABILITY NORMAL", "SCALABILITY CRASH"] {
results.insert(
name.into(),
Outcome::SkippedPrerequisites(blockers.clone()),
);
}
}
assert_eq!(score(results, 2, mc.is_none()), expected,
"MC={mc:?}, basic failures={basic_failures:05b}, other failures={other_failures:06b}");
}
}
}
}
}
@@ -1,83 +0,0 @@
//! Observe actual CPython allocation failures before AnySystem replaces a
//! Python exception with a generic panic. Student stdout is not a result channel.
use std::ffi::c_void;
use std::mem::MaybeUninit;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Once;
use pyo3::ffi::{self, PyMemAllocatorDomain, PyMemAllocatorEx};
use pyo3::Python;
static INSTALLED: Once = Once::new();
static FAILED: AtomicBool = AtomicBool::new(false);
fn observe(pointer: *mut c_void, nonzero: bool) -> *mut c_void {
if nonzero && pointer.is_null() {
FAILED.store(true, Ordering::Relaxed);
}
pointer
}
extern "C" fn malloc(ctx: *mut c_void, size: usize) -> *mut c_void {
// SAFETY: ctx points to an immutable original allocator, leaked for the
// interpreter's entire lifetime. Calls retain CPython's allocator contract.
let allocator = unsafe { &*ctx.cast::<PyMemAllocatorEx>() };
observe((allocator.malloc.unwrap())(allocator.ctx, size), size != 0)
}
extern "C" fn calloc(ctx: *mut c_void, count: usize, size: usize) -> *mut c_void {
// SAFETY: same lifetime and callback contract as malloc above.
let allocator = unsafe { &*ctx.cast::<PyMemAllocatorEx>() };
observe(
(allocator.calloc.unwrap())(allocator.ctx, count, size),
count != 0 && size != 0,
)
}
extern "C" fn realloc(ctx: *mut c_void, pointer: *mut c_void, size: usize) -> *mut c_void {
// SAFETY: same lifetime and callback contract as malloc above.
let allocator = unsafe { &*ctx.cast::<PyMemAllocatorEx>() };
observe(
(allocator.realloc.unwrap())(allocator.ctx, pointer, size),
size != 0,
)
}
extern "C" fn free(ctx: *mut c_void, pointer: *mut c_void) {
// SAFETY: same lifetime and callback contract as malloc above.
let allocator = unsafe { &*ctx.cast::<PyMemAllocatorEx>() };
(allocator.free.unwrap())(allocator.ctx, pointer);
}
pub fn install() {
Python::attach(|_| {
INSTALLED.call_once(|| {
for domain in [
PyMemAllocatorDomain::PYMEM_DOMAIN_RAW,
PyMemAllocatorDomain::PYMEM_DOMAIN_MEM,
PyMemAllocatorDomain::PYMEM_DOMAIN_OBJ,
] {
// SAFETY: installation holds the GIL before any student code runs.
// CPython requires post-initialization hooks to wrap the previous
// allocator. Its four callbacks are supplied by the interpreter.
unsafe {
let mut original = MaybeUninit::uninit();
ffi::PyMem_GetAllocator(domain, original.as_mut_ptr());
let original = Box::into_raw(Box::new(original.assume_init()));
let mut wrapper = PyMemAllocatorEx {
ctx: original.cast(),
malloc: Some(malloc),
calloc: Some(calloc),
realloc: Some(realloc),
free: Some(free),
};
ffi::PyMem_SetAllocator(domain, &mut wrapper);
}
}
})
});
}
pub fn reset() {
FAILED.store(false, Ordering::Relaxed);
}
pub fn failed() -> bool {
FAILED.load(Ordering::Relaxed)
}
@@ -1,165 +0,0 @@
//! Hard address-space limits are applied before exec, including Python imports.
//! Unlike an RSS polling loop, RLIMIT_AS also stops a single oversized allocation.
use std::fs::File;
use std::process::{Child, Command, ExitStatus};
#[cfg(target_os = "linux")]
const MIB: u64 = 1024 * 1024;
#[cfg(target_os = "linux")]
mod linux {
use std::fs;
use std::os::fd::OwnedFd;
use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt;
use std::process::Stdio;
use super::*;
fn number(path: &str) -> Option<u64> {
fs::read_to_string(path).ok()?.trim().parse().ok()
}
fn memory_capacity() -> Option<u64> {
let meminfo = fs::read_to_string("/proc/meminfo").ok()?;
let host = meminfo.lines().find_map(|line| {
line.strip_prefix("MemTotal:")?
.split_whitespace()
.next()?
.parse::<u64>()
.ok()
})? * 1024;
// Both namespaced cgroup v2 and the conventional v1 Docker mount.
let container = [
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
]
.into_iter()
.filter_map(number)
.min();
Some(container.map_or(host, |container| host.min(container)))
}
pub fn limit(requested_mib: u64) -> Result<u64, String> {
// Check static capacity, never current free memory or cgroup usage.
// Insufficient capacity is an environment error, not a lower grading limit.
let capacity = memory_capacity().ok_or("cannot determine memory capacity")? / MIB;
if requested_mib + 256 > capacity {
return Err(format!("fixed worker limit is {requested_mib} MiB, but environment capacity is {capacity} MiB; provide at least {} MiB including the supervisor reserve, or explicitly select a smaller local limit", requested_mib + 256));
}
let mut inherited = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
// SAFETY: getrlimit writes into an initialized rlimit structure.
if unsafe { libc::getrlimit(libc::RLIMIT_AS, &mut inherited) } != 0 {
return Err(std::io::Error::last_os_error().to_string());
}
if inherited.rlim_max / MIB < requested_mib {
return Err(format!("fixed worker limit is {requested_mib} MiB, but inherited address-space hard limit is {} MiB", inherited.rlim_max / MIB));
}
Ok(requested_mib)
}
pub fn capture(command: &mut Command) -> std::io::Result<File> {
let (reader, writer) = UnixStream::pair()?;
command.stdout(Stdio::from(OwnedFd::from(writer.try_clone()?)));
command.stderr(Stdio::from(OwnedFd::from(writer)));
Ok(File::from(OwnedFd::from(reader)))
}
pub fn configure(command: &mut Command, mib: u64) {
let supervisor = std::process::id() as libc::pid_t;
// SAFETY: only async-signal-safe libc calls run between fork and exec.
unsafe {
command.pre_exec(move || {
// Interrupting the supervisor must not leave a hanging worker.
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
return Err(std::io::Error::last_os_error());
}
if libc::getppid() != supervisor {
return Err(std::io::Error::from_raw_os_error(libc::ECHILD));
}
if libc::setsid() < 0 {
return Err(std::io::Error::last_os_error());
}
let memory = libc::rlimit {
rlim_cur: mib * MIB,
rlim_max: mib * MIB,
};
let core = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
if libc::setrlimit(libc::RLIMIT_AS, &memory) != 0
|| libc::setrlimit(libc::RLIMIT_CORE, &core) != 0
{
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
pub fn terminate(child: &mut Child) {
// The child starts a new session: clean up its descendants as well.
// SAFETY: the negative pid targets this worker's process group.
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
let _ = child.kill();
let _ = child.wait();
}
pub fn memory_usage(child: &Child) -> Option<u64> {
fs::read_to_string(format!("/proc/{}/status", child.id()))
.ok()?
.lines()
.find_map(|line| {
line.strip_prefix("VmSize:")?
.split_whitespace()
.next()?
.parse()
.ok()
})
.map(|kib: u64| kib / 1024)
}
}
#[cfg(target_os = "linux")]
pub use linux::{capture, configure, limit, memory_usage, terminate};
#[cfg(not(target_os = "linux"))]
pub fn limit(_: u64) -> Result<u64, String> {
Err("resource-limited workers require Linux; use the homework Docker image".into())
}
#[cfg(not(target_os = "linux"))]
pub fn capture(_: &mut Command) -> std::io::Result<File> {
Err(std::io::Error::other("use the homework Docker image"))
}
#[cfg(not(target_os = "linux"))]
pub fn configure(_: &mut Command, _: u64) {}
#[cfg(not(target_os = "linux"))]
pub fn terminate(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
#[cfg(not(target_os = "linux"))]
pub fn memory_usage(_: &Child) -> Option<u64> {
None
}
pub fn allocation_crash(status: Option<ExitStatus>, output: &str) -> bool {
#[cfg(target_os = "linux")]
{
use std::os::unix::process::ExitStatusExt;
status.is_some_and(|status| status.signal() == Some(libc::SIGABRT))
&& output.contains("memory allocation of ")
&& output.contains(" bytes failed")
}
#[cfg(not(target_os = "linux"))]
{
let _ = (status, output);
false
}
}
-910
View File
@@ -1,910 +0,0 @@
//! Based on HW4's killable-process runner. Only workers execute solution code.
//! Chaos runs share a worker, with an append-only result journal after every run.
use std::cell::OnceCell;
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Write};
use std::path::PathBuf;
use std::process::Command;
use std::sync::OnceLock;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anysystem::python::PyProcessFactory;
use anysystem::test::TestResult;
use env_logger::Builder;
use indexmap::IndexMap;
use log::{info, LevelFilter};
use pyo3::prelude::*;
use rand::{RngCore, SeedableRng};
use rand_pcg::Pcg64;
use serde::{Deserialize, Serialize};
use crate::common::TestConfig;
use crate::tests::*;
use crate::tests_mc::test_mc_group;
use crate::Args;
use crate::{python_memory, resources};
type TestFn = fn(&TestConfig) -> TestResult;
const ORDINARY: &[(&str, TestFn)] = &[
("SIMPLE", test_simple),
("GET MEMBERS SEMANTICS", test_get_members_semantics),
("RANDOM SEED", test_random_seed),
("PROCESS JOIN", test_process_join),
("PROCESS LEAVE", test_process_leave),
("PROCESS CRASH", test_process_crash),
("SEED PROCESS CRASH", test_seed_process_crash),
("PROCESS CRASH RECOVER", test_process_crash_recover),
("PROCESS OFFLINE", test_process_offline),
("SEED PROCESS OFFLINE", test_seed_process_offline),
("PROCESS OFFLINE RECOVER", test_process_offline_recover),
("PROCESS CANNOT RECEIVE", test_process_cannot_receive),
(
"PROCESS JOIN CANNOT RECEIVE",
test_process_join_cannot_receive,
),
("PROCESS CANNOT SEND", test_process_cannot_send),
("NETWORK PARTITION", test_network_partition),
("NETWORK PARTITION RECOVER", test_network_partition_recover),
("NETWORK PARTITION UNEQUAL", test_network_partition_unequal),
(
"NETWORK PARTITION UNEQUAL RECOVER",
test_network_partition_unequal_recover,
),
(
"TWO PROCESSES CANNOT COMMUNICATE",
test_two_processes_cannot_communicate,
),
(
"TWO PROCESSES CANNOT COMMUNICATE IN TRIANGLE",
test_two_processes_cannot_communicate_in_triangle,
),
("SLOW NETWORK", test_slow_network),
("FLAKY NETWORK", test_flaky_network),
("FLAKY NETWORK ON START", test_flaky_network_on_start),
("FLAKY NETWORK AND CRASH", test_flaky_network_and_crash),
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Outcome {
Passed,
Failed(String),
TimeLimit(String),
MemoryLimit(String),
WorkerError(String),
Skipped(String),
SkippedPrerequisites(Vec<String>),
}
impl Outcome {
pub fn resource_limited(&self) -> bool {
matches!(self, Self::TimeLimit(_) | Self::MemoryLimit(_))
}
// A resource failure before the Started record is not an attempted test.
// In particular it must not grant the MC exemption for the basic group.
fn require_started(self, started: bool) -> Self {
if !started && self.resource_limited() {
Self::Skipped(format!("test did not start; {}", self.description()))
} else {
self
}
}
pub fn satisfies_functional_requirement(&self, name: &str) -> bool {
matches!(self, Self::Passed) || (name == "MODEL CHECKING" && self.resource_limited())
}
fn description(&self) -> String {
match self {
Self::Passed => "passed".into(),
Self::Failed(message) => message.clone(),
Self::TimeLimit(message) => format!("time limit exceeded: {message}"),
Self::MemoryLimit(message) => format!("memory limit exceeded: {message}"),
Self::WorkerError(message) => format!("test worker error: {message}"),
Self::Skipped(message) => format!("not checked: {message}"),
Self::SkippedPrerequisites(tests) => {
let mut names = tests.iter().take(8).cloned().collect::<Vec<_>>().join(", ");
if tests.len() > 8 {
names.push_str(&format!(" (and {} more)", tests.len() - 8));
}
format!("required functional tests not passed: {names}")
}
}
}
}
#[derive(Serialize, Deserialize)]
enum Event {
Started(String),
Finished(String, Outcome),
}
struct Test {
name: String,
run: TestFn,
seed: u64,
chaos_run: Option<u32>,
}
fn tests(args: &Args) -> Vec<Test> {
let mut tests: Vec<_> = ORDINARY
.iter()
.map(|&(name, run)| Test {
name: name.into(),
run,
seed: args.seed,
chaos_run: None,
})
.collect();
let mut rand = Pcg64::seed_from_u64(args.seed);
for run in 1..=args.monkeys {
tests.push(Test {
name: format!("CHAOS MONKEY (run {run})"),
run: test_chaos_monkey,
seed: rand.next_u64(),
chaos_run: Some(run),
});
}
for (name, run) in [
("MODEL CHECKING", test_mc_group as TestFn),
("SCALABILITY NORMAL", test_scalability_normal as TestFn),
("SCALABILITY CRASH", test_scalability_crash as TestFn),
] {
if name != "MODEL CHECKING" || !args.disable_mc_tests {
tests.push(Test {
name: name.into(),
run,
seed: args.seed,
chaos_run: None,
});
}
}
tests
}
/// Missing, failed and unstarted functional tests block scalability. Only an
/// explicitly disabled MC or a resource limit after it started is exempt.
pub fn scalability_blockers(
results: &IndexMap<String, Outcome>,
monkeys: u32,
disable_mc_tests: bool,
) -> Vec<String> {
ORDINARY
.iter()
.map(|(name, _)| (*name).to_string())
.chain((1..=monkeys).map(|run| format!("CHAOS MONKEY (run {run})")))
.chain((!disable_mc_tests).then(|| "MODEL CHECKING".to_string()))
.filter(|name| {
!results
.get(name)
.is_some_and(|outcome| outcome.satisfies_functional_requirement(name))
})
.collect()
}
static PROGRESS: OnceLock<PathBuf> = OnceLock::new();
/// Bounded, replaceable diagnostics; never mixed with the structured result stream.
pub fn progress(message: impl AsRef<str>) {
if let Some(path) = PROGRESS.get() {
let temporary = path.with_extension("next");
if fs::write(&temporary, message.as_ref()).is_ok() {
let _ = fs::rename(&temporary, path);
}
}
}
fn event(file: &mut File, event: Event) {
serde_json::to_writer(&mut *file, &event).unwrap();
writeln!(file).unwrap();
file.flush().unwrap();
}
fn report(name: &str, outcome: &Outcome) {
match outcome {
Outcome::Passed => println!("\nPASSED\n"),
Outcome::Skipped(_) | Outcome::SkippedPrerequisites(_) => {
println!("\nSKIPPED: {}\n", outcome.description())
}
_ => println!("\nFAILED: {}\n", outcome.description()),
}
if name == "MODEL CHECKING" && outcome.resource_limited() {
println!("INCOMPLETE: model checking reached its resource limit; no MC bonus, basic tests remain eligible\n");
}
std::io::stdout().flush().unwrap();
}
pub fn worker(args: &Args) {
if let Some(path) = &args.test_progress {
let _ = PROGRESS.set(PathBuf::from(path));
}
if args.debug || args.verbose {
let mut logger = Builder::new();
logger.filter(Some("distsys_membership"), LevelFilter::Info);
if args.debug {
logger.filter(Some("anysystem"), LevelFilter::Debug);
}
logger
.format(|buf, record| writeln!(buf, "{}", record.args()))
.init();
}
info!(
"Test parameters: seed={}, processes={}",
args.seed, args.process_count
);
let mut journal = OpenOptions::new()
.append(true)
.open(args.test_result.as_ref().unwrap())
.unwrap();
let factory = OnceCell::new();
for test in tests(args).into_iter().filter(|test| {
if args.test_worker.as_deref() == Some("CHAOS MONKEY") {
test.chaos_run.is_some_and(|run| run >= args.chaos_start)
} else {
args.test_worker.as_deref() == Some(test.name.as_str())
}
}) {
progress("loading Python implementation");
event(&mut journal, Event::Started(test.name.clone()));
println!("\n--- {} ---\n", test.name);
std::io::stdout().flush().unwrap();
python_memory::reset();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
python_memory::install();
let factory = factory.get_or_init(|| {
// Match the preflight's local-import root when --impl is outside cwd.
let solution = fs::canonicalize(&args.solution_path).unwrap();
let directory = solution.parent().unwrap().to_string_lossy();
Python::attach(|py| {
py.import("sys")
.and_then(|sys| sys.getattr("path"))
.and_then(|path| path.call_method1("insert", (0, directory.as_ref())))
.unwrap();
});
PyProcessFactory::new(&args.solution_path, "GroupMember")
});
progress("initializing simulation");
let config = TestConfig {
process_factory: factory,
process_count: if test.name == "MODEL CHECKING" {
3
} else {
args.process_count
},
seed: test.seed,
};
(test.run)(&config)
}));
let mut outcome = match result {
Ok(Ok(_)) => Outcome::Passed,
Ok(Err(message))
if test.name == "MODEL CHECKING"
&& message
== format!(
"time limit of {}s exceeded",
crate::tests_mc::MC_TIME_LIMIT_SECONDS
) =>
{
Outcome::TimeLimit(message)
}
Ok(Err(message)) => Outcome::Failed(message),
Err(payload) => {
let message = payload
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| payload.downcast_ref::<&str>().copied())
.unwrap_or("test panicked")
.to_owned();
if python_memory::failed() {
Outcome::MemoryLimit(message)
} else {
Outcome::WorkerError(message)
}
}
};
if let Outcome::MemoryLimit(message) = &outcome {
let phase = PROGRESS
.get()
.and_then(|path| fs::read_to_string(path).ok())
.unwrap_or_default();
outcome = Outcome::MemoryLimit(format!(
"worker address-space limit {} MiB; {phase}; {message}",
memory_limit(args, &test)
));
}
// Bound IPC diagnostics independently of the worker's memory budget.
match &mut outcome {
Outcome::Failed(message)
| Outcome::TimeLimit(message)
| Outcome::MemoryLimit(message)
| Outcome::WorkerError(message)
| Outcome::Skipped(message)
if message.len() > 16384 =>
{
let mut end = 16384;
while !message.is_char_boundary(end) {
end -= 1;
}
message.truncate(end);
message.push_str(" [diagnostic truncated]");
}
_ => {}
}
event(
&mut journal,
Event::Finished(test.name.clone(), outcome.clone()),
);
// Report panics after draining their diagnostics from the output pipe.
if !matches!(outcome, Outcome::WorkerError(_)) {
report(&test.name, &outcome);
}
// A panic can leave the embedded interpreter or simulator in an unknown state.
if matches!(outcome, Outcome::WorkerError(_) | Outcome::MemoryLimit(_)) {
break;
}
}
}
struct Files(PathBuf);
impl Files {
fn new() -> std::io::Result<Self> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path =
std::env::temp_dir().join(format!("membership-test-{}-{nonce}", std::process::id()));
fs::create_dir(&path)?;
let files = Self(path);
File::create(files.journal())?;
Ok(files)
}
fn journal(&self) -> PathBuf {
self.0.join("results.jsonl")
}
fn progress(&self) -> PathBuf {
self.0.join("progress.txt")
}
}
impl Drop for Files {
fn drop(&mut self) {
for name in ["results.jsonl", "progress.txt", "progress.next"] {
let _ = fs::remove_file(self.0.join(name));
}
let _ = fs::remove_dir(&self.0);
}
}
// Retain a bounded diagnostic tail for native allocator failures. Python
// allocation failures are tracked separately; output alone is not an outcome.
fn output_reader(mut pipe: impl Read + Send + 'static) -> thread::JoinHandle<String> {
thread::spawn(move || {
let mut tail = Vec::new();
let mut chunk = [0; 4096];
while let Ok(count) = pipe.read(&mut chunk) {
if count == 0 {
break;
}
let _ = std::io::stdout().lock().write_all(&chunk[..count]);
tail.extend_from_slice(&chunk[..count]);
if tail.len() > 65536 {
tail.drain(..tail.len() - 65536);
}
}
String::from_utf8_lossy(&tail).into_owned()
})
}
struct Journal {
reader: BufReader<File>,
pending: String,
next: usize,
started: Instant,
announced: bool,
}
impl Journal {
fn read(
&mut self,
batch: &[Test],
results: &mut IndexMap<String, Outcome>,
) -> Result<(), String> {
loop {
let remaining = 65537_usize.saturating_sub(self.pending.len());
let count = (&mut self.reader)
.take(remaining as u64)
.read_line(&mut self.pending)
.map_err(|error| error.to_string())?;
if self.pending.len() > 65536 {
return Err("worker result exceeds 64 KiB".into());
}
if !self.pending.ends_with('\n') {
return Ok(());
}
let event: Event = serde_json::from_str(&self.pending)
.map_err(|error| format!("invalid worker result: {error}"))?;
self.pending.clear();
let name = match &event {
Event::Started(name) | Event::Finished(name, _) => name,
};
if batch.get(self.next).map(|test| &test.name) != Some(name) {
return Err(format!("unexpected worker result for {name}"));
}
match event {
Event::Started(_) => {
self.started = Instant::now();
self.announced = true;
}
Event::Finished(name, outcome) => {
results.insert(name, outcome);
self.next += 1;
self.started = Instant::now();
self.announced = false;
}
}
if count == 0 {
return Ok(());
}
}
}
}
fn memory_limit(args: &Args, test: &Test) -> u64 {
if test.name == "MODEL CHECKING" {
args.mc_memory_limit
} else {
args.memory_limit
}
}
fn seconds(args: &Args, test: &Test) -> u64 {
if test.name == "MODEL CHECKING" {
args.mc_timeout
} else if test.name.starts_with("SCALABILITY") {
args.scalability_timeout
} else {
args.test_timeout
}
}
fn run_worker(
args: &Args,
batch: &[Test],
results: &mut IndexMap<String, Outcome>,
suite: Instant,
phase: Instant,
) -> usize {
let setup = || -> Result<_, String> {
let files = Files::new().map_err(|error| error.to_string())?;
let limit = resources::limit(memory_limit(args, &batch[0]))?;
let mut command = Command::new(std::env::current_exe().map_err(|error| error.to_string())?);
command
.arg("--test-worker")
.arg(if batch.len() > 1 {
"CHAOS MONKEY"
} else {
&batch[0].name
})
.args([
"-i",
&args.solution_path,
"-s",
&args.seed.to_string(),
"-p",
&args.process_count.to_string(),
"-m",
&args.monkeys.to_string(),
"--chaos-start",
&batch[0].chaos_run.unwrap_or(1).to_string(),
"--memory-limit",
&args.memory_limit.to_string(),
"--mc-memory-limit",
&args.mc_memory_limit.to_string(),
"--test-result",
])
.arg(files.journal())
.arg("--test-progress")
.arg(files.progress())
.env("PYTHONHASHSEED", (args.seed as u32).to_string())
.env("PYTHONUNBUFFERED", "1");
if args.debug {
command.arg("-d");
}
if args.verbose {
command.arg("-v");
}
if args.disable_mc_tests {
command.arg("--disable-mc-tests");
}
resources::configure(&mut command, limit);
let reader =
BufReader::new(File::open(files.journal()).map_err(|error| error.to_string())?);
let output = resources::capture(&mut command).map_err(|error| error.to_string())?;
let child = command.spawn().map_err(|error| error.to_string())?;
Ok((files, limit, reader, output, child))
};
let (files, limit, reader, output, mut child) = match setup() {
Ok(setup) => setup,
Err(error) => {
let outcome = Outcome::WorkerError(error);
println!("\n--- {} ---\n", batch[0].name);
report(&batch[0].name, &outcome);
results.insert(batch[0].name.clone(), outcome);
return 1;
}
};
let stderr = output_reader(output);
let mut journal = Journal {
reader,
pending: String::new(),
next: 0,
started: Instant::now(),
announced: false,
};
let mut last_progress = Instant::now();
let mut detail = "starting worker".to_owned();
let mut stopped = None;
let status = loop {
if let Err(error) = journal.read(batch, results) {
stopped = Some(Outcome::WorkerError(error));
break None;
}
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Err(error) => {
stopped = Some(Outcome::WorkerError(error.to_string()));
break None;
}
Ok(None) => {}
}
if let Ok(message) = fs::read_to_string(files.progress()) {
detail = message;
}
let now = Instant::now();
let current = &batch[journal.next.min(batch.len() - 1)];
let limit_seconds = seconds(args, current);
if now >= suite
|| now >= phase
|| now.duration_since(journal.started).as_secs() >= limit_seconds
{
let budget = if now >= suite {
format!("suite budget of {}s", args.suite_timeout)
} else if now >= phase {
format!("chaos budget of {}s", args.chaos_timeout)
} else {
format!("test budget of {limit_seconds}s")
};
stopped = Some(Outcome::TimeLimit(format!("{budget}; {detail}")));
break None;
}
if last_progress.elapsed() >= Duration::from_secs(5) {
let memory = resources::memory_usage(&child).map_or(String::new(), |mib| {
format!("; address space {mib}/{limit} MiB")
});
eprintln!(
"RUNNING: {}; {}; elapsed {:.0}s{}",
current.name,
detail,
journal.started.elapsed().as_secs_f64(),
memory
);
last_progress = now;
}
thread::sleep(Duration::from_millis(10));
};
resources::terminate(&mut child);
let stderr = stderr.join().unwrap_or_default();
// The worker may have committed another result between the last poll and exit.
if let Err(error) = journal.read(batch, results) {
stopped = Some(Outcome::WorkerError(error));
}
if journal.next > 0 {
let finished = &batch[journal.next - 1];
if let Some(Outcome::WorkerError(message)) = results.get(&finished.name) {
let phase = fs::read_to_string(files.progress()).unwrap_or_default();
let outcome = Outcome::WorkerError(format!("{message}; {phase}"));
report(&finished.name, &outcome);
results.insert(finished.name.clone(), outcome);
}
}
if journal.next == batch.len() {
return journal.next;
}
// A handled panic/allocation failure ends the batch cleanly. Restart the
// remaining runs without inventing a failure for a test that never started.
if stopped.is_none() && journal.next > 0 && !journal.announced {
return journal.next;
}
if let Ok(message) = fs::read_to_string(files.progress()) {
detail = message;
}
let test = &batch[journal.next];
let outcome = stopped.unwrap_or_else(|| {
if resources::allocation_crash(status, &stderr) {
Outcome::MemoryLimit(format!("worker address-space limit {limit} MiB; {detail}"))
} else {
Outcome::WorkerError(format!(
"{}; {detail}",
status.map_or("worker stopped".into(), |status| format!(
"exited with {status} without a result"
))
))
}
});
// Read the final journal first: Started may have arrived after the timeout
// poll. Only an actually started test is eligible for a resource exemption.
let outcome = outcome.require_started(journal.announced);
if !journal.announced {
println!("\n--- {} ---\n", test.name);
}
report(&test.name, &outcome);
println!(
"REPRODUCE: -t {:?} -s {} -p {} -m {} (effective run seed: {})\n",
test.name, args.seed, args.process_count, args.monkeys, test.seed
);
results.insert(test.name.clone(), outcome);
journal.next + 1
}
pub fn run(args: &Args) -> Result<IndexMap<String, Outcome>, String> {
let selected: Vec<_> = tests(args)
.into_iter()
.filter(|test| args.test.as_ref().is_none_or(|name| name == &test.name))
.collect();
if selected.is_empty() {
println!("FAILED: unknown test {:?}", args.test);
return Ok(IndexMap::new());
}
let maximum_memory = selected
.iter()
.map(|test| memory_limit(args, test))
.max()
.unwrap();
resources::limit(maximum_memory)?;
let suite = Instant::now() + Duration::from_secs(args.suite_timeout);
let mut results: IndexMap<_, _> = selected
.iter()
.map(|test| {
(
test.name.clone(),
Outcome::Skipped(format!(
"suite budget of {}s exhausted before this test started",
args.suite_timeout
)),
)
})
.collect();
let mut next = 0;
while next < selected.len() && Instant::now() < suite {
// A single explicitly selected test remains independently runnable.
if args.test.is_none() && selected[next].name.starts_with("SCALABILITY") {
let blockers = scalability_blockers(&results, args.monkeys, args.disable_mc_tests);
if !blockers.is_empty() {
for test in &selected[next..] {
let outcome = Outcome::SkippedPrerequisites(blockers.clone());
println!("\n--- {} ---\n", test.name);
report(&test.name, &outcome);
results.insert(test.name.clone(), outcome);
}
break;
}
}
let mut end = next + 1;
let chaos = selected[next].chaos_run.is_some();
if chaos {
while end < selected.len() && selected[end].chaos_run.is_some() {
end += 1;
}
}
let phase = if chaos {
Instant::now() + Duration::from_secs(args.chaos_timeout)
} else {
suite
};
while next < end && Instant::now() < suite && Instant::now() < phase {
next += run_worker(args, &selected[next..end], &mut results, suite, phase);
}
if next < end {
if Instant::now() < suite {
for test in &selected[next..end] {
results.insert(
test.name.clone(),
Outcome::Skipped(format!(
"chaos budget of {}s exhausted before this run started",
args.chaos_timeout
)),
);
}
}
next = end;
}
}
if args.test.is_none() {
let passed = results
.values()
.filter(|result| matches!(result, Outcome::Passed))
.count();
println!("-------------------------------------------------------------------------------\n\nPassed {passed} from {} tests\n", selected.len());
for (name, result) in &results {
if !matches!(result, Outcome::Passed) {
println!("- {name}: {}", result.description());
}
}
println!();
}
Ok(results)
}
#[cfg(test)]
pub fn passed_results(monkeys: u32) -> IndexMap<String, Outcome> {
let args = <Args as clap::Parser>::parse_from(["membership", "-m", &monkeys.to_string()]);
tests(&args)
.into_iter()
.map(|test| (test.name, Outcome::Passed))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_failure_requires_a_started_record_even_at_the_deadline() {
// Model the final journal drain on either side of the startup boundary.
// With no Started record, the result must remain ineligible for MC credit.
for resource in [
Outcome::TimeLimit("suite budget exhausted during startup".into()),
Outcome::MemoryLimit("allocation failed during startup".into()),
] {
let files = Files::new().unwrap();
let args = <Args as clap::Parser>::parse_from(["membership", "-m", "0"]);
let registry = tests(&args);
let mc_index = registry
.iter()
.position(|test| test.name == "MODEL CHECKING")
.unwrap();
let batch = &registry[mc_index..mc_index + 1];
let mut journal = Journal {
reader: BufReader::new(File::open(files.journal()).unwrap()),
pending: String::new(),
next: 0,
started: Instant::now(),
announced: false,
};
let mut results = IndexMap::new();
journal.read(batch, &mut results).unwrap();
let outcome = resource.clone().require_started(journal.announced);
assert!(matches!(outcome, Outcome::Skipped(_)));
assert!(!outcome.resource_limited());
// Started committed just before termination is picked up by the
// final drain, and makes a genuine interrupted run resource-limited.
let mut writer = OpenOptions::new()
.append(true)
.open(files.journal())
.unwrap();
event(&mut writer, Event::Started(batch[0].name.clone()));
journal.read(batch, &mut results).unwrap();
assert!(resource
.require_started(journal.announced)
.resource_limited());
}
}
#[test]
fn mc_budget_is_450_seconds_with_the_same_suite_and_other_test_limits() {
let args = <Args as clap::Parser>::parse_from(["membership"]);
assert_eq!(args.mc_timeout, 450);
assert_eq!(args.suite_timeout, 540);
assert_eq!(args.chaos_timeout, 300);
for test in tests(&args) {
let expected = if test.name == "MODEL CHECKING" {
450
} else if test.name.starts_with("SCALABILITY") {
60
} else {
15
};
assert_eq!(seconds(&args, &test), expected, "{}", test.name);
}
assert!(
<Args as clap::Parser>::try_parse_from(["membership", "--mc-timeout", "450"]).is_ok()
);
for value in ["0", "451"] {
assert!(
<Args as clap::Parser>::try_parse_from(["membership", "--mc-timeout", value])
.is_err()
);
}
}
#[test]
fn default_memory_limits_are_separate_for_mc_and_simulation() {
let args = <Args as clap::Parser>::parse_from(["membership"]);
for test in tests(&args) {
assert_eq!(
memory_limit(&args, &test),
if test.name == "MODEL CHECKING" {
14336
} else {
4096
}
);
}
}
#[test]
fn registry_preserves_all_tests_and_chaos_seeds() {
let args = <Args as clap::Parser>::parse_from(["membership", "-m", "1000"]);
let registry = tests(&args);
assert_eq!(registry.len(), 1027);
let ordinary = ORDINARY.len();
assert_eq!(registry[0].name, "SIMPLE");
assert_eq!(registry[ordinary].name, "CHAOS MONKEY (run 1)");
assert_eq!(registry[ordinary + 999].name, "CHAOS MONKEY (run 1000)");
assert_eq!(registry[ordinary + 1000].name, "MODEL CHECKING");
assert_eq!(registry[ordinary + 1001].name, "SCALABILITY NORMAL");
assert_eq!(registry[ordinary + 1002].name, "SCALABILITY CRASH");
let mut rand = Pcg64::seed_from_u64(123);
for test in &registry[ordinary..ordinary + 1000] {
assert_eq!(test.seed, rand.next_u64());
}
}
#[test]
fn oversized_ipc_records_do_not_exhaust_supervisor_memory() {
let files = Files::new().unwrap();
fs::write(files.journal(), vec![b'x'; 100_000]).unwrap();
let args = <Args as clap::Parser>::parse_from(["membership"]);
let mut journal = Journal {
reader: BufReader::new(File::open(files.journal()).unwrap()),
pending: String::new(),
next: 0,
started: Instant::now(),
announced: false,
};
let error = journal
.read(&tests(&args), &mut IndexMap::new())
.unwrap_err();
assert_eq!(error, "worker result exceeds 64 KiB");
assert!(journal.pending.len() <= 65537);
}
#[test]
fn journal_keeps_completed_results_and_waits_for_partial_records() {
let files = Files::new().unwrap();
let args = <Args as clap::Parser>::parse_from(["membership", "-m", "2"]);
let registry = tests(&args);
let batch = &registry[ORDINARY.len()..ORDINARY.len() + 2];
let mut writer = OpenOptions::new()
.append(true)
.open(files.journal())
.unwrap();
event(&mut writer, Event::Started(batch[0].name.clone()));
event(
&mut writer,
Event::Finished(batch[0].name.clone(), Outcome::Passed),
);
let mut journal = Journal {
reader: BufReader::new(File::open(files.journal()).unwrap()),
pending: String::new(),
next: 0,
started: Instant::now(),
announced: false,
};
let mut results = IndexMap::new();
journal.read(batch, &mut results).unwrap();
assert_eq!(journal.next, 1);
assert!(matches!(results[&batch[0].name], Outcome::Passed));
let record = serde_json::to_string(&Event::Started(batch[1].name.clone())).unwrap();
writer.write_all(record.as_bytes()).unwrap();
writer.flush().unwrap();
journal.read(batch, &mut results).unwrap();
assert!(!journal.announced);
writeln!(writer).unwrap();
writer.flush().unwrap();
journal.read(batch, &mut results).unwrap();
assert!(journal.announced);
assert_eq!(results.len(), 1);
}
}
@@ -1,78 +0,0 @@
use std::ffi::CString;
use pyo3::prelude::*;
use pyo3::types::{PyList, PyModule};
const VALIDATOR_CODE: &str = include_str!("../shared_state_validator.py");
pub(crate) fn validator_module<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyModule>> {
let code = CString::new(VALIDATOR_CODE).expect("validator source contains a null byte");
PyModule::from_code(
py,
&code,
c"shared_state_validator.py",
c"shared_state_validator",
)
}
#[cfg(test)]
fn validate_source(
source: &str,
filename: &str,
class_names: &[&str],
) -> Result<Vec<String>, String> {
Python::attach(|py| {
let module = validator_module(py).map_err(|error| error.to_string())?;
let class_names = PyList::new(py, class_names).map_err(|error| error.to_string())?;
module
.getattr("validate_source")
.and_then(|function| function.call1((source, filename, class_names)))
.and_then(|result| result.extract::<Vec<String>>())
.map_err(|error| error.to_string())
})
}
fn validate_solution(path: &str, class_names: &[&str]) -> Result<Vec<String>, String> {
Python::attach(|py| {
let module = validator_module(py).map_err(|error| error.to_string())?;
let class_names = PyList::new(py, class_names).map_err(|error| error.to_string())?;
module
.getattr("validate_solution")
.and_then(|function| function.call1((path, class_names)))
.and_then(|result| result.extract::<Vec<String>>())
.map_err(|error| error.to_string())
})
}
pub fn validate_or_report(path: &str, class_names: &[&str]) -> bool {
match validate_solution(path, class_names) {
Ok(violations) if violations.is_empty() => true,
Ok(violations) => {
println!("ERROR: shared state between AnySystem processes is forbidden");
for violation in violations {
println!("{violation}");
}
println!("\nSCORE: 0\n");
false
}
Err(error) => panic!("failed to validate solution for shared state: {error}"),
}
}
#[cfg(test)]
mod tests {
use super::validate_source;
#[test]
fn embeds_validator_and_rejects_shared_state() {
let violations = validate_source(
"class ProcessImpl:\n shared = {}\n",
"solution.py",
&["ProcessImpl"],
)
.unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("ProcessImpl.shared"));
}
}
-813
View File
@@ -1,813 +0,0 @@
use std::collections::{BTreeSet, HashMap};
use assertables::{assume, assume_eq};
use log::info;
use rand::prelude::*;
use rand_pcg::Pcg64;
use sugars::boxed;
use anysystem::test::TestResult;
use anysystem::{Message, System};
use crate::common::*;
pub fn test_simple(config: &TestConfig) -> TestResult {
let mut sys = build_system(config);
let group = sys.process_names();
let seed = &group[0];
initialize_group(&mut sys, &group, seed)
}
pub fn test_get_members_semantics(config: &TestConfig) -> TestResult {
let mut sys = build_system(config);
let group = sys.process_names();
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
for proc in sys.process_names() {
get_members(&mut sys, &proc)?;
}
Ok(true)
}
pub fn test_random_seed(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = Vec::new();
for proc in sys.process_names() {
let seed = match group.len() {
0 => &proc,
_ => group.choose(&mut rand).unwrap(),
};
sys.send_local_message(&proc, Message::json("JOIN", &JoinMessage { seed }));
group.push(proc);
}
step_until_stabilized(&mut sys, group.into_iter().collect())
}
pub fn test_process_join(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let new_proc = group.remove(rand.gen_range(0..group.len()));
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// process joins the system
sys.send_local_message(&new_proc, Message::json("JOIN", &JoinMessage { seed }));
group.push(new_proc);
step_until_stabilized(&mut sys, group.into_iter().collect())
}
pub fn test_process_leave(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// process leaves the system
let left_proc = group.remove(rand.gen_range(0..group.len()));
sys.send_local_message(&left_proc, Message::json("LEAVE", &LeaveMessage {}));
step_until_stabilized(&mut sys, group.into_iter().collect())
}
pub fn test_process_crash(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// process crashes
let crashed = group.remove(rand.gen_range(0..group.len()));
crash_process(&crashed, &mut sys);
step_until_stabilized(&mut sys, group.into_iter().collect())
}
pub fn test_seed_process_crash(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0].clone();
initialize_group(&mut sys, &group, seed)?;
// seed process crashes
group.remove(0);
crash_process(seed, &mut sys);
step_until_stabilized(&mut sys, group.into_iter().collect())
}
pub fn test_process_crash_recover(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0].clone();
initialize_group(&mut sys, &group, seed)?;
// process crashes
let crashed = group.remove(rand.gen_range(0..group.len()));
crash_process(&crashed, &mut sys);
step_until_stabilized(&mut sys, group.clone().into_iter().collect())?;
// process recovers
recover_process(&crashed, &mut sys, config);
sys.send_local_message(&crashed, Message::json("JOIN", &JoinMessage { seed }));
group.push(crashed);
step_until_membership_stable(&mut sys, group.into_iter().collect(), "process recovery")
}
pub fn test_process_offline(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// process goes offline
let offline_proc = group.remove(rand.gen_range(0..group.len()));
sys.network().disconnect_node(&offline_proc);
step_until_stabilized(&mut sys, group.into_iter().collect())?;
step_until_stabilized_at(&mut sys, BTreeSet::from([offline_proc]), "isolated process")
}
pub fn test_seed_process_offline(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0].clone();
initialize_group(&mut sys, &group, seed)?;
// seed process goes offline
group.remove(0);
sys.network().disconnect_node(seed);
step_until_stabilized(&mut sys, group.into_iter().collect())?;
step_until_stabilized_at(&mut sys, BTreeSet::from([seed.clone()]), "isolated process")
}
pub fn test_process_offline_recover(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// process goes offline
let offline_proc = group.remove(rand.gen_range(0..group.len()));
sys.network().disconnect_node(&offline_proc);
step_until_stabilized(&mut sys, group.clone().into_iter().collect())?;
step_until_stabilized_at(
&mut sys,
BTreeSet::from([offline_proc.clone()]),
"isolated process",
)?;
// process goes back online
sys.network().connect_node(&offline_proc);
group.push(offline_proc);
step_until_membership_stable(
&mut sys,
group.into_iter().collect(),
"network reconnection",
)
}
pub fn test_network_partition(config: &TestConfig) -> TestResult {
run_network_partition(config, false, false)
}
pub fn test_network_partition_recover(config: &TestConfig) -> TestResult {
run_network_partition(config, false, true)
}
pub fn test_network_partition_unequal(config: &TestConfig) -> TestResult {
run_network_partition(config, true, false)
}
pub fn test_network_partition_unequal_recover(config: &TestConfig) -> TestResult {
run_network_partition(config, true, true)
}
fn run_network_partition(config: &TestConfig, unequal: bool, recover: bool) -> TestResult {
if unequal {
assume!(
config.process_count >= 3,
"Unequal network partition requires at least 3 processes"
)?;
}
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
let (group1, group2): (Vec<_>, Vec<_>) = if unequal {
// Always a nonempty strict minority, including odd process counts.
let (minority, majority) = group.split_at((group.len() - 1) / 2);
(
minority.iter().map(String::as_str).collect(),
majority.iter().map(String::as_str).collect(),
)
} else {
// Preserve the existing random partition and its seeds.
group
.iter()
.map(String::as_str)
.partition(|_| rand.gen_range(0.0..1.0) > 0.6)
};
info!(
"Network partition: [{}] ({} processes) | [{}] ({} processes)",
group1.join(", "),
group1.len(),
group2.join(", "),
group2.len()
);
sys.network().make_partition(&group1, &group2);
step_until_stabilized_at(
&mut sys,
group1.into_iter().map(String::from).collect(),
"network partition: first subgroup",
)?;
step_until_stabilized_at(
&mut sys,
group2.into_iter().map(String::from).collect(),
"network partition: second subgroup",
)?;
if recover {
sys.network().reset();
step_until_membership_stable(&mut sys, group.into_iter().collect(), "network recovery")?;
}
Ok(true)
}
pub fn test_process_cannot_receive(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// process goes partially offline (cannot receive incoming messages)
let blocked_proc = group.remove(rand.gen_range(0..group.len()));
sys.network().drop_incoming(&blocked_proc);
step_until_stabilized(&mut sys, group.into_iter().collect())?;
step_until_stabilized_at(&mut sys, BTreeSet::from([blocked_proc]), "isolated process")
}
pub fn test_process_join_cannot_receive(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let new_proc = group.remove(rand.gen_range(0..group.len()));
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// process tries to join the system but cannot receive messages
// it must not be kept in the group
sys.network().drop_incoming(&new_proc);
sys.send_local_message(&new_proc, Message::json("JOIN", &JoinMessage { seed }));
step_until_stabilized(&mut sys, group.into_iter().collect())
}
pub fn test_process_cannot_send(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// process goes partially offline (cannot send outgoing messages)
let blocked_proc = group.remove(rand.gen_range(0..group.len()));
sys.network().drop_outgoing(&blocked_proc);
step_until_stabilized(&mut sys, group.into_iter().collect())?;
step_until_stabilized_at(&mut sys, BTreeSet::from([blocked_proc]), "isolated process")
}
pub fn test_two_processes_cannot_communicate(config: &TestConfig) -> TestResult {
run_pairwise_link_failure(config, false)
}
pub fn test_two_processes_cannot_communicate_in_triangle(config: &TestConfig) -> TestResult {
let config = TestConfig {
process_count: 3,
..*config
};
run_pairwise_link_failure(&config, true)
}
fn run_pairwise_link_failure(config: &TestConfig, triangle: bool) -> TestResult {
assume!(
config.process_count >= 3,
"Pairwise link failure with a connected group requires at least 3 processes"
)?;
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
if triangle {
group.sort();
} else {
group.shuffle(&mut rand);
}
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// In a triangle, the seed remains the bridge between the other two nodes.
let proc1 = if triangle { &group[1] } else { seed };
let proc2 = if triangle {
&group[2]
} else {
group.get(rand.gen_range(1..group.len())).unwrap()
};
info!(
"Network links disabled: {proc1} <-> {proc2}; group remains connected (N={})",
group.len()
);
sys.network().disable_link(proc1, proc2);
sys.network().disable_link(proc2, proc1);
// Preserve the original test's execution before checking convergence.
sys.steps(1000);
step_until_membership_stable(
&mut sys,
group.into_iter().collect(),
"pairwise link failure",
)
}
/// Once membership is correct, observe every event without advancing time in GET_MEMBERS.
/// A transient mismatch restarts the stability window, allowing eventual convergence.
fn step_until_membership_stable(
sys: &mut System,
group: BTreeSet<String>,
stage: &str,
) -> TestResult {
const STABLE_WINDOW: f64 = 30.0;
const CONVERGENCE_LIMIT: f64 = 300.0;
let deadline = sys.time() + CONVERGENCE_LIMIT;
let mut stable_since = None;
let mut checked_events = 0_u64;
let mut search_exhausted = false;
let mut last_mismatch = String::from("no correct membership snapshot");
loop {
let now = sys.time();
// The next event may lie beyond the observed window. Its effects must
// not invalidate the unchanged state inside the completed window.
if stable_since
.is_some_and(|since| now > since + STABLE_WINDOW && since + STABLE_WINDOW <= deadline)
{
break;
}
if now > deadline {
return Err(format!(
"Group members lists are not stable for {STABLE_WINDOW}s within {CONVERGENCE_LIMIT}s; {last_mismatch}"
));
}
if checked_events.is_multiple_of(100) {
crate::runner::progress(format!(
"{stage}: membership stability: N={}, simulation time={now:.1}, stable for {:.1}/{STABLE_WINDOW}s, checked events={checked_events}",
group.len(), stable_since.map_or(0.0, |since| now - since)
));
}
let mut correct = true;
for proc in &group {
let data = get_members(sys, proc)?;
let members: BTreeSet<_> = data.members.into_iter().collect();
if members != group {
correct = false;
last_mismatch = format!(
"process {proc} at simulation time {now:.3}: expected {group:?}, got {members:?}"
);
}
}
if correct {
let since = *stable_since.get_or_insert(now);
if now - since >= STABLE_WINDOW {
break;
}
} else {
stable_since = None;
}
if !correct && !search_exhausted && now < deadline {
// Search for the next candidate snapshot at the usual 5s cadence.
// Only a correct candidate starts event-by-event observation. This
// avoids filling AnySystem's retained trace with failed queries.
search_exhausted = !sys.step_for_duration(5.0_f64.min(deadline - now));
continue;
}
if !correct {
return Err(format!(
"Group members lists are not stable for {STABLE_WINDOW}s within {CONVERGENCE_LIMIT}s; {last_mismatch}"
));
}
if !sys.step() {
// With no pending events, a correct membership cannot change.
break;
}
checked_events += 1;
}
info!(
"Membership remained stable for {STABLE_WINDOW}s ({stage}; checked after {checked_events} simulation events)"
);
Ok(true)
}
pub fn test_slow_network(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// slow down network for a while
sys.network().set_delays(0.1, 1.0);
sys.steps(200);
sys.network().set_delays(0.01, 0.1);
step_until_stabilized(&mut sys, group.into_iter().collect())
}
pub fn test_flaky_network(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// make network unreliable for a while
sys.network().set_drop_rate(0.5);
sys.steps(1000);
sys.network().set_drop_rate(0.0);
step_until_stabilized(&mut sys, group.into_iter().collect())
}
pub fn test_flaky_network_on_start(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
// make network unreliable from the start
sys.network().set_drop_rate(0.2);
for proc in &group {
sys.send_local_message(proc, Message::json("JOIN", &JoinMessage { seed }));
}
sys.steps(1000);
sys.network().set_drop_rate(0.0);
step_until_stabilized(&mut sys, group.into_iter().collect())
}
pub fn test_flaky_network_and_crash(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
// make network unreliable for a while + crash process
sys.network().set_drop_rate(0.5);
let crashed = group.remove(rand.gen_range(0..group.len()));
crash_process(&crashed, &mut sys);
sys.steps(1000);
sys.network().set_drop_rate(0.0);
step_until_stabilized(&mut sys, group.into_iter().collect())
}
pub fn test_chaos_monkey(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
info!(
"CHAOS MONKEY run seed: {}; join order: [{}]; seed process: {seed}",
config.seed,
group.join(", ")
);
// Keep at least two participants; pairwise link failures must not isolate
// a participant still expected in the control group.
let failure_count = 6.min(group.len().saturating_sub(2));
for step in 1..=failure_count {
let p = rand.gen_range(0.0..1.0);
// do some nasty things
match p {
p if p < 0.25 => {
// crash process
let victim = group.remove(rand.gen_range(0..group.len()));
info!(
"- Step {step} at {:.2}: process {victim} crashes",
sys.time()
);
crash_process(&victim, &mut sys);
}
p if p < 0.5 => {
// disconnect process
let victim = group.remove(rand.gen_range(0..group.len()));
info!(
"- Step {step} at {:.2}: process {victim} disconnects",
sys.time()
);
sys.network().disconnect_node(&victim);
}
p if p < 0.75 => {
// partially disconnect process (cannot receive)
let victim = group.remove(rand.gen_range(0..group.len()));
info!(
"- Step {step} at {:.2}: process {victim} cannot receive messages",
sys.time()
);
sys.network().drop_incoming(&victim);
}
_ => {
// two processes cannot communicate with each other
let proc1 = group.get(rand.gen_range(0..group.len())).unwrap();
let mut proc2 = group.get(rand.gen_range(0..group.len())).unwrap();
while proc1 == proc2 {
proc2 = group.get(rand.gen_range(0..group.len())).unwrap();
}
info!(
"- Step {step} at {:.2}: processes {proc1} and {proc2} cannot communicate",
sys.time()
);
sys.network().disable_link(proc1, proc2);
sys.network().disable_link(proc2, proc1);
}
}
info!(
"Control group after step {step}: [{}] ({} processes)",
group.join(", "),
group.len()
);
step_until_stabilized(&mut sys, group.clone().into_iter().collect())?;
}
Ok(true)
}
pub fn test_scalability_normal(config: &TestConfig) -> TestResult {
let sys_sizes = [
config.process_count,
config.process_count * 2,
config.process_count * 5,
config.process_count * 10,
];
let mut measurements = Vec::new();
for size in sys_sizes {
let mut run_config = *config;
run_config.process_count = size;
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(&run_config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
let init_time = sys.time();
let init_net_traffic = sys.network().traffic();
let init_msg_count = sys.network().network_message_count();
let mut init_loads = HashMap::new();
for proc in sys.process_names() {
init_loads.insert(
proc.clone(),
sys.sent_message_count(&proc) + sys.received_message_count(&proc),
);
}
crate::runner::progress(format!(
"steady-state measurement: N={size}, simulation time={:.1}",
sys.time()
));
sys.step_for_duration(10.0);
let mut loads = Vec::new();
for proc in sys.process_names() {
let load = sys.sent_message_count(&proc) + sys.received_message_count(&proc);
loads.push(load - init_loads.get(&proc).unwrap());
}
let min_load = *loads.iter().min().unwrap();
let max_load = *loads.iter().max().unwrap();
let duration = sys.time() - init_time;
let traffic = sys.network().traffic();
let message_count = sys.network().network_message_count();
measurements.push((
duration,
(traffic - init_net_traffic) as f64 / duration,
(message_count - init_msg_count) as f64 / duration,
max_load as f64 / duration,
max_load as f64 / min_load as f64,
));
}
let mut scaling_ok = true;
let mut load_ratio_ok = true;
for i in 0..sys_sizes.len() {
let (time, traffic, message_count, max_load, load_ratio) = measurements[i];
println!(
"- N = {}: time - {:.2}, traffic/s - {:.2}, messages/s - {:.2}, max load - {:.2}, max/min load - {:.2}",
sys_sizes[i], time, traffic, message_count, max_load, load_ratio
);
if load_ratio > 5.0 {
load_ratio_ok = false;
}
if i > 0 {
let size_ratio = sys_sizes[i] as f64 / sys_sizes[i - 1] as f64;
let traffic_ratio = traffic / measurements[i - 1].1;
let messages_ratio = message_count / measurements[i - 1].2;
if traffic_ratio > 2.0 * size_ratio || messages_ratio > 2.0 * size_ratio {
scaling_ok = false;
}
}
}
assume!(scaling_ok, "Bad network load scaling")?;
assume!(load_ratio_ok, "Bad max/min process load")?;
Ok(true)
}
pub fn test_scalability_crash(config: &TestConfig) -> TestResult {
let sys_sizes = [
config.process_count,
config.process_count * 2,
config.process_count * 5,
config.process_count * 10,
];
let mut measurements = Vec::new();
for size in sys_sizes {
let mut run_config = *config;
run_config.process_count = size;
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(&run_config);
let mut group = sys.process_names();
group.shuffle(&mut rand);
let seed = &group[0];
initialize_group(&mut sys, &group, seed)?;
let init_time = sys.time();
let init_net_traffic = sys.network().traffic();
let init_msg_count = sys.network().network_message_count();
let mut init_loads = HashMap::new();
for proc in sys.process_names() {
init_loads.insert(
proc.clone(),
sys.sent_message_count(&proc) + sys.received_message_count(&proc),
);
}
let crashed = group.remove(rand.gen_range(0..group.len()));
crash_process(&crashed, &mut sys);
step_until_stabilized_at(
&mut sys,
group.clone().into_iter().collect(),
"stabilization after crash",
)?;
let mut loads = Vec::new();
for proc in sys.process_names() {
if proc != crashed {
let load = sys.sent_message_count(&proc) + sys.received_message_count(&proc);
loads.push(load - init_loads.get(&proc).unwrap());
}
}
let min_load = *loads.iter().min().unwrap();
let max_load = *loads.iter().max().unwrap();
let duration = sys.time() - init_time;
let traffic = sys.network().traffic();
let message_count = sys.network().network_message_count();
measurements.push((
duration,
(traffic - init_net_traffic) as f64 / duration,
(message_count - init_msg_count) as f64 / duration,
max_load as f64 / duration,
max_load as f64 / min_load as f64,
));
}
let mut scaling_ok = true;
let mut load_ratio_ok = true;
for i in 0..sys_sizes.len() {
let (time, traffic, message_count, max_load, load_ratio) = measurements[i];
println!(
"- N = {}: time - {:.2}, traffic/s - {:.2}, messages/s - {:.2}, max load - {:.2}, max/min load - {:.2}",
sys_sizes[i], time, traffic, message_count, max_load, load_ratio
);
if load_ratio > 5.0 {
load_ratio_ok = false;
}
if i > 0 {
let size_ratio = sys_sizes[i] as f64 / sys_sizes[i - 1] as f64;
let traffic_ratio = traffic / measurements[i - 1].1;
let messages_ratio = message_count / measurements[i - 1].2;
if traffic_ratio > 2.0 * size_ratio || messages_ratio > 2.0 * size_ratio {
scaling_ok = false;
}
}
}
assume!(scaling_ok, "Bad network load scaling")?;
assume!(load_ratio_ok, "Bad max/min process load")?;
Ok(true)
}
fn initialize_group(sys: &mut System, group: &Vec<String>, seed: &str) -> TestResult {
for proc in group {
sys.send_local_message(proc, Message::json("JOIN", &JoinMessage { seed }));
}
step_until_stabilized_at(
sys,
group.clone().into_iter().collect(),
"initial group assembly",
)
}
fn crash_process(name: &str, sys: &mut System) {
// we just crash the node on which the process is running
sys.crash_node(name);
}
fn recover_process(name: &str, sys: &mut System, config: &TestConfig) {
sys.recover_node(name);
let clock_skew = sys.gen_range(0.0..10.0);
// A reboot may change the local clock. Apply it before on_start is called.
sys.set_node_clock_skew(name, clock_skew);
info!(
"Process {name} recovers at simulation time {:.6} with clock skew {clock_skew:.6}",
sys.time()
);
let process = config.process_factory.build((name,), config.seed);
sys.add_process(name, boxed!(process), name);
}
fn get_members(sys: &mut System, proc: &str) -> Result<MembersMessage, String> {
let sent_before = sys.sent_message_count(proc);
sys.send_local_message(proc, Message::json("GET_MEMBERS", &GetMembersMessage {}));
assume_eq!(
sys.sent_message_count(proc),
sent_before,
format!("GET_MEMBERS must not send network messages: process {proc}")
)?;
let messages = sys
.step_until_local_message_max_steps(proc, 0)
.map_err(|_| {
format!("GET_MEMBERS must return immediately without simulation steps: process {proc}")
})?;
assume_eq!(messages.len(), 1, "expected exactly one MEMBERS message")?;
assume_eq!(messages[0].tip, "MEMBERS", "Wrong message type")?;
serde_json::from_str(&messages[0].data)
.map_err(|error| format!("Invalid MEMBERS message from {proc}: {error}"))
}
fn step_until_stabilized(sys: &mut System, group: BTreeSet<String>) -> TestResult {
step_until_stabilized_at(sys, group, "membership stabilization")
}
fn step_until_stabilized_at(sys: &mut System, group: BTreeSet<String>, stage: &str) -> TestResult {
let max_time = sys.time() + 300.; // timeout is 5 minutes
let mut stabilized = BTreeSet::new();
let mut memberlists = HashMap::new();
while stabilized.len() < group.len() && sys.time() < max_time {
crate::runner::progress(format!("{stage}: N={}, correct members lists={}/{}, simulation time={:.1}, network messages={}",
group.len(), stabilized.len(), group.len(), sys.time(), sys.network().network_message_count()));
let cont = sys.step_for_duration(5.);
stabilized.clear();
// Query processes in a stable order without advancing simulation time.
for proc in group.iter() {
let data = get_members(sys, proc)?;
let members: BTreeSet<String> = data.members.clone().into_iter().collect();
if members.eq(&group) {
stabilized.insert(proc.clone());
}
memberlists.insert(proc.clone(), data.members);
}
if !cont {
break;
}
}
if stabilized != group && group.len() <= 10 {
println!("Members lists:");
for proc in sys.process_names() {
if group.contains(&proc) {
let members = memberlists.get_mut(&proc).unwrap();
members.sort();
println!("- [{}] {}", proc, members.join(", "));
}
}
let mut expected = group.clone().into_iter().collect::<Vec<_>>();
expected.sort();
println!("Expected group: {}", expected.join(", "));
}
assume_eq!(stabilized, group, "Group members lists are not stabilized")?;
Ok(true)
}
@@ -1,163 +0,0 @@
use std::collections::HashSet;
use std::time::Duration;
use rand::prelude::*;
use rand_pcg::Pcg64;
use sugars::boxed;
use anysystem::logger::{LogEntry, LogEntry::McMessageReceived};
use anysystem::mc::{
predicates::{collects, goals, invariants, prunes},
strategies::Bfs,
CollectFn, EventOrderingMode, InvariantFn, McState, ModelChecker, StrategyConfig,
};
use anysystem::test::TestResult;
use anysystem::{Message, System};
use crate::common::*;
pub const MC_TIME_LIMIT_SECONDS: u64 = 450;
pub fn test_mc_group(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
let mut sys = build_system(config);
let group = sys.process_names();
let seed = group.choose(&mut rand).unwrap();
crate::runner::progress("MC: exploring group assembly, N=3");
let collected_states = mc_explore_after_joins(&mut sys, seed.to_string())?;
if collected_states.is_empty() {
return Err("no states collected during explore stage".to_string());
}
crate::runner::progress(format!(
"MC: checking membership in {} collected states, N=3",
collected_states.len()
));
mc_check_members(&mut sys, collected_states)
}
fn mc_explore_after_joins(sys: &mut System, seed_proc: String) -> Result<HashSet<McState>, String> {
let procs = sys.process_names();
let mut mc = ModelChecker::new(sys);
let strategy_config = StrategyConfig::default()
// Explore only states with up to 2 timer firings per process
// (we expect each process to communicate with others at most 3 times:
// 1 time on a local message and 2 times on a timer).
.prune(prunes::events_limit_per_proc(
|entry: &LogEntry, proc_name: &String| match entry {
LogEntry::McTimerFired { proc, .. } => proc_name == proc,
_ => false,
},
procs.clone(),
2,
))
// Stop when no events left or reached depth 20 (steps of simulation).
.goal(goals::any_goal(vec![
goals::no_events(),
goals::depth_reached(20),
]))
// Inner exploration safeguard; the supervisor also bounds both MC phases.
.invariant(invariants::time_limit(Duration::from_secs(
MC_TIME_LIMIT_SECONDS,
)))
// Collect states in which the group should be stabilized, namely:
// either no events left, or every process received at least 3 messages.
//
// Considering a system with 3 processes and a stable network, we can show this is enough:
// * seed process (A) should get information from both other processes (B & C)
// * because C joins later than B, it will know the full group as well
// * B is able to get information about the full group either from A or C, depending on implementation
//
// Technically, there can be execution A <-> B (x3), A <-> C (x3) where B's information becomes outdated,
// but we consider solution wrong if it allows such execution.
.collect(collects::any_collect(vec![
collects::no_events(),
collects::all_collects(
procs
.clone()
.into_iter()
.map(|proc_name| {
collects::event_happened_n_times_current_run(
move |log_entry| match log_entry {
McMessageReceived { dst: proc, .. } => proc == &proc_name,
_ => false,
},
3,
)
})
.collect::<Vec<CollectFn>>(),
),
]));
let res = mc.run_with_change::<Bfs>(strategy_config, |sys| {
// Use event ordering mode which prioritizes messages over timers
// (this emulates perfect network where timeouts do not occur).
sys.set_event_ordering_mode(EventOrderingMode::MessagesFirst);
for proc in &procs {
sys.send_local_message(
proc.clone(),
proc.clone(),
Message::json("JOIN", &JoinMessage { seed: &seed_proc }),
);
}
});
match res {
Err(e) => {
e.print_trace();
Err(e.message())
}
Ok(stats) => {
// println!("collected {} states", stats.collected_states.len());
Ok(stats.collected_states)
}
}
}
fn mc_check_members(sys: &mut System, collected: HashSet<McState>) -> TestResult {
let procs = sys.process_names();
let mut mc = ModelChecker::new(sys);
let strategy_config = StrategyConfig::default()
.invariant(mc_invariant_check_stabilized(procs.clone()))
.goal(goals::always_ok());
let res = mc.run_from_states_with_change::<Bfs>(strategy_config, collected, |sys| {
for node in procs.iter() {
sys.send_local_message(
node,
node,
Message::json("GET_MEMBERS", &GetMembersMessage {}),
);
}
});
if let Err(e) = res {
e.print_trace();
Err(e.message())
} else {
Ok(true)
}
}
fn mc_invariant_check_stabilized(group: Vec<String>) -> InvariantFn {
boxed!(move |state| {
let group = group.clone().into_iter().collect::<HashSet<String>>();
for node in state.node_states.keys() {
if let Some(msg) = state.node_states[node].proc_states[node]
.local_outbox
.first()
{
if msg.tip != "MEMBERS" {
return Err("wrong message type".to_owned());
}
let data: MembersMessage = serde_json::from_str(&msg.data).unwrap();
let members = HashSet::from_iter(data.members.into_iter());
if !members.eq(&group) {
return Err(format!(
"expected a stabilized group {group:?} but got {members:?}"
));
}
}
}
Ok(())
})
}
+3 -17
View File
@@ -28,7 +28,7 @@
В AnySystem 0.3.0 Python-модуль встроен в Rust-тестер: при запуске тестов локально или в Docker отдельный `anysystem.py` и настройка `PYTHONPATH` не требуются. Запускайте тесты из папки задания, чтобы относительные пути к решениям были корректны. Стаб служит только для IDE. В AnySystem 0.3.0 Python-модуль встроен в Rust-тестер: при запуске тестов локально или в Docker отдельный `anysystem.py` и настройка `PYTHONPATH` не требуются. Запускайте тесты из папки задания, чтобы относительные пути к решениям были корректны. Стаб служит только для IDE.
## Регистрация ### Регистрация
(Пропустите эту часть, если вы вольнослушатель.) (Пропустите эту часть, если вы вольнослушатель.)
@@ -71,22 +71,6 @@ docker run --rm -t -v ./solution:/solution distsys-tests-local [ЗДЕСЬ МО
Тестирующая система используется для итоговой проверки и служит эталоном для временных лимитов, но не должна заменять локальное тестирование после каждой правки. Тестирующая система используется для итоговой проверки и служит эталоном для временных лимитов, но не должна заменять локальное тестирование после каждой правки.
## Бонусы за пробелы в тестах
Если вы найдёте ошибку в тестах или требование задания, нарушение которого тесты не обнаруживают, опишите проблему в отчёте; при необходимости приведите пример ошибочного решения. За подтверждённую проблему можно получить 1 бонусный балл. За тест, который выявляет её, или описание его логики можно получить ещё 1 балл. Бонусы начисляются отдельно, независимо от баллов за выполнение задания.
## Отчёт
К каждому домашнему заданию приложите краткий отчёт в файле `solution/readme.md`. Сдайте его вместе с решением до дедлайна. Без отчёта автоматические тесты запускаются, но защита не проводится и **решение не засчитывается**.
В отчёте объясните, как устроено ваше решение и как работают его основные части. Дополнительные требования к содержимому отчёта могут быть указаны в условиях соответствующего ДЗ.
Пожалуйста, пишите кратко и по существу. Перед сдачей перечитайте отчёт: проверьте, что он описывает именно вашу реализацию, и уберите повторы, общие рассуждения и ненужные подробности. Проверяющему должно быть понятно, как работает решение и почему вы выбрали такой подход.
Оценка за ДЗ может быть снижена на **1 балл**, если изложение заметно затрудняет понимание решения. В исключительных случаях снижение может составить **2 балла**, если по отчёту практически невозможно понять, как работает решение: например, существенные объяснения отсутствуют или теряются среди большого объёма лишнего текста.
При снижении оценки проверяющий укажет конкретные недостатки отчёта. Отдельные стилистические шероховатости и само по себе использование LLM основанием для снижения не являются.
## Сдача решения ## Сдача решения
После локального тестирования откройте папку задания, например `homework/01-guarantees`, и выполните команду: После локального тестирования откройте папку задания, например `homework/01-guarantees`, и выполните команду:
@@ -97,6 +81,8 @@ python3 ../cli.py submit
Утилита определит идентификатор задания по имени текущей папки и отправит содержимое её поддиректории `solution`. Она выведет идентификатор и официальное время приёма решения, а также ссылку на запуск workflow. Откройте эту ссылку в Gitea, чтобы следить за проверкой и прочитать её журнал. Утилита определит идентификатор задания по имени текущей папки и отправит содержимое её поддиректории `solution`. Она выведет идентификатор и официальное время приёма решения, а также ссылку на запуск workflow. Откройте эту ссылку в Gitea, чтобы следить за проверкой и прочитать её журнал.
В состав решения для каждого задания должен входить краткий отчёт в файле `solution/readme.md`. Отчёт необходимо сдать вместе с остальными файлами решения до дедлайна задания. При его отсутствии автоматические тесты всё равно запускаются, но защита не проводится и **решение не засчитывается**. Требования к содержанию отчёта приведены в условии соответствующего задания.
## Изоляция процессов AnySystem ## Изоляция процессов AnySystem
В заданиях на AnySystem каждый экземпляр `Process` моделирует отдельный процесс распределённой системы. Его изменяемое состояние должно храниться только в атрибутах экземпляра (`self`). Все взаимодействия между процессами должны происходить через сообщения, как и в реальной распределённой системе. В заданиях на AnySystem каждый экземпляр `Process` моделирует отдельный процесс распределённой системы. Его изменяемое состояние должно храниться только в атрибутах экземпляра (`self`). Все взаимодействия между процессами должны происходить через сообщения, как и в реальной распределённой системе.
Binary file not shown.
-11
View File
@@ -1,11 +0,0 @@
## 3. Протокол HTTP, REST и веб-сервисы.
### Лекция
- [Презентация](03-http.pdf)
- См. также материалы в конце
### Семинар
- [Материалы семинара](seminar/readme.md)
- [Пример WebSocket-сервера](seminar/websockets/README.md) — факультативный пример, который не разбирался на семинаре.
-172
View File
@@ -1,172 +0,0 @@
# Семинар 3. HTTP на практике
На семинаре мы проследили путь HTTP-запроса: от `curl`, браузера или Python-клиента через Nginx до Flask-приложения и обратно. Ниже — основные понятия и способы повторить демонстрации на [стенде](website/docker-compose.yaml).
## HTTP: запрос и ответ
**Запрос** содержит метод, адрес ресурса (URI), версию HTTP, заголовки и, при необходимости, тело. **Ответ** содержит версию HTTP, код состояния, заголовки и тело. Пустая строка отделяет заголовки от тела.
На запущенном стенде выполните:
```bash
curl -v http://localhost:8080/const
```
В подробном выводе `curl` строки с `>` относятся к запросу, с `<` — к ответу, а с `*` описывают работу самого клиента. `/const` возвращает заранее заданный текст непосредственно из Nginx.
Например, при `curl -4sv http://localhost:8080/` на запущенном стенде получили такой фрагмент вывода:
```text
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: nginx/1.31.6
< Content-Type: text/html; charset=utf-8
< Content-Length: 17
< Connection: keep-alive
< X-Upstream: 172.18.0.2:5002
<
Hello from app v2
```
Здесь Nginx передал запрос одному из Flask-серверов и вернул его ответ клиенту. `X-Upstream` показывает адрес выбранного сервера внутри сети Docker; при другом запуске адрес, версия приложения и версия Nginx могут отличаться. Флаги `-4` и `-s` в примере убирают попытку соединиться по IPv6 и индикатор прогресса, а `-v` показывает обмен запросом и ответом.
Заголовки, которые разбирали на занятии:
| Заголовок | Где смотреть | Что показывает |
| --- | --- | --- |
| `Host` | Запрос | К какому сайту или приложению обращается клиент, в том числе когда один адрес и порт обслуживают несколько сайтов. |
| `User-Agent` | Запрос | Сведения о клиенте, например `curl`. |
| `Accept` | Запрос | Какие форматы ответа клиент готов принять. |
| `Accept-Encoding` | Запрос браузера | Какие способы сжатия ответа клиент поддерживает. |
| `Server` | Ответ | Какой сервер отправил ответ; в демонстрации через proxy виден Nginx. |
| `Content-Type` | Ответ | Формат тела ответа, например обычный текст, HTML или JSON. |
| `Connection: keep-alive` | Ответ в примере | TCP-соединение можно использовать повторно, не устанавливая его заново перед каждым запросом. |
**Браузер тоже HTTP-клиент.** Во вкладке **Network** в DevTools найдите запрос к `/const` и сравните его заголовки и ответ с выводом `curl`. Браузер может посылать дополнительные заголовки: два клиента не обязаны формировать запрос одинаково.
## HTTP запросы из кода
На примере Python-библиотеки `requests` разобрали GET-запрос, query-параметры, timeout и проверку статуса.
- **Query-параметры** — пары «ключ — значение» после `?` в URI. Передавайте их через аргумент `params`, а не собирайте URI вручную: символ `&` внутри значения иначе можно принять за разделитель параметров.
- `timeout=(1, 3)` в показанном примере задаёт 1 секунду на установление соединения и 3 секунды на ожидание данных при чтении ответа. Второе число — не общий предел времени на получение всего ответа: пока сервер регулярно присылает данные, запрос может длиться дольше. Без timeout вызов зависимого сервиса может ждать слишком долго.
- `raise_for_status()` помогает обнаружить ответ с ошибочным HTTP-статусом и обработать его в коде.
- Если после timeout или ошибки зависимого сервиса нужных данных нет, приложение может вернуть заранее предусмотренный **fallback**. На семинаре обсуждали fallback-текст и запасную картинку кота.
## Что происходит в Flask-приложении
В [приложении стенда](website/kittens/app.py) Flask связывает пути `/` и `/kittens` с функциями-обработчиками через `@app.route(...)`.
- `/` возвращает текст с версией приложения: `v1` или `v2`.
- `/kittens` запрашивает JSON у внешнего Cat API, извлекает URL изображения и подставляет его в [HTML-шаблон](website/kittens/templates/index.html) через `render_template`.
- Получив HTML, браузер делает **ещё один HTTP-запрос** за самой картинкой. Оба обращения можно увидеть в DevTools Network.
Этот пример не рассматривали как готовое production-приложение. Исходящий запрос к Cat API сделан без явного timeout и проверки HTTP-статуса. Если внешний сервис зависнет, ответит ошибкой или пришлёт неожиданные данные, нужно обработать сбой. В качестве возможного ответа на семинаре предложили заранее сохранённую картинку.
## Как устроен демонстрационный стенд
[Docker Compose](website/docker-compose.yaml) запускает два экземпляра Flask-приложения и Nginx. Запись `порт_хоста:порт_контейнера` означает, что слева указан порт для обращения с вашей машины, справа — порт внутри контейнера.
| Сервис | Внутри контейнера | С вашей машины | Назначение |
| --- | --- | --- | --- |
| `server1` | `5001` | `9001` | Flask-приложение с `APP_VERSION=v1`. |
| `server2` | `5002` | `9002` | То же приложение с `APP_VERSION=v2`. |
| `proxy` | `80` | `8080` | Nginx: proxy, redirect и `/const`. |
| `proxy` | `10000` | `8081` | Nginx: раздача файлов из `static`. |
Compose подключает [конфигурацию Nginx](website/nginx.conf) и каталог `static` внутрь контейнера `proxy`. Суффикс `:ro` у файла конфигурации задаёт доступ только для чтения.
Чтобы поднять стенд, перейдите из корня репозитория в каталог с `docker-compose.yaml`. Понадобятся работающий Docker и команда `docker compose`:
```bash
cd materials/03-http/seminar/website
docker compose up --build -d
docker compose ps
```
`up --build` собирает образ Flask-приложения и запускает сервисы; `-d` оставляет контейнеры работать в фоне. `docker compose ps` показывает их состояние. Современный Compose может предупредить, что поле `version` в файле устарело; это предупреждение не мешает запуску. Закончив с примерами, остановите и удалите контейнеры стенда командой `docker compose down` из того же каталога.
<details>
<summary>Что такое Docker и как в стенде связаны порты</summary>
Docker запускает приложения в контейнерах — изолированных окружениях с нужными зависимостями. Образ Flask-приложения собирается по [Dockerfile](website/kittens/Dockerfile), а для Nginx Compose использует готовый образ `nginx`. Compose запускает три контейнера, задаёт их переменные окружения и публикует нужные порты на вашей машине.
```text
Ваша машина Контейнеры
localhost:9001 ── 9001:5001 ──▶ server1:5001 (Flask v1)
localhost:9002 ── 9002:5002 ──▶ server2:5002 (Flask v2)
localhost:8080 ── 8080:80 ──▶ proxy:80 (Nginx)
└─▶ server1:5001 или server2:5002
localhost:8081 ── 8081:10000 ─▶ proxy:10000 (файлы из static)
```
Слева от `:` в Compose указан порт вашей машины, справа — порт контейнера. Когда Nginx обращается к `server1:5001` или `server2:5002`, он использует внутренние адреса сервисов, а не порты `9001` и `9002` вашей машины.
</details>
## Возможности Nginx и файл его конфигурации
В этом стенде Nginx принимает запросы как единая точка входа, распределяет их между копиями приложения, возвращает redirect и сам отдаёт текст или файлы. Какую из этих задач выполнять, определяет его [конфигурация](website/nginx.conf).
**Reverse proxy** принимает запрос клиента и сам обращается к backend. В группе **upstream** стенда перечислены `server1:5001` и `server2:5002`. Для `/` и `/kittens` Nginx выбирает один из них и добавляет в ответ `X-Upstream` с адресом выбранного backend.
Если один backend перестаёт отвечать, Nginx временно исключает его из балансировки и направляет новые запросы к оставшимся живым репликам. При ошибке соединения он может попробовать другую реплику и для текущего запроса, поэтому отказ одного сервера не обязательно приводит к ошибке у клиента.
Когда мы несколько раз отправили запрос к `/` через Nginx, он по очереди направил запросы к двум Flask-серверам: ответы `v1` и `v2` чередовались. Так мы увидели **round-robin** — простой алгоритм выбора backend по очереди. Для трёх серверов порядок мог бы выглядеть так: `3 → 1 → 2 → 3 → 1 → 2`. Алгоритм не оценивает, сколько работы потребует конкретный запрос. Если серверы различаются по мощности или сетевой задержке, равное число запросов может дать им разную нагрузку; в обсуждении упоминали веса backend-серверов. При этом клиент обращается к одной точке входа — Nginx — и не выбирает реплику сам.
При **redirect** Nginx возвращает клиенту код 3xx и заголовок `Location` с новым URI. Следующий запрос по этому URI делает уже клиент; при reverse proxy Nginx обращается к backend сам. Правило `/search/` на стенде перенаправляет запрос в поиск Google: браузер следует переходу автоматически, а `curl` можно передать флаг `-L`. Как прикладной случай обсудили перенаправление со старого URI `/promotions` на новый `/discounts`.
Кроме проксирования и redirect, Nginx сам отдаёт текст по `/const` и статические файлы через порт `8081`. Для каталога `static` включён `autoindex`, поэтому в браузере можно переходить по его содержимому.
## Как повторить примеры
Команды ниже рассчитаны на запущенный [стенд](website/docker-compose.yaml). `localhost` означает машину, на которой опубликованы порты контейнеров. Флаг `-i` показывает заголовки ответа.
```bash
# Посмотреть запрос, ответ и заголовки.
curl -v http://localhost:8080/const
# Обратиться напрямую к двум экземплярам Flask-приложения.
curl -i http://localhost:9001/
curl -i http://localhost:9002/
# Обратиться через Nginx; повторите команду и сравните ответ и X-Upstream.
curl -i http://localhost:8080/
# Посмотреть redirect на поиск Google, затем пройти по нему.
curl -i http://localhost:8080/search/cats
curl -L http://localhost:8080/search/cats
# Получить файл, который отдаёт сам Nginx.
curl -i http://localhost:8081/greetings.txt
```
Ещё два опыта удобно провести в браузере: откройте `http://localhost:8080/const` и сравните его запрос с `curl` во вкладке Network; затем откройте `http://localhost:9001/kittens` и найдите отдельную загрузку изображения. `/kittens` зависит от внешнего Cat API, а переход на Google — от доступности Google.
## API заказов: набросок контракта
В конце занятия мы наметили операции интернет-магазина: создать заказ, получить его данные, изменить адрес или другую часть заказа. Для создания предложили `POST`, для чтения — `GET`, для частичного изменения — `PATCH`; в зависимости от контракта приложение может использовать `PUT`. Упомянули и системы, где для упрощения чтение реализуют через `POST` с телом запроса, но не представляли это как универсальное правило.
В результате можно предложить следующий контракт:
| Операция | Запрос | Успешный ответ | Почему так |
| --- | --- | --- | --- |
| Создать заказ | `POST /api/orders` с данными заказа в JSON | `201 Created`, заголовок `Location` с URI заказа | Запрос отправляют коллекции заказов; сервер обрабатывает данные и создаёт новый заказ с собственным ID. |
| Получить заказ | `GET /api/orders/{id}` | `200 OK` и данные заказа в JSON | Клиент обращается к уже известному заказу по его ID и читает данные, не создавая новую сущность. |
| Частично изменить заказ | `PATCH /api/orders/{id}` с JSON, например с новым адресом | `200 OK` и обновлённый заказ в JSON | Клиент указывает существующий заказ и передаёт только те данные, которые нужно изменить. |
| Удалить заказ | `DELETE /api/orders/{id}` | `204 No Content` | Клиент указывает конкретный заказ, который нужно удалить; тело успешного ответа не требуется. |
Если заказ не найден, для чтения, изменения или удаления предусмотрен `404 Not Found`; если данные для создания неверны — ошибка клиента, например `400 Bad Request`. Для долгой обработки допускается отдать `202 Accepted` и отдельный ресурс операции.
### Повторы запросов и идемпотентность
`GET` только читает заказ, поэтому его повтор не создаёт новую сущность. С `POST /api/orders` иначе: если клиент не получил ответ и повторил запрос, он может случайно создать ещё один заказ. Сервису нужно понять, относится ли повтор к той же логической операции. Для этого можно использовать токен идемпотентности: повтор с тем же токеном сервис распознаёт и не создаёт второй заказ. На семинаре обсудили два способа получить такой идентификатор.
- **Ключ создаёт клиент.** Например, фронтенд генерирует токен и передаёт его в заголовке или в JSON-теле запроса. Сервис сохраняет токен, чтобы определить возможные повторы запросов. Если ключ нужен лишь на время нескольких попыток и позже клиент его не воспроизводит, хранить его бессрочно незачем: подойдёт кэш на стороне сервиса с ограниченным сроком жизни, например несколько минут. Важно, чтобы на протяжении этого срока повторные попытки запроса приходили с тем же ключом.
- **Ключ создаёт сервис.** Если предметная область такова, что у сущности имеется некоторый уникальный идентификатор (например, `order.id` в случае создания заказа), то сервис может использовать этот ID как основу ключа идемпотентности. Поскольку `order.id` скорее всего хранится в базе вместе с заказом, такой ключ идемпотентности может жить столько же, сколько запись о заказе; отдельное короткое время жизни, как у временного ключа в кэше, ему не обязательно.
Таким образом, выбор способа и срока хранения ключа идемпотентности зависит от контракта API и предметной области.
@@ -1,30 +0,0 @@
version: '3'
services:
server1:
build:
context: ./kittens
environment:
APP_VERSION: 'v1'
BIND_PORT: 5001
ports:
- 9001:5001
server2:
build:
context: ./kittens
environment:
APP_VERSION: 'v2'
BIND_PORT: 5002
ports:
- 9002:5002
proxy:
image: nginx
ports:
- 8080:80
- 8081:10000
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./static:/static
restart: unless-stopped
@@ -1,3 +0,0 @@
docker-compose.yaml
Dockerfile
__pycache__
@@ -1,12 +0,0 @@
FROM python:3.11.5-slim-bullseye
WORKDIR /application
# Copy the requirements file and download the dependencies.
COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt
# Copy other data.
COPY . .
CMD [ "python3", "app.py"]
@@ -1,45 +0,0 @@
from flask import Flask, render_template, request
import logging
import os
import requests
app = Flask(__name__)
app.logger.setLevel(logging.INFO)
APP_VERSION = os.getenv('APP_VERSION')
BIND_HOST = os.getenv('BIND_HOST') or '0.0.0.0'
BIND_PORT = os.getenv('BIND_PORT') or '8000'
@app.before_request
def log_request():
app.logger.info(
'method=%s path=%s host=%s remote_addr=%s',
request.method, request.path, request.host, request.remote_addr,
)
@app.route('/')
def home():
return f'Hello from app {APP_VERSION}'
@app.route('/kittens')
def kittens():
# Get a URL to a random kitten photo.
response = requests.get('https://api.thecatapi.com/v1/images/search?api_')
try:
# Response structure is as follows:
# [{"id":"bL3lrUi1A","url":"ex.com/bL3lrUi1A.jpg","width":1280,"height":720}]
data = response.json()
kitten_url = data[0]['url']
return render_template('index.html', kitten_url=kitten_url)
except Exception as e:
return f'Failed to fetch a kitten image :(\n {e}'
if __name__ == '__main__':
app.run(host=BIND_HOST, port=BIND_PORT)
@@ -1,2 +0,0 @@
Flask==2.3.3
requests==2.28.2
@@ -1,8 +0,0 @@
<html>
<head>
<title>DistSys kittens</title>
</head>
<body>
<img src="{{ kitten_url }}">
</body>
</html>
@@ -1,49 +0,0 @@
events {}
http {
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
gzip on;
server {
listen 80;
server_name distsys.baliuk.me;
location /kittens {
proxy_pass http://backends;
add_header X-Upstream $upstream_addr;
}
location = / {
proxy_pass http://backends;
add_header X-Upstream $upstream_addr;
}
location /search/ {
rewrite ^/search/(.*)$ https://google.com/search?q=$1 redirect;
}
location = /const {
add_header Content-Type text/plain;
return 200 "Any preset text!";
}
}
upstream backends {
server server1:5001;
server server2:5002;
}
server {
listen 10000;
autoindex on; # enable directory listing output
autoindex_exact_size off; # output file sizes rounded to kilobytes, megabytes, and gigabytes
autoindex_localtime on; # output local times in the directory
location / {
root /static;
}
}
}
@@ -1 +0,0 @@
Hello, distributed systems!
@@ -1,127 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" style="background-color:#fff" viewBox="-.5 -.5 310 191">
<rect width="100%" height="100%" fill="#FFF"/>
<rect width="297" height="87" x="7" y="58" fill="#e6e6e6" pointer-events="all" rx="6.96" ry="6.96"/>
<g stroke="#000" stroke-miterlimit="10">
<path fill="none" d="M32.62 58.64 30.9 30" pointer-events="stroke"/>
<path d="m32.93 63.88-2.75-6.84 2.44 1.6 2.22-1.88Z" pointer-events="all"/>
</g>
<path fill="none" d="M0 0h60v30H0z" pointer-events="all"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:58px;height:1px;padding-top:15px;margin-left:1px">
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
<div style="font-size:16px">Method</div>
</div>
</div>
</div>
</foreignObject>
<text x="30" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Method</text>
</switch>
<path fill="none" d="M56 0h60v30H56z" pointer-events="all"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:58px;height:1px;padding-top:15px;margin-left:57px">
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">Path</div>
</div>
</div>
</foreignObject>
<text x="86" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Path</text>
</switch>
<path fill="none" d="M113 0h122v30H113z" pointer-events="all"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:120px;height:1px;padding-top:15px;margin-left:114px">
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">Protocol version</div>
</div>
</div>
</foreignObject>
<text x="174" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Protocol version</text>
</switch>
<path fill="none" d="M127 161h60v30h-60z" pointer-events="all"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:58px;height:1px;padding-top:176px;margin-left:128px">
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
<div style="font-size:16px">Headers</div>
</div>
</div>
</div>
</foreignObject>
<text x="157" y="181" font-family="Helvetica" font-size="16" text-anchor="middle">Headers</text>
</switch>
<g stroke="#000" stroke-miterlimit="10">
<path fill="none" d="M79.45 58.2 83.73 30" pointer-events="stroke"/>
<path d="m78.67 63.39-1.26-7.27 2.04 2.08 2.57-1.38Z" pointer-events="all"/>
</g>
<g stroke="#000" stroke-miterlimit="10">
<path fill="none" d="M153.71 58.24 166.96 30" pointer-events="stroke"/>
<path d="m151.48 62.99.86-7.33 1.37 2.58 2.85-.6Z" pointer-events="all"/>
</g>
<rect width="36" height="21" x="15" y="65" fill="#f2fffc" pointer-events="all" rx="1.68" ry="1.68"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:76px;margin-left:19px">
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:left">
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">
<p style="line-height:100%">
<font color="#509ad4" style="font-size:16px">GET</font>
</p>
</div>
</div>
</div>
</foreignObject>
<text x="19" y="80" fill="#333" font-family="Monospace" font-size="16">GET</text>
</switch>
<rect width="17" height="21" x="70" y="64.5" fill="#f5f5ff" pointer-events="all" rx="1.36" ry="1.36"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:75px;margin-left:74px">
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:left">
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">
<p style="line-height:100%">/</p>
</div>
</div>
</div>
</foreignObject>
<text x="74" y="80" fill="#333" font-family="Monospace" font-size="16">/</text>
</switch>
<rect width="100" height="22" x="101" y="64" fill="#f5ebef" pointer-events="all" rx="1.76" ry="1.76"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:75px;margin-left:105px">
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:left">
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">
<p style="line-height:100%">
<font color="#5d837e" style="font-size:16px">HTTP</font>/<font color="#cd5b92" style="font-size:16px">1.1</font>
</p>
</div>
</div>
</div>
</foreignObject>
<text x="105" y="80" fill="#333" font-family="Monospace" font-size="16">HTTP/1.1</text>
</switch>
<rect width="284" height="48" x="15" y="90" fill="#cfe3f5" pointer-events="all" rx="3.84" ry="3.84"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:278px;height:1px;padding-top:114px;margin-left:19px">
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:left">
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
<div style="font-size:16px">
<font color="#5d837e" style="font-size:16px">Host</font>: developer.mozilla.org</div>
<div style="font-size:16px">
<font color="#5d837e" style="font-size:16px">Accept-Language</font>: fr</div>
</div>
</div>
</div>
</foreignObject>
<text x="19" y="119" fill="#333" font-family="Monospace" font-size="16">Host: developer.mozilla.org...</text>
</switch>
<g stroke="#000" stroke-miterlimit="10">
<path fill="none" d="M157 144.37V161" pointer-events="stroke"/>
<path d="m157 139.12 2.33 7-2.33-1.75-2.33 1.75Z" pointer-events="all"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.1 KiB

@@ -1,121 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" style="background-color:#fff" viewBox="-0.5 -0.5 422 205">
<rect width="100%" height="100%" fill="#FFF"/>
<rect width="381" height="105" x="30" y="51" fill="#e6e6e6" pointer-events="all" rx="3.15" ry="3.15"/>
<path fill="none" d="M233 0h137v30H233z" pointer-events="all"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:135px;height:1px;padding-top:15px;margin-left:234px">
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">Status message</div>
</div>
</div>
</foreignObject>
<text x="302" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Status message</text>
</switch>
<path fill="none" d="M137 0h94v30h-94z" pointer-events="all"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:92px;height:1px;padding-top:15px;margin-left:138px">
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">Status code</div>
</div>
</div>
</foreignObject>
<text x="184" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Status code</text>
</switch>
<path fill="none" d="M0 0h118v30H0z" pointer-events="all"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:116px;height:1px;padding-top:15px;margin-left:1px">
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">Protocol version</div>
</div>
</div>
</foreignObject>
<text x="59" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Protocol version</text>
</switch>
<path fill="none" d="M192 175h60v30h-60z" pointer-events="all"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:58px;height:1px;padding-top:190px;margin-left:193px">
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
<div style="font-size:16px">Headers</div>
</div>
</div>
</div>
</foreignObject>
<text x="222" y="195" font-family="Helvetica" font-size="16" text-anchor="middle">Headers</text>
</switch>
<rect width="36" height="21" x="197" y="56" fill="#f2fffc" pointer-events="all" rx="1.68" ry="1.68"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:66px;margin-left:215px">
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:center">
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">OK</div>
</div>
</div>
</foreignObject>
<text x="215" y="71" fill="#333" font-family="Monospace" font-size="16" text-anchor="middle">OK</text>
</switch>
<rect width="42" height="21" x="139" y="56" fill="#f5f5ff" pointer-events="all" rx="1.68" ry="1.68"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:66px;margin-left:160px">
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:center">
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">200</div>
</div>
</div>
</foreignObject>
<text x="160" y="71" fill="#333" font-family="Monospace" font-size="16" text-anchor="middle">200</text>
</switch>
<rect width="92" height="22" x="38" y="55" fill="#f5ebef" pointer-events="all" rx="1.76" ry="1.76"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:66px;margin-left:84px">
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:center">
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">
<p style="line-height:100%">
<font color="#5d837e" style="font-size:16px">HTTP</font>/<font color="#cd5b92" style="font-size:16px">1.1</font>
</p>
</div>
</div>
</div>
</foreignObject>
<text x="84" y="71" fill="#333" font-family="Monospace" font-size="16" text-anchor="middle">HTTP/1.1</text>
</switch>
<rect width="368" height="63" x="38" y="83" fill="#cfe3f5" pointer-events="all" rx="1.89" ry="1.89"/>
<switch transform="translate(-.5 -.5)">
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:362px;height:1px;padding-top:115px;margin-left:42px">
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:left">
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
<div style="font-size:16px">
<font color="#5d837e" style="font-size:16px">date</font>: Tue, 18 Jun 2024 10:03:55 GMT</div>
<div style="font-size:16px">
<font color="#5d837e" style="font-size:16px">cache-control</font>: public, max-age=3600</div>
<div style="font-size:16px">
<font color="#5d837e" style="font-size:16px">content-type</font>: text/html</div>
</div>
</div>
</div>
</foreignObject>
<text x="42" y="119" fill="#333" font-family="Monospace" font-size="16">date: Tue, 18 Jun 2024 10:03:55 GMT...</text>
</switch>
<g stroke="#000" stroke-miterlimit="10">
<path fill="none" d="M222 152.37V175" pointer-events="stroke"/>
<path d="m222 147.12 2.33 7-2.33-1.75-2.33 1.75Z" pointer-events="all"/>
</g>
<g stroke="#000" stroke-miterlimit="10">
<path fill="none" d="M229.63 53.02 273.15 30" pointer-events="stroke"/>
<path d="m224.99 55.48 5.09-5.34-.45 2.88 2.64 1.25Z" pointer-events="all"/>
</g>
<g stroke="#000" stroke-miterlimit="10">
<path fill="none" d="m163.22 50.5 12-20.5" pointer-events="stroke"/>
<path d="m160.56 55.04 1.53-7.22 1.13 2.68 2.89-.33Z" pointer-events="all"/>
</g>
<g stroke="#000" stroke-miterlimit="10">
<path fill="none" d="M60.68 48.64 59.75 30" pointer-events="stroke"/>
<path d="m60.94 53.88-2.68-6.87 2.42 1.63 2.25-1.86Z" pointer-events="all"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,32 +0,0 @@
# websockets
В данной директории находится простая реализация websocket сервера на Go.
## Управление
Запуск сервера: `go run .`.
Для подключения к серверу предлагается использовать утилиту `wscat`:
```bash
wscat --connect 'localhost:8000/connect?author=igor'
# В параметре author указываем наш никнейм
```
Далее можно отправить JSON сообщение с полем `text`, после чего это сообщение будет доставлено всем
клиентам сервера:
```bash
~ wscat --connect 'localhost:8000/connect?author=igor'
> {"text": "privet"}
< {"Text":"privet","Author":"igor","SendTime":"2024-09-24T01:49:50.623671+03:00"}
```
## Бонус
Чтобы открыть свой сервер, запущенный локально, всему интернету,
можно использовать ngrok:
```
ngrok http 8000
```
После выполнения вам будет выдан публичный домен, по которому будет доступен
порт на вашей локальной системе.
@@ -1,8 +0,0 @@
module ws
go 1.20
require (
github.com/google/uuid v1.3.1
github.com/gorilla/websocket v1.5.0
)
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff. Show more