From 209c66683dc920aa12c4c14386261aaf0e375238 Mon Sep 17 00:00:00 2001 From: Oleg Sukhoroslov Date: Sat, 3 Oct 2026 11:42:32 +0300 Subject: [PATCH] Add HW 4 --- homework/04-broadcast/readme.md | 126 ++ homework/04-broadcast/solution/broadcast.py | 31 + homework/04-broadcast/tests/.dockerignore | 5 + homework/04-broadcast/tests/Cargo.lock | 985 +++++++++ homework/04-broadcast/tests/Cargo.toml | 18 + homework/04-broadcast/tests/Dockerfile | 20 + .../tests/shared_state_validator.py | 1890 +++++++++++++++++ homework/04-broadcast/tests/src/common.rs | 36 + homework/04-broadcast/tests/src/main.rs | 219 ++ .../04-broadcast/tests/src/optimization.rs | 516 +++++ homework/04-broadcast/tests/src/runner.rs | 336 +++ .../04-broadcast/tests/src/shared_state.rs | 78 + homework/04-broadcast/tests/src/tests.rs | 495 +++++ homework/04-broadcast/tests/src/tests_mc.rs | 289 +++ homework/readme.md | 20 +- 15 files changed, 5061 insertions(+), 3 deletions(-) create mode 100644 homework/04-broadcast/readme.md create mode 100644 homework/04-broadcast/solution/broadcast.py create mode 100644 homework/04-broadcast/tests/.dockerignore create mode 100644 homework/04-broadcast/tests/Cargo.lock create mode 100644 homework/04-broadcast/tests/Cargo.toml create mode 100644 homework/04-broadcast/tests/Dockerfile create mode 100644 homework/04-broadcast/tests/shared_state_validator.py create mode 100644 homework/04-broadcast/tests/src/common.rs create mode 100644 homework/04-broadcast/tests/src/main.rs create mode 100644 homework/04-broadcast/tests/src/optimization.rs create mode 100644 homework/04-broadcast/tests/src/runner.rs create mode 100644 homework/04-broadcast/tests/src/shared_state.rs create mode 100644 homework/04-broadcast/tests/src/tests.rs create mode 100644 homework/04-broadcast/tests/src/tests_mc.rs diff --git a/homework/04-broadcast/readme.md b/homework/04-broadcast/readme.md new file mode 100644 index 0000000..dbf8760 --- /dev/null +++ b/homework/04-broadcast/readme.md @@ -0,0 +1,126 @@ +# Надежная упорядоченная рассылка + +В этом задании вам предстоит реализовать механизм рассылки сообщений с описанными далее гарантиями по надежности и порядку доставки. Выполнение и тестирование решения будет происходить в фреймворке 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) и дождитесь результатов. diff --git a/homework/04-broadcast/solution/broadcast.py b/homework/04-broadcast/solution/broadcast.py new file mode 100644 index 0000000..2802610 --- /dev/null +++ b/homework/04-broadcast/solution/broadcast.py @@ -0,0 +1,31 @@ +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 diff --git a/homework/04-broadcast/tests/.dockerignore b/homework/04-broadcast/tests/.dockerignore new file mode 100644 index 0000000..d291891 --- /dev/null +++ b/homework/04-broadcast/tests/.dockerignore @@ -0,0 +1,5 @@ +target +**/target +**/__pycache__ +**/.pytest_cache +*.pyc diff --git a/homework/04-broadcast/tests/Cargo.lock b/homework/04-broadcast/tests/Cargo.lock new file mode 100644 index 0000000..5fec165 --- /dev/null +++ b/homework/04-broadcast/tests/Cargo.lock @@ -0,0 +1,985 @@ +# 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", +] diff --git a/homework/04-broadcast/tests/Cargo.toml b/homework/04-broadcast/tests/Cargo.toml new file mode 100644 index 0000000..523a7d5 --- /dev/null +++ b/homework/04-broadcast/tests/Cargo.toml @@ -0,0 +1,18 @@ +[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" diff --git a/homework/04-broadcast/tests/Dockerfile b/homework/04-broadcast/tests/Dockerfile new file mode 100644 index 0000000..924fd9f --- /dev/null +++ b/homework/04-broadcast/tests/Dockerfile @@ -0,0 +1,20 @@ +# 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"] diff --git a/homework/04-broadcast/tests/shared_state_validator.py b/homework/04-broadcast/tests/shared_state_validator.py new file mode 100644 index 0000000..4f47a41 --- /dev/null +++ b/homework/04-broadcast/tests/shared_state_validator.py @@ -0,0 +1,1890 @@ +"""Static checks for Python state shared between AnySystem processes. + +The checker parses source files only. It never imports or executes a student's +solution. Local imports which can be resolved next to the entrypoint are checked +recursively; calls through external imports are limited to explicitly reviewed APIs. +""" + +from __future__ import annotations + +import ast +from pathlib import Path +from typing import Callable, Iterable + + +MUTABLE_NODES = ( + ast.List, + ast.ListComp, + ast.Dict, + ast.DictComp, + ast.Set, + ast.SetComp, + ast.GeneratorExp, +) +MUTABLE_CONSTRUCTORS = { + "builtins.bytearray", + "builtins.dict", + "builtins.list", + "builtins.set", + "collections.ChainMap", + "collections.Counter", + "collections.OrderedDict", + "collections.defaultdict", + "collections.deque", + "weakref.WeakKeyDictionary", + "weakref.WeakValueDictionary", +} +IMMUTABLE_SCALAR_CONSTRUCTORS = { + "builtins.bool", + "builtins.bytes", + "builtins.complex", + "builtins.float", + "builtins.int", + "builtins.range", + "builtins.str", +} +KNOWN_IMMUTABLE_CALLS = { + "os.getcwd", + "os.path.abspath", + "os.path.basename", + "os.path.dirname", + "os.path.join", + "os.path.normpath", + "os.path.realpath", + "random.randint", + "random.randrange", +} +TYPE_SUBSCRIPT_NAMES = { + "builtins.dict", + "builtins.frozenset", + "builtins.list", + "builtins.set", + "builtins.tuple", + "builtins.type", + "collections.abc.Mapping", + "collections.abc.Sequence", + "typing.Annotated", + "typing.Callable", + "typing.ClassVar", + "typing.Dict", + "typing.FrozenSet", + "typing.List", + "typing.Literal", + "typing.Mapping", + "typing.Optional", + "typing.Sequence", + "typing.Set", + "typing.Tuple", + "typing.Type", + "typing.Union", +} +TYPE_FACTORIES = { + "collections.namedtuple", + "typing.NewType", + "typing.ParamSpec", + "typing.TypeVar", +} +ENUM_BASE_NAMES = { + "enum.Enum", + "enum.Flag", + "enum.IntEnum", + "enum.IntFlag", + "enum.StrEnum", +} +MUTATOR_METHODS = frozenset( + "__delitem__ __setitem__ add append clear discard extend insert move_to_end " + "pop popitem remove reverse rotate setdefault sort update".split() +) +UNBOUND_MUTATOR_METHODS = {"__delattr__", "__delitem__", "__setattr__", "__setitem__"} +OPERATOR_MUTATORS = frozenset( + f"operator.{name}" + for name in "delitem iadd iand iconcat imatmul imul ior isub ixor setitem".split() +) +STATEFUL_DECORATORS = {"functools.cache", "functools.lru_cache"} +# Calls through imported objects are denied unless explicitly reviewed for the +# course. This is deliberately an allowlist: method names alone cannot +# distinguish random.seed() from APIs which construct values, transform their +# arguments, or provide the limited random operations used by the assignments. +ALLOWED_IMPORTED_CALLS = frozenset( + """ + anysystem.Message anysystem.Message.from_json anysystem.python.anysystem.Message + array.array ast.literal_eval + bisect.bisect bisect.bisect_left bisect.bisect_right bisect.insort builtins.print + collections.defaultdict collections.deque collections.namedtuple copy.copy copy.deepcopy + dataclasses.asdict dataclasses.field dataclasses.fields dataclasses.is_dataclass + functools.cmp_to_key functools.reduce + hashlib.blake2b hashlib.md5 hashlib.sha1 hashlib.sha512 hashlib.sha256 + hashlib.sha3_256 hashlib.shake_256 + heapq.heapify heapq.heappop heapq.heappush heapq.merge importlib.import_module + inspect.signature itertools.count json.dumps json.loads + logging.critical logging.debug logging.error logging.exception logging.info logging.log + logging.warning math.ceil math.log math.log2 math.sqrt + os.getcwd os.getenv os.path.abspath os.path.basename os.path.dirname os.path.join + os.path.normpath os.path.realpath pathlib.Path pickle.dumps pickle.loads + portion.closedopen portion.empty pprint.pprint pydantic.Field + random.Random random.choice random.choices random.randint random.random random.randrange + random.sample random.shuffle random.uniform re.fullmatch sys.intern threading.Lock + types.MappingProxyType time.time typing.cast uuid.uuid4 + """.split() +) +IMPORTED_ARGUMENT_MUTATORS = set( + "bisect.insort heapq.heapify heapq.heappop heapq.heappush random.shuffle".split() +) +ALLOWED_MUTABLE_METADATA = {"__all__"} +TRUSTED_STAR_EXPORTS = { + "dataclasses": {"dataclass", "field"}, + "enum": {"Enum", "Flag", "IntEnum", "IntFlag", "StrEnum", "auto"}, + "functools": {"cache", "lru_cache"}, + "types": {"MappingProxyType"}, + "typing": { + *"Annotated Any Callable ClassVar Dict FrozenSet List Literal Mapping NewType " + "Optional ParamSpec Sequence Set Tuple Type TypeVar Union".split(), + }, +} +TRUSTED_BUILTINS = set( + "bool bytearray bytes complex dict float frozenset int list range set str tuple type".split() +) + + +def _resolved_attribute(node: ast.AST) -> tuple[ast.AST, str] | None: + if isinstance(node, ast.Attribute): + return node.value, node.attr + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and len(node.args) >= 2 + and isinstance(node.args[1], ast.Constant) + and isinstance(node.args[1].value, str) + ): + return node.args[0], node.args[1].value + return None + + +def _call_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Name): + return node.id + resolved = _resolved_attribute(node) + return resolved[1] if resolved is not None else None + + +def _symbol(node: ast.AST, symbols: dict[str, str]) -> str | None: + if isinstance(node, ast.Name): + return symbols.get(node.id) + resolved = _resolved_attribute(node) + if resolved is not None: + value, attribute = resolved + parent = _symbol(value, symbols) + return f"{parent}.{attribute}" if parent is not None else None + return None + + +def _root_name(node: ast.AST) -> str | None: + while (resolved := _resolved_attribute(node)) is not None: + node = resolved[0] + return node.id if isinstance(node, ast.Name) else None + + +def _assignment_parts(node: ast.AST) -> tuple[ast.AST, list[ast.AST]] | None: + if isinstance(node, ast.Assign): + return node.value, node.targets + if isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and node.value is not None: + return node.value, [node.target] + return None + + +def _call_returns_shared( + node: ast.AST, + is_shared: Callable[[ast.AST], bool], + shared_functions: set[str], + returned_parameters: dict[str, set[int]], +) -> bool: + if not isinstance(node, ast.Call): + return False + name = node.func.id if isinstance(node.func, ast.Name) else None + if name in shared_functions: + return True + return any( + index < len(node.args) and is_shared(node.args[index]) + for index in returned_parameters.get(name or "", set()) + ) + + +def _is_instance_path(node: ast.AST, instance_names: set[str]) -> bool: + while True: + if isinstance(node, ast.Subscript): + node = node.value + elif (resolved := _resolved_attribute(node)) is not None: + node = resolved[0] + else: + break + return isinstance(node, ast.Name) and node.id in instance_names + + +def _static_string(node: ast.AST, names: dict[str, str]) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.Name): + return names.get(node.id) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left = _static_string(node.left, names) + right = _static_string(node.right, names) + return left + right if left is not None and right is not None else None + if isinstance(node, ast.JoinedStr): + parts: list[str] = [] + for value in node.values: + if isinstance(value, ast.Constant) and isinstance(value.value, str): + parts.append(value.value) + elif isinstance(value, ast.FormattedValue): + part = _static_string(value.value, names) + if part is None: + return None + parts.append(part) + else: + return None + return "".join(parts) + return None + + +def _static_string_values(tree: ast.Module) -> dict[str, str]: + assignments: list[tuple[str, ast.AST]] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + assignments.extend((name, node.value) for name in _target_names(target)) + elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and node.value is not None: + assignments.extend((name, node.value) for name in _target_names(node.target)) + + values: dict[str, str] = {} + ambiguous: set[str] = set() + changed = True + while changed: + changed = False + for name, value_node in assignments: + value = _static_string(value_node, values) + if value is None or name in ambiguous: + continue + if name in values and values[name] != value: + values.pop(name) + ambiguous.add(name) + changed = True + elif name not in values: + values[name] = value + changed = True + return values + + +def _dynamic_import_names(tree: ast.Module) -> tuple[set[str], set[str]]: + modules = {"importlib"} + functions: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "importlib": + modules.add(alias.asname or "importlib") + elif isinstance(node, ast.ImportFrom) and node.module == "importlib": + for alias in node.names: + if alias.name == "import_module": + functions.add(alias.asname or alias.name) + assignments = [node for node in ast.walk(tree) if isinstance(node, ast.Assign)] + changed = True + while changed: + changed = False + for assignment in assignments: + names = set().union(*(_target_names(target) for target in assignment.targets)) + if isinstance(assignment.value, ast.Name): + if assignment.value.id in modules: + before = len(modules) + modules.update(names) + changed |= len(modules) != before + if assignment.value.id in functions: + before = len(functions) + functions.update(names) + changed |= len(functions) != before + elif ( + isinstance(assignment.value, ast.Attribute) + and assignment.value.attr == "import_module" + and isinstance(assignment.value.value, ast.Name) + and assignment.value.value.id in modules + ): + before = len(functions) + functions.update(names) + changed |= len(functions) != before + return modules, functions + + +def _is_dynamic_import_call( + node: ast.Call, importlib_modules: set[str], import_module_functions: set[str] +) -> bool: + return ( + isinstance(node.func, ast.Name) + and (node.func.id == "__import__" or node.func.id in import_module_functions) + ) or ( + (resolved := _resolved_attribute(node.func)) is not None + and resolved[1] == "import_module" + and isinstance(resolved[0], ast.Name) + and resolved[0].id in importlib_modules + ) + + +def _class_has_mutation_escape(class_node: ast.ClassDef) -> bool: + for statement in class_node.body: + if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + positional = [*statement.args.posonlyargs, *statement.args.args] + if not positional: + continue + instance_names = {positional[0].arg} + for node in _function_nodes(statement): + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Delete)): + targets = ( + node.targets + if isinstance(node, (ast.Assign, ast.Delete)) + else [node.target] + ) + if any(_is_instance_path(target, instance_names) for target in targets): + return True + if not isinstance(node, ast.Call): + continue + if ( + isinstance(node.func, ast.Name) + and node.func.id in {"delattr", "setattr"} + and node.args + and _is_instance_path(node.args[0], instance_names) + ): + return True + if ( + (resolved := _resolved_attribute(node.func)) is not None + and resolved[1] in UNBOUND_MUTATOR_METHODS + and node.args + and _is_instance_path(node.args[0], instance_names) + ): + return True + if ( + (resolved := _resolved_attribute(node.func)) is not None + and resolved[1] in MUTATOR_METHODS + and _is_instance_path(resolved[0], instance_names) + ): + return True + return False + + +def _target_names(node: ast.AST) -> set[str]: + if isinstance(node, ast.Name): + return {node.id} + if isinstance(node, (ast.Tuple, ast.List)): + result: set[str] = set() + for element in node.elts: + result.update(_target_names(element)) + return result + return set() + + +def _dict_is_deeply_immutable( + node: ast.Dict, safe_names: set[str], symbols: dict[str, str] +) -> bool: + return all( + key is not None + and _is_immutable_expr(key, safe_names, symbols) + and _is_immutable_expr(value, safe_names, symbols) + for key, value in zip(node.keys, node.values) + ) + + +def _sequence_is_deeply_immutable( + node: ast.List | ast.Set | ast.Tuple, + safe_names: set[str], + symbols: dict[str, str], +) -> bool: + return all(_is_immutable_expr(element, safe_names, symbols) for element in node.elts) + + +def _is_immutable_expr( + node: ast.AST | None, safe_names: set[str], symbols: dict[str, str] +) -> bool: + if node is None or isinstance(node, ast.Constant): + return True + if isinstance(node, ast.Name): + return node.id in safe_names + if isinstance(node, ast.Attribute): + root = _root_name(node) + if root is not None and root in symbols and symbols[root] != f"builtins.{root}": + return node.attr.isupper() + return _is_immutable_expr(node.value, safe_names, symbols) + if isinstance(node, ast.Subscript): + return _symbol(node.value, symbols) in TYPE_SUBSCRIPT_NAMES + if isinstance(node, ast.Tuple): + return _sequence_is_deeply_immutable(node, safe_names, symbols) + if isinstance(node, ast.UnaryOp): + return _is_immutable_expr(node.operand, safe_names, symbols) + if isinstance(node, ast.BinOp): + return _is_immutable_expr(node.left, safe_names, symbols) and _is_immutable_expr( + node.right, safe_names, symbols + ) + if isinstance(node, ast.BoolOp): + return all(_is_immutable_expr(value, safe_names, symbols) for value in node.values) + if isinstance(node, ast.Compare): + return _is_immutable_expr(node.left, safe_names, symbols) and all( + _is_immutable_expr(value, safe_names, symbols) for value in node.comparators + ) + if isinstance(node, ast.IfExp): + return _is_immutable_expr(node.body, safe_names, symbols) and _is_immutable_expr( + node.orelse, safe_names, symbols + ) + if isinstance(node, ast.JoinedStr): + return all( + not isinstance(value, ast.FormattedValue) + or _is_immutable_expr(value.value, safe_names, symbols) + for value in node.values + ) + if isinstance(node, ast.Lambda): + return True + if isinstance(node, ast.Call): + name = _symbol(node.func, symbols) + if name in TYPE_FACTORIES: + return True + local_name = _call_name(node.func) + if local_name is not None and f"frozen-dataclass:{local_name}" in safe_names: + return all( + _is_immutable_expr(arg, safe_names, symbols) for arg in node.args + ) and all( + _is_immutable_expr(keyword.value, safe_names, symbols) + for keyword in node.keywords + ) + if name in IMMUTABLE_SCALAR_CONSTRUCTORS: + return all(_is_immutable_expr(arg, safe_names, symbols) for arg in node.args) + if name in KNOWN_IMMUTABLE_CALLS: + return True + if name in {"builtins.tuple", "builtins.frozenset"}: + if not node.args: + return True + if len(node.args) != 1 or node.keywords: + return False + value = node.args[0] + return isinstance(value, (ast.List, ast.Set, ast.Tuple)) and ( + _sequence_is_deeply_immutable(value, safe_names, symbols) + ) + if name == "types.MappingProxyType": + return ( + len(node.args) == 1 + and not node.keywords + and isinstance(node.args[0], ast.Dict) + and _dict_is_deeply_immutable(node.args[0], safe_names, symbols) + ) + return False + + +def _is_mutable_expr( + node: ast.AST | None, safe_names: set[str], symbols: dict[str, str] +) -> bool: + if node is None: + return False + if isinstance(node, MUTABLE_NODES): + return True + if isinstance(node, ast.Call) and _symbol(node.func, symbols) in MUTABLE_CONSTRUCTORS: + return True + return not _is_immutable_expr(node, safe_names, symbols) + + +def _scope_statements(body: Iterable[ast.stmt]) -> Iterable[ast.stmt]: + for statement in body: + yield statement + nested: list[list[ast.stmt]] = [] + if isinstance(statement, (ast.If, ast.For, ast.AsyncFor, ast.While)): + nested.extend([statement.body, statement.orelse]) + elif isinstance(statement, (ast.With, ast.AsyncWith)): + nested.append(statement.body) + elif isinstance(statement, (ast.Try, ast.TryStar)): + nested.extend([statement.body, statement.orelse, statement.finalbody]) + nested.extend(handler.body for handler in statement.handlers) + elif isinstance(statement, ast.Match): + nested.extend(case.body for case in statement.cases) + for statements in nested: + yield from _scope_statements(statements) + + +def _function_nodes(function: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.AST]: + result: list[ast.AST] = [] + stack: list[ast.AST] = list(reversed(function.body)) + while stack: + node = stack.pop() + result.append(node) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): + continue + stack.extend(reversed(list(ast.iter_child_nodes(node)))) + return result + + +def _shared_class_nodes( + body: Iterable[ast.stmt], prefix: str = "" +) -> Iterable[tuple[str, ast.ClassDef]]: + for statement in _scope_statements(body): + if not isinstance(statement, ast.ClassDef): + continue + qualified_name = f"{prefix}.{statement.name}" if prefix else statement.name + yield qualified_name, statement + yield from _shared_class_nodes(statement.body, qualified_name) + + +def _is_classvar_annotation(node: ast.AST, symbols: dict[str, str]) -> bool: + value = node.value if isinstance(node, ast.Subscript) else node + return _symbol(value, symbols) == "typing.ClassVar" + + +class _FileChecker: + def __init__( + self, + tree: ast.Module, + path: Path, + _target_classes: set[str], + root: Path | None = None, + ): + self.tree = tree + self.path = path + self.root = (root or path.parent).resolve() + self.violations: list[tuple[int, int, str]] = [] + self.safe_names: set[str] = { + "False", + "None", + "True", + "bool", + "bytes", + "complex", + "float", + "frozenset", + "int", + "range", + "str", + "tuple", + } + self.symbols = {name: f"builtins.{name}" for name in TRUSTED_BUILTINS} + self.import_names: set[str] = set() + self.definition_names: set[str] = set() + self.module_binding_names: set[str] = set() + self.module_mutable_names: set[str] = set() + self.classes: dict[str, ast.ClassDef] = {} + self.shared_classes = list(_shared_class_nodes(tree.body)) + self.functions: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {} + self.lambdas: dict[str, ast.Lambda] = {} + self.shared_closure_factories: set[str] = set() + self.shared_returning_functions: set[str] = set() + self.returned_parameters: dict[str, set[int]] = {} + self.stateful_decorator_factories: set[str] = set() + self.shared_instance_attributes: dict[str, set[str]] = {} + + def add(self, node: ast.AST, message: str) -> None: + self.violations.append( + (getattr(node, "lineno", 1), getattr(node, "col_offset", 0) + 1, message) + ) + + def collect_symbols(self) -> None: + shadowed_names: set[str] = set() + for statement in _scope_statements(self.tree.body): + if isinstance(statement, ast.Import): + for alias in statement.names: + name = alias.asname or alias.name.split(".")[0] + self.import_names.add(name) + self.module_binding_names.add(name) + if _candidate_module(self.root, alias.name.split(".")) is None: + self.symbols[name] = alias.name if alias.asname else name + else: + self.symbols.pop(name, None) + elif isinstance(statement, ast.ImportFrom): + local_module = ( + statement.level > 0 + or statement.module is None + or _candidate_module(self.root, statement.module.split(".")) + is not None + ) + for alias in statement.names: + if alias.name == "*": + if statement.module is not None and not local_module: + for export in TRUSTED_STAR_EXPORTS.get( + statement.module, set() + ): + self.import_names.add(export) + self.module_binding_names.add(export) + self.symbols[export] = f"{statement.module}.{export}" + if statement.module == "typing": + self.safe_names.add(export) + else: + name = alias.asname or alias.name + self.import_names.add(name) + self.module_binding_names.add(name) + if statement.module is not None and not local_module: + self.symbols[name] = f"{statement.module}.{alias.name}" + else: + self.symbols.pop(name, None) + if ( + name.isupper() + or statement.module in {"collections.abc", "typing"} + or ( + statement.module == "math" + and alias.name in {"e", "inf", "nan", "pi", "tau"} + ) + ): + self.safe_names.add(name) + elif isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + self.definition_names.add(statement.name) + self.module_binding_names.add(statement.name) + shadowed_names.add(statement.name) + self.functions[statement.name] = statement + elif isinstance(statement, ast.ClassDef): + self.definition_names.add(statement.name) + self.module_binding_names.add(statement.name) + shadowed_names.add(statement.name) + self.classes[statement.name] = statement + elif isinstance(statement, ast.Assign): + for target in statement.targets: + names = _target_names(target) + self.module_binding_names.update(names) + shadowed_names.update(names) + if isinstance(statement.value, ast.Lambda): + for name in names: + self.lambdas[name] = statement.value + elif isinstance(statement, (ast.AnnAssign, ast.AugAssign)): + names = _target_names(statement.target) + self.module_binding_names.update(names) + shadowed_names.update(names) + for name in shadowed_names: + self.symbols.pop(name, None) + self.safe_names.difference_update(shadowed_names) + self.safe_names.update(self.definition_names) + for _, class_node in self.shared_classes: + frozen = any( + isinstance(decorator, ast.Call) + and _symbol(decorator.func, self.symbols) == "dataclasses.dataclass" + and any( + keyword.arg == "frozen" + and isinstance(keyword.value, ast.Constant) + and keyword.value.value is True + for keyword in decorator.keywords + ) + for decorator in class_node.decorator_list + ) + immutable_defaults = all( + not isinstance(statement, ast.AnnAssign) + or statement.value is None + or _is_immutable_expr(statement.value, self.safe_names, self.symbols) + for statement in class_node.body + ) + if frozen and immutable_defaults and not _class_has_mutation_escape(class_node): + self.safe_names.add(f"frozen-dataclass:{class_node.name}") + + def mark_shared_closure_factories(self) -> None: + def consider(value: ast.AST) -> None: + if not isinstance(value, ast.Call): + return + name = _call_name(value.func) + function = self.functions.get(name or "") + if function is not None and any( + isinstance(node, ast.Nonlocal) for node in ast.walk(function) + ): + self.shared_closure_factories.add(function.name) + + scopes = [self.tree.body] + scopes.extend(class_node.body for class_node in self.classes.values()) + for body in scopes: + for statement in _scope_statements(body): + if isinstance(statement, ast.Assign): + consider(statement.value) + elif isinstance(statement, ast.AnnAssign) and statement.value is not None: + consider(statement.value) + + for node in ast.walk(self.tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for decorator in node.decorator_list: + name = _call_name(decorator.func if isinstance(decorator, ast.Call) else decorator) + function = self.functions.get(name or "") + if function is not None and any( + isinstance(child, ast.Nonlocal) for child in ast.walk(function) + ): + self.shared_closure_factories.add(function.name) + + def collect_function_summaries(self) -> None: + """Summarize simple wrappers without executing student code.""" + + callables: dict[ + str, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda + ] = {**self.functions, **self.lambdas} + changed = True + while changed: + changed = False + for function_name, function in callables.items(): + positional = [*function.args.posonlyargs, *function.args.args] + local_names = { + argument.arg + for argument in [*positional, *function.args.kwonlyargs] + } + if function.args.vararg is not None: + local_names.add(function.args.vararg.arg) + if function.args.kwarg is not None: + local_names.add(function.args.kwarg.arg) + + if isinstance(function, ast.Lambda): + nodes = [function.body] + return_values = [function.body] + else: + nodes = _function_nodes(function) + return_values = [ + node.value + for node in nodes + if isinstance(node, ast.Return) and node.value is not None + ] + assignments = [ + node + for node in nodes + if _assignment_parts(node) is not None + ] + for assignment in assignments: + _, targets = _assignment_parts(assignment) or (assignment, []) + for target in targets: + local_names.update(_target_names(target)) + + shared_aliases: set[str] = set() + parameter_aliases = { + argument.arg: {index} for index, argument in enumerate(positional) + } + + def returned_parameter_indexes(node: ast.AST) -> set[int]: + if isinstance(node, ast.Name): + return parameter_aliases.get(node.id, set()) + resolved = _resolved_attribute(node) + if resolved is not None: + return returned_parameter_indexes(resolved[0]) + if isinstance(node, ast.Subscript): + return returned_parameter_indexes(node.value) + if isinstance(node, ast.Call): + name = node.func.id if isinstance(node.func, ast.Name) else None + return set().union( + *( + returned_parameter_indexes(node.args[index]) + for index in self.returned_parameters.get( + name or "", set() + ) + if index < len(node.args) + ), + set(), + ) + return set() + + def returns_shared(node: ast.AST) -> bool: + if _is_immutable_expr(node, self.safe_names, self.symbols): + return False + if isinstance(node, ast.Name): + return ( + node.id in self.module_binding_names + and node.id not in local_names + ) or node.id in shared_aliases + resolved = _resolved_attribute(node) + if resolved is not None: + return returns_shared(resolved[0]) + if isinstance(node, ast.Subscript): + return returns_shared(node.value) + return _call_returns_shared( + node, + returns_shared, + self.shared_returning_functions, + self.returned_parameters, + ) or ( + isinstance(node, ast.Call) + and _symbol(node.func, self.symbols) + == "importlib.import_module" + ) + + aliases_changed = True + while aliases_changed: + aliases_changed = False + for assignment in assignments: + value, targets = _assignment_parts(assignment) or (assignment, []) + indexes = returned_parameter_indexes(value) + shared = returns_shared(value) + before = ( + len(shared_aliases), + sum(map(len, parameter_aliases.values())), + ) + for target in targets: + for name in _target_names(target): + if shared: + shared_aliases.add(name) + if indexes: + parameter_aliases.setdefault(name, set()).update(indexes) + aliases_changed |= before != ( + len(shared_aliases), + sum(map(len, parameter_aliases.values())), + ) + + indexes = set().union( + *(returned_parameter_indexes(value) for value in return_values), set() + ) + known_indexes = self.returned_parameters.setdefault(function_name, set()) + before = len(known_indexes) + known_indexes.update(indexes) + changed |= len(known_indexes) != before + if any(returns_shared(value) for value in return_values): + if function_name not in self.shared_returning_functions: + self.shared_returning_functions.add(function_name) + changed = True + for value in return_values: + if isinstance(value, ast.Call): + called = _symbol(value.func, self.symbols) + local_called = ( + value.func.id + if isinstance(value.func, ast.Name) + else None + ) + if ( + called in STATEFUL_DECORATORS + or local_called in self.stateful_decorator_factories + ) and function_name not in self.stateful_decorator_factories: + self.stateful_decorator_factories.add(function_name) + changed = True + + def decorator_is_stateful( + self, decorator: ast.AST, symbols: dict[str, str] + ) -> bool: + expression = decorator.func if isinstance(decorator, ast.Call) else decorator + return _symbol(expression, symbols) in STATEFUL_DECORATORS or ( + isinstance(expression, ast.Name) + and expression.id in self.stateful_decorator_factories + ) + + def check_module_bindings(self) -> None: + for statement in _scope_statements(self.tree.body): + value: ast.AST | None = None + targets: set[str] = set() + if isinstance(statement, ast.Assign): + value = statement.value + for target in statement.targets: + targets.update(_target_names(target)) + elif isinstance(statement, ast.AnnAssign): + value = statement.value + targets.update(_target_names(statement.target)) + elif isinstance(statement, ast.AugAssign): + value = statement.value + targets.update(_target_names(statement.target)) + else: + continue + + if value is None: + continue + if _is_immutable_expr(value, self.safe_names, self.symbols): + self.safe_names.update(targets) + continue + for name in sorted(targets - ALLOWED_MUTABLE_METADATA): + self.module_mutable_names.add(name) + self.add( + statement, + f"module-level value '{name}' is mutable or stateful and can be shared between processes", + ) + + def check_class_bindings(self) -> None: + for class_name, class_node in self.shared_classes: + class_safe_names = set(self.safe_names) + is_dataclass = any( + _symbol( + decorator.func if isinstance(decorator, ast.Call) else decorator, + self.symbols, + ) + == "dataclasses.dataclass" + for decorator in class_node.decorator_list + ) + is_pydantic_model = any( + ( + isinstance(base, ast.Name) + and _symbol(base, self.symbols) + in {"pydantic.BaseModel", "pydantic.main.BaseModel"} + ) + or ( + isinstance(base, ast.Attribute) + and _symbol(base, self.symbols) + in {"pydantic.BaseModel", "pydantic.main.BaseModel"} + ) + for base in class_node.bases + ) + is_enum = any( + ( + isinstance(base, ast.Name) + and _symbol(base, self.symbols) in ENUM_BASE_NAMES + ) + or ( + isinstance(base, ast.Attribute) + and _symbol(base, self.symbols) in ENUM_BASE_NAMES + ) + for base in class_node.bases + ) + class_symbols = dict(self.symbols) + class_import_symbols: dict[str, str] = {} + class_shadowed_names: set[str] = set() + for class_statement in _scope_statements(class_node.body): + names: set[str] = set() + if isinstance( + class_statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + ): + names.add(class_statement.name) + elif isinstance(class_statement, ast.Assign): + for target in class_statement.targets: + names.update(_target_names(target)) + elif isinstance(class_statement, (ast.AnnAssign, ast.AugAssign)): + names.update(_target_names(class_statement.target)) + elif isinstance(class_statement, ast.Import): + for alias in class_statement.names: + name = alias.asname or alias.name.split(".")[0] + if _candidate_module(self.root, alias.name.split(".")) is None: + class_import_symbols[name] = ( + alias.name if alias.asname else name + ) + elif isinstance(class_statement, ast.ImportFrom): + local_module = ( + class_statement.level > 0 + or class_statement.module is None + or _candidate_module( + self.root, class_statement.module.split(".") + ) + is not None + ) + if class_statement.module is not None and not local_module: + for alias in class_statement.names: + if alias.name == "*": + for export in TRUSTED_STAR_EXPORTS.get( + class_statement.module, set() + ): + class_import_symbols[export] = ( + f"{class_statement.module}.{export}" + ) + else: + name = alias.asname or alias.name + class_import_symbols[name] = ( + f"{class_statement.module}.{alias.name}" + ) + class_shadowed_names.update(names) + class_symbols.update( + (name, symbol) + for name, symbol in class_import_symbols.items() + if name not in class_shadowed_names + ) + for name in class_shadowed_names: + class_symbols.pop(name, None) + class_safe_names.discard(name) + for class_statement in class_node.body: + if not isinstance( + class_statement, (ast.FunctionDef, ast.AsyncFunctionDef) + ): + continue + for decorator in class_statement.decorator_list: + expression = decorator.func if isinstance(decorator, ast.Call) else decorator + name = _symbol(expression, class_symbols) + if self.decorator_is_stateful(decorator, class_symbols): + self.add( + decorator, + f"'{(name or _call_name(expression) or 'decorator').rsplit('.', 1)[-1]}' keeps a cache shared between process instances", + ) + for statement in _scope_statements(class_node.body): + def targets_class_namespace(target: ast.AST) -> bool: + if isinstance(target, ast.Subscript): + return ( + isinstance(target.value, ast.Call) + and isinstance(target.value.func, ast.Name) + and target.value.func.id in {"globals", "locals"} + ) + if isinstance(target, (ast.Tuple, ast.List)): + return any(targets_class_namespace(item) for item in target.elts) + return False + + if isinstance(statement, ast.Assign) and any( + targets_class_namespace(target) for target in statement.targets + ): + self.add(statement, "assignment mutates class-level shared state") + elif isinstance(statement, (ast.AnnAssign, ast.AugAssign)) and ( + targets_class_namespace(statement.target) + ): + self.add(statement, "assignment mutates class-level shared state") + elif isinstance(statement, ast.Delete) and any( + targets_class_namespace(target) for target in statement.targets + ): + self.add(statement, "deletion mutates class-level shared state") + elif isinstance(statement, ast.Expr) and isinstance( + statement.value, ast.Call + ): + call = statement.value + if ( + isinstance(call.func, ast.Attribute) + and call.func.attr in MUTATOR_METHODS | {"__setitem__"} + and isinstance(call.func.value, ast.Call) + and isinstance(call.func.value.func, ast.Name) + and call.func.value.func.id in {"globals", "locals"} + ): + self.add(call, "call mutates class-level shared state") + + value: ast.AST | None = None + targets: set[str] = set() + if isinstance(statement, ast.Assign): + value = statement.value + for target in statement.targets: + targets.update(_target_names(target)) + elif isinstance(statement, ast.AnnAssign): + value = statement.value + targets.update(_target_names(statement.target)) + else: + continue + if value is None: + continue + if _is_immutable_expr(value, class_safe_names, class_symbols): + class_safe_names.update(targets) + for name in targets: + class_symbols.pop(name, None) + continue + if ( + is_enum + and isinstance(value, ast.Call) + and _symbol(value.func, class_symbols) == "enum.auto" + and not value.args + and not value.keywords + ): + class_safe_names.update(targets) + continue + if ( + is_dataclass + and isinstance(statement, ast.AnnAssign) + and not _is_classvar_annotation(statement.annotation, class_symbols) + and isinstance(value, ast.Call) + and _symbol(value.func, class_symbols) == "dataclasses.field" + and not value.args + and all( + keyword.arg != "default" + or not _is_mutable_expr( + keyword.value, class_safe_names, class_symbols + ) + for keyword in value.keywords + ) + ): + continue + if ( + is_pydantic_model + and isinstance(statement, ast.AnnAssign) + and not _is_classvar_annotation(statement.annotation, class_symbols) + and isinstance(value, ast.Call) + and _symbol(value.func, class_symbols) == "pydantic.Field" + and not value.args + and all( + keyword.arg != "default" + or not _is_mutable_expr( + keyword.value, class_safe_names, class_symbols + ) + for keyword in value.keywords + ) + ): + continue + for name in sorted(targets): + self.add( + statement, + f"class attribute '{class_name}.{name}' is mutable or stateful and is shared by all process instances", + ) + + def check_module_mutations(self) -> None: + statements = list(_scope_statements(self.tree.body)) + class_aliases = set(self.classes) + shared_aliases: set[str] = set() + global_objects = self.module_binding_names + + def is_class_ref(node: ast.AST) -> bool: + return isinstance(node, ast.Name) and node.id in class_aliases + + def is_shared_ref(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return node.id in global_objects or node.id in shared_aliases + if isinstance(node, ast.Attribute): + return ( + is_class_ref(node.value) + or is_shared_ref(node.value) + or ( + isinstance(node.value, ast.Name) + and node.value.id in global_objects + ) + ) + if isinstance(node, ast.Subscript): + return is_shared_ref(node.value) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and node.args + ): + return is_class_ref(node.args[0]) or is_shared_ref(node.args[0]) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "eval" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + try: + expression = ast.parse(node.args[0].value, mode="eval").body + except SyntaxError: + return False + return is_shared_ref(expression) + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "vars" + and len(node.args) == 1 + and (is_class_ref(node.args[0]) or is_shared_ref(node.args[0])) + ) or ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in {"globals", "locals"} + ) + + assignments = [ + statement + for statement in statements + if isinstance(statement, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) + ] + changed = True + while changed: + changed = False + for assignment in assignments: + value, targets = _assignment_parts(assignment) or (assignment, []) + names = set().union(*map(_target_names, targets), set()) + before = (len(class_aliases), len(shared_aliases)) + if is_class_ref(value): + class_aliases.update(names) + if is_shared_ref(value): + shared_aliases.update(names) + if before != (len(class_aliases), len(shared_aliases)): + changed = True + + def target_is_shared(target: ast.AST) -> bool: + if isinstance(target, ast.Attribute): + return ( + is_class_ref(target.value) + or is_shared_ref(target.value) + or ( + isinstance(target.value, ast.Name) + and target.value.id in global_objects + ) + ) + if isinstance(target, ast.Subscript): + return is_shared_ref(target.value) + if isinstance(target, (ast.Tuple, ast.List)): + return any(target_is_shared(element) for element in target.elts) + return False + + for statement in statements: + if isinstance(statement, ast.Assign): + if any(target_is_shared(target) for target in statement.targets): + self.add(statement, "assignment creates or mutates module-level shared state") + elif isinstance(statement, (ast.AnnAssign, ast.AugAssign)): + if target_is_shared(statement.target): + self.add(statement, "assignment creates or mutates module-level shared state") + elif isinstance(statement, ast.Delete): + if any(target_is_shared(target) for target in statement.targets): + self.add(statement, "deletion mutates module-level shared state") + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Name) and call.func.id in { + "exec", + "globals", + "locals", + }: + self.add(call, f"'{call.func.id}' can access or modify module-level shared state") + elif ( + isinstance(call.func, ast.Name) + and call.func.id == "eval" + and is_shared_ref(call) + ): + self.add(call, "'eval' accesses module-level shared state") + elif ( + isinstance(call.func, ast.Name) + and call.func.id in {"setattr", "delattr"} + and call.args + and ( + is_class_ref(call.args[0]) + or is_shared_ref(call.args[0]) + or ( + isinstance(call.args[0], ast.Name) + and call.args[0].id in global_objects + ) + ) + ): + self.add(call, f"'{call.func.id}' mutates module-level shared state") + elif ( + _symbol(call.func, self.symbols) in OPERATOR_MUTATORS + and call.args + and is_shared_ref(call.args[0]) + ): + self.add(call, "operator call mutates module-level shared state") + elif ( + (resolved := _resolved_attribute(call.func)) is not None + and resolved[1] in UNBOUND_MUTATOR_METHODS + and call.args + and ( + is_class_ref(call.args[0]) + or is_shared_ref(call.args[0]) + or ( + isinstance(call.args[0], ast.Name) + and call.args[0].id in global_objects + ) + ) + ): + self.add(call, f"'{resolved[1]}' mutates module-level shared state") + elif ( + (resolved := _resolved_attribute(call.func)) is not None + and resolved[1] in MUTATOR_METHODS + and is_shared_ref(resolved[0]) + ): + if not ( + _symbol(resolved[0], self.symbols) == "sys.path" + and resolved[1] in {"append", "insert"} + ): + self.add( + call, + f"'{resolved[1]}' mutates module-level shared state", + ) + elif ( + (canonical := _symbol(call.func, self.symbols)) is not None + and (_root_name(call.func) or "") in self.import_names + and canonical not in ALLOWED_IMPORTED_CALLS + and canonical not in STATEFUL_DECORATORS + and canonical not in TYPE_FACTORIES + and canonical not in MUTABLE_CONSTRUCTORS + and canonical not in IMMUTABLE_SCALAR_CONSTRUCTORS + ): + self.add( + call, + f"call to imported API '{canonical}' is not known to be free of shared module-state mutation", + ) + + def check_defaults(self) -> None: + for node in ast.walk(self.tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + continue + defaults = list(node.args.defaults) + defaults.extend(default for default in node.args.kw_defaults if default is not None) + for default in defaults: + if _is_mutable_expr(default, self.safe_names, self.symbols): + self.add(default, "mutable default argument is shared between function calls") + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + for decorator in node.decorator_list: + expression = decorator.func if isinstance(decorator, ast.Call) else decorator + name = _symbol(expression, self.symbols) + if self.decorator_is_stateful(decorator, self.symbols): + self.add( + decorator, + f"'{(name or _call_name(expression) or 'decorator').rsplit('.', 1)[-1]}' keeps a cache shared between process instances", + ) + + def check_dynamic_imports(self) -> None: + string_values = _static_string_values(self.tree) + importlib_modules, import_module_functions = _dynamic_import_names(self.tree) + for node in ast.walk(self.tree): + if ( + isinstance(node, ast.Call) + and node.args + and _is_dynamic_import_call( + node, importlib_modules, import_module_functions + ) + and _static_string(node.args[0], string_values) is None + ): + self.add( + node, + "dynamic import name cannot be resolved statically; shared state in the imported module cannot be checked", + ) + + def collect_shared_instance_attributes(self) -> None: + for class_name, class_node in self.classes.items(): + attributes = self.shared_instance_attributes.setdefault(class_name, set()) + functions = [ + statement + for statement in class_node.body + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) + ] + changed = True + while changed: + changed = False + for function in functions: + positional = [*function.args.posonlyargs, *function.args.args] + if not positional: + continue + instance_names = {positional[0].arg} + nodes = _function_nodes(function) + assignments = [ + node + for node in nodes + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) + ] + local_names = { + argument.arg + for argument in [*positional, *function.args.kwonlyargs] + } + local_import_names: set[str] = set() + for node in nodes: + if isinstance(node, ast.Assign): + for target in node.targets: + local_names.update(_target_names(target)) + elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)): + local_names.update(_target_names(node.target)) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + names = { + alias.asname or alias.name.split(".")[0] + for alias in node.names + if alias.name != "*" + } + local_names.update(names) + local_import_names.update(names) + + shared_aliases: set[str] = set() + + def is_instance(node: ast.AST) -> bool: + return isinstance(node, ast.Name) and node.id in instance_names + + def is_shared(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return ( + node.id in self.module_binding_names + and node.id not in local_names + ) or node.id in shared_aliases | local_import_names + if (resolved := _resolved_attribute(node)) is not None: + return ( + is_instance(resolved[0]) and resolved[1] in attributes + ) or is_shared(resolved[0]) + if isinstance(node, ast.Subscript): + return is_shared(node.value) + return _call_returns_shared( + node, + is_shared, + self.shared_returning_functions, + self.returned_parameters, + ) or ( + isinstance(node, ast.Call) + and _symbol(node.func, self.symbols) + == "importlib.import_module" + ) + + aliases_changed = True + while aliases_changed: + aliases_changed = False + for assignment in assignments: + value, targets = _assignment_parts(assignment) or (assignment, []) + if is_instance(value): + names = set().union(*(_target_names(t) for t in targets)) + before = len(instance_names) + instance_names.update(names) + aliases_changed |= len(instance_names) != before + if not is_shared(value): + continue + for target in targets: + names = _target_names(target) + before = len(shared_aliases) + shared_aliases.update(names) + aliases_changed |= len(shared_aliases) != before + if ( + isinstance(target, ast.Attribute) + and is_instance(target.value) + and target.attr not in attributes + ): + attributes.add(target.attr) + changed = True + + def check_function( + self, + function: ast.FunctionDef | ast.AsyncFunctionDef, + owner: str | None, + shared_closure: bool, + ) -> None: + nodes = _function_nodes(function) + all_class_names = set(self.classes) + instance_aliases: set[str] = set() + class_aliases: set[str] = set() + shared_aliases: set[str] = set() + shared_instance_attributes = self.shared_instance_attributes.get(owner or "", set()) + positional = [*function.args.posonlyargs, *function.args.args] + local_names = { + argument.arg + for argument in [*positional, *function.args.kwonlyargs] + } + if function.args.vararg is not None: + local_names.add(function.args.vararg.arg) + if function.args.kwarg is not None: + local_names.add(function.args.kwarg.arg) + local_import_names: set[str] = set() + local_import_symbols: dict[str, str] = {} + for node in nodes: + if isinstance(node, ast.Assign): + for target in node.targets: + local_names.update(_target_names(target)) + elif isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.NamedExpr)): + local_names.update(_target_names(node.target)) + elif isinstance(node, ast.Import): + names = { + alias.asname or alias.name.split(".")[0] + for alias in node.names + } + local_names.update(names) + local_import_names.update(names) + for alias in node.names: + name = alias.asname or alias.name.split(".")[0] + if _candidate_module(self.root, alias.name.split(".")) is None: + local_import_symbols[name] = alias.name if alias.asname else name + elif isinstance(node, ast.ImportFrom): + names = { + alias.asname or alias.name + for alias in node.names + if alias.name != "*" + } + local_names.update(names) + local_import_names.update(names) + local_module = ( + node.level > 0 + or node.module is None + or _candidate_module(self.root, node.module.split(".")) is not None + ) + if node.module is not None and not local_module: + for alias in node.names: + if alias.name != "*": + local_import_symbols[alias.asname or alias.name] = ( + f"{node.module}.{alias.name}" + ) + function_symbols = dict(self.symbols) + for name in local_names - local_import_names: + function_symbols.pop(name, None) + function_symbols.update(local_import_symbols) + imported_callable_names = (self.import_names - local_names) | local_import_names + if owner is not None and positional: + first = positional[0].arg + if first == "cls" or any( + isinstance(decorator, ast.Name) and decorator.id == "classmethod" + for decorator in function.decorator_list + ): + class_aliases.add(first) + else: + instance_aliases.add(first) + + def is_instance_ref(node: ast.AST) -> bool: + return isinstance(node, ast.Name) and node.id in instance_aliases + + def is_class_ref(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return ( + node.id in all_class_names and node.id not in local_names + ) or node.id in class_aliases + if isinstance(node, ast.Attribute): + return node.attr == "__class__" and is_instance_ref(node.value) + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "type" + and len(node.args) == 1 + and is_instance_ref(node.args[0]) + ) + + global_objects = self.module_binding_names + + def is_shared_ref(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return ( + node.id in global_objects and node.id not in local_names + ) or node.id in shared_aliases | local_import_names + if (resolved := _resolved_attribute(node)) is not None: + value, attribute = resolved + if is_instance_ref(value) and attribute in shared_instance_attributes: + return True + if attribute == "__dict__" and ( + is_class_ref(value) + or (isinstance(value, ast.Name) and value.id in global_objects) + ): + return True + return is_class_ref(value) or is_shared_ref(value) or ( + isinstance(value, ast.Name) and value.id in global_objects + ) + if isinstance(node, ast.Subscript): + return is_shared_ref(node.value) + if _call_returns_shared( + node, + is_shared_ref, + self.shared_returning_functions, + self.returned_parameters, + ): + return True + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and node.args + ): + return ( + is_class_ref(node.args[0]) + or is_shared_ref(node.args[0]) + or ( + isinstance(node.args[0], ast.Name) + and node.args[0].id in global_objects + ) + ) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "eval" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + try: + expression = ast.parse(node.args[0].value, mode="eval").body + except SyntaxError: + return False + return is_shared_ref(expression) + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "vars" + and len(node.args) == 1 + and ( + is_class_ref(node.args[0]) + or is_shared_ref(node.args[0]) + or ( + isinstance(node.args[0], ast.Name) + and node.args[0].id in global_objects + ) + ) + ) or ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "globals" + ) + + assignments = [ + node + for node in nodes + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) + ] + unbound_mutator_aliases: set[str] = set() + bound_mutator_aliases: set[str] = set() + imported_callable_aliases: dict[str, str] = {} + changed = True + while changed: + changed = False + for assignment in assignments: + value, targets = _assignment_parts(assignment) or (assignment, []) + names: set[str] = set() + for target in targets: + names.update(_target_names(target)) + before = ( + len(instance_aliases), + len(class_aliases), + len(shared_aliases), + len(unbound_mutator_aliases), + len(bound_mutator_aliases), + len(imported_callable_aliases), + ) + if is_instance_ref(value): + instance_aliases.update(names) + if is_class_ref(value): + class_aliases.update(names) + if is_shared_ref(value): + shared_aliases.update(names) + symbol = _symbol(value, function_symbols) + aliased_symbol = ( + imported_callable_aliases.get(value.id) + if isinstance(value, ast.Name) + else None + ) + if aliased_symbol is not None: + symbol = aliased_symbol + if ( + symbol is not None + and ( + aliased_symbol is not None + or (_root_name(value) or "") in imported_callable_names + ) + ): + for name in names: + imported_callable_aliases[name] = symbol + if symbol in OPERATOR_MUTATORS or ( + isinstance(value, ast.Name) + and value.id in unbound_mutator_aliases + ): + unbound_mutator_aliases.update(names) + if ( + (resolved := _resolved_attribute(value)) is not None + and resolved[1] in MUTATOR_METHODS + and is_shared_ref(resolved[0]) + ) or ( + isinstance(value, ast.Name) and value.id in bound_mutator_aliases + ): + bound_mutator_aliases.update(names) + if before != ( + len(instance_aliases), + len(class_aliases), + len(shared_aliases), + len(unbound_mutator_aliases), + len(bound_mutator_aliases), + len(imported_callable_aliases), + ): + changed = True + + def target_is_shared(target: ast.AST) -> bool: + if isinstance(target, ast.Attribute): + return ( + is_class_ref(target.value) + or is_shared_ref(target.value) + or ( + isinstance(target.value, ast.Name) + and target.value.id in global_objects + ) + ) + if isinstance(target, ast.Subscript): + return is_shared_ref(target.value) + if isinstance(target, (ast.Tuple, ast.List)): + return any(target_is_shared(element) for element in target.elts) + return False + + for node in nodes: + if isinstance(node, ast.Global): + self.add(node, "'global' state can be shared between process instances") + elif isinstance(node, ast.Nonlocal) and shared_closure: + self.add(node, "shared closure state can be accessed by multiple process instances") + elif isinstance(node, ast.Assign): + if any(target_is_shared(target) for target in node.targets): + self.add(node, "assignment mutates state outside the process instance") + elif isinstance(node, (ast.AnnAssign, ast.AugAssign)): + if target_is_shared(node.target): + self.add(node, "assignment mutates state outside the process instance") + elif isinstance(node, ast.Delete): + if any(target_is_shared(target) for target in node.targets): + self.add(node, "deletion mutates state outside the process instance") + elif isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id in {"exec", "globals"}: + self.add(node, f"'{node.func.id}' can access or modify module-level shared state") + elif ( + isinstance(node.func, ast.Name) + and node.func.id == "eval" + and is_shared_ref(node) + ): + self.add(node, "'eval' accesses module-level shared state") + elif ( + isinstance(node.func, ast.Name) + and node.func.id in {"setattr", "delattr"} + and node.args + and ( + is_class_ref(node.args[0]) + or is_shared_ref(node.args[0]) + or ( + isinstance(node.args[0], ast.Name) + and node.args[0].id in global_objects + ) + ) + ): + self.add(node, f"'{node.func.id}' mutates shared object state") + elif ( + ( + _symbol(node.func, function_symbols) in OPERATOR_MUTATORS + or ( + isinstance(node.func, ast.Name) + and node.func.id in unbound_mutator_aliases + ) + ) + and node.args + and is_shared_ref(node.args[0]) + ): + self.add(node, "operator call mutates shared object state") + elif ( + isinstance(node.func, ast.Name) + and node.func.id in bound_mutator_aliases + ): + self.add(node, "aliased method mutates shared object state") + elif ( + ( + imported_callable_aliases.get( + node.func.id, _symbol(node.func, function_symbols) + ) + if isinstance(node.func, ast.Name) + else _symbol(node.func, function_symbols) + ) + in IMPORTED_ARGUMENT_MUTATORS + and node.args + and is_shared_ref(node.args[0]) + ): + self.add(node, "imported function mutates shared object state") + elif ( + (resolved := _resolved_attribute(node.func)) is not None + and resolved[1] in UNBOUND_MUTATOR_METHODS + and node.args + and ( + is_class_ref(node.args[0]) + or is_shared_ref(node.args[0]) + or ( + isinstance(node.args[0], ast.Name) + and node.args[0].id in global_objects + ) + ) + ): + self.add(node, f"'{resolved[1]}' mutates shared object state") + elif ( + (resolved := _resolved_attribute(node.func)) is not None + and resolved[1] in MUTATOR_METHODS + and is_shared_ref(resolved[0]) + ): + self.add(node, f"'{resolved[1]}' mutates state outside the process instance") + elif ( + ( + canonical := ( + imported_callable_aliases.get( + node.func.id, _symbol(node.func, function_symbols) + ) + if isinstance(node.func, ast.Name) + else _symbol(node.func, function_symbols) + ) + ) + is not None + and ( + (_root_name(node.func) or "") in imported_callable_names + or ( + isinstance(node.func, ast.Name) + and node.func.id in imported_callable_aliases + ) + ) + and canonical not in ALLOWED_IMPORTED_CALLS + and canonical not in STATEFUL_DECORATORS + and canonical not in TYPE_FACTORIES + and canonical not in MUTABLE_CONSTRUCTORS + and canonical not in IMMUTABLE_SCALAR_CONSTRUCTORS + ): + self.add( + node, + f"call to imported API '{canonical}' is not known to be free of shared module-state mutation", + ) + + def check_functions(self) -> None: + def visit_body( + body: Iterable[ast.stmt], + owner: str | None = None, + shared_closure: bool = False, + ) -> None: + for statement in body: + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + function_closure = shared_closure or ( + owner is None and statement.name in self.shared_closure_factories + ) + self.check_function(statement, owner, function_closure) + visit_body(statement.body, None, function_closure) + elif isinstance(statement, ast.ClassDef): + visit_body(statement.body, statement.name, shared_closure) + else: + for child in ast.iter_child_nodes(statement): + if isinstance(child, ast.stmt): + visit_body([child], owner, shared_closure) + + visit_body(self.tree.body) + + def run(self) -> list[tuple[int, int, str]]: + self.collect_symbols() + self.mark_shared_closure_factories() + self.check_module_bindings() + self.collect_function_summaries() + self.check_class_bindings() + self.check_module_mutations() + self.check_defaults() + self.check_dynamic_imports() + self.collect_shared_instance_attributes() + self.check_functions() + return sorted(set(self.violations)) + + +def _candidate_module(root: Path, parts: list[str]) -> Path | None: + if not parts: + return None + module = root.joinpath(*parts) + candidates = [module.with_suffix(".py"), module / "__init__.py"] + root = root.resolve() + for candidate in candidates: + try: + resolved = candidate.resolve() + resolved.relative_to(root) + except (OSError, ValueError): + continue + if resolved.is_file(): + return resolved + return None + + +def _local_imports(tree: ast.Module, current: Path, root: Path) -> set[Path]: + result: set[Path] = set() + importlib_modules, import_module_functions = _dynamic_import_names(tree) + string_values = _static_string_values(tree) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + candidate = _candidate_module(root, alias.name.split(".")) + if candidate is not None: + result.add(candidate) + elif isinstance(node, ast.ImportFrom): + if node.level: + base = current.parent + for _ in range(node.level - 1): + base = base.parent + else: + base = root + module_parts = node.module.split(".") if node.module else [] + candidate = _candidate_module(base, module_parts) + if candidate is not None: + result.add(candidate) + for alias in node.names: + if alias.name == "*": + continue + candidate = _candidate_module(base, [*module_parts, alias.name]) + if candidate is not None: + result.add(candidate) + elif isinstance(node, ast.Call) and node.args: + module_name = _static_string(node.args[0], string_values) + if ( + _is_dynamic_import_call( + node, importlib_modules, import_module_functions + ) + and module_name is not None + and not module_name.startswith(".") + ): + candidate = _candidate_module(root, module_name.split(".")) + if candidate is not None: + result.add(candidate) + return result + + +def validate_source(source: str, filename: str, class_names: list[str]) -> list[str]: + tree = ast.parse(source, filename=filename) + path = Path(filename) + return [ + f"{filename}:{line}:{column}: {message}" + for line, column, message in _FileChecker( + tree, path, set(class_names), path.parent + ).run() + ] + + +def validate_solution(path: str, class_names: list[str]) -> list[str]: + entrypoint = Path(path).resolve() + root = entrypoint.parent + pending = [entrypoint] + visited: set[Path] = set() + violations: list[str] = [] + while pending: + current = pending.pop() + if current in visited: + continue + visited.add(current) + source = current.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(current)) + checker = _FileChecker( + tree, + current, + set(class_names) if current == entrypoint else set(), + root, + ) + violations.extend( + f"{current}:{line}:{column}: {message}" + for line, column, message in checker.run() + ) + pending.extend(sorted(_local_imports(tree, current, root) - visited)) + return sorted(set(violations)) diff --git a/homework/04-broadcast/tests/src/common.rs b/homework/04-broadcast/tests/src/common.rs new file mode 100644 index 0000000..fcfeb2c --- /dev/null +++ b/homework/04-broadcast/tests/src/common.rs @@ -0,0 +1,36 @@ +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 +} diff --git a/homework/04-broadcast/tests/src/main.rs b/homework/04-broadcast/tests/src/main.rs new file mode 100644 index 0000000..ad144e8 --- /dev/null +++ b/homework/04-broadcast/tests/src/main.rs @@ -0,0 +1,219 @@ +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, + + /// 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, + + /// 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, + + #[clap(long, hide = true)] + test_result: Option, +} + +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) -> 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}"); + } + } +} diff --git a/homework/04-broadcast/tests/src/optimization.rs b/homework/04-broadcast/tests/src/optimization.rs new file mode 100644 index 0000000..b13f355 --- /dev/null +++ b/homework/04-broadcast/tests/src/optimization.rs @@ -0,0 +1,516 @@ +//! 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, + latency_max: Option, + 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>, + 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 = (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::::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::(&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::() / 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, + problem: Option, + diagnostics: Vec, +} + +fn read_bounded( + reader: impl Read + Send + 'static, + exceeded: Arc, +) -> thread::JoinHandle> { + 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 { + 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, String> { + let json = line + .strip_prefix(RESULT_PREFIX) + .ok_or("worker did not return measurements")?; + let rows: Vec = + 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| { + 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)) + ); + } + } + } +} diff --git a/homework/04-broadcast/tests/src/runner.rs b/homework/04-broadcast/tests/src/runner.rs new file mode 100644 index 0000000..c23ffbf --- /dev/null +++ b/homework/04-broadcast/tests/src/runner.rs @@ -0,0 +1,336 @@ +//! 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 { + 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 { + 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)); + } +} diff --git a/homework/04-broadcast/tests/src/shared_state.rs b/homework/04-broadcast/tests/src/shared_state.rs new file mode 100644 index 0000000..03156d4 --- /dev/null +++ b/homework/04-broadcast/tests/src/shared_state.rs @@ -0,0 +1,78 @@ +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> { + 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, 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::>()) + .map_err(|error| error.to_string()) + }) +} + +fn validate_solution(path: &str, class_names: &[&str]) -> Result, 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::>()) + .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")); + } +} diff --git a/homework/04-broadcast/tests/src/tests.rs b/homework/04-broadcast/tests/src/tests.rs new file mode 100644 index 0000000..3f92ee7 --- /dev/null +++ b/homework/04-broadcast/tests/src/tests.rs @@ -0,0 +1,495 @@ +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 = (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::>(), + ); + 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 = (0..failures).map(|n| n.to_string()).collect(); + let majority: Vec = (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::>(), + &majority.iter().map(String::as_str).collect::>(), + ); + 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::>() + .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) -> 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::>(); + 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(", "))) + } +} diff --git a/homework/04-broadcast/tests/src/tests_mc.rs b/homework/04-broadcast/tests/src/tests_mc.rs new file mode 100644 index 0000000..eeda0ea --- /dev/null +++ b/homework/04-broadcast/tests/src/tests_mc.rs @@ -0,0 +1,289 @@ +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::>(), + ); + 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::(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::>(), + ); + + 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::(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::>(), + ); + 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::(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::(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::(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 { + state.node_states[proc].proc_states[proc] + .local_outbox + .iter() + .position(|msg| { + msg.tip == "DELIVER" + && serde_json::from_str::(&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::::from_iter(equivalent_procs.clone().into_iter()); + let mut used_proc_names = HashSet::::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, 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(()) + }) +} diff --git a/homework/readme.md b/homework/readme.md index 3fca7b6..a83c8f4 100644 --- a/homework/readme.md +++ b/homework/readme.md @@ -28,7 +28,7 @@ В AnySystem 0.3.0 Python-модуль встроен в Rust-тестер: при запуске тестов локально или в Docker отдельный `anysystem.py` и настройка `PYTHONPATH` не требуются. Запускайте тесты из папки задания, чтобы относительные пути к решениям были корректны. Стаб служит только для IDE. -### Регистрация +## Регистрация (Пропустите эту часть, если вы вольнослушатель.) @@ -71,6 +71,22 @@ docker run --rm -t -v ./solution:/solution distsys-tests-local [ЗДЕСЬ МО Тестирующая система используется для итоговой проверки и служит эталоном для временных лимитов, но не должна заменять локальное тестирование после каждой правки. +## Бонусы за пробелы в тестах + +Если вы найдёте ошибку в тестах или требование задания, нарушение которого тесты не обнаруживают, опишите проблему в отчёте; при необходимости приведите пример ошибочного решения. За подтверждённую проблему можно получить 1 бонусный балл. За тест, который выявляет её, или описание его логики можно получить ещё 1 балл. Бонусы начисляются отдельно, независимо от баллов за выполнение задания. + +## Отчёт + +К каждому домашнему заданию приложите краткий отчёт в файле `solution/readme.md`. Сдайте его вместе с решением до дедлайна. Без отчёта автоматические тесты запускаются, но защита не проводится и **решение не засчитывается**. + +В отчёте объясните, как устроено ваше решение и как работают его основные части. Дополнительные требования к содержимому отчёта могут быть указаны в условиях соответствующего ДЗ. + +Пожалуйста, пишите кратко и по существу. Перед сдачей перечитайте отчёт: проверьте, что он описывает именно вашу реализацию, и уберите повторы, общие рассуждения и ненужные подробности. Проверяющему должно быть понятно, как работает решение и почему вы выбрали такой подход. + +Оценка за ДЗ может быть снижена на **1 балл**, если изложение заметно затрудняет понимание решения. В исключительных случаях снижение может составить **2 балла**, если по отчёту практически невозможно понять, как работает решение: например, существенные объяснения отсутствуют или теряются среди большого объёма лишнего текста. + +При снижении оценки проверяющий укажет конкретные недостатки отчёта. Отдельные стилистические шероховатости и само по себе использование LLM основанием для снижения не являются. + ## Сдача решения После локального тестирования откройте папку задания, например `homework/01-guarantees`, и выполните команду: @@ -81,8 +97,6 @@ python3 ../cli.py submit Утилита определит идентификатор задания по имени текущей папки и отправит содержимое её поддиректории `solution`. Она выведет идентификатор и официальное время приёма решения, а также ссылку на запуск workflow. Откройте эту ссылку в Gitea, чтобы следить за проверкой и прочитать её журнал. -В состав решения для каждого задания должен входить краткий отчёт в файле `solution/readme.md`. Отчёт необходимо сдать вместе с остальными файлами решения до дедлайна задания. При его отсутствии автоматические тесты всё равно запускаются, но защита не проводится и **решение не засчитывается**. Требования к содержанию отчёта приведены в условии соответствующего задания. - ## Изоляция процессов AnySystem В заданиях на AnySystem каждый экземпляр `Process` моделирует отдельный процесс распределённой системы. Его изменяемое состояние должно храниться только в атрибутах экземпляра (`self`). Все взаимодействия между процессами должны происходить через сообщения, как и в реальной распределённой системе.