fix: fix signal.aborted logic

This commit is contained in:
Nolan Lawson 2024-03-17 15:53:54 -07:00
parent 546cc9301b
commit ec880b4ac1
7 changed files with 71 additions and 64 deletions

View File

@ -21,7 +21,7 @@ import {
import { customEmojiIndex } from './customEmojiIndex'
import { cleanEmoji } from './utils/cleanEmoji'
import { loadDataForFirstTime, checkForUpdates } from './dataLoading'
import { isSignalAborted } from './utils/isSignalAborted.js'
import { abortOpportunity } from './utils/abortSignalUtils.js'
export default class Database {
constructor ({ dataSource = DEFAULT_DATA_SOURCE, locale = DEFAULT_LOCALE, customEmoji = [] } = {}) {
@ -41,13 +41,21 @@ export default class Database {
const { signal } = controller
const db = this._db = await openDatabase(this._dbName)
addOnCloseListener(this._dbName, this._clear)
if (isSignalAborted(signal)) {
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
await abortOpportunity()
}
if (signal.aborted) {
return
}
const dataSource = this.dataSource
const empty = await isEmpty(db)
if (isSignalAborted(signal)) {
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
await abortOpportunity()
}
if (signal.aborted) {
return
}

View File

@ -1,33 +1,42 @@
import { getETag, getETagAndData } from './utils/ajax'
import { jsonChecksum } from './utils/jsonChecksum'
import { hasData, loadData } from './idbInterface'
import { isSignalAborted } from './utils/isSignalAborted.js'
import { abortOpportunity } from './utils/abortSignalUtils.js'
export async function checkForUpdates (db, dataSource, signal) {
// just do a simple HEAD request first to see if the eTags match
let emojiData
let eTag = await getETag(dataSource, signal)
if (isSignalAborted(signal)) {
return
}
if (!eTag) { // work around lack of ETag/Access-Control-Expose-Headers
const eTagAndData = await getETagAndData(dataSource, signal)
if (isSignalAborted(signal)) {
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
await abortOpportunity()
}
if (signal.aborted) {
return
}
eTag = eTagAndData[0]
emojiData = eTagAndData[1]
if (!eTag) {
eTag = await jsonChecksum(emojiData)
if (isSignalAborted(signal)) {
eTag = await jsonChecksum(emojiData, signal)
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
await abortOpportunity()
}
if (signal.aborted) {
return
}
}
}
const doesHaveData = await hasData(db, dataSource, eTag)
if (isSignalAborted(signal)) {
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
await abortOpportunity()
}
if (signal.aborted) {
return
}
@ -37,7 +46,11 @@ export async function checkForUpdates (db, dataSource, signal) {
console.log('Database update available')
if (!emojiData) {
const eTagAndData = await getETagAndData(dataSource, signal)
if (isSignalAborted(signal)) {
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
await abortOpportunity()
}
if (signal.aborted) {
return
}
@ -49,15 +62,16 @@ export async function checkForUpdates (db, dataSource, signal) {
export async function loadDataForFirstTime (db, dataSource, signal) {
let [eTag, emojiData] = await getETagAndData(dataSource, signal)
if (isSignalAborted(signal)) {
return
}
if (!eTag) {
// Handle lack of support for ETag or Access-Control-Expose-Headers
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility
eTag = await jsonChecksum(emojiData)
if (isSignalAborted(signal)) {
eTag = await jsonChecksum(emojiData, signal)
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
await abortOpportunity()
}
if (signal.aborted) {
return
}
}

View File

@ -0,0 +1,9 @@
export const abortOpportunityEventTarget = /* @__PURE__ */ new EventTarget()
export async function abortOpportunity () {
// we need some way to test that someone called signal.aborted in our tests
abortOpportunityEventTarget.dispatchEvent(new CustomEvent('called'))
// Allow enough microtasks for our test code to handle the event and respond to it asynchronously
await Promise.resolve()
await Promise.resolve()
}

View File

@ -9,10 +9,6 @@ function assertStatus (response, dataSource) {
export async function getETag (dataSource, signal) {
performance.mark('getETag')
/* istanbul ignore if */
if (import.meta.env.MODE !== 'production' && !signal) {
throw new Error('signal must be defined')
}
const response = await fetch(dataSource, { method: 'HEAD', signal })
assertStatus(response, dataSource)
const eTag = response.headers.get('etag')
@ -23,10 +19,6 @@ export async function getETag (dataSource, signal) {
export async function getETagAndData (dataSource, signal) {
performance.mark('getETagAndData')
/* istanbul ignore if */
if (import.meta.env.MODE !== 'production' && !signal) {
throw new Error('signal must be defined')
}
const response = await fetch(dataSource, { signal })
assertStatus(response, dataSource)
const eTag = response.headers.get('etag')

View File

@ -1,16 +0,0 @@
export let signalAbortedEventTarget
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
signalAbortedEventTarget = new EventTarget()
}
export function isSignalAborted (signal) {
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
// we need some way to test that someone called signal.aborted in our tests
signalAbortedEventTarget.dispatchEvent(new CustomEvent('called'))
}
return signal.aborted
}

View File

@ -161,10 +161,15 @@ describe('database tests', () => {
expect((await db1.getEmojiByUnicodeOrName('🐵')).annotation).toBe('monkey face')
await tick(40)
await db2.close()
await tick(40)
expect((await db2.getEmojiByUnicodeOrName('🐵')).annotation).toBe('monkey face')
await tick(40)
const db3 = new Database({ dataSource: ALL_EMOJI })
await tick(40)
await db3.ready()
await tick(40)
await db3.delete()
await tick(40)
})
test('multiple databases in multiple locales', async () => {

View File

@ -7,22 +7,22 @@ import {
tick, truncatedFrEmoji
} from '../shared.js'
import Database from '../../../src/database/Database.js'
import { signalAbortedEventTarget } from '../../../src/database/utils/isSignalAborted.js'
import { abortOpportunityEventTarget } from '../../../src/database/utils/abortSignalUtils.js'
import { mockFetch, mockGetAndHead } from '../mockFetch.js'
const waitForSignalAbortCalledNTimes = async (n) => {
for (let i = 0; i < n; i++) {
await Promise.race([
new Promise(resolve => {
signalAbortedEventTarget.addEventListener('called', resolve, { once: true })
abortOpportunityEventTarget.addEventListener('called', resolve, { once: true })
}),
new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out waiting for signal.aborted call')), 1000))
new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out waiting for abort opportunity')), 500))
])
}
}
function runTest ({ secondLoad, dataChanged, dataSource, signalAbortedCallCount }) {
test(`throws no errors when DB is closed after ${signalAbortedCallCount} signal.aborted calls`, async () => {
function runTest ({ secondLoad, dataChanged, dataSource, abortOpportunityCount }) {
test(`throws no errors when DB is closed after ${abortOpportunityCount} signal.aborted calls`, async () => {
if (secondLoad) {
// do a throwaway first load
const db = new Database({ dataSource })
@ -37,24 +37,19 @@ function runTest ({ secondLoad, dataChanged, dataSource, signalAbortedCallCount
if (dataSource === ALL_EMOJI_NO_ETAG) {
mockGetAndHead(dataSource, truncatedFrEmoji)
} else if (dataSource === ALL_EMOJI_MISCONFIGURED_ETAG) {
mockFetch('get', dataSource, truncatedFrEmoji, { headers: { ETag: 'W/yyy' } })
mockFetch('get', dataSource, truncatedFrEmoji, { headers: { ETag: 'W/updated' } })
mockFetch('head', dataSource, null)
} else {
mockGetAndHead(dataSource, truncatedFrEmoji, { headers: { ETag: 'W/yyy' } })
mockGetAndHead(dataSource, truncatedFrEmoji, { headers: { ETag: 'W/updated' } })
}
}
const db2 = new Database({ dataSource })
await waitForSignalAbortCalledNTimes(signalAbortedCallCount)
const db = new Database({ dataSource })
await waitForSignalAbortCalledNTimes(abortOpportunityCount)
const doClose = async () => {
await db2.close()
}
if (!secondLoad && signalAbortedCallCount === 2) {
// this happens to cancel an inflight fetch request
await expect(doClose).rejects.toThrow(/The operation was aborted/)
} else {
await doClose()
await db.close()
}
await doClose()
await tick(40)
})
}
@ -73,26 +68,26 @@ describe('database timing tests', () => {
{
testName: 'basic',
dataSource: ALL_EMOJI,
maxExpectedSignalAbortedCallCount: secondLoad ? (dataChanged ? 6 : 5) : 4
maxExpectedAbortOpportunityCount: secondLoad ? 4 : 3
},
{
testName: 'misconfigured etag',
dataSource: ALL_EMOJI_MISCONFIGURED_ETAG,
maxExpectedSignalAbortedCallCount: secondLoad ? 6 : 4
maxExpectedAbortOpportunityCount: secondLoad ? 4 : 3
},
{
testName: 'no etag',
dataSource: ALL_EMOJI_NO_ETAG,
maxExpectedSignalAbortedCallCount: secondLoad ? 7 : 5
maxExpectedAbortOpportunityCount: secondLoad ? 5 : 3
}
]
scenarios.forEach(({ testName, dataSource, maxExpectedSignalAbortedCallCount }) => {
scenarios.forEach(({ testName, dataSource, maxExpectedAbortOpportunityCount }) => {
describe(testName, () => {
// Number of times somebody called the getter on `signal.aborted` which
// we are using as an easy way to get full code coverage here
const signalAbortedCallCounts = new Array(maxExpectedSignalAbortedCallCount).fill().map((_, i) => i)
signalAbortedCallCounts.forEach(signalAbortedCallCount => {
runTest({ secondLoad, dataChanged, dataSource, signalAbortedCallCount })
const abortOpportunityCounts = new Array(maxExpectedAbortOpportunityCount).fill().map((_, i) => i)
abortOpportunityCounts.forEach(abortOpportunityCount => {
runTest({ secondLoad, dataChanged, dataSource, abortOpportunityCount })
})
})
})