Skip to content

Commit 912e755

Browse files
committed
fix(wgc): capture per-step stop timing, bump timeout, add diagnostic tool
Issue #34: Windows 10 recording stop times out at 15s with no useful data. Three changes: 1. The stop timeout (electron/ipc/handlers.ts:415) was 15s, shorter than the macOS equivalent at 30s. Bumped to 60s so the Media Foundation SinkWriter::Finalize has room to drain on slow encoders. 2. Added per-step elapsed-time logs ([stop-timing] step=... elapsed_ms=...) around the cleanup chain in main.cpp so future stop hangs pinpoint which step is slow. These go to stderr, which Electron already captures into nativeWindowsCaptureOutput. 3. Save Diagnostics (electron/ipc/handlers.ts) previously hardcoded logs: [] in the renderer; now includes helperOutput (helper stdout + stderr, capped at 64KB) and mainProcessLogs (a console ring buffer populated when OPENSCREEN_DIAGNOSTIC=1). 4. New scripts/diagnostic-tool/ runs the helper outside the Electron app, captures [stop-timing] lines, writes a structured JSON report. Used as a standalone diagnostic bundle attached to bug reports. 5. .github/workflows/diagnostic-artifact.yml builds per-platform diagnostic zips on every push to main, retained 14 days. Windows job smoke-tests the bundle before upload. Replaces scripts/repro-stop-hang.mjs and scripts/wgc-diagnostic-bundle/ which were scratch tools; the new diagnostic.mjs subsumes both.
1 parent cce4524 commit 912e755

11 files changed

Lines changed: 694 additions & 1 deletion

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
name: Diagnostic artifact
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
build-windows:
15+
name: Windows x64 diagnostic bundle
16+
runs-on: windows-latest
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: 22
23+
cache: npm
24+
25+
- name: Install dependencies
26+
run: npm ci
27+
28+
- name: Build native helper
29+
run: npm run build:native:win
30+
31+
- name: Bundle diagnostic tool
32+
shell: pwsh
33+
run: |
34+
$ErrorActionPreference = "Stop"
35+
$bundle = "openscreen-diagnostic-windows-x64"
36+
$staging = New-Item -ItemType Directory -Path "$bundle"
37+
Copy-Item scripts/diagnostic-tool/diagnostic.mjs -Destination $staging
38+
Copy-Item scripts/diagnostic-tool/diagnostic.bat -Destination $staging
39+
Copy-Item scripts/diagnostic-tool/README.md -Destination $staging
40+
New-Item -ItemType Directory -Path "$staging/helpers/win32-x64" | Out-Null
41+
Copy-Item electron/native/bin/win32-x64/wgc-capture.exe -Destination "$staging/helpers/win32-x64/"
42+
Compress-Archive -Path "$staging" -DestinationPath "$bundle.zip"
43+
44+
- name: Smoke-test the bundle
45+
shell: pwsh
46+
run: |
47+
$ErrorActionPreference = "Stop"
48+
Expand-Archive -Path openscreen-diagnostic-windows-x64.zip -DestinationPath smoke
49+
$smoke = Resolve-Path "smoke/openscreen-diagnostic-windows-x64"
50+
& "$smoke/diagnostic.bat" --duration 3 --output "$smoke/smoke.json"
51+
$report = Get-Content "$smoke/smoke.json" -Raw | ConvertFrom-Json
52+
if ($report.exitCode -ne 0) { throw "Smoke test failed: exitCode=$($report.exitCode)" }
53+
if ($report.stopTiming.Count -lt 1) { throw "Smoke test produced no [stop-timing] entries" }
54+
55+
- name: Upload Windows diagnostic bundle
56+
uses: actions/upload-artifact@v4
57+
with:
58+
name: openscreen-diagnostic-windows-x64
59+
path: openscreen-diagnostic-windows-x64.zip
60+
if-no-files-found: error
61+
retention-days: 14
62+
63+
build-macos:
64+
name: macOS ${{ matrix.arch }} diagnostic bundle
65+
runs-on: macos-latest
66+
strategy:
67+
fail-fast: false
68+
matrix:
69+
arch: [arm64, x64]
70+
steps:
71+
- uses: actions/checkout@v4
72+
73+
- uses: actions/setup-node@v4
74+
with:
75+
node-version: 22
76+
cache: npm
77+
78+
- name: Install dependencies
79+
run: npm ci
80+
81+
- name: Build native helper
82+
run: npm run build:native:mac
83+
env:
84+
OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }}
85+
86+
- name: Bundle diagnostic tool
87+
run: |
88+
set -euo pipefail
89+
arch="${{ matrix.arch }}"
90+
arch_tag="darwin-$([ "$arch" = "x64" ] && echo x64 || echo arm64)"
91+
bundle="openscreen-diagnostic-macos-$arch"
92+
rm -rf "$bundle" "$bundle.tar.gz"
93+
mkdir -p "$bundle/helpers/$arch_tag"
94+
cp scripts/diagnostic-tool/diagnostic.mjs "$bundle/"
95+
cp scripts/diagnostic-tool/diagnostic.sh "$bundle/"
96+
cp scripts/diagnostic-tool/README.md "$bundle/"
97+
cp "electron/native/bin/$arch_tag/openscreen-screencapturekit-helper" "$bundle/helpers/$arch_tag/"
98+
chmod +x "$bundle/diagnostic.sh" "$bundle/helpers/$arch_tag/openscreen-screencapturekit-helper"
99+
tar -czf "$bundle.tar.gz" "$bundle"
100+
101+
- name: Upload macOS diagnostic bundle
102+
uses: actions/upload-artifact@v4
103+
with:
104+
name: openscreen-diagnostic-macos-${{ matrix.arch }}
105+
path: openscreen-diagnostic-macos-${{ matrix.arch }}.tar.gz
106+
if-no-files-found: error
107+
retention-days: 14
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { MainLogBuffer } from "./main-log-buffer";
3+
4+
describe("MainLogBuffer", () => {
5+
const buffers: MainLogBuffer[] = [];
6+
7+
function make(capacity: number) {
8+
const b = new MainLogBuffer(capacity);
9+
buffers.push(b);
10+
return b;
11+
}
12+
13+
afterEach(() => {
14+
for (const b of buffers) b.uninstall();
15+
buffers.length = 0;
16+
});
17+
18+
it("captures every console level and routes to original", () => {
19+
const buf = make(10);
20+
const captured: string[] = [];
21+
const original = {
22+
log: (...args: unknown[]) => captured.push(`log:${args.join(",")}`),
23+
info: (...args: unknown[]) => captured.push(`info:${args.join(",")}`),
24+
warn: (...args: unknown[]) => captured.push(`warn:${args.join(",")}`),
25+
error: (...args: unknown[]) => captured.push(`error:${args.join(",")}`),
26+
};
27+
Object.assign(console, original);
28+
buf.install();
29+
console.log("hello");
30+
console.info("world");
31+
console.warn("watch");
32+
console.error("bad");
33+
const snap = buf.snapshot();
34+
expect(snap.map((e) => e.level)).toEqual(["log", "info", "warn", "error"]);
35+
expect(snap.map((e) => e.text)).toEqual(["hello", "world", "watch", "bad"]);
36+
expect(captured).toEqual(["log:hello", "info:world", "warn:watch", "error:bad"]);
37+
});
38+
39+
it("stringifies non-string args", () => {
40+
const buf = make(5);
41+
buf.install();
42+
console.info({ a: 1 });
43+
const snap = buf.snapshot();
44+
expect(snap[0].text).toBe('{"a":1}');
45+
});
46+
47+
it("drops oldest entries past capacity", () => {
48+
const buf = make(3);
49+
buf.install();
50+
for (let i = 0; i < 5; i += 1) console.info(`line ${i}`);
51+
const snap = buf.snapshot();
52+
expect(snap.map((e) => e.text)).toEqual(["line 2", "line 3", "line 4"]);
53+
});
54+
55+
it("uninstall restores originals", () => {
56+
const buf = make(5);
57+
buf.install();
58+
console.info("captured");
59+
buf.uninstall();
60+
console.info("after-uninstall");
61+
const snap = buf.snapshot();
62+
expect(snap.map((e) => e.text)).toEqual(["captured"]);
63+
});
64+
65+
it("clear empties the buffer", () => {
66+
const buf = make(5);
67+
buf.install();
68+
console.info("one");
69+
console.info("two");
70+
buf.clear();
71+
expect(buf.snapshot()).toEqual([]);
72+
});
73+
74+
it("install is idempotent", () => {
75+
const buf = make(5);
76+
buf.install();
77+
buf.install();
78+
console.info("once");
79+
const snap = buf.snapshot();
80+
expect(snap.map((e) => e.text)).toEqual(["once"]);
81+
});
82+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Ring buffer for main-process console output.
3+
*
4+
* Captures the last `capacity` lines written via console.info / console.warn /
5+
* console.error / console.log into a single in-memory buffer. Disabled by
6+
* default — install only when verbose diagnostics are wanted, e.g. when
7+
* OPENSCREEN_DIAGNOSTIC=1 is set or when a developer wants a more complete
8+
* "Save Diagnostics" payload for an upstream bug report.
9+
*
10+
* Cost when enabled: one array.push + occasional shift per console call,
11+
* negligible against the rest of the app. Cost when disabled: zero, the
12+
* original console methods are kept untouched.
13+
*/
14+
15+
const DEFAULT_CAPACITY = 500;
16+
17+
export interface MainLogEntry {
18+
timestampMs: number;
19+
level: "info" | "warn" | "error" | "log";
20+
text: string;
21+
}
22+
23+
export class MainLogBuffer {
24+
private readonly capacity: number;
25+
private readonly entries: MainLogEntry[] = [];
26+
private installed = false;
27+
private readonly originals: Partial<
28+
Record<"log" | "info" | "warn" | "error", (...args: unknown[]) => void>
29+
> = {};
30+
31+
constructor(capacity = DEFAULT_CAPACITY) {
32+
this.capacity = Math.max(1, capacity);
33+
}
34+
35+
install(): void {
36+
if (this.installed) return;
37+
this.installed = true;
38+
const console_ = console as unknown as Record<
39+
"log" | "info" | "warn" | "error",
40+
(...args: unknown[]) => void
41+
>;
42+
for (const level of ["log", "info", "warn", "error"] as const) {
43+
this.originals[level] = console_[level].bind(console);
44+
console_[level] = (...args: unknown[]) => {
45+
this.push(level, args);
46+
this.originals[level]?.(...args);
47+
};
48+
}
49+
}
50+
51+
uninstall(): void {
52+
if (!this.installed) return;
53+
this.installed = false;
54+
const console_ = console as unknown as Record<
55+
"log" | "info" | "warn" | "error",
56+
(...args: unknown[]) => void
57+
>;
58+
for (const level of ["log", "info", "warn", "error"] as const) {
59+
if (this.originals[level]) {
60+
console_[level] = this.originals[level] as (...args: unknown[]) => void;
61+
}
62+
}
63+
this.originals.log = undefined;
64+
this.originals.info = undefined;
65+
this.originals.warn = undefined;
66+
this.originals.error = undefined;
67+
}
68+
69+
snapshot(): MainLogEntry[] {
70+
return this.entries.slice();
71+
}
72+
73+
clear(): void {
74+
this.entries.length = 0;
75+
}
76+
77+
private push(level: MainLogEntry["level"], args: unknown[]): void {
78+
const text = args
79+
.map((arg) => {
80+
if (typeof arg === "string") return arg;
81+
try {
82+
return JSON.stringify(arg);
83+
} catch {
84+
return String(arg);
85+
}
86+
})
87+
.join(" ");
88+
this.entries.push({ timestampMs: Date.now(), level, text });
89+
if (this.entries.length > this.capacity) {
90+
this.entries.splice(0, this.entries.length - this.capacity);
91+
}
92+
}
93+
}
94+
95+
export const mainLogBuffer = new MainLogBuffer();
96+
97+
export function isDiagnosticModeEnabled(): boolean {
98+
const raw = process.env.OPENSCREEN_DIAGNOSTIC;
99+
if (!raw) return false;
100+
const lowered = raw.trim().toLowerCase();
101+
return lowered === "1" || lowered === "true" || lowered === "yes";
102+
}

‎electron/ipc/handlers.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type {
3535
ProjectFileResult,
3636
ProjectPathResult,
3737
} from "../../src/native/contracts";
38+
import { mainLogBuffer } from "../diagnostics/main-log-buffer";
3839
import { mainT } from "../i18n";
3940
import { RECORDINGS_DIR } from "../main";
4041
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
@@ -412,7 +413,7 @@ let nativeWindowsCursorRecordingStartMs = 0;
412413
let nativeWindowsPauseStartedAtMs: number | null = null;
413414
let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = [];
414415
let nativeWindowsIsPaused = false;
415-
const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000;
416+
const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 60_000;
416417
let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null;
417418
let nativeMacCaptureOutput = "";
418419
let nativeMacCaptureTargetPath: string | null = null;
@@ -2873,6 +2874,9 @@ export function registerIpcHandlers(
28732874

28742875
if (canceled || !filePath) return { success: false, canceled: true };
28752876

2877+
const HELPER_OUTPUT_MAX_BYTES = 64 * 1024;
2878+
const tail = (s: string, max: number) => (s.length <= max ? s : s.slice(s.length - max));
2879+
28762880
const diagnostic = {
28772881
timestamp: new Date().toISOString(),
28782882
appVersion: app.getVersion(),
@@ -2888,6 +2892,11 @@ export function registerIpcHandlers(
28882892
stack: payload.stack,
28892893
projectState: payload.projectState,
28902894
recentLogs: payload.logs,
2895+
helperOutput: {
2896+
windows: tail(nativeWindowsCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
2897+
mac: tail(nativeMacCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
2898+
},
2899+
mainProcessLogs: mainLogBuffer.snapshot(),
28912900
};
28922901

28932902
try {

‎electron/main.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Tray,
1313
} from "electron";
1414
import { ShortcutBinding } from "../src/lib/shortcuts";
15+
import { isDiagnosticModeEnabled, mainLogBuffer } from "./diagnostics/main-log-buffer";
1516
import {
1617
loadAndRegisterGlobalShortcut,
1718
registerOpenAppShortcut,
@@ -478,6 +479,11 @@ app.on("will-quit", () => {
478479
const appReady = hasSingleInstanceLock ? app.whenReady() : null;
479480

480481
appReady?.then(async () => {
482+
if (isDiagnosticModeEnabled()) {
483+
mainLogBuffer.install();
484+
console.info("[diagnostic] OPENSCREEN_DIAGNOSTIC=1, capturing console.* into ring buffer");
485+
}
486+
481487
// Force "regular" activation policy so the Dock icon appears. The HUD overlay
482488
// (transparent, frameless, skipTaskbar) is the first window, and AppKit would
483489
// otherwise classify us as an accessory app.

‎electron/native/wgc-capture/src/main.cpp‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,19 +823,34 @@ int main(int argc, char* argv[]) {
823823
});
824824
}
825825

826+
const auto stopStart = std::chrono::steady_clock::now();
827+
auto logStopStep = [&](const char* step) {
828+
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
829+
std::chrono::steady_clock::now() - stopStart).count();
830+
std::cerr << "[stop-timing] step=" << step << " elapsed_ms=" << ms << std::endl;
831+
};
832+
826833
microphoneCapture.stop();
834+
logStopStep("microphone");
827835
loopbackCapture.stop();
836+
logStopStep("loopback");
828837
webcamCapture.stop();
838+
logStopStep("webcam");
829839
if (audioMixer) {
830840
audioMixer->stop();
831841
}
842+
logStopStep("audio-mixer");
832843
stopVideoWriter();
844+
logStopStep("video-writer-join");
833845
session.stop();
846+
logStopStep("wgc-session-close");
834847
{
835848
std::scoped_lock lock(mutex);
836849
encoder.finalize();
850+
logStopStep("encoder-finalize");
837851
if (writeSeparateWebcam) {
838852
webcamEncoder.finalize();
853+
logStopStep("webcam-encoder-finalize");
839854
}
840855
}
841856

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
"test:wgc-full:win": "node scripts/test-windows-wgc-helper.mjs --webcam --system-audio --microphone",
4444
"capture:openscreen-preview": "node scripts/capture-openscreen-preview.mjs",
4545
"inspect:cursor-click-bounce": "node scripts/inspect-native-cursor-click-bounce.mjs",
46+
"diagnostic:run": "node scripts/diagnostic-tool/diagnostic.mjs",
47+
"diagnostic:smoke:win": "node scripts/diagnostic-tool/diagnostic.mjs --duration 3",
4648
"build-vite": "tsc && vite build",
4749
"test:browser": "vitest --config vitest.browser.config.ts --run",
4850
"test:browser:install": "playwright install --with-deps chromium-headless-shell",

0 commit comments

Comments
 (0)