fix: stop leaking an unawaited permission traversal on every check

`validateUserPerms` started the linked SQL traversal beside the flat read
and returned on a flat hit without awaiting it. The hit is the common
case, so nearly every user-permission check left a floating promise: with
`keep_alive_on_uncaught` unset no `unhandledRejection` listener is
registered, so a DB wobble inside the traversal exits the process.

Run it only when the flat read misses. That also stops a SQL walk the
cache had already answered.
This commit is contained in:
Juan Castro
2026-09-21 16:07:26 -04:00
parent 4de1d1fb39
commit 07e889f135
2 changed files with 33 additions and 6 deletions
@@ -1712,6 +1712,32 @@ describe('PermissionService — scan paths', () => {
expect(await permService.check(actor, permission)).toBe(false);
});
// The traversal used to run beside the flat read with nobody awaiting it.
it('answers a flat hit without running the linked traversal', async () => {
const { row, actor } = await makeGroupedUser();
const permission = `zztest:flat-${uuidv4()}:ii:read`;
await server.stores.permission.setFlatUserPerm(row.id, permission, {
permission,
deleted: false,
issuer_user_id: row.id,
} as never);
const linked = vi
.spyOn(server.stores.permission, 'readLinkedUserUserPerms')
.mockRejectedValue(new Error('db wobble'));
try {
const reading = await permService.validateUserPerms({
actor,
permissions: [permission],
});
expect(reading).toHaveLength(1);
expect(reading[0]).toMatchObject({ permission });
expect(linked).not.toHaveBeenCalled();
} finally {
linked.mockRestore();
}
});
it('returns nothing for an actor with no user id', async () => {
expect(
await permService.validateUserPerms({
@@ -705,19 +705,20 @@ export class PermissionService extends PuterService {
}): Promise<ReadingNode[]> {
if (!actor.user?.id) return [];
const flatPromise = this.#flatValidateUserPerms(actor, permissions);
const linkedPromise = this.#linkedValidateUserPerms(
const flatReading = await this.#flatValidateUserPerms(
actor,
permissions,
state ?? { antiCycleActors: [actor] },
);
const flatReading = await flatPromise;
if (flatReading.length > 0) {
return flatReading[0].deleted ? [] : flatReading;
}
const linkedReading = await linkedPromise;
// Only on a miss: started beside the flat read, nothing awaits its rejection.
const linkedReading = await this.#linkedValidateUserPerms(
actor,
permissions,
state ?? { antiCycleActors: [actor] },
);
const flatOptions = PermissionUtil.readingToOptions(linkedReading);
// Warm flat KV cache for future hits (fire-and-forget, don't block