From a3b4b5cc7de69a2bc3c806844a52e304f42890c5 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 11 Jun 2026 11:02:52 -0400 Subject: [PATCH] =?UTF-8?q?fix(api):=20NATS=20subscriptions=20moved=20to?= =?UTF-8?q?=20onApplicationBootstrap=20=E2=80=94=20they=20silently=20no-op?= =?UTF-8?q?ed=20before=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production bug caught live: provider onModuleInit order put bridge/ consumer subscription hooks BEFORE NatsService finished connecting, so every subscribe() hit the [OFFLINE] no-op path — the WS bridge has been dead-on-boot in every production build, and the new v2 consumer never saw a heartbeat (server_connections stayed empty under a live agent). onApplicationBootstrap is guaranteed to run after all module inits, including the awaited NATS connect. The new CI contract suite fails on exactly this class of bug. Co-Authored-By: Claude Fable 5 --- .../src/services/host-agent-consumer.service.ts | 8 +++++--- backend-nest/src/services/nats-bridge.service.ts | 11 ++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend-nest/src/services/host-agent-consumer.service.ts b/backend-nest/src/services/host-agent-consumer.service.ts index f6cf56e..c61b6b1 100644 --- a/backend-nest/src/services/host-agent-consumer.service.ts +++ b/backend-nest/src/services/host-agent-consumer.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { Interval } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -17,7 +17,7 @@ import { License } from '../entities/license.entity'; * staleness sweep marks hosts offline when heartbeats stop arriving. */ @Injectable() -export class HostAgentConsumerService implements OnModuleInit { +export class HostAgentConsumerService implements OnApplicationBootstrap { private readonly logger = new Logger(HostAgentConsumerService.name); /** licenseId -> cache expiry epoch-ms. Positive = exists, absent = unknown. */ @@ -39,7 +39,9 @@ export class HostAgentConsumerService implements OnModuleInit { private readonly licenseRepository: Repository, ) {} - onModuleInit() { + // Bootstrap, not module-init: subscriptions registered before NatsService + // finished connecting silently no-op (see NatsBridgeService note). + onApplicationBootstrap() { this.nats.subscribe('corrosion.*.host.heartbeat', (data, subject) => { const licenseId = subject.split('.')[1]; void this.onHeartbeat(licenseId).catch((err) => diff --git a/backend-nest/src/services/nats-bridge.service.ts b/backend-nest/src/services/nats-bridge.service.ts index c36a673..885198d 100644 --- a/backend-nest/src/services/nats-bridge.service.ts +++ b/backend-nest/src/services/nats-bridge.service.ts @@ -1,14 +1,19 @@ -import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { Injectable, OnApplicationBootstrap, Logger } from '@nestjs/common'; import { NatsService } from './nats.service'; @Injectable() -export class NatsBridgeService implements OnModuleInit { +export class NatsBridgeService implements OnApplicationBootstrap { private readonly logger = new Logger(NatsBridgeService.name); private listeners: Map void>> = new Map(); constructor(private nats: NatsService) {} - onModuleInit() { + // Subscriptions MUST happen in onApplicationBootstrap, not onModuleInit: + // provider onModuleInit order is not guaranteed, and these hooks once ran + // before NatsService connected — every subscribe() silently no-oped and the + // WS bridge was dead from boot. Bootstrap runs after ALL module inits + // (including the awaited NATS connect) complete. + onApplicationBootstrap() { this.nats.subscribe('corrosion.*.companion.heartbeat', (data, subject) => { const licenseId = subject.split('.')[1]; this.emit(licenseId, 'heartbeat', data);