mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-07 22:18:33 +00:00
* feat(collections): implement sliding window for script request UIDs to manage stale updates - Introduced a bounded sliding window mechanism for tracking recent script request UIDs, allowing for better handling of stale updates from superseded or cancelled requests. - Updated relevant functions to utilize the new `isStaleScriptRequest` utility for improved clarity and maintainability. - Enhanced the persistence logic to ensure that updates from retired requests do not interfere with newer requests, improving data integrity during concurrent operations. - Added comprehensive tests to validate the behavior of the new UID management system and its impact on variable propagation across requests. * refactor(tests): remove outdated test for persistentEnvVariables in runtime tests - Eliminated the test case that checked for the inclusion of persistentEnvVariables in the result, as it is no longer relevant. - Cleaned up the test suite to enhance clarity and maintainability, focusing on relevant assertions for variable management. * refactor(collections): simplify environment persistence logic and remove stale request handling - Updated the `persistActiveEnvironment` and related functions to eliminate the requestUid parameter, streamlining the logic for environment persistence. - Removed outdated checks for stale updates from superseded requests, enhancing clarity and maintainability. - Deleted the `_scriptRequestUids` management code and associated tests, as they are no longer necessary for the current implementation. - Improved the handling of environment updates to ensure accurate state management without stale data interference. * refactor(filesystem): remove unused hasJsExtension function - Deleted the `hasJsExtension` utility function from the filesystem module as it was no longer needed. - Cleaned up the exports to enhance clarity and maintainability of the codebase. * test(network): add unit tests for applyCollectionVarsToCollectionRoot * refactor(ipc, environments): implement file locking for environment updates - Updated the environment persistence logic in both `collection.js` and `workspace-environments.js` to utilize file locking during read and write operations. - This change ensures that concurrent access to environment files is managed safely, preventing potential data corruption and enhancing reliability during updates.
183 lines
6.1 KiB
JavaScript
183 lines
6.1 KiB
JavaScript
const { withFileLock } = require('../../src/utils/filesystem');
|
|
|
|
// Manual-gate helper: returns a Promise + a `resolve` function. Tests use this
|
|
// to deterministically interleave two async operations through withFileLock —
|
|
// without it, racing setTimeouts make the test flaky.
|
|
const deferred = () => {
|
|
let resolve, reject;
|
|
const promise = new Promise((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
};
|
|
|
|
// Drain the entire microtask queue. withFileLock chains `.catch().then(() => fn())`,
|
|
// so fn() starts two microtask hops after the synchronous call — a single
|
|
// `await Promise.resolve()` isn't enough. setImmediate runs after all queued
|
|
// microtasks resolve, giving us a clean barrier.
|
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
|
|
describe('withFileLock', () => {
|
|
test('serializes two concurrent ops on the same path in queued order', async () => {
|
|
const events = [];
|
|
const gateA = deferred();
|
|
const gateB = deferred();
|
|
|
|
const opA = withFileLock('/p/one', async () => {
|
|
events.push('A:start');
|
|
await gateA.promise;
|
|
events.push('A:end');
|
|
return 'A';
|
|
});
|
|
|
|
// Start B while A is still inside its critical section. B must NOT start
|
|
// until A resolves — that's the whole point of the lock.
|
|
const opB = withFileLock('/p/one', async () => {
|
|
events.push('B:start');
|
|
await gateB.promise;
|
|
events.push('B:end');
|
|
return 'B';
|
|
});
|
|
|
|
// Let microtasks settle. A should have started but B must still be queued.
|
|
await flush();
|
|
expect(events).toEqual(['A:start']);
|
|
|
|
// Open A's gate first; B's start should follow.
|
|
gateA.resolve();
|
|
await flush();
|
|
expect(events).toEqual(['A:start', 'A:end', 'B:start']);
|
|
|
|
gateB.resolve();
|
|
|
|
const [a, b] = await Promise.all([opA, opB]);
|
|
expect(a).toBe('A');
|
|
expect(b).toBe('B');
|
|
expect(events).toEqual(['A:start', 'A:end', 'B:start', 'B:end']);
|
|
});
|
|
|
|
test('different paths run concurrently (lock is per-path)', async () => {
|
|
const events = [];
|
|
const gateA = deferred();
|
|
const gateB = deferred();
|
|
|
|
const opA = withFileLock('/p/one', async () => {
|
|
events.push('A:start');
|
|
await gateA.promise;
|
|
events.push('A:end');
|
|
});
|
|
|
|
const opB = withFileLock('/p/two', async () => {
|
|
events.push('B:start');
|
|
await gateB.promise;
|
|
events.push('B:end');
|
|
});
|
|
|
|
// Both should be inside their critical sections concurrently.
|
|
await flush();
|
|
expect(events.sort()).toEqual(['A:start', 'B:start']);
|
|
|
|
// Resolve in reverse order to demonstrate true independence — B finishes
|
|
// before A despite starting in a separate concurrent path.
|
|
gateB.resolve();
|
|
await opB;
|
|
expect(events).toContain('B:end');
|
|
expect(events).not.toContain('A:end');
|
|
|
|
gateA.resolve();
|
|
await opA;
|
|
expect(events).toContain('A:end');
|
|
});
|
|
|
|
test('an error in op A does NOT block op B on the same path', async () => {
|
|
// Pre-fix: if `prior` rejected, `prior.then(...)` would also reject, leaking
|
|
// an unhandled rejection AND propagating B's chain to a rejected state before
|
|
// its own fn ran. The `.catch(() => {})` on `prior` is what makes B independent.
|
|
const opA = withFileLock('/p/lock-err', async () => {
|
|
throw new Error('boom');
|
|
});
|
|
|
|
const opB = withFileLock('/p/lock-err', async () => {
|
|
return 'B-completed';
|
|
});
|
|
|
|
await expect(opA).rejects.toThrow('boom');
|
|
await expect(opB).resolves.toBe('B-completed');
|
|
});
|
|
|
|
test('subsequent ops on a path after the queue drains start a fresh chain', async () => {
|
|
// Regression guard: the cleanup `if (_pathLocks.get(pathname) === next) delete`
|
|
// must remove the entry when the queue drains, so a later op on the same path
|
|
// doesn't chain off a stale (already-settled) promise. If it did, the new op
|
|
// would still run — but the lock would leak one Map entry per saved file.
|
|
const order = [];
|
|
|
|
await withFileLock('/p/drained', async () => {
|
|
order.push('first');
|
|
});
|
|
await withFileLock('/p/drained', async () => {
|
|
order.push('second');
|
|
});
|
|
|
|
expect(order).toEqual(['first', 'second']);
|
|
});
|
|
|
|
test('three concurrent ops on the same path execute strictly in queued order', async () => {
|
|
// Smoke test for the rapid-fire scripted-write scenario: a folder run
|
|
// emitting bru.setEnvVar(..., persist:true) on each of 3 back-to-back requests.
|
|
const events = [];
|
|
const gates = [deferred(), deferred(), deferred()];
|
|
|
|
const ops = gates.map((g, i) =>
|
|
withFileLock('/p/triple', async () => {
|
|
events.push(`op-${i}:start`);
|
|
await g.promise;
|
|
events.push(`op-${i}:end`);
|
|
})
|
|
);
|
|
|
|
await flush();
|
|
expect(events).toEqual(['op-0:start']);
|
|
|
|
gates[0].resolve();
|
|
await flush();
|
|
expect(events).toEqual(['op-0:start', 'op-0:end', 'op-1:start']);
|
|
|
|
gates[1].resolve();
|
|
await flush();
|
|
expect(events).toEqual(['op-0:start', 'op-0:end', 'op-1:start', 'op-1:end', 'op-2:start']);
|
|
|
|
gates[2].resolve();
|
|
await Promise.all(ops);
|
|
expect(events).toEqual([
|
|
'op-0:start', 'op-0:end',
|
|
'op-1:start', 'op-1:end',
|
|
'op-2:start', 'op-2:end'
|
|
]);
|
|
});
|
|
|
|
test('the inner fn sees post-prior-write state (read-modify-write safety)', async () => {
|
|
// The reason the lock exists. Simulate the env-save read-then-write pattern:
|
|
// two writers both read the "file," compute new content, then write. Without
|
|
// the lock the second writer's read would capture pre-first-write state and
|
|
// overwrite the first writer's update.
|
|
let onDisk = '0';
|
|
|
|
const write = (newContent) =>
|
|
withFileLock('/p/rmw', async () => {
|
|
const existing = onDisk; // "read"
|
|
const merged = `${existing}+${newContent}`;
|
|
// Simulate a small async cost between read and write.
|
|
await new Promise((r) => setImmediate(r));
|
|
onDisk = merged; // "write"
|
|
});
|
|
|
|
// Fire both concurrently; without the lock the second read would see '0'
|
|
// and overwrite '0+A' with '0+B'. With the lock the second read sees '0+A'.
|
|
await Promise.all([write('A'), write('B')]);
|
|
|
|
expect(onDisk).toBe('0+A+B');
|
|
});
|
|
});
|