From a1bc642615e8a50716c1c3168bd9a641afe79b17 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 8 Oct 2026 07:15:26 -0400 Subject: [PATCH] Keep private device list requests separate across view generations --- docs/firewall-tunnel-followup-20261008.md | 12 ++++++++++++ .../src/views/server/VpnDeviceSettings.test.ts | 18 +++++++++++++++++- .../src/views/server/VpnDeviceSettings.vue | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/firewall-tunnel-followup-20261008.md b/docs/firewall-tunnel-followup-20261008.md index 15488b90..5f35c65b 100644 --- a/docs/firewall-tunnel-followup-20261008.md +++ b/docs/firewall-tunnel-followup-20261008.md @@ -87,3 +87,15 @@ Final follow-up receipts: All fixture browser/server processes stopped after verification. No node, helper, firewall, wallet or saved-device configuration was changed by these UI checks. + +A final narrow follow-up disables in-flight deduplication for the private device +list. The shared client's dedup key is only method+params; a new view/capability +generation could otherwise attach to the preceding generation's unresolved +promise. The device suite now passes 18/18, including fresh-list recovery and late +old-response rejection (`/tmp/archy-firewall-private-generation-tests-20261008.log`). +No broader browser rerun was needed for this request-policy-only change. + +Retry behavior is unchanged: this client's maxRetries value of 1 already means +one network attempt, just like 0 after its minimum clamp. A confirmed pre-dispatch +CSRF rejection may still refresh credentials and retry. No cross-node dedup fault +was claimed: the client's base URL is fixed per instance. diff --git a/neode-ui/src/views/server/VpnDeviceSettings.test.ts b/neode-ui/src/views/server/VpnDeviceSettings.test.ts index 3deb39a6..674a1dfd 100644 --- a/neode-ui/src/views/server/VpnDeviceSettings.test.ts +++ b/neode-ui/src/views/server/VpnDeviceSettings.test.ts @@ -36,7 +36,23 @@ describe('Private device management', () => { expect(rpcClient.call).not.toHaveBeenCalled() await w.setProps({ statusKnown: true }); await flushPromises() expect(rpcClient.call).toHaveBeenCalledTimes(1) - expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'vpn.list-peers' })) + expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'vpn.list-peers', dedup: false })) + w.unmount() + }) + it('starts a fresh non-deduplicated list after capability recovery and ignores the prior generation', async () => { + const replies: Array<(value: { peers: typeof peer[] }) => void> = [] + vi.mocked(rpcClient.call).mockImplementation(async () => new Promise(resolve => { replies.push(resolve) })) + const w = view(); await flushPromises() + await w.setProps({ managementVerified: false }); await flushPromises() + await w.setProps({ managementVerified: true }); await flushPromises() + expect(replies).toHaveLength(2) + for (const [options] of vi.mocked(rpcClient.call).mock.calls) { + expect(options).toEqual(expect.objectContaining({ method: 'vpn.list-peers', dedup: false })) + } + replies[1]!({ peers: [{ ...peer, name: 'Tablet' }] }); await flushPromises() + expect(w.text()).toContain('Tablet') + replies[0]!({ peers: [{ ...peer, name: 'Old Phone' }] }); await flushPromises() + expect(w.text()).toContain('Tablet'); expect(w.text()).not.toContain('Old Phone') w.unmount() }) it('clears private details and rejects an in-flight reveal when capability is lost', async () => { diff --git a/neode-ui/src/views/server/VpnDeviceSettings.vue b/neode-ui/src/views/server/VpnDeviceSettings.vue index 60f8472e..d56af5e9 100644 --- a/neode-ui/src/views/server/VpnDeviceSettings.vue +++ b/neode-ui/src/views/server/VpnDeviceSettings.vue @@ -32,7 +32,7 @@ async function refresh() { loading.value = true error.value = '' try { - const result = await rpcClient.call<{ peers: Device[] }>({ method: 'vpn.list-peers', dedup: true, maxRetries: 1 }) + const result = await rpcClient.call<{ peers: Device[] }>({ method: 'vpn.list-peers', dedup: false, maxRetries: 1 }) if (!canReadDevices.value || !active || generation !== request) return if (!Array.isArray(result?.peers) || result.peers.some(p => !p || typeof p.name !== 'string' || typeof p.ip !== 'string' || (p.management_state !== undefined && !['active', 'pending', 'revoking'].includes(p.management_state)))) throw new Error('Invalid device list') devices.value = result.peers.map(p => ({ name: p.name, ip: p.ip, management_state: p.management_state }))