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
1 change: 1 addition & 0 deletions src/AppContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ export class AppContext {
workerIndexingLocked = false;
isUsingWorkerIndexing = false;
currentEntryPoint?: string;
blockingAppStartError?: Error;
}
6 changes: 5 additions & 1 deletion src/eventHandlers/FunctionLoadHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language
import { loadLegacyFunction } from '../LegacyFunctionLoader';
import { worker } from '../WorkerContext';
import { ensureErrorType } from '../errors';
import { nonNullProp } from '../utils/nonNull';
import { isDefined, nonNullProp } from '../utils/nonNull';
import { EventHandler } from './EventHandler';
import LogCategory = rpc.RpcLog.RpcLogCategory;
import LogLevel = rpc.RpcLog.Level;
Expand All @@ -31,6 +31,10 @@ export class FunctionLoadHandler extends EventHandler<'functionLoadRequest', 'fu
logCategory: LogCategory.System,
});

if (isDefined(worker.app.blockingAppStartError)) {
throw worker.app.blockingAppStartError;
}

if (!worker.app.isUsingWorkerIndexing) {
const functionId = nonNullProp(msg, 'functionId');
const metadata = nonNullProp(msg, 'metadata');
Expand Down
13 changes: 8 additions & 5 deletions src/eventHandlers/FunctionsMetadataHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc';
import { worker } from '../WorkerContext';
import { isDefined } from '../utils/nonNull';
import { EventHandler } from './EventHandler';
import LogCategory = rpc.RpcLog.RpcLogCategory;
import LogLevel = rpc.RpcLog.Level;
Expand All @@ -12,7 +13,7 @@ export class FunctionsMetadataHandler extends EventHandler<'functionsMetadataReq

getDefaultResponse(_msg: rpc.IFunctionsMetadataRequest): rpc.IFunctionMetadataResponse {
return {
useDefaultMetadataIndexing: true,
useDefaultMetadataIndexing: !worker.app.isUsingWorkerIndexing,
};
}

Expand All @@ -27,10 +28,12 @@ export class FunctionsMetadataHandler extends EventHandler<'functionsMetadataReq
logCategory: LogCategory.System,
});

const functions = Object.values(worker.app.functions);
if (functions.length > 0) {
response.useDefaultMetadataIndexing = false;
response.functionMetadataResults = functions.map((f) => f.metadata);
if (worker.app.isUsingWorkerIndexing) {
if (isDefined(worker.app.blockingAppStartError)) {
throw worker.app.blockingAppStartError;
}

response.functionMetadataResults = Object.values(worker.app.functions).map((f) => f.metadata);
}

return response;
Expand Down
59 changes: 51 additions & 8 deletions src/startApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { AzFuncSystemError, ensureErrorType, ReadOnlyError } from './errors';
import { executeHooks } from './hooks/executeHooks';
import { loadScriptFile } from './loadScriptFile';
import { parsePackageJson } from './parsers/parsePackageJson';
import { isDefined, nonNullProp } from './utils/nonNull';
import { isEnvironmentVariableSet, isNode20Plus } from './utils/util';
import { worker } from './WorkerContext';
import globby = require('globby');
import path = require('path');
Expand Down Expand Up @@ -63,7 +65,16 @@ async function loadEntryPointFile(functionAppDirectory: string): Promise<void> {
try {
const files = await globby(entryPointPattern, { cwd: functionAppDirectory });
if (files.length === 0) {
throw new AzFuncSystemError(`Found zero files matching the supplied pattern`);
let message: string = globby.hasMagic(entryPointPattern, { cwd: functionAppDirectory })
? 'Found zero files matching the supplied pattern'
: 'File does not exist';

if (entryPointPattern === 'index.js') {
// This is by far the most common error and typically happens by accident, so we'll give these folks a little more help
message += '. Learn more here: https://aka.ms/AAla7et';
}

throw new AzFuncSystemError(message);
}

for (const file of files) {
Expand All @@ -88,13 +99,45 @@ async function loadEntryPointFile(functionAppDirectory: string): Promise<void> {
}
} catch (err) {
const error = ensureErrorType(err);
worker.log({
message: `Worker was unable to load entry point "${currentFile ? currentFile : entryPointPattern}": ${
error.message
}`,
level: LogLevel.Warning,
logCategory: LogCategory.System,
});
const newMessage = `Worker was unable to load entry point "${currentFile || entryPointPattern}": ${
error.message
}`;

if (shouldBlockOnEntryPointError()) {
error.message = newMessage;
error.isAzureFunctionsSystemError = true;
// We don't want to throw this error now (during workerInit or funcEnvReload) because technically the worker is fine
// Instead, it will be thrown during functionMetadata or functionLoad response which better indicates that the user's app is the problem
worker.app.blockingAppStartError = error;
// This will ensure the error makes it to the user's app insights
console.error(error.stack);
} else {
// In this case, the error will never block the app
// The most we can do without breaking backwards compatibility is log it as a system log
worker.log({
message: newMessage,
level: LogLevel.Error,
logCategory: LogCategory.System,
});
}
}
}
}

function shouldBlockOnEntryPointError(): boolean {
if (isNode20Plus()) {
// Starting with Node 20, this will always be blocking
// https://github.com/Azure/azure-functions-nodejs-worker/issues/697
return true;
} else {
const key = 'FUNCTIONS_NODE_BLOCK_ON_ENTRY_POINT_ERROR';
if (isDefined(process.env[key])) {
return isEnvironmentVariableSet(process.env[key]);
} else {
// We think this should be a blocking error by default, but v3 can't do that for backwards compatibility reasons
// https://github.com/Azure/azure-functions-nodejs-worker/issues/630
const model = nonNullProp(worker.app, 'programmingModel');
return !(model.name === '@azure/functions' && model.version.startsWith('3.'));
}
}
}
4 changes: 4 additions & 0 deletions src/utils/nonNull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ export function nonNullValue<T>(value: T | undefined, propertyNameOrMessage?: st

return value;
}

export function isDefined<T>(data: T | undefined | null): data is T {
return data !== null && data !== undefined;
}
6 changes: 6 additions & 0 deletions src/utils/util.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import * as semver from 'semver';

export function isEnvironmentVariableSet(val: string | boolean | number | undefined | null): boolean {
return !/^(false|0)?$/i.test(val === undefined || val === null ? '' : String(val));
}

export function isNode20Plus(): boolean {
return semver.gte(process.versions.node, '20.0.0');
}
32 changes: 15 additions & 17 deletions test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ describe('FunctionEnvironmentReloadHandler', () => {
functionAppDirectory: 'pathWithoutPackageJson',
},
});
await stream.assertCalledWith(msg.indexing.receivedRequestLog, msg.indexing.response());
await stream.assertCalledWith(msg.indexing.receivedRequestLog, msg.indexing.response([], true));
}

