Skip to content

Commit 6081abd

Browse files
JohnMcLearclaude
andcommitted
fix(updater): address Qodo review on tier 4
- UpdatePage: only show "deferred until" subtitle when scheduledFor actually matches nextWindowOpensAt. The previous `scheduledFor > now + 60s` heuristic misfired during a normal in-window 15-min grace period. - applyPipeline: return the enriched preflight reason (`reason: detail`) instead of only `pf.reason`, so /admin/update/apply 409 bodies and failure-notify emails preserve diagnostics like the Node engine mismatch detail. - updater/index: key the cached nodemailer transport on the full set of SMTP options (host + port + secure + auth) so runtime changes to port/credentials via reloadSettings() invalidate the cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 764ffbb commit 6081abd

5 files changed

Lines changed: 101 additions & 14 deletions

File tree

admin/src/pages/UpdatePage.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,17 @@ export const UpdatePage = () => {
192192
values={{tag: scheduled.targetTag, remaining: fmtRemaining(remainingMs)}}
193193
/>
194194
</p>
195-
{/* Tier 4: when the next fire is gated by a maintenance window, the
196-
backend persists `scheduledFor` to the next opening. Surface that
197-
so the admin understands why the countdown is long. */}
195+
{/* Tier 4: only surface the deferral subtitle when `scheduledFor`
196+
was actually snapped forward to the next window opening. The
197+
backend keeps `scheduledFor = now + grace` whenever that lands
198+
inside the window, so we can't use a fixed time-distance
199+
heuristic (a normal 15-min grace would falsely match). Instead,
200+
compare against `nextWindowOpensAt` with a small tolerance — the
201+
two are computed seconds apart at request time, so an exact-ish
202+
match is the only safe signal that the schedule was deferred. */}
198203
{us.tier === 'autonomous' && us.nextWindowOpensAt
199-
&& new Date(scheduled.scheduledFor).getTime()
200-
> Date.now() + 60 * 1000 && (
204+
&& Math.abs(new Date(scheduled.scheduledFor).getTime()
205+
- new Date(us.nextWindowOpensAt).getTime()) < 60 * 1000 && (
201206
<p className="update-scheduled-deferred">
202207
<Trans
203208
i18nKey="update.page.scheduled.deferred_until"

src/node/updater/applyPipeline.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import {UpdateState} from './types';
2-
import {PreflightResult, PreflightReason} from './preflight';
2+
import {PreflightResult} from './preflight';
33
import {ExecutorResult} from './UpdateExecutor';
44
import {Drainer, DrainBroadcastKey} from './SessionDrainer';
55

66
export type ApplyOutcome =
77
| {outcome: 'pending-verification'}
8-
| {outcome: 'preflight-failed'; reason: PreflightReason}
8+
| {outcome: 'preflight-failed'; reason: string}
99
| {outcome: 'cancelled'}
1010
| {outcome: 'lock-held'}
1111
| {outcome: 'busy'; status: string}
@@ -99,7 +99,7 @@ export const applyUpdate = async (
9999
lastResult: {targetTag, fromSha: '', outcome: 'preflight-failed', reason: reasonStr, at},
100100
});
101101
deps.appendLog(`[${at}] PREFLIGHT_FAILED ${reasonStr}`);
102-
return {outcome: 'preflight-failed', reason: pf.reason};
102+
return {outcome: 'preflight-failed', reason: reasonStr};
103103
}
104104

105105
// Re-load state after preflight: the cancel endpoint can flip execution

src/node/updater/index.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,31 @@ export const getDetectedInstallMethod = () => detectedMethod;
4848
* set; never imported when mail is disabled (keeps boot costs predictable for
4949
* installs that don't care about outbound mail).
5050
*
51-
* Rebuilt automatically only if the cached host doesn't match current
52-
* settings — a settings reload mid-process therefore picks up new mail config
53-
* without requiring a restart.
51+
* The cache is keyed on the full set of SMTP options that `buildTransport`
52+
* consumes (host, port, secure, auth). `reloadSettings()` can mutate any of
53+
* these at runtime, so a host-only key would silently keep using a stale
54+
* transport when an operator rotates credentials or moves to a different
55+
* port without changing host.
5456
*/
55-
let transportCache: {host: string; transporter: {sendMail: (m: any) => Promise<any>}} | null = null;
57+
let transportCache: {key: string; transporter: {sendMail: (m: any) => Promise<any>}} | null = null;
58+
59+
/**
60+
* Stable string key derived from the SMTP options `buildTransport` consumes.
61+
* Exported as `_internal` so tests can verify that runtime mutations to
62+
* `port`/`secure`/`auth` (without a host change) actually invalidate the
63+
* cache — a regression caught by Qodo on PR #7753.
64+
*/
65+
export const smtpTransportKey = (mail: {
66+
host?: string | null;
67+
port?: number | string | null;
68+
secure?: boolean | null;
69+
auth?: unknown;
70+
}): string => JSON.stringify({
71+
host: mail.host ?? null,
72+
port: Number(mail.port) || 587,
73+
secure: !!mail.secure,
74+
auth: mail.auth ?? null,
75+
});
5676

5777
const buildTransport = async (host: string) => {
5878
const {default: nodemailer} = await import('nodemailer');
@@ -73,8 +93,9 @@ const sendEmailViaSmtp = async (to: string, subject: string, body: string): Prom
7393
logger.info(`(would send email) to=${to} subject="${subject}"`);
7494
return;
7595
}
76-
if (!transportCache || transportCache.host !== host) {
77-
transportCache = {host, transporter: await buildTransport(host)};
96+
const key = smtpTransportKey(settings.mail);
97+
if (!transportCache || transportCache.key !== key) {
98+
transportCache = {key, transporter: await buildTransport(host)};
7899
}
79100
try {
80101
await transportCache.transporter.sendMail({

src/tests/backend-new/specs/updater/applyPipeline.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,26 @@ describe('applyUpdate (extracted pipeline)', () => {
8484
expect(final.lastResult?.reason).toBe('low-disk-space');
8585
});
8686

87+
it('preserves the preflight detail in the returned reason (HTTP + email use the return value)', async () => {
88+
// Regression: applyUpdate built `reasonStr = reason: detail` for state +
89+
// logs but returned only `pf.reason`, so /admin/update/apply 409 bodies
90+
// and failure-notify emails lost the engine-mismatch detail.
91+
const {deps, loadState} = baseDeps();
92+
deps.runPreflight = async () => ({
93+
ok: false,
94+
reason: 'node-engine-mismatch',
95+
detail: 'target requires Node >=26.0.0, running 25.0.0',
96+
});
97+
const r = await applyUpdate({targetTag: 'v2.0.1', deps});
98+
expect(r).toEqual({
99+
outcome: 'preflight-failed',
100+
reason: 'node-engine-mismatch: target requires Node >=26.0.0, running 25.0.0',
101+
});
102+
const final = loadState();
103+
expect(final.lastResult?.reason)
104+
.toBe('node-engine-mismatch: target requires Node >=26.0.0, running 25.0.0');
105+
});
106+
87107
it('returns cancelled when the post-preflight state check shows state was reset (admin cancelled mid-preflight)', async () => {
88108
const {deps} = baseDeps();
89109
// First preflight pass mutates state to 'preflight'. Then the cancel handler
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {describe, it, expect} from 'vitest';
2+
import {smtpTransportKey} from '../../../../node/updater/index';
3+
4+
describe('smtpTransportKey', () => {
5+
// Regression for Qodo PR #7753 review: the nodemailer transport cache was
6+
// invalidated only on host change. Operators rotating SMTP credentials or
7+
// moving to a different port without changing host would keep using the
8+
// stale transport after reloadSettings().
9+
10+
it('differs when port changes', () => {
11+
const base = {host: 'smtp.example.com', port: 587, secure: false, auth: null};
12+
expect(smtpTransportKey(base))
13+
.not.toBe(smtpTransportKey({...base, port: 465}));
14+
});
15+
16+
it('differs when secure flag changes', () => {
17+
const base = {host: 'smtp.example.com', port: 587, secure: false, auth: null};
18+
expect(smtpTransportKey(base))
19+
.not.toBe(smtpTransportKey({...base, secure: true}));
20+
});
21+
22+
it('differs when auth changes', () => {
23+
const base = {host: 'smtp.example.com', port: 587, secure: false,
24+
auth: {user: 'a', pass: '1'}};
25+
expect(smtpTransportKey(base))
26+
.not.toBe(smtpTransportKey({...base, auth: {user: 'a', pass: '2'}}));
27+
});
28+
29+
it('is stable for an unchanged config (cache hit on repeat calls)', () => {
30+
const cfg = {host: 'smtp.example.com', port: 587, secure: false,
31+
auth: {user: 'a', pass: '1'}};
32+
expect(smtpTransportKey(cfg)).toBe(smtpTransportKey({...cfg}));
33+
});
34+
35+
it('falls back to port 587 when port is unset or non-numeric', () => {
36+
expect(smtpTransportKey({host: 'h'}))
37+
.toBe(smtpTransportKey({host: 'h', port: 587}));
38+
expect(smtpTransportKey({host: 'h', port: 'not-a-number' as any}))
39+
.toBe(smtpTransportKey({host: 'h', port: 587}));
40+
});
41+
});

0 commit comments

Comments
 (0)