How to implement a cancel function to several downloads being made using Promise.all()

I have a Promise.all() that returns when all my download promises are finished, the downloads are basically several images. I’m trying to add the possibility to cancel the remaining downloads in the list of URLs that is passed to Promise.all(). I tried some ways to do this, but the items are always downloaded anyway.

How my code is right now:

async downloadFunction(arrayToDownload: Object[]): Promise<void[]> {
	let loading = await this.loadingController.create({
		message: 'Touch backgroud to cancel',
		backdropDismiss: true
	});
	await loading.present();

	const fileTransfer: FileTransferObject = this.transfer.create();
	let canceled: boolean = false;

	loading.onDidDismiss().then(obj => {
		console.log(obj); // obj.role will be = 'backdrop' if clicked in background
		if (obj.role === 'backdrop') {
			console.log('canceled');
			//fileTransfer.abort(); -> Tried to abort filetransfer here first, but it seems that it need to be fired during download.
			canceled = true;
			return;
		}
	}); 

	return Promise.all(arrayToDownload.map(element => {
		if (canceled === true) {
			return;	// Added a return here, so it would skip the rest, but didn't work					
		}

		if (element != undefined) {
			// Get data from URL and do other preparations
			//...

			return fileTransfer.download(encodedURI, this.file.externalDataDirectory + 'randomFolder').then(entry => {
				return;
			
			}, error => {
				console.log("Error while downloading image: ", error);
				return;		
			}).catch(error => {
				console.log("Error while downloading image: ", error);
				return;
			}); 
		} else {
			throw new Error("image url not available");  				
		}

	})).then(() => {
		loading.dismiss();
		return [];
	});
}

If you don’t get any better answers, I would suggest ditching FileTransfer in favor of ordinary HttpClient. That way you can cancel requests by terminating your subscriptions to them.

1 Like

If I do something like this:

return Promise.all(arrayToDownload.map(element => {
    if (element != undefined) {
        // Get data from URL and do other preparations
        //...

        return this.http.get(encodedURI).subscribe(res => {
            return saveImageLocally(res);
        });
    } else {
        throw new Error("image url not available");                 
    }

}))

How can I cancel the subscription in this case? Or the way I’m thinking is wrong and the HttpClient method should be implemented in another way?

I just realized that no promise will return to Promise.all() because http.get() is an observable. In this case, what would be the equivalent way to handle a bunch of Observables, like Promise.all() do with Promises?

What I would do is to put a tripwire in your subscriptions. There are a bunch of places to put it: the service that is spawning the HTTP requests, a separate dedicated canceller service (with or without an interceptor), the component that is requesting all of this.

The basic idea, however, is something like so:

cancelTripwire = new Subject<void>();
getStuffs(urls: string[]): void {
  urls.forEach(url => {
    this.http.get(url).pipe(takeUntil(this.cancelTripwire)).subscribe(...);
  });
}
pullRipcord(): void {
  this.cancelTripwire.next();
}

I am trying to implement this option, but I am not having much success. I’m triggering the change of value for the Subject from the backdropDismis event of the LoadingController:

async downloadFunction(arrayToDownload: Object[]): Promise<void> {
    // declaring loader...

    let cancelTripwire = new Subject<void>();
    loading.onDidDismiss().then(obj => {
        if (obj.role === 'backdrop') {
            console.log('canceled'); // Firing in the correct moment
            cancelTripwire.next();;
        }
    });

    return arrayToDownload.forEach(element => {
        // Preparing url...

        //responseType: 'text' because is an image
        this.http.get(encodedURI, {responseType: 'text'}).pipe(takeUntil(cancelTripwire))
        .subscribe(res => {
            console.log(res);
        }); 

    });
} 

However, despite triggering the event at the right time, the images continue to be downloaded.

I fear there is something lurking in the code you’ve elided. Here’s what I have, in the hopes you can adapt it somehow to your situation.

mock-http-client.ts

