Skip to content

Commit fe845b2

Browse files
committed
feat(toolkit): watch operation can be stopped
The `toolkit.watch()` operation used to start a filesystem watcher in the background and immediately return. The caller would continue running to its end, while the filesystem watcher would keep the node process alive. Instead, now return an `IWatcher` object that can be disposed to stop the watching, and can be used to wait for the watcher to stop.
1 parent f816a1b commit fe845b2

File tree

5 files changed

+93
-7
lines changed

5 files changed

+93
-7
lines changed

packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ export interface ICloudAssemblySource {
55
* Produce a CloudAssembly from the current source
66
*/
77
produce(): Promise<cxapi.CloudAssembly>;
8-
}
8+
}

packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import type { AssemblyData, StackDetails, ToolkitAction } from '../api/shared-pu
3434
import { DiffFormatter, RequireApproval, ToolkitError, removeNonImportResources } from '../api/shared-public';
3535
import { obscureTemplate, serializeStructure, validateSnsTopicArn, formatTime, formatErrorMessage, deserializeStructure } from '../private/util';
3636
import { pLimit } from '../util/concurrency';
37+
import { promiseWithResolvers } from '../util/promises';
3738

3839
export interface ToolkitOptions {
3940
/**
@@ -736,10 +737,12 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
736737
/**
737738
* Watch Action
738739
*
739-
* Continuously observe project files and deploy the selected stacks automatically when changes are detected.
740-
* Implies hotswap deployments.
740+
* Continuously observe project files and deploy the selected stacks
741+
* automatically when changes are detected. Implies hotswap deployments.
742+
*
743+
* This function returns immediately, starting a watcher in the background.
741744
*/
742-
public async watch(cx: ICloudAssemblySource, options: WatchOptions): Promise<void> {
745+
public async watch(cx: ICloudAssemblySource, options: WatchOptions): Promise<IWatcher> {
743746
const ioHelper = asIoHelper(this.ioHost, 'watch');
744747
const assembly = await assemblyFromSource(ioHelper, cx, false);
745748
const rootDir = options.watchDir ?? process.cwd();
@@ -818,7 +821,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
818821
await cloudWatchLogMonitor?.activate();
819822
};
820823

821-
chokidar
824+
const watcher = chokidar
822825
.watch(watchIncludes, {
823826
ignored: watchExcludes,
824827
cwd: rootDir,
@@ -848,6 +851,26 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
848851
));
849852
}
850853
});
854+
855+
const stoppedPromise = promiseWithResolvers<void>();
856+
857+
return {
858+
async dispose() {
859+
watcher.close();
860+
// Prevents Node from staying alive. There is no 'end' event that the watcher emits
861+
// that we can know it's definitely done, so best we can do is tell it to stop watching,
862+
// stop keeping Node alive, and then pretend that's everything we needed to do.
863+
watcher.unref();
864+
stoppedPromise.resolve();
865+
return stoppedPromise.promise;
866+
},
867+
async waitForEnd() {
868+
return stoppedPromise.promise;
869+
},
870+
async [Symbol.asyncDispose]() {
871+
return this.dispose();
872+
},
873+
} satisfies IWatcher;
851874
}
852875

853876
/**
@@ -1016,3 +1039,25 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
10161039
}
10171040
}
10181041
}
1042+
1043+
/**
1044+
* The result of a `cdk.watch()` operation.
1045+
*/
1046+
export interface IWatcher extends AsyncDisposable {
1047+
/**
1048+
* Stop the watcher and wait for the current watch iteration to complete.
1049+
*
1050+
* An alias for `[Symbol.asyncDispose]`, as a more readable alternative for
1051+
* environments that don't support the Disposable APIs yet.
1052+
*/
1053+
dispose(): Promise<void>;
1054+
1055+
/**
1056+
* Wait for the watcher to stop.
1057+
*
1058+
* The watcher will only stop if `dispose()` or `[Symbol.asyncDispose]()` are called.
1059+
*
1060+
* If neither of those is called, awaiting this promise will wait forever.
1061+
*/
1062+
waitForEnd(): Promise<void>;
1063+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* A backport of Promiser.withResolvers
3+
*
4+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers
5+
*/
6+
export function promiseWithResolvers<A>(): PromiseAndResolvers<A> {
7+
let resolve: PromiseAndResolvers<A>['resolve'], reject: PromiseAndResolvers<A>['reject'];
8+
const promise = new Promise<A>((res, rej) => {
9+
resolve = res;
10+
reject = rej;
11+
});
12+
return { promise, resolve: resolve!, reject: reject! };
13+
}
14+
15+
interface PromiseAndResolvers<A> {
16+
promise: Promise<A>;
17+
resolve: (value: A) => void;
18+
reject: (reason: any) => void;
19+
}

packages/@aws-cdk/toolkit-lib/test/actions/watch.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
// Apparently, they hoist jest.mock commands just below the import statements so we
44
// need to make sure that the constants they access are initialized before the imports.
55
const mockChokidarWatcherOn = jest.fn();
6+
const mockChokidarWatcherClose = jest.fn();
7+
const mockChokidarWatcherUnref = jest.fn();
68
const fakeChokidarWatcher = {
79
on: mockChokidarWatcherOn,
8-
};
10+
close: mockChokidarWatcherClose,
11+
unref: mockChokidarWatcherUnref,
12+
} satisfies Partial<ReturnType<typeof import('chokidar')['watch']>>;
913
const fakeChokidarWatcherOn = {
1014
get readyCallback(): () => Promise<void> {
1115
expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(1);
@@ -149,6 +153,23 @@ describe('watch', () => {
149153
(deploySpy.mock.calls[0]?.[2] as any).cloudWatchLogMonitor?.deactivate();
150154
});
151155

156+
test('watch returns an object that can be used to stop the watch', async () => {
157+
const cx = await builderFixture(toolkit, 'stack-with-role');
158+
159+
const watcher = await toolkit.watch(cx, { include: [] });
160+
161+
expect(mockChokidarWatcherClose).not.toHaveBeenCalled();
162+
expect(mockChokidarWatcherUnref).not.toHaveBeenCalled();
163+
164+
await Promise.all([
165+
watcher.waitForEnd(),
166+
watcher.dispose(),
167+
]);
168+
169+
expect(mockChokidarWatcherClose).toHaveBeenCalled();
170+
expect(mockChokidarWatcherUnref).toHaveBeenCalled();
171+
});
172+
152173
describe.each([
153174
[HotswapMode.FALL_BACK, 'on'],
154175
[HotswapMode.HOTSWAP_ONLY, 'on'],

packages/aws-cdk/lib/cli/cli.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
424424

425425
case 'watch':
426426
ioHost.currentAction = 'watch';
427-
return cli.watch({
427+
await cli.watch({
428428
selector,
429429
exclusively: args.exclusively,
430430
toolkitStackName,
@@ -441,6 +441,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
441441
traceLogs: args.logs,
442442
concurrency: args.concurrency,
443443
});
444+
return;
444445

445446
case 'destroy':
446447
ioHost.currentAction = 'destroy';

0 commit comments

Comments
 (0)