Skip to content

Commit 955a43c

Browse files
committed
address comments
1 parent 56c48bc commit 955a43c

4 files changed

Lines changed: 170 additions & 6 deletions

File tree

apps/sim/executor/execution/snapshot-serializer.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { describe, expect, it } from 'vitest'
4+
import { describe, expect, it, vi } from 'vitest'
55
import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer'
66
import type { ExecutionContext } from '@/executor/types'
77

@@ -67,4 +67,26 @@ describe('serializePauseSnapshot', () => {
6767
},
6868
})
6969
})
70+
71+
it('rejects oversized snapshot values without full JSON serialization', () => {
72+
const stringifySpy = vi.spyOn(JSON, 'stringify').mockImplementation(() => {
73+
throw new Error('full stringify should not be used for compactness checks')
74+
})
75+
const context = createContext({
76+
workflowVariables: {
77+
oversized: {
78+
type: 'string',
79+
value: 'x'.repeat(9 * 1024 * 1024),
80+
},
81+
},
82+
})
83+
84+
try {
85+
expect(() => serializePauseSnapshot(context, ['next-block'])).toThrow(
86+
'Cannot serialize pause snapshot with oversized workflow variables'
87+
)
88+
} finally {
89+
stringifySpy.mockRestore()
90+
}
91+
})
7092
})

apps/sim/executor/execution/snapshot-serializer.ts

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,132 @@ import { ExecutionSnapshot } from '@/executor/execution/snapshot'
44
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
55
import type { ExecutionContext, SerializedSnapshot } from '@/executor/types'
66

