-
-
Notifications
You must be signed in to change notification settings - Fork 87
perf(screenshot): offload white-image detection to worker #665
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
Changes from all commits
63138d8
09bb777
8c52fb6
89ac764
0ae6b4d
661452f
df5ad61
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| 'use strict' | ||
|
|
||
| const { Jimp } = require('jimp') | ||
|
|
||
| module.exports = async uint8array => { | ||
| try { | ||
| const image = await Jimp.fromBuffer(Buffer.from(uint8array)) | ||
| const firstPixel = image.getPixelColor(0, 0) | ||
| const height = image.bitmap.height | ||
| const width = image.bitmap.width | ||
|
|
||
| // For 2D grid sampling, calculate stepSize to achieve approximately the target sample percentage. | ||
| // When sampling every 'stepSize' pixels in both dimensions, actual samples = (height/stepSize) * (width/stepSize). | ||
| // To achieve samplePercentage, we need: (h*w)/(stepSize²) ≈ samplePercentage*(h*w) | ||
| // Therefore: stepSize ≈ sqrt(1 / samplePercentage) | ||
| const samplePercentage = 0.25 // Sample ~25% of the image | ||
| const stepSize = Math.max(1, Math.ceil(Math.sqrt(1 / samplePercentage))) | ||
|
|
||
| for (let i = 0; i < height; i += stepSize) { | ||
| for (let j = 0; j < width; j += stepSize) { | ||
| if (firstPixel !== image.getPixelColor(j, i)) return false | ||
| } | ||
| } | ||
|
|
||
| return true | ||
| } catch (error) { | ||
| if (error.message.includes('maxMemoryUsageInMB')) { | ||
| return false | ||
| } | ||
| throw error | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| 'use strict' | ||
|
|
||
| const { parentPort } = require('worker_threads') | ||
|
|
||
| const analyze = require('./is-white-screenshot-analyze') | ||
|
|
||
| parentPort.on('message', async ({ id, uint8array }) => { | ||
| try { | ||
| const value = await analyze(uint8array) | ||
| parentPort.postMessage({ id, value }) | ||
| } catch (error) { | ||
| parentPort.postMessage({ | ||
| id, | ||
| error: { | ||
| message: error.message, | ||
| name: error.name | ||
| } | ||
| }) | ||
| } | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,32 +1,78 @@ | ||
| 'use strict' | ||
|
|
||
| const { Jimp } = require('jimp') | ||
| const path = require('path') | ||
| const { Worker } = require('worker_threads') | ||
|
|
||
| module.exports = async uint8array => { | ||
| try { | ||
| const image = await Jimp.fromBuffer(Buffer.from(uint8array)) | ||
| const firstPixel = image.getPixelColor(0, 0) | ||
| const height = image.bitmap.height | ||
| const width = image.bitmap.width | ||
|
|
||
| // For 2D grid sampling, calculate stepSize to achieve approximately the target sample percentage. | ||
| // When sampling every 'stepSize' pixels in both dimensions, actual samples = (height/stepSize) * (width/stepSize). | ||
| // To achieve samplePercentage, we need: (h*w)/(stepSize²) ≈ samplePercentage*(h*w) | ||
| // Therefore: stepSize ≈ sqrt(1 / samplePercentage) | ||
| const samplePercentage = 0.25 // Sample ~25% of the image | ||
| const stepSize = Math.max(1, Math.ceil(Math.sqrt(1 / samplePercentage))) | ||
|
|
||
| for (let i = 0; i < height; i += stepSize) { | ||
| for (let j = 0; j < width; j += stepSize) { | ||
| if (firstPixel !== image.getPixelColor(j, i)) return false | ||
| let worker | ||
| let messageId = 0 | ||
|
|
||
| const pending = new Map() | ||
|
|
||
| const syncWorkerRef = instance => { | ||
| if (worker !== instance) return | ||
| if (typeof instance.ref !== 'function' || typeof instance.unref !== 'function') return | ||
| if (pending.size > 0) instance.ref() | ||
| else instance.unref() | ||
| } | ||
|
|
||
| const rejectPending = error => { | ||
| for (const { reject } of pending.values()) reject(error) | ||
| pending.clear() | ||
| } | ||
|
|
||
| const getWorker = () => { | ||
| if (worker) return worker | ||
|
|
||
| const instance = new Worker(path.resolve(__dirname, './is-white-screenshot-worker.js')) | ||
| worker = instance | ||
|
|
||
| syncWorkerRef(instance) | ||
|
|
||
| instance.on('message', ({ id, value, error }) => { | ||
| const resolver = pending.get(id) | ||
| if (!resolver) return | ||
| pending.delete(id) | ||
| syncWorkerRef(instance) | ||
| if (error) { | ||
| const err = new Error(error.message) | ||
| err.name = error.name || 'Error' | ||
| return resolver.reject(err) | ||
| } | ||
| resolver.resolve(value) | ||
| }) | ||
|
|
||
| instance.on('error', error => { | ||
| if (worker === instance) { | ||
| rejectPending(error) | ||
| worker = undefined | ||
| } | ||
| }) | ||
|
|
||
| instance.on('exit', code => { | ||
| if (worker === instance) { | ||
| if (pending.size > 0) { | ||
| rejectPending(new Error(`is-white-screenshot worker exited with code ${code}`)) | ||
| } | ||
| worker = undefined | ||
| } | ||
| }) | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return instance | ||
| } | ||
|
|
||
| module.exports = async uint8array => { | ||
| return new Promise((resolve, reject) => { | ||
| const activeWorker = getWorker() | ||
| const id = ++messageId | ||
| pending.set(id, { resolve, reject }) | ||
| syncWorkerRef(activeWorker) | ||
|
|
||
| return true | ||
| } catch (error) { | ||
| if (error.message.includes('maxMemoryUsageInMB')) { | ||
| return false | ||
| try { | ||
| activeWorker.postMessage({ id, uint8array: Buffer.from(uint8array) }) | ||
| } catch (error) { | ||
| pending.delete(id) | ||
| syncWorkerRef(activeWorker) | ||
| reject(error) | ||
| } | ||
| throw error | ||
| } | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| 'use strict' | ||
|
|
||
| const { spawnSync } = require('child_process') | ||
| const path = require('path') | ||
| const test = require('ava') | ||
|
|
||
| test.serial('uses worker thread path when available', t => { | ||
| const isWhitePath = path.resolve(__dirname, '../src/is-white-screenshot.js') | ||
| const whiteFixture = path.resolve(__dirname, './fixtures/white-5k.png') | ||
| const nonWhiteFixture = path.resolve(__dirname, './fixtures/no-white-5k.png') | ||
|
|
||
| const script = ` | ||
| const fs = require('fs') | ||
| const workerThreads = require('worker_threads') | ||
| const OriginalWorker = workerThreads.Worker | ||
| let workerCount = 0 | ||
|
|
||
| workerThreads.Worker = class WorkerSpy extends OriginalWorker { | ||
| constructor (...args) { | ||
| workerCount += 1 | ||
| super(...args) | ||
| } | ||
| } | ||
|
|
||
| const isWhite = require(${JSON.stringify(isWhitePath)}) | ||
|
|
||
| Promise.all([ | ||
| isWhite(fs.readFileSync(${JSON.stringify(whiteFixture)})), | ||
| isWhite(fs.readFileSync(${JSON.stringify(nonWhiteFixture)})) | ||
| ]) | ||
| .then(([white, nonWhite]) => { | ||
| process.stdout.write(JSON.stringify({ workerCount, white, nonWhite })) | ||
| process.exit(0) | ||
| }) | ||
| .catch(error => { | ||
| console.error(error) | ||
| process.exit(1) | ||
| }) | ||
| ` | ||
|
|
||
| const { status, stdout, stderr } = spawnSync(process.execPath, ['-e', script], { | ||
| encoding: 'utf8' | ||
| }) | ||
|
|
||
| t.is(status, 0, stderr) | ||
| const result = JSON.parse(stdout.trim()) | ||
| t.true(result.workerCount >= 1) | ||
| t.true(result.white) | ||
| t.false(result.nonWhite) | ||
| }) | ||
|
|
||
| test.serial('rejects in-flight requests when worker exits with code 0', t => { | ||
| const isWhitePath = path.resolve(__dirname, '../src/is-white-screenshot.js') | ||
| const whiteFixture = path.resolve(__dirname, './fixtures/white-5k.png') | ||
|
|
||
| const script = ` | ||
| const workerThreads = require('worker_threads') | ||
| const OriginalWorker = workerThreads.Worker | ||
|
|
||
| workerThreads.Worker = class WorkerExitZero extends OriginalWorker { | ||
| constructor () { | ||
| super( | ||
| "const { parentPort } = require('worker_threads'); parentPort.on('message', () => process.exit(0))", | ||
| { eval: true } | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| const isWhite = require(${JSON.stringify(isWhitePath)}) | ||
| const screenshot = fs.readFileSync(${JSON.stringify(whiteFixture)}) | ||
| const timeout = setTimeout(() => { | ||
| process.stdout.write(JSON.stringify({ timedOut: true })) | ||
| process.exit(0) | ||
| }, 1500) | ||
|
|
||
| isWhite(screenshot) | ||
| .then(() => { | ||
| clearTimeout(timeout) | ||
| process.stdout.write(JSON.stringify({ resolved: true })) | ||
| process.exit(0) | ||
| }) | ||
| .catch(error => { | ||
| clearTimeout(timeout) | ||
| process.stdout.write(JSON.stringify({ rejected: true, message: error.message })) | ||
| process.exit(0) | ||
| }) | ||
| ` | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing
|
||
|
|
||
| const { status, stdout, stderr } = spawnSync(process.execPath, ['-e', script], { | ||
| encoding: 'utf8' | ||
| }) | ||
|
|
||
| t.is(status, 0, stderr) | ||
| const result = JSON.parse(stdout.trim()) | ||
| t.false(Boolean(result.timedOut), 'request should not hang when worker exits') | ||
| t.true(Boolean(result.rejected), 'request should be rejected on worker exit') | ||
| t.regex(result.message, /exited with code 0/) | ||
| }) | ||
|
|
||
| test.serial('stale worker exit does not reject new worker promises', t => { | ||
| const isWhitePath = path.resolve(__dirname, '../src/is-white-screenshot.js') | ||
| const tinyWhitePngBase64 = | ||
| 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEUlEQVR4AWP8DwQMQMDEAAUAPfgEADYYS7QAAAAASUVORK5CYII=' | ||
|
|
||
| const script = ` | ||
| const fs = require('fs') | ||
| const workerThreads = require('worker_threads') | ||
| const OriginalWorker = workerThreads.Worker | ||
|
|
||
| let callCount = 0 | ||
| let firstErrorMessage | ||
|
|
||
| workerThreads.Worker = class WorkerCrashOnce extends OriginalWorker { | ||
| constructor (...args) { | ||
| callCount += 1 | ||
| if (callCount === 1) { | ||
| // First worker crashes immediately on any message | ||
| super( | ||
| "const { parentPort } = require('worker_threads'); parentPort.on('message', () => { throw new Error('boom') })", | ||
| { eval: true } | ||
| ) | ||
| } else { | ||
| // Subsequent workers behave normally | ||
| super(...args) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const isWhite = require(${JSON.stringify(isWhitePath)}) | ||
| const screenshot = Buffer.from(${JSON.stringify(tinyWhitePngBase64)}, 'base64') | ||
|
|
||
| const timeout = setTimeout(() => { | ||
| process.stdout.write(JSON.stringify({ timedOut: true, callCount })) | ||
| process.exit(1) | ||
| }, 5000) | ||
|
|
||
| process.on('uncaughtException', error => { | ||
| clearTimeout(timeout) | ||
| process.stdout.write( | ||
| JSON.stringify({ | ||
| ok: false, | ||
| uncaughtException: true, | ||
| message: error.message, | ||
| stack: error.stack, | ||
| callCount, | ||
| firstErrorMessage | ||
| }) | ||
| ) | ||
| process.exit(1) | ||
| }) | ||
|
|
||
| process.on('unhandledRejection', error => { | ||
| clearTimeout(timeout) | ||
| process.stdout.write( | ||
| JSON.stringify({ | ||
| ok: false, | ||
| unhandledRejection: true, | ||
| message: error && error.message, | ||
| stack: error && error.stack, | ||
| callCount, | ||
| firstErrorMessage | ||
| }) | ||
| ) | ||
| process.exit(1) | ||
| }) | ||
|
|
||
| isWhite(screenshot) | ||
| .catch(error => { | ||
| firstErrorMessage = error && error.message | ||
| return isWhite(screenshot) | ||
| }) | ||
| .then(result => { | ||
| clearTimeout(timeout) | ||
| process.stdout.write(JSON.stringify({ ok: true, result, callCount, firstErrorMessage })) | ||
| process.exit(0) | ||
| }) | ||
| .catch(error => { | ||
| clearTimeout(timeout) | ||
| process.stdout.write( | ||
| JSON.stringify({ | ||
| ok: false, | ||
| message: error.message, | ||
| stack: error.stack, | ||
| callCount, | ||
| firstErrorMessage | ||
| }) | ||
| ) | ||
| process.exit(1) | ||
| }) | ||
| ` | ||
|
|
||
| const { status, stdout, stderr, signal, error } = spawnSync(process.execPath, ['-e', script], { | ||
| encoding: 'utf8', | ||
| timeout: 10000 | ||
| }) | ||
|
|
||
| const debugPayload = JSON.stringify( | ||
| { | ||
| status, | ||
| signal, | ||
| error: error | ||
| ? { | ||
| message: error.message, | ||
| name: error.name, | ||
| code: error.code | ||
| } | ||
| : null, | ||
| stdout: stdout.trim(), | ||
| stderr: stderr.trim() | ||
| }, | ||
| null, | ||
| 2 | ||
| ) | ||
|
|
||
| t.is(status, 0, debugPayload) | ||
| const result = JSON.parse(stdout.trim()) | ||
| t.false(Boolean(result.timedOut), 'retry should not hang') | ||
| t.true(result.ok, `retry should succeed, got: ${result.message || 'no error'}`) | ||
| t.is(result.callCount, 2, 'should have spawned exactly 2 workers') | ||
| t.true(result.result, 'white screenshot should be detected as white') | ||
| }) | ||


Uh oh!
There was an error while loading. Please reload this page.