From f615e4d23683bd2747881112daa5eacd71279a5c Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:24:03 +0200 Subject: [PATCH] fix(release): close remaining PR review gaps --- extension/background.js | 6 +++- extension/target-tab-lifecycle.test.mjs | 8 ++++- scripts/prepare-release.mjs | 44 ++++++++++++++++--------- scripts/prepare-release.test.mjs | 12 +++++++ scripts/test-server-ws.mjs | 13 +++++--- server/index.js | 12 ++++--- 6 files changed, 69 insertions(+), 26 deletions(-) diff --git a/extension/background.js b/extension/background.js index 4ac260a..a62a7c7 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2172,6 +2172,9 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con break; } case EVENTS.CONTROL_MODE: + // Terminal room teardown is authoritative. Older/custom relays may + // still emit a trailing role update after ROOM_CLOSED. + if (!currentRoom) break; // Host Control Mode changed (toggle or host-leave fallback). controlMode = data.controlMode || CONTROL_MODES.EVERYONE; hostPeerId = data.hostPeerId || null; @@ -2239,7 +2242,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con await chatReceiveQueue; break; } - case EVENTS.ERROR: + case EVENTS.ERROR: { isConnecting = false; const terminalRoomError = data.code === ERROR_CODES.ROOM_CLOSED || data.code === ERROR_CODES.PEER_TIMED_OUT @@ -2274,6 +2277,7 @@ async function handleServerEvent(event, data, expectedConnectionGeneration = con const errStatusMsg = { type: 'JOIN_STATUS', success: false, message: data.message }; await broadcastJoinStatus(errStatusMsg); break; + } case EVENTS.PLAY: case EVENTS.PAUSE: case EVENTS.SEEK: diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index 9ff87a0..79918fd 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -116,11 +116,17 @@ describe('target tab lifecycle', () => { expect(backgroundSource).toContain("data.message === 'Removed from room after inactivity'"); expect(backgroundSource).toContain('await endRoomSession({ reason: `Room session ended: ${data.message}` });'); + const controlModeStart = backgroundSource.indexOf('case EVENTS.CONTROL_MODE:'); + const controlModeEnd = backgroundSource.indexOf('case EVENTS.ROOM_LIST:', controlModeStart); + expect(backgroundSource.slice(controlModeStart, controlModeEnd)).toContain('if (!currentRoom) break;'); + expect(sharedConstantsSource).toContain("ROOM_CLOSED: 'room_closed'"); expect(sharedConstantsSource).toContain("PEER_TIMED_OUT: 'peer_timed_out'"); expect(serverSource).toContain('code: ERROR_CODES.ROOM_CLOSED'); expect(serverSource).toContain('code: ERROR_CODES.PEER_TIMED_OUT'); - expect(serverSource).toContain("removePeerFromRoom(sid, roomId, 'room-timeout')"); + expect(serverSource).toContain( + "removePeerFromRoom(sid, roomId, 'room-timeout', { notifyRemainingPeers: false })" + ); }); it('does not promote a nested media target without confirmed parent visibility', () => { diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index 1f558e3..2928676 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -13,67 +13,81 @@ export function replaceExactly(text, pattern, replacement, label) { return text.replace(pattern, replacement); } -function writeJson(root, relativePath, update) { +function stageJson(stagedUpdates, root, relativePath, update) { const absolutePath = path.join(root, relativePath); - const value = JSON.parse(fs.readFileSync(absolutePath, 'utf8')); + const current = stagedUpdates.has(absolutePath) + ? stagedUpdates.get(absolutePath) + : fs.readFileSync(absolutePath, 'utf8'); + const value = JSON.parse(current); update(value); - fs.writeFileSync(absolutePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + stagedUpdates.set(absolutePath, `${JSON.stringify(value, null, 2)}\n`); } -function updateText(root, relativePath, pattern, replacement, label) { +function stageText(stagedUpdates, root, relativePath, pattern, replacement, label) { const absolutePath = path.join(root, relativePath); - const current = fs.readFileSync(absolutePath, 'utf8'); - fs.writeFileSync(absolutePath, replaceExactly(current, pattern, replacement, label), 'utf8'); + const current = stagedUpdates.has(absolutePath) + ? stagedUpdates.get(absolutePath) + : fs.readFileSync(absolutePath, 'utf8'); + stagedUpdates.set(absolutePath, replaceExactly(current, pattern, replacement, label)); } export function prepareRelease(version, date = new Date(), root = repoRoot) { versionFromTag(`v${version}`); const timestamp = date.toISOString().replace(/\.\d{3}Z$/u, 'Z'); - writeJson(root, 'package.json', value => { value.version = version; }); - writeJson(root, 'package-lock.json', value => { + const stagedUpdates = new Map(); + stageJson(stagedUpdates, root, 'package.json', value => { value.version = version; }); + stageJson(stagedUpdates, root, 'package-lock.json', value => { value.version = version; value.packages[''].version = version; }); - writeJson(root, 'extension/manifest.base.json', value => { value.version = version; }); - writeJson(root, 'website/version.json', value => { + stageJson(stagedUpdates, root, 'extension/manifest.base.json', value => { value.version = version; }); + stageJson(stagedUpdates, root, 'website/version.json', value => { value.version = version; value.date = timestamp; }); - updateText( + stageText( + stagedUpdates, root, 'shared/constants.js', /export const APP_VERSION = ["'][^"']+["'];/gu, `export const APP_VERSION = "${version}";`, 'shared/constants.js' ); - updateText( + stageText( + stagedUpdates, root, 'website/template.html', /"softwareVersion": "[^"]+"/gu, `"softwareVersion": "${version}"`, 'website/template.html' ); - updateText( + stageText( + stagedUpdates, root, 'website/llms.txt', /Current website release: .+/gu, `Current website release: ${version}`, 'website/llms.txt' ); - updateText( + stageText( + stagedUpdates, root, 'README.md', /Release-v\d+\.\d+\.\d+-blue/gu, `Release-v${version}-blue`, 'README.md release badge' ); - updateText( + stageText( + stagedUpdates, root, 'README.md', /New v\d+\.\d+\.\d+ Release!/gu, `New v${version} Release!`, 'README.md release banner' ); + for (const [absolutePath, content] of stagedUpdates) { + fs.writeFileSync(absolutePath, content, 'utf8'); + } console.log(`Prepared release v${version} at ${timestamp}`); } diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs index a6d649b..e5413d8 100644 --- a/scripts/prepare-release.test.mjs +++ b/scripts/prepare-release.test.mjs @@ -87,4 +87,16 @@ describe('release preparation helpers', () => { .toThrow('vMAJOR.MINOR.PATCH'); expect(readFixture(root)).toEqual(before); }); + + it('does not partially update release sources when a later marker is invalid', () => { + const root = createReleaseFixture(); + const llmsPath = path.join(root, 'website/llms.txt'); + fs.writeFileSync(llmsPath, fs.readFileSync(llmsPath, 'utf8') + .replace(/Current website release: .+/u, 'Release marker intentionally missing'), 'utf8'); + const before = readFixture(root); + + expect(() => prepareRelease('9.8.7', new Date('2030-01-01T00:00:00Z'), root)) + .toThrow('website/llms.txt must contain exactly one release-version marker'); + expect(readFixture(root)).toEqual(before); + }); }); diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index b9a6075..bdf2d0f 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -575,17 +575,22 @@ try { assert.equal(mod.rooms.has(msgateRid), false, 'empty-room cleanup removes canonical state with the room'); // --- Terminal room timeout: coded error + complete membership cleanup --- const timeoutClient = await c(); + const timeoutPeer = await c(); const timeoutRoomId = 'timeout-'+Date.now(); await j(timeoutClient, timeoutRoomId, 'timeout-peer'); - timeoutClient._m.length = 0; + await j(timeoutPeer, timeoutRoomId, 'timeout-peer-2'); + timeoutClient._m.length = timeoutPeer._m.length = 0; mod.rooms.get(timeoutRoomId).lastActivity = 0; mod.cleanupInactiveRooms(Date.now()); - const [timeoutEvent, timeoutData] = await a(timeoutClient); - assert.equal(timeoutEvent, 'error'); + const timeoutData = await w(timeoutClient, 'error'); + const timeoutPeerData = await w(timeoutPeer, 'error'); assert.equal(timeoutData.code, 'room_closed'); assert.equal(timeoutData.message, 'Room closed'); + assert.equal(timeoutPeerData.code, 'room_closed'); + await delay(80); + assert.deepEqual(timeoutClient._m, [], 'terminal room cleanup emits nothing after room_closed'); + assert.deepEqual(timeoutPeer._m, [], 'terminal room cleanup emits nothing after room_closed'); assert.equal(mod.rooms.has(timeoutRoomId), false, 'inactive room is deleted'); - timeoutClient._m.length = 0; // The same connected socket must be able to join that room again. This // proves timeout cleanup removed its stale socketToRoom membership. diff --git a/server/index.js b/server/index.js index 3de56d7..561dc61 100644 --- a/server/index.js +++ b/server/index.js @@ -250,8 +250,10 @@ function log(type, message, details = '') { * @param {string} socketId - The socket.id being removed. * @param {string} roomId - The room it belongs to. * @param {string} reason - Log label ('disconnect', 'leave', 'reaper', 'dedupe', 'room-switch'). + * @param {object} options + * @param {boolean} options.notifyRemainingPeers - Whether to emit room-state updates after removal. */ -function removePeerFromRoom(socketId, roomId, reason) { +function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = true } = {}) { const room = rooms.get(roomId); if (!room) return; @@ -275,7 +277,7 @@ function removePeerFromRoom(socketId, roomId, reason) { // 3. Notify remaining peers (use io.to so the removed socket itself // doesn't receive it — it has already left or is disconnecting) const isPeerStillConnected = Array.from(room.peerData.values()).some(data => data.peerId === peerId); - if (!isPeerStillConnected) { + if (notifyRemainingPeers && !isPeerStillConnected) { io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' }); } @@ -325,11 +327,11 @@ function removePeerFromRoom(socketId, roomId, reason) { room.hostPeerId = nextPeerData ? nextPeerData.peerId : null; room.controlMode = CONTROL_MODES.EVERYONE; room.controllers = new Set(room.hostPeerId ? [room.hostPeerId] : []); - io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); + if (notifyRemainingPeers) io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); log('ROOM', `Owner left room ${roomId.substring(0, 3)}*** — fell back to 'everyone', new owner: ${room.hostPeerId}`); } else if (wasController) { // A co-host left → keep the mode, just broadcast the updated controller list. - io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); + if (notifyRemainingPeers) io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); log('ROOM', `Controller ${peerId} left room ${roomId.substring(0, 3)}***`); } } @@ -1111,7 +1113,7 @@ export function cleanupInactiveRooms(now = Date.now()) { for (const sid of Array.from(currentRoom.peers)) { const memberSocket = io.sockets?.sockets?.get(sid); if (memberSocket) memberSocket.leave(roomId); - removePeerFromRoom(sid, roomId, 'room-timeout'); + removePeerFromRoom(sid, roomId, 'room-timeout', { notifyRemainingPeers: false }); } rooms.delete(roomId); log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);