Files
omarchycn/test/shell.d/network-test.sh
T
96bbe53634 Fix panel delegate segfault and the network panel's open stall (#6605)
* fix(network): drop the redundant rescan on the bar click

Opening from the bar ran open() and then a bare refresh(). open() already
triggers onOpenedChanged -> refresh(true), which defers the PHY scan by
disabling the scanner and re-enabling it from scanRestart. The bare
refresh() that followed defaults scanWifi to false, so it took the other
branch and set wifiDevice.scannerEnabled synchronously on the click frame,
undoing the deferral and stalling the open on NetworkManager's access-point
flood. It also double-started the DNS and band probes.

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(network): keep wifi rows QObject-free to prevent a delegate crash

wifiRow() embedded the WifiNetwork QObject in the row it returns, and those
rows are list-model data, so every delegate held a live QObject wrapper in a
var property. When NetworkManager churns the list -- a scan's access-point
flood, an AP disappearing -- the object can be destroyed while a delegate is
still incubating, and quickshell segfaults in QObjectWrapper::wrap_slowPath
on the dangling wrapper.

Project primitives only and resolve the backend object at action time via
the existing networkForSsid(). Both failNetworkAction() and
checkActionCompletion() already no-op on a null network, so a row whose
network has since vanished is handled the same way it was before.

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bluetooth): keep device rows QObject-free to prevent a delegate crash

Same crash class as the wifi rows: scrollRows embedded the BlueZ Device
QObject in list-model data, so every delegate held a live wrapper in a var
property. Discovery churn -- a scan timeout dropping a device, an unpair --
can destroy the object while a delegate is still incubating, and quickshell
segfaults on the dangling wrapper.

Project primitives for both the scroll rows and the connected rows, and
resolve the backend object by address in deviceFor() for the click actions.
The keyboard flow already went through deviceAt(), which reads the live
device arrays directly rather than model data, so it is untouched.

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(network): guard row disconnects against a vanished network

Row activation resolved the WifiNetwork with networkForSsid() and passed the
result straight to disconnect(), which falls back to connectedWifiNetwork
when handed null. A row is a primitive snapshot, so scan churn can remove its
backing object while the row is still on screen -- activating it then tore
down whatever happened to be connected at that moment rather than doing
nothing.

Route both row paths through disconnectRow(), which resolves first and only
acts when the row still maps to a live network. disconnect() keeps its
fallback for callers that mean "drop the current connection".

Also covers the bar-click open path, which had no regression: the suite
already asserts against Panel.qml source, so assert the closed branch calls
open() alone and never a second refresh().

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: shrijit <shrijitsrivastav@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:32:36 +02:00

174 lines
10 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const network = requireFromRoot('shell/plugins/panels/network/Model.js')
const panelSource = fs.readFileSync(root + '/shell/plugins/panels/network/Panel.qml', 'utf8')
assert(/IpcHandler[\s\S]*?function toggleNetwork\(\) \{ root\.toggleNetwork\(\) \}/.test(panelSource), 'network exposes the Wi-Fi radio toggle over IPC')
assert(/manageIpc: false/.test(panelSource), 'network owns its IPC handler so it can extend the target methods')
// Opening from the bar must call open() and nothing else. open() runs
// refresh(true), which defers the PHY scan; a second bare refresh() defaults
// scanWifi to false, sets scannerEnabled synchronously, and stalls the open on
// NetworkManager's access-point flood.
const barPress = panelSource.match(/onPressed: function\(b\) \{[\s\S]*?\n {4}\}/)
assert(barPress, 'network bar button has an onPressed handler')
const barPressCode = barPress[0].replace(/\/\/.*$/gm, '')
assert(!/refresh\(/.test(barPressCode), 'network bar click opens the panel without a second refresh that would undo the deferred scan')
// A row is a primitive snapshot that can outlive its WifiNetwork, and
// disconnect() falls back to the live connection when handed null, so row
// activation must go through the guarded disconnectRow().
assert(
/function disconnectRow\(ssid\) \{\s*var network = networkForSsid\(ssid\)\s*if \(network\) disconnect\(network\)/.test(panelSource),
'network guards row disconnects so a stale row cannot drop an unrelated connection'
)
assert(!/disconnect\(\s*(root\.)?networkForSsid\(/.test(panelSource), 'network never passes an unguarded networkForSsid() lookup to disconnect()')
assertDeepEqual(
network.parseNetworkStatus('wifi\tCafe WiFi\t78\t5200\n'),
{ kind: 'wifi', label: 'Cafe WiFi', signalStrength: 78, frequency: '5200' },
'network parses bar status'
)
assertEqual(network.connectionIcon('wifi', 80), network.wifiIconFor(80), 'network maps wifi icon from signal')
assertEqual(network.formatHeaderSpeed('1000'), '1gbit', 'network formats gigabit speed')
assertEqual(network.formatHeaderSpeed('2500'), '2.5gbit', 'network formats fractional gigabit speed')
assertEqual(network.formatHeaderFreq('2462'), '2.4ghz', 'network formats 2.4GHz wifi band')
assertEqual(network.formatHeaderFreq('5200'), '5ghz', 'network formats 5GHz wifi band')
assertEqual(network.formatHeaderFreq('6455.0'), '6ghz', 'network formats 6GHz wifi band')
assertEqual(network.formatHeaderFreq('18300'), '18.3ghz', 'network falls back to exact GHz for unknown bands')
assertEqual(network.headerDetail({ type: 'ethernet', speed: '100' }), '100mbit', 'network header uses ethernet speed')
assertDeepEqual(
network.parseKeyValue('iface\twlan0\nrx_bytes\t100\ntx_bytes\t50\n'),
{ iface: 'wlan0', rx_bytes: '100', tx_bytes: '50' },
'network parses detail key values'
)
assertEqual(network.decodeIwSsid('Cafe\\xe2\\x80\\x99'), 'Cafe', 'network decodes UTF-8 SSID bytes')
assertEqual(network.decodeIwSsid('Smile \\xf0\\x9f\\x98\\x80'), 'Smile 😀', 'network decodes emoji SSID bytes')
assertEqual(network.decodeIwSsid('\\x20Cafe\\x20'), ' Cafe ', 'network preserves edge spaces in SSIDs')
assertEqual(network.decodeIwSsid('slash\\x5cname'), 'slash\\name', 'network decodes SSID backslashes once')
assertEqual(network.decodeIwSsid('invalid\\xff'), 'invalid\\xff', 'network preserves invalid UTF-8 escapes')
assertEqual(network.decodeIwSsid('already 😀'), 'already 😀', 'network safely preserves unexpected non-BMP input')
assertDeepEqual(
network.parseKeyValue('ssid\tline\\x0abreak\\x09tab\\x00nul\nsignal_dbm\t-40\n'),
{ ssid: 'line\\x0abreak\\x09tab\\x00nul', signal_dbm: '-40' },
'network leaves control-byte escapes safe for single-line display'
)
assertDeepEqual(
network.throughputState({ prevIface: '', prevSampleTime: 0 }, { iface: 'wlan0', rx_bytes: '100', tx_bytes: '50' }, 10),
{ prevIface: 'wlan0', prevRxBytes: 100, prevTxBytes: 50, prevSampleTime: 10, downloadRate: 0, uploadRate: 0 },
'network seeds throughput state on first sample'
)
assertDeepEqual(
network.throughputState({ prevIface: 'wlan0', prevRxBytes: 100, prevTxBytes: 50, prevSampleTime: 10 }, { iface: 'wlan0', rx_bytes: '300', tx_bytes: '90' }, 12),
{ prevIface: 'wlan0', prevRxBytes: 300, prevTxBytes: 90, prevSampleTime: 12, downloadRate: 100, uploadRate: 20 },
'network computes throughput deltas'
)
let ping = network.pingLatencyState(
{ pingIface: '', routerPingSamples: [], internetPingSamples: [] },
{ iface: 'wlan0', router_ping_ms: '2.0', internet_ping_ms: '20.0' },
4
)
assertDeepEqual(
ping,
{ pingIface: 'wlan0', routerPingSamples: [2], internetPingSamples: [20], routerPingLatency: 2, internetPingLatency: 20, internetPingPacketLoss: 0 },
'network seeds ping latency samples'
)
ping = network.pingLatencyState(ping, { iface: 'wlan0', router_ping_ms: '4.0', internet_ping_ms: '' }, 4)
assertDeepEqual(
ping,
{ pingIface: 'wlan0', routerPingSamples: [2, 4], internetPingSamples: [20, null], routerPingLatency: 3, internetPingLatency: 20, internetPingPacketLoss: 50 },
'network averages recent successful ping samples'
)
assertDeepEqual(
network.pingLatencyState(ping, { iface: 'eth0', router_ping_ms: '1.5', internet_ping_ms: '10.0' }, 4),
{ pingIface: 'eth0', routerPingSamples: [1.5], internetPingSamples: [10], routerPingLatency: 1.5, internetPingLatency: 10, internetPingPacketLoss: 0 },
'network resets ping samples when interface changes'
)
assertDeepEqual(
network.pingLatencyState(ping, { iface: 'wlan0', internet_ping_ms: '22.0' }, 4),
{ pingIface: 'wlan0', routerPingSamples: [], internetPingSamples: [20, null, 22], routerPingLatency: -1, internetPingLatency: 21, internetPingPacketLoss: 33 },
'network clears ping samples when a target is unavailable'
)
assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes')
assertEqual(network.formatRate(1536), '1.5 KB/s', 'network formats rates')
assertEqual(network.formatPingLatency('2.54'), '2.5 ms', 'network formats low ping with precision')
assertEqual(network.formatPingLatency('25.4'), '25 ms', 'network formats ping')
assertEqual(network.formatPingLatency(''), 'Timeout', 'network formats missing ping as timeout')
assertEqual(network.formatPingLatency(-1, false), '--', 'network holds the ping row before the first sample')
assertEqual(network.formatPingLatency('25.4', true), '25 ms', 'network formats ping once samples exist')
assertEqual(network.formatPingLatency('', true), 'Timeout', 'network still reports a timeout among real samples')
assertEqual(network.formatPacketLoss(2), '2%', 'network formats packet loss')
assertEqual(network.formatPacketLoss(0), '0%', 'network formats zero packet loss')
assertEqual(network.formatPacketLoss(0, false), '--', 'network holds the packet loss row before the first sample')
assertEqual(network.formatPacketLoss(0, true), '0%', 'network reports zero loss once samples exist')
const rows = network.sortWifiRows([
{ ssid: 'Open', connected: false, known: false, signal: 95 },
{ ssid: 'Known', connected: false, known: true, signal: 10 },
{ ssid: 'Connected', connected: true, known: true, signal: 20 }
])
assertDeepEqual(rows.map(row => row.ssid), ['Connected', 'Known', 'Open'], 'network sorts wifi rows by connection and known state')
assertEqual(network.wifiSectionTitle(rows, 0), 'KNOWN NETWORKS', 'network labels known wifi section')
assertEqual(network.wifiSectionTitle(rows, 2), 'OTHER NETWORKS', 'network labels other wifi section')
const wifiRow = network.wifiRow({ connected: true, known: true, name: 'Home', signalStrength: 0.8, security: 1 })
assertDeepEqual(
wifiRow,
{ connected: true, known: true, ssid: 'Home', signal: 80, security: 1 },
'network projects wifi rows with primitives so delegates never hold the live WifiNetwork object'
)
assertDeepEqual(
Object.keys(wifiRow).sort(),
['connected', 'known', 'security', 'signal', 'ssid'],
'network wifi rows project exactly the primitive fields, so each delegate stores no live QObject'
)
const reasons = { NoSecrets: 1, WifiAuthTimeout: 2, WifiNetworkLost: 3, WifiClientDisconnected: 4, WifiClientFailed: 5 }
assertEqual(network.networkFailureReason(1, reasons), 'Passphrase required', 'network maps missing passphrase failures')
assertEqual(network.networkFailureReason(2, reasons), 'Wrong password', 'network maps auth timeout failures')
assertEqual(network.networkFailureReason(99, reasons), 'Failed to connect', 'network maps unknown failures')
assertEqual(network.shouldRepromptPassphrase(reasons.NoSecrets, false, reasons), true, 'network reprompts when secrets are missing')
assertEqual(network.shouldRepromptPassphrase(reasons.WifiAuthTimeout, true, reasons), true, 'network reprompts a protected network after a wrong password')
assertEqual(network.shouldRepromptPassphrase(reasons.WifiAuthTimeout, false, reasons), false, 'network does not reprompt an open network on auth timeout')
assertEqual(network.shouldRepromptPassphrase(reasons.WifiClientFailed, true, reasons), false, 'network does not reprompt on generic connection failures')
assertEqual(network.bandLabel('2.4'), '2.4ghz', 'network labels the 2.4GHz band')
assertEqual(network.bandLabel('6'), '6ghz', 'network labels the 6GHz band')
assertEqual(network.bandLabel('auto'), 'Auto', 'network labels the automatic band choice')
assertEqual(network.bandSectionTitle('auto', '2.4'), 'WI-FI BAND: 2.4GHZ', 'network names the live band in the header under automatic')
assertEqual(network.bandSectionTitle('auto', ''), 'WI-FI BAND', 'network omits an unknown band from the header')
assertEqual(network.bandSectionTitle('5', '5'), 'WI-FI BAND', 'network drops the header band once the pills are showing')
assertEqual(network.bandSectionTitle('5', '2.4'), 'WI-FI BAND', 'network keeps a plain header while a pin is settling')
assertDeepEqual(
network.parseBandStatus('band\t5\navailable\t2.4 5 6\nselected\tauto\n'),
{ band: '5', selected: 'auto', available: ['2.4', '5', '6'] },
'network parses band status'
)
assertDeepEqual(
network.parseBandStatus(''),
{ band: '', selected: 'auto', available: [] },
'network parses empty band status without a wifi connection'
)
assertEqual(network.headerDetail({ type: 'wifi', freq: '5745' }), '', 'network keeps wifi band state out of the hero')
assertEqual(network.headerDetail({ type: 'ethernet', speed: '100' }), '100mbit', 'network keeps ethernet speed in the hero')
JS