pinokiod 8.0.15 → 8.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -185,7 +185,9 @@ report_errors: false`)
185
185
  install_path_exists = await this.kernel.exists(`bin/${CONDA_ROOT_DIR}`)
186
186
  if (install_path_exists) {
187
187
  console.log("Removing existing install path...", install_path)
188
+ ondata({ raw: `removing existing Miniforge installation...\r\n` })
188
189
  await this.removeInstallPath(install_path, ondata)
190
+ ondata({ raw: `existing Miniforge installation removed.\r\n` })
189
191
  }
190
192
 
191
193
  // 2. run the script
@@ -715,7 +715,22 @@ class Bin {
715
715
  let res = await Util.filepicker(req, ondata, this.kernel)
716
716
  return res
717
717
  }
718
+ async runInstallExclusive(task) {
719
+ const previous = this.installQueue || Promise.resolve()
720
+ let release
721
+ const current = new Promise((resolve) => { release = resolve })
722
+ this.installQueue = previous.then(() => current)
723
+ await previous
724
+ try {
725
+ return await task()
726
+ } finally {
727
+ release()
728
+ }
729
+ }
718
730
  async install(req, ondata) {
731
+ return await this.runInstallExclusive(() => this.installRequirements(req, ondata))
732
+ }
733
+ async installRequirements(req, ondata) {
719
734
  /*
720
735
  req.params := {
721
736
  client: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.15",
3
+ "version": "8.0.17",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -16303,26 +16303,35 @@ class Server {
16303
16303
  }
16304
16304
  try {
16305
16305
  if (req.body.type === 'conda-runtime') {
16306
- for (const runtimeName of ["miniconda", "miniforge"]) {
16307
- const runtimePath = this.kernel.path("bin", runtimeName)
16308
- await removePath(runtimePath, {
16306
+ await this.kernel.bin.runInstallExclusive(async () => {
16307
+ for (const runtimeName of ["miniconda", "miniforge"]) {
16308
+ const runtimePath = this.kernel.path("bin", runtimeName)
16309
+ await removePath(runtimePath, {
16310
+ recursive: true,
16311
+ force: true,
16312
+ diagnose: req.body.background !== true,
16313
+ operation: "Updating Miniforge",
16314
+ })
16315
+ }
16316
+ })
16317
+ res.json({ success: true })
16318
+ } else if (req.body.type === 'bin') {
16319
+ let folderPath = this.kernel.path("bin")
16320
+ await this.kernel.bin.runInstallExclusive(async () => {
16321
+ const removalStartedAt = Date.now()
16322
+ console.log(`[fresh install] removing bin: start ${folderPath}`)
16323
+ await removePath(folderPath, {
16309
16324
  recursive: true,
16310
16325
  force: true,
16311
16326
  diagnose: req.body.background !== true,
16312
- operation: "Updating Miniforge",
16327
+ operation: "Resetting managed tools",
16313
16328
  })
16314
- }
16315
- res.json({ success: true })
16316
- } else if (req.body.type === 'bin') {
16317
- let folderPath = this.kernel.path("bin")
16318
- await removePath(folderPath, {
16319
- recursive: true,
16320
- force: true,
16321
- diagnose: req.body.background !== true,
16322
- operation: "Resetting managed tools",
16329
+ console.log(`[fresh install] removing bin: complete (${((Date.now() - removalStartedAt) / 1000).toFixed(1)}s)`)
16330
+ await fs.promises.mkdir(folderPath, { recursive: true }).catch((e) => { })
16331
+ const refreshStartedAt = Date.now()
16332
+ await this.kernel.bin.refreshInstalled()
16333
+ console.log(`[fresh install] refreshing installed state: complete (${((Date.now() - refreshStartedAt) / 1000).toFixed(1)}s)`)
16323
16334
  })
16324
- await fs.promises.mkdir(folderPath, { recursive: true }).catch((e) => { })
16325
- await this.kernel.bin.refreshInstalled()
16326
16335
  res.json({ success: true })
16327
16336
  } else if (req.body.type === 'cache') {
16328
16337
  let folderPath = this.kernel.path("cache")
@@ -16619,7 +16628,7 @@ class Server {
16619
16628
  this.app.post("/pinokio/install", ex((req, res) => {
16620
16629
  req.session.requirements = req.body.requirements
16621
16630
  req.session.callback = req.body.callback
16622
- res.redirect("/pinokio/install")
16631
+ res.redirect(req.query.fresh === "1" ? "/pinokio/install?fresh=1" : "/pinokio/install")
16623
16632
  }))
16624
16633
  this.app.post("/pinokio/install/validate", ex(async (req, res) => {
16625
16634
  res.json({
@@ -819,6 +819,29 @@ document.addEventListener("DOMContentLoaded", async () => {
819
819
  options[key] = value;
820
820
  }
821
821
  }
822
+ if (options && options.fresh === "1") {
823
+ setInstallStatus("<b>Removing existing tools...</b><br>This can take a minute on Windows.")
824
+ try {
825
+ const result = await PinokioPathRemoval.request((background) => fetch("/pinokio/delete", {
826
+ method: "post",
827
+ headers: { "Content-Type": "application/json" },
828
+ body: JSON.stringify({ type: "bin", background })
829
+ }))
830
+ if (result.cancelled) {
831
+ navigateToCallback()
832
+ return
833
+ }
834
+ if (result.error) throw new Error(result.error)
835
+ } catch (error) {
836
+ ModalInput({
837
+ title: "Error",
838
+ type: "modal",
839
+ description: error && error.message ? error.message : "Unable to reset managed tools."
840
+ })
841
+ return
842
+ }
843
+ setInstallStatus("<b>Installing tools...</b><br>Starting the fresh installation.")
844
+ }
822
845
  term.onData((data) => {
823
846
  if (shell_id) {
824
847
  socket.emit({
@@ -179,7 +179,7 @@
179
179
 
180
180
  <div class="setup-secondary-actions">
181
181
  <span class="task-inline-help">Not working?</span>
182
- <button class="task-button" type="submit" form="install-form">
182
+ <button class="task-button" type="submit" form="install-form" formaction="/pinokio/install?fresh=1">
183
183
  <i class="fa-solid fa-trash" aria-hidden="true"></i>
184
184
  <span>Try a fresh install</span>
185
185
  </button>
@@ -495,7 +495,7 @@
495
495
  <span>Install</span>
496
496
  </a>
497
497
  <% } else { %>
498
- <form method="post" action="/pinokio/install">
498
+ <form method="post" action="/pinokio/install?fresh=1">
499
499
  <input type="hidden" name="requirements" value="<%= JSON.stringify(bundle.requirements) %>">
500
500
  <input type="hidden" name="callback" value="/tools">
501
501
  <button class="task-link-button" type="submit">
@@ -425,6 +425,84 @@ test('Conda install keeps the old runtime when the replacement installer downloa
425
425
  assert.equal(await pathExists(oldRuntimeFile), true)
426
426
  })
427
427
 
428
+ test('Conda waits for runtime removal before starting the installer', async () => {
429
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-conda-removal-order-'))
430
+ await createMiniforge(root)
431
+ const kernel = createKernel(root, 'win32')
432
+ const events = []
433
+ const output = []
434
+ let releaseRemoval
435
+ let removalStarted
436
+ const removalGate = new Promise((resolve) => { releaseRemoval = resolve })
437
+ const removalStartedPromise = new Promise((resolve) => { removalStarted = resolve })
438
+
439
+ kernel.bin.mods = []
440
+ kernel.bin.download = async () => events.push('download')
441
+ kernel.bin.rm = async () => events.push('installer-cleanup')
442
+ kernel.bin.exec = async (payload) => {
443
+ events.push(payload && payload.conda && payload.conda.skip ? 'installer' : 'conda-install')
444
+ if (payload && payload.conda && payload.conda.skip) {
445
+ await fs.mkdir(path.join(root, 'bin', 'miniforge', 'conda-meta'), { recursive: true })
446
+ await fs.writeFile(path.join(root, 'bin', 'miniforge', 'python.exe'), 'fake python\n')
447
+ }
448
+ }
449
+
450
+ const conda = createConda(kernel)
451
+ const removeInstallPath = conda.removeInstallPath.bind(conda)
452
+ conda.removeInstallPath = async (...args) => {
453
+ events.push('remove-start')
454
+ removalStarted()
455
+ await removalGate
456
+ await removeInstallPath(...args)
457
+ events.push('remove-complete')
458
+ }
459
+
460
+ const installation = conda._install({ dependencies: [] }, (data) => {
461
+ if (data && data.raw) output.push(data.raw)
462
+ })
463
+ await removalStartedPromise
464
+
465
+ assert.deepEqual(events, ['download', 'remove-start'])
466
+ assert.match(output.join(''), /removing existing Miniforge installation/)
467
+
468
+ releaseRemoval()
469
+ await installation
470
+
471
+ assert.ok(events.indexOf('remove-complete') < events.indexOf('installer'))
472
+ assert.match(output.join(''), /existing Miniforge installation removed/)
473
+ })
474
+
475
+ test('Bin serializes concurrent installs without dropping either request', async () => {
476
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-bin-install-single-flight-'))
477
+ const bin = createBin(root)
478
+ const events = []
479
+ let release
480
+ const gate = new Promise((resolve) => { release = resolve })
481
+
482
+ bin.installRequirements = async (req) => {
483
+ events.push(`start:${req.id}`)
484
+ if (req.id === 'first') await gate
485
+ events.push(`finish:${req.id}`)
486
+ return req.id
487
+ }
488
+
489
+ const first = bin.install({ id: 'first' }, () => {})
490
+ const second = bin.install({ id: 'second' }, () => {})
491
+ await Promise.resolve()
492
+ assert.deepEqual(events, ['start:first'])
493
+
494
+ release()
495
+ assert.deepEqual(await Promise.all([first, second]), ['first', 'second'])
496
+ assert.deepEqual(events, ['start:first', 'finish:first', 'start:second', 'finish:second'])
497
+
498
+ bin.installRequirements = async (req) => {
499
+ if (req.id === 'failure') throw new Error('install failed')
500
+ return req.id
501
+ }
502
+ await assert.rejects(bin.install({ id: 'failure' }, () => {}), /install failed/)
503
+ assert.equal(await bin.install({ id: 'after-failure' }, () => {}), 'after-failure')
504
+ })
505
+
428
506
  test('Conda install propagates bootstrap failure after replacing the runtime', async () => {
429
507
  const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pinokio-conda-bootstrap-fails-'))
430
508
  await createMiniforge(root)
@@ -457,3 +457,62 @@ test("successful runtime cleanup resubmits the interrupted install", async (t) =
457
457
  fields: { requirements, callback: "/tools" },
458
458
  })
459
459
  })
460
+
461
+ test("fresh install shows removal status and waits for cleanup before starting", async (t) => {
462
+ const source = await fs.readFile(installViewPath, "utf8")
463
+ const html = ejs.render(source, {
464
+ agent: "web",
465
+ callback: "/tools",
466
+ requirements: '[{"name":"conda"}]',
467
+ theme: "dark",
468
+ }, { filename: installViewPath })
469
+
470
+ let cleanupStarted = false
471
+ let resolveCleanup
472
+ let socketStarted = false
473
+ const cleanup = new Promise((resolve) => { resolveCleanup = resolve })
474
+ const dom = new JSDOM(html, {
475
+ runScripts: "dangerously",
476
+ url: "http://localhost/pinokio/install?fresh=1",
477
+ beforeParse(window) {
478
+ window.fetch = async () => ({ json: async () => ({ config: {} }) })
479
+ window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} })
480
+ window.ResizeObserver = class { observe() {} }
481
+ window.N = class { Noty() {} }
482
+ window.Terminal = class {
483
+ constructor() { this.cols = 80; this.rows = 24 }
484
+ attachCustomKeyEventHandler() {}
485
+ focus() {}
486
+ hasSelection() { return false }
487
+ loadAddon() {}
488
+ onData() {}
489
+ open() {}
490
+ write() {}
491
+ }
492
+ window.FitAddon = { FitAddon: class { fit() {} } }
493
+ window.WebLinksAddon = { WebLinksAddon: class {} }
494
+ window.xtermTheme = { FrontEndDelight: {} }
495
+ window.PinokioTouch = { bindTerminalFocus() {} }
496
+ window.PinokioPathRemoval = {
497
+ request: async () => {
498
+ cleanupStarted = true
499
+ return await cleanup
500
+ },
501
+ }
502
+ window.Socket = class {
503
+ emit() {}
504
+ run() { socketStarted = true }
505
+ }
506
+ },
507
+ })
508
+ t.after(() => dom.window.close())
509
+
510
+ await waitFor(() => cleanupStarted, "fresh cleanup request")
511
+ assert.match(dom.window.document.querySelector("#status-screen").textContent, /Removing existing tools/)
512
+ assert.match(dom.window.document.querySelector("#status-screen").textContent, /take a minute on Windows/)
513
+ assert.equal(socketStarted, false)
514
+
515
+ resolveCleanup({ success: true })
516
+ await waitFor(() => socketStarted, "installer socket")
517
+ assert.match(dom.window.document.querySelector("#status-screen").textContent, /Installing tools/)
518
+ })
@@ -36,11 +36,11 @@ test("Tools reinstall immediately submits a fresh install request", async (t) =>
36
36
  })
37
37
  t.after(() => dom.window.close())
38
38
 
39
- const form = dom.window.document.querySelector('.tools-bundle-actions form[action="/pinokio/install"]')
39
+ const form = dom.window.document.querySelector('.tools-bundle-actions form[action="/pinokio/install?fresh=1"]')
40
40
  form.addEventListener("submit", (event) => {
41
41
  event.preventDefault()
42
42
  submission = {
43
- action: new URL(form.action).pathname,
43
+ action: `${new URL(form.action).pathname}${new URL(form.action).search}`,
44
44
  fields: Object.fromEntries(new dom.window.FormData(form)),
45
45
  }
46
46
  })
@@ -48,7 +48,7 @@ test("Tools reinstall immediately submits a fresh install request", async (t) =>
48
48
  button.click()
49
49
 
50
50
  assert.deepEqual(submission, {
51
- action: "/pinokio/install",
51
+ action: "/pinokio/install?fresh=1",
52
52
  fields: {
53
53
  requirements: '[{"name":"conda","type":"bin","installed":true}]',
54
54
  callback: "/tools",
@@ -58,6 +58,6 @@ test("Tools reinstall immediately submits a fresh install request", async (t) =>
58
58
 
59
59
  test("setup fresh install immediately submits the existing install form", async () => {
60
60
  const source = await fs.readFile(setupViewPath, "utf8")
61
- assert.match(source, /<button class="task-button" type="submit" form="install-form">/)
61
+ assert.match(source, /<button class="task-button" type="submit" form="install-form" formaction="\/pinokio\/install\?fresh=1">/)
62
62
  assert.doesNotMatch(source, /id="del-bin"|setup-reset-loading/)
63
63
  })