Skip to content

[Bug]: Jest behaves non-deterministically with decorators. #15890

@iacobucci

Description

@iacobucci

Version

30.2.0

Steps to reproduce

In a ts project such as this, i find no problems when compiling and executing:

import { bin } from "./utils";
import { Register, Registers } from "./register";
import { InstructionRegistry } from "./instructionRegistry";
import { SubInstruction } from "./instructions/rtype";
let a = new SubInstruction(Registers.get(1), Registers.get(2), Registers.get(3))
for (let el of InstructionRegistry.getInstance().getBinaryRegistry().keys()) {
let i = InstructionRegistry.getInstance().getBinaryRegistry().get(el) as any;
if (i) {
let b = bin(el, 32);
console.log(b, i.tag, i.opcode, i.f3, i.f7);
}
}

// instructionRegistry.ts

import { Instruction } from "./instruction";
export class InstructionRegistry {
private static instance: InstructionRegistry;
private binaryRegistry = new Map<number, typeof Instruction>();
private tagRegistry = new Map<string, typeof Instruction>();
private constructor() { }
static getInstance(): InstructionRegistry {
if (!InstructionRegistry.instance) {
InstructionRegistry.instance = new InstructionRegistry();
}
return InstructionRegistry.instance;
}
static key(opcode: number, f3: number | null, f7: number | null): number {
const f3Val = f3 ?? 0;
const f7Val = f7 ?? 0;
return (f7Val << 10) | (f3Val << 7) | opcode;
}
static register(f: Function) {
const i = f as typeof Instruction;
const key = InstructionRegistry.key(i.opcode, i.f3, i.f7);
console.log("registring " + i + " with key " + key);
InstructionRegistry.getInstance().binaryRegistry.set(key, i);
InstructionRegistry.getInstance().tagRegistry.set(i.tag, i);
}
getBinaryRegistry(): Map<number, typeof Instruction> {
return this.binaryRegistry;
}
getTagRegistry(): Map<string, typeof Instruction> {
return this.tagRegistry;
}
}

// instruction.ts

import { InstructionRegistry } from "./instructionRegistry";

export abstract class Instruction {
abstract execute(): void;
abstract encode(): number;
abstract disassemble(): string;
static opcode: number = 0;
static f3: number | null = null;
static f7: number | null = null;
static tag: string = "";
// get fields for instances
get opcode(): number {
return (this.constructor as typeof Instruction).opcode;
}
get f3(): number | null {
return (this.constructor as typeof Instruction).f3;
}
get f7(): number | null {
return (this.constructor as typeof Instruction).f7;
}
get tag(): string {
return (this.constructor as typeof Instruction).tag;
}

static decode(encoded: number): Instruction {
let opcode = 0;
let f3: number | null = null;
let f7: number | null = null;
let key: number;
let instructionClass: typeof Instruction;
let possibleInstructionClass: typeof Instruction | undefined;
let prepareDecoding = () => { return (instructionClass as any).factoryFromBinary(encoded) }
opcode = encoded & 0b1111111;
key = InstructionRegistry.key(opcode, f3, f7);
possibleInstructionClass = InstructionRegistry.getInstance().getBinaryRegistry().get(key);
if (!possibleInstructionClass)
throw new Error("opcode not found");
instructionClass = possibleInstructionClass;
f3 = (encoded >> 12) & 0b111;
key = InstructionRegistry.key(opcode, f3, f7);
possibleInstructionClass = InstructionRegistry.getInstance().getBinaryRegistry().get(key);
if (possibleInstructionClass)
instructionClass = possibleInstructionClass;
f7 = (encoded >> 25) & 0b1111111;
key = InstructionRegistry.key(opcode, f3, f7);
possibleInstructionClass = InstructionRegistry.getInstance().getBinaryRegistry().get(key);
if (possibleInstructionClass)
instructionClass = possibleInstructionClass;
if (!instructionClass) {
throw new Error(Unknown instruction: opcode=${opcode}, f3=${f3}, f7=${f7});
}
return prepareDecoding();
}
static factoryFromBinary(encoded: number): Instruction {
throw new Error("factory must be implemented by subclass");
}
/**
 * a static method to parse the assembly line into an object representation.
 */
static assemble(line: string): Instruction {
let tag = line.split(" ")[0];
let parameters = line.substr(line.indexOf(" ") + 1);
let instructionClass = InstructionRegistry.getInstance().getTagRegistry().get(tag);
if (!instructionClass)
throw new Error(instruction ${tag} not implemented.);
return (instructionClass as any).factoryFromBinary(parameters);
}
static factoryFromTag(parameters: string): Instruction {
throw new Error("factory must be implemented by subclass");
}
}

// rtype.ts

import { Instruction } from "../instruction"
import { InstructionRegistry } from "../instructionRegistry";
import { Register, Registers } from "../register";
abstract class RTypeInstruction extends Instruction {
destination: Register;
source1: Register;
source2: Register;
static opcode = 0b0110011;
constructor(destination: Register, source1: Register, source2: Register) {
super();
this.destination = destination;
this.source1 = source1;
this.source2 = source2;
}
encode(): number {
let encoded = 0;
let shift = 0;
encoded += this.opcode;
shift += 7;
encoded += this.destination.index << shift;
shift += 5;
encoded += (this.f3 || 0) << shift;
shift += 3;
encoded += this.source1.index << shift;
shift += 5;
encoded += this.source2.index << shift;
shift += 5;
encoded += (this.f7 || 0) << shift;
return encoded;
}
static factoryFromBinary(encoded: number): Instruction {
const rd = (encoded >> 7) & 0b11111;
const rs1 = (encoded >> 15) & 0b11111;
const rs2 = (encoded >> 20) & 0b11111;
return new (this as any)(
Registers.get(rd),
Registers.get(rs1),
Registers.get(rs2)
) as Instruction;
}
disassemble(): string {
return ${this.tag} ${this.destination}, ${this.source1}, ${this.source2}
}
static factoryFromAssembly(parameters: string): Instruction {
let p = parameters.split(",");
const destination = Registers.parse(p[0])
const source1 = Registers.parse(p[1]);
const source2 = Registers.parse(p[2]);
return new (this as any)(
destination,
source1,
source2
) as Instruction;
}
}
@InstructionRegistry.register
export class AddInstruction extends RTypeInstruction {
static tag = "add";
execute(): void {
this.destination.value = this.source1.value + this.source2.value;
}
}
@InstructionRegistry.register
export class SubInstruction extends RTypeInstruction {
static tag = "sub";
static f7 = 0b0100000;
execute(): void {
this.destination.value = this.source1.value - this.source2.value;
}
}
@InstructionRegistry.register
export class XorInstruction extends RTypeInstruction {
static tag = "xor";
static f3 = 0b100;
execute(): void {
this.destination.value = this.source1.value ^ this.source2.value;
}
}
@InstructionRegistry.register
export class OrInstruction extends RTypeInstruction {
static tag = "or";
static f3 = 0b110;
execute(): void {
this.destination.value = this.source1.value | this.source2.value;
}
}
@InstructionRegistry.register
export class AndInstruction extends RTypeInstruction {
static tag = "and";
static f3 = 0b111;
execute(): void {
this.destination.value = this.source1.value & this.source2.value;
}
}

 
But in a Jest testing environment, the registry does not behave deterministically:

export default {
	preset: 'ts-jest',
	testEnvironment: 'node',
}
import { InstructionRegistry } from "../../src/riscv/instructionRegistry";
import { AddInstruction, SubInstruction, XorInstruction, OrInstruction, AndInstruction } from "../../src/riscv/instructions/rtype";
import { Registers } from "../../src/riscv/register";
test("registry", () => {
let sub = new SubInstruction(Registers.get(1), Registers.get(2), Registers.get(3));
console.log(InstructionRegistry.getInstance().getBinaryRegistry());
});

Expected behavior

Tests should behave in a way that is consistent with the production environment. The use of decorators is not respected in Jest, though.

Actual behavior

The output i'm logging shows how all the concrete Instruction classes are trying to be registred with the same key, cause opcode is the only defined number. The static properties f3 and f7 are not being correctly inherited by the parent class.

PASS test/riscv/execution.test.ts
PASS test/riscv/instructions.test.ts
● Console
console.log
registring class extends _classSuper {
execute() {
this.destination.value = this.source1.value + this.source2.value;
}
} with key 51
at Function.register (src/riscv/instructionRegistry.ts:26:11)
console.log
registring class extends _classSuper {
execute() {
this.destination.value = this.source1.value - this.source2.value;
}
} with key 51
at Function.register (src/riscv/instructionRegistry.ts:26:11)
console.log
registring class extends _classSuper {
execute() {
this.destination.value = this.source1.value ^ this.source2.value;
}
} with key 51
at Function.register (src/riscv/instructionRegistry.ts:26:11)
console.log
registring class extends _classSuper {
execute() {
this.destination.value = this.source1.value | this.source2.value;
}
} with key 51
at Function.register (src/riscv/instructionRegistry.ts:26:11)
console.log
registring class extends _classSuper {
execute() {
this.destination.value = this.source1.value & this.source2.value;
}
} with key 51
at Function.register (src/riscv/instructionRegistry.ts:26:11)
console.log
Map(1) {
51 => [class AndInstruction extends RTypeInstruction] { tag: 'and', f3: 7 }
}
at Object.<anonymous> (test/riscv/instructions.test.ts:7:10)

This is avoided with the use of static blocks:

// @InstructionRegistry.register
export class AddInstruction extends RTypeInstruction {
	static tag = "add";

	execute(): void {
		this.destination.value = this.source1.value + this.source2.value;
	}

	static {
		InstructionRegistry.register(this);
	}
}

And the output is:

 vale@msi:~/.loc+/dir/sou+/ts/parser(master)# pnpm run test

> parser@1.0.0 test /home/valerio/.local/dir/source/ts/parser
> jest

 PASS  test/riscv/execution.test.ts
 PASS  test/riscv/instructions.test.ts
  ● Console

    console.log
      registring class AddInstruction extends RTypeInstruction {
          execute() {
              this.destination.value = this.source1.value + this.source2.value;
          }
      } with key 51

      at Function.register (src/riscv/instructionRegistry.ts:26:11)

    console.log
      registring class SubInstruction extends RTypeInstruction {
          execute() {
              this.destination.value = this.source1.value - this.source2.value;
          }
      } with key 32819

      at Function.register (src/riscv/instructionRegistry.ts:26:11)

    console.log
      registring class XorInstruction extends RTypeInstruction {
          execute() {
              this.destination.value = this.source1.value ^ this.source2.value;
          }
      } with key 563

      at Function.register (src/riscv/instructionRegistry.ts:26:11)

    console.log
      registring class OrInstruction extends RTypeInstruction {
          execute() {
              this.destination.value = this.source1.value | this.source2.value;
          }
      } with key 819

      at Function.register (src/riscv/instructionRegistry.ts:26:11)

    console.log
      registring class AndInstruction extends RTypeInstruction {
          execute() {
              this.destination.value = this.source1.value & this.source2.value;
          }
      } with key 947

      at Function.register (src/riscv/instructionRegistry.ts:26:11)

    console.log
      Map(5) {
        51 => [class AddInstruction extends RTypeInstruction] { tag: 'add' },
        32819 => [class SubInstruction extends RTypeInstruction] { tag: 'sub', f7: 32 },
        563 => [class XorInstruction extends RTypeInstruction] { tag: 'xor', f3: 4 },
        819 => [class OrInstruction extends RTypeInstruction] { tag: 'or', f3: 6 },
        947 => [class AndInstruction extends RTypeInstruction] { tag: 'and', f3: 7 }
      }

      at Object.<anonymous> (test/riscv/instructions.test.ts:12:11)

    console.log
      Map(5) {
        51 => [class AddInstruction extends RTypeInstruction] { tag: 'add' },
        32819 => [class SubInstruction extends RTypeInstruction] { tag: 'sub', f7: 32 },
        563 => [class XorInstruction extends RTypeInstruction] { tag: 'xor', f3: 4 },
        819 => [class OrInstruction extends RTypeInstruction] { tag: 'or', f3: 6 },
        947 => [class AndInstruction extends RTypeInstruction] { tag: 'and', f3: 7 }
      }

      at Object.<anonymous> (test/riscv/instructions.test.ts:18:10)

 PASS  test/riscv/architecture.test.ts

But the semantics of decorators are lost.

Environment

System:
OS: Linux 6.14 Arch Linux
CPU: (20) x64 12th Gen Intel(R) Core(TM) i7-12700K
Binaries:
Node: 23.9.0 - /usr/bin/node
Yarn: 1.22.22 - /usr/bin/yarn
npm: 11.2.0 - /usr/bin/npm
pnpm: 9.5.0 - /home/valerio/.local/share/npm/bin/pnpm
Deno: 2.2.1 - /usr/bin/deno
npmPackages:
jest: ^30.2.0 => 30.2.0

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions