Skip to content
Open
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
5 changes: 4 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# This file extends the .gitignore file to include files that should not be formatted by Prettier
# Only list additional files and directories that aren't already in `.gitignore`

unitTests/security/jsLoader/fixtures
unitTests/security/jsLoader/fixtures

# intentionally malformed schema — the graphqlSchema load-error test depends on the parse failure
integrationTests/components/fixtures/graphql-load-error/schemas
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
rest: true
graphqlSchema:
files: schemas/*.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Intentionally malformed: the closing brace is missing, so parsing this schema throws a
# GraphQLError during the graphqlSchema component's initial load. The component must fail fast
# with that error surfaced, not hang until the load watchdog times out.
type Broken @table(database: "graphql_load_error_db") @export {
id: ID @primaryKey
name: String
49 changes: 49 additions & 0 deletions integrationTests/components/graphql-load-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* #1917 — a schema error during a graphqlSchema component's initial load (bad directive, reserved
* database name, malformed schema) used to be swallowed: handleApplication hung until the
* component-load watchdog fired 30s later with a generic "handleApplication timed out" message that
* named none of the actual problem. The fix rejects the load with the real error, so the component
* fails fast and its route serves the actual cause.
*
* The fixture ships a syntactically malformed schema. This test asserts the failed component's route
* returns the underlying GraphQLError, NOT a timeout — which is what distinguishes the fix from the
* pre-fix behavior.
*
* Reproduction:
* npm run test:integration -- "integrationTests/components/graphql-load-error.test.ts"
*/
import { suite, test, before, after } from 'node:test';
import assert from 'node:assert';
import { resolve } from 'node:path';
import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';

const FIXTURE_PATH = resolve(import.meta.dirname, './fixtures/graphql-load-error');

function basicAuth(username: string, password: string): string {
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}

suite('graphqlSchema load error surfaces the real cause, not a timeout (#1917)', (ctx: ContextWithHarper) => {
before(async () => {
await setupHarperWithFixture(ctx, FIXTURE_PATH);
});

after(async () => {
await teardownHarper(ctx);
});

test('the failed component route reports the schema parse error', async () => {
const res = await fetch(new URL('/Broken/', ctx.harper.httpURL), {
headers: {
accept: 'application/json',
authorization: basicAuth(ctx.harper.admin.username, ctx.harper.admin.password),
},
});
const body = await res.text();

assert.strictEqual(res.status, 500, `expected the failed component to serve a 500: ${res.status} ${body}`);
assert.match(body, /Syntax Error/, `expected the real parse error to be surfaced: ${body}`);
// The pre-fix behavior surfaced only the watchdog timeout; guard against a regression to it.
assert.doesNotMatch(body, /timed out/, `component-load timed out instead of failing fast: ${body}`);
});
});
27 changes: 23 additions & 4 deletions resources/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { table } from './databases.ts';
import { getWorkerIndex } from '../server/threads/manageThreads.js';
import { Resources } from './Resources.ts';
import type { NamedTypeNode, StringValueNode, ValueNode } from 'graphql';
import { once } from 'node:events';
import { ClientError } from '../utility/errors/hdbError.ts';
import { attributeToFragment, type JsonSchemaFragment } from './jsonSchemaTypes.ts';
import harperLogger from '../utility/logging/harper_logger.ts';
Expand Down Expand Up @@ -63,6 +62,12 @@ server.knownGraphQLDirectives.push(
*/
export function handleApplication(scope: import('../components/Scope.ts').Scope) {
let initialLoadComplete = false;
let resolveInitialLoad!: () => void;
let rejectInitialLoad!: (error: unknown) => void;
const initialLoadPromise = new Promise<void>((resolve, reject) => {
resolveInitialLoad = resolve;
rejectInitialLoad = reject;
});
const entryHandler = scope.handleEntry(async (entry) => {
if (initialLoadComplete) {
scope.requestRestart();
Expand All @@ -75,11 +80,25 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
return;
}

await processGraphQLSchema((entry as any).contents, entry.urlPath, entry.absolutePath, scope.resources);
try {
await processGraphQLSchema((entry as any).contents, entry.urlPath, entry.absolutePath, scope.resources);
} catch (error) {
// Fail the initial load fast with the real cause (bad directive, reserved database name,
// malformed schema) instead of letting handleApplication hang until the component-load
// watchdog times out 30s later with a message that names none of the actual problem.
// Rejecting the returned promise fails the component through the loader's normal path; we
// don't rethrow, so the entry handler settles cleanly and the load-tracking machinery
// doesn't surface the same error a second time as an unhandled rejection.
if (!initialLoadComplete) {
rejectInitialLoad(error);
return;
}
throw error;
}
});
const initialLoadPromise = once(entryHandler, 'initialLoadComplete');
initialLoadPromise.then(() => {
entryHandler.once('initialLoadComplete', () => {
initialLoadComplete = true;
resolveInitialLoad();
});
return initialLoadPromise;
}
Expand Down
Loading