This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 7df91fde3 fix(studio): preserve field boundaries and pull ownership 
(#6064)
7df91fde3 is described below

commit 7df91fde36241973ee604b763e5edfae5f26c40f
Author: coder999o <[email protected]>
AuthorDate: Mon Oct 12 16:22:27 2026 +0800

    fix(studio): preserve field boundaries and pull ownership (#6064)
    
    Three fixes where a delimiter or an in-flight request let one value stand 
in for another: separators written verbatim into a message's wire properties, a 
`|` used to delimit a connection identity when `|` is a legal topic character, 
and a dismissed queue card whose ownership token outlived the dismissal.
    
    - #6064 RocketMQ's property codec does not escape its separators — 
`MessageDecoder.messageProperties2String` concatenates `name\u0001value\u0002` 
and `string2messageProperties` splits pairs on `indexOf(PROPERTY_SEPARATOR)` 
and then on the **first** `NAME_VALUE_SEPARATOR` inside a pair — while 
`RocketMQAdminClientImpl.sendOwnedMessage` wrote the tag, key and every user 
property straight onto the wire at `:526`, `:529` and `:542`, so a `\u0002` in 
a name, tag, key or value truncated tha [...]
    - #6207 `closeEntry` at `QueueBrowser.tsx:145-147` only filtered `entries` 
and left `pullingRef` alone, so a same-key pull still in flight passed the 
`requestId === requestSeqRef.current` ownership check at `:128` and re-inserted 
the card the operator had just dismissed (the `catch` branch at `:134` 
re-raised a toast for the same abandoned pull), while `pullingRef.has(key)` at 
`:116` kept that queue locked until the stale request settled → the in-flight 
registry becomes a `Map<string, [...]
    - #6303 `clientConnectionDiagnostics.ts:156-162` built the 
duplicate-detection identity with `[type, clientId, groupOrTopic, 
address].join('|')` and `:267` decoded it with `split('|')`, but `|` is a legal 
topic and group character — the kernel's `TopicValidator` sets 
`VALID_CHAR_BIT_MAP['|'] = true` — and a client `instanceName` is arbitrary, so 
two distinct quadruples could collapse into one `identityCounts` key and raise 
a false `EXACT_DUPLICATE_CONNECTION`, while the `clientId`, `r [...]
    
    Tests: `RocketMQAdminClientImplTest` adds four `@Test` methods plus a 
`@ParameterizedTest(ints = {1, 2})` — six executions covering an illegal 
property name for each separator, an illegal value, tag and key, a 
`verifyNoInteractions(runtimeAdminClientResolver, clientPool, sendProducer)` 
proof that the rejection happens before any client is resolved, an audit 
verification, and a round-trip case that re-encodes and re-decodes the captured 
properties through the real `MessageDecoder` so o [...]
    
    Folded: #6207, #6303 were closed as folded into this commit (same author).
---
 .../provider/apache/RocketMQAdminClientImpl.java   |  26 ++++
 .../apache/RocketMQAdminClientImplTest.java        |  90 +++++++++++++-
 web/src/components/QueueBrowser.tsx                |  19 +--
 web/src/components/__tests__/QueueBrowser.test.tsx | 134 ++++++++++++++++++++-
 web/src/utils/clientConnectionDiagnostics.test.ts  |  26 ++++
 web/src/utils/clientConnectionDiagnostics.ts       |   6 +-
 6 files changed, 286 insertions(+), 15 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
index a72f219c1..c5ab62e94 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
@@ -27,6 +27,7 @@ import org.apache.rocketmq.common.TopicConfig;
 import org.apache.rocketmq.common.TopicAttributes;
 import org.apache.rocketmq.common.message.Message;
 import org.apache.rocketmq.common.message.MessageConst;
+import org.apache.rocketmq.common.message.MessageDecoder;
 import org.apache.rocketmq.common.message.MessageQueue;
 import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
 import org.apache.rocketmq.remoting.protocol.ResponseCode;
@@ -498,6 +499,7 @@ public class RocketMQAdminClientImpl implements AdminClient 
{
         }
         ResourceOwnershipGuard.requireText(request.getInstanceId(), 
"instanceId");
         validateMessageSize(request);
+        validateMessageProperties(request);
         InstanceVO instance = 
ownershipGuard.requireInstance(request.getInstanceId());
         request.setInstanceId(instance.getName());
         
request.setTopic(ResourceOwnershipGuard.requireText(request.getTopic(), 
"topicName"));
@@ -590,6 +592,30 @@ public class RocketMQAdminClientImpl implements 
AdminClient {
         }
     }
 
+    private void validateMessageProperties(SendMessageDTO request) {
+        validatePropertyText(request, request.getTag(), "tag", false);
+        validatePropertyText(request, request.getKey(), "key", false);
+        if (request.getProperties() != null) {
+            request.getProperties().forEach((name, value) -> {
+                if (!isSystemReservedProperty(name)) {
+                    validatePropertyText(request, name, "property name", true);
+                    validatePropertyText(request, value, "property value", 
false);
+                }
+            });
+        }
+    }
+
+    private void validatePropertyText(SendMessageDTO request, String text, 
String field, boolean name) {
+        // RocketMQ does not escape its wire separators. The name/value 
separator is safe
+        // inside a value, but either separator inside a name changes the 
decoded property.
+        if (text != null && (text.indexOf(MessageDecoder.PROPERTY_SEPARATOR) 
>= 0
+                || name && text.indexOf(MessageDecoder.NAME_VALUE_SEPARATOR) 
>= 0)) {
+            String message = "Message " + field + " contains a RocketMQ 
property separator";
+            recordAudit("SEND_MESSAGE", request.getTopic(), message, "FAILED");
+            throw new BusinessException(400, message);
+        }
+    }
+
     private static boolean isSystemReservedProperty(String key) {
         return key == null
                 || MessageConst.STRING_HASH_SET.contains(key)
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
index 5b53ba63a..809a99b09 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
@@ -31,6 +31,7 @@ import org.apache.rocketmq.client.producer.SendResult;
 import org.apache.rocketmq.client.producer.SendStatus;
 import org.apache.rocketmq.common.message.Message;
 import org.apache.rocketmq.common.message.MessageConst;
+import org.apache.rocketmq.common.message.MessageDecoder;
 import org.apache.rocketmq.common.message.MessageQueue;
 import org.apache.rocketmq.remoting.exception.RemotingTimeoutException;
 import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
@@ -1730,6 +1731,93 @@ class RocketMQAdminClientImplTest {
                 eq(null), eq("Message send did not succeed: null"), 
eq("FAILED"));
     }
 
+    @ParameterizedTest
+    @ValueSource(ints = {1, 2})
+    void sendMessageRejectsPropertyNameSeparatorsTest(int separator) {
+        SendMessageDTO request = SendMessageDTO.builder()
+                .instanceId("instance-a").topic("TopicA").body("hello")
+                .properties(Map.of("customer" + (char) separator + "name", 
"alice"))
+                .build();
+
+        assertThatThrownBy(() -> adminClient.sendMessage(request))
+                .isInstanceOfSatisfying(BusinessException.class, exception -> {
+                    assertThat(exception.getCode()).isEqualTo(400);
+                    assertThat(exception).hasMessageContaining("property 
name");
+                });
+        verifyNoInteractions(runtimeAdminClientResolver, clientPool, 
sendProducer);
+        verify(auditService).record(eq("SEND_MESSAGE"), eq("MESSAGE"), 
eq("TopicA"),
+                isNull(), eq("Message property name contains a RocketMQ 
property separator"), eq("FAILED"));
+    }
+
+    @Test
+    void sendMessageRejectsPropertyValueInjectionTest() {
+        SendMessageDTO request = SendMessageDTO.builder()
+                .instanceId("instance-a").topic("TopicA").body("hello")
+                .properties(Map.of("customer", "alice" + 
MessageDecoder.PROPERTY_SEPARATOR
+                        + "extra" + MessageDecoder.NAME_VALUE_SEPARATOR + 
"unexpected"))
+                .build();
+
+        assertThatThrownBy(() -> adminClient.sendMessage(request))
+                .isInstanceOfSatisfying(BusinessException.class, exception -> {
+                    assertThat(exception.getCode()).isEqualTo(400);
+                    assertThat(exception).hasMessageContaining("property 
value");
+                });
+        verifyNoInteractions(runtimeAdminClientResolver, clientPool, 
sendProducer);
+    }
+
+    @Test
+    void sendMessageRejectsTagPropertySeparatorTest() {
+        SendMessageDTO request = SendMessageDTO.builder()
+                .instanceId("instance-a").topic("TopicA").body("hello")
+                .tag("tagA" + MessageDecoder.PROPERTY_SEPARATOR + "extra")
+                .build();
+
+        assertThatThrownBy(() -> adminClient.sendMessage(request))
+                .isInstanceOfSatisfying(BusinessException.class, exception -> {
+                    assertThat(exception.getCode()).isEqualTo(400);
+                    assertThat(exception).hasMessageContaining("tag");
+                });
+        verifyNoInteractions(runtimeAdminClientResolver, clientPool, 
sendProducer);
+    }
+
+    @Test
+    void sendMessageRejectsKeyPropertySeparatorTest() {
+        SendMessageDTO request = SendMessageDTO.builder()
+                .instanceId("instance-a").topic("TopicA").body("hello")
+                .key("keyA" + MessageDecoder.PROPERTY_SEPARATOR + "extra")
+                .build();
+
+        assertThatThrownBy(() -> adminClient.sendMessage(request))
+                .isInstanceOfSatisfying(BusinessException.class, exception -> {
+                    assertThat(exception.getCode()).isEqualTo(400);
+                    assertThat(exception).hasMessageContaining("key");
+                });
+        verifyNoInteractions(runtimeAdminClientResolver, clientPool, 
sendProducer);
+    }
+
+    @Test
+    void sendMessagePreservesEncodablePropertyValuesTest() throws Exception {
+        SendResult sendResult = new SendResult();
+        sendResult.setSendStatus(SendStatus.SEND_OK);
+        when(sendProducer.send(any(Message.class))).thenReturn(sendResult);
+        Map<String, String> userProperties = Map.of("customer", 
"\u754c,alice\nsecond=line\tend"
+                + MessageDecoder.NAME_VALUE_SEPARATOR + "suffix");
+        SendMessageDTO request = SendMessageDTO.builder()
+                .instanceId("instance-a").topic("TopicA").body("hello")
+                .tag("tagA").key("keyA").properties(userProperties)
+                .build();
+
+        adminClient.sendMessage(request);
+
+        ArgumentCaptor<Message> captor = 
ArgumentCaptor.forClass(Message.class);
+        verify(sendProducer).send(captor.capture());
+        Map<String, String> decoded = MessageDecoder.string2messageProperties(
+                
MessageDecoder.messageProperties2String(captor.getValue().getProperties()));
+        assertThat(decoded).containsAllEntriesOf(userProperties)
+                .containsEntry(MessageConst.PROPERTY_TAGS, "tagA")
+                .containsEntry(MessageConst.PROPERTY_KEYS, "keyA");
+    }
+
     @Test
     void sendMessageFiltersSystemReservedPropertiesTest() throws Exception {
         SendResult sendResult = new SendResult();
@@ -1740,7 +1828,7 @@ class RocketMQAdminClientImplTest {
         // Source properties copied verbatim from a stored message used to 
fail the send with
         // "The Property<KEYS> is used by system"; reserved keys must be 
dropped instead.
         Map<String, String> properties = new HashMap<>();
-        properties.put(MessageConst.PROPERTY_KEYS, "injected-keys");
+        properties.put(MessageConst.PROPERTY_KEYS, "injected" + 
MessageDecoder.PROPERTY_SEPARATOR + "keys");
         properties.put(MessageConst.PROPERTY_TAGS, "injected-tags");
         properties.put(MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX, 
"injected-uniq");
         properties.put(MessageConst.PROPERTY_WAIT_STORE_MSG_OK, "false");
diff --git a/web/src/components/QueueBrowser.tsx 
b/web/src/components/QueueBrowser.tsx
index 26adcab79..9de3120b5 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -59,7 +59,7 @@ export const useQueueBrowser = (instanceId?: string) => {
   const [entries, setEntries] = useState<PulledEntry[]>([]);
   const requestSeqRef = useRef(0);
   const loadingRef = useRef(false);
-  const pullingRef = useRef(new Set<string>());
+  const pullingRef = useRef(new Map<string, symbol>());
 
   useEffect(() => {
     const requestId = ++requestSeqRef.current;
@@ -119,8 +119,11 @@ export const useQueueBrowser = (instanceId?: string) => {
     const key = `${queue.brokerName}-${queue.queueId}`;
     if (pullingRef.current.has(key)) return;
     const offset = offsets[key] ?? queue.minOffset;
-    pullingRef.current.add(key);
-    setPulling(new Set(pullingRef.current));
+    const pullToken = Symbol(key);
+    pullingRef.current.set(key, pullToken);
+    setPulling(new Set(pullingRef.current.keys()));
+    const ownsPull = () =>
+      requestId === requestSeqRef.current && pullingRef.current.get(key) === 
pullToken;
     try {
       const msg = await pullMessageAtOffset({
         instanceId,
@@ -129,24 +132,26 @@ export const useQueueBrowser = (instanceId?: string) => {
         queueId: queue.queueId,
         offset,
       });
-      if (requestId !== requestSeqRef.current) return;
+      if (!ownsPull()) return;
       setEntries((prev) => [
         ...prev.filter((entry) => entry.key !== key),
         { key, offset, message: msg },
       ]);
     } catch (err) {
-      if (requestId === requestSeqRef.current) {
+      if (ownsPull()) {
         message.error(err instanceof Error ? err.message : '拉取消息失败');
       }
     } finally {
-      if (requestId === requestSeqRef.current) {
+      if (ownsPull()) {
         pullingRef.current.delete(key);
-        setPulling(new Set(pullingRef.current));
+        setPulling(new Set(pullingRef.current.keys()));
       }
     }
   };
 
   const closeEntry = (key: string) => {
+    pullingRef.current.delete(key);
+    setPulling(new Set(pullingRef.current.keys()));
     setEntries((prev) => prev.filter((entry) => entry.key !== key));
   };
 
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx 
b/web/src/components/__tests__/QueueBrowser.test.tsx
index 0285171c3..e59a440b3 100644
--- a/web/src/components/__tests__/QueueBrowser.test.tsx
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -15,11 +15,19 @@
  * limitations under the License.
  */
 
-import { act, fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
+import {
+  act,
+  fireEvent,
+  render,
+  renderHook,
+  screen,
+  waitFor,
+  within,
+} from '@testing-library/react';
+import { message } from 'antd';
 import userEvent from '@testing-library/user-event';
 import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
 import { StrictMode } from 'react';
-import { message } from 'antd';
 import type { MessageRecord, QueueOffset } from '../../api/message';
 import { getQueueOffsets, pullMessageAtOffset } from '../../api/message';
 import { QueueBrowserResults, useQueueBrowser } from '../QueueBrowser';
@@ -47,10 +55,12 @@ beforeAll(() => {
 
 const createDeferred = <T,>() => {
   let resolve!: (value: T) => void;
-  const promise = new Promise<T>((promiseResolve) => {
+  let reject!: (reason: Error) => void;
+  const promise = new Promise<T>((promiseResolve, promiseReject) => {
     resolve = promiseResolve;
+    reject = promiseReject;
   });
-  return { promise, resolve };
+  return { promise, resolve, reject };
 };
 
 const queue = (brokerName: string): QueueOffset => ({
@@ -76,6 +86,16 @@ const messageRecord = (msgId: string): MessageRecord => ({
   size: 2,
 });
 
+const renderLoadedBrowser = async (queues = [queue('broker-a')]) => {
+  vi.mocked(getQueueOffsets).mockResolvedValue(queues);
+  const { result } = renderHook(() => useQueueBrowser('instance-a'));
+  act(() => result.current.setTopic('topic-a'));
+  await act(async () => {
+    await result.current.loadQueues();
+  });
+  return result;
+};
+
 function QueueBrowserProbe({ instanceId = 'instance-a' }: { instanceId?: 
string }) {
   const state = useQueueBrowser(instanceId);
   const firstQueue = state.queues[0];
@@ -130,6 +150,8 @@ function QueueBrowserResultsProbe() {
 describe('QueueBrowser request ownership', () => {
   beforeEach(() => {
     vi.clearAllMocks();
+    vi.mocked(getQueueOffsets).mockReset();
+    vi.mocked(pullMessageAtOffset).mockReset();
   });
 
   describe('page lifetime', () => {
@@ -189,6 +211,7 @@ describe('QueueBrowser request ownership', () => {
       },
     );
   });
+  afterEach(() => vi.restoreAllMocks());
 
   it('keeps the newest topic queue load when an older request resolves later', 
async () => {
     const topicA = createDeferred<QueueOffset[]>();
@@ -283,6 +306,109 @@ describe('QueueBrowser request ownership', () => {
     await act(async () => pull.resolve(messageRecord('message-a')));
   });
 
+  it('does not reopen a closed message card when its replacement pull 
resolves', async () => {
+    const replacement = createDeferred<MessageRecord | null>();
+    vi.mocked(getQueueOffsets).mockResolvedValue([queue('broker-a')]);
+    vi.mocked(pullMessageAtOffset)
+      .mockResolvedValueOnce(messageRecord('initial-message'))
+      .mockReturnValueOnce(replacement.promise);
+    const user = userEvent.setup();
+    render(<QueueBrowserResultsProbe />);
+
+    await user.click(screen.getByRole('button', { name: 'topic-a' }));
+    await user.click(screen.getByRole('button', { name: 'load' }));
+    await screen.findByText('broker-a');
+    await user.click(screen.getByRole('button', { name: 'pull' }));
+    await screen.findByText('initial-message');
+    await user.click(screen.getByRole('button', { name: 'pull' }));
+    await waitFor(() => expect(pullMessageAtOffset).toHaveBeenCalledTimes(2));
+    await user.click(screen.getByRole('button', { name: 'close' }));
+
+    await act(async () => replacement.resolve(messageRecord('late-message')));
+
+    expect(screen.queryByText('initial-message')).not.toBeInTheDocument();
+    expect(screen.queryByText('late-message')).not.toBeInTheDocument();
+  });
+
+  it('closing one queue keeps another queue pull active', async () => {
+    const closedPull = createDeferred<MessageRecord | null>();
+    const otherPull = createDeferred<MessageRecord | null>();
+    vi.mocked(pullMessageAtOffset)
+      .mockResolvedValueOnce(messageRecord('initial-message'))
+      .mockReturnValueOnce(closedPull.promise)
+      .mockReturnValueOnce(otherPull.promise);
+    const browser = await renderLoadedBrowser([queue('broker-a'), 
queue('broker-b')]);
+    await act(async () => {
+      await browser.current.handlePull(queue('broker-a'));
+    });
+    act(() => {
+      void browser.current.handlePull(queue('broker-a'));
+      void browser.current.handlePull(queue('broker-b'));
+      browser.current.closeEntry('broker-a-0');
+    });
+
+    await act(async () => closedPull.resolve(messageRecord('late-message')));
+    expect(browser.current.entries).toEqual([]);
+    expect([...browser.current.pulling]).toEqual(['broker-b-0']);
+    await act(async () => otherPull.resolve(messageRecord('other-message')));
+    expect(browser.current.entries.map((entry) => 
entry.message?.msgId)).toEqual(['other-message']);
+    expect(browser.current.pulling.size).toBe(0);
+  });
+
+  it('a dismissed pull cannot unlock or overwrite a newly requested message', 
async () => {
+    const stalePull = createDeferred<MessageRecord | null>();
+    const currentPull = createDeferred<MessageRecord | null>();
+    vi.mocked(pullMessageAtOffset)
+      .mockResolvedValueOnce(messageRecord('initial-message'))
+      .mockReturnValueOnce(stalePull.promise)
+      .mockReturnValueOnce(currentPull.promise);
+    const browser = await renderLoadedBrowser();
+    await act(async () => {
+      await browser.current.handlePull(queue('broker-a'));
+    });
+    act(() => {
+      void browser.current.handlePull(queue('broker-a'));
+      browser.current.closeEntry('broker-a-0');
+      void browser.current.handlePull(queue('broker-a'));
+    });
+    expect(pullMessageAtOffset).toHaveBeenCalledTimes(3);
+
+    await act(async () => stalePull.resolve(messageRecord('stale-message')));
+    expect(browser.current.entries).toEqual([]);
+    expect(browser.current.pulling.has('broker-a-0')).toBe(true);
+    act(() => {
+      void browser.current.handlePull(queue('broker-a'));
+    });
+    expect(pullMessageAtOffset).toHaveBeenCalledTimes(3);
+    await act(async () => 
currentPull.resolve(messageRecord('current-message')));
+    expect(browser.current.entries.map((entry) => 
entry.message?.msgId)).toEqual([
+      'current-message',
+    ]);
+    expect(browser.current.pulling.size).toBe(0);
+  });
+
+  it('ignores a dismissed pull failure', async () => {
+    const dismissedPull = createDeferred<MessageRecord | null>();
+    const errorToast = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+    vi.mocked(pullMessageAtOffset)
+      .mockResolvedValueOnce(messageRecord('initial-message'))
+      .mockReturnValueOnce(dismissedPull.promise);
+    const browser = await renderLoadedBrowser();
+    await act(async () => {
+      await browser.current.handlePull(queue('broker-a'));
+    });
+    act(() => {
+      void browser.current.handlePull(queue('broker-a'));
+      browser.current.closeEntry('broker-a-0');
+    });
+
+    await act(async () => dismissedPull.reject(new Error('dismissed request 
failed')));
+    expect(errorToast).not.toHaveBeenCalled();
+    expect(browser.current.entries).toEqual([]);
+    expect(browser.current.pulling.size).toBe(0);
+    errorToast.mockRestore();
+  });
+
   it('keeps a replacement pull locked when a stale pull settles', async () => {
     const stalePull = createDeferred<MessageRecord | null>();
     const currentPull = createDeferred<MessageRecord | null>();
diff --git a/web/src/utils/clientConnectionDiagnostics.test.ts 
b/web/src/utils/clientConnectionDiagnostics.test.ts
index 30221b91d..1c8912c64 100644
--- a/web/src/utils/clientConnectionDiagnostics.test.ts
+++ b/web/src/utils/clientConnectionDiagnostics.test.ts
@@ -131,6 +131,32 @@ describe('client connection diagnostics', () => {
     );
   });
 
+  it('keeps distinct identities containing pipe characters separate', () => {
+    const diagnostics = analyzeClientConnections([
+      connection({ clientId: 'client|partition', groupOrTopic: 'orders' }),
+      connection({ clientId: 'client', groupOrTopic: 'partition|orders' }),
+    ]);
+
+    expect(diagnostics.issues.filter((item) => item.code === 
'EXACT_DUPLICATE_CONNECTION')).toEqual(
+      [],
+    );
+  });
+
+  it('preserves complete identity fields in duplicate evidence', () => {
+    const original = connection({ clientId: 'client|partition', groupOrTopic: 
'orders|events' });
+    const diagnostics = analyzeClientConnections([original, { ...original }]);
+    const duplicates = diagnostics.issues.filter(
+      (item) => item.code === 'EXACT_DUPLICATE_CONNECTION',
+    );
+
+    expect(duplicates).toHaveLength(1);
+    expect(duplicates[0]).toMatchObject({
+      clientId: 'client|partition',
+      resource: 'orders|events',
+      evidence: ['address=10.0.1.10:49152', 'count=2'],
+    });
+  });
+
   it('flags mixed protocols, mixed versions, and single consumer instances per 
resource', () => {
     const diagnostics = analyzeClientConnections([
       connection({
diff --git a/web/src/utils/clientConnectionDiagnostics.ts 
b/web/src/utils/clientConnectionDiagnostics.ts
index 9bc9243c2..e9d9029d4 100644
--- a/web/src/utils/clientConnectionDiagnostics.ts
+++ b/web/src/utils/clientConnectionDiagnostics.ts
@@ -154,12 +154,12 @@ const issue = (
 });
 
 const connectionIdentity = (connection: ClientConnection): string =>
-  [
+  JSON.stringify([
     normalizeText(connection.type),
     normalizeText(connection.clientId),
     normalizeText(connection.groupOrTopic),
     normalizeText(connection.address),
-  ].join('|');
+  ]);
 
 const resourceKey = (connection: ClientConnection): string =>
   
`${normalizeText(connection.type)}:${normalizeText(connection.groupOrTopic)}`;
@@ -264,7 +264,7 @@ const addClientIdIssues = (connections: ClientConnection[], 
issues: ClientConnec
 
   identityCounts.forEach((count, identity) => {
     if (count <= 1) return;
-    const [, clientId, resource, address] = identity.split('|');
+    const [, clientId, resource, address]: string[] = JSON.parse(identity);
     issues.push(
       issue(
         'EXACT_DUPLICATE_CONNECTION',

Reply via email to