Skip to content

feat: enhance AtemSocket to handle caching#191

Open
milux wants to merge 2 commits intoSofie-Automation:mainfrom
milux:main
Open

feat: enhance AtemSocket to handle caching#191
milux wants to merge 2 commits intoSofie-Automation:mainfrom
milux:main

Conversation

@milux
Copy link

@milux milux commented Feb 23, 2026

About the Contributor

This PR is a personal contribution to the sofie-atem-connection library in order to improve overall efficiency.
Fixes #190.

Type of Contribution

This is a:

Bug fix / Feature

Current Behavior

As discussed in #190, highly frequent command batches of TimeCommands followed by other redundant commands with identical contents cause significant CPU load, even when ATEM devices are not actively "doing" anything in particular.

New Behavior

The CPU load was (again) cut down approximately by half for affected ATEM devices.
This is achieved by two means primarily:

  • A full copy of the incoming UDP packet's serialized commands was substituted by a "normalization function" which creates a view on the passed memory instead of copying it.
  • The serialized representation of command batches starting with a TimeCommand are (partially, after the TimeCommand itself) cached. Iff the next batch also starts with a time command and otherwise equals the previous batch (byte-wise) entirely. Only the TimeCommand is emitted and the remaining contents are dropped without (redundant) deserialization and further processing.

Testing Instructions

I tried to provide full (jest) test coverage for all added and modified code snippets.

I took precautions to make sure my caching strategy is sound by always flushing/updating the cached copy if a command batch does either not start with a TimeCommand or differs by content or length after the TimeCommand.

My optimization should be valid iff the following assumptions hold:

  • Processing of commands is idempotent, i.e., processing the same batches of commands multiple times always yields the same state.
  • Any message changing internal state managed by command messages passes through _parseCommands, i.e., there is no "side channel" that may cause state changes that would normally be overwritten by the commands received.

If I am mistaken about any of those assumptions, my PR may introduce subtile semantic errors and should not be merged as is.

Other Information

Since this is my first contribution to this lib, I want to kindly ask you carefully reviewing my changes and see if they fit your expectations.
Specifically, please see if my assumptions above hold. If they do, I am optimistic that this PR can be safely merged.

Status

  • PR is ready to be reviewed.
  • The functionality has been tested by the author.
  • Relevant unit tests has been added / updated.
  • Relevant documentation (code comments, system documentation) has been added / updated.

Overview

Implements an optimization to reduce CPU load from frequent, redundant ATEM command batches (notably TimeCommand-heavy traffic). Adds payload normalization to avoid buffer copies and a caching mechanism to skip redundant deserialization of command batch remainders when batches start with a TimeCommand.

Key Changes

Core Implementation (src/lib/atemSocket.ts)

  • Exports new type: ThreadedPayload = Buffer | Uint8Array | { type: 'Buffer'; data: number[] }.
  • Payload normalization:
    • Adds private _normalizePayload(payload: ThreadedPayload) to convert supported payload variants to a native Buffer view without making full copies; returns undefined for invalid shapes.
    • Applies normalization in both the socket and threaded-socket code paths before parsing.
  • TimeCommand remainder caching:
    • Adds private _lastTimeCommandRemainder: Buffer | undefined to cache the bytes following a leading TimeCommand.
    • _parseCommands() tracks the first command; if it is a TimeCommand and the remainder matches the cached remainder, it processes only the TimeCommand and skips the remainder (no deserialization/processing). If different, updates the cache. If the first command is not a TimeCommand, clears the cache.
    • Adds stricter length validation for commands (length < 8 || length > buffer.length).
  • Connection lifecycle:
    • Resets _lastTimeCommandRemainder on connect and disconnect.

Public API / Type Changes

  • ThreadedPayload is exported from the AtemSocket module.
  • Callback/constructor signatures used in tests/child implementations updated: onCommandsReceived now accepts ThreadedPayload instead of raw Buffer.

