@forgeax/engine-rhi-debug 0.1.28 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +13 -0
  2. package/dist/__tests__/query-set-lifecycle.unit.test.d.ts +2 -0
  3. package/dist/__tests__/query-set-lifecycle.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/query-set-replay-fixture.d.ts +39 -0
  5. package/dist/__tests__/query-set-replay-fixture.d.ts.map +1 -0
  6. package/dist/__tests__/recorder-canvas-context.unit.test.d.ts +2 -0
  7. package/dist/__tests__/recorder-canvas-context.unit.test.d.ts.map +1 -0
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.mjs +465 -53
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/protocol/event-semantics.d.ts +1 -1
  13. package/dist/protocol/event-semantics.d.ts.map +1 -1
  14. package/dist/protocol/types.d.ts +1 -1
  15. package/dist/protocol/types.d.ts.map +1 -1
  16. package/dist/recorder/closure.d.ts.map +1 -1
  17. package/dist/recorder/core.d.ts +10 -1
  18. package/dist/recorder/core.d.ts.map +1 -1
  19. package/dist/recorder/device.d.ts.map +1 -1
  20. package/dist/recorder/encoder.d.ts.map +1 -1
  21. package/dist/recorder/pass.d.ts.map +1 -1
  22. package/dist/recorder/pipeline.d.ts +5 -0
  23. package/dist/recorder/pipeline.d.ts.map +1 -0
  24. package/dist/recorder/proxy.d.ts +3 -1
  25. package/dist/recorder/proxy.d.ts.map +1 -1
  26. package/dist/recorder/session.d.ts +1 -1
  27. package/dist/recorder/session.d.ts.map +1 -1
  28. package/dist/recorder/shader.d.ts +3 -1
  29. package/dist/recorder/shader.d.ts.map +1 -1
  30. package/dist/recorder.d.ts +2 -2
  31. package/dist/recorder.d.ts.map +1 -1
  32. package/dist/replay/execute.d.ts.map +1 -1
  33. package/dist/replay/resources.d.ts +4 -1
  34. package/dist/replay/resources.d.ts.map +1 -1
  35. package/dist/replay/session.d.ts +7 -0
  36. package/dist/replay/session.d.ts.map +1 -1
  37. package/dist/types.d.ts +39 -4
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +6 -6
  40. package/src/__tests__/coverage-invariant.test-d.ts +4 -5
  41. package/src/__tests__/coverage-invariant.unit.test.ts +6 -17
  42. package/src/__tests__/e2e.browser.test.ts +50 -0
  43. package/src/__tests__/guard-gates.test.ts +1 -1
  44. package/src/__tests__/query-set-lifecycle.unit.test.ts +265 -0
  45. package/src/__tests__/query-set-replay-fixture.ts +553 -0
  46. package/src/__tests__/readback-format-matrix.dawn.test.ts +7 -0
  47. package/src/__tests__/recorder-canvas-context.unit.test.ts +67 -0
  48. package/src/__tests__/recorder-session.integration.test.ts +55 -0
  49. package/src/__tests__/recorder-session.unit.test.ts +18 -1
  50. package/src/__tests__/replay-session.dawn.test.ts +49 -0
  51. package/src/__tests__/replay-session.test-d.ts +1 -0
  52. package/src/__tests__/rhi-debug-fresh-replay.dawn.test.ts +61 -0
  53. package/src/index.ts +1 -0
  54. package/src/protocol/event-semantics.ts +20 -2
  55. package/src/protocol/types.ts +1 -0
  56. package/src/protocol/validation.ts +31 -0
  57. package/src/recorder/assemble.ts +8 -1
  58. package/src/recorder/closure.ts +17 -0
  59. package/src/recorder/core.ts +11 -0
  60. package/src/recorder/device.ts +33 -6
  61. package/src/recorder/encoder.ts +22 -4
  62. package/src/recorder/pass.ts +7 -2
  63. package/src/recorder/pipeline.ts +31 -0
  64. package/src/recorder/proxy.ts +58 -2
  65. package/src/recorder/session.ts +1 -1
  66. package/src/recorder/shader.ts +29 -22
  67. package/src/recorder.ts +6 -2
  68. package/src/replay/execute.ts +111 -0
  69. package/src/replay/readback.ts +8 -3
  70. package/src/replay/resources.ts +6 -0
  71. package/src/replay/session.ts +234 -25
  72. package/src/types.ts +53 -12
@@ -3,6 +3,7 @@ import { err, ok, type Result } from '@forgeax/engine-types';
3
3
  import { createRhiDebugError, type RhiDebugError } from '../errors';
4
4
  import {
5
5
  type CreateShaderModuleFn,
6
+ type CreateShaderModuleImmediateFn,
6
7
  type DebugRhiInstance,
7
8
  wrap,
8
9
  wrapCreateShaderModule,
@@ -15,6 +16,8 @@ export interface RecordableBackend {
15
16
  ) => Result<RhiCanvasContext, RhiError>;
16
17
  };
17
18
  readonly createShaderModule: CreateShaderModuleFn;
19
+ /** Optional synchronous factory retained for render's first-use path. */
20
+ readonly createShaderModuleImmediate?: CreateShaderModuleImmediateFn;
18
21
  }
19
22
 
20
23
  export interface RecorderBackend extends RecordableBackend {
@@ -29,21 +32,74 @@ export interface RecorderProxy {
29
32
  export function createRecorderProxy(backend: RecordableBackend): RecorderProxy {
30
33
  const recorder = wrap(backend.rhi);
31
34
  const wrappedCreateShaderModule = wrapCreateShaderModule(backend.createShaderModule, recorder);
35
+ const originalCreateShaderModuleImmediate = backend.createShaderModuleImmediate;
36
+ const wrappedCreateShaderModuleImmediate =
37
+ originalCreateShaderModuleImmediate === undefined
38
+ ? undefined
39
+ : (device: RhiDevice, desc: { code: string; label?: string | undefined }) => {
40
+ const realDevice =
41
+ (device as RhiDevice & { readonly _realDevice?: RhiDevice })._realDevice ?? device;
42
+ const result = originalCreateShaderModuleImmediate(realDevice, desc);
43
+ if (!result.ok) return result;
44
+ const hId = recorder.pushExternalCreateEvent(result.value, 'shaderModule', {
45
+ kind: 'createShaderModule',
46
+ handleId: '' as import('../types').HandleId,
47
+ wgslCode: desc.code,
48
+ });
49
+ recorder.registerShaderModule(result.value, hId);
50
+ return result;
51
+ };
32
52
  const acquireCanvasContext = backend.rhi.acquireCanvasContext;
33
53
  // Runtime's explicit-RHI seam receives the wrapped singleton as one value.
34
54
  // Keep the standalone shader factory enumerable on that value so the
35
55
  // renderer can preserve the complete typed backend pack after capture
36
56
  // attachment; the public RhiInstance contract remains unchanged.
37
- const rhi: RecordableBackend['rhi'] & { readonly createShaderModule: CreateShaderModuleFn } = {
57
+ const rhi: RecordableBackend['rhi'] & {
58
+ readonly createShaderModule: CreateShaderModuleFn;
59
+ readonly createShaderModuleImmediate?: CreateShaderModuleImmediateFn;
60
+ } = {
38
61
  requestAdapter: recorder.requestAdapter.bind(recorder),
39
62
  createShaderModule: wrappedCreateShaderModule,
63
+ ...(wrappedCreateShaderModuleImmediate === undefined
64
+ ? {}
65
+ : { createShaderModuleImmediate: wrappedCreateShaderModuleImmediate }),
40
66
  ...(acquireCanvasContext === undefined
41
67
  ? {}
42
- : { acquireCanvasContext: acquireCanvasContext.bind(backend.rhi) }),
68
+ : {
69
+ acquireCanvasContext(canvas) {
70
+ const result = acquireCanvasContext.call(backend.rhi, canvas);
71
+ if (!result.ok) return result;
72
+ const context = result.value;
73
+ // rhi-webgpu's canvas shim translates the forgeax RhiDevice to the
74
+ // native GPUDevice through its own WeakMap. The recorder device is
75
+ // an intentional proxy, so passing it directly makes configure()
76
+ // reject with a foreign-device error before the first frame. Keep
77
+ // the context owned by the selected backend, but unwrap only the
78
+ // device argument at this boundary; all resource calls continue to
79
+ // flow through the recorder device and remain capturable.
80
+ return ok({
81
+ ...context,
82
+ configure(configuration) {
83
+ const device = (
84
+ configuration.device as RhiDevice & {
85
+ readonly _realDevice?: RhiDevice;
86
+ }
87
+ )._realDevice;
88
+ return context.configure({
89
+ ...configuration,
90
+ ...(device === undefined ? {} : { device }),
91
+ });
92
+ },
93
+ });
94
+ },
95
+ }),
43
96
  };
44
97
  const wrappedBackend: RecorderBackend = {
45
98
  rhi,
46
99
  createShaderModule: wrappedCreateShaderModule,
100
+ ...(wrappedCreateShaderModuleImmediate === undefined
101
+ ? {}
102
+ : { createShaderModuleImmediate: wrappedCreateShaderModuleImmediate }),
47
103
  unwrapDeviceForSurface(device) {
48
104
  const raw = (device as RhiDevice & { readonly _realDevice?: RhiDevice })._realDevice;
49
105
  if (raw !== undefined) return ok(raw);
@@ -147,6 +147,6 @@ export function attachRecorder(
147
147
  return ok(attachment);
148
148
  }
149
149
 
150
- export type { CreateShaderModuleFn } from '../recorder';
150
+ export type { CreateShaderModuleFn, CreateShaderModuleImmediateFn } from '../recorder';
151
151
  export type { EncodedTape } from './assemble';
152
152
  export type { RecordableBackend, RecorderBackend } from './proxy';
@@ -2,7 +2,24 @@
2
2
 
3
3
  import type { Result, RhiDevice, ShaderModule } from '@forgeax/engine-rhi';
4
4
  import type { HandleId } from '../types';
5
- import type { CreateShaderModuleFn, DebugRhiInstance } from './core';
5
+ import type { CreateShaderModuleFn, CreateShaderModuleImmediateFn, DebugRhiInstance } from './core';
6
+
7
+ function recordShaderModule(
8
+ debugInst: DebugRhiInstance,
9
+ result: Result<ShaderModule, import('@forgeax/engine-rhi').RhiError>,
10
+ desc: { code: string; label?: string | undefined },
11
+ ): Result<ShaderModule, import('@forgeax/engine-rhi').RhiError> {
12
+ if (!result.ok) return result;
13
+ const hId = debugInst.pushExternalCreateEvent(result.value, 'shaderModule', {
14
+ kind: 'createShaderModule',
15
+ handleId: '' as HandleId,
16
+ wgslCode: desc.code,
17
+ });
18
+ // Register the shader module handle in the recorder's handleMap so
19
+ // downstream pipeline events can resolve the handleId during replay.
20
+ debugInst.registerShaderModule(result.value, hId);
21
+ return result;
22
+ }
6
23
 
7
24
  export function wrapCreateShaderModule(
8
25
  originalFn: CreateShaderModuleFn,
@@ -21,27 +38,17 @@ export function wrapCreateShaderModule(
21
38
  // proxyDevice exposes for exactly this purpose.
22
39
  const realDevice = (device as RhiDevice & { _realDevice?: RhiDevice })._realDevice ?? device;
23
40
  const result = await originalFn(realDevice, desc);
24
- if (!result.ok) return result;
25
-
26
- // I-12 (round 1 implement-review) fix: route the createShaderModule
27
- // event through the same `pushExternalEvent` helper that wraps the
28
- // internal `pushEvent` (and therefore the `_skipRecord` + state-machine
29
- // guard). The previous `(events as RhiCallEvent[]).push(...)` cast
30
- // bypassed those guards — recorder-internal RHI calls during shader
31
- // construction would have self-polluted the tape.
32
- const hId = debugInst.pushExternalCreateEvent(result.value, 'shaderModule', {
33
- kind: 'createShaderModule',
34
- handleId: '' as HandleId,
35
- wgslCode: desc.code,
36
- });
37
- // Register the shader module handle in the recorder's handleMap so
38
- // downstream pipeline events (createRenderPipeline / createComputePipeline)
39
- // can resolve the handleId via getHandleId. Required for cross-device
40
- // replay: the tape's pipeline desc carries live GPU module objects which
41
- // are device-bound, so the replayer must swap them with re-created shader
42
- // modules from the handleMap using the handleId.
43
- debugInst.registerShaderModule(result.value, hId);
41
+ return recordShaderModule(debugInst, result, desc);
42
+ };
43
+ }
44
44
 
45
- return result;
45
+ /** Wrap the synchronous render-path factory while preserving tape ownership. */
46
+ export function wrapCreateShaderModuleImmediate(
47
+ originalFn: CreateShaderModuleImmediateFn,
48
+ debugInst: DebugRhiInstance,
49
+ ): CreateShaderModuleImmediateFn {
50
+ return (device, desc) => {
51
+ const realDevice = (device as RhiDevice & { _realDevice?: RhiDevice })._realDevice ?? device;
52
+ return recordShaderModule(debugInst, originalFn(realDevice, desc), desc);
46
53
  };
47
54
  }
package/src/recorder.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  // @forgeax/engine-rhi-debug/src/recorder -- split recorder public seam.
2
2
 
3
- export type { CreateShaderModuleFn, DebugRhiInstance } from './recorder/core';
3
+ export type {
4
+ CreateShaderModuleFn,
5
+ CreateShaderModuleImmediateFn,
6
+ DebugRhiInstance,
7
+ } from './recorder/core';
4
8
  export { PER_EVENT_OVERHEAD, TAPE_FORMAT_VERSION } from './recorder/core';
5
9
  export type {
6
10
  CaptureFrameOptions,
@@ -11,5 +15,5 @@ export type {
11
15
  RecorderOptions,
12
16
  } from './recorder/session';
13
17
  export { attachRecorder } from './recorder/session';
14
- export { wrapCreateShaderModule } from './recorder/shader';
18
+ export { wrapCreateShaderModule, wrapCreateShaderModuleImmediate } from './recorder/shader';
15
19
  export { wrap } from './recorder/wrap';
@@ -10,6 +10,7 @@ import type {
10
10
  RhiDevice,
11
11
  RhiError,
12
12
  RhiQueue,
13
+ RhiRenderPipelineOps,
13
14
  Texture,
14
15
  } from '@forgeax/engine-rhi';
15
16
  import { ok, type Result } from '@forgeax/engine-types';
@@ -70,10 +71,20 @@ export async function executeEvent(
70
71
  kind: 'texture',
71
72
  },
72
73
  );
74
+ case 'createQuerySet':
75
+ return createResource(
76
+ context,
77
+ eventIndex,
78
+ event,
79
+ context.device.createQuerySet(event.desc),
80
+ { kind: 'query-set' },
81
+ );
73
82
  case 'destroyBuffer':
74
83
  return destroyResource(context, eventIndex, event, 'buffer');
75
84
  case 'destroyTexture':
76
85
  return destroyResource(context, eventIndex, event, 'texture');
86
+ case 'destroyQuerySet':
87
+ return destroyQuerySet(context, event, eventIndex);
77
88
  case 'createTextureView': {
78
89
  const texture = requireResource<Texture>(
79
90
  context,
@@ -110,6 +121,28 @@ export async function executeEvent(
110
121
  context.device.createBindGroupLayout(event.desc as never),
111
122
  { kind: 'binding', role: 'bind-group-layout' },
112
123
  );
124
+ case 'getBindGroupLayout': {
125
+ const pipeline = requireReplayResource(
126
+ context,
127
+ event.pipelineHandleId,
128
+ 'pipeline',
129
+ eventIndex,
130
+ event.kind,
131
+ );
132
+ if (!pipeline.ok) return pipeline;
133
+ return createResource(
134
+ context,
135
+ eventIndex,
136
+ event,
137
+ ok(
138
+ (
139
+ pipeline.value.value as import('@forgeax/engine-rhi').RenderPipeline &
140
+ RhiRenderPipelineOps
141
+ ).getBindGroupLayout(event.index),
142
+ ),
143
+ { kind: 'binding', role: 'bind-group-layout' },
144
+ );
145
+ }
113
146
  case 'createBindGroup':
114
147
  return createBindGroup(context, event, eventIndex);
115
148
  case 'createPipelineLayout':
@@ -141,6 +174,22 @@ export async function executeEvent(
141
174
  return beginRenderPass(context, event, eventIndex);
142
175
  case 'beginComputePass':
143
176
  return beginComputePass(context, event, eventIndex);
177
+ case 'resolveQuerySet':
178
+ return resolveQuerySet(context, event, eventIndex);
179
+ case 'beginOcclusionQuery':
180
+ return passCall(context, event, eventIndex, 'render-pass', (pass) => {
181
+ const result = pass.beginOcclusionQuery(event.queryIndex);
182
+ return result.ok
183
+ ? ok(undefined)
184
+ : eventFailure(eventIndex, event, 'encode', result.error);
185
+ });
186
+ case 'endOcclusionQuery':
187
+ return passCall(context, event, eventIndex, 'render-pass', (pass) => {
188
+ const result = pass.endOcclusionQuery();
189
+ return result.ok
190
+ ? ok(undefined)
191
+ : eventFailure(eventIndex, event, 'encode', result.error);
192
+ });
144
193
  case 'copyBufferToBuffer':
145
194
  return copyBufferToBuffer(context, event, eventIndex);
146
195
  case 'copyBufferToTexture':
@@ -386,6 +435,60 @@ function destroyResource(
386
435
  return ok(undefined);
387
436
  }
388
437
 
438
+ function destroyQuerySet(
439
+ context: ReplayExecutionContext,
440
+ event: Extract<RhiCallEvent, { kind: 'destroyQuerySet' }>,
441
+ eventIndex: number,
442
+ ): Result<void, RhiDebugError> {
443
+ const entry = context.table.get(event.handleId);
444
+ if (entry?.resource.kind !== 'query-set')
445
+ return missingResource(eventIndex, event, event.handleId, 'query-set');
446
+ const result = context.device.destroyQuerySet(entry.resource.value);
447
+ if (!result.ok) return eventFailure(eventIndex, event, 'create', result.error);
448
+ context.table.delete(event.handleId);
449
+ return ok(undefined);
450
+ }
451
+
452
+ function resolveQuerySet(
453
+ context: ReplayExecutionContext,
454
+ event: Extract<RhiCallEvent, { kind: 'resolveQuerySet' }>,
455
+ eventIndex: number,
456
+ ): Result<void, RhiDebugError> {
457
+ const encoder = requireResource<RhiCommandEncoder>(
458
+ context,
459
+ event.cmdHandleId,
460
+ 'encoder',
461
+ eventIndex,
462
+ event.kind,
463
+ 'command',
464
+ );
465
+ const querySet = requireResource<import('@forgeax/engine-rhi').QuerySet>(
466
+ context,
467
+ event.querySetHandleId,
468
+ 'query-set',
469
+ eventIndex,
470
+ event.kind,
471
+ );
472
+ const destination = requireResource<Buffer>(
473
+ context,
474
+ event.destinationHandleId,
475
+ 'buffer',
476
+ eventIndex,
477
+ event.kind,
478
+ );
479
+ if (!encoder.ok) return encoder;
480
+ if (!querySet.ok) return querySet;
481
+ if (!destination.ok) return destination;
482
+ const result = encoder.value.resolveQuerySet(
483
+ querySet.value,
484
+ event.firstQuery,
485
+ event.queryCount,
486
+ destination.value,
487
+ event.destinationOffset,
488
+ );
489
+ return result.ok ? ok(undefined) : eventFailure(eventIndex, event, 'encode', result.error);
490
+ }
491
+
389
492
  async function createShaderModule(
390
493
  context: ReplayExecutionContext,
391
494
  event: Extract<RhiCallEvent, { kind: 'createShaderModule' }>,
@@ -673,6 +776,13 @@ function beginRenderPass(
673
776
  event.depthStencilViewHandleId === undefined
674
777
  ? undefined
675
778
  : context.table.get(event.depthStencilViewHandleId)?.resource;
779
+ const querySet =
780
+ event.occlusionQuerySetHandleId === undefined
781
+ ? undefined
782
+ : context.table.get(event.occlusionQuerySetHandleId)?.resource;
783
+ if (event.occlusionQuerySetHandleId !== undefined && querySet?.kind !== 'query-set') {
784
+ return missingResource(eventIndex, event, event.occlusionQuerySetHandleId, 'query-set');
785
+ }
676
786
  const descriptor = {
677
787
  ...event.desc,
678
788
  colorAttachments: colors,
@@ -684,6 +794,7 @@ function beginRenderPass(
684
794
  } as RenderPassDepthStencilAttachment,
685
795
  }
686
796
  : {}),
797
+ ...(querySet?.kind === 'query-set' ? { occlusionQuerySet: querySet.value } : {}),
687
798
  };
688
799
  const pass = encoder.value.beginRenderPass(descriptor as never);
689
800
  return context.table.set(
@@ -100,7 +100,7 @@ function resolveTextureViewSubresource(
100
100
  source: ResourceTableEntry,
101
101
  requested: ReplayReadbackRequest | undefined,
102
102
  ): Result<ReplayReadbackRequest | undefined, RhiDebugError> {
103
- if (requested === undefined || isBufferRange(requested)) return ok(requested);
103
+ if (isBufferRange(requested)) return ok(requested);
104
104
  const sourceDescriptor = recordField(source.descriptor, 'desc');
105
105
  const sourceSize = textureSize(sourceDescriptor?.size);
106
106
  const sourceMipCount = numberField(sourceDescriptor, 'mipLevelCount') ?? 1;
@@ -112,8 +112,8 @@ function resolveTextureViewSubresource(
112
112
  const arrayLayerCount =
113
113
  numberField(viewDescriptor, 'arrayLayerCount') ??
114
114
  sourceSize.depthOrArrayLayers - baseArrayLayer;
115
- const localMipLevel = requested.mipLevel ?? 0;
116
- const localArrayLayer = requested.arrayLayer ?? 0;
115
+ const localMipLevel = requested?.mipLevel ?? 0;
116
+ const localArrayLayer = requested?.arrayLayer ?? 0;
117
117
  if (
118
118
  !validIndex(baseMipLevel, sourceMipCount + 1) ||
119
119
  !validIndex(baseArrayLayer, sourceSize.depthOrArrayLayers + 1) ||
@@ -128,6 +128,11 @@ function resolveTextureViewSubresource(
128
128
  ...requested,
129
129
  mipLevel: baseMipLevel + localMipLevel,
130
130
  arrayLayer: baseArrayLayer + localArrayLayer,
131
+ aspect:
132
+ requested?.aspect ??
133
+ (viewDescriptor?.aspect === 'depth-only' || viewDescriptor?.aspect === 'stencil-only'
134
+ ? viewDescriptor.aspect
135
+ : 'all'),
131
136
  });
132
137
  }
133
138
 
@@ -5,6 +5,7 @@ import type {
5
5
  CommandBuffer,
6
6
  ComputePipeline,
7
7
  PipelineLayout,
8
+ QuerySet,
8
9
  RenderPipeline,
9
10
  RhiCommandEncoder,
10
11
  RhiComputePassEncoder,
@@ -24,6 +25,7 @@ export type ReplayResource = {
24
25
  } & (
25
26
  | { readonly kind: 'buffer'; readonly value: Buffer }
26
27
  | { readonly kind: 'texture'; readonly value: Texture }
28
+ | { readonly kind: 'query-set'; readonly value: QuerySet }
27
29
  | { readonly kind: 'texture-view'; readonly value: TextureView }
28
30
  | { readonly kind: 'sampler'; readonly value: Sampler }
29
31
  | { readonly kind: 'shader-module'; readonly value: ShaderModule }
@@ -132,6 +134,10 @@ export class ResourceTable {
132
134
  const result = this.device.destroyTexture(entry.resource.value);
133
135
  if (!result.ok && firstFailure === undefined)
134
136
  firstFailure = resourceDisposeFailure(entry.resourceId, result.error.code);
137
+ } else if (entry.resource.kind === 'query-set') {
138
+ const result = this.device.destroyQuerySet(entry.resource.value);
139
+ if (!result.ok && firstFailure === undefined)
140
+ firstFailure = resourceDisposeFailure(entry.resourceId, result.error.code);
135
141
  }
136
142
  }
137
143
  return firstFailure ?? ok(undefined);