Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug: Race case in temporary directory creation #6

Open
a3957273 opened this issue Oct 15, 2022 · 5 comments
Open

Bug: Race case in temporary directory creation #6

a3957273 opened this issue Oct 15, 2022 · 5 comments

Comments

@a3957273
Copy link

https://github.com/karolyp/docker-tar-pusher/blob/master/src/WorkDirUtils.ts#L10-L1

  public createTempDir(prefix = 'dtp-'): void {
    this.cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
  }

If two Docker tars are being uploaded at the same time, they use the same folder. To prevent this, generate pseudo-random folder names, e.g:

import { v4 as uuidv4 } from 'uuid'

  public createTempDir(prefix = 'dtp-'): void {
    this.cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix + uuidv4()));
  }
@karolyp
Copy link
Owner

karolyp commented Oct 16, 2022

Hi @a3957273 ,

Thanks for letting me know about your issue. May I ask you to provide the driver code you used (e.g. how you implemented parallel exeuction), so that I can reproduce the exact issue you encountered with?

Thank you!

@a3957273
Copy link
Author

It's quite hard to reliably reproduce, due to the very quick speed of:

      const manifest = this.workDirUtils.readManifest();

I don't have a reliable script to reproduce. But logically, with two separate processes you could run into the following situation:

A: this.workDirUtils.createTempDir() (mkdir /tmp/dtp-)
B: this.workDirUtils.createTempDir() (mkdir /tmp/dtp-) # this is fine, recreating an existing folder is a no-op
A: this.workDirUtils.extract(this.config.tarball) 
B: this.workDirUtils.extract(this.config.tarball) # this is bad, as we've just overwritten `manifest.json`
A: const manifest = this.workDirUtils.readManifest() # !!! refers to the B manifest

Due to your use of sync, this is only an issue with our setup where we run multiple Node processes on the same host. It's possible to make this easier to reproduce by adding a sleep 10s into the readManifest() function.

@karolyp
Copy link
Owner

karolyp commented Oct 16, 2022

As far as I understood, this race condition would be correct, if we would have only one DockerTarPusher object whose pushToRegistry could be re-used. Since you have to instantiate a new DockerTarPusher every time you want to upload a new image, it will instantiate a new WorkDirUtils which will eventually solve this concurrency issue.

I tried to reproduce your issue with some images and I also added some extra info to the logs (see here). As you can see on the timestamps, all of them were running concurrently, without an issue.

Here is the driver code I used:

const tarballs = [...];
const dtpUploads = tarballs 
  .map((tarball) => getDTP(tarball))
  .map(
    (dtp) =>
      new Promise((res) => {
        dtp.pushToRegistry().then(res);
      })
  );
(async () => {
  await Promise.all(dtpUploads);
})();

@a3957273
Copy link
Author

You have to be running in different processes as all your calls are synchronous, so your above example will actually be running them one at a time (despite them looking to be promises).

@a3957273
Copy link
Author

a3957273 commented Oct 16, 2022

const fs = require('fs')

// run:
//   node race.js & node race.js && fg

function synchronousWait(ms) {
  let start = Date.now()
  let now = start
  while (now - start < ms) {
    now = Date.now()
  }
}

function fake_workDirUtils_extract(name) {
  // fake opening a tar file and extracting the metadata.json
  fs.writeFileSync('./metadata.json', JSON.stringify({ name }))
}

function fake_workDirUtils_readManifest() {
  // make this function take some time
  synchronousWait(150)

  return JSON.parse(fs.readFileSync('./metadata.json'))
}

function main() {
  const name = String(Math.random())

  fake_workDirUtils_extract(name)
  const metadata = fake_workDirUtils_readManifest()

  console.assert(name === metadata.name, JSON.stringify({ name, metadataName: metadata.name }))
  console.assert(name !== metadata.name, 'Program saw no race case')
}

main()

Save this to a file race.js and run it with node race.js to see that it succeeds. Switch the main() call out to do many of them, e.g:

for (let i = 0; i < 20; i++) {
    new Promise(() => main())
}

// expected output:
// Assertion failed: Program saw no race case
// Assertion failed: Program saw no race case
// Assertion failed: Program saw no race case
// Assertion failed: Program saw no race case
// Assertion failed: Program saw no race case
// Assertion failed: Program saw no race case

And it still succeeds. Run multiple processes at once and you can reliably get a race case:

// go back to just running `main()`, instead of the loop.
node race.js & node race.js && fg

// expected output:
// [1] 30057
// Assertion failed: {"name":"0.3444892547688003","metadataName":"0.15235109223203924"}
// Assertion failed: Program saw no race case
// [1]+  Done                    node race.js

Observe that one thread didn't see the same file read as it wrote, because we're running multiple simultaneously. This doesn't happen even if you put them all inside promises inside one process because under the hood v8 still processes all synchronous jobs (as is the case with your readFileSync and extract({ sync: true })) one at a time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants