diff --git a/web/packages/teleterm/src/services/grpcCredentials/files.test.ts b/web/packages/teleterm/src/services/grpcCredentials/files.test.ts
new file mode 100644
index 0000000000000..385dfbd33f780
--- /dev/null
+++ b/web/packages/teleterm/src/services/grpcCredentials/files.test.ts
@@ -0,0 +1,84 @@
+/**
+ * @jest-environment node
+ */
+/**
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import os from 'node:os';
+import timers from 'node:timers/promises';
+
+import { readGrpcCert } from './files';
+
+let tempDir: string;
+
+beforeAll(async () => {
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grpc-files-test'));
+});
+
+afterAll(async () => {
+ await fs.rm(tempDir, { recursive: true, force: true });
+});
+
+beforeEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe('readGrpcCert', () => {
+ it('reads the file if the file already exists', async () => {
+ await fs.writeFile(path.join(tempDir, 'already-exists'), 'foobar');
+
+ await expect(
+ readGrpcCert(tempDir, 'already-exists').then(buffer => buffer.toString())
+ ).resolves.toEqual('foobar');
+ });
+
+ it('reads the file when the file is created after starting a watcher', async () => {
+ const readGrpcCertPromise = readGrpcCert(
+ tempDir,
+ 'created-after-start'
+ ).then(buffer => buffer.toString());
+ await timers.setTimeout(10);
+
+ await fs.writeFile(path.join(tempDir, 'created-after-start'), 'foobar');
+
+ await expect(readGrpcCertPromise).resolves.toEqual('foobar');
+ });
+
+ it('returns an error if the file is not created within the timeout', async () => {
+ await expect(
+ readGrpcCert(tempDir, 'non-existent', { timeoutMs: 1 })
+ ).rejects.toMatchObject({
+ message: expect.stringContaining('within the timeout'),
+ });
+ });
+
+ it('returns an error if stat fails', async () => {
+ const expectedError = new Error('Something went wrong');
+ jest.spyOn(fs, 'stat').mockRejectedValue(expectedError);
+
+ await expect(
+ readGrpcCert(
+ tempDir,
+ 'non-existent',
+ { timeoutMs: 100 } // Make sure that the test doesn't hang for 10s on failure.
+ )
+ ).rejects.toEqual(expectedError);
+ });
+});
diff --git a/web/packages/teleterm/src/services/grpcCredentials/files.ts b/web/packages/teleterm/src/services/grpcCredentials/files.ts
index e639bf6d8382c..c8eaac1cffec4 100644
--- a/web/packages/teleterm/src/services/grpcCredentials/files.ts
+++ b/web/packages/teleterm/src/services/grpcCredentials/files.ts
@@ -17,9 +17,11 @@
*/
import path from 'path';
-import { watch } from 'fs';
+import { type Stats, watch } from 'fs';
import { readFile, writeFile, stat, rename } from 'fs/promises';
+import { wait } from 'shared/utils/wait';
+
import { makeCert } from './makeCert';
/**
@@ -51,49 +53,64 @@ export async function generateAndSaveGrpcCert(
/**
* Reads a cert with given `certName` in the `certDir`.
- * If the file doesn't exist, it will wait up to 10 seconds for it.
+ * If the file doesn't exist, by default it will wait up to 10 seconds for it.
*/
export async function readGrpcCert(
certsDir: string,
- certName: string
+ certName: string,
+ { timeoutMs = 10_000 } = {}
): Promise {
const fullPath = path.join(certsDir, certName);
const abortController = new AbortController();
async function fileExistsAndHasSize(): Promise {
- return !!(await stat(fullPath)).size;
+ let stats: Stats;
+ try {
+ stats = await stat(fullPath);
+ } catch (error) {
+ if (error?.code === 'ENOENT') {
+ return false;
+ }
+ throw error;
+ }
+
+ return !!stats.size;
}
- function watchForFile(): Promise {
+ function waitForFile(): Promise {
return new Promise((resolve, reject) => {
- abortController.signal.onabort = () => {
- watcher.close();
- clearTimeout(timeout);
- };
-
- const timeout = setTimeout(() => {
- reject(
- `Could not read ${certName} certificate. The operation timed out.`
- );
- }, 10_000);
+ wait(timeoutMs, abortController.signal).then(
+ () =>
+ reject(
+ new Error(
+ `Could not read ${certName} certificate within the timeout.`
+ )
+ ),
+ () => {} // Ignore abort errors.
+ );
- const watcher = watch(certsDir, async (event, filename) => {
- if (certName === filename && (await fileExistsAndHasSize())) {
- resolve(readFile(fullPath));
+ // Watching must be started before checking if the file already exists to avoid race
+ // conditions. If we checked if the file exists and then started the watcher, the file could
+ // in theory be created between those two actions.
+ watch(
+ certsDir,
+ { signal: abortController.signal },
+ async (_, filename) => {
+ if (certName === filename && (await fileExistsAndHasSize())) {
+ resolve(readFile(fullPath));
+ }
}
- });
- });
- }
+ );
- async function checkIfFileAlreadyExists(): Promise {
- if (await fileExistsAndHasSize()) {
- return readFile(fullPath);
- }
+ fileExistsAndHasSize().then(
+ exists => exists && resolve(readFile(fullPath)),
+ err => reject(err)
+ );
+ });
}
try {
- // watching must be started before checking if the file already exists to avoid race conditions
- return await Promise.any([watchForFile(), checkIfFileAlreadyExists()]);
+ return await waitForFile();
} finally {
abortController.abort();
}