Skip to content

Watch settings file external changes - #98

Merged
heavyrubberslave merged 17 commits into
mainfrom
feat/watch-settings-file-external-changes
Aug 2, 2026
Merged

heavyrubberslave merged 17 commits into
mainfrom
feat/watch-settings-file-external-changes

Conversation

@heavyrubberslave

@heavyrubberslave heavyrubberslave commented Jul 25, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features
    • Settings changes made outside the app are now detected and applied automatically without restarting.
    • Devices are re-announced when providers reconnect or join an active connection.
    • Serial-device discovery cancels outdated scans to prevent stale results.
  • Bug Fixes
    • Improved device recovery, shutdown handling, and duplicate disconnect prevention.
    • Invalid external settings changes are safely rejected while preserving the current configuration.
  • Tests
    • Added coverage for settings updates, reconnection, observer restarts, and serial disconnect behavior.

@heavyrubberslave heavyrubberslave added the patch Creates a new patch/bugfix release if merged label Jul 25, 2026
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@heavyrubberslave, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 442f88a3-1765-48f7-b11f-842f3bf2763c

📥 Commits

Reviewing files that changed from the base of the PR and between 18bc0bd and 2cf0640.

📒 Files selected for processing (2)
  • tests/unit/automation/scriptRuntime.spec.ts
  • tests/unit/device/testDevice.ts
📝 Walkthrough

Walkthrough

Changes

The application now watches settings files and reloads valid external changes. Device detection uses branded DetectionId values distinct from canonical DeviceId values. Device managers, observers, serial ports, and device classes now coordinate asynchronous lifecycle operations.

Settings and device lifecycle

Layer / File(s) Summary
Settings watcher lifecycle
package.json, src/settings/settingsManager.ts, src/serviceProvider/settingsServiceProvider.ts, src/app.ts, tests/unit/settings/settingsManager.spec.ts
Adds settings-file watching, validation, self-write suppression, and application lifecycle integration.
Typed detection and device management
src/device/deviceId.ts, src/device/deviceManager.ts, src/device/detectedDeviceOfferQueue.ts, src/device/protocol/*, src/device/transport/bleObserver.ts, src/device/serializedTypes.ts, tests/unit/device/*
Adds DetectionId, preserves detection metadata, updates factory and queue APIs, and separates detection identifiers from canonical device identifiers.
Shared observer and serial shutdown
src/device/transport/sharedObserver.ts, src/device/transport/serialPortObserver.ts, src/serial/synchronousSerialPort.ts, tests/unit/device/transport/*, tests/integration/devices/sharedSerialObserver.spec.ts
Adds catch-up announcements, latest-only serial discovery, cancellation handling, and asynchronous serial-port shutdown.
Device closure and logger ownership
src/device/device.ts, src/device/deviceState.ts, src/device/peripheralDevice.ts, src/device/protocol/*Device.ts, tests/unit/device/testDevice.ts, tests/unit/automation/scriptRuntime.spec.ts
Adds coordinated device closure and moves logger ownership to the base device class.
Runtime and tooling updates
docker/dev/node/Dockerfile, eslint.config.ts, src/util/latestOnlyTaskQueue.ts, package.json
Updates the Node.js image, promise linting, task-queue support, and production dependencies.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Application
  participant SettingsManager
  participant SettingsFile
  participant ObservableSettings
  Application->>SettingsManager: startWatching()
  SettingsFile-->>SettingsManager: external file change
  SettingsManager->>SettingsFile: read and validate content
  SettingsManager->>ObservableSettings: apply valid settings
  SettingsManager-->>Application: emit settingsChanged
Loading
sequenceDiagram
  participant DeviceProvider
  participant SharedObserver
  participant SerialPortObserver
  participant SynchronousSerialPort
  DeviceProvider->>SharedObserver: start()
  SharedObserver->>SerialPortObserver: onSubsequentStart()
  SerialPortObserver-->>DeviceProvider: re-announce managed devices
  DeviceProvider->>SharedObserver: stop()
  SerialPortObserver->>SynchronousSerialPort: close()
  SynchronousSerialPort-->>SerialPortObserver: resolve close promise
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the settings-file watcher added by the pull request, which is a significant change in the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/watch-settings-file-external-changes

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.

@heavyrubberslave
heavyrubberslave force-pushed the feat/watch-settings-file-external-changes branch from b821854 to 408e8f1 Compare July 25, 2026 07:30
Watches settings.json with chokidar so external edits (manual changes,
other tooling) trigger the same reload pipeline as changes made
through the API - device providers and devices get re-applied without
a restart.

Own writes from save()/replace() are recognized via a content
comparison against the last content we wrote, so they don't trigger a
redundant reload loop. Invalid JSON or schema-invalid content from an
external edit is logged and ignored, keeping the previously loaded
settings.
SerialPortObserver.onLastStop() never cleared managedDevices, so a
provider restarting the shared observer after a full stop (all users
gone) had discoverSerialDevices() treat every already-plugged port as
"already managed" and silently skip re-announcing it - devices that
disappeared when their source got disabled never came back on
re-enable.

onLastStop() now revokes every still-tracked device through
DeviceManager before clearing managedDevices, mirroring what
discoverSerialDevices() already does per-port when a device
disappears.
…es when joining an already-running observer

- SynchronousSerialPort.close() now calls the SerialPortStream's own
  close() (which releases the OS-level handle) instead of generic
  stream end()/destroy(), and returns a promise that resolves only
  once the port is actually released. Without this, a device source
  disabled then re-enabled could fail to reopen the same port path
  ('Port is locked').
- Fixed a re-entrancy bug this exposed: destroying the writer/reader
  after close() re-triggers their 'close' event, which looped back
  into the device's close callback a second time while the original
  close() was still in flight. handleClose() now recognizes a
  self-initiated close and skips re-invoking the callback.
- SharedObserver gained an onSubsequentStart() hook, invoked when a
  provider joins an observer that's already running (as opposed to
  the very first joiner, including concurrent first-time joins).
  SerialPortObserver implements it by re-announcing managed devices
  that aren't currently connected. Root cause: all serial protocol
  providers (SlvCtrl+, zc95, estim2b) share one SerialPortObserver
  instance, so disabling a single device source doesn't stop the
  observer while other sources keep it running - the observer never
  reruns discovery, so a re-enabled provider never got a chance at
  devices the observer already knew about.

Tests: unit coverage for SharedObserver's subsequent-start hook and
SerialPortObserver's catch-up announce; new integration test file
covering the reconnect scenario with all three serial providers
sharing an observer, matching the real multi-source configuration
that exposed the bug.
… re-enabled

DeviceManager.applySettingsChange() closed a connected device whose known
device just got disabled, but - unlike offerDevice()'s reject-at-connect-time
path - never registered a pending retry for it. So re-enabling that specific
known device (without touching its device source) never re-announced it: the
device's provider never stops/restarts in this scenario, so nothing else
would trigger a retry either.

DeviceManager now retains each connected device's original
DeviceDetectionInfo (connectedDeviceInfos), so a device closed for this
reason can be registered in detectedDisabledDevices for retry the same way
an offerDevice()-rejected one already is.

Ported forward from the now-superseded claiming-arbitration branch, adapted
onto the DetectedDeviceOfferQueue-based design merged in #99.
@heavyrubberslave
heavyrubberslave force-pushed the feat/watch-settings-file-external-changes branch from af47618 to 3f079be Compare August 2, 2026 06:24
DeviceManager.connectedDevices was the only collection field taking
external input via the constructor - every other internal collection
(detectedDisabledDevices, settingsChangeQueue, the offer queue) already
owns its state internally. That inconsistency also let a caller seed
connectedDevices directly (several existing unit tests did), bypassing
offerDevice()/registerDevice() and their DeviceDetectionInfo bookkeeping
entirely - so the two could start out-of-sync from construction,
independent of any internal code path.

Removed the constructor parameter entirely (no production caller ever
needed the same Map instance back - deviceServiceProvider.ts always
passed a fresh empty one, and no test read the injected map after
construction either), and merged the previously separate
connectedDeviceInfos map into connectedDevices as a single map of
{ device, deviceDetectionInfo } entries. registerDevice() is now the
only way to populate it, which structurally guarantees a connected
device always has its detection info available - removing the
'if (undefined !== deviceDetectionInfo)' guard applySettingsChange()
needed previously.

Updated the two unit tests that seeded connectedDevices directly to go
through the connectDevice() test helper instead, and dropped the
now-unused constructor argument at all other call sites.

Ported forward from the now-superseded claiming-arbitration branch
(af47618), adapted onto the DetectedDeviceOfferQueue-based design
merged in #99.
A device's preliminary/raw id at the point it's detected can differ from
its final, canonical DeviceId - some protocols only learn the real one
during a handshake (e.g. zc95's firmware serial number). Both were
previously typed as plain DeviceId, so nothing stopped one from being
passed where the other was meant - which had already hidden a real bug
(see below).

- deviceId.ts: add a DetectionId branded type alongside DeviceId, plus
  explicit DeviceId.fromDetectionId()/DetectionId.fromDeviceId() escape
  hatches for the places that deliberately treat one as the other.
- deviceManager.ts: DeviceDetectionInfo.detectionId is now a DetectionId;
  detectedDisabledDevices is keyed by DetectionId (it always was, just
  mistyped as DeviceId); connectedDevices is keyed by DeviceId and
  getConnectedDevice() takes DeviceId, matching its only production
  caller (ConnectedDeviceRepository) which already required it.
- fix: announceDetectedDevice()/offerDevice()'s already-connected check
  used to look up connectedDevices (keyed by canonical id) directly by
  detection id, which only worked by coincidence when the two are equal.
  For a device whose canonical id is only learned during a handshake
  (e.g. zc95 with fw >=2.0, whose canonical id comes from its firmware
  serial number rather than the serial port's) this silently never
  matched, so an already-connected device could be re-announced. Replaced
  with isDetectedDeviceAlreadyConnected(), which scans connected devices
  by their stored detection id instead of guessing a canonical id.
- serialPortObserver.ts: onSubsequentStart() had the exact same bug via
  getConnectedDevice(). Since announceDetectedDevice() now correctly
  no-ops for an already-connected device on its own, the check here was
  redundant - removed entirely rather than fixed twice.
- detectedDeviceOfferQueue.ts: keyed and parameterized by DetectionId.
- serialPortObserver.ts, bleObserver.ts, virtualDeviceProvider.ts,
  buttplugIoWebsocketDeviceProvider.ts: construct detectionId via
  DetectionId.create()/fromDeviceId() instead of DeviceId.create().
- serializedTypes.ts: SerializedDevice.deviceId is now DeviceId instead
  of a plain string, matching WebSocket/HTTP payloads that always carried
  the canonical id.

Ported forward from the now-superseded claiming-arbitration branch
(484f3dd), separated from the parts of that commit already superseded by
the DetectedDeviceOfferQueue-based design merged in #99.
…as the canonical device id

Providers for protocols with no way to derive a better canonical id
(estim2b, slvCtrlPlus, airotic, buttplugIo) were casting their detection
id to DeviceId themselves via DeviceId.fromDetectionId() before calling
their factory's create() - a decision providers have no real basis to
make, since identity derivation is protocol-specific knowledge that
belongs with whatever constructs the device.

zc95 already got this right: its factory takes the detection id as a
fallback and prefers a firmware-reported serial number when available
(fw >=2.0). Applied the same shape to the other four factories, whose
create() now takes a DetectionId instead of a pre-cast DeviceId and
converts it internally - currently always falling back to the detection
id itself, since none of these protocols' handshakes carry a better
per-device identifier today. If one of them ever gains a real
handshake-derived id, only that factory needs to change.

virtualDeviceFactory is unaffected - it already takes a KnownDevice
directly, with no detection/canonical id split to begin with.
SettingsManager.load()/startWatching() were called as a side effect
inside SettingsServiceProvider's DI factory, the only service in the
codebase that self-starts this way - every other stateful service
(health.metricsCollector, device.provider.manager, automation.scriptRuntime)
is constructed inertly by its provider and explicitly started/stopped
from createApp()/shutdown().

That made settings watching start implicitly, whenever something first
happened to call container.get('settings.manager') (currently
configureWebsocket(), incidentally) - and created an asymmetry with
stopWatching(), which was already called explicitly in shutdown().

Both load() and startWatching() are idempotent (no-op if already
loaded/watching), so moving them to explicit calls in createApp(),
before anything that depends on settings runs, is behavior-preserving.
@heavyrubberslave
heavyrubberslave marked this pull request as ready for review August 2, 2026 10:49

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@package.json`:
- Line 13: Update the Docker Compose development service’s Node image to a Node
20-or-newer image, ensuring the local runtime supports chokidar 5 and aligns
with the package and CI Node requirements. Locate the compose service
configuration rather than changing the chokidar dependency declaration.

In `@src/app.ts`:
- Around line 269-270: Guard the settings manager cleanup call in shutdown() by
wrapping await container.get('settings.manager').stopWatching() in try/catch and
reporting failures through the existing logError pattern. Ensure an exception
does not abort subsequent shutdown steps such as stopProviders(), metrics
shutdown, websocket closure, or server closure.

In `@src/device/transport/serialPortObserver.ts`:
- Around line 63-77: Prevent stale serial discovery from repopulating
managedDevices after shutdown by adding an active/generation cancellation guard
in the observer class. Set or advance the guard in onLastStop() before revoking
devices, and have discoverSerialDevices() validate the captured guard after
awaiting SerialPort.list() and before writing or announcing entries; preserve
normal discovery and restart behavior by invalidating only the canceled
discovery generation.

In `@src/settings/settingsManager.ts`:
- Around line 62-72: Update load() to wrap JSON.parse(fileContent) in a
try/catch, logging parse failures with logError using the same invalid-settings
context as the existing transformPlainToSettings handler, then rethrow the
original error. Keep the subsequent transformation and lastWrittenContent flow
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 61956f8b-cd9b-4a74-8ef6-378e377332d1

📥 Commits

Reviewing files that changed from the base of the PR and between 1c22131 and 6e2dc2d.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (28)
  • package.json
  • src/app.ts
  • src/device/detectedDeviceOfferQueue.ts
  • src/device/deviceId.ts
  • src/device/deviceManager.ts
  • src/device/protocol/airotic/airoticDeviceFactory.ts
  • src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts
  • src/device/protocol/estim2b/estim2bDeviceFactory.ts
  • src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/zc95/zc95DeviceFactory.ts
  • src/device/serializedTypes.ts
  • src/device/transport/bleObserver.ts
  • src/device/transport/serialPortObserver.ts
  • src/device/transport/sharedObserver.ts
  • src/serial/synchronousSerialPort.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • src/serviceProvider/settingsServiceProvider.ts
  • src/settings/settingsManager.ts
  • tests/integration/devices/sharedSerialObserver.spec.ts
  • tests/unit/device/detectedDeviceOfferQueue.spec.ts
  • tests/unit/device/deviceManager.spec.ts
  • tests/unit/device/protocol/zc95/zc95DeviceFactory.spec.ts
  • tests/unit/device/provider/deviceProvider.spec.ts
  • tests/unit/device/transport/serialPortObserver.spec.ts
  • tests/unit/device/transport/sharedObserver.spec.ts
  • tests/unit/settings/settingsManager.spec.ts
💤 Files with no reviewable changes (1)
  • src/serviceProvider/deviceServiceProvider.ts

Comment thread package.json
Comment thread src/app.ts Outdated
Comment thread src/device/transport/serialPortObserver.ts
Comment thread src/settings/settingsManager.ts
…rial devices after stop

SerialPortObserver.onLastStop() revoked and cleared managedDevices without
waiting for or cancelling a rescan that was still awaiting SerialPort.list()
(scheduled by the debounced USB event handler). Once that stale call
resolved, it saw managedDevices as empty and treated every still-plugged-in
port as newly discovered, writing them back and re-announcing them - even
though the observer had already fully stopped. A later restart would then
skip re-announcing those ports, since discoverSerialDevices() saw them as
already managed.

- util/latestOnlyTaskQueue.ts: exported (was unused/untracked), and given a
  cancel() passthrough to SequentialTaskQueue.cancel() - needed for the
  stop-and-stay-stopped case, where nothing will call run() again to
  supersede the stale task.
- serialPortObserver.ts: the debounced rescan now runs through a
  discoveryQueue (LatestOnlyTaskQueue), so a later rescan trigger always
  supersedes a still-running one instead of racing it, and onLastStop()
  cancels it outright if nothing supersedes it first. discoverSerialDevices()
  takes an optional CancellationToken and bails out - without touching
  managedDevices - if it's been cancelled by the time SerialPort.list()
  resolves.
- onFirstStart()'s own (non-debounced) discovery call intentionally bypasses
  the queue: SharedObserver keeps activeUsers at 0 until it resolves, so
  onLastStop() can't run concurrently with it.
- bumped @timesplinter/sequential-task-queue to 1.4.0, whose
  CancellablePromiseLike now extends Promise instead of PromiseLike, so the
  debounced rescan's error handling can use .catch() directly instead of
  wrapping in Promise.resolve() first.

Found by CodeRabbit on PR #98.
SynchronousSerialPort.close()/handleClose() only called queue.cancel(),
which clears pending/in-flight tasks but leaves the queue open for new
ones. In close(), that left a real window: after cancel() but before
writer.close()'s callback actually destroys the streams, a racing
write()/writeAndExpect() call would still be queued and reach the
still-open writer. close(true) sets the queue's closed flag synchronously
in the same call, so a racing write() now throws immediately instead.

Also enabled checkThenables on @typescript-eslint/no-floating-promises,
since it was previously never checking values typed as the (structural)
PromiseLike our task queue used to return instead of a real Promise -
now that the queue returns real Promises (see previous commit's dependency
bump), plain 'warn' would have started flagging the queue.cancel()/close()
calls above. Fixed the resulting warnings with void, and dropped the
now-unnecessary Promise.resolve() wrap in DetectedDeviceOfferQueue.offer(),
which existed for the same PromiseLike-vs-Promise reason.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docker/dev/node/Dockerfile`:
- Line 1: Update the Dockerfile to ensure the container runs as a non-root user:
make /app writable for the intended user, then add a USER instruction switching
away from root while preserving the existing Node image setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9344238c-938e-444a-ae7a-586d08c34af1

📥 Commits

Reviewing files that changed from the base of the PR and between 6e2dc2d and 9000f44.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (10)
  • docker/dev/node/Dockerfile
  • eslint.config.ts
  • package.json
  • src/app.ts
  • src/device/detectedDeviceOfferQueue.ts
  • src/device/transport/serialPortObserver.ts
  • src/serial/synchronousSerialPort.ts
  • src/settings/settingsManager.ts
  • src/util/latestOnlyTaskQueue.ts
  • tests/unit/device/transport/serialPortObserver.spec.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • package.json
  • src/app.ts
  • src/device/detectedDeviceOfferQueue.ts
  • src/settings/settingsManager.ts

Comment thread docker/dev/node/Dockerfile
…n any close

SynchronousSerialPort.onClose() registered an independent listener/closure
per call, sharing one 'this.closed' flag - so when called more than once on
the same instance (PeripheralDevice and MessageResponseHandler both do),
whichever registered first would flip that flag before the next one's own
check ran, and only the first-registered subscriber's callback ever actually
fired on a real disconnect. For zc95 devices (the only ones combining both),
this meant PeripheralDevice's own onClose callback - the one that sets
device state to closed and emits deviceDisconnected, which DeviceManager
depends on for cleanup - never fired on a physical disconnect.

- writer/reader now share a single 'close' listener registered once in the
  constructor; onClose() just appends to a closeSubscribers list, notified
  unconditionally whenever the port closes, for any reason - not just
  physical disconnects. That makes the class's own contract simple and
  self-contained ('closed' means closed, regardless of cause) instead of
  encoding assumptions about what a caller does with the notification.
- close()/doClose() now also destroy() both streams even if only one of
  them fired 'close' (e.g. a parser-level error killing the reader while the
  physical port is still fine) - previously only the stream that closed
  itself got cleaned up, the other was neither released nor negotiated
  closed.
- close() still negotiates a real writer.close() before destroying if the
  writer is still open, since destroy() alone never releases the OS-level
  port handle (SerialPortStream has no _destroy() override - only its own
  close() calls the binding's close()).

Notifying subscribers on every close (not just disconnects) exposed a
pre-existing re-entrancy bug in Device.close(): its only guard
(state === closed) is set in a finally block, so a second call arriving
while the first is still in-flight wasn't blocked, and PeripheralDevice's
own onClose callback (which calls this.close()) would run again from
inside SynchronousSerialPort's own close chain, causing deviceDisconnected
to fire twice - or, once Device.close() was given a proper closePromise
memoization guard, deadlock entirely, since the re-entrant call would await
the very promise whose completion it was blocking.

- Device.close() now memoizes its own closePromise, same pattern as
  SynchronousSerialPort, so concurrent/re-entrant calls all resolve
  together once actually done instead of re-running doClose().
- Added DeviceState.closing, set for the duration of close(); refresh()
  and DeviceManager's refresh-interval skip both treat it like closed.
- PeripheralDevice's onClose callback no longer awaits its own close()
  call - it's dispatched fire-and-forget, since awaiting it can mean
  awaiting the very close chain that's currently invoking it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/serial/synchronousSerialPort.ts`:
- Around line 28-30: Prevent unhandled rejections from fire-and-forget close
operations: update handleStreamClose in src/serial/synchronousSerialPort.ts
(lines 28-30) to attach a catch handler using the existing logError helper and
this.logger, while preserving the non-awaited close to avoid the circular wait;
update the transport.onClose callback in src/device/peripheralDevice.ts (lines
38-40) to attach a catch handler that logs or otherwise surfaces close errors
instead of discarding the rejection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 82f8eaa4-f747-45a9-97de-e9402915d0c6

📥 Commits

Reviewing files that changed from the base of the PR and between 9000f44 and 1df12ca.

📒 Files selected for processing (5)
  • src/device/device.ts
  • src/device/deviceManager.ts
  • src/device/deviceState.ts
  • src/device/peripheralDevice.ts
  • src/serial/synchronousSerialPort.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/device/deviceManager.ts

Comment thread src/serial/synchronousSerialPort.ts
…viceSimulator

The simulator's write() override schedules the device's response via
setImmediate(), a later macrotask. If the port gets closed (a disconnect
racing the response) before that callback fires, MockPortBinding.emitData()
throws 'Port must be open to pretend to receive data' as an uncaught
exception - the source of the intermittent 'Port must be open...' flake
seen across integration test runs. Guard the deferred call with an isOpen
check; if the port's already closed, nothing is listening for the response
anymore anyway.
…base Device

CodeRabbit (PR #98): SynchronousSerialPort.handleStreamClose() and
PeripheralDevice's onClose callback both use 'void this.close()' -
deliberately not awaited, to avoid a circular wait (each runs from within
the very close chain that awaiting would depend on). But a bare void
discards the promise entirely: any eventual rejection becomes an unhandled
promise rejection, which terminates the Node process by default. Neither
currently rejects in practice, but that's incidental to the code as
written today, not something actually enforced - not worth staking process
uptime on.

Fixed both with .catch(), which doesn't reintroduce the circular-wait
problem (it doesn't await anything, same as void) but does give any
eventual rejection somewhere to go.

Device had no Logger to log to, so this also plumbs one into the base
class:

- Device gets a new required logger: Logger constructor param, and scopes
  it itself via logger.child({ name: new.target.name }) - new.target
  (not this.constructor, which isn't available until every constructor in
  the chain has run) is the actual concrete class new was called on, e.g.
  AiroticDevice - before storing it as a protected readonly logger field.
- Every subclass already received logger as its own last constructor
  param; some stored it locally raw (SlvCtrlPlusDevice, EStim2bDevice,
  VirtualDevice), some additionally scoped it themselves first
  (Zc95Device, BleDevice, ButtplugIoDevice). All of them now just forward
  the raw logger straight to super() and rely on the inherited, uniformly
  self-scoping field instead of duplicating the scoping decision
  themselves. Zc95Device's scoping used to be more specific than just the
  class name (it included the transport's device identifier, so multiple
  connected zc95 devices had distinguishable log lines) - traded that
  away deliberately for a single, uniform mechanism.
- No public constructor signature changed for any concrete device class,
  since logger was already their last param; only Device's and
  PeripheralDevice's own signatures grew it, rippling through each
  subclass's super() call.
- Test-only Device subclasses (TestDevice, StubDevice) pass a mock<Logger>()
  internally rather than taking it as a new param, so none of their many
  existing call sites needed updating.

The bleDeviceTransport.ts console.error → logError (same CodeRabbit thread,
different file - BleUartDeviceTransport isn't a Device subclass and has no
logger of its own) is left for a separate follow-up.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unit/automation/scriptRuntime.spec.ts`:
- Line 31: Configure Logger.child() on the mock<Logger>() fixtures before
constructing both Device instances: update StubDevice in
tests/unit/automation/scriptRuntime.spec.ts:31-31 and TestDevice in
tests/unit/device/testDevice.ts:16-16 so child() returns a Logger mock,
preserving valid logger initialization in each fixture.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 633c194c-2d70-49db-8df6-a920221d2e60

📥 Commits

Reviewing files that changed from the base of the PR and between 1df12ca and 18bc0bd.

📒 Files selected for processing (12)
  • src/device/bleDevice.ts
  • src/device/device.ts
  • src/device/peripheralDevice.ts
  • src/device/protocol/buttplugIo/buttplugIoDevice.ts
  • src/device/protocol/estim2b/estim2bDevice.ts
  • src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts
  • src/device/protocol/virtual/virtualDevice.ts
  • src/device/protocol/zc95/zc95Device.ts
  • src/serial/synchronousSerialPort.ts
  • tests/integration/helpers/slvCtrlPlusDeviceSimulator.ts
  • tests/unit/automation/scriptRuntime.spec.ts
  • tests/unit/device/testDevice.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/device/device.ts

Comment thread tests/unit/automation/scriptRuntime.spec.ts Outdated
CodeRabbit (PR #98): Device's constructor calls logger.child(...) to build
its own scoped logger. TestDevice and StubDevice both passed a bare
mock<Logger>() with child() left unconfigured, which vitest-mock-extended
returns undefined from by default - so this.logger ended up undefined for
every device built through either fixture, despite its declared type being
non-nullable. Doesn't break anything today since nothing in Device's base
methods or either fixture currently calls a method on it, but any future
logging call added to Device would immediately fail every test using these
fixtures with a confusing 'Cannot read properties of undefined' instead of
pointing at the real cause.

Fixed by configuring child() to return a mock logger, matching the pattern
already used for a different Logger instance elsewhere in
scriptRuntime.spec.ts.
@heavyrubberslave
heavyrubberslave merged commit 8e2b062 into main Aug 2, 2026
7 checks passed
@heavyrubberslave
heavyrubberslave deleted the feat/watch-settings-file-external-changes branch August 2, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patch Creates a new patch/bugfix release if merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant