diff --git a/src/objectid.ts b/src/objectid.ts index a84a5790..db902eda 100644 --- a/src/objectid.ts +++ b/src/objectid.ts @@ -4,9 +4,6 @@ import { type InspectFn, defaultInspect } from './parser/utils'; import { ByteUtils } from './utils/byte_utils'; import { NumberUtils } from './utils/number_utils'; -// Unique sequence for the current process (initialized on first use) -let PROCESS_UNIQUE: Uint8Array | null = null; - /** ObjectId hexString cache @internal */ const __idCache = new WeakMap(); // TODO(NODE-6549): convert this to #__id private field when target updated to ES2022 @@ -33,7 +30,28 @@ export class ObjectId extends BSONValue { } /** @internal */ - private static index = Math.floor(Math.random() * 0xffffff); + private static index = 0; + + /** Unique sequence for the current process (initialized on first use) + * @internal + */ + private static PROCESS_UNIQUE: Uint8Array | null = null; + + /** @internal */ + private static resetState = (): void => { + this.index = Math.floor(Math.random() * 0x1000000); + this.PROCESS_UNIQUE = null; + }; + + static { + this.resetState(); + // https://nodejs.org/api/v8.html#startup-snapshot-api + // @ts-expect-error Node.js types not present since this is an optional API + const { startupSnapshot } = globalThis?.process?.getBuiltinModule('v8') ?? {}; + if (startupSnapshot?.isBuildingSnapshot()) { + startupSnapshot?.addDeserializeCallback(this.resetState); + } + } static cacheHexString: boolean; @@ -178,7 +196,7 @@ export class ObjectId extends BSONValue { * @internal */ private static getInc(): number { - return (ObjectId.index = (ObjectId.index + 1) % 0xffffff); + return (ObjectId.index = (ObjectId.index + 1) % 0x1000000); } /** @@ -198,9 +216,7 @@ export class ObjectId extends BSONValue { NumberUtils.setInt32BE(buffer, 0, time); // set PROCESS_UNIQUE if yet not initialized - if (PROCESS_UNIQUE === null) { - PROCESS_UNIQUE = ByteUtils.randomBytes(5); - } + const PROCESS_UNIQUE = (this.PROCESS_UNIQUE ??= ByteUtils.randomBytes(5)); // 5-byte process unique buffer[4] = PROCESS_UNIQUE[0]; diff --git a/test/node/startup_snapshot.test.ts b/test/node/startup_snapshot.test.ts new file mode 100644 index 00000000..4fcc4766 --- /dev/null +++ b/test/node/startup_snapshot.test.ts @@ -0,0 +1,90 @@ +import * as child_process from 'node:child_process'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { expect } from 'chai'; + +describe('snapshot support', () => { + let tmpdir: string; + + beforeEach(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), 'js-bson-snapshot-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpdir, { recursive: true, force: true }); + }); + + // Regression test for https://jira.mongodb.org/browse/MONGOSH-3277 + it('should reset relevant state when using startup snapshot', async () => { + // Build a startup snapshot including the BSON library and an example ObjectId + const bsonBundleSource = path.join(__dirname, '..', '..', 'lib', 'bson.bundle.js'); + await fs.writeFile( + path.join(tmpdir, 'snapshot_main.cjs'), + ` + ${await fs.readFile(bsonBundleSource, 'utf8')} + const { startupSnapshot } = require('v8'); + globalThis.pid = new BSON.ObjectId(); + startupSnapshot.setDeserializeMainFunction(() => { + console.log(globalThis.pid.toHexString()); + console.log(new BSON.ObjectId().toHexString()); + }); + ` + ); + await promisify(child_process.execFile)( + process.execPath, + ['--snapshot-blob', 'snapshot.blob', '--build-snapshot', 'snapshot_main.cjs'], + { + cwd: tmpdir, + encoding: 'utf8' + } + ); + + // Run the resulting snapshot twice to compare + const stdout1 = ( + await promisify(child_process.execFile)( + process.execPath, + ['--snapshot-blob', 'snapshot.blob'], + { + cwd: tmpdir, + encoding: 'utf8' + } + ) + ).stdout + .trim() + .split('\n'); + const stdout2 = ( + await promisify(child_process.execFile)( + process.execPath, + ['--snapshot-blob', 'snapshot.blob'], + { + cwd: tmpdir, + encoding: 'utf8' + } + ) + ).stdout + .trim() + .split('\n'); + + // Each process should print two values + expect(stdout1).to.have.lengthOf(2); + expect(stdout2).to.have.lengthOf(2); + + // The in-snapshot ObjectId is persisted + expect(stdout1[0]).to.equal(stdout2[0]); + + // We get different per-process values (counter and process unique) + // created after deserialization + const [oid1, oid2] = [stdout1[1], stdout2[1]].map( + oid => oid.match(/^(?\w{8})(?\w{10})(?\w{6})$/)!.groups! + ); + + // Less than 20 seconds between timestamps should be plenty of leeway + expect(Math.abs(parseInt(oid2.ts, 16) - parseInt(oid1.ts, 16))).to.be.lessThan(20); + // Distinct process unique values + expect(oid1.uniq).to.not.equal(oid2.uniq); + // Distinct counter values + expect(oid1.counter).to.not.equal(oid2.counter); + }); +});