@Injectable()
export class MockHttpClient {
  activeRequests = 0;
  fruits = {a: "apple", b: "banana", c: "cherry"};

  get(pfx: string): Observable<string> {
    ++this.activeRequests;
    // magic number boogaloo: basically wait 2 seconds for apple, 4 for banana, 6 for cherry
    return timer(2000 * (pfx.charCodeAt(0) - 96)).pipe(
      map(() => this.fruits[pfx]),
      finalize(() => --this.activeRequests;
      );
  }
}

home.page.ts

export class HomePage {
  pfxen = ["a", "b", "c"];
  fruits = {};
  cancelTripwire = new Subject<void>();

  constructor(public http: MockHttpClient) {
  }

  go(): void {
    this.fruits = {};
    this.pfxen.forEach(pfx => {
      this.http.get(pfx).pipe(takeUntil(this.cancelTripwire))
        .subscribe(fruit => this.fruits[pfx] = fruit);
    });
  }

  cancel(): void {
    this.cancelTripwire.next();
  }
}

home.page.html

<ion-content>
  <div>
    <ion-button (click)="go()">go</ion-button>
    <ion-button (click)="cancel()">cancel</ion-button>
  </div>
  <div>
    {{http.activeRequests}} active requests
  </div>
  <div>
    <ion-list>
      <ion-item *ngFor="let pfx of pfxen">
        <ion-label>{{pfx}} -> {{fruits[pfx]}}</ion-label>
      </ion-item>
    </ion-list>
  </div>
</ion-content>

Click the “go” button to start things, and you should instantly see “0 active requests” go up to “3 active requests”. After two seconds, “apple” should fill in, followed by the other fruits at further two-second intervals. Pressing “cancel” at any point in the process should instantly drop the active request count to 0 and cancel any unfulfilled requests. The whole shebang can be repeated as desired.

Well, this is really intriguing. Your method works perfectly, but I tried several ways to implement it in my code, but the result is always the same. I even created a StackBlitz to simulate my code with this methodology and to my frustration the StackBlitz worked correctly too. I tried to break the code into several pieces to make it as succinct as possible, but I still can’t identify the source of the problem.

I will make my real code available here for a limited time, if you have the time and desire to take a more experienced look, you may be able to notice something that I missed. Thanks in advance.

// Removed by author

This may be a bit of a long shot, but I am always suspicious of async / await and wonder if completely eliminating any use of it in the relevant code paths might do anything.

The underlying idiom here (using unsubscription to abort an activity being performed by HttpClient) relies on the “coldness” of HttpClient's Observables, which is a concept that can’t really be captured by Promises, so whenever an Observable is mogrified into a Promise, the Observable gets “heated up”. In this case, I would not be surprised to see that manifest as a rogue subscription that would be immune to our cancellation tripwire.

async and await in certain cases litter the transpiled JavaScript with Promises that aren’t visible from the original TypeScript. As I find those sorts of bugs supremely vexing, I am yet to jump aboard the async / await train.

Again, not a guess I feel particularly strongly will help you here, but maybe it’s worth experimenting with if you can’t find anything else.

I’ve been trying to avoid using async/await for a while. In this case I used it only for the loadingController because I couldn’t figure out a way to use it without these operators. Now I made some modifications to this controller and the loading controller worked without the operators, but unfortunately the main problem still persists.

Modified function without async/await:

saveBasemapInStorage(arrayToDownload: Object[]): Promise<void> {
  let cancelTripwire = new Subject<void>();
    
  let loading = this.loadingController.create({
    message: 'Downloading, touch background do cancel',
    backdropDismiss: true
  });
  loading.then((solved) => {
    solved.present();

    solved.onDidDismiss().then(obj => {
      if (obj.role === 'backdrop') {
        console.log('canceled');
        cancelTripwire.next();
      }
    });
  });  

  return this.prepareTilesFolder().then(() => {
    arrayToDownload.forEach(element => {
      if (element != undefined) {
        this.prepareTileToDownload(element).then(obj => {
          this.http.get(obj['encodedURI'])
          .pipe(takeUntil(cancelTripwire))
          .subscribe(res => {
            console.log(res);
          });
        });
      }
    })
  })      
}
1 Like

Not sure if you’re going to like this or feel the exact opposite, but I kludged your code into my scratchpad thusly, and still see it behaving as I would expect:

export class MockHttpClient {
  activeRequests = 0;
  fruits = ["apple","banana", "cherry"];

