Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gentle-cooks-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/core': patch
---

Serialize a `DataView` as the bytes it views. It previously fell through to devalue's built-in encoding, which persists the whole backing `ArrayBuffer` — for a view onto Node's pooled `Buffer` allocator, unrelated process memory.
5 changes: 5 additions & 0 deletions .changeset/violet-hounds-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/web-shared': patch
---

Add a `DataView` reviver so run payloads containing one still hydrate in the observability UI.
1 change: 1 addition & 0 deletions docs/content/docs/v4/foundations/serialization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The following types can be serialized and passed through workflow functions:
- `bigint`
- `ArrayBuffer`
- `BigInt64Array`, `BigUint64Array`
- `DataView`
- `Date`
- `Float32Array`, `Float64Array`
- `Int8Array`, `Int16Array`, `Int32Array`
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/v5/foundations/serialization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ The following types can be serialized and passed through workflow functions:
- `bigint`
- `ArrayBuffer`
- `BigInt64Array`, `BigUint64Array`
- `DataView`
- `Date`
- `Float32Array`, `Float64Array`
- `Int8Array`, `Int16Array`, `Int32Array`
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/runtime/quickjs-serde.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,22 @@ describe('wire parity: guest serialize matches the reference codec', () => {
'new Uint8Array([1,2,3,4,5]).subarray(1, 4)',
() => new Uint8Array([1, 2, 3, 4, 5]).subarray(1, 4),
],
[
'DataView',
'new DataView(new Uint8Array([9, 8, 7]).buffer)',
() => new DataView(new Uint8Array([9, 8, 7]).buffer),
],
[
// Neither encoding may carry the bytes outside [2, 5).
'DataView subview',
'new DataView(new Uint8Array([1,2,3,4,5,6,7,8]).buffer, 2, 3)',
() => new DataView(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]).buffer, 2, 3),
],
[
'empty DataView',
'new DataView(new ArrayBuffer(8), 4, 0)',
() => new DataView(new ArrayBuffer(8), 4, 0),
],
[
'Error',
'(() => { const e = new Error("boom"); e.stack = "fake-stack"; return e; })()',
Expand Down Expand Up @@ -344,6 +360,68 @@ describe('full round trip through the host serde only', () => {
)
).toEqual({ sameRef: true, mapVal: 1, time: 1700000000000, setHas: true });
});

it('carries a DataView subview without the rest of its buffer', () => {
const bytes = serializeGuest(
'new DataView(new Uint8Array([1,2,3,4,5,6,7,8]).buffer, 2, 3)'
);

// The payload is the base64 of [3, 4, 5] and nothing else; the whole
// buffer would additionally encode 1, 2, 6, 7 and 8.
expect(text(bytes)).toBe('devl[["DataViewBytes",1],"AwQF"]');

expect(
checkInGuest(
bytes,
`function (v) {
return {
isDataView: v instanceof DataView,
byteLength: v.byteLength,
bufferByteLength: v.buffer.byteLength,
first: v.getUint8(0),
last: v.getUint8(2),
};
}`
)
).toEqual({
isDataView: true,
byteLength: 3,
bufferByteLength: 3,
first: 3,
last: 5,
});
});

it('revives a pre-existing DataView payload with its bounds', () => {
// Written by devalue's built-in encoding before `DataViewBytes` existed:
// a reference to the whole buffer, with the view's bounds beside it.
// `fromViewInfo` is the only thing that can read those bounds back, and
// devalue only reaches it while no custom reviver claims the tag.
const legacy = new TextEncoder().encode(
'devl[["DataView",1,1,2],["ArrayBuffer",2],"AQIDBA=="]'
);

expect(
checkInGuest(
legacy,
`function (v) {
return {
isDataView: v instanceof DataView,
byteOffset: v.byteOffset,
byteLength: v.byteLength,
first: v.getUint8(0),
last: v.getUint8(1),
};
}`
)
).toEqual({
isDataView: true,
byteOffset: 1,
byteLength: 2,
first: 2,
last: 3,
});
});
});

describe('NUL (U+0000) safety across the WASM boundary', () => {
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/runtime/quickjs-serde.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,17 @@ export function createQuickJSSerde(
isHandle(value) && tagOfHandle(value) === 'BigUint64Array'
? bytesToBase64(viewBytes(value))
: false,
// Mirrors the typed-array reducers, and for the same reason: devalue's
// built-in DataView encoding emits the whole backing ArrayBuffer, so a
// view onto a slice of a larger buffer would put the rest of that
// buffer on the wire. `viewBytes` copies only the viewed range. The tag
// is not `DataView`, so payloads already written under that name still
// take the built-in parse branch (`fromViewInfo` below) with their
// bounds intact; see serialization/reducers/common.ts.
DataViewBytes: (value) =>
isHandle(value) && tagOfHandle(value) === 'DataView'
? bytesToBase64(viewBytes(value))
: false,
Date: (value) => {
if (!isHandle(value) || tagOfHandle(value) !== 'Date') return false;
const time = call(i.dateGetTime, value).consume((h) => h.toNumber());
Expand Down Expand Up @@ -1716,6 +1727,10 @@ export function createQuickJSSerde(
buildTypedArray('BigInt64Array', value),
BigUint64Array: (value: string | JSValueHandle) =>
buildTypedArray('BigUint64Array', value),
// No `DataView` reviver: older payloads under that tag must keep taking
// the built-in branch, which restores their bounds via `fromViewInfo`.
DataViewBytes: (value: string | JSValueHandle) =>
buildTypedArray('DataView', value),
Date: (value: JSValueHandle | string) => {
// The reducer emits '.' for invalid dates and an ISO string otherwise.
const iso = isHandle(value) ? value.toString() : value;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type Serializable =
| bigint
| BigInt64Array
| BigUint64Array
| DataView
| Date
| DOMException
| Error
Expand Down
168 changes: 168 additions & 0 deletions packages/core/src/serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2743,6 +2743,174 @@ describe('step return value', () => {
});
});

describe('DataView serialization', () => {
/**
* A step that returns a `DataView` must persist the bytes that view spans
* and nothing else.
*
* Left to devalue's built-in `DataView` encoding, the payload is the whole
* backing `ArrayBuffer` plus the view's offset and length. Node hands out
* small `Buffer`s as windows onto a shared 8 KiB pool, so "the whole
* backing buffer" for a `DataView` over a `Buffer.allocUnsafe(n)` is a
* slab of unrelated prior allocations — and a step return is written to
* the run's event log, where it outlives the process that leaked it.
*/
it('writes only the viewed bytes of a pooled DataView to a step return', async () => {
// A neighbour in the pool, allocated first so the pool offset the view
// lands on sits after it. `Buffer.allocUnsafe` does not zero what it
// hands back, so these bytes are exactly the kind of residue that must
// not reach the event log.
const neighbour = Buffer.allocUnsafe(256);
neighbour.fill('SECRET-POOL-RESIDUE-');

const viewed = Buffer.allocUnsafe(4);
viewed.set([1, 2, 3, 4]);
const dataView = new DataView(
viewed.buffer,
viewed.byteOffset,
viewed.byteLength
);

// Precondition: this really is a pooled view, not a standalone buffer.
// Without it a passing assertion below would prove nothing.
expect(dataView.buffer.byteLength).toBeGreaterThan(dataView.byteLength);

const serialized = (await dehydrateStepReturnValue(
dataView,
mockRunId,
noEncryptionKey,
[]
)) as Uint8Array;

const wire = new TextDecoder().decode(serialized);
expect(wire).not.toContain('ArrayBuffer');
// Every byte of the payload is accounted for by the four viewed bytes:
// base64 of the pool would be several KiB of it.
expect(wire).toBe('devl[["DataViewBytes",1],"AQIDBA=="]');

const hydrated = (await hydrateStepReturnValue(
serialized,
mockRunId,
noEncryptionKey
)) as DataView;

expect(hydrated).toBeInstanceOf(DataView);
expect(hydrated.byteOffset).toBe(0);
expect(hydrated.byteLength).toBe(4);
expect(hydrated.buffer.byteLength).toBe(4);
expect([...new Uint8Array(hydrated.buffer)]).toEqual([1, 2, 3, 4]);
});

it('writes only the viewed window of a subview onto a larger buffer', async () => {
const backing = new Uint8Array(64);
for (let i = 0; i < backing.length; i++) backing[i] = i;
const dataView = new DataView(backing.buffer, 8, 4);

const serialized = (await dehydrateStepReturnValue(
dataView,
mockRunId,
noEncryptionKey,
[]
)) as Uint8Array;

const hydrated = (await hydrateStepReturnValue(
serialized,
mockRunId,
noEncryptionKey
)) as DataView;

expect([...new Uint8Array(hydrated.buffer)]).toEqual([8, 9, 10, 11]);
expect(hydrated.getUint8(0)).toBe(8);
expect(hydrated.getUint8(3)).toBe(11);
});

it('round-trips a zero-length DataView', async () => {
const serialized = await dehydrateStepReturnValue(
new DataView(new ArrayBuffer(8), 4, 0),
mockRunId,
noEncryptionKey,
[]
);

const hydrated = (await hydrateStepReturnValue(
serialized,
mockRunId,
noEncryptionKey
)) as DataView;

expect(hydrated).toBeInstanceOf(DataView);
expect(hydrated.byteLength).toBe(0);
expect(hydrated.buffer.byteLength).toBe(0);
});

it('revives into the workflow VM realm', async () => {
const { globalThis: vmGlobalThis, context } = createContext({
seed: 'test',
fixedTimestamp: 1714857600000,
});

const serialized = await dehydrateStepReturnValue(
new DataView(new Uint8Array([9, 8, 7]).buffer),
mockRunId,
noEncryptionKey,
[]
);

vmGlobalThis.val = await hydrateStepReturnValue(
serialized,
mockRunId,
noEncryptionKey,
vmGlobalThis
);

expect(runInContext('val instanceof DataView', context)).toBe(true);
expect(runInContext('val.byteLength', context)).toBe(3);
expect(runInContext('val.getUint8(0)', context)).toBe(9);
});

it('revives a pre-existing DataView payload with its bounds', async () => {
// devalue's built-in encoding: a reference to the whole backing
// ArrayBuffer, with the subview bounds alongside it. This is what is
// already in event logs, and the tuple below records the two bytes
// [2, 3] of a four-byte buffer.
//
// devalue skips its built-in branch for any tag that has a custom
// reviver, and hands a custom reviver the hydrated referent rather than
// the tuple — so a reviver registered under `DataView` could not see
// `1, 2` and would widen this back to the whole buffer. That is why the
// new encoding uses its own tag; this pins that the old one is
// untouched.
const legacy = [
['DataView', 1, 1, 2],
['ArrayBuffer', 2],
Buffer.from([1, 2, 3, 4]).toString('base64'),
];

const hydrated = hydrateData(legacy, getCommonRevivers()) as DataView;

expect(hydrated).toBeInstanceOf(DataView);
expect(hydrated.byteOffset).toBe(1);
expect(hydrated.byteLength).toBe(2);
expect(hydrated.getUint8(0)).toBe(2);
expect(hydrated.getUint8(1)).toBe(3);
});

it('keeps the old and new encodings distinguishable on the wire', async () => {
const serialized = (await dehydrateStepReturnValue(
new DataView(new Uint8Array([1, 2, 3, 4]).buffer, 1, 2),
mockRunId,
noEncryptionKey,
[]
)) as Uint8Array;

// The reducer claims the value before devalue's built-in DataView
// branch can run, so the built-in tag never appears in a new payload
// and cannot collide with the tuples already in event logs.
const wire = new TextDecoder().decode(serialized);
expect(wire).toBe('devl[["DataViewBytes",1],"AgM="]');
});
});

describe('step function serialization', () => {
const { globalThis: vmGlobalThis } = createContext({
seed: 'test',
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/serialization/reducers/common-vm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,35 @@ describe('common-vm reducer/reviver drift guard', () => {
.sort();
expect(extras).toEqual(VM_ONLY_TYPES);
});

it('both sides bound a DataView to its viewed bytes', () => {
// The two implementations read the view's range through different
// primitives (hardened internal-slot getters vs. plain property reads),
// so agreement here is worth pinning: a regression on either side puts
// the untouched remainder of the backing buffer on the wire.
const backing = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]);
const view = new DataView(backing.buffer, 2, 3);
const expected = Buffer.from([2, 3, 4]).toString('base64');

expect(getNodeReducers().DataViewBytes!(view)).toBe(expected);
expect(getVmReducers().DataViewBytes!(view)).toBe(expected);

const revived = getVmRevivers().DataViewBytes!(expected) as DataView;
expect(revived.byteLength).toBe(3);
expect(Array.from(new Uint8Array(revived.buffer))).toEqual([2, 3, 4]);
});

it('neither side claims the built-in DataView tag', () => {
// A custom reviver for that tag would take devalue's built-in branch
// out of reach, and with it the bounds recorded in payloads that
// predate `DataViewBytes`.
for (const set of [
getNodeReducers(),
getVmReducers(),
getNodeRevivers(),
getVmRevivers(),
]) {
expect(set).not.toHaveProperty('DataView');
}
});
});
6 changes: 6 additions & 0 deletions packages/core/src/serialization/reducers/common-vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export function getCommonReducers(): Partial<Reducers> {
value instanceof BigInt64Array && viewToBase64(value),
BigUint64Array: (value) =>
value instanceof BigUint64Array && viewToBase64(value),
// Claimed rather than left to devalue, which encodes a DataView as its
// whole backing ArrayBuffer plus offset/length. The tag is not `DataView`
// so that older payloads under that name keep reaching devalue's built-in
// branch with their bounds; see the host-side common.ts.
DataViewBytes: (value) => value instanceof DataView && viewToBase64(value),
Date: (value) => {
if (!(value instanceof Date)) return false;
const valid = !Number.isNaN(value.getDate());
Expand Down Expand Up @@ -387,6 +392,7 @@ export function getCommonRevivers(): Partial<Revivers> {
new BigInt64Array(reviveArrayBuffer(value)),
BigUint64Array: (value: string) =>
new BigUint64Array(reviveArrayBuffer(value)),
DataViewBytes: (value: string) => new DataView(reviveArrayBuffer(value)),
Date: (value) => new Date(value),
DOMException: (value) => {
const error = new DOMException(value.message, value.name);
Expand Down
Loading
Loading