From 12b67742c8091280c2e74e97a7c7392021db542f Mon Sep 17 00:00:00 2001
From: Christina Wang <christina@balena.io>
Date: Tue, 2 Aug 2022 14:34:25 -0700
Subject: [PATCH] Wait for Stopping services to stop before target apply
 success

This mitigates an edge case bug introduced in v13.1.3 where services that
are slow to exit may get stuck in a state of Downloaded if a service var is
changed then reverted rapidly. More detailed description in linked issue.

Change-type: patch
Closes: #1991
Signed-off-by: Christina Wang <christina@balena.io>
---
 src/compose/app.ts           | 15 ++++++++++++++-
 test/src/compose/app.spec.ts | 17 +++++++++++++++++
 2 files changed, 31 insertions(+), 1 deletion(-)

diff --git a/src/compose/app.ts b/src/compose/app.ts
index 3cae07fe..c5c81f69 100644
--- a/src/compose/app.ts
+++ b/src/compose/app.ts
@@ -360,6 +360,18 @@ export class App {
 			);
 		};
 
+		/**
+		 * Checks if Supervisor should keep the state loop alive while waiting on a service to stop
+		 * @param serviceCurrent
+		 * @param serviceTarget
+		 */
+		const shouldWaitForStop = (serviceCurrent: Service) => {
+			return (
+				serviceCurrent.config.running === true &&
+				serviceCurrent.status === 'Stopping'
+			);
+		};
+
 		/**
 		 * Filter all the services which should be updated due to run state change, or config mismatch.
 		 */
@@ -372,7 +384,8 @@ export class App {
 				({ current: c, target: t }) =>
 					!isEqualExceptForRunningState(c, t) ||
 					shouldBeStarted(c, t) ||
-					shouldBeStopped(c, t),
+					shouldBeStopped(c, t) ||
+					shouldWaitForStop(c),
 			);
 
 		return {
diff --git a/test/src/compose/app.spec.ts b/test/src/compose/app.spec.ts
index 3a9f686f..d6ce0cbd 100644
--- a/test/src/compose/app.spec.ts
+++ b/test/src/compose/app.spec.ts
@@ -657,6 +657,23 @@ describe('compose/app', () => {
 			expectNoStep('kill', steps);
 		});
 
+		it('should emit a noop while waiting on a stopping service', async () => {
+			const current = createApp({
+				services: [
+					await createService(
+						{ serviceName: 'main', running: true },
+						{ state: { status: 'Stopping' } },
+					),
+				],
+			});
+			const target = createApp({
+				services: [await createService({ serviceName: 'main', running: true })],
+			});
+
+			const steps = current.nextStepsForAppUpdate(defaultContext, target);
+			expectSteps('noop', steps);
+		});
+
 		it('should remove a dead container that is still referenced in the target state', async () => {
 			const current = createApp({
 				services: [