1

I'm working on a library and I'd like to prevent users from calling a specific function in order to prevent infinite loops. Usually I'd go about doing it like this:

let preventFooCalls = false;

function fireUserCallbacks() {
    preventFooCalls = true;

    // Fire callbacks of the user here...

    preventFooCalls = false;
}

function foo() {
    if (preventFooCalls) throw Error();

    // Run the content of foo() ...
    // It will probably call fireUserCallbacks() at some point
}

However, if fireUserCallbacks is async, this method is not possible. It might be called multiple times, and with async user callbacks, preventFooCalls is not guaranteed to have the correct value. For instance:

let preventFooCalls = false;

async function fireUserCallbacks() {
    preventFooCalls = true;

    // Fire callbacks of the user here one of which being:
    await new Promise(r => setTimeout(r, 1000));

    preventFooCalls = false;
}

// Then when doing:
fireUserCallbacks();
foo(); // This will throw even though it's being called from outside fireUserCallbacks()

How can I detect if code is running from within a specific promise? The only thing I can think of is new Error().stack, but oof that sounds like a terrible way to do it.


Some context

The reason why I want this is because I'm working on a part of a library that takes care of loading assets. Some of these assets might contain other assets with the possibility of infinite recursion. In order to handle recursion I have another function that I want users to call instead. Therefore I want to warn users when they call foo() from within one of the fireUserCallbacks() callbacks. While this will only be an issue when assets actually contain infinite loops, I'd rather block the usage of foo() completely to prevent unexpected hangs due to infinite loops.

edit: Here's a somewhat more sophisticated example of my actual code. I would share my actual code but that is really way too long for this format, tbh this example is already getting a bit too complex.

class AssetManager {
  constructor() {
    this.registeredAssetTypes = new Map();
    
    this.availableAssets = new Map();
  }
  
  registerAssetType(typeId, assetTypeConstructor) {
    this.registeredAssetTypes.set(typeId, assetTypeConstructor);
  }
  
  fillAvailableAssets(assetDatas) {
    for (const assetData of assetDatas) {
      const constructor = this.registeredAssetTypes.get(assetData.type);
      const asset = new constructor(assetData.id, assetData.data);
      this.availableAssets.set(assetData.id, asset);
    }
  }
  
  async loadAsset(assetId, recursionTracker = null) {
    // I have some extra code here that makes sure this function will only have
    // a single running instance, but this example is getting way too long already
    const asset = this.availableAssets.get(assetId);
    
    let isRootRecursionTracker = false;
    if (!recursionTracker) {
      isRootRecursionTracker = true;
      recursionTracker = new RecursionTracker(assetId);
    }
    const assetData = await asset.generateAsset(recursionTracker);
    
    if (isRootRecursionTracker) {
      // If this call was made from outside any `generateAsset` implementation,
      // we will wait for all assets to be loaded and put on the right place.
      await recursionTracker.waitForAll();
      
      // Finally we will give the recursionTracker the created root asset,
      // in case any of the sub assets reference the root asset.
      // Note that circular references in any of the sub assets (i.e. not
      // containing the root asset anywhere in the chain) are already taken care of.
      if (recursionTracker.rootLoadingAsset) {
        recursionTracker.rootLoadingAsset.setLoadedAssetData(assetData);
      }
    }
    
    return assetData;
  }
}
const assetManager = new AssetManager();



class RecursionTracker {
  constructor(rootAssetId) {
    this.rootAssetId = rootAssetId;
    
    this.rootLoadingAsset = null;
    this.loadingAssets = new Map();
  }
  
  loadAsset(assetId, cb){
    let loadingAsset = this.loadingAssets.get(assetId);
    if (!loadingAsset) {
      loadingAsset = new LoadingAsset(assetId);
      this.loadingAssets.set(assetId, loadingAsset);
      if (assetId != this.rootAssetId) {
        loadingAsset.startLoading(this);
      } else {
        this.rootLoadingAsset = loadingAsset;
      }
    }
    loadingAsset.onLoad(cb);
  }
  
  async waitForAll() {
    const promises = [];
    for (const loadingAsset of this.loadingAssets.values()) {
      promises.push(loadingAsset.waitForLoad());
    }
    await Promise.all(promises);
  }
}



class LoadingAsset {
  constructor(assetId) {
    this.assetId = assetId;
    
    this.onLoadCbs = new Set();
    this.loadedAssetData = null;
  }
  
  async startLoading(recursionTracker) {
    const loadedAssetData = await assetManager.loadAsset(this.assetId, recursionTracker);
    this.setLoadedAssetData(loadedAssetData);
  }
  
  onLoad(cb) {
    if (this.loadedAssetData) {
      cb(this.loadedAssetData)
    } else {
      this.onLoadCbs.add(cb);
    }
  }
  
  setLoadedAssetData(assetData) {
    this.loadedAssetData = assetData;
    this.onLoadCbs.forEach(cb => cb(assetData));
  }
  
  async waitForLoad() {
    await new Promise(r => this.onLoad(r));
  }
}



class AssetTypeInterface {
  constructor(id, rawAssetData) {
    this.id = id;
    this.rawAssetData = rawAssetData;
  }
  async generateAsset(recursionTracker) {}
}



class AssetTypeFoo extends AssetTypeInterface {  
  async generateAsset(recursionTracker) {
    // This is here just to simulate network traffic, an indexeddb lookup, or any other async operation:
    await new Promise(r => setTimeout(r, 200));
      
    const subAssets = [];
    for (const subAssetId of this.rawAssetData.subAssets) {
      
      
      // This won't work, as it will start waiting for itself to finish:
      // const subAsset = await assetManager.loadAsset(subAssetId);
      // subAssets.push(subAsset);
      
      // So instead we will create a dummy asset:
      const dummyAsset = {}
      const insertionIndex = subAssets.length;
      subAssets[insertionIndex] = dummyAsset;
      // and load the asset with a callback rather than waiting for a promise
      recursionTracker.loadAsset(subAssetId, (loadedAsset) => {
        // since this will be called outside the `generateAsset` function, this won't hang
        subAssets[insertionIndex] = loadedAsset;
      });
    }
    return {
      foo: this.id,
      subAssets,
    }
  }
}
assetManager.registerAssetType("foo", AssetTypeFoo);



class AssetTypeBar extends AssetTypeInterface {
  async generateAsset(recursionTracker) {
    // This is here just to simulate network traffic, an indexeddb lookup, or any other async operation:
    await new Promise(r => setTimeout(r, 200));
    
    // We'll just return a simple object for this one.
    // No recursion here...
    return {
      bar: this.id,
    };
  }
}
assetManager.registerAssetType("bar", AssetTypeBar);



// This is all the raw asset data as stored on the users disk.
// These are not instances of the assets yet, so no circular references yet.
// The assets only reference other assets by their "id"
assetManager.fillAvailableAssets([
  {
    id: "mainAsset",
    type: "foo",
    data: {
      subAssets: ["subAsset1", "subAsset2"]
    }
  },
  {
    id: "subAsset1",
    type: "bar",
    data: {},
  },
  {
    id: "subAsset2",
    type: "foo",
    data: {
      subAssets: ["mainAsset"]
    }
  }
]);

