Skip to content

Commit 634f512

Browse files
authored
feat(toolkit): watch operation can be stopped (#308)
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. Closes #299 --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent c14df8e commit 634f512

File tree

4 files changed

+94
-6
lines changed

4 files changed

+94
-6
lines changed

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

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

3940
export interface ToolkitOptions {
4041
/**
@@ -720,10 +721,12 @@ export class Toolkit extends CloudAssemblySourceBuilder {
720721
/**
721722
* Watch Action
722723
*
723-
* Continuously observe project files and deploy the selected stacks automatically when changes are detected.
724-
* Implies hotswap deployments.
724+
* Continuously observe project files and deploy the selected stacks
725+
* automatically when changes are detected. Implies hotswap deployments.
726+
*
727+
* This function returns immediately, starting a watcher in the background.
725728
*/
726-
public async watch(cx: ICloudAssemblySource, options: WatchOptions): Promise<void> {
729+
public async watch(cx: ICloudAssemblySource, options: WatchOptions): Promise<IWatcher> {
727730
const ioHelper = asIoHelper(this.ioHost, 'watch');
728731
const assembly = await assemblyFromSource(ioHelper, cx, false);
729732
const rootDir = options.watchDir ?? process.cwd();
@@ -810,7 +813,7 @@ export class Toolkit extends CloudAssemblySourceBuilder {
810813
await cloudWatchLogMonitor?.activate();
811814
};
812815

813-
chokidar
816+
const watcher = chokidar
814817
.watch(watchIncludes, {
815818
ignored: watchExcludes,
816819
cwd: rootDir,
@@ -840,6 +843,26 @@ export class Toolkit extends CloudAssemblySourceBuilder {
840843
));
841844
}
842845
});
846+
847+
const stoppedPromise = promiseWithResolvers<void>();
848+
849+
return {
850+
async dispose() {
851+
await watcher.close();
852+
// Prevents Node from staying alive. There is no 'end' event that the watcher emits
853+
// that we can know it's definitely done, so best we can do is tell it to stop watching,
854+
// stop keeping Node alive, and then pretend that's everything we needed to do.
855+
watcher.unref();
856+
stoppedPromise.resolve();
857+
return stoppedPromise.promise;
858+
},
859+
async waitForEnd() {
860+
return stoppedPromise.promise;
861+
},
862+
async [Symbol.asyncDispose]() {
863+
return this.dispose();
864+
},
865+
} satisfies IWatcher;
843866
}
844867

845868
/**
@@ -1007,3 +1030,25 @@ export class Toolkit extends CloudAssemblySourceBuilder {
10071030
}
10081031
}
10091032
}
1033+
1034+
/**
1035+
* The result of a `cdk.watch()` operation.
1036+
*/
1037+
export interface IWatcher extends AsyncDisposable {
1038+
/**
1039+
* Stop the watcher and wait for the current watch iteration to complete.
1040+
*
1041+
* An alias for `[Symbol.asyncDispose]`, as a more readable alternative for
1042+
* environments that don't support the Disposable APIs yet.
1043+
*/
1044+
dispose(): Promise<void>;
1045+
1046+
/**
1047+
* Wait for the watcher to stop.
1048+
*
1049+
* The watcher will only stop if `dispose()` or `[Symbol.asyncDispose]()` are called.
1050+
*
1051+
* If neither of those is called, awaiting this promise will wait forever.
1052+
*/
1053+
waitForEnd(): Promise<void>;
1054+
}
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: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
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+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
13+
} satisfies Partial<ReturnType<typeof import('chokidar')['watch']>>;
914
const fakeChokidarWatcherOn = {
1015
get readyCallback(): () => Promise<void> {
1116
expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(1);
@@ -173,6 +178,24 @@ describe('watch', () => {
173178
(deploySpy.mock.calls[0]?.[2] as any).cloudWatchLogMonitor?.deactivate();
174179
});
175180

181+
test('watch returns an object that can be used to stop the watch', async () => {
182+
const cx = await builderFixture(toolkit, 'stack-with-role');
183+
184+
const watcher = await toolkit.watch(cx, { include: [] });
185+
186+
expect(mockChokidarWatcherClose).not.toHaveBeenCalled();
187+
expect(mockChokidarWatcherUnref).not.toHaveBeenCalled();
188+
189+
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
190+
await Promise.all([
191+
watcher.waitForEnd(),
192+
watcher.dispose(),
193+
]);
194+
195+
expect(mockChokidarWatcherClose).toHaveBeenCalled();
196+
expect(mockChokidarWatcherUnref).toHaveBeenCalled();
197+
});
198+
176199
describe.each([
177200
[HotswapMode.FALL_BACK, 'on'],
178201
[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
@@ -426,7 +426,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
426426

427427
case 'watch':
428428
ioHost.currentAction = 'watch';
429-
return cli.watch({
429+
await cli.watch({
430430
selector,
431431
exclusively: args.exclusively,
432432
toolkitStackName,
@@ -443,6 +443,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
443443
traceLogs: args.logs,
444444
concurrency: args.concurrency,
445445
});
446+
return;
446447

447448
case 'destroy':
448449
ioHost.currentAction = 'destroy';

0 commit comments

Comments
 (0)