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
18 changes: 18 additions & 0 deletions src/lib/init/wizard-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,11 +415,29 @@ async function runWizardInner(initialOptions: WizardOptions): Promise<void> {

const token = context.authToken;

// AbortController bound to the MastraClient lifecycle. Aborting on
// teardown (success OR failure, via `using` below) cancels any in-flight
// fetches — releasing keep-alive sockets so the event loop drains and
// `sentry init` returns to the shell promptly. Without this, a stuck or
// idle socket in Bun's fetch dispatcher can hold the process alive past
// the wizard's natural exit.
const abortController = new AbortController();
using _mastraCleanup = {
[Symbol.dispose]: (): void => {
// AbortController.abort() is spec-idempotent, so no guard needed.
abortController.abort();
},
};

const client = new MastraClient({
baseUrl: MASTRA_API_URL,
headers: token ? { Authorization: `Bearer ${token}` } : {},
abortSignal: abortController.signal,
fetch: ((url, init) => {
const traceData = getTraceData();
// Preserve `init.signal` via the spread — MastraClient may pass its
// own per-request signal, and the client-level `abortSignal` is
// forwarded through the same channel.
return fetch(url, {
...init,
headers: {
Expand Down
112 changes: 109 additions & 3 deletions test/lib/init/wizard-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ let precomputeSentryDetectionSpy: ReturnType<typeof spyOn>;
let closeFreshTtyForwardingSpy: ReturnType<typeof spyOn>;
let getWorkflowSpy: ReturnType<typeof spyOn>;
let stderrSpy: ReturnType<typeof spyOn>;
/**
* ClientOptions captured from each MastraClient instance constructed by
* runWizard. Used by the MastraClient lifecycle suite to assert that the
* `abortSignal` passed at construction time is aborted on teardown.
*/
let capturedClientOptions: { abortSignal?: AbortSignal }[] = [];

beforeEach(() => {
mockStartResult = { status: "success", result: { platform: "React" } };
Expand Down Expand Up @@ -171,9 +177,18 @@ beforeEach(() => {
const workflow = {
createRun: mock(() => Promise.resolve(run)),
};
getWorkflowSpy = spyOn(MastraClient.prototype, "getWorkflow").mockReturnValue(
workflow as any
);
capturedClientOptions = [];
getWorkflowSpy = spyOn(
MastraClient.prototype,
"getWorkflow"
).mockImplementation(function (this: MastraClient) {
// `this` is the MastraClient instance. `BaseResource.options` holds the
// full ClientOptions passed to the constructor — including abortSignal.
capturedClientOptions.push(
(this as unknown as { options: { abortSignal?: AbortSignal } }).options
);
return workflow as any;
});
});

afterEach(() => {
Expand Down Expand Up @@ -507,3 +522,94 @@ describe("runWizard", () => {
expect(spinnerMock.stop).toHaveBeenCalledWith("Using existing project");
});
});

describe("runWizard — MastraClient lifecycle", () => {
test("aborts the MastraClient signal after a successful run", async () => {
await runWizard(makeOptions());

expect(capturedClientOptions).toHaveLength(1);
const signal = capturedClientOptions[0]?.abortSignal;
expect(signal).toBeInstanceOf(AbortSignal);
// Using the non-null assertion safely — we asserted toBeInstanceOf above.
expect((signal as AbortSignal).aborted).toBe(true);
});

test("aborts the MastraClient signal when a tool throws", async () => {
const payload: ToolPayload = {
type: "tool",
operation: "run-commands",
cwd: "/tmp/test",
params: { commands: ["npm install @sentry/node"] },
};
mockStartResult = {
status: "suspended",
suspended: [["install-deps"]],
steps: {
"install-deps": { suspendPayload: payload },
},
};
executeToolSpy.mockRejectedValue(new Error("tool blew up"));

await expect(runWizard(makeOptions())).rejects.toThrow(WizardError);

expect(capturedClientOptions).toHaveLength(1);
const signal = capturedClientOptions[0]?.abortSignal;
expect(signal).toBeInstanceOf(AbortSignal);
expect((signal as AbortSignal).aborted).toBe(true);
});

test("aborts the MastraClient signal on cancellation", async () => {
const payload: ToolPayload = {
type: "tool",
operation: "run-commands",
cwd: "/tmp/test",
params: { commands: ["npm install @sentry/node"] },
};
mockStartResult = {
status: "suspended",
suspended: [["install-deps"]],
steps: {
"install-deps": { suspendPayload: payload },
},
};
executeToolSpy.mockRejectedValue(new WizardCancelledError());

await runWizard(makeOptions());

expect(capturedClientOptions).toHaveLength(1);
const signal = capturedClientOptions[0]?.abortSignal;
expect(signal).toBeInstanceOf(AbortSignal);
expect((signal as AbortSignal).aborted).toBe(true);
});

test("signal is live (not pre-aborted) while the wizard is running", async () => {
// `getWorkflow` runs BEFORE `startAsync` (client.getWorkflow is called
// synchronously right after `new MastraClient(...)`), so the signal
// observed at that time is the same instance that in-flight fetches
// would see during the wizard. If the signal were somehow pre-aborted
// at construction, it would be aborted here too. This proves the
// `using _mastraCleanup` disposable does NOT fire until teardown.
let abortedAtConstruction: boolean | undefined;
getWorkflowSpy.mockImplementation(function (this: MastraClient) {
const opts = (
this as unknown as { options: { abortSignal?: AbortSignal } }
).options;
capturedClientOptions.push(opts);
abortedAtConstruction = opts.abortSignal?.aborted;
return {
createRun: mock(() =>
Promise.resolve({
startAsync: startAsyncMock,
resumeAsync: mock(() => Promise.resolve({ status: "success" })),
})
),
} as any;
});

await runWizard(makeOptions());

expect(abortedAtConstruction).toBe(false);
// And teardown aborted it by the time the wizard returned.
expect(capturedClientOptions[0]?.abortSignal?.aborted).toBe(true);
});
});
Loading