  get(pfx: string): Observable<string> {
    ++this.activeRequests;
    return timer(2000 * this.activeRequests).pipe(
      map(() => this.fruits[Math.floor(Math.random() * 3)]),
      finalize(() => --this.activeRequests)
      );
  }
}

export class HomePage {
  pfxen = [
    {url: "a.jpg", tileCoord: [0, 1]},
    {url: "b.png", tileCoord: [2, 3]},
    {url: "c.jpeg", tileCoord: [4, 5]}];
  fruits = {};
  file = {externalDataDirectory: "nowhere", removeRecursively: () => Promise.resolve()};

  constructor(public http: MockHttpClient, public loadingController: LoadingController) {
  }

  saveBasemapInStorage(arrayToDownload: Object[]): Promise<void> {
    this.fruits = {};
   let cancelTripwire = new Subject<void>();

    let loading = this.loadingController.create({
      message: 'Downloading, touch background do cancel',
      backdropDismiss: true
    });
    loading.then((solved) => {
      solved.present();

      solved.onDidDismiss().then(obj => {
        if (obj.role === 'backdrop') {
          console.log('canceled');
          cancelTripwire.next();
        }
      });
    });

    return this.prepareTilesFolder().then(() => {
      arrayToDownload.forEach(element => {
        if (element != undefined) {
          this.prepareTileToDownload(element).then(obj => {
            this.http.get(obj['encodedURI'])
              .pipe(takeUntil(cancelTripwire))
              .subscribe(res => {
                console.log(res);
                this.fruits[element.url] = res;
              });
          });
        }
      })
    })
  }

  prepareTilesFolder(): Promise<void> {
    // Check if folder exist, remove content + folder and then create a fresh folder
    return this.checkAndCreateDir(this.file.externalDataDirectory, 'offline_tiles').then(() => {
      return this.file.removeRecursively(this.file.externalDataDirectory, 'offline_tiles').then(() => {
        return this.checkAndCreateDir(this.file.externalDataDirectory, 'offline_tiles').then(() => {
          return;
        });
      });
    });
  }

  checkAndCreateDir(): Promise<void> {
    return Promise.resolve();
  }


  prepareTileToDownload(element): Promise<Object> {
    // Get data from URL
    let image_extension = element['url'].split('.').pop();

    // image extension can be '.jpeg?w=123'
    if (image_extension.includes('jpeg')) {
      image_extension = '.jpeg';
    }
    if (image_extension.includes('png')) {
      image_extension = '.png';
    }

    let encodedURI = encodeURI(element['url']);

    let x:string = element['tileCoord'][0].toString();
    let y:string = element['tileCoord'][1].toString();

    // Convert to positive, then remove 1 (openlayers workaround), then to string, then add extension
    // z is the actual name of the image
    let z:string = (Math.abs(element['tileCoord'][2]) - 1).toString() + image_extension;

    // Create a folder structure like a tile server (x/y/z)
    return this.checkAndCreateDir(this.file.externalDataDirectory + 'offline_tiles/', x).then(() => {
      return this.checkAndCreateDir(this.file.externalDataDirectory + 'offline_tiles/' + x + '/', y).then(() => {
        return {url: encodedURI, filename: z};
      });
    });
  }
}
<ion-content>
  <div>
    <ion-button (click)="saveBasemapInStorage(pfxen)">go</ion-button>
  </div>
  <div>
    {{http.activeRequests}} active requests
  </div>
  <div>
    <ion-list>
      <ion-item *ngFor="let pfx of pfxen">
        <ion-label>{{pfx.url}} -> {{fruits[pfx.url]}}</ion-label>
      </ion-item>
    </ion-list>
  </div>
</ion-content>
1 Like

try this, if you are using angular httpClient, convert it response to promise is very easy whit toPromise method:

saveBasemapInStorage(arrayToDownload: Object[]): Promise<void> {

    let isCancelled = false;
    const loading = this.loadingController.create({
      message: 'Downloading, touch background do cancel',
      backdropDismiss: true
    });
    loading.then((solved) => {
      solved.present();

      solved.onDidDismiss().then(obj => {
        if (obj.role === 'backdrop') {
          console.log('canceled');
          isCancelled = true;
        }
      });
    });

    return this.prepareTilesFolder().then(() => {
      arrayToDownload.forEach(element => {
        if (element != undefined && !isCancelled) {
          this.doDownload(element);
        }
      })
    })
  }
  
