diff --git a/src/stateMachines/activation.test.ts b/src/stateMachines/activation.test.ts index bc9791690..e4f2b66b3 100644 --- a/src/stateMachines/activation.test.ts +++ b/src/stateMachines/activation.test.ts @@ -466,7 +466,8 @@ describe('Activation State Machine', () => { devices[clientId].nonce = PasswordHelper.generateNonce() await activation.sendAdminSetup({ input: context }) expect(createSignedStringSpy).toHaveBeenCalled() - expect(invokeWsmanCallSpy).toHaveBeenCalled() + // AdminSetup is one-shot: AMT drops the session on success without replying. + expect(invokeWsmanCallSpy).toHaveBeenCalledWith(context, 0, undefined, true) }) it('should return null when signature in null', async () => { context.certChainPfx = { provisioningCertificateObj: { certChain: [ @@ -496,7 +497,7 @@ describe('Activation State Machine', () => { null }, fingerprint: { sha256: '82f2ed575db4abe462499cf550dbff9584980d70a0272894639c3653b9ad932c', sha384: 'bb00173b0fb55bc1b24fff5a32a02d210d2bbe16dc6ba4f8300729c1d545313a66930bcd1bcf9ed5a76e82ce602ef04a', sha1: '47d7b7db23f3e300189f54802482b1bd18b945ef' }, hashAlgorithm: 'sha256' } await activation.sendUpgradeClientToAdmin({ input: context }) expect(createSignedStringSpy).toHaveBeenCalled() - expect(invokeWsmanCallSpy).toHaveBeenCalled() + expect(invokeWsmanCallSpy).toHaveBeenCalledWith(context, 0, undefined, true) }) it('should send WSMan to change AMT password', async () => { await activation.changeAMTPassword({ input: context }) diff --git a/src/stateMachines/activation.ts b/src/stateMachines/activation.ts index e6c4259dd..554063d59 100644 --- a/src/stateMachines/activation.ts +++ b/src/stateMachines/activation.ts @@ -284,7 +284,8 @@ export class Activation { 2, clientObj.signature ) - return await invokeWsmanCall(input) + // One-shot: ACM activation drops the session on success; don't retry (state machine re-checks status). + return await invokeWsmanCall(input, 0, undefined, true) } return null } @@ -300,7 +301,8 @@ export class Activation { 2, clientObj.signature ) - return await invokeWsmanCall(input) + // One-shot: CCM->ACM upgrade drops the session on success; don't retry (state machine re-checks status). + return await invokeWsmanCall(input, 0, undefined, true) } return null } diff --git a/src/stateMachines/common.test.ts b/src/stateMachines/common.test.ts index 7fd393738..641e818df 100644 --- a/src/stateMachines/common.test.ts +++ b/src/stateMachines/common.test.ts @@ -136,6 +136,39 @@ describe('Common', () => { expect(sendSpy).toHaveBeenCalledTimes(1) await expect(wsmanPromise).rejects.toBeInstanceOf(UNEXPECTED_PARSE_ERROR) }) + it('should cap oneShot calls to a single attempt even when wsman_max_attempts > 1', async () => { + Environment.Config.wsman_max_attempts = 3 + sendSpy = vi.spyOn(devices[clientId].ClientSocket, 'send') + sendSpy.mockImplementation(async () => devices[clientId].reject(new UNEXPECTED_PARSE_ERROR())) + const wsmanPromise = invokeWsmanCall(context, 0, undefined, true) + await expect(wsmanPromise).rejects.toBeInstanceOf(UNEXPECTED_PARSE_ERROR) + expect(sendSpy).toHaveBeenCalledTimes(1) + }) + it('should honor the wsman_max_attempts floor when oneShot is false', async () => { + Environment.Config.wsman_max_attempts = 3 + sendSpy = vi.spyOn(devices[clientId].ClientSocket, 'send') + sendSpy.mockImplementation(async () => devices[clientId].reject(new UNEXPECTED_PARSE_ERROR())) + const wsmanPromise = invokeWsmanCall(context) + await expect(wsmanPromise).rejects.toBeInstanceOf(UNEXPECTED_PARSE_ERROR) + expect(sendSpy).toHaveBeenCalledTimes(3) + }) + it('should not log exhaustion for the expected oneShot timeout', async () => { + const errorLogSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}) + const wsmanPromise = invokeWsmanCall(context, 0, undefined, true) + vi.advanceTimersByTime(Environment.Config.delay_timer * 1000) + await expect(wsmanPromise).rejects.toBeInstanceOf(GATEWAY_TIMEOUT_ERROR) + expect(errorLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('Max WSMAN attempts')) + errorLogSpy.mockRestore() + }) + it('should log exhaustion for a non-timeout oneShot failure', async () => { + const errorLogSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}) + sendSpy = vi.spyOn(devices[clientId].ClientSocket, 'send') + sendSpy.mockImplementation(async () => devices[clientId].reject(new UNEXPECTED_PARSE_ERROR())) + const wsmanPromise = invokeWsmanCall(context, 0, undefined, true) + await expect(wsmanPromise).rejects.toBeInstanceOf(UNEXPECTED_PARSE_ERROR) + expect(errorLogSpy).toHaveBeenCalledWith(expect.stringContaining('Max WSMAN attempts')) + errorLogSpy.mockRestore() + }) it('should not retry when error is not UNEXPECTED_PARSE_ERROR', async () => { const expected = { statusCode: 401, diff --git a/src/stateMachines/common.ts b/src/stateMachines/common.ts index 4c9a7caf9..eba390556 100644 --- a/src/stateMachines/common.ts +++ b/src/stateMachines/common.ts @@ -279,7 +279,7 @@ const timeout = async (ms: number): Promise => { }) } -const invokeWsmanCall = async (context: any, maxRetries = 0, timeoutMs?: number): Promise => { +const invokeWsmanCall = async (context: any, maxRetries = 0, timeoutMs?: number, oneShot = false): Promise => { const { clientId } = context const clientObj = devices[clientId] @@ -293,7 +293,8 @@ const invokeWsmanCall = async (context: any, maxRetries = 0, timeoutMs?: numb } let retriesUsed = 0 - const maxAttempts = Math.max(maxRetries + 1, Environment.Config.wsman_max_attempts) + // One-shot calls (ACM activation/upgrade) drop the session on success; never re-issue them. + const maxAttempts = oneShot ? 1 : Math.max(maxRetries + 1, Environment.Config.wsman_max_attempts) const timeoutValue = timeoutMs ?? Environment.Config.delay_timer * 1000 const retryDelayMs = Environment.Config.delay_tls_timer * 1000 @@ -353,7 +354,9 @@ const invokeWsmanCall = async (context: any, maxRetries = 0, timeoutMs?: numb continue } - if (isRetryableError && retriesUsed >= maxAttempts - 1) { + // Skip the exhaustion log only for the expected one-shot timeout; still log real failures. + const isExpectedOneShotTimeout = oneShot && error instanceof GATEWAY_TIMEOUT_ERROR + if (isRetryableError && retriesUsed >= maxAttempts - 1 && !isExpectedOneShotTimeout) { const errorType = (error as any)?.constructor?.name ?? typeof error const tunnelState = clientObj?.tlsTunnelManager != null ? 'present' : 'none' const sessionId = clientObj?.tlsTunnelSessionId ?? 'none' @@ -363,10 +366,8 @@ const invokeWsmanCall = async (context: any, maxRetries = 0, timeoutMs?: numb invokeWsmanLogger.error( `WSMAN final failure context: attempts=${maxAttempts}, retriesUsed=${retriesUsed}, errorType=${errorType}, tunnelState=${tunnelState}, sessionId=${sessionId}, tunnelNeedsReset=${clientObj?.tlsTunnelNeedsReset === true}, amtReconfiguring=${clientObj?.amtReconfiguring === true}` ) - throw error - } else { - throw error } + throw error } } return await Promise.reject(new Error('Max retries reached'))