Skip to content

Bound every startup wait, listener and queue, with regression tests - #110

Open
tunarabuuu wants to merge 3 commits into
anszom:masterfrom
tunarabuuu:bounded-lifecycle-hardening
Open

Bound every startup wait, listener and queue, with regression tests#110
tunarabuuu wants to merge 3 commits into
anszom:masterfrom
tunarabuuu:bounded-lifecycle-hardening

Conversation

@tunarabuuu

Copy link
Copy Markdown
Contributor

Hi! I've been running rethink as a Home Assistant add-on for a while — it's a fantastic project, thank you. While reviewing ownership and cancellation across the bridge and cloud paths I found a set of waits, listeners and queues that nothing owned, and this PR fixes them. Every fix comes with a regression test; the suite grows from 251 to 345, all passing.

ThinQ1 framing and connection

  • A four-byte negative length made the length-prefixed frame parser loop forever without consuming anything, and an oversized positive length threw out through the socket event handler. Both are rejected as framing errors now (util/length_prefixed_frame.ts).
  • ThinQ1 bridge startup settles exactly once instead of surfacing an unhandled rejection, and destroy() cancels the HTTP request, TLS handshake, socket, agent and heartbeat wherever it arrives (bridge/thinq1connection.ts).
  • The RTI TLS connection verifies the peer certificate by default. If someone depends on connecting to an endpoint with an unverifiable chain, this is the change to look at — happy to gate it behind an option instead if you prefer.
  • The ThinQ1 HTTP client and rethink-setup have explicit deadlines where waits were previously indefinite, and an OpenSSL failure is surfaced instead of being reported as success.

ThinQ2 bridge

  • mqtt.js does not await its listeners, so a failed subscribe or publish inside the async 'connect' handler became an unhandled rejection. The failure now tears the connection down through its own error path, and destroy() settles any operation whose completion callback will never fire.

Home Assistant MQTT

  • MQTT.js's QoS 0 queue grew one entry per publish for as long as the broker was away — repeated publishes while HA's broker is offline grow memory without bound. queueQoSZero is now off; retained state and offline publishes are held per topic instead (latest value wins), capped at 2048 topics each, and replayed on reconnect. Retained values are also reasserted after a broker restart, so a broker that lost its retained set recovers.

Management

  • Device-monitor WebSockets detach both device listeners on close, so a closed monitor no longer keeps receiving (and leaking) device events.

OAuth and tools

  • The OAuth refresh path requires both tokens to be present before accepting a response.
  • subprocess() bounds stdout/stderr capture and enforces a timeout.
  • The MCP server's cloud feed is generation-guarded so a stop during a slow connect cannot leak the late client.

One driver fix from the same review

  • WIN_056905_WW: turning the mode to off wrote a property the climate entity does not read; it now writes climate-power and stops the frame.

Verification

  • npm test345 passed, 0 failed (was 251)
  • tsc -p tsconfig.build.json --noEmit — clean
  • prettier --check on every changed file — clean

Happy to split this into smaller PRs if that's easier to review — the commits are independent per subsystem, so say the word and I'll break it up.

🤖 Generated with Claude Code

@maciejsszmigiero

Copy link
Copy Markdown
Collaborator

You definitely should split this huge commit (2478 lines added) into smaller ones, one per logical change.

@tunarabuuu
tunarabuuu force-pushed the bounded-lifecycle-hardening branch from 15ea978 to 52e071d Compare August 2, 2026 12:04
@tunarabuuu

Copy link
Copy Markdown
Contributor Author

Done — split into 13 commits, one per logical change (frame parser, subprocess bounds, oauth2, thinqApi, thinq1 connection/http, setup deadlines, thinq2 connect handler, HA MQTT queue, management WebSockets, mcp-server, WIN driver fix), each with its regression test alongside. Every intermediate commit type-checks on its own, and the final tree is byte-identical to what was here before — 345 tests passing.

@anszom anszom left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for the PR. I've merged/cherry-picked some commits already (you may need to remove the duplicated commits from your branch now).

See my comments for the remaining ones.

Comment thread bridge/thinqApi.ts Outdated
Comment on lines +104 to +125
class RouteFetchTimeoutError extends Error {}

export async function fetchRoute(
env: Environment,
request: typeof apiFetch = apiFetch,
timeoutMs = 5000,
): Promise<RouteResponse> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await request<RouteResponse>(`${IOT_BASE_URL}/route`, {
headers: { 'x-country-code': env.countryCode, 'x-service-phase': 'OP', accept: 'application/json' },
signal: controller.signal,
})
} catch (error) {
if (!controller.signal.aborted) throw error
throw new RouteFetchTimeoutError(`route fetch failed on ${IOT_BASE_URL}`)
} finally {
clearTimeout(timer)
}
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This change makes sense, but the commit message "thinqApi: surface OpenSSL failures instead of reporting success" is plainly wrong.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

On second look, this isn't good. The AbortController is passed to apiFetch, which runs its own retry loop. If one request fails, the retry loop will continue, adding extra delays (but not running more requests because of the timeout).

The modified version adds 20 more lines and trades one bug for another :)

Comment on lines -97 to -98

// don't forward upstream Start & Stop to the actual device

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I don't think you meant to remove these comments, they remain valid after the change.

constructor(device: Thinq1Device, options: { reconnectPeriod?: number } = {}) {
private readonly options: Thinq1ConnectionOptions
private readonly abortController = new AbortController()
private agent?: HTTPS.Agent

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Since we're already refactoring the code, it would be better to shift the agent to an upper layer so that its connection pool can be reused across multiple Connection objects.

} finally {
clearTimeout(timeout)
agent.destroy()
if (this.agent === agent) this.agent = undefined

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

What is the purpose of this conditional?

(never mind, it will be irrelevant when the agent's ownership is moved elsewhere)

Comment thread tests/bridge/thinq1connection.test.ts Outdated
Comment on lines +160 to +183
const directory = mkdtempSync(join(tmpdir(), 'rethink-thinq1-tls-'))
t.after(() => rmSync(directory, { recursive: true }))
const keyFile = join(directory, 'server.key')
const certFile = join(directory, 'server.cert')
execFileSync(
'openssl',
[
'req',
'-x509',
'-newkey',
'rsa:2048',
'-keyout',
keyFile,
'-out',
certFile,
'-days',
'1',
'-nodes',
'-subj',
'/CN=localhost',
],
{ stdio: 'ignore' },
)
const server = createServer({ key: readFileSync(keyFile), cert: readFileSync(certFile) })

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

paste a hardcoded pem key+cert here instead of all the scaffolding

Comment thread rethink-setup.ts Outdated
import * as tls from 'node:tls'
import jsonSplitter from './util/json_splitter'
import * as mtosp from './util/mtosp'
import { resolve } from 'node:path'

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Sorry but I'm gonna pass on this one. The provisioning process is a bit sensitive in itself - and difficult to test, since its effectiveness can only be gauged with an actual device. The changes you propose don't seem to address any real-world concerns, and - by necessity - introduce a regression risk.

Comment thread bridge/thinq2connection.ts Outdated
this.mqtt.on('close', () => this.emit('close'))
this.mqtt.on('error', (err) => this.emit('error', err))
private reportError(error: Error) {
if (this.listenerCount('error') > 0) this.emit('error', error)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This changes the semantics, any mqtt errors will not be forwarded to the default error handler (previously they were). Is this intentional?

Comment thread cloud/homeassistant.ts Outdated
private remember(cache: Map<string, Publish>, topic: string, publish: Publish) {
cache.delete(topic)
cache.set(topic, publish)
if (cache.size > MAX_REPLAY_TOPICS) cache.delete(cache.keys().next().value!)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I don't see how silently dropping cache entries is a good solution here. We're not building a rocket, if the memory consumption grows unbounded in pathological cases, let it :)

Comment thread cloud/homeassistant.ts Outdated
Comment thread cloud/devices/WIN_056905_WW.ts Outdated
this.setProperty('climate-power', 'OFF')
return null
}
this.raw_clip_state[0x1f7] = 1

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

this line isn't present in RAC_056905_WW. I'm guessing that either it's missing there as well or it's superfluous here.

@tunarabuuu
tunarabuuu force-pushed the bounded-lifecycle-hardening branch from 52e071d to 160c899 Compare August 2, 2026 15:14
@tunarabuuu

Copy link
Copy Markdown
Contributor Author

Thanks for merging those and for the detailed review — really appreciate it. I've rebased onto master (the six merged commits are gone) and addressed the rest:

Dropped

  • rethink-setup / rethink-setup-xml — understood, pulled both. You're right that provisioning can only be validated against a real device and the changes didn't earn the regression risk.
  • WIN_056905_WW — dropped from this PR. It's a driver quirk unrelated to the lifecycle theme here, and (as you spotted) this.raw_clip_state[0x1f7] = 1 isn't in RAC; it replaced an old setProperty('power','ON') in that branch. I'd rather re-verify it against the actual unit and send it separately than guess here.

Fixed

  • thinqApi — you're right, the message was plainly wrong. Reworded to what it does: the /route fetch used Promise.race against a timer, so the losing request kept running; it now aborts via AbortController. Nothing about OpenSSL.
  • thinq1/http — reworded too; that commit bounds the device-metadata store (Map with per-entry TTL + max size + id/type/model validation, and an XML body cap), which the old message didn't describe.
  • thinq1connection — restored the protocol comments (DevInfo/CmdWId/ACK/status), they were valid and I shouldn't have dropped them.
  • thinq2connection:133 — good catch, that was an unintended semantic change. Reverted to forwarding every error to the error event exactly as before; the only new behaviour is that a rejected async startup is now caught and emitted the same way instead of becoming an unhandled rejection.
  • homeassistant — dropped the cache cap entirely per your call (unbounded in pathological cases is fine), and rephrased the comment + commit message so they don't need guessing.
  • test:183 — replaced the openssl scaffolding with a hardcoded throwaway key+cert.

One I'd like to defer

  • thinq1connection agent → upper layer: agreed that pooling the agent across Connections is the better shape, but moving its ownership out is a bigger refactor than this bugfix, and the conditional you flagged at :90 only exists to serve the current per-connection agent. I'd prefer to land the cancellation fix as-is and do the agent-pooling move as a focused follow-up — happy to open it right after this if you're on board.

Now 5 commits, each type-checks on its own, 338 tests passing.

@anszom anszom left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you again for the effort. This resolves some lurking issues that I didn't consider.

This said, please understand that reviewing the changes takes time. And any additional code introduces an ongoing maintenance cost.

Writing code is cheap (especially with today's tools), but please consider the maintenance trade-offs :) Sometimes simpler is better, and non-ideal is acceptable :)

Comment thread bridge/thinqApi.ts Outdated
Comment on lines +104 to +125
class RouteFetchTimeoutError extends Error {}

export async function fetchRoute(
env: Environment,
request: typeof apiFetch = apiFetch,
timeoutMs = 5000,
): Promise<RouteResponse> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await request<RouteResponse>(`${IOT_BASE_URL}/route`, {
headers: { 'x-country-code': env.countryCode, 'x-service-phase': 'OP', accept: 'application/json' },
signal: controller.signal,
})
} catch (error) {
if (!controller.signal.aborted) throw error
throw new RouteFetchTimeoutError(`route fetch failed on ${IOT_BASE_URL}`)
} finally {
clearTimeout(timer)
}
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

On second look, this isn't good. The AbortController is passed to apiFetch, which runs its own retry loop. If one request fails, the retry loop will continue, adding extra delays (but not running more requests because of the timeout).

The modified version adds 20 more lines and trades one bug for another :)

Comment thread cloud/thinq1/http.ts Outdated
metadata: Metadata
expiresAt: number
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I would argue that expiring the entries introduces unnecessary complexity. Rethink is not supposed to be used in truly untrusted environments. Under normal circumstances, the map will only hold your actual devices. Under pathological circumstances... who cares. But I do care about having more code to maintain.

Switching from Record to Map protects against prototype pollution which is a valid point (although not mentioned in the commit message).

Comment thread cloud/thinq1/http.ts
Comment on lines +46 to +70
let stopped = false

req.on('data', (data: Buffer) => {
if (stopped) return
length += data.length
if (length > MAX_XML_BODY_LENGTH) {
stopped = true
buffers.length = 0
res.status(413).end()
return
}
buffers.push(data)
})

req.on('end', () => {
if (!error) {
req.body = new XMLParser().parse(Buffer.concat(buffers))
if (stopped) return
stopped = true
try {
const xml = Buffer.concat(buffers).toString('utf-8')
buffers.length = 0
if (XMLValidator.validate(xml) !== true) return res.status(400).end()
req.body = new XMLParser().parse(xml)
next()
} catch {
res.status(400).end()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This is useful, protecting us against invalid XML input, but it's not mentioned in the commit message.

Comment thread cloud/thinq1/http.ts Outdated
Comment on lines +94 to +101
if (!deviceId || !DEVICE_ID_PATTERN.test(deviceId)) return res.status(400).end()

if (modelName && deviceType)
deviceMeta[deviceId] = {
deviceType,
modelId: modelName,
modelName,
}
if (
typeof modelName !== 'string' ||
!MODEL_NAME_PATTERN.test(modelName) ||
!deviceType ||
!DEVICE_TYPE_PATTERN.test(deviceType)
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

What is the purpose of all this pattern matching? What are we protecting against?

Comment thread cloud/homeassistant.ts Outdated
this.emit('statusChanged', false)
}

async destroy(force = true): Promise<void> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

what is the purpose of force ? It gets passed to endAsync which ignores it...

Comment thread cloud/homeassistant.ts Outdated
private remember(cache: Map<string, Publish>, topic: string, publish: Publish) {
// Latest value per topic, so the map is bounded by the topic count in normal use. A
// pathological producer of unique topics could grow it without bound; that is acceptable.
cache.delete(topic)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This delete doesn't seem to serve any purpose.

Comment thread cloud/homeassistant.ts Outdated
Comment on lines +61 to +63
// Publishes issued while the socket was down. MQTT.js no longer queues QoS 0 for us, so this
// holds the latest value per topic instead — one entry per topic, never one per publish.
private readonly offline = new Map<string, Publish>()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I've scanned the code and it seems that the only unretained publish we do is device//config. This message will already be republished by the device itself on reconnect, so there is no need (or arguably it's even wrong) to replay them.

Comment thread cloud/homeassistant.ts
this.client.subscribe(this.config.rethink_prefix + '/+/+/set')

this.client.subscribe(this.config.rethink_prefix + '/+/availability')
this.client.publish(this.config.rethink_prefix + '/availability', Buffer.from('online'), { retain: true })

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I guess this.client.publish should be routed via this.publish instead.

tunarabuuu and others added 3 commits August 5, 2026 06:41
Startup failure produced an unhandled rejection, and the HTTP request, TLS
handshake, socket, agent and heartbeat had no cancellation path. Startup now
settles exactly once, destroy() cancels whichever stage is in flight, and the
RTI TLS connection verifies the peer certificate by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The metadata a device posts was kept in a plain object keyed by device id, so a
device id such as `__proto__` could collide with an Object.prototype key. It now
lives in a Map, which has no such keys. The request body is also length-capped
(413 on overflow) and run through XMLValidator before parsing, and a request that
aborts mid-stream discards its partial buffer instead of parsing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mqtt.js does not await its 'connect' listener, so a rejected subscribe or
publish in the async handler became an unhandled rejection. It is now caught,
tears the connection down, and is emitted on the same 'error' event mqtt
errors already use — so no error path is silently dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tunarabuuu
tunarabuuu force-pushed the bounded-lifecycle-hardening branch from 160c899 to f4c2c31 Compare August 4, 2026 21:41
@tunarabuuu

Copy link
Copy Markdown
Contributor Author

Thanks — point taken on the maintenance cost, and you're right on all of these. I've trimmed the PR down to the two lifecycle fixes you'd already accepted, dropping or shrinking the rest. Now 3 commits, 355 tests passing.

Dropped entirely

  • thinqApi /route abort — you're right, on second look it's worse, not better. Since apiFetch runs its own retry loop, aborting one attempt just lets the loop keep spinning under the same deadline while adding ~20 lines. One bug for another. Removed the commit; the original Promise.race stands.
  • homeassistant replay — dropped the whole thing. As you said, the only unretained publish is device/<id>/config, which the device republishes on reconnect, so there's nothing that actually needs replaying. force (ignored by endAsync) and the no-op delete went with it.

Shrunk

  • thinq1/http — kept only the two parts that carry their weight: the store is a Map instead of a plain object (so a device id like __proto__ can't collide with a prototype key), and the request body is length-capped + run through XMLValidator before parsing. Dropped the TTL/size eviction (agreed — under normal use the map just holds your real devices, and pathological growth is nobody's problem) and the id/type/model regex validation (it wasn't guarding against anything concrete). Commit message rewritten to describe what the commit actually does.

Kept

  • thinq1connection: settle startup once / cancel on destroy and thinq2connection: forward a failed startup to the error event — the two you'd already signed off on, with the comment/scaffolding fixes from the last round.

The agent-pooling move (shifting the agent to an upper layer) is still on the table as a separate follow-up whenever you'd like it — happy to open that once this lands.

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.

3 participants