stream.addTestMessage({
Expand All @@ -261,24 +261,22 @@ describe('FunctionEnvironmentReloadHandler', () => {
msg.envReload.response
);

stream.addTestMessage({
requestId: 'testReqId',
functionsMetadataRequest: {
functionAppDirectory: testAppPath,
},
});
stream.addTestMessage(msg.indexing.request);
await stream.assertCalledWith(
msg.indexing.receivedRequestLog,
msg.indexing.response([
{
bindings: {},
directory: testAppSrcPath,
functionId: 'testFunc',
name: 'testFunc',
rawBindings: [],
scriptFile: fileName,
},
])
msg.indexing.response(
[
{
bindings: {},
directory: testAppSrcPath,
functionId: 'testFunc',
name: 'testFunc',
rawBindings: [],
scriptFile: fileName,
},
],
false
)
);
});
}
Expand Down
26 changes: 2 additions & 24 deletions test/eventHandlers/WorkerInitHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import * as coreTypes from '@azure/functions-core';
import { expect } from 'chai';
import * as fs from 'fs/promises';
import 'mocha';
import { ITestCallbackContext } from 'mocha';
import * as semver from 'semver';
import { worker } from '../../src/WorkerContext';
import { logColdStartWarning } from '../../src/eventHandlers/WorkerInitHandler';
import { isNode20Plus } from '../../src/utils/util';
import { TestEventStream } from './TestEventStream';
import { beforeEventHandlerSuite } from './beforeEventHandlerSuite';
import { msg } from './msg';
Expand Down Expand Up @@ -71,7 +70,7 @@ describe('WorkerInitHandler', () => {
it('ignores malformed package.json', async () => {
await fs.writeFile(testPackageJsonPath, 'gArB@g3 dAtA');

const jsonError = semver.gte(process.versions.node, '19.0.0')
const jsonError = isNode20Plus()
? 'Unexpected token \'g\', "gArB@g3 dAtA" is not valid JSON'
: 'Unexpected token g in JSON at position 0';

Expand Down Expand Up @@ -117,27 +116,6 @@ describe('WorkerInitHandler', () => {
);
});

it('Fails for missing entry point', async function (this: ITestCallbackContext) {
const fileSubpath = await setTestAppMainField('missing.js');

stream.addTestMessage(msg.init.request(testAppPath));
const warningMessage = `Worker was unable to load entry point "${fileSubpath}": Found zero files matching the supplied pattern`;
await stream.assertCalledWith(msg.init.receivedRequestLog, msg.warningLog(warningMessage), msg.init.response);
});

it('Fails for invalid entry point', async function (this: ITestCallbackContext) {
const fileSubpath = await setTestAppMainField('throwError.js');

stream.addTestMessage(msg.init.request(testAppPath));
const warningMessage = `Worker was unable to load entry point "${fileSubpath}": test`;
await stream.assertCalledWith(
msg.init.receivedRequestLog,
msg.loadingEntryPoint(fileSubpath),
msg.warningLog(warningMessage),
msg.init.response
);
});

for (const rfpValue of ['1', 'https://url']) {
it(`Skips warn for long load time if rfp already set to ${rfpValue}`, async () => {
const fileSubpath = await setTestAppMainField('longLoad.js');
Expand Down
48 changes: 41 additions & 7 deletions test/eventHandlers/msg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import 'mocha';
import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc';
import { RegExpProps, RegExpStreamingMessage } from './TestEventStream';
import { testAppSrcPath } from './testAppUtils';
import { testAppPath, testAppSrcPath } from './testAppUtils';
import LogCategory = rpc.RpcLog.RpcLogCategory;
import LogLevel = rpc.RpcLog.Level;
import escapeStringRegexp = require('escape-string-regexp');
Expand All @@ -22,8 +22,8 @@ function workerMetadataRegExps(responseName: string) {
return {
[`${responseName}.workerMetadata.runtimeVersion`]: /^[0-9]+\.[0-9]+\.[0-9]+$/,
[`${responseName}.workerMetadata.workerBitness`]: /^(x64|x86|arm64)$/,
[`${responseName}.workerMetadata.workerVersion`]: /^3\.[0-9]+\.[0-9]+$/,
[`${responseName}.workerMetadata.customProperties.modelVersion`]: /^3\.[0-9]+\.[0-9]+$/,
[`${responseName}.workerMetadata.workerVersion`]: /^(3|4)\.[0-9]+\.[0-9]+$/,
[`${responseName}.workerMetadata.customProperties.modelVersion`]: /^(3|4)\.[0-9]+\.[0-9]+$/,
};
}

Expand Down Expand Up @@ -157,7 +157,7 @@ export namespace msg {
workerMetadataRegExps('workerInitResponse')
);

export function failedResponse(fileName: string, errorMessage: string): RegExpStreamingMessage {
export function failedResponse(errorMessage: string): RegExpStreamingMessage {
const expectedMsg: rpc.IStreamingMessage = {
requestId: 'testReqId',
workerInitResponse: {
Expand All @@ -169,6 +169,9 @@ export namespace msg {
},
workerMetadata: {
runtimeName: 'node',
customProperties: {
modelName: '@azure/functions',
},
},
},
};
Expand Down Expand Up @@ -208,23 +211,54 @@ export namespace msg {
}

export namespace indexing {
export const request = {
requestId: 'testReqId',
functionsMetadataRequest: {
functionAppDirectory: testAppPath,
},
};

export const receivedRequestLog = msg.receivedRequestLog('FunctionsMetadataRequest');

export function response(functions: rpc.IRpcFunctionMetadata[] = []): TestMessage {
export function response(
functions: rpc.IRpcFunctionMetadata[],
useDefaultMetadataIndexing: boolean
): TestMessage {
const response: rpc.IStreamingMessage = {
requestId: 'testReqId',
functionMetadataResponse: {
useDefaultMetadataIndexing: functions.length === 0,
useDefaultMetadataIndexing: useDefaultMetadataIndexing,
result: {
status: rpc.StatusResult.Status.Success,
},
},
};
if (functions.length > 0) {
if (!useDefaultMetadataIndexing) {
response.functionMetadataResponse!.functionMetadataResults = functions;
}
return response;
}

export function failedResponse(
errorMessage: string,
useDefaultMetadataIndexing: boolean
): RegExpStreamingMessage {
const expectedMsg: rpc.IStreamingMessage = {
requestId: 'testReqId',
functionMetadataResponse: {
useDefaultMetadataIndexing: useDefaultMetadataIndexing,
result: {
status: rpc.StatusResult.Status.Failure,
exception: {
message: errorMessage,
},
},
},
};
return new RegExpStreamingMessage(expectedMsg, {
...stackTraceRegExpProps('functionMetadataResponse', errorMessage),
});
}
}

export namespace funcLoad {
Expand Down
Loading