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
78 changes: 78 additions & 0 deletions src/asyncSerializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* AsyncSerializer ensures that async operations are executed serially,
* preventing race conditions when the same operation is triggered multiple times rapidly.
*
* When an operation is already in progress, subsequent calls are coalesced - the operation
* will run again after the current execution completes, but only once regardless of how many
* times execute() was called during that time.
*
* Example usage:
* ```typescript
* const serializer = new AsyncSerializer();
*
* // These rapid calls won't overlap - first runs immediately,
* // subsequent calls are coalesced into one re-execution
* await serializer.execute(async () => {
* await loadConfig();
* refreshUI();
* });
* ```
*/
export class AsyncSerializer {
private _isExecuting = false;
private _pendingFn: (() => Promise<void>) | undefined = undefined;

/**
* Execute an async operation, ensuring it doesn't overlap with previous calls.
*
* If an execution is already in progress, this call stores {@link fn} as the next
* operation to run and returns immediately. When the current execution finishes, the
* most recently stored pending function will be executed. Multiple calls during
* execution are coalesced — only the last-queued function runs for the coalesced
* re-execution.
*
* @param fn The async operation to execute
* @returns A promise whose meaning depends on whether this call starts a new
* execution cycle. If no execution is in progress, the promise resolves
* after the current execution cycle completes, including any coalesced
* re-execution triggered while it is running, and rejects if the final
* execution of that cycle fails. If an execution is already in progress,
* the promise resolves once this call has been acknowledged and {@link fn}
* has been stored as the latest pending operation; in that case, the
* returned promise does not observe the eventual success or failure of the
* running/coalesced execution and does not guarantee a distinct execution
* of {@link fn}.
*/
async execute(fn: () => Promise<void>): Promise<void> {
// If already executing, store latest fn for the coalesced re-run and return immediately
if (this._isExecuting) {
this._pendingFn = fn;
return;
}
Comment thread
jcouball marked this conversation as resolved.

this._isExecuting = true;
let lastError: unknown | undefined;
try {
// Keep executing while new requests come in, always using the latest queued fn
let nextFn: (() => Promise<void>) | undefined = fn;
while (nextFn !== undefined) {
const toRun = nextFn;
this._pendingFn = undefined;
try {
await toRun();
lastError = undefined; // Clear error if a subsequent execution succeeds
} catch (error) {
lastError = error;
}
nextFn = this._pendingFn;
}
} finally {
this._isExecuting = false;
this._pendingFn = undefined;
}

if (lastError !== undefined) {
throw lastError;
}
}
}
31 changes: 22 additions & 9 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PreviewManager } from './previewManager';
import { SnippetHoverProvider } from './snippetHoverProvider';
import { DiagnosticManager } from './diagnosticManager';
import { setLogger } from './mkdocsConfigReader';
import { AsyncSerializer } from './asyncSerializer';

/**
* Activates the mkdocs-snippet-lens extension
Expand Down Expand Up @@ -44,17 +45,29 @@ export async function activate(context: vscode.ExtensionContext) {
false // ignoreDeleteEvents
);

// Create serializer to prevent race conditions when mkdocs.yml changes rapidly
const configReloadSerializer = new AsyncSerializer();

// Reload config and refresh diagnostics when mkdocs config changes
const reloadMkdocsConfig = async () => {
if (workspaceFolders && workspaceFolders.length > 0) {
await diagnosticManager.loadMkdocsConfig(workspaceFolders[0].uri.fsPath);

// Refresh diagnostics for all open markdown files
vscode.window.visibleTextEditors
.filter(editor => editor.document.languageId === 'markdown')
.forEach(editor => {
diagnosticManager.updateDiagnostics(editor.document);
});
try {
await configReloadSerializer.execute(async () => {
const currentFolders = vscode.workspace.workspaceFolders;
if (currentFolders && currentFolders.length > 0) {
await diagnosticManager.loadMkdocsConfig(currentFolders[0].uri.fsPath);

Comment thread
jcouball marked this conversation as resolved.
// Refresh diagnostics for all open markdown files
vscode.window.visibleTextEditors
.filter(editor => editor.document.languageId === 'markdown')
.forEach(editor => {
diagnosticManager.updateDiagnostics(editor.document);
});
}
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
outputChannel.appendLine(`Failed to reload MkDocs configuration: ${errorMessage}`);
void vscode.window.showErrorMessage('Failed to reload MkDocs configuration. See output for details.');
}
};

Expand Down
172 changes: 172 additions & 0 deletions src/test/unit/asyncSerializer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import * as assert from 'assert';
import { AsyncSerializer } from '../../asyncSerializer';

/**
* Unit tests for AsyncSerializer
*
* AsyncSerializer ensures that async operations are executed serially,
* preventing race conditions when the same async operation is triggered
* multiple times rapidly.
*/
describe('AsyncSerializer', () => {
it('should prevent concurrent executions', async () => {
const serializer = new AsyncSerializer();
let inFlight = 0;
let maxInFlight = 0;

// A manually controlled barrier holds the operation in-flight until released,
// making this test deterministic and free of real-time delays.
let release!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });

const operation = async () => {
inFlight++;
if (inFlight > maxInFlight) { maxInFlight = inFlight; }
await barrier;
inFlight--;
};

// Fire three operations; first executes immediately, the rest are coalesced
const promises = [
serializer.execute(operation),
serializer.execute(operation),
serializer.execute(operation),
];

// Release the barrier so all executions can complete
release();
await Promise.all(promises);

assert.strictEqual(maxInFlight, 1, 'At most one execution should be in flight at a time');
});

it('should coalesce multiple pending calls into a single additional execution', async () => {
const serializer = new AsyncSerializer();
let executionCount = 0;

// A manually controlled barrier holds the first execution in-flight while
// the remaining calls are dispatched synchronously, ensuring coalescing occurs.
let release!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });

const countingOperation = async () => {
executionCount++;
await barrier;
};

// Fire five operations rapidly while first is running
const promises = [
serializer.execute(countingOperation),
serializer.execute(countingOperation),
serializer.execute(countingOperation),
serializer.execute(countingOperation),
serializer.execute(countingOperation),
];

release();
await Promise.all(promises);

// With coalescing of synchronously scheduled calls, this should be deterministic:
// - First call runs immediately.
// - Calls 2-5 are all queued while the first is running and are coalesced into
// a single additional execution that services all pending callers.
// Therefore we expect exactly 2 executions.
assert.strictEqual(
executionCount,
2,
`Expected exactly 2 executions with coalescing, got ${executionCount}`
);
});

it('should execute immediately if not already running', async () => {
const serializer = new AsyncSerializer();
let executed = false;

await serializer.execute(async () => {
executed = true;
});

assert.strictEqual(executed, true, 'Should execute immediately when not busy');
});

it('should handle errors without breaking serialization', async () => {
const serializer = new AsyncSerializer();
const executions: number[] = [];

const failingOperation = async () => {
executions.push(1);
throw new Error('Operation failed');
};

const successOperation = async () => {
executions.push(2);
};

// First operation fails
await serializer.execute(failingOperation).catch(() => {});

// Second operation should still run
await serializer.execute(successOperation);

assert.deepStrictEqual(executions, [1, 2], 'Should execute both operations despite error');
});
Comment thread
jcouball marked this conversation as resolved.

it('should execute coalesced pending calls even when an error occurs', async () => {
const serializer = new AsyncSerializer();
let executionCount = 0;

// A manually controlled barrier holds the first execution in-flight so calls
// B and C are coalesced before A finishes (and throws).
let release!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });

const operation = async () => {
const id = ++executionCount;
await barrier;
if (id === 1) {
throw new Error('First execution failed');
}
};

// Start call A (will error partway through but coalesced re-run succeeds),
// then dispatch B and C which get coalesced
const promiseA = serializer.execute(operation);
const promiseB = serializer.execute(operation);
const promiseC = serializer.execute(operation);

release();

// promiseA resolves (not rejects) because the coalesced re-run succeeded,
// clearing the error from the first execution
await promiseA;
await promiseB;
await promiseC;

// The pending coalesced work must still have been executed despite A's error
assert.strictEqual(
executionCount,
2,
`Expected 2 executions (A + coalesced B/C), got ${executionCount}`
);
});

it('should handle rapid sequential calls after completion', async () => {
const serializer = new AsyncSerializer();
const executions: number[] = [];

const operation = async (id: number) => {
executions.push(id);
await Promise.resolve();
};

// First batch
await serializer.execute(() => operation(1));
await serializer.execute(() => operation(2));

// Second batch after first completes
await serializer.execute(() => operation(3));
await serializer.execute(() => operation(4));

assert.deepStrictEqual(executions, [1, 2, 3, 4], 'Should execute all operations in order');
});
});
Loading