Tests (src/lib/tests/atemSocket.spec.ts)

  • Tests adapted to use ThreadedPayload and the normalization path.
  • New/updated tests cover:
    • TimeCommand remainder deduplication and cache-update/clear behavior.
    • Payload normalization for Uint8Array and { type: 'Buffer'; data: number[] }.
    • Invalid payload handling and related error paths.
  • Imports adjusted to explicitly import TimeCommand and ThreadedPayload.

Implications & Assumptions

  • Correctness depends on command processing being idempotent and all state-changing messages being routed through _parseCommands() (author-documented assumptions). Reviewers should validate these assumptions before merging.
  • Cache semantics: only applies when the first command is a TimeCommand; flushed whenever a batch starts with a different command or the remainder differs in content or length.

Performance Impact

  • Reduces memory allocations by creating Buffer views instead of full copies.
  • Avoids redundant deserialization/processing for repeated TimeCommand-led batches; author reports roughly a 50% CPU reduction in affected scenarios.

@coderabbitai
Copy link

coderabbitai bot commented Feb 23, 2026

Walkthrough

Exports a new ThreadedPayload union, normalizes incoming threaded payloads to Buffer, and updates command parsing to deduplicate TimeCommand remainders across packet boundaries. onCommandsReceived signature and tests were updated to accept ThreadedPayload. Connection lifecycle resets the TimeCommand remainder cache.

Changes

Cohort / File(s) Summary
Payload Type & Normalization
src/lib/atemSocket.ts
Adds exported `ThreadedPayload = Buffer
TimeCommand Remainder Deduplication
src/lib/atemSocket.ts
Adds private _lastTimeCommandRemainder state; _parseCommands tracks isFirstCommand and compares/caches the TimeCommand remainder to avoid duplicate processing across packet batches; cache cleared on connect/disconnect.
Parsing Robustness & Validation
src/lib/atemSocket.ts
Strengthens length checks (rejects length < 8 or length > buffer.length), handles deserialization failures with error/debug messages, and integrates payload normalization into parsing flow.
Public API & Tests
src/lib/atemSocket.ts, src/lib/__tests__/atemSocket.spec.ts
Changes onCommandsReceived signature from (payload: Buffer, packetId: number) to (payload: ThreadedPayload, packetId: number); updates test mocks/specs to import/use ThreadedPayload; adds tests for remainder deduplication, payload normalization variants, and invalid payload handling.

Sequence Diagram(s)

sequenceDiagram
participant Client as Client
participant Socket as AtemSocket
participant Normalizer as Normalizer
participant Parser as Parser
participant Cache as TimeCache

Client->>Socket: send payload (ThreadedPayload, packetId)
Socket->>Normalizer: _normalizePayload(payload)
Normalizer-->>Socket: Buffer or undefined
alt payload invalid
    Socket-->>Client: emit error / drop
else payload valid
    Socket->>Parser: _parseCommands(buffer, packetId)
    Parser->>Cache: check isFirstCommand & remainder
    alt first is TimeCommand and remainder == cached
        Cache-->>Parser: match -> consume first only, stop parsing
    else first is TimeCommand and remainder != cached
        Cache-->>Parser: cache remainder -> stop parsing
    else not TimeCommand
        Cache-->>Parser: clear cached remainder
    end
    Parser-->>Socket: parsed commands
    Socket-->>Client: deliver commands / callbacks
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I chewed the bytes from odd-shaped trays,
Turned threaded crumbs to buffers in neat arrays.
TimeCommand echoes now hop just once and then stop,
Cache cleared on connect — I gave parsing a hop. 🎩✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: enhance AtemSocket to handle caching' clearly and concisely describes the main feature addition of caching logic in the AtemSocket module, which is the primary change across both modified files.
Linked Issues check ✅ Passed The PR implements caching of serialized TimeCommand batches and payload normalization to reduce CPU usage, directly addressing issue #190's objective of reducing excessive CPU consumption from redundant command processing.
Out of Scope Changes check ✅ Passed All changes are focused on the caching mechanism and payload normalization related to reducing CPU load; type signature updates and test additions directly support the core objectives and fall within scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/atemSocket.ts`:
- Line 44: The cached buffer _lastTimeCommandRemainder must be cleared on
connection lifecycle changes to avoid stale-remainder matches; update the
AtemSocket.connect and AtemSocket.disconnect methods to reset
_lastTimeCommandRemainder = undefined (and likewise reset any other "remainder"
buffer fields you have, e.g., packet/command remainder variables) so no leftover
bytes from a previous session can be matched against a new device/session.
- Around line 215-233: The bug is that isFirstCommand stays true if command
deserialization fails or is unknown, causing a later command to be treated as
the "first" and wrongly apply the TimeCommand remainder logic; to fix, ensure
isFirstCommand is set to false for every command slot immediately after
attempting to read/deserialise a command (regardless of success), so move the
isFirstCommand = false assignment out of the TimeCommand-success branch and into
the per-command processing loop right after command identification/attempted
deserialization; update logic around TimeCommand, _lastTimeCommandRemainder,
parsedCommands and any early-break paths so the remainder cache is only applied
when isFirstCommand was true for that slot and then cleared/updated
consistently.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fd53b77 and 9fc0a3f.

