Skip to content

fix(channels): stop proto3 boolean elision re-enabling uplink/downlink (#3594)#3598

Merged
Yeraze merged 1 commit into
mainfrom
fix/3594-downlink-proto3-elision
Jun 21, 2026
Merged

fix(channels): stop proto3 boolean elision re-enabling uplink/downlink (#3594)#3598
Yeraze merged 1 commit into
mainfrom
fix/3594-downlink-proto3-elision

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 21, 2026

Copy link
Copy Markdown
Owner

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 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() in src/server/meshtasticManager.ts decodes a user-disabled downlink_enabled/uplink_enabled as undefined (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 ?? true fallback then coerced that undefined to true before it reached the repository, 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.

Fix

  • processChannelProtobuf() (the actual bug): ?? true?? false for both uplinkEnabled and downlinkEnabled.
  • ChannelsRepository.upsertChannel() INSERT branch: ?? true?? false for both fields, to match proto3 semantics for any caller that passes undefined.
  • UPDATE branch left untouched: it already coalesces to the existing stored value (?? 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() reads ch.uplinkEnabled/ch.downlinkEnabled from the DB (not the protobuf decode path) — no elision risk.
  • The hardcoded { uplinkEnabled: true, downlinkEnabled: true } Primary-channel display fallback (used only when no channels exist) is a legitimate UI default, not a decode default.
  • The user-save route (channelRoutes.ts) passes the boolean explicitly (or existingChannel?.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): drives processChannelProtobuf with elided and explicit booleans and asserts the value handed to upsertChannel is false when elided and preserved verbatim when explicit. Verified the test fails against the pre-fix ?? true code.

  • Full Vitest suite: success=true, 0 failed suites, 0 failed tests (7017 passed).
  • tsc -p tsconfig.server.json --noEmit: no new errors (the 5 reported are pre-existing TelemetryChart.tsx frontend issues, untouched here).

Closes #3594

🤖 Generated with Claude Code

#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
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(channels): stop proto3 boolean elision re-enabling uplink/downlink

  • Read changed files
  • Review code quality and correctness
  • Review test coverage
  • Post final feedback

Summary

This 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:


src/server/meshtasticManager.ts — Core Fix

Correctness: ✅

The change at lines 5091–5092 from ?? true?? false is exactly right. Meshtastic's ChannelSettings proto defines both uplink_enabled and downlink_enabled as plain bool with an implicit proto3 default of false. When a user disables one of these fields, proto3 omits it on the wire, protobuf.js decodes that absence as undefined, and the old ?? true fallback silently re-enabled it. Changing to ?? false faithfully reconstructs the proto3 default.

The comment block (lines 5084–5090) is thorough and directly references the issue number — good for future maintainers.

One minor observation: the debug log on line 5095 still reads channel.settings.uplinkEnabled and channel.settings.downlinkEnabled directly (pre-coalesced values), so it will log undefined instead of false when the fields are elided. This is cosmetically confusing in logs but has no functional impact. Consider capturing the coalesced values into locals if log readability matters:

const uplinkEnabled = channel.settings.uplinkEnabled ?? false;
const downlinkEnabled = channel.settings.downlinkEnabled ?? false;

await databaseService.channels.upsertChannel({
  ...
  uplinkEnabled,
  downlinkEnabled,
  ...
}, this.sourceId);
logger.debug(`... uplink: ${uplinkEnabled}, downlink: ${downlinkEnabled} ...`);

src/db/repositories/channels.ts — INSERT Branch Fix

Correctness: ✅

The INSERT branch change at lines 179–180 (?? false instead of ?? true) is correct and consistent with the fix above. The explanatory comment (lines 175–178) clearly documents the #3594 rationale.

UPDATE branch intentionally untouched: ✅

Lines 140–141 correctly use ?? existingChannel.uplinkEnabled / ?? existingChannel.downlinkEnabled for updates, preserving the stored value when the field is absent. This is the right behavior for reconnect-path ingest on a channel the DB already knows about — the PR description correctly explains why this path was left alone.


src/server/meshtasticManager.channelProto3Elision.test.ts — Test File

Coverage: ✅ Comprehensive

The 5 test cases cover the important branches:

# 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.

@Yeraze Yeraze merged commit 3d75071 into main Jun 21, 2026
19 checks passed
@Yeraze Yeraze deleted the fix/3594-downlink-proto3-elision branch June 21, 2026 12:31
Yeraze added a commit to TheWISPRer/meshmonitor that referenced this pull request Jun 21, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

downlinkEnabled: false reverts to true after container restart (proto3 boolean elision)

1 participant