From 5113089abad28e4e208ce0208ba189bfb1a84f62 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:18:57 +0200 Subject: [PATCH] fix(managed-tools): wait for checksum handles before promotion Delay managed-tool installation promotion until checksum read streams have emitted close, preventing intermittent Windows rename failures caused by open file handles. Add a deterministic end-before-close regression test and prepare the 1.0.9 patch release across package metadata, UI version text, README, changelog, and release contracts. --- CHANGELOG.md | 4 ++ README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/dev.mjs | 2 +- scripts/smoke-test-public-release-config.js | 8 ++-- src/index.html | 2 +- src/main/domain/managed-tools.test.ts | 46 ++++++++++++++++++++- src/main/domain/managed-tools.ts | 8 +++- 9 files changed, 65 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f9b15..21c180b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.0.9 - 2026-08-12 + +- Waited for managed-tool checksum streams to close before promoting verified installations, preventing intermittent Windows repair failures caused by open file handles. + ## 1.0.8 - 2026-08-12 - Updated the Windows CI runtime to Node.js 24.11.1, resolving the SQLite native-module crash that interrupted verification workers on Node.js 22.13.0. diff --git a/README.md b/README.md index 79a7558..961ca87 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The application works in public mode without a Twitch login. Connecting a Twitch ## Installation 1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest). -2. Download `Twitch-VOD-Manager-Setup-1.0.8.exe`. +2. Download `Twitch-VOD-Manager-Setup-1.0.9.exe`. 3. Run the installer and choose the installation directory. 4. Start Twitch VOD Manager and add a streamer. diff --git a/package-lock.json b/package-lock.json index 97424b0..5f7f2b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "1.0.8", + "version": "1.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "1.0.8", + "version": "1.0.9", "license": "MIT", "dependencies": { "axios": "^1.16.1", diff --git a/package.json b/package.json index 8986786..04a16f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "1.0.8", + "version": "1.0.9", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "Sucukdeluxe", diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 6fd7f56..53327d5 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -93,7 +93,7 @@ if (process.platform === 'win32') { sourcePath: electronSourceExecutable, destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'), iconPath: resolve(rootDirectory, 'build', 'icon.ico'), - version: '1.0.8', + version: '1.0.9', }); } diff --git a/scripts/smoke-test-public-release-config.js b/scripts/smoke-test-public-release-config.js index ff96b3c..fe6b6b1 100644 --- a/scripts/smoke-test-public-release-config.js +++ b/scripts/smoke-test-public-release-config.js @@ -15,9 +15,9 @@ function check(condition, message) { if (!condition) failures.push(message); } -check(packageJson.version === '1.0.8', `package version is ${packageJson.version}`); -check(packageLock.version === '1.0.8', `lockfile version is ${packageLock.version}`); -check(packageLock.packages?.['']?.version === '1.0.8', `lockfile root package version is ${packageLock.packages?.['']?.version}`); +check(packageJson.version === '1.0.9', `package version is ${packageJson.version}`); +check(packageLock.version === '1.0.9', `lockfile version is ${packageLock.version}`); +check(packageLock.packages?.['']?.version === '1.0.9', `lockfile root package version is ${packageLock.packages?.['']?.version}`); check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`); check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`); check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`); @@ -62,7 +62,7 @@ check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing'); check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing'); check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source'); -check(indexSource.includes('Version: v1.0.8'), 'initial version label is not 1.0.8'); +check(indexSource.includes('Version: v1.0.9'), 'initial version label is not 1.0.9'); check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present'); check(fs.existsSync(manifestPath), 'public release manifest is missing'); diff --git a/src/index.html b/src/index.html index 9eb2f58..2d99d08 100644 --- a/src/index.html +++ b/src/index.html @@ -940,7 +940,7 @@ diff --git a/src/main/domain/managed-tools.test.ts b/src/main/domain/managed-tools.test.ts index 1d81dd8..6d45ba6 100644 --- a/src/main/domain/managed-tools.test.ts +++ b/src/main/domain/managed-tools.test.ts @@ -1,16 +1,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const fileSystemSpies = vi.hoisted(() => ({ - readFileSync: vi.fn() + readFileSync: vi.fn(), + createReadStream: vi.fn() })); vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); fileSystemSpies.readFileSync.mockImplementation(actual.readFileSync); - return { ...actual, readFileSync: fileSystemSpies.readFileSync }; + fileSystemSpies.createReadStream.mockImplementation(actual.createReadStream); + return { + ...actual, + readFileSync: fileSystemSpies.readFileSync, + createReadStream: fileSystemSpies.createReadStream + }; }); import * as crypto from 'node:crypto'; +import { EventEmitter } from 'node:events'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -380,4 +387,39 @@ describe('managed tool installer', () => { await expect(installer.status(manifest())).resolves.toMatchObject({ state: 'corrupt', verified: false }); expect(fileSystemSpies.readFileSync).not.toHaveBeenCalled(); }); + + it('waits for hash streams to close before promoting a verified installation', async () => { + const createReadStream = fileSystemSpies.createReadStream.getMockImplementation(); + if (!createReadStream) throw new Error('createReadStream mock is not initialized'); + let openStreams = 0; + fileSystemSpies.createReadStream.mockImplementation((filePath: fs.PathLike) => { + const stream = new EventEmitter(); + openStreams += 1; + queueMicrotask(() => { + stream.emit('data', fs.readFileSync(filePath)); + stream.emit('end'); + setImmediate(() => { + openStreams -= 1; + stream.emit('close'); + }); + }); + return stream as fs.ReadStream; + }); + + const installPath = path.join(directory, 'installed'); + const { installer } = createInstaller({ + rename: (sourcePath, destinationPath) => { + if (sourcePath.includes('.stage-') && destinationPath === installPath && openStreams > 0) { + throw new Error('hash stream still open'); + } + fs.renameSync(sourcePath, destinationPath); + } + }); + + try { + await expect(installer.repair(manifest())).resolves.toMatchObject({ success: true }); + } finally { + fileSystemSpies.createReadStream.mockImplementation(createReadStream); + } + }); }); diff --git a/src/main/domain/managed-tools.ts b/src/main/domain/managed-tools.ts index 8cf0500..97c1b02 100644 --- a/src/main/domain/managed-tools.ts +++ b/src/main/domain/managed-tools.ts @@ -96,11 +96,17 @@ function sha256File(filePath: string): Promise { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); const stream = fs.createReadStream(filePath); + let digest: string | null = null; stream.on('data', (chunk) => { hash.update(chunk); }); stream.once('error', reject); - stream.once('end', () => resolve(hash.digest('hex'))); + stream.once('end', () => { + digest = hash.digest('hex'); + }); + stream.once('close', () => { + if (digest !== null) resolve(digest); + }); }); }