// This sets the loading of the "mainAsset" in motion. It recursively loads
// all referenced assets and finally puts the loaded assets in the right place,
// completing the circle.
(async () => {
  const asset = await assetManager.loadAsset("mainAsset");
  console.log(asset);
})();
Jespertheend
  • 1,814
  • 19
  • 27
  • if this is library, just dont expose the foo method? – about14sheep Nov 06 '21 at 21:10
  • @about14sheep I'd still like people to be able to call it from outside the `fireUserCallbacks()` function. I oversimplified things a lot but in my case `foo()` essentially loads an asset and returns it in a Promise. The user callbacks represent the parsing of raw asset data into usable objects. – Jespertheend Nov 06 '21 at 21:38
  • "*Some of these assets might contain other assets with the possibility of infinite recursion.*" - you mean assets (directly or indirectly) containing themselves? – Bergi Nov 07 '21 at 01:56
  • Given it's asynchronous, what would you expect to happen if `fireUserCallbacks()` was called multiple times concurrently? – Bergi Nov 07 '21 at 01:56
  • "*In order to handle recursion I have another function that I want users to call instead.*" - don't. Just have one function with a simple interface that works for both cases. – Bergi Nov 07 '21 at 01:57
  • “ what would you expect to happen if fireUserCallbacks() was called multiple times concurrently?” I have logic in place that prevents this. There will always be one running instance running. If you call it while it’s already running it will wait for the first instance to finish and return the same result. Hence my problem, if the function is called from within one of the callbacks, it will hang because it will wait for itself to finish which is never. – Jespertheend Nov 08 '21 at 08:00
  • “Just have one function that works for both cases” I was hoping to be able to do this, but I don’t think I can. In order to solve the hangs I want users to pass an extra object to the function that tracks all the calls that have been made. So that I can detect if two similar calls are in the same stack. When called from the callbacks function they receive the object as argument and can pass it along. But when called from outside the callbacks function the object has to be created. So if I use one function people will create a new object rather than pass the existing argument along. – Jespertheend Nov 08 '21 at 08:13
  • Ah, I see. Yeah that's a bit harder, you'll probably need some way to [tie the knot](https://wiki.haskell.org/Tying_the_Knot) even in the presence of user callbacks, and probably will need to forbid `await` in there. Can you share your actual code, maybe I can come up with something? It might require splitting the user callback in two parts, one to declare dependencies, one to process them. – Bergi Nov 08 '21 at 14:47
  • I do plan on making the code open source eventually, but it's not super ready for the public yet. But I can give you private access to the repository if you like. – Jespertheend Nov 08 '21 at 17:48
  • Nah, just [edit] the relevant parts into your question – Bergi Nov 08 '21 at 17:49
  • 1
    I've created a new example because my actual code is way too long. This is the closest I could get to the real thing without making things overly complex. – Jespertheend Nov 08 '21 at 20:01
  • 1
    @Jespertheend Thanks, that helps a lot! I can see two approaches already, but am not sure if they're viable or if the example has lost too much in the simplification. a) Is it always `rawAssetData.subAssets` that hold the references (for asset types that do have sub assets)? b) for each asset, you create multiple objects, in particular `const asset = new constructor(assetData.id, assetData.data);` and `const assetData = await asset.generateAsset(recursionTracker);` - can you merge them into a single instance created with `new` and "filled" by a `.generate()` method? – Bergi Nov 09 '21 at 00:05
  • Unfortunately the answer to a is no :( I'd like the raw asset data to be highly customisable by the user, so they can use any structure they like. I used an array of `subAssets` just as an example. As for b, I suppose I might be able to put the contents of assets in a single object rather than two. Though the reason why I currently have it like this is to separate loading logic from the actual asset logic. This way `generateAsset` could return a `new Image()` for instance and once the asset is loaded all you're left with is the actual image, no more `AssetTypeImage` instance. – Jespertheend Nov 09 '21 at 11:53

1 Answers1

1

Maintain a queue and a set. The queue contains pending requests. The set contains pending requests, requests in progress, and successfully completed requests. (Each item would include the request itself; the request's status: pending, processing, complete; and possibly a retry counter.)

When request is made, check if it is in the set. If it is in the set, it was already requested and will be processed, is being processed, or was processed successfully and is already available. If not in the set, add it to both the set and the queue, then trigger queue processing. If queue processing is already running, the trigger is ignored. If not, queue processing starts.

Queue processing pulls requests off the queue, one by one, and processes them. If a request fails, it can either be put back onto the queue for repeat attempts (a counter can be included in the item to limit retries) or it can be removed from the set so it can be requested again later. Queue processing ends when the queue is empty.

This avoids recursion and unnecessary repeat requests.

Ouroborus
  • 16,237
  • 4
  • 39
  • 62
  • Or just store one promise per request, don't keep track of the "request status" yourself and let the async functions do the "queue handling" automatically. – Bergi Nov 07 '21 at 02:00
  • @Bergi How might you implement rate limiting with that strategy? (Not part of the question, just interested for my own stuff.) – Ouroborus Nov 07 '21 at 02:27
  • 2
    Rate limiting will require some sort of queuing, but still there are [some](https://stackoverflow.com/a/38778887/1048572) [elegant](https://stackoverflow.com/a/39197252/1048572) solutions that don't keep and explicitly "process" a queue – Bergi Nov 07 '21 at 02:35
  • Thanks! This is sort of the setup I have at the moment. I've created an extra example in the original answer that is a lot closer to my real code. In this example the `RecursionTracker` is sort of the queue you're talking about. The thing is, I'd like to enforce the usage of the RecursionTracker but only when called from inside `generateAsset()`. I.e. I'd like to prevent the usage of `assetManager.loadAsset` inside that function. – Jespertheend Nov 08 '21 at 20:05