Skip to content

feat: add bedtime mode, and fix the hourly restart and dead scheduler - #24

Merged
and3rn3t merged 4 commits into
mainfrom
feature/bedtime-mode
Sep 19, 2026
Merged

and3rn3t merged 4 commits into
mainfrom
feature/bedtime-mode

Conversation

@and3rn3t

Copy link
Copy Markdown
Owner

Description

W5 from the family server roadmap, plus two
pre-existing bugs found while building it.

Bedtime mode

A shutdown that arrives without warning starts an argument. Bedtime gives a
countdown instead:

20:00  30 minutes until bedtime      (bossbar appears)
20:20  10 minutes until bedtime
20:25  5 minutes until bedtime
20:29  1 minute until bedtime
20:30  Goodnight. The server is closing.
20:35  Jonah: The server is closed until 07:00. Goodnight!

It is a window, not a moment. Stopping the server is not enough on its own,
because a restart policy, an update timer or somebody pressing start on the
dashboard all reopen the evening. Between bedtime and the wake time, anyone who
joins is sent straight back out with a message saying when the server opens
again. That holds regardless of how the server came back.

The window spans midnight, with anything before the wake time belonging to the
previous evening, so 01:00 on Saturday is still Friday night. Weeknight and
weekend bedtimes are chosen by the evening rather than the day, because it is
the night before a non-school morning that matters.

Controls are extend, skip tonight and start now. Extending re-arms the warnings
so the countdown is announced again against the new deadline. All three refuse
once bedtime has happened and say why.

New page at /bedtime, endpoints under /api/bedtime, config in
config/bedtime.conf.example, and docs/BEDTIME.md.

Two bugs found along the way

Both are in the first commit, separate from the feature, and both are
pre-existing on main.

The hourly update timer restarted the server every hour.
systemd/minecraft-update.service ran docker compose up -d --force-recreate
unconditionally on an hourly timer. --force-recreate recreates containers even
when nothing has changed, so everyone online was disconnected on the hour
whether or not a new image existed. The comment above the line says "Restart
container if image changed", but nothing checked. It also restarted servers that
had been stopped deliberately, which would have undone bedtime within the hour.
scripts/auto-update.sh now compares image ids and restarts only on a real
change, and leaves a stopped server stopped.

Scheduled commands were never executed. The Scheduler page writes to
config/command-schedule.json, and scripts/command-scheduler.py run executes
whatever is due, but nothing invoked it: no timer, no cron entry, no loop in the
API. Every schedule created through the web UI was stored and silently ignored,
and the page gave no sign of it. systemd/minecraft-scheduler.{service,timer}
runs it once a minute.

Notes for review

The join kick is queued rather than sent in the event handler. Bus handlers
run on the thread that follows the server log, and sending a command makes a
network call. This is the same trap the Hall of Deaths review caught in #23,
avoided here rather than repeated.

Bedtime defaults to disabled. Something that can stop the server and turn
people away should never switch itself on because a default said so.

The tests caught a real bug. Warnings fired the largest unfired mark rather
than the one describing the time actually left, so a server started with ten
minutes to go greeted everyone with "30 minutes until bedtime". Every mark at or
above the remaining time is now treated as given, and the smallest is announced.

All scheduling takes the current time as an argument, so a whole evening is
tested without waiting for one, including the midnight boundary.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Refactor (code change that neither fixes a bug nor adds a feature)
  • Documentation (changes to docs only)
  • Other (please describe):

Checklist

  • I have performed a self-review of my code
  • I have added tests that prove my fix or feature works
  • I have updated documentation as needed
  • Lint and tests pass locally
  • I have not introduced any security vulnerabilities

Verification run locally:

Check Before After
make test-api 495 571
make test-web 172 184
Coverage 56.6% 60.6%

make lint passes (79 shell scripts through shellcheck, up from 77),
docker compose config validates, Black and flake8 are clean, and the
production web build succeeds.

Note: the two systemd units and the update-script change take effect only after
systemctl daemon-reload and re-enabling on the Pi. The scheduler timer is new
and needs systemctl enable --now minecraft-scheduler.timer.

Related Issues

🤖 Generated with Claude Code

and3rn3t and others added 2 commits September 19, 2026 09:36
…hedules

Two bugs found while building bedtime mode. Both are pre-existing.

The update timer restarted the server every hour

systemd/minecraft-update.service ran `docker compose up -d --force-recreate`
unconditionally, on an hourly timer. --force-recreate recreates containers even
when nothing has changed, so everyone online was disconnected on the hour
whether or not a new image existed. The comment above the line says "Restart
container if image changed", but nothing checked.

It also restarted servers that had been stopped deliberately, which would have
undone bedtime within the hour.

scripts/auto-update.sh now pulls, compares the image id before and after, and
restarts only when it actually changed. A server that is not running is left
alone, because it was stopped for a reason and starting it again behind the
owner's back is worse than being a version behind. It also skips the pull in
that case, so a stopped server costs nothing hourly.

Scheduled commands were never executed

The Scheduler page writes to config/command-schedule.json, and
scripts/command-scheduler.py has a `run` subcommand that executes whatever is
due. Nothing invoked it: no timer, no cron entry, no loop in the API. Every
schedule created through the web UI was stored and silently ignored, and the
page gave no sign of it.

systemd/minecraft-scheduler.{service,timer} runs it once a minute, which is the
finest granularity the schedule format expresses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
W5 from the family server roadmap.

A shutdown that arrives without warning starts an argument. Bedtime gives a
countdown instead: a bossbar that empties, titles at the configured marks, and a
goodnight message. The end of the evening becomes something the server announced
rather than something a parent did.

A window, not a moment

Stopping the server is not enough on its own. A restart policy, an update timer
or somebody pressing start on the dashboard all reopen the evening. So bedtime
defines a closed window: between bedtime and the wake time, anyone who joins is
sent straight back out with a message saying when the server opens again. That
holds regardless of how the server came back.

The window spans midnight, with anything before the wake time belonging to the
previous evening, so 01:00 on Saturday is still Friday night. Weeknight and
weekend bedtimes are chosen by the evening rather than the day, because it is
the night before a non-school morning that matters.

Controls

Extend by a configured amount, skip tonight, or start bedtime now. Extending
re-arms the warnings, so the countdown is announced again against the new
deadline rather than going quiet. All three refuse once bedtime has happened and
say why; a refusal is 409, because the request was well-formed and the server
simply will not do it.

Notes on two decisions

Bedtime defaults to disabled. Something that can stop the server and turn people
away should never switch itself on because a default said so.

The join kick is queued rather than sent in the event handler. Bus handlers run
on the thread that follows the server log, and sending a command makes a network
call; this is the same trap that the Hall of Deaths review caught, avoided here
rather than repeated.

A bug the tests caught

Warnings fired the largest unfired mark rather than the one describing the time
actually left, so a server started with ten minutes to go greeted everyone with
"30 minutes until bedtime". Every mark at or above the remaining time is now
treated as given, and the smallest is the one announced.

Elsewhere

- New page at /bedtime with the countdown, the controls and the schedule.
- GET /api/bedtime (server.view, so a read-only account can watch the
  countdown), and POST /api/bedtime/extend, /skip and /now (server.control).
- config/bedtime.conf.example, and docs/BEDTIME.md linked from the docs index.

All scheduling takes the current time as an argument, so a whole evening is
tested without waiting for one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 19, 2026 14:36
@github-actions github-actions Bot added documentation Documentation additions or updates tests labels Sep 19, 2026
@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

Comment thread api/bedtime.py Fixed
Comment thread api/bedtime.py Fixed
The tests used bats-assert's `--partial` flag and `refute_line`, neither of
which exists in this repo's vendored helper. tests/helpers/bats-assert is a
minimal stub: assert_output matches exactly, assert_line greps, and there are no
refute helpers, so every partial match was being compared against the literal
string "--partial".