📒 Files selected for processing (2)
  • src/lib/__tests__/atemSocket.spec.ts
  • src/lib/atemSocket.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/lib/atemSocket.ts (2)

56-77: connect() still does not reset _lastTimeCommandRemainder — stale cache via the restarted path.

The restarted event (line 176) calls this.connect() directly without going through disconnect(), so a stale remainder from the previous session can survive into the new connection and silently suppress real state-update commands on the first matching batch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/atemSocket.ts` around lines 56 - 77, The connect() method must clear
the stale command remainder so a leftover _lastTimeCommandRemainder from a
previous session doesn't suppress new commands; update connect() (the method
invoked by the restarted event) to reset/empty the _lastTimeCommandRemainder
before establishing a new socket (e.g., at the start of connect() or right after
setting this._address/this._port) so the restarted path behaves the same as
disconnect()+connect().

239-243: isFirstCommand still not reset for the unknown-command path.

isFirstCommand = false (line 240) is inside the if (cmdConstructor && …) branch, so if the first command is unrecognised (falls into the else at line 241), isFirstCommand remains true and the second command inherits first-command status, opening the TimeCommand-cache logic to misapplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/atemSocket.ts` around lines 239 - 243, The flag isFirstCommand is
only cleared inside the recognized-command branch, so if the very first incoming
command is unrecognized the flag remains true and mislabels the next command;
update the handler around isFirstCommand (the code that checks cmdConstructor
and emits 'debug' for unknown commands) to always set isFirstCommand = false
after processing any command path (either move the assignment after the if/else
or add the assignment inside the else branch) so the first-command state is
cleared regardless of whether the command was recognized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/atemSocket.ts`:
- Around line 151-158: Change the callback parameter type from Buffer to the
union type used at runtime (ThreadedPayload) where the IPC message handler is
declared so TypeScript matches reality; update the async handler signature (the
function that calls this._normalizePayload and this._parseCommands) to accept
payload: ThreadedPayload, and ensure ThreadedPayload is imported or referenced
in the file so the call to _normalizePayload(payload) and subsequent
this._parseCommands(normalizedPayload) type-check correctly.

---

Duplicate comments:
In `@src/lib/atemSocket.ts`:
- Around line 56-77: The connect() method must clear the stale command remainder
so a leftover _lastTimeCommandRemainder from a previous session doesn't suppress
new commands; update connect() (the method invoked by the restarted event) to
reset/empty the _lastTimeCommandRemainder before establishing a new socket
(e.g., at the start of connect() or right after setting
this._address/this._port) so the restarted path behaves the same as
disconnect()+connect().
- Around line 239-243: The flag isFirstCommand is only cleared inside the
recognized-command branch, so if the very first incoming command is unrecognized
the flag remains true and mislabels the next command; update the handler around
isFirstCommand (the code that checks cmdConstructor and emits 'debug' for
unknown commands) to always set isFirstCommand = false after processing any
command path (either move the assignment after the if/else or add the assignment
inside the else branch) so the first-command state is cleared regardless of
whether the command was recognized.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9fc0a3f and 1a02279.

📒 Files selected for processing (1)
  • src/lib/atemSocket.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/atemSocket.ts`:
- Around line 240-244: The first-command state isn't cleared when a command is
unrecognized: ensure isFirstCommand and the cached _lastTimeCommandRemainder are
reset regardless of whether a cmdConstructor exists or deserialization succeeds;
specifically, move or duplicate the assignment isFirstCommand = false (and set
this._lastTimeCommandRemainder = undefined) out of the successful-deserialize
branch so that the else branch that emits `Unknown command ${name} (${length}b)`
also clears both flags/caches, preventing stale TimeCommand remainder from
affecting subsequent commands/batches.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1a02279 and 9fa1007.

📒 Files selected for processing (1)
  • src/lib/atemSocket.ts

@milux
Copy link
Author

milux commented Feb 24, 2026

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 24, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/lib/atemSocket.ts (1)

217-234: ⚠️ Potential issue | 🟡 Minor

TimeCommand remainder-caching logic is sound, but _lastTimeCommandRemainder is not invalidated when the first command is unknown or fails to deserialize.

When the first command in a batch is unrecognized (line 240–241) or its deserialization throws (line 237–238), the isFirstCommand block (lines 217–234) is never entered, so _lastTimeCommandRemainder silently persists from a previous batch. A later batch whose first command is a TimeCommand could then match against that stale remainder and incorrectly skip processing.

In practice this is a narrow edge case (unknown first commands are unusual, and the byte comparison provides a safety net), but for correctness it would be cleaner to clear the cache whenever the first-command slot is consumed without entering the TimeCommand path.

Proposed fix — clear cache for unknown / failed first commands
 			} else {
 				this.emit('debug', `Unknown command ${name} (${length}b)`)
 			}

 			// Always clear the first command flag after processing the first command.
+			if (isFirstCommand) {
+				// First command was unknown or deserialization failed — invalidate the cache.
+				this._lastTimeCommandRemainder = undefined
+			}
 			isFirstCommand = false

This works because whenever the code does reach the isFirstCommand block inside the try (line 217), it either caches a new remainder or clears it explicitly, and then falls through here with isFirstCommand already consumed. Adding this guard catches only the paths that bypassed the inner block.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/atemSocket.ts` around lines 217 - 234, The _lastTimeCommandRemainder
cache can persist when the first-command slot is consumed by an unknown command
or when deserialization throws, so update the branches that consume the first
command but bypass the isFirstCommand TimeCommand logic to explicitly clear this
cache: inside the unknown-command branch (where the code currently handles
unrecognized commands) and inside the catch block that handles deserialization
errors, set this._lastTimeCommandRemainder = undefined so stale remainders
cannot later cause a TimeCommand batch to be skipped; refer to the
isFirstCommand flag, TimeCommand class check, and the _lastTimeCommandRemainder
field to locate the exact places to clear the cache.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/lib/atemSocket.ts`:
- Around line 217-234: The _lastTimeCommandRemainder cache can persist when the
first-command slot is consumed by an unknown command or when deserialization
throws, so update the branches that consume the first command but bypass the
isFirstCommand TimeCommand logic to explicitly clear this cache: inside the
unknown-command branch (where the code currently handles unrecognized commands)
and inside the catch block that handles deserialization errors, set
this._lastTimeCommandRemainder = undefined so stale remainders cannot later
cause a TimeCommand batch to be skipped; refer to the isFirstCommand flag,
TimeCommand class check, and the _lastTimeCommandRemainder field to locate the
exact places to clear the cache.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9fa1007 and 22515f7.

📒 Files selected for processing (1)
  • src/lib/atemSocket.ts

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.

Bug Report: High CPU load with 2 M/E Constellation HD

1 participant