Skip to content

Commit c550d5d

Browse files
committed
fix(gateway): abort gmail tailscale setup
Signed-off-by: samzong <[email protected]>
1 parent 1a61ecc commit c550d5d

8 files changed

Lines changed: 198 additions & 24 deletions

src/gateway/server-startup-post-attach.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,9 +788,12 @@ describe("startGatewayPostAttachRuntime", () => {
788788

789789
it("runs Gmail watcher after sidecars are ready", async () => {
790790
let resolveWatcher: (() => void) | undefined;
791+
let watcherSignal: AbortSignal | undefined;
791792
hoisted.startGmailWatcherWithLogs.mockImplementationOnce(
792-
async () =>
793+
async (...args: unknown[]) =>
793794
await new Promise<void>((resolve) => {
795+
const [params] = args as [{ signal?: AbortSignal }];
796+
watcherSignal = params.signal;
794797
resolveWatcher = resolve;
795798
}),
796799
);
@@ -825,11 +828,14 @@ describe("startGatewayPostAttachRuntime", () => {
825828
await vi.waitFor(() => {
826829
expect(hoisted.startGmailWatcherWithLogs).toHaveBeenCalledTimes(1);
827830
});
831+
expect(watcherSignal?.aborted).toBe(false);
828832
expect(log.warn).not.toHaveBeenCalled();
829833

830834
if (!resolveWatcher) {
831835
throw new Error("Expected gmail watcher resolver to be initialized");
832836
}
837+
result.postReadySidecars[0]?.stop();
838+
expect(watcherSignal?.aborted).toBe(true);
833839
resolveWatcher();
834840
});
835841

src/gateway/server-startup-post-attach.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,24 +150,26 @@ function schedulePostReadySidecarTask(params: {
150150
startupTrace?: GatewayStartupTrace;
151151
name: string;
152152
log: { warn: (msg: string) => void };
153-
run: (isStopped: () => boolean) => Awaitable<void>;
153+
run: (isStopped: () => boolean, signal: AbortSignal) => Awaitable<void>;
154154
}): GatewayPostReadySidecarHandle {
155155
let stopped = false;
156+
const abortController = new AbortController();
156157
const isStopped = () => stopped;
157158
const handle = setImmediate(() => {
158159
if (isStopped()) {
159160
return;
160161
}
161-
void measureStartup(params.startupTrace, params.name, () => params.run(isStopped)).catch(
162-
(err) => {
163-
params.log.warn(`${params.name} failed after gateway ready: ${String(err)}`);
164-
},
165-
);
162+
void measureStartup(params.startupTrace, params.name, () =>
163+
params.run(isStopped, abortController.signal),
164+
).catch((err) => {
165+
params.log.warn(`${params.name} failed after gateway ready: ${String(err)}`);
166+
});
166167
});
167168
handle.unref?.();
168169
return {
169170
stop: () => {
170171
stopped = true;
172+
abortController.abort();
171173
clearImmediate(handle);
172174
},
173175
};
@@ -620,7 +622,7 @@ export async function startGatewaySidecars(params: {
620622
startupTrace: params.startupTrace,
621623
name: "sidecars.gmail-watch",
622624
log: params.log,
623-
run: async (isStopped) => {
625+
run: async (isStopped, signal) => {
624626
const { startGmailWatcherWithLogs } = await import("../hooks/gmail-watcher-lifecycle.js");
625627
if (isStopped()) {
626628
return;
@@ -629,6 +631,7 @@ export async function startGatewaySidecars(params: {
629631
cfg: params.cfg,
630632
log: params.logHooks,
631633
isCancelled: isStopped,
634+
signal,
632635
});
633636
},
634637
}),

src/hooks/gmail-setup-utils.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,47 @@ describe("ensureTailscaleEndpoint", () => {
205205
expect(message).toContain("stdout: not-json");
206206
expect(message).toContain("code=0");
207207
});
208+
209+
it("passes abort signal to tailscale status and serve commands", async () => {
210+
const abortController = new AbortController();
211+
runCommandWithTimeoutMock
212+
.mockResolvedValueOnce({
213+
stdout: JSON.stringify({ Self: { DNSName: "host.tailnet.ts.net." } }),
214+
stderr: "",
215+
code: 0,
216+
signal: null,
217+
killed: false,
218+
})
219+
.mockResolvedValueOnce({
220+
stdout: "",
221+
stderr: "",
222+
code: 0,
223+
signal: null,
224+
killed: false,
225+
});
226+
227+
await ensureTailscaleEndpoint({
228+
mode: "serve",
229+
path: "/gmail-pubsub",
230+
port: 8788,
231+
signal: abortController.signal,
232+
});
233+
234+
expect(runCommandWithTimeoutMock).toHaveBeenNthCalledWith(
235+
1,
236+
["tailscale", "status", "--json"],
237+
{
238+
timeoutMs: 30_000,
239+
signal: abortController.signal,
240+
},
241+
);
242+
expect(runCommandWithTimeoutMock).toHaveBeenNthCalledWith(
243+
2,
244+
["tailscale", "serve", "--bg", "--set-path", "/gmail-pubsub", "--yes", "8788"],
245+
{
246+
timeoutMs: 30_000,
247+
signal: abortController.signal,
248+
},
249+
);
250+
});
208251
});

src/hooks/gmail-setup-utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export async function ensureTailscaleEndpoint(params: {
264264
mode: "off" | "serve" | "funnel";
265265
path: string;
266266
port?: number;
267+
signal?: AbortSignal;
267268
target?: string;
268269
token?: string;
269270
}): Promise<string> {
@@ -276,6 +277,7 @@ export async function ensureTailscaleEndpoint(params: {
276277
const statusCommand = formatCommand("tailscale", statusArgs);
277278
const status = await runCommandWithTimeout([tailscaleBin, ...statusArgs], {
278279
timeoutMs: 30_000,
280+
signal: params.signal,
279281
});
280282
if (status.code !== 0) {
281283
throw new Error(formatCommandFailure(statusCommand, status));
@@ -305,6 +307,7 @@ export async function ensureTailscaleEndpoint(params: {
305307
const funnelCommand = formatCommand("tailscale", funnelArgs);
306308
const funnelResult = await runCommandWithTimeout([tailscaleBin, ...funnelArgs], {
307309
timeoutMs: 30_000,
310+
signal: params.signal,
308311
});
309312
if (funnelResult.code !== 0) {
310313
throw new Error(formatCommandFailure(funnelCommand, funnelResult));

src/hooks/gmail-watcher-lifecycle.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,20 @@ describe("startGmailWatcherWithLogs", () => {
3131

3232
it("passes cancellation state to watcher startup", async () => {
3333
const isCancelled = vi.fn(() => true);
34+
const abortController = new AbortController();
3435
startGmailWatcherMock.mockResolvedValue({ started: false, reason: "startup cancelled" });
3536

3637
await startGmailWatcherWithLogs({
3738
cfg: {},
3839
log,
3940
isCancelled,
41+
signal: abortController.signal,
4042
});
4143

42-
expect(startGmailWatcherMock).toHaveBeenCalledWith({}, { isCancelled });
44+
expect(startGmailWatcherMock).toHaveBeenCalledWith(
45+
{},
46+
{ isCancelled, signal: abortController.signal },
47+
);
4348
});
4449

4550
it("logs startup success", async () => {

src/hooks/gmail-watcher-lifecycle.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@ export async function startGmailWatcherWithLogs(params: {
1313
log: GMailWatcherLog;
1414
onSkipped?: () => void;
1515
isCancelled?: () => boolean;
16+
signal?: AbortSignal;
1617
}) {
1718
if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_GMAIL_WATCHER)) {
1819
params.onSkipped?.();
1920
return;
2021
}
2122

2223
try {
23-
const gmailResult = await startGmailWatcher(params.cfg, { isCancelled: params.isCancelled });
24+
const gmailResult = await startGmailWatcher(params.cfg, {
25+
isCancelled: params.isCancelled,
26+
signal: params.signal,
27+
});
2428
if (gmailResult.started) {
2529
params.log.info("gmail watcher started");
2630
return;

src/hooks/gmail-watcher.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,52 @@ describe("startGmailWatcher", () => {
146146
});
147147
expect(mocks.spawn).not.toHaveBeenCalled();
148148
});
149+
150+
it("aborts tailscale setup and does not spawn gog serve when cancelled in flight", async () => {
151+
let cancelled = false;
152+
let tailscaleSignal: AbortSignal | undefined;
153+
mocks.runCommandWithTimeout.mockImplementation(
154+
async (_args, options: { signal?: AbortSignal }) =>
155+
await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve) => {
156+
tailscaleSignal = options.signal;
157+
options.signal?.addEventListener(
158+
"abort",
159+
() => resolve({ code: null, stdout: "", stderr: "aborted" }),
160+
{ once: true },
161+
);
162+
}),
163+
);
164+
const startPromise = startGmailWatcher(
165+
{
166+
hooks: {
167+
enabled: true,
168+
token: "hook-token",
169+
gmail: {
170+
account: "[email protected]",
171+
topic: "projects/demo/topics/gmail",
172+
pushToken: "push-token",
173+
tailscale: { mode: "serve" },
174+
},
175+
},
176+
} as never,
177+
{
178+
isCancelled: () => cancelled,
179+
},
180+
);
181+
182+
await vi.waitFor(() => {
183+
expect(tailscaleSignal).toBeDefined();
184+
});
185+
cancelled = true;
186+
187+
await vi.waitFor(() => {
188+
expect(tailscaleSignal?.aborted).toBe(true);
189+
});
190+
191+
await expect(startPromise).resolves.toEqual({
192+
started: false,
193+
reason: "startup cancelled",
194+
});
195+
expect(mocks.spawn).not.toHaveBeenCalled();
196+
});
149197
});

src/hooks/gmail-watcher.ts

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,17 @@ export type GmailWatcherStartResult = {
164164
reason?: string;
165165
};
166166

167+
type GmailWatcherCancellation = {
168+
dispose: () => void;
169+
isCancelled: () => boolean;
170+
signal?: AbortSignal;
171+
};
172+
173+
type GmailWatcherStartOptions = {
174+
isCancelled?: () => boolean;
175+
signal?: AbortSignal;
176+
};
177+
167178
function cancelledGmailWatcherStart(
168179
expectedConfig: GmailHookRuntimeConfig,
169180
): GmailWatcherStartResult {
@@ -173,13 +184,63 @@ function cancelledGmailWatcherStart(
173184
return { started: false, reason: "startup cancelled" };
174185
}
175186

187+
function isGmailWatcherStartCancelled(options: GmailWatcherStartOptions): boolean {
188+
return options.signal?.aborted === true || options.isCancelled?.() === true;
189+
}
190+
191+
function createGmailWatcherCancellation(
192+
options: GmailWatcherStartOptions,
193+
): GmailWatcherCancellation {
194+
if (!options.signal && !options.isCancelled) {
195+
return {
196+
dispose: () => {},
197+
isCancelled: () => false,
198+
};
199+
}
200+
201+
const abortController = new AbortController();
202+
const abort = () => {
203+
if (!abortController.signal.aborted) {
204+
abortController.abort();
205+
}
206+
};
207+
const onAbort = () => abort();
208+
options.signal?.addEventListener("abort", onAbort, { once: true });
209+
210+
let cancelPoll: ReturnType<typeof setInterval> | null = null;
211+
if (options.isCancelled) {
212+
cancelPoll = setInterval(() => {
213+
if (options.isCancelled?.()) {
214+
abort();
215+
}
216+
}, 100);
217+
cancelPoll.unref?.();
218+
}
219+
220+
if (isGmailWatcherStartCancelled(options)) {
221+
abort();
222+
}
223+
224+
return {
225+
dispose: () => {
226+
if (cancelPoll) {
227+
clearInterval(cancelPoll);
228+
cancelPoll = null;
229+
}
230+
options.signal?.removeEventListener("abort", onAbort);
231+
},
232+
isCancelled: () => abortController.signal.aborted || isGmailWatcherStartCancelled(options),
233+
signal: abortController.signal,
234+
};
235+
}
236+
176237
/**
177238
* Start the Gmail watcher service.
178239
* Called automatically by the gateway if hooks.gmail is configured.
179240
*/
180241
export async function startGmailWatcher(
181242
cfg: OpenClawConfig,
182-
options: { isCancelled?: () => boolean } = {},
243+
options: GmailWatcherStartOptions = {},
183244
): Promise<GmailWatcherStartResult> {
184245
// Check if gmail hooks are configured
185246
if (!cfg.hooks?.enabled) {
@@ -203,54 +264,55 @@ export async function startGmailWatcher(
203264
}
204265

205266
const runtimeConfig = resolved.value;
206-
if (options.isCancelled?.()) {
267+
if (isGmailWatcherStartCancelled(options)) {
207268
return cancelledGmailWatcherStart(runtimeConfig);
208269
}
209270
currentConfig = runtimeConfig;
210271

211272
// Set up Tailscale endpoint if needed
212273
if (runtimeConfig.tailscale.mode !== "off") {
274+
const cancellation = createGmailWatcherCancellation(options);
213275
try {
214276
await ensureTailscaleEndpoint({
215277
mode: runtimeConfig.tailscale.mode,
216278
path: runtimeConfig.tailscale.path,
217279
port: runtimeConfig.serve.port,
280+
signal: cancellation.signal,
218281
target: runtimeConfig.tailscale.target,
219282
});
220283
log.info(
221284
`tailscale ${runtimeConfig.tailscale.mode} configured for port ${runtimeConfig.serve.port}`,
222285
);
223-
if (options.isCancelled?.()) {
286+
if (cancellation.isCancelled()) {
224287
return cancelledGmailWatcherStart(runtimeConfig);
225288
}
226289
} catch (err) {
290+
if (cancellation.isCancelled()) {
291+
return cancelledGmailWatcherStart(runtimeConfig);
292+
}
227293
log.error(`tailscale setup failed: ${String(err)}`);
228294
return {
229295
started: false,
230296
reason: `tailscale setup failed: ${String(err)}`,
231297
};
298+
} finally {
299+
cancellation.dispose();
232300
}
233301
}
234302

235303
// Start the Gmail watch (register with Gmail API)
236-
const abortController = new AbortController();
237-
const cancelPoll = setInterval(() => {
238-
if (options.isCancelled?.()) {
239-
abortController.abort();
240-
}
241-
}, 100);
242-
cancelPoll.unref?.();
243-
const watchStarted = await startGmailWatch(runtimeConfig, { signal: abortController.signal });
244-
clearInterval(cancelPoll);
245-
if (options.isCancelled?.()) {
304+
const cancellation = createGmailWatcherCancellation(options);
305+
const watchStarted = await startGmailWatch(runtimeConfig, { signal: cancellation.signal });
306+
cancellation.dispose();
307+
if (cancellation.isCancelled()) {
246308
return cancelledGmailWatcherStart(runtimeConfig);
247309
}
248310
if (!watchStarted) {
249311
log.warn("gmail watch start failed, but continuing with serve");
250312
}
251313

252314
// Spawn the gog serve process
253-
if (options.isCancelled?.()) {
315+
if (isGmailWatcherStartCancelled(options)) {
254316
return cancelledGmailWatcherStart(runtimeConfig);
255317
}
256318
shuttingDown = false;

0 commit comments

Comments
 (0)