The script logic was correct throughout; only the assertions were wrong. They
now use assert_line for substring matches, and negative checks are written as an
explicit grep whose failure is asserted, wrapped in two named helpers so the
intent stays readable.

The stub docker also needed to answer `docker compose version`, which the
compose() wrapper in scripts/lib/common.sh probes before running anything.

Verified locally with bats this time rather than on CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@and3rn3t

Copy link
Copy Markdown
Owner Author

CI is green. Two notes on what happened on the way there.

The bash test failure was mine, and the fix is worth knowing about. I wrote the tests using bats-assert's --partial flag and refute_line, neither of which exists in this repo. tests/helpers/bats-assert/load.bash is a minimal stub: assert_output matches exactly, assert_line greps, and there are no refute helpers. So every partial match was being compared against the literal string --partial. The script logic was correct throughout. Rewritten to use assert_line, with negative checks as an explicit grep whose failure is asserted. I ran bats locally this time rather than pushing and waiting.

The Python failure on the first run was a pre-existing flaky test, not a regression. test_status_endpoint_performance asserts the status endpoint answers in under 300ms and measured 483ms under four-way parallel execution on the runner. I checked rather than assumed:

Branch /api/status latency
main 115.4ms min, 115.9ms avg
this branch 114.3ms min, 115.5ms avg

No difference. The endpoint spends its time in a subprocess call to manage.sh status, so it is at the mercy of whatever else the runner is doing. The test's own comment says the threshold was already raised once "to account for CI environment variability", which is the shape of a wall-clock assertion that will keep doing this.

It passed on the re-run and I have left it alone, because raising the threshold again is whack-a-mole. The structural fix is that test_performance.py is not marked performance and so runs inside the default parallel suite, even though the Makefile already has a separate test-api-performance target for running it deliberately. Happy to do that in its own PR if you want it.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

There are a few correctness issues in the new code paths (notably concurrency/idempotence around bedtime enforcement and some UI request/handling details) that should be addressed before merging.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 3 Medium severity · 1 Low severity

Open (4)
What changed in this PR

Adds “Bedtime mode” to enforce a warned, configurable evening shutdown window (with dashboard controls + REST/OpenAPI), and fixes two operational gaps: the hourly update timer restarting unconditionally, and the command scheduler never being invoked.

Changes:

  • Implement Bedtime mode (API logic + endpoints + OpenAPI + dashboard page + docs/config) with a closed window that kicks late joiners until wake time.
  • Fix hourly updates to restart only when the image actually changes, and keep deliberately-stopped servers stopped.
  • Add a systemd timer/service to run scheduled commands once per minute (so schedules created in the UI actually execute).
File Description
web/​src/​services/​api.js Adds client methods for bedtime status + controls.
web/​src/​pages/​Bedtime.jsx New dashboard page for bedtime countdown, status, and controls.
web/​src/​pages/​__tests__/​Bedtime.test.jsx Vitest coverage for Bedtime page behavior and control flows.
web/​src/​components/​Layout.jsx Adds Bedtime to the navigation.
web/​src/​App.jsx Adds a lazy-loaded /bedtime protected route.
tests/​unit/​test-auto-update.sh BATS unit tests for the new auto-update script behavior.
tests/​api/​test_bedtime.py Pytest suite covering bedtime scheduling, window enforcement, controls, and endpoints.
systemd/​minecraft-update.service Switches hourly update unit to scripts/auto-update.sh run behavior.
systemd/​minecraft-scheduler.timer New timer to run scheduled commands every minute.
systemd/​minecraft-scheduler.service New oneshot service invoking scripts/command-scheduler.py run.
scripts/​auto-update.sh New script to pull/restart only on real image changes.
docs/​INDEX.md Links the new Bedtime documentation page.
docs/​BEDTIME.md Documents bedtime behavior, configuration, API, and operational notes.
config/​bedtime.conf.example Example config for enabling and tuning bedtime mode.
CHANGELOG.md Documents bedtime feature + scheduler timer + update-timer fix.
api/​server.py Wires bedtime into the Flask API and event bus; adds endpoints and runner/stopper plumbing.
api/​openapi.yaml Adds Bedtime schemas and endpoints to the OpenAPI spec.
api/​bedtime.py New core Bedtime implementation: scheduling, countdown/bossbar, enforcement, and queued join kicks.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread api/bedtime.py
Comment thread web/src/pages/Bedtime.jsx Outdated
Comment thread web/src/services/api.js
Comment thread web/src/pages/Bedtime.jsx Outdated
All four Copilot findings and both CodeQL alerts. Each was checked against the
code; all six were real.

Enforcement could close the evening twice

_enforce() set the enforced flag without first checking it. tick() runs on the
bedtime thread and start_now() runs on a request thread, and both check the flag
before calling, so the check and the set sat in separate lock acquisitions. Two
callers arriving together could both pass their check and both act: two
goodnights, two kicks, two attempts to stop the server.

_enforce() now does an atomic check-and-set under the lock and returns early if
the evening is already closed.

Worth noting about the tests: my first attempt spawned threads at a barrier and
passed with the guard removed, so it proved nothing. The overlap is too narrow
to reproduce reliably under the GIL. Calling _enforce() twice is the same
condition without the timing, and that test does fail without the guard. The
thread test is kept as a stress check and labelled as one.

A server error was shown as a friendly refusal

The dashboard treated any response carrying an `error` field as a refusal and
showed it as a green notice. A 500 or 503 also carries that field, so a genuine
server failure was dressed up as a normal "no extensions left" message, and a
previous error could stay on screen underneath. Only 409 is a refusal now;
everything else is an error, and the two clear each other.

The page loaded twice on mount

usePolling already calls its fetch function on mount, so the separate useEffect
fired a second identical request every time the page opened.

Bedtime controls left the cache stale

Every other state-changing method in web/src/services/api.js calls
invalidateCache(). The three bedtime POSTs did not, which matters most for
"bedtime now", since it can stop the server while a cached /status still says it
is running.

CodeQL: both empty except clauses in the config parser now state why a bad value
is ignored rather than raised.

The three dashboard fixes have tests confirmed to fail without them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@and3rn3t

Copy link
Copy Markdown
Owner Author

All four Copilot findings and both CodeQL alerts are fixed in aadb027. Each was checked against the code first; all six were real.

The one that mattered is the enforcement race. tick() runs on the bedtime thread and start_now() on a request thread, and both checked enforced before calling _enforce(), which then set it. Check and set in separate lock acquisitions, so two callers arriving together could both act: two goodnights, two kicks, two attempts to stop the server. Now an atomic check-and-set.

My first test for it was worthless and I want to flag that rather than bury it. I spawned eight threads at a barrier, it passed, and it also passed with the guard removed. The overlap is too narrow to hit reliably under the GIL. Calling _enforce() twice is the same condition without the timing, and that test does fail without the guard. The thread test is kept as a stress check and its docstring now says so instead of implying it covers the race.

The refusal misclassification was also worse than it looks: the 500 and 503 paths on these endpoints return {"error": ...} too, so a genuine server failure rendered as a green refusal notice. Only 409 is a refusal now.

The other three: the page fired two identical requests on mount because usePolling already fetches there; the bedtime POSTs skipped invalidateCache(), which matters because /status is cached for two seconds and bedtime now can stop the server; and both empty except clauses in the config parser now say why a bad value is ignored.

All three dashboard fixes have tests confirmed to fail without them.

Check Before After
API tests 571 577
Web tests 184 187

make lint passes, Black and flake8 are clean, and the API suite passes under four-way parallel execution locally.

🤖 Generated with Claude Code

@and3rn3t
and3rn3t merged commit 5528fc9 into main Sep 19, 2026
17 checks passed
@and3rn3t
and3rn3t deleted the feature/bedtime-mode branch September 19, 2026 14:47
@and3rn3t and3rn3t added the feature New feature request label Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Documentation additions or updates feature New feature request tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants