mirror of
https://github.com/ethereum/go-ethereum.git
synced 2026-08-19 02:12:23 +00:00
core/vm: remove unnecessary check, fix benchmarks, use immediates in analysis
Using the immediates-map in jumpdest analysis leads to less error prone code (only one place to update it) and it improves the worst case from ``` BenchmarkJumpdestOpEOFAnalysis/EOFCREATE-24 724 1471215 ns/op 835.23 MB/s ``` to ``` BenchmarkJumpdestOpEOFAnalysis/EOFCREATE-24 1710 693609 ns/op 1771.60 MB/s ``` which is a quite significant 2x improvement and brings them in line with the average case
This commit is contained in:
parent
d3f736faf0
commit
f05c07b8fe
8 changed files with 85 additions and 41 deletions
2
cmd/eofdump/testdata/results.initcode.txt
vendored
2
cmd/eofdump/testdata/results.initcode.txt
vendored
|
|
@ -1906,7 +1906,7 @@ ERR: unreachable code: arg 253, last 1, pos 13
|
||||||
ERR: invalid magic: want ef00 in sub container 0
|
ERR: invalid magic: want ef00 in sub container 0
|
||||||
ERR: missing data header: found section fe instead in sub container 0
|
ERR: missing data header: found section fe instead in sub container 0
|
||||||
ERR: invalid type content, max stack height exceeds limit for section 0: have 65007 in sub container 0
|
ERR: invalid type content, max stack height exceeds limit for section 0: have 65007 in sub container 0
|
||||||
ERR: section already referenced, arg :0
|
ERR: stack underflow (0 <=> 4): at pos 0
|
||||||
ERR: subcontainer not referenced at all
|
ERR: subcontainer not referenced at all
|
||||||
ERR: truncated immediate: op PUSH20, pos 6
|
ERR: truncated immediate: op PUSH20, pos 6
|
||||||
ERR: invalid jump destination: out-of-bounds offset: offset 12336, dest 12342, pos 4
|
ERR: invalid jump destination: out-of-bounds offset: offset 12336, dest 12342, pos 4
|
||||||
|
|
|
||||||
4
cmd/eofdump/testdata/results.regular.txt
vendored
4
cmd/eofdump/testdata/results.regular.txt
vendored
|
|
@ -1906,14 +1906,14 @@ ERR: unreachable code: arg 253, last 1, pos 13
|
||||||
ERR: invalid container size: have 24594, want 24591
|
ERR: invalid container size: have 24594, want 24591
|
||||||
ERR: missing data header: found section fe instead in sub container 0
|
ERR: missing data header: found section fe instead in sub container 0
|
||||||
ERR: invalid type content, max stack height exceeds limit for section 0: have 65007 in sub container 0
|
ERR: invalid type content, max stack height exceeds limit for section 0: have 65007 in sub container 0
|
||||||
ERR: section already referenced, arg :0
|
ERR: stack underflow (0 <=> 4): at pos 0
|
||||||
ERR: subcontainer not referenced at all
|
ERR: subcontainer not referenced at all
|
||||||
ERR: truncated immediate: op PUSH20, pos 6
|
ERR: truncated immediate: op PUSH20, pos 6
|
||||||
ERR: invalid jump destination: out-of-bounds offset: offset 12336, dest 12342, pos 4
|
ERR: invalid jump destination: out-of-bounds offset: offset 12336, dest 12342, pos 4
|
||||||
ERR: invalid backward jump want 4 as current min got 5 at pos 12,
|
ERR: invalid backward jump want 4 as current min got 5 at pos 12,
|
||||||
ERR: invalid backward jump want 0 as current min got 1 at pos 12,
|
ERR: invalid backward jump want 0 as current min got 1 at pos 12,
|
||||||
ERR: unreachable code: arg 48, last 1, pos 13
|
ERR: unreachable code: arg 48, last 1, pos 13
|
||||||
ERR: section already referenced, arg :0
|
ERR: invalid code termination: end with ADDRESS, pos 17
|
||||||
ERR: unreachable code: arg 48, last 2, pos 8
|
ERR: unreachable code: arg 48, last 2, pos 8
|
||||||
ERR: invalid code termination: end with ADDRESS, pos 1
|
ERR: invalid code termination: end with ADDRESS, pos 1
|
||||||
ERR: invalid section argument: arg 12336, last 1, pos 14
|
ERR: invalid section argument: arg 12336, last 1, pos 14
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ func TestInstructionIterator(t *testing.T) {
|
||||||
{2, "5900", ""}, // push0
|
{2, "5900", ""}, // push0
|
||||||
{0, "", ""}, // empty
|
{0, "", ""}, // empty
|
||||||
{2, "d1aabb00", ""}, // DATALOADN(aabb),STOP
|
{2, "d1aabb00", ""}, // DATALOADN(aabb),STOP
|
||||||
|
{0, "d1aa", "incomplete instruction at 0"}, // DATALOADN(aa) invalid
|
||||||
|
|
||||||
} {
|
} {
|
||||||
var (
|
var (
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,7 @@ func eofCodeBitmapInternal(code, bits bitvec) bitvec {
|
||||||
)
|
)
|
||||||
pc++
|
pc++
|
||||||
|
|
||||||
switch {
|
if op == RJUMPV {
|
||||||
case op < PUSH1:
|
|
||||||
continue
|
|
||||||
case op <= PUSH32:
|
|
||||||
numbits = uint16(op - PUSH1 + 1)
|
|
||||||
case op == RJUMP || op == RJUMPI || op == CALLF || op == JUMPF || op == DATALOADN:
|
|
||||||
numbits = 2
|
|
||||||
case op == RJUMPV:
|
|
||||||
// RJUMPV is unique as it has a variable sized operand.
|
// RJUMPV is unique as it has a variable sized operand.
|
||||||
// The total size is determined by the count byte which
|
// The total size is determined by the count byte which
|
||||||
// immediate proceeds RJUMPV. Truncation will be caught
|
// immediate proceeds RJUMPV. Truncation will be caught
|
||||||
|
|
@ -60,11 +53,11 @@ func eofCodeBitmapInternal(code, bits bitvec) bitvec {
|
||||||
// as possible.
|
// as possible.
|
||||||
numbits = uint16(end - pc)
|
numbits = uint16(end - pc)
|
||||||
}
|
}
|
||||||
case op == DUPN || op == SWAPN || op == EXCHANGE || op == EOFCREATE || op == RETURNCONTRACT:
|
} else {
|
||||||
numbits = 1
|
numbits = uint16(Immediates(op))
|
||||||
default:
|
if numbits == 0 {
|
||||||
// Op had no immediate operand, continue.
|
continue
|
||||||
continue
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if numbits >= 8 {
|
if numbits >= 8 {
|
||||||
|
|
|
||||||
|
|
@ -105,3 +105,31 @@ func BenchmarkJumpdestOpAnalysis(bench *testing.B) {
|
||||||
op = STOP
|
op = STOP
|
||||||
bench.Run(op.String(), bencher)
|
bench.Run(op.String(), bencher)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BenchmarkJumpdestOpEOFAnalysis(bench *testing.B) {
|
||||||
|
var op OpCode
|
||||||
|
bencher := func(b *testing.B) {
|
||||||
|
code := make([]byte, analysisCodeSize)
|
||||||
|
b.SetBytes(analysisCodeSize)
|
||||||
|
for i := range code {
|
||||||
|
code[i] = byte(op)
|
||||||
|
}
|
||||||
|
bits := make(bitvec, len(code)/8+1+4)
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
clear(bits)
|
||||||
|
eofCodeBitmapInternal(code, bits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for op = PUSH1; op <= PUSH32; op++ {
|
||||||
|
bench.Run(op.String(), bencher)
|
||||||
|
}
|
||||||
|
op = JUMPDEST
|
||||||
|
bench.Run(op.String(), bencher)
|
||||||
|
op = STOP
|
||||||
|
bench.Run(op.String(), bencher)
|
||||||
|
op = RJUMPV
|
||||||
|
bench.Run(op.String(), bencher)
|
||||||
|
op = EOFCREATE
|
||||||
|
bench.Run(op.String(), bencher)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,10 +127,15 @@ func (c *Container) MarshalBinary() []byte {
|
||||||
|
|
||||||
// UnmarshalBinary decodes an EOF container.
|
// UnmarshalBinary decodes an EOF container.
|
||||||
func (c *Container) UnmarshalBinary(b []byte, isInitcode bool) error {
|
func (c *Container) UnmarshalBinary(b []byte, isInitcode bool) error {
|
||||||
return c.unmarshalSubContainer(b, isInitcode, true)
|
return c.unmarshalContainer(b, isInitcode, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) unmarshalSubContainer(b []byte, isInitcode bool, topLevel bool) error {
|
// UnmarshalSubContainer decodes an EOF container that is inside another container.
|
||||||
|
func (c *Container) UnmarshalSubContainer(b []byte, isInitcode bool) error {
|
||||||
|
return c.unmarshalContainer(b, isInitcode, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) unmarshalContainer(b []byte, isInitcode bool, topLevel bool) error {
|
||||||
if !hasEOFMagic(b) {
|
if !hasEOFMagic(b) {
|
||||||
return fmt.Errorf("%w: want %x", ErrInvalidMagic, eofMagic)
|
return fmt.Errorf("%w: want %x", ErrInvalidMagic, eofMagic)
|
||||||
}
|
}
|
||||||
|
|
@ -275,7 +280,7 @@ func (c *Container) unmarshalSubContainer(b []byte, isInitcode bool, topLevel bo
|
||||||
}
|
}
|
||||||
subC := new(Container)
|
subC := new(Container)
|
||||||
end := min(idx+size, len(b))
|
end := min(idx+size, len(b))
|
||||||
if err := subC.unmarshalSubContainer(b[idx:end], isInitcode, false); err != nil {
|
if err := subC.unmarshalContainer(b[idx:end], isInitcode, false); err != nil {
|
||||||
if topLevel {
|
if topLevel {
|
||||||
return fmt.Errorf("%w in sub container %d", err, i)
|
return fmt.Errorf("%w in sub container %d", err, i)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,9 +182,6 @@ func validateCode(code []byte, section int, container *Container, jt *JumpTable,
|
||||||
if visitedSubcontainers == nil {
|
if visitedSubcontainers == nil {
|
||||||
visitedSubcontainers = make(map[int]int)
|
visitedSubcontainers = make(map[int]int)
|
||||||
}
|
}
|
||||||
if _, ok := visitedSubcontainers[arg]; ok {
|
|
||||||
return nil, fmt.Errorf("section already referenced, arg :%d", arg)
|
|
||||||
}
|
|
||||||
// We need to store per subcontainer how it was referenced
|
// We need to store per subcontainer how it was referenced
|
||||||
if v, ok := visitedSubcontainers[arg]; ok && v != refByEOFCreate {
|
if v, ok := visitedSubcontainers[arg]; ok && v != refByEOFCreate {
|
||||||
return nil, fmt.Errorf("section already referenced, arg :%d", arg)
|
return nil, fmt.Errorf("section already referenced, arg :%d", arg)
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,8 @@ func TestValidateCode(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BenchmarkRJUMPI tries to benchmark the RJUMPI opcode validation
|
||||||
|
// For this we do a bunch of RJUMPIs that jump backwards (in a potential infinite loop).
|
||||||
func BenchmarkRJUMPI(b *testing.B) {
|
func BenchmarkRJUMPI(b *testing.B) {
|
||||||
snippet := []byte{
|
snippet := []byte{
|
||||||
byte(PUSH0),
|
byte(PUSH0),
|
||||||
|
|
@ -275,13 +277,15 @@ func BenchmarkRJUMPI(b *testing.B) {
|
||||||
}
|
}
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_, err := validateCode(code, 0, container, &pragueEOFInstructionSet, true)
|
_, err := validateCode(code, 0, container, &pragueEOFInstructionSet, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BenchmarkRJUMPV tries to benchmark the validation of the RJUMPV opcode
|
||||||
|
// for this we set up as many RJUMPV opcodes with a full jumptable (containing 0s) as possible.
|
||||||
func BenchmarkRJUMPV(b *testing.B) {
|
func BenchmarkRJUMPV(b *testing.B) {
|
||||||
snippet := []byte{
|
snippet := []byte{
|
||||||
byte(PUSH0),
|
byte(PUSH0),
|
||||||
|
|
@ -305,13 +309,18 @@ func BenchmarkRJUMPV(b *testing.B) {
|
||||||
}
|
}
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_, err := validateCode(code, 0, container, &pragueEOFInstructionSet, true)
|
_, err := validateCode(code, 0, container, &pragueEOFInstructionSet, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BenchmarkEOFValidation tries to benchmark the code validation for the CALLF/RETF operation.
|
||||||
|
// For this we set up code that calls into 1024 code sections which can either
|
||||||
|
// - just contain a RETF opcode
|
||||||
|
// - or code to again call into 1024 code sections.
|
||||||
|
// We can't have all code sections calling each other, otherwise we would exceed 48KB.
|
||||||
func BenchmarkEOFValidation(b *testing.B) {
|
func BenchmarkEOFValidation(b *testing.B) {
|
||||||
var container Container
|
var container Container
|
||||||
var code []byte
|
var code []byte
|
||||||
|
|
@ -320,22 +329,21 @@ func BenchmarkEOFValidation(b *testing.B) {
|
||||||
code = append(code, byte(CALLF))
|
code = append(code, byte(CALLF))
|
||||||
code = binary.BigEndian.AppendUint16(code, uint16(i%(maxSections-1))+1)
|
code = binary.BigEndian.AppendUint16(code, uint16(i%(maxSections-1))+1)
|
||||||
}
|
}
|
||||||
code = append(code, byte(STOP))
|
|
||||||
// First container
|
// First container
|
||||||
container.codeSections = append(container.codeSections, code)
|
container.codeSections = append(container.codeSections, append(code, byte(STOP)))
|
||||||
container.types = append(container.types, &functionMetadata{inputs: 0, outputs: 0x80, maxStackHeight: 0})
|
container.types = append(container.types, &functionMetadata{inputs: 0, outputs: 0x80, maxStackHeight: 0})
|
||||||
|
|
||||||
inner := []byte{
|
inner := []byte{
|
||||||
byte(STOP),
|
byte(RETF),
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < 1023; i++ {
|
for i := 0; i < 1023; i++ {
|
||||||
container.codeSections = append(container.codeSections, inner)
|
container.codeSections = append(container.codeSections, inner)
|
||||||
container.types = append(container.types, &functionMetadata{inputs: 0, outputs: 0x80, maxStackHeight: 0})
|
container.types = append(container.types, &functionMetadata{inputs: 0, outputs: 0, maxStackHeight: 0})
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < 12; i++ {
|
for i := 0; i < 12; i++ {
|
||||||
container.codeSections[i+1] = code
|
container.codeSections[i+1] = append(code, byte(RETF))
|
||||||
}
|
}
|
||||||
|
|
||||||
bin := container.MarshalBinary()
|
bin := container.MarshalBinary()
|
||||||
|
|
@ -349,12 +357,16 @@ func BenchmarkEOFValidation(b *testing.B) {
|
||||||
if err := container2.UnmarshalBinary(bin, true); err != nil {
|
if err := container2.UnmarshalBinary(bin, true); err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := container2.ValidateCode(&pragueEOFInstructionSet, true); err != nil {
|
if err := container2.ValidateCode(&pragueEOFInstructionSet, false); err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BenchmarkEOFValidation tries to benchmark the code validation for the CALLF/RETF operation.
|
||||||
|
// For this we set up code that calls into 1024 code sections which
|
||||||
|
// - contain calls to some other code sections.
|
||||||
|
// We can't have all code sections calling each other, otherwise we would exceed 48KB.
|
||||||
func BenchmarkEOFValidation2(b *testing.B) {
|
func BenchmarkEOFValidation2(b *testing.B) {
|
||||||
var container Container
|
var container Container
|
||||||
var code []byte
|
var code []byte
|
||||||
|
|
@ -380,12 +392,13 @@ func BenchmarkEOFValidation2(b *testing.B) {
|
||||||
byte(CALLF), 0x03, 0xF6,
|
byte(CALLF), 0x03, 0xF6,
|
||||||
byte(CALLF), 0x03, 0xF7,
|
byte(CALLF), 0x03, 0xF7,
|
||||||
byte(CALLF), 0x03, 0xF8,
|
byte(CALLF), 0x03, 0xF8,
|
||||||
byte(JUMPF), 0x00, 0x00,
|
byte(CALLF), 0x03, 0xF,
|
||||||
|
byte(RETF),
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < 1023; i++ {
|
for i := 0; i < 1023; i++ {
|
||||||
container.codeSections = append(container.codeSections, inner)
|
container.codeSections = append(container.codeSections, inner)
|
||||||
container.types = append(container.types, &functionMetadata{inputs: 0, outputs: 0x80, maxStackHeight: 0})
|
container.types = append(container.types, &functionMetadata{inputs: 0, outputs: 0, maxStackHeight: 0})
|
||||||
}
|
}
|
||||||
|
|
||||||
bin := container.MarshalBinary()
|
bin := container.MarshalBinary()
|
||||||
|
|
@ -399,12 +412,17 @@ func BenchmarkEOFValidation2(b *testing.B) {
|
||||||
if err := container2.UnmarshalBinary(bin, true); err != nil {
|
if err := container2.UnmarshalBinary(bin, true); err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := container2.ValidateCode(&pragueEOFInstructionSet, true); err != nil {
|
if err := container2.ValidateCode(&pragueEOFInstructionSet, false); err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BenchmarkEOFValidation3 tries to benchmark the code validation for the CALLF/RETF and RJUMPI/V operations.
|
||||||
|
// For this we set up code that calls into 1024 code sections which either
|
||||||
|
// - contain an RJUMP opcode
|
||||||
|
// - contain calls to other code sections
|
||||||
|
// We can't have all code sections calling each other, otherwise we would exceed 48KB.
|
||||||
func BenchmarkEOFValidation3(b *testing.B) {
|
func BenchmarkEOFValidation3(b *testing.B) {
|
||||||
var container Container
|
var container Container
|
||||||
var code []byte
|
var code []byte
|
||||||
|
|
@ -418,23 +436,25 @@ func BenchmarkEOFValidation3(b *testing.B) {
|
||||||
snippet = append(snippet, []byte{0x00, 0x00}...)
|
snippet = append(snippet, []byte{0x00, 0x00}...)
|
||||||
}
|
}
|
||||||
code = append(code, snippet...)
|
code = append(code, snippet...)
|
||||||
|
// First container, calls into all other containers
|
||||||
maxSections := 1024
|
maxSections := 1024
|
||||||
for i := 0; i < maxSections; i++ {
|
for i := 0; i < maxSections; i++ {
|
||||||
code = append(code, byte(CALLF))
|
code = append(code, byte(CALLF))
|
||||||
code = binary.BigEndian.AppendUint16(code, uint16(i%(maxSections-1))+1)
|
code = binary.BigEndian.AppendUint16(code, uint16(i%(maxSections-1))+1)
|
||||||
}
|
}
|
||||||
code = append(code, byte(STOP))
|
code = append(code, byte(STOP))
|
||||||
// First container
|
|
||||||
container.codeSections = append(container.codeSections, code)
|
container.codeSections = append(container.codeSections, code)
|
||||||
container.types = append(container.types, &functionMetadata{inputs: 0, outputs: 0x80, maxStackHeight: 1})
|
container.types = append(container.types, &functionMetadata{inputs: 0, outputs: 0x80, maxStackHeight: 1})
|
||||||
|
|
||||||
|
// Other containers
|
||||||
for i := 0; i < 1023; i++ {
|
for i := 0; i < 1023; i++ {
|
||||||
container.codeSections = append(container.codeSections, []byte{byte(RJUMP), 0x00, 0x00, byte(JUMPF), 0x00, 0x00})
|
container.codeSections = append(container.codeSections, []byte{byte(RJUMP), 0x00, 0x00, byte(RETF)})
|
||||||
container.types = append(container.types, &functionMetadata{inputs: 0, outputs: 0x80, maxStackHeight: 0})
|
container.types = append(container.types, &functionMetadata{inputs: 0, outputs: 0, maxStackHeight: 0})
|
||||||
}
|
}
|
||||||
for i := 0; i < 65; i++ {
|
// Other containers
|
||||||
container.codeSections[i+1] = append(snippet, byte(STOP))
|
for i := 0; i < 68; i++ {
|
||||||
container.types[i+1] = &functionMetadata{inputs: 0, outputs: 0x80, maxStackHeight: 1}
|
container.codeSections[i+1] = append(snippet, byte(RETF))
|
||||||
|
container.types[i+1] = &functionMetadata{inputs: 0, outputs: 0, maxStackHeight: 1}
|
||||||
}
|
}
|
||||||
bin := container.MarshalBinary()
|
bin := container.MarshalBinary()
|
||||||
if len(bin) > 48*1024 {
|
if len(bin) > 48*1024 {
|
||||||
|
|
@ -448,7 +468,7 @@ func BenchmarkEOFValidation3(b *testing.B) {
|
||||||
if err := container2.UnmarshalBinary(bin, true); err != nil {
|
if err := container2.UnmarshalBinary(bin, true); err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := container2.ValidateCode(&pragueEOFInstructionSet, true); err != nil {
|
if err := container2.ValidateCode(&pragueEOFInstructionSet, false); err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -474,7 +494,7 @@ func BenchmarkRJUMPI_2(b *testing.B) {
|
||||||
}
|
}
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_, err := validateCode(code, 0, container, &pragueEOFInstructionSet, true)
|
_, err := validateCode(code, 0, container, &pragueEOFInstructionSet, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue