slopfixing queue stuffs

This commit is contained in:
legop3
2026-07-21 23:38:57 -04:00
parent b26daed93f
commit 3a26b4871a
10 changed files with 218 additions and 30 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -78,7 +78,7 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BWsjlMbl.js"></script>
<script type="module" crossorigin src="/assets/index-DynS6sFA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-L6xBNcuq.css">
</head>
<body>
+67 -5
View File
@@ -112,6 +112,7 @@ function driverAdded(roverId, socketId, options = {}) {
const { force, pauseQueue } = normalizeDriverAddOptions(options);
const queue = ensureQueue(roverId);
const alreadyQueued = queue.queue.includes(socketId);
const previousQueueLength = queue.queue.length;
if (pauseQueue) {
applyAdminQueuePause(roverId, socketId, queue);
@@ -141,6 +142,30 @@ function driverAdded(roverId, socketId, options = {}) {
if (!queue.current || force) {
queue.current = socketId;
}
/*
A normal join changes who will receive a future turn; it does not begin a
new turn for the person who is already driving. Preserve both deadlines and
the current driver's activity state while an already-rotating queue grows.
The one-to-two transition is intentionally different. Before the second
driver arrives there is no reason to time the sole driver, so this is the
moment the first real rotating turn begins. A forced grant also changes the
active driver immediately and therefore starts that driver's fresh turn.
*/
const turnsAreRotating = getMode() === MODES.TURNS && queue.queue.length > 1;
const beginsRotatingTurns = turnsAreRotating && previousQueueLength <= 1;
if (turnsAreRotating && (force || beginsRotatingTurns)) {
startCurrentTurn(roverId, queue);
return;
}
if (turnsAreRotating) {
setActiveDriver(roverId, queue.current);
turnEvents.emit('queue', { roverId, reason: 'driver-joined' });
return;
}
syncState(roverId);
}
@@ -165,13 +190,32 @@ function driverRemoved(roverId, socketId) {
idleSkips.delete(roverId);
}
}
idleDisarmed.delete(roverId);
if (queue.current === socketId) {
/*
Removing the active driver is a real handoff. advanceTurn() owns clearing
the old turn state and creating the next driver's deadlines, keeping this
path identical for explicit release, disconnect, and stale-socket reap.
*/
stopRover(roverId);
advanceTurn(roverId);
} else {
scheduleIdleTimer(roverId);
return;
}
/*
A waiting driver leaving must not re-arm idle detection or extend the
unrelated active driver's turn. If this removal ends rotation entirely,
syncState() clears both deadlines immediately; otherwise only the public
queue membership changes.
*/
if (getMode() !== MODES.TURNS || queue.queue.length <= 1) {
syncState(roverId);
return;
}
if (isQueuePaused(queue)) {
turnEvents.emit('queue', { roverId, reason: 'driver-left-admin-pause' });
return;
}
turnEvents.emit('queue', { roverId, reason: 'driver-left' });
}
function cleanupRover(roverId) {
@@ -242,11 +286,24 @@ function syncState(roverId) {
if (!queue.current) {
queue.current = queue.queue[0];
}
/*
syncState() is used when entering turns mode and when a previously untimed
queue becomes rotatable. Both cases begin a genuine turn rather than merely
publishing a membership update.
*/
startCurrentTurn(roverId, queue);
}
function startCurrentTurn(roverId, queue) {
/*
This is the sole normal entry point for a fresh timed turn. Keeping active
ownership, the full-turn deadline, and the activity grace window together
prevents callers from resetting only part of the lifecycle.
*/
setActiveDriver(roverId, queue.current);
idleDisarmed.set(roverId, false);
scheduleNextTurn(roverId);
scheduleIdleTimer(roverId);
turnEvents.emit('queue', { roverId });
}
function scheduleNextTurn(roverId) {
@@ -423,7 +480,12 @@ function reapStaleDrivers() {
});
}
setInterval(reapStaleDrivers, STALE_REAPER_MS);
/*
Stale cleanup should run for the lifetime of the server, but it should not by
itself keep short-lived tools or the Node test runner alive after their work
has completed.
*/
setInterval(reapStaleDrivers, STALE_REAPER_MS).unref();
module.exports = {
driverAdded,
@@ -0,0 +1,96 @@
// Turn Service Tests
// Purpose: Protects timed-turn state from unrelated queue membership changes.
// Scope: Exercises public turn operations and inspects only their published queue model; no HTTP server is started.
const assert = require('node:assert/strict');
const test = require('node:test');
const { MODES, setMode } = require('../modeManager');
const turnService = require('./index');
function enterTurnsMode() {
/*
Mode authorization is irrelevant to this state-machine test. The forced
transition uses the same production mode event, ensuring existing queues
are reconciled exactly as they would be after an administrator mode change.
*/
setMode(MODES.TURNS, null, { force: true });
}
function resetRover(roverId) {
turnService.cleanupRover(roverId);
}
test('joining an active rotation preserves the current turn and completed activity grace', () => {
const roverId = 'turn-test-join';
enterTurnsMode();
turnService.driverAdded(roverId, 'driver-a');
turnService.driverAdded(roverId, 'driver-b');
const started = turnService.getTurnQueues()[roverId];
assert.ok(started.deadline);
assert.ok(started.idleDeadline);
turnService.recordActivity(roverId, 'driver-a');
const active = turnService.getTurnQueues()[roverId];
assert.equal(active.idleDeadline, null);
turnService.driverAdded(roverId, 'driver-c');
const joined = turnService.getTurnQueues()[roverId];
assert.equal(joined.current, 'driver-a');
assert.equal(joined.deadline, active.deadline);
assert.equal(joined.idleDeadline, null);
resetRover(roverId);
});
test('a waiting driver leaving preserves both active deadlines', () => {
const roverId = 'turn-test-waiting-leave';
enterTurnsMode();
turnService.driverAdded(roverId, 'driver-a');
turnService.driverAdded(roverId, 'driver-b');
turnService.driverAdded(roverId, 'driver-c');
const before = turnService.getTurnQueues()[roverId];
turnService.driverRemoved(roverId, 'driver-c');
const after = turnService.getTurnQueues()[roverId];
assert.equal(after.current, 'driver-a');
assert.equal(after.deadline, before.deadline);
assert.equal(after.idleDeadline, before.idleDeadline);
resetRover(roverId);
});
test('dropping to one driver clears turn and idle deadlines immediately', () => {
const roverId = 'turn-test-single-driver';
enterTurnsMode();
turnService.driverAdded(roverId, 'driver-a');
turnService.driverAdded(roverId, 'driver-b');
assert.ok(turnService.getTurnQueues()[roverId].deadline);
turnService.driverRemoved(roverId, 'driver-b');
const single = turnService.getTurnQueues()[roverId];
assert.equal(single.current, 'driver-a');
assert.equal(single.deadline, null);
assert.equal(single.idleDeadline, null);
resetRover(roverId);
});
test('turn deadlines are absent outside turns mode during queue grants', () => {
const roverId = 'turn-test-admin-mode';
setMode(MODES.ADMIN, null, { force: true });
turnService.driverAdded(roverId, 'driver-a');
turnService.driverAdded(roverId, 'driver-b', { force: true });
const queue = turnService.getTurnQueues()[roverId];
/*
In non-turn modes syncState keeps the first regular queued driver current.
Explicit administrator takeover uses the separate pauseQueue path; this
assertion is concerned only with ensuring neither grant creates timers.
*/
assert.equal(queue.current, 'driver-a');
assert.equal(queue.deadline, null);
assert.equal(queue.idleDeadline, null);
resetRover(roverId);
});
@@ -88,6 +88,15 @@ function TurnsOverlay({
if (!isTurnsMode || !isActiveDriver) return null;
return turnSeconds != null ? `${turnSeconds}s left` : 'Your turn';
}, [isTurnsMode, isActiveDriver, turnSeconds]);
const idleTimerText = useMemo(() => {
/*
Idle grace is independent from the full turn deadline. Rendering it in
the persistent corner status means initial page loads and reconnects get
the same warning as users who happened to see the large handoff cue.
*/
if (!isTurnsMode || !isActiveDriver || idleSkipSeconds == null) return null;
return `Idle skip in ${idleSkipSeconds}s`;
}, [idleSkipSeconds, isActiveDriver, isTurnsMode]);
const notTurnCountdownText = useMemo(() => {
if (!showNotTurnNotice || !isNextDriver || turnSeconds == null) return null;
return `${turnSeconds} seconds until your turn.`;
@@ -230,13 +239,16 @@ function TurnsOverlay({
{turnTimerText ? (
<div
className={`pointer-events-none absolute bottom-1 left-1 rounded border ${
className={`pointer-events-none absolute bottom-1 left-1 flex flex-col rounded border ${
turnTimerFlashActive
? 'border-red-300/90 bg-red-900/80 text-red-100'
: 'border-amber-300/80 bg-black/75 text-amber-200'
} ${timerPadClass} ${timerTextClass}`}
>
{turnTimerText}
{idleTimerText ? (
<span className="font-semibold text-red-200">{idleTimerText}</span>
) : null}
</div>
) : null}
@@ -249,9 +249,20 @@ export default function RoverQueuesPanel({
const roverId = String(rover.id);
const info = turnQueues?.[roverId] || null;
const queue = info?.queue || [];
const deadline = info?.idleDeadline || info?.deadline || null;
const remainingSeconds =
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
/*
A turn handoff and an inactivity skip are separate server
deadlines. Keep them separate through formatting so an idle
warning cannot masquerade as the length of the whole turn or
jump from seven seconds back to the turn countdown.
*/
const turnDeadline = info?.deadline || null;
const idleDeadline = info?.idleDeadline || null;
const turnRemainingSeconds = turnDeadline
? Math.max(0, Math.ceil((turnDeadline - now) / 1000))
: null;
const idleRemainingSeconds = idleDeadline
? Math.max(0, Math.ceil((idleDeadline - now) / 1000))
: null;
const currentId = info?.current || null;
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
const nextId =
@@ -262,7 +273,14 @@ export default function RoverQueuesPanel({
: null;
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
const timerLabel = isSelfCurrent
? [
turnRemainingSeconds != null ? `${turnRemainingSeconds}s left` : '',
idleRemainingSeconds != null ? `Idle skip in ${idleRemainingSeconds}s` : '',
].filter(Boolean).join(' · ')
: isSelfNext && turnRemainingSeconds != null
? `Your turn in ${turnRemainingSeconds}s`
: '';
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
const locked = Boolean(rover.locked);
@@ -293,7 +311,7 @@ export default function RoverQueuesPanel({
buttonLabel={buttonLabel}
batteryLabel={formatBattery(rover)}
batteryClassName={batteryClass(rover)}
timerLabel={showTimer ? (isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`) : ''}
timerLabel={timerLabel}
thumbnailUrl={externalMode ? rover?.snapshots?.latestUrl : ''}
onRequest={handleRequest}
showAction={Boolean(canRequest)}