fix(channels): stop proto3 boolean elision re-enabling uplink/downlink (#3594)#3598
Conversation
#3594) When a user disables "Downlink Enabled" (or "Uplink Enabled") on a channel, the change saves correctly, but after a container restart the setting reverts to true. Root cause: proto3 omits boolean `false` on the wire (it's the zero/default value). When the device streams its channel config on reconnect, processChannelProtobuf() decodes a disabled downlink_enabled/uplink_enabled as `undefined` (per the repo's "protobuf.js decoded-message shape" gotcha — an unset scalar reads as null/undefined, not the proto default). The old `?? true` fallback then incorrectly stored `true`, overwriting the user's saved setting. Both `uplink_enabled` and `downlink_enabled` are plain `bool` in the Meshtastic ChannelSettings proto and default to false, so the correct reconstruction of an absent value is `false`. Changes: - processChannelProtobuf() in meshtasticManager.ts: `?? true` -> `?? false` for both uplinkEnabled and downlinkEnabled (the actual bug — coerced undefined to true before it reached the repo). - ChannelsRepository.upsertChannel() insert branch: `?? true` -> `?? false` for both fields, to match proto3 semantics for any future caller passing undefined. The UPDATE branch is left untouched — it correctly coalesces to the existing stored value (`?? existingChannel.X`), which is the right behavior for an absent field on an already-known channel. Added meshtasticManager.channelProto3Elision.test.ts: drives processChannelProtobuf with elided/explicit booleans and asserts the value handed to upsertChannel is false when elided and preserved when explicit. Verified the test fails against the pre-fix `?? true` code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
|
Claude finished @Yeraze's task —— View job PR Review: fix(channels): stop proto3 boolean elision re-enabling uplink/downlink
SummaryThis is a well-scoped, correct fix for a genuine proto3 wire-format bug. The root cause analysis in the PR description is accurate. Here's the detailed review:
|
| # | Scenario | What it validates |
|---|---|---|
| 1 | downlinkEnabled elided, uplinkEnabled explicit true |
Only the absent field defaults to false |
| 2 | uplinkEnabled elided, downlinkEnabled explicit true |
Symmetric case |
| 3 | Both fields elided | Both correctly default to false |
| 4 | Both fields explicitly true |
No false-positive regression |
| 5 | Both fields explicitly false |
Explicit false round-trips unchanged |
Mock setup: ✅
The mock infrastructure is thorough. All heavy dependencies (TcpTransport, protobufLoader, cronScheduler, etc.) are cleanly mocked. The test correctly uses vi.clearAllMocks() in beforeEach and vi.restoreAllMocks() in afterEach.
Minor nit — redundant assertion in test case 1:
// line 143-145
expect(stored.downlinkEnabled).toBe(false);
// Bug would have stored true via `?? true`.
expect(stored.downlinkEnabled).not.toBe(true);expect(x).toBe(false) already implies x !== true. The .not.toBe(true) assertion is redundant. Same pattern appears in test case 2 (lines 164–165). Not a blocking issue, just noise.
Minor nit — manager typed as any:
// line 111
let manager: any;If MeshtasticManager is exported, a typed reference would give better IDE support and surface regressions at compile time. But given the complexity of mocking this class, any is pragmatic and acceptable for a focused regression test.
Overall Assessment
Approve. The fix is minimal, correct, well-documented, and backed by a regression test that was verified to fail against the pre-fix code. No security concerns. No performance concerns. The behavioral change is safe: proto3 false (the zero default) is the only value that was being incorrectly inflated to true, and the fix precisely targets that path without touching the user-save route or the UPDATE branch.
Resolve migration-number collision: renumber the dead-drop migration 094_create_dead_drop -> 095_create_dead_drop (keeping main's 094_add_meshcore_node_favorite). Update migrations.ts registry, migrations.test.ts (count 95, last=create_dead_drop), and the migration's internal Postgres/MySQL export names. meshtasticManager.ts auto-merged cleanly (main's Yeraze#3593/Yeraze#3598/Yeraze#3600 changes + the mailbox dispatch coexist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
Problem
When a user disables Downlink Enabled (or Uplink Enabled) on a secondary channel, the change saves (success toast, JSON export confirms
"downlinkEnabled": false), but after restarting the MeshMonitor container the setting reverts totrue.Root cause
proto3 omits boolean
falseon the wire — it's the zero/default value. When the device streams its channel config on reconnect,processChannelProtobuf()insrc/server/meshtasticManager.tsdecodes a user-disableddownlink_enabled/uplink_enabledasundefined(consistent with the repo's known "protobuf.js decoded-message shape" gotcha: an unset scalar reads as null/undefined, not the proto default).The old
?? truefallback then coerced thatundefinedtotruebefore it reached the repository, overwriting the user's saved setting. Bothuplink_enabledanddownlink_enabledare plainboolin the MeshtasticChannelSettingsproto and default to false, so the correct reconstruction of an absent value isfalse.Fix
processChannelProtobuf()(the actual bug):?? true→?? falsefor bothuplinkEnabledanddownlinkEnabled.ChannelsRepository.upsertChannel()INSERT branch:?? true→?? falsefor both fields, to match proto3 semantics for any caller that passesundefined.?? existingChannel.X), which is correct for an absent field on an already-known channel — changing it would have re-introduced data loss.Fields deliberately left alone
buildDeviceConfigFromActual()readsch.uplinkEnabled/ch.downlinkEnabledfrom the DB (not the protobuf decode path) — no elision risk.{ uplinkEnabled: true, downlinkEnabled: true }Primary-channel display fallback (used only when no channels exist) is a legitimate UI default, not a decode default.channelRoutes.ts) passes the boolean explicitly (orexistingChannel?.X ?? null), so saves already worked; this PR fixes only the device-reconnect ingest path.Tests
Added
src/server/meshtasticManager.channelProto3Elision.test.ts(5 cases): drivesprocessChannelProtobufwith elided and explicit booleans and asserts the value handed toupsertChannelisfalsewhen elided and preserved verbatim when explicit. Verified the test fails against the pre-fix?? truecode.success=true, 0 failed suites, 0 failed tests (7017 passed).tsc -p tsconfig.server.json --noEmit: no new errors (the 5 reported are pre-existingTelemetryChart.tsxfrontend issues, untouched here).Closes #3594
🤖 Generated with Claude Code