7+
const JSON_SYNTAX_BYTES = {
8+
QUOTE: 1,
9+
COLON: 1,
10+
COMMA: 1,
11+
ARRAY_BRACKETS: 2,
12+
OBJECT_BRACES: 2,
13+
NULL: 4,
14+
} as const
15+
16+
function getEscapedJsonStringByteLength(value: string): number {
17+
let bytes = JSON_SYNTAX_BYTES.QUOTE * 2
18+
for (let index = 0; index < value.length; index++) {
19+
const code = value.charCodeAt(index)
20+
if (code === 0x22 || code === 0x5c) {
21+
bytes += 2
22+
} else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) {
23+
bytes += 2
24+
} else if (code < 0x20) {
25+
bytes += 6
26+
} else if (code >= 0xd800 && code <= 0xdbff) {
27+
const next = value.charCodeAt(index + 1)
28+
if (next >= 0xdc00 && next <= 0xdfff) {
29+
bytes += 4
30+
index++
31+
} else {
32+
bytes += 6
33+
}
34+
} else if (code >= 0xdc00 && code <= 0xdfff) {
35+
bytes += 6
36+
} else if (code < 0x80) {
37+
bytes += 1
38+
} else if (code < 0x800) {
39+
bytes += 2
40+
} else {
41+
bytes += 3
42+
}
43+
}
44+
return bytes
45+
}
46+
47+
function getPrimitiveJsonByteLength(value: unknown): number | undefined {
48+
if (value === null) {
49+
return JSON_SYNTAX_BYTES.NULL
50+
}
51+
if (typeof value === 'string') {
52+
return getEscapedJsonStringByteLength(value)
53+
}
54+
if (typeof value === 'number') {
55+
return Number.isFinite(value)
56+
? Buffer.byteLength(String(value), 'utf8')
57+
: JSON_SYNTAX_BYTES.NULL
58+
}
59+
if (typeof value === 'boolean') {
60+
return value ? 4 : 5
61+
}
62+
if (typeof value === 'bigint') {
63+
throw new TypeError('Do not know how to serialize a BigInt')
64+
}
65+
return undefined
66+
}
67+
68+
function getBoundedJsonByteLength(
69+
value: unknown,
70+
maxBytes: number,
71+
seen = new WeakSet<object>()
72+
): number | undefined {
73+
const primitiveSize = getPrimitiveJsonByteLength(value)
74+
if (primitiveSize !== undefined) {
75+
return primitiveSize
76+
}
77+
78+
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
79+
return undefined
80+
}
81+
82+
if (!value || typeof value !== 'object') {
83+
return undefined
84+
}
85+
86+
if (seen.has(value)) {
87+
throw new TypeError('Converting circular structure to JSON')
88+
}
89+
seen.add(value)
90+
91+
let bytes = Array.isArray(value)
92+
? JSON_SYNTAX_BYTES.ARRAY_BRACKETS
93+
: JSON_SYNTAX_BYTES.OBJECT_BRACES
94+
if (Array.isArray(value)) {
95+
for (let index = 0; index < value.length; index++) {
96+
if (index > 0) bytes += JSON_SYNTAX_BYTES.COMMA
97+
const itemSize = getBoundedJsonByteLength(value[index], maxBytes - bytes, seen)
98+
bytes += itemSize ?? JSON_SYNTAX_BYTES.NULL
99+
if (bytes > maxBytes) return bytes
100+
}
101+
seen.delete(value)
102+
return bytes
103+
}
104+
105+
let hasEntries = false
106+
for (const key of Object.keys(value)) {
107+
const entryValue = (value as Record<string, unknown>)[key]
108+
if (
109+
entryValue === undefined ||
110+
typeof entryValue === 'function' ||
111+
typeof entryValue === 'symbol'
112+
) {
113+
continue
114+
}
115+
if (hasEntries) bytes += JSON_SYNTAX_BYTES.COMMA
116+
bytes += getEscapedJsonStringByteLength(key) + JSON_SYNTAX_BYTES.COLON
117+
const entrySize = getBoundedJsonByteLength(entryValue, maxBytes - bytes, seen)
118+
if (entrySize === undefined) {
119+
continue
120+
}
121+
bytes += entrySize
122+
hasEntries = true
123+
if (bytes > maxBytes) return bytes
124+
}
125+
126+
seen.delete(value)
127+
return bytes
128+
}
129+
7130
function assertSnapshotValueIsCompact(value: unknown, label: string): void {
8-
const json = JSON.stringify(value)
9-
if (json && Buffer.byteLength(json, 'utf8') > LARGE_VALUE_THRESHOLD_BYTES) {
131+
const byteLength = getBoundedJsonByteLength(value, LARGE_VALUE_THRESHOLD_BYTES)
132+
if (byteLength !== undefined && byteLength > LARGE_VALUE_THRESHOLD_BYTES) {
10133
throw new Error(`Cannot serialize pause snapshot with oversized ${label}; compact it first.`)
11134
}
12135
}

apps/sim/executor/orchestrators/loop.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,28 @@ describe('LoopOrchestrator', () => {
9797
expect(scope.condition).toBe('true')
9898
})
9999

100+
it('keeps doWhile condition semantics when iterations are also configured', async () => {
101+
const { orchestrator } = createOrchestrator(
102+
new Map([
103+
[
104+
'loop-1',
105+
{
106+
loopType: 'doWhile',
107+
iterations: 2,
108+
doWhileCondition: 'true',
109+
nodes: ['block-1'],
110+
},
111+
],
112+
])
113+
)
114+
const ctx = createContext({})
115+
116+
const scope = await orchestrator.initializeLoopScope(ctx, 'loop-1')
117+
118+
expect(scope.maxIterations).toBeUndefined()
119+
expect(scope.condition).toBe('true')
120+
})
121+
100122
it('compacts current iteration outputs before retaining them', async () => {
101123
const { orchestrator, setBlockOutput } = createOrchestrator()
102124
const ctx = createContext({

apps/sim/executor/orchestrators/loop.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,6 @@ export class LoopOrchestrator {
163163

164164
case 'doWhile': {
165165
scope.loopType = 'doWhile'
166-
if (loopConfig.iterations) {
167-
scope.maxIterations = loopConfig.iterations
168-
}
169166
if (loopConfig.doWhileCondition) {
170167
scope.condition = loopConfig.doWhileCondition
171168
} else {

0 commit comments

Comments
 (0)