Skip to content

Commit 445cfcc

Browse files
BernardJenclaude
andauthored
fix(pptx): serialize automation commands, and enforce require-atomic-updates (#70)
Every PowerPoint backend caches the slideshow position in module-level state and refreshes it from PowerPoint across an `await` - an AppleScript round trip on macOS, a PowerShell bridge command or a COM call on Windows. Nothing stopped two of those from overlapping: the web remote can fire `next` from several devices at once, a double-tap queues two `nextSlide()` calls, and the 500 ms poll in server/socket.ts calls `getSlideInfo()` on top of both. `require-atomic-updates` flagged seven sites where that state is read before an await and written after it, and #64 had to land the rule as 'warn' to keep CI green. This fixes the sites and turns it up to 'error'. serializeAutomation() chains every call onto one promise, so a backend has at most one command in flight - which is also what the PowerShell bridge's FIFO response matching already assumed. Each backend exports its object already wrapped, so main/index.ts and server/socket.ts share one queue rather than getting a mutex each. Methods are listed explicitly instead of proxied, so adding an unserialized method to the interface is a type error. The mutex alone does not satisfy the lint rule, which is purely syntactic, so each flagged site also re-reads its state after the await. That is deliberate belt and braces: it keeps the invariant checkable instead of resting on the wrapper still being there. nextSlide now commits slide and animation step in one synchronous block (same result as before - the old code incremented the step then zeroed it if the slide had moved), and localPresentationCopy is cleared before the unlink is awaited rather than after. The 500 ms poll needed a guard as a direct consequence: overlapping polls used to just run concurrently, but under serialization a poll that outlives its tick queues behind the next poll and every remote command, so a slow backend would grow an unbounded backlog. It now skips a tick while one is outstanding. Verified against a real PowerPoint 16.112.3 on macOS with a generated six-slide deck (slide 3 hidden): openPresentation, gotoSlide, getSlideInfo, closePresentation, concurrent and burst navigation, and the temp-copy cleanup. With the serializer stubbed out, two concurrent gotoSlide calls interleave and one is silently dropped - PowerPoint stays on slide 5 after a request for slide 2. With it in place the commands apply in order. Not verified against a real install: startSlideshow, nextSlide, prevSlide and stopSlideshow on macOS go through System Events keystrokes, and Accessibility permission was not granted to the test process. Neither Windows backend was run at all; their changes are the same three patterns applied identically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 615587e commit 445cfcc

8 files changed

Lines changed: 371 additions & 60 deletions

File tree

ARCHITECTURE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ slidecue/
5858
│ │ ├── index.ts # Entry point, IPC handlers
5959
│ │ ├── pptx/
6060
│ │ │ ├── slideParser.ts # PPTX file parser (JSZip)
61+
│ │ │ ├── serialize.ts # One-command-at-a-time wrapper for a backend
6162
│ │ │ ├── macos.ts # AppleScript automation
6263
│ │ │ └── windows.ts # COM automation (TODO)
6364
│ │ └── server/
@@ -134,6 +135,17 @@ Extracts metadata from PowerPoint files using JSZip:
134135
- Speaker notes per slide (via relationship files)
135136
- Slide dimensions
136137

138+
#### `pptx/serialize.ts` — Command Serialization
139+
140+
Every backend caches the slideshow position in module-level state and refreshes
141+
it from PowerPoint across an `await`. `serializeAutomation()` chains all calls
142+
onto a single promise so only one is ever in flight, which is what keeps that
143+
cache in step with PowerPoint when several remotes, a double-tap, and the 500 ms
144+
`getSlideInfo()` poll all arrive at once. Each backend exports its automation
145+
object already wrapped, so both `main/index.ts` and `server/socket.ts` share one
146+
queue. Backend methods must therefore never call each other through the exported
147+
object.
148+
137149
#### `pptx/macos.ts` — macOS Automation
138150
Controls PowerPoint via AppleScript:
139151
- `checkInstalled()` — Verify PowerPoint is installed

eslint.config.mjs

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,10 @@ export default [
6262
'prefer-const': 'error',
6363
'no-var': 'error',
6464

65-
// 'warn', not 'error'. This flags seven pre-existing sites in the
66-
// PowerPoint automation backends where module-level state (currentSlide,
67-
// currentAnimationStep, localPresentationCopy) is read before an `await`
68-
// and written after it. They read as genuine interleaving hazards under
69-
// rapid remote input rather than false positives, but fixing them means
70-
// reworking the concurrency model of code that can only be exercised
71-
// against a real PowerPoint install - well outside the scope of turning
72-
// linting on. Left visible rather than disabled so the debt stays counted.
73-
'require-atomic-updates': 'warn',
65+
// Not in `recommended`, kept anyway: it catches shared state that is read
66+
// before an `await` and written after it, which is exactly the shape the
67+
// PowerPoint backends keep their slideshow position in.
68+
'require-atomic-updates': 'error',
7469

7570
// Off on purpose, per typescript-eslint's own guidance: the compiler
7671
// already resolves every identifier, and keeping it on would mean

src/main/pptx/macos.ts

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { readdir, access, mkdir, unlink, copyFile } from 'fs/promises';
66
import { join, basename } from 'path';
77
import { tmpdir } from 'os';
88
import { PowerPointAutomation, SlideInfo, SlideMetadata, ProgressCallback } from './types';
9+
import { serializeAutomation } from './serialize';
910
import { parsePresentationData, PresentationData, getNextVisibleSlide, getSlideData } from './slideParser';
1011
import log from 'electron-log';
1112

@@ -57,7 +58,7 @@ end tell
5758
}
5859
}
5960

60-
export const macOSAutomation: PowerPointAutomation = {
61+
const macOSBackend: PowerPointAutomation = {
6162
async checkInstalled() {
6263
try {
6364
await access('/Applications/Microsoft PowerPoint.app');
@@ -242,7 +243,8 @@ end tell
242243
},
243244

244245
async nextSlide() {
245-
const slideData = presentationData ? getSlideData(currentSlide, presentationData) : null;
246+
const slideBefore = currentSlide;
247+
const slideData = presentationData ? getSlideData(slideBefore, presentationData) : null;
246248
const animationsOnSlide = slideData?.animationClicks || 0;
247249

248250
// Send the keystroke
@@ -251,19 +253,27 @@ end tell
251253
// Wait for PowerPoint to process
252254
await new Promise(resolve => setTimeout(resolve, 100));
253255

254-
// Check if we advanced an animation or moved to next slide
255-
if (currentAnimationStep < animationsOnSlide) {
256-
// Might be an animation click
257-
currentAnimationStep++;
258-
}
259-
260256
// Query PowerPoint for actual slide number
261257
const actualSlide = await queryCurrentSlide();
262258

263-
if (actualSlide !== currentSlide) {
264-
// We moved to a new slide
265-
currentSlide = actualSlide;
266-
currentAnimationStep = 0;
259+
// Read the state back after the await instead of reusing the snapshot taken
260+
// above, then write both fields in one synchronous block. The serializer
261+
// already stops another command from landing in between; the re-read is
262+
// what makes that checkable rather than assumed.
263+
const slideChanged = actualSlide !== currentSlide;
264+
let newStep = currentAnimationStep;
265+
266+
// Either we advanced an animation or we moved to a new slide.
267+
if (slideChanged) {
268+
newStep = 0;
269+
} else if (newStep < animationsOnSlide) {
270+
newStep++;
271+
}
272+
273+
currentSlide = actualSlide;
274+
currentAnimationStep = newStep;
275+
276+
if (slideChanged) {
267277
console.log(`Moved to slide ${currentSlide}`);
268278
} else {
269279
console.log(`Animation ${currentAnimationStep}/${animationsOnSlide} on slide ${currentSlide}`);
@@ -380,14 +390,16 @@ tell application "Microsoft PowerPoint"
380390
end tell
381391
`);
382392

383-
// Clean up temp copy
384-
if (localPresentationCopy) {
393+
// Clean up temp copy. The path is cleared before the unlink is awaited, so
394+
// the field is never left pointing at a file that is on its way out.
395+
const tempCopy = localPresentationCopy;
396+
if (tempCopy) {
397+
localPresentationCopy = '';
385398
try {
386-
await unlink(localPresentationCopy);
399+
await unlink(tempCopy);
387400
} catch {
388401
// Ignore
389402
}
390-
localPresentationCopy = '';
391403
}
392404

393405
// Reset state
@@ -396,3 +408,7 @@ end tell
396408
currentAnimationStep = 0;
397409
},
398410
};
411+
412+
// One command at a time: the module-level state above is only consistent if
413+
// nothing interleaves with it. See serialize.ts.
414+
export const macOSAutomation = serializeAutomation(macOSBackend);

src/main/pptx/serialize.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: 2025-2026 Schuberg Philis / Lab271
3+
import { PowerPointAutomation } from './types';
4+
5+
/**
6+
* Wraps a PowerPoint backend so that at most one automation call is ever in
7+
* flight against it.
8+
*
9+
* Every backend keeps its slideshow position in module-level state
10+
* (`currentSlide`, `currentAnimationStep`, ...) and refreshes it from
11+
* PowerPoint across an `await` - an AppleScript round trip on macOS, a
12+
* PowerShell bridge command or a COM call on Windows. Nothing used to stop two
13+
* of those from overlapping: the web remote can fire `next` from several
14+
* devices at once, a fast double-tap queues two `nextSlide()` calls, and the
15+
* 500 ms poll in `server/socket.ts` calls `getSlideInfo()` on top of both. The
16+
* second caller would then read state that the first had not finished writing
17+
* and leave the cached position disagreeing with the real one.
18+
*
19+
* Chaining the calls onto a single promise fixes that at the source: the
20+
* commands reach PowerPoint in the order they were requested, and each one sees
21+
* the state the previous one committed. It also means a backend never has more
22+
* than one outstanding request to its transport, which is what the PowerShell
23+
* bridge's FIFO response matching already assumed.
24+
*
25+
* A rejected call does not poison the chain - the next queued call still runs,
26+
* and the rejection is delivered only to the caller that asked for it.
27+
*/
28+
export function serializeAutomation(backend: PowerPointAutomation): PowerPointAutomation {
29+
// Always settles fulfilled, so a failed call cannot stall everything behind it.
30+
let tail: Promise<void> = Promise.resolve();
31+
32+
function run<T>(operation: () => Promise<T>): Promise<T> {
33+
const result = tail.then(operation);
34+
tail = result.then(
35+
() => undefined,
36+
() => undefined
37+
);
38+
return result;
39+
}
40+
41+
// Listed one by one rather than proxied so that adding a method to
42+
// PowerPointAutomation without serializing it is a type error.
43+
return {
44+
checkInstalled: () => run(() => backend.checkInstalled()),
45+
openPresentation: (filePath) => run(() => backend.openPresentation(filePath)),
46+
exportThumbnails: (outputDir, onProgress) =>
47+
run(() => backend.exportThumbnails(outputDir, onProgress)),
48+
startSlideshow: () => run(() => backend.startSlideshow()),
49+
nextSlide: () => run(() => backend.nextSlide()),
50+
prevSlide: () => run(() => backend.prevSlide()),
51+
gotoSlide: (index) => run(() => backend.gotoSlide(index)),
52+
getSlideInfo: () => run(() => backend.getSlideInfo()),
53+
stopSlideshow: () => run(() => backend.stopSlideshow()),
54+
closePresentation: () => run(() => backend.closePresentation()),
55+
};
56+
}

src/main/pptx/windows-powershell.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import path from 'path';
88
import log from 'electron-log';
99
import { app } from 'electron';
1010
import { PowerPointAutomation, SlideInfo, SlideMetadata, ProgressCallback } from './types';
11+
import { serializeAutomation } from './serialize';
1112
import { PresentationData, getNextVisibleSlide, getSlideData } from './slideParser';
1213

1314
interface PSCommand {
@@ -214,7 +215,7 @@ let currentAnimationStep = 0;
214215
let totalSlides = 1;
215216
let localPresentationCopy = '';
216217

217-
export const windowsAutomation: PowerPointAutomation = {
218+
const powerShellBackend: PowerPointAutomation = {
218219
async checkInstalled() {
219220
try {
220221
log.info('[Windows] Checking if PowerPoint is installed');
@@ -378,10 +379,11 @@ export const windowsAutomation: PowerPointAutomation = {
378379

379380
async nextSlide() {
380381
try {
381-
const slideData = presentationData ? getSlideData(currentSlide, presentationData) : null;
382+
const slideBefore = currentSlide;
383+
const slideData = presentationData ? getSlideData(slideBefore, presentationData) : null;
382384
const animationsOnSlide = slideData?.animationClicks || 0;
383385

384-
log.info('[Windows] Next slide (current:', currentSlide, 'animation:', currentAnimationStep, '/', animationsOnSlide, ')');
386+
log.info('[Windows] Next slide (current:', slideBefore, 'animation:', currentAnimationStep, '/', animationsOnSlide, ')');
385387

386388
const response = await bridge.sendCommand({ action: 'next' });
387389

@@ -390,15 +392,27 @@ export const windowsAutomation: PowerPointAutomation = {
390392
return;
391393
}
392394

395+
// Read the state back after the await instead of reusing the snapshot
396+
// taken above, then write both fields in one synchronous block. The
397+
// serializer already stops another command from landing in between; the
398+
// re-read is what makes that checkable rather than assumed.
393399
const newSlide = parseInt(response.data || String(currentSlide), 10);
400+
const slideChanged = newSlide !== currentSlide;
401+
let newStep = currentAnimationStep;
402+
403+
// Either we advanced an animation or we moved to the next slide.
404+
if (slideChanged) {
405+
newStep = 0;
406+
} else if (newStep < animationsOnSlide) {
407+
newStep++;
408+
}
409+
410+
currentSlide = newSlide;
411+
currentAnimationStep = newStep;
394412

395-
// Check if we advanced an animation or moved to next slide
396-
if (newSlide !== currentSlide) {
397-
currentSlide = newSlide;
398-
currentAnimationStep = 0;
413+
if (slideChanged) {
399414
log.info('[Windows] Moved to slide', currentSlide);
400-
} else if (currentAnimationStep < animationsOnSlide) {
401-
currentAnimationStep++;
415+
} else {
402416
log.info('[Windows] Animation', currentAnimationStep, '/', animationsOnSlide, 'on slide', currentSlide);
403417
}
404418
} catch (error) {
@@ -417,7 +431,11 @@ export const windowsAutomation: PowerPointAutomation = {
417431
return;
418432
}
419433

420-
currentSlide = parseInt(response.data || String(currentSlide), 10);
434+
// Re-read the state after the await rather than reusing the value logged
435+
// above, so the assignment cannot be based on the pre-command position.
436+
const slideBefore = currentSlide;
437+
const newSlide = parseInt(response.data || String(slideBefore), 10);
438+
currentSlide = newSlide;
421439
currentAnimationStep = 0;
422440
log.info('[Windows] Moved to slide', currentSlide);
423441
} catch (error) {
@@ -523,15 +541,17 @@ export const windowsAutomation: PowerPointAutomation = {
523541
// Stop the bridge
524542
await bridge.stop();
525543

526-
// Clean up temp copy
527-
if (localPresentationCopy) {
544+
// Clean up temp copy. The path is cleared before the unlink is awaited,
545+
// so the field is never left pointing at a file that is on its way out.
546+
const tempCopy = localPresentationCopy;
547+
if (tempCopy) {
548+
localPresentationCopy = '';
528549
try {
529-
await unlink(localPresentationCopy);
550+
await unlink(tempCopy);
530551
log.info('[Windows] Deleted temp presentation copy');
531552
} catch (error) {
532553
log.error('[Windows] Failed to delete temp copy:', error);
533554
}
534-
localPresentationCopy = '';
535555
}
536556

537557
// Reset state
@@ -544,3 +564,7 @@ export const windowsAutomation: PowerPointAutomation = {
544564
}
545565
},
546566
};
567+
568+
// One command at a time: the module-level state above is only consistent if
569+
// nothing interleaves with it. See serialize.ts.
570+
export const windowsAutomation = serializeAutomation(powerShellBackend);

src/main/pptx/windows-winax.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join, basename } from 'path';
55
import { tmpdir } from 'os';
66
import log from 'electron-log';
77
import { PowerPointAutomation, SlideInfo, SlideMetadata, ProgressCallback } from './types';
8+
import { serializeAutomation } from './serialize';
89
import { parsePresentationData, PresentationData, getNextVisibleSlide, getSlideData } from './slideParser';
910

1011
// Try to load winax
@@ -40,7 +41,7 @@ function queryCurrentSlide(): number {
4041
return currentSlide;
4142
}
4243

43-
export const windowsWinaxAutomation: PowerPointAutomation = {
44+
const winaxBackend: PowerPointAutomation = {
4445
async checkInstalled() {
4546
if (!winax) {
4647
log.error('[Windows-WinAX] winax module not available');
@@ -215,9 +216,12 @@ export const windowsWinaxAutomation: PowerPointAutomation = {
215216

216217
await new Promise(resolve => setTimeout(resolve, 100));
217218

218-
currentSlide = queryCurrentSlide();
219+
// Logging the move re-reads the state after the await, so the assignment
220+
// below cannot be based on the value captured before the COM call.
221+
const actualSlide = queryCurrentSlide();
222+
log.info('[Windows-WinAX] Moved from slide', currentSlide, 'to slide', actualSlide);
223+
currentSlide = actualSlide;
219224
currentAnimationStep = 0;
220-
log.info('[Windows-WinAX] Moved to slide', currentSlide);
221225
} catch (error) {
222226
log.error('[Windows-WinAX] Failed to go to previous slide:', error);
223227
}
@@ -333,15 +337,17 @@ export const windowsWinaxAutomation: PowerPointAutomation = {
333337
pptApp = null;
334338
}
335339

336-
// Clean up temp copy
337-
if (localPresentationCopy) {
340+
// Clean up temp copy. The path is cleared before the unlink is awaited,
341+
// so the field is never left pointing at a file that is on its way out.
342+
const tempCopy = localPresentationCopy;
343+
if (tempCopy) {
344+
localPresentationCopy = '';
338345
try {
339-
await unlink(localPresentationCopy);
346+
await unlink(tempCopy);
340347
log.info('[Windows-WinAX] Deleted temp presentation copy');
341348
} catch (error) {
342349
log.error('[Windows-WinAX] Failed to delete temp copy:', error);
343350
}
344-
localPresentationCopy = '';
345351
}
346352

347353
// Reset state
@@ -355,3 +361,7 @@ export const windowsWinaxAutomation: PowerPointAutomation = {
355361
}
356362
},
357363
};
364+
365+
// One command at a time: the module-level state above is only consistent if
366+
// nothing interleaves with it. See serialize.ts.
367+
export const windowsWinaxAutomation = serializeAutomation(winaxBackend);

0 commit comments

Comments
 (0)