fix: prevent duplicate channels from concurrent add/join requests

Two near-simultaneous POSTs to /api/channels/join (observed 7 ms apart
in demo-server logs) each found a different free slot and both
succeeded, producing two entries for the same channel name on the
device. This also shifted the sidebar so each channel rendered the
next one's messages.

- Wrap free-slot detection + set_channel in a module-level lock so
  concurrent requests serialize instead of racing.
- Idempotency: if a channel with this name already exists, return the
  existing slot with already_existed=true instead of creating a
  duplicate. Applies to both POST /api/channels and /api/channels/join
  (skipped when caller targets an explicit index).
- Disable submit buttons on create/join forms while a request is in
  flight, and guard against double-registration of the channel-link
  click delegate to stop a single click from firing N POSTs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-04-22 20:45:39 +02:00
parent 66c378c17d
commit cbcdbdcae9
3 changed files with 155 additions and 77 deletions
+20 -2
View File
@@ -761,8 +761,12 @@ function setupEventListeners() {
document.getElementById('createChannelForm').addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
if (submitBtn && submitBtn.disabled) return; // in-flight guard
const name = document.getElementById('newChannelName').value.trim();
if (submitBtn) submitBtn.disabled = true;
try {
const response = await fetch('/api/channels', {
method: 'POST',
@@ -775,7 +779,10 @@ function setupEventListeners() {
const data = await response.json();
if (data.success) {
showNotification(`Channel "${name}" created!`, 'success');
const msg = data.already_existed
? `Channel "${name}" already exists.`
: `Channel "${name}" created!`;
showNotification(msg, data.already_existed ? 'info' : 'success');
// Show warning if returned (e.g., exceeding soft limit of 7 channels)
if (data.warning) {
@@ -795,6 +802,8 @@ function setupEventListeners() {
}
} catch (error) {
showNotification('Failed to create channel', 'danger');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
});
@@ -802,6 +811,9 @@ function setupEventListeners() {
document.getElementById('joinChannelFormSubmit').addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
if (submitBtn && submitBtn.disabled) return; // in-flight guard
const name = document.getElementById('joinChannelName').value.trim();
const key = document.getElementById('joinChannelKey').value.trim().toLowerCase();
@@ -817,6 +829,7 @@ function setupEventListeners() {
return;
}
if (submitBtn) submitBtn.disabled = true;
try {
const payload = { name: name };
if (key) {
@@ -834,7 +847,10 @@ function setupEventListeners() {
const data = await response.json();
if (data.success) {
showNotification(`Joined channel "${name}"!`, 'success');
const msg = data.already_existed
? `Already joined channel "${name}".`
: `Joined channel "${name}"!`;
showNotification(msg, data.already_existed ? 'info' : 'success');
// Show warning if returned (e.g., exceeding soft limit of 7 channels)
if (data.warning) {
@@ -855,6 +871,8 @@ function setupEventListeners() {
}
} catch (error) {
showNotification('Failed to join channel', 'danger');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
});
+8
View File
@@ -360,10 +360,18 @@ async function joinAndSwitchToChannel(channelName) {
/**
* Initialize channel link click handlers using event delegation
*/
let _channelLinkHandlersInitialized = false;
function initializeChannelLinkHandlers() {
// Guard against double registration - otherwise one click fires N handlers
// and sends N duplicate POSTs to /api/channels/join.
if (_channelLinkHandlersInitialized) return;
_channelLinkHandlersInitialized = true;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('channel-link')) {
e.preventDefault();
// Swallow clicks while this link is already handling a request.
if (e.target.classList.contains('loading')) return;
const channelName = e.target.getAttribute('data-channel-name');
if (channelName) {