@openclaw/plugin-inspector 0.3.23 → 0.3.25

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.
@@ -1,27 +1,22 @@
1
1
  import { rmSync } from "node:fs";
2
2
  import { mkdtemp } from "node:fs/promises";
3
- import { register } from "node:module";
4
3
  import os from "node:os";
5
4
  import path from "node:path";
6
- import { pathToFileURL } from "node:url";
7
5
  import { captureEntrypoint } from "./inspector.js";
8
- import { createMockSdkPackage } from "./sdk-mock.js";
6
+ import { createMockSdkPackage, installMockSdkLoader } from "./sdk-mock.js";
9
7
  import { runCapturedSyntheticProbes } from "./synthetic-probes.js";
10
8
 
11
9
  export async function runEntrypointSyntheticProbes(entrypoint, options = {}) {
12
- const capture = await captureEntrypointForSyntheticProbes(entrypoint, {
10
+ const captureOptions = {
13
11
  ...options,
14
12
  apiOptions: {
15
13
  ...(options.apiOptions ?? {}),
16
14
  retainHandlers: true,
17
15
  },
18
- });
19
- return runCapturedSyntheticProbes(capture, options);
20
- }
21
-
22
- async function captureEntrypointForSyntheticProbes(entrypoint, options) {
16
+ };
23
17
  if (options.mockSdk !== true) {
24
- return captureEntrypoint(entrypoint, options);
18
+ const capture = await captureEntrypoint(entrypoint, captureOptions);
19
+ return runCapturedSyntheticProbes(capture, options);
25
20
  }
26
21
 
27
22
  const cwd = options.cwd ?? process.cwd();
@@ -29,15 +24,20 @@ async function captureEntrypointForSyntheticProbes(entrypoint, options) {
29
24
  const pluginRoot = path.resolve(cwd, options.pluginRoot ?? path.dirname(resolvedEntrypoint));
30
25
  const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-synthetic-mock-sdk-"));
31
26
  cleanupTempDirOnExit(workspace);
32
- const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
33
- register(pathToFileURL(loaderPath));
34
-
35
- return captureEntrypoint(entrypoint, {
36
- ...options,
37
- cwd,
38
- mockSdk: false,
39
- pluginRoot,
40
- });
27
+ const mockPackage = await createMockSdkPackage(workspace, { pluginRoot });
28
+ const stopLoader = await installMockSdkLoader(mockPackage);
29
+ try {
30
+ const capture = await captureEntrypoint(entrypoint, {
31
+ ...captureOptions,
32
+ cwd,
33
+ mockSdk: false,
34
+ pluginRoot,
35
+ });
36
+ // Retained handlers can require SDK modules lazily during invocation.
37
+ return await runCapturedSyntheticProbes(capture, options);
38
+ } finally {
39
+ stopLoader();
40
+ }
41
41
  }
42
42
 
43
43
  function cleanupTempDirOnExit(dir) {
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { runEntrypointSyntheticProbes, writeArtifacts } from "./advanced.js";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { readBoundedJsonArtifact, writeArtifacts } from "./artifacts.js";
7
+ import { resolveProcessLimits, startOwnedProcess } from "./process-profile.js";
3
8
 
4
9
  const args = process.argv.slice(2);
5
10
 
@@ -26,7 +31,7 @@ async function run(commandArgs) {
26
31
  throw new Error("synthetic probes import plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 in an isolated workspace");
27
32
  }
28
33
 
29
- const results = await runEntrypointSyntheticProbes(entrypoint, {
34
+ const results = await runInChild(entrypoint, {
30
35
  mockSdk,
31
36
  pluginRoot,
32
37
  apiOptions: { retainHandlers: true },
@@ -43,6 +48,78 @@ async function run(commandArgs) {
43
48
  }
44
49
  }
45
50
 
51
+ async function runInChild(entrypoint, options) {
52
+ const limits = resolveProcessLimits({}, "PROBE");
53
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-synthetic-cli-"));
54
+ const outputPath = path.join(workspace, "result.json");
55
+ const controller = new AbortController();
56
+ const cancel = () => controller.abort(new Error("Synthetic probes cancelled"));
57
+ process.once("SIGINT", cancel);
58
+ process.once("SIGTERM", cancel);
59
+ try {
60
+ const runnerPath = fileURLToPath(new URL("./mock-sdk-capture-runner.js", import.meta.url));
61
+ const { result } = startOwnedProcess({
62
+ command: process.execPath,
63
+ args: [
64
+ "--no-warnings",
65
+ ...(options.mockSdk ? ["--preserve-symlinks"] : []),
66
+ runnerPath,
67
+ JSON.stringify({
68
+ ...options, ...limits, entrypoint, outputPath,
69
+ cwd: process.cwd(), syntheticProbes: true,
70
+ }),
71
+ ],
72
+ ...limits,
73
+ signal: controller.signal,
74
+ }, "PROBE");
75
+ const outcome = await result;
76
+ if (outcome.exitCode !== 0 || outcome.outputTruncated) {
77
+ const message = outcome.cancelled ? "Synthetic probes cancelled"
78
+ : outcome.timedOut ? `Synthetic probes timed out after ${limits.timeoutMs}ms`
79
+ : outcome.outputTruncated ? "Synthetic probe child output exceeded its byte limit"
80
+ : outcome.stderr.trim() || outcome.error?.message || "Synthetic probe child failed";
81
+ throw new Error(message);
82
+ }
83
+ controller.signal.throwIfAborted();
84
+ // The report is separate from plugin stdout, including direct fd writes.
85
+ // Only accept a fresh complete artifact after successful child cleanup.
86
+ const results = await readBoundedJsonArtifact(outputPath, limits.maxOutputBytes);
87
+ validateSyntheticReport(results);
88
+ controller.signal.throwIfAborted();
89
+ return results;
90
+ } finally {
91
+ process.removeListener("SIGINT", cancel);
92
+ process.removeListener("SIGTERM", cancel);
93
+ await rm(workspace, { recursive: true, force: true });
94
+ }
95
+ }
96
+
97
+ function validateSyntheticReport(report) {
98
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
99
+ if (!isObject(report) || typeof report.entrypoint !== "string" ||
100
+ !["captured", "no-register-export"].includes(report.status) ||
101
+ !isObject(report.summary) || !Array.isArray(report.results)) {
102
+ throw new Error("Invalid synthetic probe report: expected entrypoint, status, summary, and results");
103
+ }
104
+ const counts = { probeCount: report.results.length, passCount: 0, failCount: 0, blockedCount: 0 };
105
+ for (const row of report.results) {
106
+ if (!isObject(row) || !Number.isSafeInteger(row.captureIndex) || row.captureIndex < 0 ||
107
+ !["kind", "seam", "label"].every((key) => typeof row[key] === "string") ||
108
+ !["pass", "fail", "blocked"].includes(row.status) ||
109
+ (row.status === "fail" && typeof row.error !== "string") ||
110
+ (row.status === "blocked" && typeof row.reason !== "string")) {
111
+ throw new Error("Invalid synthetic probe report: malformed result row");
112
+ }
113
+ counts[`${row.status}Count`] += 1;
114
+ }
115
+ for (const [key, expected] of Object.entries(counts)) {
116
+ if (!Number.isSafeInteger(report.summary[key]) || report.summary[key] < 0 ||
117
+ report.summary[key] !== expected) {
118
+ throw new Error(`Invalid synthetic probe report: invalid or inconsistent ${key}`);
119
+ }
120
+ }
121
+ }
122
+
46
123
  function readFlag(commandArgs, name) {
47
124
  const index = commandArgs.indexOf(name);
48
125
  if (index === -1) {
@@ -1,4 +1,5 @@
1
1
  import { renderPaddedMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
2
+ import { resolveProcessLimits } from "./process-profile.js";
2
3
 
3
4
  export const syntheticRegistrationExecutionProfiles = {
4
5
  createChatChannelPlugin: {
@@ -46,6 +47,11 @@ export const syntheticRegistrationExecutionProfiles = {
46
47
  callableProperties: [],
47
48
  reason: "auto-enable probes are captured as registration metadata before runtime activation checks",
48
49
  },
50
+ registerBoardWidgetContentKind: {
51
+ mode: "metadata-only",
52
+ callableProperties: [],
53
+ reason: "board widget content kinds are captured as registration metadata before source validation or document composition",
54
+ },
49
55
  registerCli: {
50
56
  mode: "direct",
51
57
  callableProperties: ["handler", "run", "execute"],
@@ -126,6 +132,16 @@ export const syntheticRegistrationExecutionProfiles = {
126
132
  callableProperties: [],
127
133
  reason: "hosted media resolvers are captured as registration metadata before media URL resolution",
128
134
  },
135
+ registerMcpServerConnectionResolver: {
136
+ mode: "metadata-only",
137
+ callableProperties: [],
138
+ reason: "MCP server connection resolvers are captured as registration metadata before requester-bound transport resolution",
139
+ },
140
+ registerMemoryPromptPreparation: {
141
+ mode: "metadata-only",
142
+ callableProperties: [],
143
+ reason: "memory prompt preparation callbacks are captured as registration metadata before prompt-runtime execution",
144
+ },
129
145
  registerMemoryPromptSection: {
130
146
  mode: "metadata-only",
131
147
  callableProperties: [],
@@ -275,6 +291,11 @@ export const syntheticRegistrationExecutionProfiles = {
275
291
  callableProperties: [],
276
292
  reason: "text transforms are captured as registration metadata before content mutation execution",
277
293
  },
294
+ registerTranscriptSourceProvider: {
295
+ mode: "metadata-only",
296
+ callableProperties: [],
297
+ reason: "transcript source providers are captured as registration metadata before live capture or transcript import",
298
+ },
278
299
  registerVideoGenerationProvider: {
279
300
  mode: "metadata-only",
280
301
  callableProperties: [],
@@ -295,6 +316,16 @@ export const syntheticRegistrationExecutionProfiles = {
295
316
  callableProperties: [],
296
317
  reason: "web search providers are captured as registration metadata before provider runtime execution",
297
318
  },
319
+ registerWidgetPresenter: {
320
+ mode: "metadata-only",
321
+ callableProperties: [],
322
+ reason: "widget presenters are captured as registration metadata before presentation runtime execution",
323
+ },
324
+ registerWorkerProvider: {
325
+ mode: "metadata-only",
326
+ callableProperties: [],
327
+ reason: "worker providers are captured as registration metadata before cloud-worker lifecycle execution",
328
+ },
298
329
  };
299
330
 
300
331
  export const defaultSyntheticHookEvents = {
@@ -478,6 +509,7 @@ export const defaultSyntheticRegistrationProbeInputs = {
478
509
  registerGatewayMethod: {
479
510
  execute: gatewayProbeArgs,
480
511
  handler: gatewayProbeArgs,
512
+ invoke: gatewayProbeArgs,
481
513
  run: gatewayProbeArgs,
482
514
  },
483
515
  registerHttpRoute: {
@@ -592,8 +624,27 @@ export async function writeSyntheticProbePlan(plan, options = {}) {
592
624
  }
593
625
 
594
626
  export async function runCapturedSyntheticProbes(capture, options = {}) {
627
+ options.signal?.throwIfAborted();
595
628
  const hookEvents = options.hookEvents ?? defaultSyntheticHookEvents;
596
629
  const hookContexts = options.hookContexts ?? defaultSyntheticHookContexts;
630
+ const { timeoutMs } = resolveProcessLimits(options, "PROBE");
631
+ const controller = new AbortController();
632
+ const onCancel = () => controller.abort(new Error("Synthetic probes cancelled"));
633
+ options.signal?.addEventListener("abort", onCancel, { once: true });
634
+ if (options.signal?.aborted) onCancel();
635
+ try {
636
+ const result = await runCapturedProbes(capture, {
637
+ ...options, hookEvents, hookContexts, timeoutMs, controller, signal: controller.signal,
638
+ });
639
+ if (options.signal?.aborted) throw controller.signal.reason;
640
+ return result;
641
+ } finally {
642
+ options.signal?.removeEventListener("abort", onCancel);
643
+ }
644
+ }
645
+
646
+ async function runCapturedProbes(capture, options) {
647
+ const { hookEvents, hookContexts, timeoutMs, controller, signal } = options;
597
648
  const captured = capture.captured ?? [];
598
649
  const retained = new Map((capture.retained ?? []).map((item) => [item.captureIndex, item]));
599
650
  const resultsByCaptureIndex = new Map();
@@ -606,6 +657,10 @@ export async function runCapturedSyntheticProbes(capture, options = {}) {
606
657
  );
607
658
 
608
659
  for (const { entry, captureIndex } of executionEntries) {
660
+ if (signal.aborted) {
661
+ resultsByCaptureIndex.set(captureIndex, [blockedResult(entry, captureIndex, signal.reason.message)]);
662
+ continue;
663
+ }
609
664
  const retainedEntry = retained.get(captureIndex);
610
665
  if (!retainedEntry) {
611
666
  resultsByCaptureIndex.set(captureIndex, [blockedResult(entry, captureIndex, "handler retention was not enabled")]);
@@ -613,7 +668,7 @@ export async function runCapturedSyntheticProbes(capture, options = {}) {
613
668
  }
614
669
  if (entry.kind === "hook") {
615
670
  resultsByCaptureIndex.set(captureIndex, [
616
- await runHookProbe(entry, retainedEntry, captureIndex, { hookEvents, hookContexts }),
671
+ await runHookProbe(entry, retainedEntry, captureIndex, { hookEvents, hookContexts, timeoutMs, controller }),
617
672
  ]);
618
673
  continue;
619
674
  }
@@ -714,7 +769,7 @@ function probeBlocker({ hasSyntheticArguments, execution }) {
714
769
  return null;
715
770
  }
716
771
 
717
- async function runHookProbe(entry, retainedEntry, captureIndex, { hookEvents, hookContexts }) {
772
+ async function runHookProbe(entry, retainedEntry, captureIndex, { hookEvents, hookContexts, timeoutMs, controller }) {
718
773
  if (typeof retainedEntry.handler !== "function") {
719
774
  return blockedResult(entry, captureIndex, "captured hook has no callable handler");
720
775
  }
@@ -723,6 +778,8 @@ async function runHookProbe(entry, retainedEntry, captureIndex, { hookEvents, ho
723
778
  kind: "hook",
724
779
  seam: entry.name,
725
780
  label: entry.name,
781
+ timeoutMs,
782
+ controller,
726
783
  invoke: () =>
727
784
  retainedEntry.handler(
728
785
  hookEvents[entry.name] ?? { hook: entry.name },
@@ -741,7 +798,9 @@ async function runRegistrationProbes(entry, retainedEntry, captureIndex, options
741
798
  }
742
799
 
743
800
  const descriptor =
744
- retainedEntry.arguments?.find((value) => value && typeof value === "object") ?? retainedEntry.returnValue;
801
+ entry.name === "registerGatewayMethod" && typeof retainedEntry.arguments?.[0] === "string"
802
+ ? retainedEntry.returnValue
803
+ : retainedEntry.arguments?.find((value) => value && typeof value === "object") ?? retainedEntry.returnValue;
745
804
  if (!descriptor || typeof descriptor !== "object") {
746
805
  return [blockedResult(entry, captureIndex, "captured registration has no object descriptor")];
747
806
  }
@@ -754,17 +813,29 @@ async function runRegistrationProbes(entry, retainedEntry, captureIndex, options
754
813
  return [blockedResult(entry, captureIndex, "captured registration has no supported callable probe")];
755
814
  }
756
815
 
757
- return Promise.all(
758
- invocations.map((invocation) =>
759
- runProbe({
816
+ // The profile owns lifecycle order; finish each callback before starting the next.
817
+ const results = [];
818
+ for (const invocation of invocations) {
819
+ if (options.signal.aborted) {
820
+ results.push({
821
+ ...blockedResult(entry, captureIndex, options.signal.reason.message),
822
+ label: invocation.label,
823
+ });
824
+ continue;
825
+ }
826
+ results.push(
827
+ await runProbe({
760
828
  captureIndex,
761
829
  kind: "registration",
762
830
  seam: entry.name,
763
831
  label: invocation.label,
832
+ timeoutMs: options.timeoutMs,
833
+ controller: options.controller,
764
834
  invoke: invocation.invoke,
765
835
  }),
766
- ),
767
- );
836
+ );
837
+ }
838
+ return results;
768
839
  }
769
840
 
770
841
  function registrationInvocations(registrar, descriptor, returnValue, profile, options) {
@@ -781,20 +852,100 @@ function registrationInvocations(registrar, descriptor, returnValue, profile, op
781
852
  if (typeof callable === "function") {
782
853
  invocations.push({
783
854
  label: `${registrar}.${property}`,
784
- invoke: () => invokeRegistrationCallable(callable, registrar, property, options),
855
+ invoke: () => registrar === "registerGatewayMethod"
856
+ ? invokeGatewayCallable(callable, property, descriptor, options)
857
+ : invokeRegistrationCallable(callable, registrar, property, options),
785
858
  });
859
+ // Gateway aliases describe one method, not independent lifecycle callbacks.
860
+ if (registrar === "registerGatewayMethod") break;
786
861
  }
787
862
  }
788
863
  return invocations;
789
864
  }
790
865
 
791
- function invokeRegistrationCallable(callable, registrar, property, options) {
792
- const event = syntheticRegistrationEvent(registrar, property, options);
866
+ function invokeRegistrationCallable(
867
+ callable, registrar, property, options,
868
+ event = syntheticRegistrationEvent(registrar, property, options),
869
+ ) {
793
870
  const inputFactory = options.registrationProbeInputs?.[registrar]?.[property] ?? defaultSyntheticRegistrationProbeInputs[registrar]?.[property];
794
871
  const args = inputFactory ? inputFactory(event, options) : [event];
795
872
  return callable(...args);
796
873
  }
797
874
 
875
+ async function invokeGatewayCallable(callable, property, descriptor, options) {
876
+ let responded = false;
877
+ let closed = false;
878
+ let resolveResponse;
879
+ const response = new Promise((resolve) => { resolveResponse = resolve; });
880
+ const onAbort = () => {
881
+ closed = true;
882
+ resolveResponse({ error: options.signal.reason });
883
+ };
884
+ options.signal.addEventListener("abort", onAbort, { once: true });
885
+ const respond = (ok, payload, error) => {
886
+ if (closed || responded) return;
887
+ // Snapshot the first emission, including invalid frames. Later responses
888
+ // cannot recover it; logging metadata is not part of the wire envelope.
889
+ responded = true;
890
+ try {
891
+ resolveResponse({ frame: gatewayResponseFrame(ok, payload, error) });
892
+ } catch (error) {
893
+ resolveResponse({ error });
894
+ }
895
+ };
896
+ try {
897
+ options.signal.throwIfAborted();
898
+ const event = {
899
+ ...syntheticRegistrationEvent("registerGatewayMethod", property, options),
900
+ method: descriptor.method ?? descriptor.name,
901
+ respond,
902
+ };
903
+ const result = await invokeRegistrationCallable(callable, "registerGatewayMethod", property, options, event);
904
+ // OpenClaw's plugin registrar adapts returns only without an explicit reply.
905
+ if (!responded && result !== undefined) respond(true, result);
906
+ // A void handler may respond later. The existing probe deadline bounds this
907
+ // observation as well as handler settlement; it owns timeout/cancellation.
908
+ const outcome = await response;
909
+ if (outcome.error) throw outcome.error;
910
+ if (!outcome.frame.ok) {
911
+ throw new Error(`Gateway response error: ${outcome.frame.error?.message ?? "request failed"}`);
912
+ }
913
+ return outcome.frame;
914
+ } finally {
915
+ closed = true;
916
+ options.signal.removeEventListener("abort", onAbort);
917
+ }
918
+ }
919
+
920
+ function gatewayResponseFrame(ok, payload, error) {
921
+ let frame;
922
+ try {
923
+ frame = JSON.parse(JSON.stringify({ type: "res", id: "fixture-request", ok, payload, error }));
924
+ } catch {
925
+ throw new Error("Gateway response is not JSON serializable");
926
+ }
927
+ // ResponseFrameSchema / ErrorShapeSchema in OpenClaw v2026.9.3. Payload and
928
+ // error are optional independently of ok; error codes are nonempty strings.
929
+ const responseError = frame.error;
930
+ if (
931
+ typeof frame.ok !== "boolean" ||
932
+ (responseError !== undefined && (
933
+ !responseError ||
934
+ typeof responseError !== "object" ||
935
+ Array.isArray(responseError) ||
936
+ typeof responseError.code !== "string" || responseError.code.length === 0 ||
937
+ typeof responseError.message !== "string" || responseError.message.length === 0 ||
938
+ (responseError.retryable !== undefined && typeof responseError.retryable !== "boolean") ||
939
+ (responseError.retryAfterMs !== undefined &&
940
+ (!Number.isInteger(responseError.retryAfterMs) || responseError.retryAfterMs < 0)) ||
941
+ Object.keys(responseError).some((key) => !["code", "message", "details", "retryable", "retryAfterMs"].includes(key))
942
+ ))
943
+ ) {
944
+ throw new Error("Gateway response is malformed");
945
+ }
946
+ return frame;
947
+ }
948
+
798
949
  function syntheticRegistrationEvent(registrar, property, options) {
799
950
  const hookEvents = options.hookEvents ?? defaultSyntheticHookEvents;
800
951
  const beforeToolCall = hookEvents.before_tool_call ?? defaultSyntheticHookEvents.before_tool_call;
@@ -817,21 +968,21 @@ function syntheticRegistrationEvent(registrar, property, options) {
817
968
  };
818
969
  }
819
970
 
820
- function toolRunProbeArgs(event) {
971
+ function toolRunProbeArgs(event, options = {}) {
821
972
  return [
822
973
  event.params,
823
974
  {
824
975
  source: event.source,
825
976
  toolName: event.toolName,
826
977
  toolCallId: event.toolCall.id,
827
- signal: new AbortController().signal,
978
+ signal: options.signal ?? new AbortController().signal,
828
979
  logger: console,
829
980
  },
830
981
  ];
831
982
  }
832
983
 
833
- function toolExecuteProbeArgs(event) {
834
- return [event.toolCall.id, event.params, new AbortController().signal, () => undefined];
984
+ function toolExecuteProbeArgs(event, options = {}) {
985
+ return [event.toolCall.id, event.params, options.signal ?? new AbortController().signal, () => undefined];
835
986
  }
836
987
 
837
988
  function httpRouteProbeArgs(event) {
@@ -853,34 +1004,31 @@ function httpRouteProbeArgs(event) {
853
1004
  ];
854
1005
  }
855
1006
 
856
- function commandProbeArgs(event) {
1007
+ function commandProbeArgs(event, options = {}) {
857
1008
  return [
858
1009
  event.input,
859
1010
  {
860
1011
  source: event.source,
861
- signal: new AbortController().signal,
1012
+ signal: options.signal ?? new AbortController().signal,
862
1013
  logger: console,
863
1014
  },
864
1015
  ];
865
1016
  }
866
1017
 
867
- function gatewayProbeArgs(event) {
1018
+ function gatewayProbeArgs(event, options = {}) {
868
1019
  return [
869
1020
  {
870
1021
  ...event,
871
- params: event.params,
872
- body: event.body,
873
- headers: event.headers,
874
- respond: event.respond,
875
- },
876
- {
877
- source: event.source,
878
- logger: console,
1022
+ req: { type: "req", id: "fixture-request", method: event.method ?? "fixture.gateway.method", params: event.params },
1023
+ client: null,
1024
+ isWebchatConnect: () => false,
1025
+ context: { source: event.source, logger: console },
1026
+ signal: options.signal,
879
1027
  },
880
1028
  ];
881
1029
  }
882
1030
 
883
- function channelSendProbeArgs(event) {
1031
+ function channelSendProbeArgs(event, options = {}) {
884
1032
  return [
885
1033
  {
886
1034
  source: event.source,
@@ -891,12 +1039,12 @@ function channelSendProbeArgs(event) {
891
1039
  replyToId: "fixture-reply",
892
1040
  threadId: "fixture-thread",
893
1041
  logger: console,
894
- signal: new AbortController().signal,
1042
+ signal: options.signal ?? new AbortController().signal,
895
1043
  },
896
1044
  ];
897
1045
  }
898
1046
 
899
- function channelReceiveProbeArgs(event) {
1047
+ function channelReceiveProbeArgs(event, options = {}) {
900
1048
  return [
901
1049
  {
902
1050
  source: event.source,
@@ -916,7 +1064,7 @@ function channelReceiveProbeArgs(event) {
916
1064
  to: "fixture-channel",
917
1065
  },
918
1066
  logger: console,
919
- signal: new AbortController().signal,
1067
+ signal: options.signal ?? new AbortController().signal,
920
1068
  },
921
1069
  ];
922
1070
  }
@@ -934,7 +1082,7 @@ function interactiveProbeArgs(event) {
934
1082
  ];
935
1083
  }
936
1084
 
937
- function lifecycleProbeArgs(event) {
1085
+ function lifecycleProbeArgs(event, options = {}) {
938
1086
  return [
939
1087
  {
940
1088
  source: event.source,
@@ -942,7 +1090,7 @@ function lifecycleProbeArgs(event) {
942
1090
  logger: console,
943
1091
  runtime: { env: {}, logger: console },
944
1092
  secrets: { get: async () => null, has: async () => false },
945
- signal: new AbortController().signal,
1093
+ signal: options.signal ?? new AbortController().signal,
946
1094
  },
947
1095
  ];
948
1096
  }
@@ -960,9 +1108,9 @@ function speechProbeArgs(event) {
960
1108
  ];
961
1109
  }
962
1110
 
963
- async function runProbe({ captureIndex, kind, seam, label, invoke }) {
1111
+ async function runProbe({ captureIndex, kind, seam, label, invoke, timeoutMs, controller }) {
964
1112
  try {
965
- const output = await invoke();
1113
+ const output = await invokeWithTimeout(invoke, timeoutMs, controller);
966
1114
  return {
967
1115
  captureIndex,
968
1116
  kind,
@@ -983,6 +1131,30 @@ async function runProbe({ captureIndex, kind, seam, label, invoke }) {
983
1131
  }
984
1132
  }
985
1133
 
1134
+ function invokeWithTimeout(invoke, timeoutMs, controller) {
1135
+ const { signal } = controller;
1136
+ let rejectAborted;
1137
+ const aborted = new Promise((_, reject) => { rejectAborted = reject; });
1138
+ const onAbort = () => rejectAborted(signal.reason);
1139
+ signal.addEventListener("abort", onAbort, { once: true });
1140
+ const timeoutId = setTimeout(() => {
1141
+ controller.abort(new Error(`Synthetic probe timed out after ${timeoutMs}ms`));
1142
+ }, timeoutMs);
1143
+ // Keep observing settlement after abort without starting dependent callbacks.
1144
+ // In-process JavaScript itself is not preempted by this deadline.
1145
+ const result = Promise.resolve().then(() => {
1146
+ signal.throwIfAborted();
1147
+ return invoke();
1148
+ }).then((output) => {
1149
+ signal.throwIfAborted();
1150
+ return output;
1151
+ });
1152
+ return Promise.race([result, aborted]).finally(() => {
1153
+ clearTimeout(timeoutId);
1154
+ signal.removeEventListener("abort", onAbort);
1155
+ });
1156
+ }
1157
+
986
1158
  function blockedResult(entry, captureIndex, reason) {
987
1159
  return {
988
1160
  captureIndex,