mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
slopfixing queue stuffs
This commit is contained in:
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
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
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user