  async doDownload(element): Promise<void> {
    try {
      const obj = await this.prepareTileToDownload(element);
      const resp = await this.http.get(obj[ 'encodedURI' ]).toPromise();
      console.log('resp: ', resp);
    }catch (e) {
      console.error('error: ', e);
    }
  }

I had already tried something very similar to this. I tried again now exactly the way you show, but the same behavior persists. :sob: The worst kind of bug is the one you can’t even figure it out why is happening. I’m very grateful for all the help so far, but if you guys have an alternative method to do the same thing, which is download a list of urls and be able to cancel at any point, I will happily try.

This method can be useful for aggregating the results of multiple promises.

Try whit traditional for intead of .map or .forEach, I’ve a hunch.

If that does not work, try creating an Angular interceptor for all http request, then you can create a global variable that you can use to control downloads inside the interceptor.

How would you propose using this global variable to affect downloads that have already commenced? Can you illustrate your idea with some code we can use in test projects?

Look at this, it does not have the interceptor but works

https://github.com/ThonyFD/angular-cancel-request-example

I’m using stubby to fake request

Hmm. Can you help me understand how that’s different from what I’ve been proposing throughout the thread so far?

Look a this https://github.com/ThonyFD/angular-cancel-request-example/tree/with-interceptor

I was able to solve this problem by rewriting and fractioning this function further:

saveBasemapInStorage(arrayToDownload: Object[]): Promise<void> {
	// loadingController impossible to dismiss...

	return this.prepareTilesFolder().then(() => {
		return this.prepareTileToDownload(arrayToDownload).then(() => {
			prepareLoading.dismiss();
			this.downloadTiles(arrayToDownload);
		});
	})			
}

async downloadTiles(arrayToDownload) {	
	// loadingController where is possible to dismiss and cancel the download...

	for (let element of arrayToDownload) {
		let obj = this.getXYZ(element);

		if (element != undefined && this.downloadWasCanceled === false) { // <-- Canceling element
			const fileTransfer: FileTransferObject = this.transfer.create();
			let destination:string = this.file.externalDataDirectory + 'offline_tiles/' + obj['x'] + '/' + obj['y'] + '/' + obj['z'];
  			fileTransfer.download(obj['encodedURI'], destination)
  			.then(entry => {
				console.log("donwloaded successfully: ", entry);
				return;					
			}, error => {
				console.log("Error while downloading: ", error);
				donwloadLoading.dismiss();
				return;					
			}).catch(error => {
				console.log("Error while downloading: ", error);
				donwloadLoading.dismiss();
				return;
			});

		} else {
			donwloadLoading.dismiss();
		}
	}
	donwloadLoading.dismiss();
}

I basically separated the loading function from the directories structuring part and the download part. I believe that this was preventing me to cancel the remaining downloads. I decided to go back to using Cordova’s FileTransfer because it already does the job of downloading and saving the file in the desired location. In place of Promise.all() I left a standard for loop, considering that it is not possible to cancel a promise after it is triggered. I thank you again for all your help!