From f1b1f6bb0cfcf3a17f24f36cfe358263a8b15603 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 22 Apr 2026 09:56:13 +0000 Subject: [PATCH 1/5] feat: include VS Code-side logs in support bundle After the CLI generates the support bundle zip, append logs from three sources under a vscode-logs/ directory: - Remote SSH extension log (from SshProcessMonitor) - SSH proxy logs (from PathResolver.getProxyLogPath()) - Extension output channel logs (from PathResolver.getCodeLogDir()) Each source is collected independently and failures are warned and skipped. If zip manipulation fails the original file is left as-is. Writes to a `-vscode.zip` temp file first, then uses renameWithRetry to atomically replace the original. If rename fails the user still has the temp file with a descriptive name. Uses fflate (zero-dep, 8kB, tree-shakeable) to read/modify/write the zip. Bundle size impact: +753 bytes (dev), negligible in production. Closes #889 --- package.json | 1 + pnpm-lock.yaml | 16 ++- src/commands.ts | 13 ++ src/core/supportBundleLogs.ts | 148 +++++++++++++++++++++++ test/unit/core/supportBundleLogs.test.ts | 123 +++++++++++++++++++ 5 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 src/core/supportBundleLogs.ts create mode 100644 test/unit/core/supportBundleLogs.test.ts diff --git a/package.json b/package.json index 38f491f9e0..7578f7de51 100644 --- a/package.json +++ b/package.json @@ -597,6 +597,7 @@ "axios": "1.15.0", "date-fns": "catalog:", "eventsource": "^4.1.0", + "fflate": "^0.8.2", "find-process": "^2.1.1", "jsonc-parser": "^3.3.1", "openpgp": "^6.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f87fa8591e..154e93d333 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: eventsource: specifier: ^4.1.0 version: 4.1.0 + fflate: + specifier: ^0.8.2 + version: 0.8.2 find-process: specifier: ^2.1.1 version: 2.1.1 @@ -188,7 +191,7 @@ importers: version: 4.1.0 coder: specifier: 'catalog:' - version: https://codeload.github.com/coder/coder/tar.gz/6b0bb02e5dcb1766e5d8c7aa88471019c80e49b4 + version: https://codeload.github.com/coder/coder/tar.gz/ad1906589d5a794de86b94ecf22af19448a471d1 concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -1955,8 +1958,8 @@ packages: resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==} engines: {node: '>=16'} - coder@https://codeload.github.com/coder/coder/tar.gz/6b0bb02e5dcb1766e5d8c7aa88471019c80e49b4: - resolution: {tarball: https://codeload.github.com/coder/coder/tar.gz/6b0bb02e5dcb1766e5d8c7aa88471019c80e49b4} + coder@https://codeload.github.com/coder/coder/tar.gz/ad1906589d5a794de86b94ecf22af19448a471d1: + resolution: {tarball: https://codeload.github.com/coder/coder/tar.gz/ad1906589d5a794de86b94ecf22af19448a471d1} version: 0.0.0 color-convert@2.0.1: @@ -2462,6 +2465,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -6138,7 +6144,7 @@ snapshots: cockatiel@3.2.1: {} - coder@https://codeload.github.com/coder/coder/tar.gz/6b0bb02e5dcb1766e5d8c7aa88471019c80e49b4: {} + coder@https://codeload.github.com/coder/coder/tar.gz/ad1906589d5a794de86b94ecf22af19448a471d1: {} color-convert@2.0.1: dependencies: @@ -6726,6 +6732,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 diff --git a/src/commands.ts b/src/commands.ts index 3d7d51eaa3..3155b87069 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -20,6 +20,7 @@ import { type ServiceContainer } from "./core/container"; import { type MementoManager } from "./core/mementoManager"; import { type PathResolver } from "./core/pathResolver"; import { type SecretsManager } from "./core/secretsManager"; +import { appendVsCodeLogs } from "./core/supportBundleLogs"; import { type DeploymentManager } from "./deployment/deploymentManager"; import { CertificateError } from "./error/certificateError"; import { toError } from "./error/errorUtils"; @@ -256,6 +257,18 @@ export class Commands { progress.report({ message: "Collecting diagnostics..." }); await cliExec.supportBundle(env, workspaceId, outputUri.fsPath, signal); + + progress.report({ message: "Adding VS Code logs..." }); + await appendVsCodeLogs( + outputUri.fsPath, + { + remoteSshLogPath: this.workspaceLogPath, + proxyLogDir: this.pathResolver.getProxyLogPath(), + extensionLogDir: this.pathResolver.getCodeLogDir(), + }, + this.logger, + ); + return outputUri; }, { diff --git a/src/core/supportBundleLogs.ts b/src/core/supportBundleLogs.ts new file mode 100644 index 0000000000..af07804c33 --- /dev/null +++ b/src/core/supportBundleLogs.ts @@ -0,0 +1,148 @@ +import { unzipSync, zipSync } from "fflate"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { toError } from "../error/errorUtils"; +import { type Logger } from "../logging/logger"; +import { renameWithRetry } from "../util"; + +export interface LogSources { + remoteSshLogPath?: string; + proxyLogDir?: string; + extensionLogDir?: string; +} + +/** Collect regular files from a directory into zip-ready entries. */ +async function collectDirFiles( + dirPath: string, + zipPrefix: string, + logger: Logger, +): Promise> { + const files: Record = {}; + + let entries: string[]; + try { + entries = await fs.readdir(dirPath); + } catch (error) { + logger.warn( + `Could not read log directory ${dirPath}: ${toError(error).message}`, + ); + return files; + } + + for (const entry of entries) { + const filePath = path.join(dirPath, entry); + try { + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + continue; + } + const content = await fs.readFile(filePath); + files[`${zipPrefix}/${entry}`] = new Uint8Array(content); + } catch (error) { + logger.warn( + `Could not read log file ${filePath}: ${toError(error).message}`, + ); + } + } + + return files; +} + +/** + * Gather log files from each source independently so a failure in one + * does not affect the others. + */ +export async function collectLogFiles( + sources: LogSources, + logger: Logger, +): Promise> { + const files: Record = {}; + + if (sources.remoteSshLogPath) { + try { + const content = await fs.readFile(sources.remoteSshLogPath); + const name = path.basename(sources.remoteSshLogPath); + files[`vscode-logs/remote-ssh/${name}`] = new Uint8Array(content); + } catch (error) { + logger.warn(`Could not read Remote SSH log: ${toError(error).message}`); + } + } + + if (sources.proxyLogDir) { + Object.assign( + files, + await collectDirFiles(sources.proxyLogDir, "vscode-logs/proxy", logger), + ); + } + + if (sources.extensionLogDir) { + Object.assign( + files, + await collectDirFiles( + sources.extensionLogDir, + "vscode-logs/extension", + logger, + ), + ); + } + + return files; +} + +function vscodeBundlePath(zipPath: string): string { + const { dir, name, ext } = path.parse(zipPath); + return path.join(dir, `${name}-vscode${ext}`); +} + +/** + * Best-effort: append VS Code logs to a support bundle zip. + * Uses atomic rename to avoid corrupting the original bundle on failure. + */ +export async function appendVsCodeLogs( + zipPath: string, + sources: LogSources, + logger: Logger, +): Promise { + const logFiles = await collectLogFiles(sources, logger); + const count = Object.keys(logFiles).length; + if (count === 0) { + logger.info("No VS Code logs found to add to support bundle"); + return; + } + + logger.info(`Adding ${count} VS Code log file(s) to support bundle`); + + let updatedData: Uint8Array; + try { + const existingData = new Uint8Array(await fs.readFile(zipPath)); + const entries = unzipSync(existingData); + Object.assign(entries, logFiles); + updatedData = zipSync(entries); + } catch (error) { + logger.error( + `Failed to add VS Code logs to support bundle: ${toError(error).message}`, + ); + return; + } + + // Write to a named temporary path first so a failure mid-write leaves + // the user with a properly named file containing VS Code logs. + const tmpPath = vscodeBundlePath(zipPath); + try { + await fs.writeFile(tmpPath, updatedData); + } catch (error) { + logger.error( + `Failed to write updated support bundle: ${toError(error).message}`, + ); + return; + } + + try { + await renameWithRetry(fs.rename, tmpPath, zipPath); + } catch (error) { + logger.warn( + `Could not replace original bundle, VS Code logs saved separately: ${toError(error).message}`, + ); + } +} diff --git a/test/unit/core/supportBundleLogs.test.ts b/test/unit/core/supportBundleLogs.test.ts new file mode 100644 index 0000000000..92314f0811 --- /dev/null +++ b/test/unit/core/supportBundleLogs.test.ts @@ -0,0 +1,123 @@ +import { strToU8, unzipSync, zipSync } from "fflate"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { appendVsCodeLogs, collectLogFiles } from "@/core/supportBundleLogs"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "support-bundle-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +const logger = createMockLogger(); + +describe("collectLogFiles", () => { + it("collects from all three sources and skips subdirectories", async () => { + const sshLog = path.join(tmpDir, "ssh.log"); + await fs.writeFile(sshLog, "ssh"); + + const proxyDir = path.join(tmpDir, "proxy"); + await fs.mkdir(proxyDir); + await fs.writeFile(path.join(proxyDir, "99.log"), "proxy"); + await fs.mkdir(path.join(proxyDir, "subdir")); + + const extDir = path.join(tmpDir, "ext"); + await fs.mkdir(extDir); + await fs.writeFile(path.join(extDir, "Coder.log"), "ext"); + + const files = await collectLogFiles( + { + remoteSshLogPath: sshLog, + proxyLogDir: proxyDir, + extensionLogDir: extDir, + }, + logger, + ); + + expect(Object.keys(files).sort()).toEqual([ + "vscode-logs/extension/Coder.log", + "vscode-logs/proxy/99.log", + "vscode-logs/remote-ssh/ssh.log", + ]); + expect(Buffer.from(files["vscode-logs/proxy/99.log"]).toString()).toBe( + "proxy", + ); + }); + + it("returns empty when no sources are provided", async () => { + const files = await collectLogFiles({}, logger); + expect(Object.keys(files)).toHaveLength(0); + }); + + it("skips missing or unreadable sources and collects the rest", async () => { + const proxyDir = path.join(tmpDir, "proxy"); + await fs.mkdir(proxyDir); + await fs.writeFile(path.join(proxyDir, "good.log"), "ok"); + await fs.writeFile(path.join(proxyDir, "bad.log"), "secret"); + await fs.chmod(path.join(proxyDir, "bad.log"), 0o000); + + const files = await collectLogFiles( + { + remoteSshLogPath: path.join(tmpDir, "nonexistent.log"), + proxyLogDir: proxyDir, + extensionLogDir: path.join(tmpDir, "no-such-dir"), + }, + logger, + ); + + expect(Object.keys(files)).toEqual(["vscode-logs/proxy/good.log"]); + + await fs.chmod(path.join(proxyDir, "bad.log"), 0o644); + }); +}); + +describe("appendVsCodeLogs", () => { + let zipPath: string; + let originalZipBytes: Uint8Array; + + beforeEach(async () => { + zipPath = path.join(tmpDir, "coder-support-123.zip"); + originalZipBytes = zipSync({ "server/info.txt": strToU8("server data") }); + await fs.writeFile(zipPath, originalZipBytes); + }); + + it("merges log files into the existing zip", async () => { + const logPath = path.join(tmpDir, "ssh.log"); + await fs.writeFile(logPath, "ssh content"); + + await appendVsCodeLogs(zipPath, { remoteSshLogPath: logPath }, logger); + + const zip = unzipSync(new Uint8Array(await fs.readFile(zipPath))); + expect(Buffer.from(zip["server/info.txt"]).toString()).toBe("server data"); + expect(Buffer.from(zip["vscode-logs/remote-ssh/ssh.log"]).toString()).toBe( + "ssh content", + ); + }); + + it("does not touch the zip when no logs are found", async () => { + await appendVsCodeLogs(zipPath, {}, logger); + + const data = new Uint8Array(await fs.readFile(zipPath)); + expect(Buffer.from(data)).toEqual(Buffer.from(originalZipBytes)); + }); + + it("leaves the original zip intact when it is corrupted", async () => { + await fs.writeFile(zipPath, "not a zip"); + + const logPath = path.join(tmpDir, "ssh.log"); + await fs.writeFile(logPath, "content"); + + await appendVsCodeLogs(zipPath, { remoteSshLogPath: logPath }, logger); + + expect(await fs.readFile(zipPath, "utf-8")).toBe("not a zip"); + }); +}); From 224739b0bb9969365ce5229126ac50209fa0499f Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 22 Apr 2026 11:14:37 +0000 Subject: [PATCH 2/5] feat: filter proxy logs older than 3 days by filename timestamp Parse the date embedded in coder-ssh-YYYYMMDD-HHMMSS-.log filenames instead of using fs.stat. Only proxy logs are filtered; extension logs are included unconditionally. --- src/core/supportBundleLogs.ts | 33 +++++++++++++++++++++++- test/unit/core/supportBundleLogs.test.ts | 32 +++++++++++++++++++---- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/core/supportBundleLogs.ts b/src/core/supportBundleLogs.ts index af07804c33..a402da3903 100644 --- a/src/core/supportBundleLogs.ts +++ b/src/core/supportBundleLogs.ts @@ -12,11 +12,33 @@ export interface LogSources { extensionLogDir?: string; } +// Matches proxy log filenames: coder-ssh-YYYYMMDD-HHMMSS-.log +const PROXY_LOG_DATE_RE = + /^coder-ssh-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-/; +const PROXY_LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; + +function isProxyLogRecent(filename: string, now: Date): boolean { + const match = PROXY_LOG_DATE_RE.exec(filename); + if (!match) { + return true; + } + const fileDate = new Date( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6]), + ); + return now.getTime() - fileDate.getTime() <= PROXY_LOG_MAX_AGE_MS; +} + /** Collect regular files from a directory into zip-ready entries. */ async function collectDirFiles( dirPath: string, zipPrefix: string, logger: Logger, + filter?: (filename: string) => boolean, ): Promise> { const files: Record = {}; @@ -31,6 +53,9 @@ async function collectDirFiles( } for (const entry of entries) { + if (filter && !filter(entry)) { + continue; + } const filePath = path.join(dirPath, entry); try { const stat = await fs.stat(filePath); @@ -70,9 +95,15 @@ export async function collectLogFiles( } if (sources.proxyLogDir) { + const now = new Date(); Object.assign( files, - await collectDirFiles(sources.proxyLogDir, "vscode-logs/proxy", logger), + await collectDirFiles( + sources.proxyLogDir, + "vscode-logs/proxy", + logger, + (name) => isProxyLogRecent(name, now), + ), ); } diff --git a/test/unit/core/supportBundleLogs.test.ts b/test/unit/core/supportBundleLogs.test.ts index 92314f0811..d81b8afed0 100644 --- a/test/unit/core/supportBundleLogs.test.ts +++ b/test/unit/core/supportBundleLogs.test.ts @@ -20,14 +20,22 @@ afterEach(async () => { const logger = createMockLogger(); +function proxyLogName(daysAgo: number): string { + const d = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); + const pad = (n: number) => String(n).padStart(2, "0"); + const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; + return `coder-ssh-${ts}-abc123.log`; +} + describe("collectLogFiles", () => { it("collects from all three sources and skips subdirectories", async () => { const sshLog = path.join(tmpDir, "ssh.log"); await fs.writeFile(sshLog, "ssh"); + const recentLog = proxyLogName(1); const proxyDir = path.join(tmpDir, "proxy"); await fs.mkdir(proxyDir); - await fs.writeFile(path.join(proxyDir, "99.log"), "proxy"); + await fs.writeFile(path.join(proxyDir, recentLog), "proxy"); await fs.mkdir(path.join(proxyDir, "subdir")); const extDir = path.join(tmpDir, "ext"); @@ -45,12 +53,12 @@ describe("collectLogFiles", () => { expect(Object.keys(files).sort()).toEqual([ "vscode-logs/extension/Coder.log", - "vscode-logs/proxy/99.log", + `vscode-logs/proxy/${recentLog}`, "vscode-logs/remote-ssh/ssh.log", ]); - expect(Buffer.from(files["vscode-logs/proxy/99.log"]).toString()).toBe( - "proxy", - ); + expect( + Buffer.from(files[`vscode-logs/proxy/${recentLog}`]).toString(), + ).toBe("proxy"); }); it("returns empty when no sources are provided", async () => { @@ -58,6 +66,20 @@ describe("collectLogFiles", () => { expect(Object.keys(files)).toHaveLength(0); }); + it("filters proxy logs older than 3 days by filename timestamp", async () => { + const proxyDir = path.join(tmpDir, "proxy"); + await fs.mkdir(proxyDir); + + const recentLog = proxyLogName(1); + const oldLog = proxyLogName(5); + await fs.writeFile(path.join(proxyDir, recentLog), "recent"); + await fs.writeFile(path.join(proxyDir, oldLog), "old"); + + const files = await collectLogFiles({ proxyLogDir: proxyDir }, logger); + + expect(Object.keys(files)).toEqual([`vscode-logs/proxy/${recentLog}`]); + }); + it("skips missing or unreadable sources and collects the rest", async () => { const proxyDir = path.join(tmpDir, "proxy"); await fs.mkdir(proxyDir); From d5f5c429bb14881647ac47e8b51d7e619cc5d2ba Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 22 Apr 2026 11:21:12 +0000 Subject: [PATCH 3/5] refactor: use mtime for proxy log age filtering Replace filename-based timestamp parsing with stat mtime, consistent with the cleanup logic in sshProcess.ts. The stat call is already made to skip subdirectories, so this adds zero extra cost. --- src/core/supportBundleLogs.ts | 31 +++++------------------- test/unit/core/supportBundleLogs.test.ts | 29 +++++++++++----------- 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/src/core/supportBundleLogs.ts b/src/core/supportBundleLogs.ts index a402da3903..bdc1a25360 100644 --- a/src/core/supportBundleLogs.ts +++ b/src/core/supportBundleLogs.ts @@ -12,35 +12,17 @@ export interface LogSources { extensionLogDir?: string; } -// Matches proxy log filenames: coder-ssh-YYYYMMDD-HHMMSS-.log -const PROXY_LOG_DATE_RE = - /^coder-ssh-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-/; const PROXY_LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; -function isProxyLogRecent(filename: string, now: Date): boolean { - const match = PROXY_LOG_DATE_RE.exec(filename); - if (!match) { - return true; - } - const fileDate = new Date( - Number(match[1]), - Number(match[2]) - 1, - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - ); - return now.getTime() - fileDate.getTime() <= PROXY_LOG_MAX_AGE_MS; -} - /** Collect regular files from a directory into zip-ready entries. */ async function collectDirFiles( dirPath: string, zipPrefix: string, logger: Logger, - filter?: (filename: string) => boolean, + maxAgeMs?: number, ): Promise> { const files: Record = {}; + const now = Date.now(); let entries: string[]; try { @@ -53,15 +35,15 @@ async function collectDirFiles( } for (const entry of entries) { - if (filter && !filter(entry)) { - continue; - } const filePath = path.join(dirPath, entry); try { const stat = await fs.stat(filePath); if (!stat.isFile()) { continue; } + if (maxAgeMs !== undefined && now - stat.mtimeMs > maxAgeMs) { + continue; + } const content = await fs.readFile(filePath); files[`${zipPrefix}/${entry}`] = new Uint8Array(content); } catch (error) { @@ -95,14 +77,13 @@ export async function collectLogFiles( } if (sources.proxyLogDir) { - const now = new Date(); Object.assign( files, await collectDirFiles( sources.proxyLogDir, "vscode-logs/proxy", logger, - (name) => isProxyLogRecent(name, now), + PROXY_LOG_MAX_AGE_MS, ), ); } diff --git a/test/unit/core/supportBundleLogs.test.ts b/test/unit/core/supportBundleLogs.test.ts index d81b8afed0..e2018b2e35 100644 --- a/test/unit/core/supportBundleLogs.test.ts +++ b/test/unit/core/supportBundleLogs.test.ts @@ -20,11 +20,10 @@ afterEach(async () => { const logger = createMockLogger(); -function proxyLogName(daysAgo: number): string { - const d = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); - const pad = (n: number) => String(n).padStart(2, "0"); - const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; - return `coder-ssh-${ts}-abc123.log`; +/** Set a file's mtime to N days in the past. */ +async function setAge(filePath: string, daysAgo: number): Promise { + const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); + await fs.utimes(filePath, past, past); } describe("collectLogFiles", () => { @@ -32,10 +31,9 @@ describe("collectLogFiles", () => { const sshLog = path.join(tmpDir, "ssh.log"); await fs.writeFile(sshLog, "ssh"); - const recentLog = proxyLogName(1); const proxyDir = path.join(tmpDir, "proxy"); await fs.mkdir(proxyDir); - await fs.writeFile(path.join(proxyDir, recentLog), "proxy"); + await fs.writeFile(path.join(proxyDir, "coder-ssh-recent.log"), "proxy"); await fs.mkdir(path.join(proxyDir, "subdir")); const extDir = path.join(tmpDir, "ext"); @@ -53,11 +51,11 @@ describe("collectLogFiles", () => { expect(Object.keys(files).sort()).toEqual([ "vscode-logs/extension/Coder.log", - `vscode-logs/proxy/${recentLog}`, + "vscode-logs/proxy/coder-ssh-recent.log", "vscode-logs/remote-ssh/ssh.log", ]); expect( - Buffer.from(files[`vscode-logs/proxy/${recentLog}`]).toString(), + Buffer.from(files["vscode-logs/proxy/coder-ssh-recent.log"]).toString(), ).toBe("proxy"); }); @@ -66,18 +64,19 @@ describe("collectLogFiles", () => { expect(Object.keys(files)).toHaveLength(0); }); - it("filters proxy logs older than 3 days by filename timestamp", async () => { + it("filters proxy logs older than 3 days by mtime", async () => { const proxyDir = path.join(tmpDir, "proxy"); await fs.mkdir(proxyDir); - const recentLog = proxyLogName(1); - const oldLog = proxyLogName(5); - await fs.writeFile(path.join(proxyDir, recentLog), "recent"); - await fs.writeFile(path.join(proxyDir, oldLog), "old"); + const recentFile = path.join(proxyDir, "recent.log"); + const oldFile = path.join(proxyDir, "old.log"); + await fs.writeFile(recentFile, "recent"); + await fs.writeFile(oldFile, "old"); + await setAge(oldFile, 5); const files = await collectLogFiles({ proxyLogDir: proxyDir }, logger); - expect(Object.keys(files)).toEqual([`vscode-logs/proxy/${recentLog}`]); + expect(Object.keys(files)).toEqual(["vscode-logs/proxy/recent.log"]); }); it("skips missing or unreadable sources and collects the rest", async () => { From 5624bbf57e03fb7bf7c1b137109a059f03a3b138 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 22 Apr 2026 20:13:03 +0300 Subject: [PATCH 4/5] refactor: review feedback on VS Code logs appender - Switch fflate to async `unzip`/`zip` via `util.promisify` - Return `Map` instead of `Record<>` - Drop `zipPrefix` param; caller attaches the prefix - Apply the 3-day mtime filter to extension logs too (was proxy-only) - Unexport `collectLogFiles`; inline `vscodeBundlePath` - Parallelize stat+read inside `collectDirFiles` - Pass raw errors to `logger.warn`/`logger.error` (drop `toError().message`) - On merge failure, clean up the `-vscode.zip` sibling via `fs.rm({ force: true })` wrapped in try/catch so non-ENOENT errors are surfaced rather than masked - Reference the `-vscode.zip` path in the rename-failure warning so the user can still find the bundle Tests: fold collectLogFiles into appendVsCodeLogs, mtime-based "not touched" assertions, rename-failure + extension-log-age tests, gate chmod 0o000 to POSIX non-root, assert sibling cleanup on corrupt input. --- src/core/supportBundleLogs.ts | 157 +++++++++--------- test/unit/core/supportBundleLogs.test.ts | 195 ++++++++++++++++------- 2 files changed, 206 insertions(+), 146 deletions(-) diff --git a/src/core/supportBundleLogs.ts b/src/core/supportBundleLogs.ts index bdc1a25360..9eaa9aeb89 100644 --- a/src/core/supportBundleLogs.ts +++ b/src/core/supportBundleLogs.ts @@ -1,8 +1,8 @@ -import { unzipSync, zipSync } from "fflate"; +import { unzip, zip } from "fflate"; import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { promisify } from "node:util"; -import { toError } from "../error/errorUtils"; import { type Logger } from "../logging/logger"; import { renameWithRetry } from "../util"; @@ -12,101 +12,87 @@ export interface LogSources { extensionLogDir?: string; } -const PROXY_LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; +const LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; + +const unzipAsync = promisify(unzip); +const zipAsync = promisify(zip); -/** Collect regular files from a directory into zip-ready entries. */ async function collectDirFiles( dirPath: string, - zipPrefix: string, logger: Logger, - maxAgeMs?: number, -): Promise> { - const files: Record = {}; - const now = Date.now(); +): Promise> { + const results = new Map(); let entries: string[]; try { entries = await fs.readdir(dirPath); } catch (error) { - logger.warn( - `Could not read log directory ${dirPath}: ${toError(error).message}`, - ); - return files; + logger.warn(`Could not read log directory ${dirPath}`, error); + return results; } - for (const entry of entries) { - const filePath = path.join(dirPath, entry); - try { - const stat = await fs.stat(filePath); - if (!stat.isFile()) { - continue; + const cutoff = Date.now() - LOG_MAX_AGE_MS; + + await Promise.all( + entries.map(async (entry) => { + const filePath = path.join(dirPath, entry); + try { + const stat = await fs.stat(filePath); + if (!stat.isFile() || stat.mtimeMs < cutoff) { + return; + } + results.set(entry, await fs.readFile(filePath)); + } catch (error) { + logger.warn(`Could not read log file ${filePath}`, error); } - if (maxAgeMs !== undefined && now - stat.mtimeMs > maxAgeMs) { - continue; - } - const content = await fs.readFile(filePath); - files[`${zipPrefix}/${entry}`] = new Uint8Array(content); - } catch (error) { - logger.warn( - `Could not read log file ${filePath}: ${toError(error).message}`, - ); - } - } + }), + ); - return files; + return results; } /** * Gather log files from each source independently so a failure in one * does not affect the others. */ -export async function collectLogFiles( +async function collectLogFiles( sources: LogSources, logger: Logger, -): Promise> { - const files: Record = {}; +): Promise> { + const files = new Map(); if (sources.remoteSshLogPath) { try { - const content = await fs.readFile(sources.remoteSshLogPath); - const name = path.basename(sources.remoteSshLogPath); - files[`vscode-logs/remote-ssh/${name}`] = new Uint8Array(content); + files.set( + `vscode-logs/remote-ssh/${path.basename(sources.remoteSshLogPath)}`, + await fs.readFile(sources.remoteSshLogPath), + ); } catch (error) { - logger.warn(`Could not read Remote SSH log: ${toError(error).message}`); + logger.warn("Could not read Remote SSH log", error); } } if (sources.proxyLogDir) { - Object.assign( - files, - await collectDirFiles( - sources.proxyLogDir, - "vscode-logs/proxy", - logger, - PROXY_LOG_MAX_AGE_MS, - ), - ); + for (const [name, data] of await collectDirFiles( + sources.proxyLogDir, + logger, + )) { + files.set(`vscode-logs/proxy/${name}`, data); + } } if (sources.extensionLogDir) { - Object.assign( - files, - await collectDirFiles( - sources.extensionLogDir, - "vscode-logs/extension", - logger, - ), - ); + for (const [name, data] of await collectDirFiles( + sources.extensionLogDir, + logger, + )) { + files.set(`vscode-logs/extension/${name}`, data); + } } return files; } -function vscodeBundlePath(zipPath: string): string { - const { dir, name, ext } = path.parse(zipPath); - return path.join(dir, `${name}-vscode${ext}`); -} - /** * Best-effort: append VS Code logs to a support bundle zip. * Uses atomic rename to avoid corrupting the original bundle on failure. @@ -117,44 +103,47 @@ export async function appendVsCodeLogs( logger: Logger, ): Promise { const logFiles = await collectLogFiles(sources, logger); - const count = Object.keys(logFiles).length; - if (count === 0) { + if (logFiles.size === 0) { logger.info("No VS Code logs found to add to support bundle"); return; } - logger.info(`Adding ${count} VS Code log file(s) to support bundle`); + logger.info(`Adding ${logFiles.size} VS Code log file(s) to support bundle`); - let updatedData: Uint8Array; - try { - const existingData = new Uint8Array(await fs.readFile(zipPath)); - const entries = unzipSync(existingData); - Object.assign(entries, logFiles); - updatedData = zipSync(entries); - } catch (error) { - logger.error( - `Failed to add VS Code logs to support bundle: ${toError(error).message}`, - ); - return; - } + // Write to a named temporary path first so a failure at the rename step + // leaves the user with a properly named file containing VS Code logs. + const parsed = path.parse(zipPath); + const vscodeBundlePath = path.join( + parsed.dir, + `${parsed.name}-vscode${parsed.ext}`, + ); - // Write to a named temporary path first so a failure mid-write leaves - // the user with a properly named file containing VS Code logs. - const tmpPath = vscodeBundlePath(zipPath); try { - await fs.writeFile(tmpPath, updatedData); + const entries = await unzipAsync(await fs.readFile(zipPath)); + for (const [name, data] of logFiles) { + entries[name] = data; + } + const updated = await zipAsync(entries); + await fs.writeFile(vscodeBundlePath, updated); } catch (error) { - logger.error( - `Failed to write updated support bundle: ${toError(error).message}`, - ); + logger.error("Failed to add VS Code logs to support bundle", error); + try { + await fs.rm(vscodeBundlePath, { force: true }); + } catch (cleanupError) { + logger.warn( + `Could not clean up partial bundle at ${vscodeBundlePath}`, + cleanupError, + ); + } return; } try { - await renameWithRetry(fs.rename, tmpPath, zipPath); + await renameWithRetry(fs.rename, vscodeBundlePath, zipPath); } catch (error) { logger.warn( - `Could not replace original bundle, VS Code logs saved separately: ${toError(error).message}`, + `Could not replace original bundle; VS Code logs saved separately at ${vscodeBundlePath}`, + error, ); } } diff --git a/test/unit/core/supportBundleLogs.test.ts b/test/unit/core/supportBundleLogs.test.ts index e2018b2e35..c53a66878b 100644 --- a/test/unit/core/supportBundleLogs.test.ts +++ b/test/unit/core/supportBundleLogs.test.ts @@ -2,12 +2,24 @@ import { strToU8, unzipSync, zipSync } from "fflate"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { appendVsCodeLogs, collectLogFiles } from "@/core/supportBundleLogs"; +import { appendVsCodeLogs } from "@/core/supportBundleLogs"; +import { renameWithRetry } from "@/util"; import { createMockLogger } from "../../mocks/testHelpers"; +// Wrap renameWithRetry so individual tests can override it via +// mockRejectedValueOnce; by default it calls through to the real impl. +vi.mock("@/util", async () => { + const actual = await vi.importActual("@/util"); + return { ...actual, renameWithRetry: vi.fn(actual.renameWithRetry) }; +}); + +// chmod to 0o000 is a no-op as root and on Windows. +const canTestUnreadable = + process.getuid?.() !== 0 && process.platform !== "win32"; + let tmpDir: string; beforeEach(async () => { @@ -26,8 +38,35 @@ async function setAge(filePath: string, daysAgo: number): Promise { await fs.utimes(filePath, past, past); } -describe("collectLogFiles", () => { - it("collects from all three sources and skips subdirectories", async () => { +async function makeBundle(): Promise { + const zipPath = path.join(tmpDir, "coder-support-123.zip"); + await fs.writeFile( + zipPath, + zipSync({ "server/info.txt": strToU8("server data") }), + ); + return zipPath; +} + +async function readZip(zipPath: string): Promise> { + const entries = unzipSync(await fs.readFile(zipPath)); + return Object.fromEntries( + Object.entries(entries).map(([name, data]) => [ + name, + Buffer.from(data).toString(), + ]), + ); +} + +function vsCodeLogKeys(entries: Record): string[] { + return Object.keys(entries) + .filter((k) => k.startsWith("vscode-logs/")) + .sort(); +} + +describe("appendVsCodeLogs", () => { + it("merges logs from all three sources and skips subdirectories", async () => { + const zipPath = await makeBundle(); + const sshLog = path.join(tmpDir, "ssh.log"); await fs.writeFile(sshLog, "ssh"); @@ -40,7 +79,8 @@ describe("collectLogFiles", () => { await fs.mkdir(extDir); await fs.writeFile(path.join(extDir, "Coder.log"), "ext"); - const files = await collectLogFiles( + await appendVsCodeLogs( + zipPath, { remoteSshLogPath: sshLog, proxyLogDir: proxyDir, @@ -49,96 +89,127 @@ describe("collectLogFiles", () => { logger, ); - expect(Object.keys(files).sort()).toEqual([ + const entries = await readZip(zipPath); + expect(Object.keys(entries).sort()).toEqual([ + "server/info.txt", "vscode-logs/extension/Coder.log", "vscode-logs/proxy/coder-ssh-recent.log", "vscode-logs/remote-ssh/ssh.log", ]); - expect( - Buffer.from(files["vscode-logs/proxy/coder-ssh-recent.log"]).toString(), - ).toBe("proxy"); + expect(entries["server/info.txt"]).toBe("server data"); + expect(entries["vscode-logs/proxy/coder-ssh-recent.log"]).toBe("proxy"); }); - it("returns empty when no sources are provided", async () => { - const files = await collectLogFiles({}, logger); - expect(Object.keys(files)).toHaveLength(0); + it("does not touch the zip when no logs are found", async () => { + const zipPath = await makeBundle(); + const before = await fs.stat(zipPath); + + await appendVsCodeLogs(zipPath, {}, logger); + + expect((await fs.stat(zipPath)).mtimeMs).toBe(before.mtimeMs); }); it("filters proxy logs older than 3 days by mtime", async () => { + const zipPath = await makeBundle(); + const proxyDir = path.join(tmpDir, "proxy"); await fs.mkdir(proxyDir); + await fs.writeFile(path.join(proxyDir, "recent.log"), "recent"); + await fs.writeFile(path.join(proxyDir, "old.log"), "old"); + await setAge(path.join(proxyDir, "old.log"), 5); - const recentFile = path.join(proxyDir, "recent.log"); - const oldFile = path.join(proxyDir, "old.log"); - await fs.writeFile(recentFile, "recent"); - await fs.writeFile(oldFile, "old"); - await setAge(oldFile, 5); - - const files = await collectLogFiles({ proxyLogDir: proxyDir }, logger); + await appendVsCodeLogs(zipPath, { proxyLogDir: proxyDir }, logger); - expect(Object.keys(files)).toEqual(["vscode-logs/proxy/recent.log"]); + expect(vsCodeLogKeys(await readZip(zipPath))).toEqual([ + "vscode-logs/proxy/recent.log", + ]); }); - it("skips missing or unreadable sources and collects the rest", async () => { - const proxyDir = path.join(tmpDir, "proxy"); - await fs.mkdir(proxyDir); - await fs.writeFile(path.join(proxyDir, "good.log"), "ok"); - await fs.writeFile(path.join(proxyDir, "bad.log"), "secret"); - await fs.chmod(path.join(proxyDir, "bad.log"), 0o000); + it("filters extension logs older than 3 days by mtime", async () => { + const zipPath = await makeBundle(); - const files = await collectLogFiles( - { - remoteSshLogPath: path.join(tmpDir, "nonexistent.log"), - proxyLogDir: proxyDir, - extensionLogDir: path.join(tmpDir, "no-such-dir"), - }, - logger, - ); + const extDir = path.join(tmpDir, "ext"); + await fs.mkdir(extDir); + await fs.writeFile(path.join(extDir, "Coder-recent.log"), "recent"); + await fs.writeFile(path.join(extDir, "Coder-old.log"), "old"); + await setAge(path.join(extDir, "Coder-old.log"), 5); - expect(Object.keys(files)).toEqual(["vscode-logs/proxy/good.log"]); + await appendVsCodeLogs(zipPath, { extensionLogDir: extDir }, logger); - await fs.chmod(path.join(proxyDir, "bad.log"), 0o644); + expect(vsCodeLogKeys(await readZip(zipPath))).toEqual([ + "vscode-logs/extension/Coder-recent.log", + ]); }); -}); -describe("appendVsCodeLogs", () => { - let zipPath: string; - let originalZipBytes: Uint8Array; + it.runIf(canTestUnreadable)( + "skips missing or unreadable sources and includes the rest", + async () => { + const zipPath = await makeBundle(); + + const proxyDir = path.join(tmpDir, "proxy"); + await fs.mkdir(proxyDir); + await fs.writeFile(path.join(proxyDir, "good.log"), "ok"); + const badLog = path.join(proxyDir, "bad.log"); + await fs.writeFile(badLog, "secret"); + await fs.chmod(badLog, 0o000); + + try { + await appendVsCodeLogs( + zipPath, + { + remoteSshLogPath: path.join(tmpDir, "nonexistent.log"), + proxyLogDir: proxyDir, + extensionLogDir: path.join(tmpDir, "no-such-dir"), + }, + logger, + ); + + expect(vsCodeLogKeys(await readZip(zipPath))).toEqual([ + "vscode-logs/proxy/good.log", + ]); + } finally { + await fs.chmod(badLog, 0o644); + } + }, + ); + + it("keeps the -vscode.zip sibling when rename fails", async () => { + const zipPath = await makeBundle(); + const before = await fs.stat(zipPath); - beforeEach(async () => { - zipPath = path.join(tmpDir, "coder-support-123.zip"); - originalZipBytes = zipSync({ "server/info.txt": strToU8("server data") }); - await fs.writeFile(zipPath, originalZipBytes); - }); - - it("merges log files into the existing zip", async () => { - const logPath = path.join(tmpDir, "ssh.log"); - await fs.writeFile(logPath, "ssh content"); - - await appendVsCodeLogs(zipPath, { remoteSshLogPath: logPath }, logger); + const sshLog = path.join(tmpDir, "ssh.log"); + await fs.writeFile(sshLog, "ssh content"); - const zip = unzipSync(new Uint8Array(await fs.readFile(zipPath))); - expect(Buffer.from(zip["server/info.txt"]).toString()).toBe("server data"); - expect(Buffer.from(zip["vscode-logs/remote-ssh/ssh.log"]).toString()).toBe( - "ssh content", + vi.mocked(renameWithRetry).mockRejectedValueOnce( + new Error("simulated rename failure"), ); - }); - it("does not touch the zip when no logs are found", async () => { - await appendVsCodeLogs(zipPath, {}, logger); + await appendVsCodeLogs(zipPath, { remoteSshLogPath: sshLog }, logger); + + expect((await fs.stat(zipPath)).mtimeMs).toBe(before.mtimeMs); - const data = new Uint8Array(await fs.readFile(zipPath)); - expect(Buffer.from(data)).toEqual(Buffer.from(originalZipBytes)); + const siblingPath = path.join(tmpDir, "coder-support-123-vscode.zip"); + const entries = await readZip(siblingPath); + expect(Object.keys(entries).sort()).toEqual([ + "server/info.txt", + "vscode-logs/remote-ssh/ssh.log", + ]); + expect(entries["vscode-logs/remote-ssh/ssh.log"]).toBe("ssh content"); }); - it("leaves the original zip intact when it is corrupted", async () => { + it("leaves the original zip intact and cleans up the partial sibling when corrupted", async () => { + const zipPath = path.join(tmpDir, "coder-support-123.zip"); await fs.writeFile(zipPath, "not a zip"); + const before = await fs.stat(zipPath); const logPath = path.join(tmpDir, "ssh.log"); await fs.writeFile(logPath, "content"); await appendVsCodeLogs(zipPath, { remoteSshLogPath: logPath }, logger); - expect(await fs.readFile(zipPath, "utf-8")).toBe("not a zip"); + expect((await fs.stat(zipPath)).mtimeMs).toBe(before.mtimeMs); + expect(await fs.readdir(tmpDir)).not.toContain( + "coder-support-123-vscode.zip", + ); }); }); From fc0093ba7546f8479b753dcba0c34e35df43a1f7 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 27 Apr 2026 13:21:11 +0300 Subject: [PATCH 5/5] refactor: address review feedback on VS Code logs appender - Wrap appendVsCodeLogs body in a top-level try/catch so any unexpected failure cannot lose the original support bundle. - Document why LOG_MAX_AGE_MS is 3 days (not the 7-day rotation). - Strengthen unchanged-bundle assertions with Buffer.compare alongside mtime, since mtime alone can be unreliable across filesystems. - Add a stress test covering 60 files across two source directories. --- src/core/supportBundleLogs.ts | 77 +++++++++++++----------- test/unit/core/supportBundleLogs.test.ts | 51 ++++++++++++++-- 2 files changed, 88 insertions(+), 40 deletions(-) diff --git a/src/core/supportBundleLogs.ts b/src/core/supportBundleLogs.ts index 9eaa9aeb89..db8e7cbb2b 100644 --- a/src/core/supportBundleLogs.ts +++ b/src/core/supportBundleLogs.ts @@ -12,6 +12,8 @@ export interface LogSources { extensionLogDir?: string; } +// 3 days is enough context for recent issues; matching the 7-day +// rotation would bloat the bundle. const LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; const unzipAsync = promisify(unzip); @@ -102,48 +104,55 @@ export async function appendVsCodeLogs( sources: LogSources, logger: Logger, ): Promise { - const logFiles = await collectLogFiles(sources, logger); - if (logFiles.size === 0) { - logger.info("No VS Code logs found to add to support bundle"); - return; - } + try { + const logFiles = await collectLogFiles(sources, logger); + if (logFiles.size === 0) { + logger.info("No VS Code logs found to add to support bundle"); + return; + } - logger.info(`Adding ${logFiles.size} VS Code log file(s) to support bundle`); + logger.info( + `Adding ${logFiles.size} VS Code log file(s) to support bundle`, + ); - // Write to a named temporary path first so a failure at the rename step - // leaves the user with a properly named file containing VS Code logs. - const parsed = path.parse(zipPath); - const vscodeBundlePath = path.join( - parsed.dir, - `${parsed.name}-vscode${parsed.ext}`, - ); + // Write to a named temporary path first so a failure at the rename step + // leaves the user with a properly named file containing VS Code logs. + const parsed = path.parse(zipPath); + const vscodeBundlePath = path.join( + parsed.dir, + `${parsed.name}-vscode${parsed.ext}`, + ); - try { - const entries = await unzipAsync(await fs.readFile(zipPath)); - for (const [name, data] of logFiles) { - entries[name] = data; + try { + const entries = await unzipAsync(await fs.readFile(zipPath)); + for (const [name, data] of logFiles) { + entries[name] = data; + } + const updated = await zipAsync(entries); + await fs.writeFile(vscodeBundlePath, updated); + } catch (error) { + logger.error("Failed to add VS Code logs to support bundle", error); + try { + await fs.rm(vscodeBundlePath, { force: true }); + } catch (cleanupError) { + logger.warn( + `Could not clean up partial bundle at ${vscodeBundlePath}`, + cleanupError, + ); + } + return; } - const updated = await zipAsync(entries); - await fs.writeFile(vscodeBundlePath, updated); - } catch (error) { - logger.error("Failed to add VS Code logs to support bundle", error); + try { - await fs.rm(vscodeBundlePath, { force: true }); - } catch (cleanupError) { + await renameWithRetry(fs.rename, vscodeBundlePath, zipPath); + } catch (error) { logger.warn( - `Could not clean up partial bundle at ${vscodeBundlePath}`, - cleanupError, + `Could not replace original bundle; VS Code logs saved separately at ${vscodeBundlePath}`, + error, ); } - return; - } - - try { - await renameWithRetry(fs.rename, vscodeBundlePath, zipPath); } catch (error) { - logger.warn( - `Could not replace original bundle; VS Code logs saved separately at ${vscodeBundlePath}`, - error, - ); + // Best-effort: never let a failure here lose the user's bundle. + logger.error("Unexpected error appending VS Code logs", error); } } diff --git a/test/unit/core/supportBundleLogs.test.ts b/test/unit/core/supportBundleLogs.test.ts index c53a66878b..bdd7a1647c 100644 --- a/test/unit/core/supportBundleLogs.test.ts +++ b/test/unit/core/supportBundleLogs.test.ts @@ -102,11 +102,46 @@ describe("appendVsCodeLogs", () => { it("does not touch the zip when no logs are found", async () => { const zipPath = await makeBundle(); - const before = await fs.stat(zipPath); + const beforeStat = await fs.stat(zipPath); + const beforeBytes = await fs.readFile(zipPath); await appendVsCodeLogs(zipPath, {}, logger); - expect((await fs.stat(zipPath)).mtimeMs).toBe(before.mtimeMs); + expect((await fs.stat(zipPath)).mtimeMs).toBe(beforeStat.mtimeMs); + expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); + }); + + it("merges a large number of files without dropping any", async () => { + const zipPath = await makeBundle(); + + const proxyDir = path.join(tmpDir, "proxy"); + const extDir = path.join(tmpDir, "ext"); + await fs.mkdir(proxyDir); + await fs.mkdir(extDir); + + const fileCount = 60; + await Promise.all( + Array.from({ length: fileCount }, (_, i) => + Promise.all([ + fs.writeFile(path.join(proxyDir, `proxy-${i}.log`), `proxy-${i}`), + fs.writeFile(path.join(extDir, `ext-${i}.log`), `ext-${i}`), + ]), + ), + ); + + await appendVsCodeLogs( + zipPath, + { proxyLogDir: proxyDir, extensionLogDir: extDir }, + logger, + ); + + const entries = await readZip(zipPath); + const keys = vsCodeLogKeys(entries); + expect(keys).toHaveLength(fileCount * 2); + for (let i = 0; i < fileCount; i++) { + expect(entries[`vscode-logs/proxy/proxy-${i}.log`]).toBe(`proxy-${i}`); + expect(entries[`vscode-logs/extension/ext-${i}.log`]).toBe(`ext-${i}`); + } }); it("filters proxy logs older than 3 days by mtime", async () => { @@ -175,7 +210,8 @@ describe("appendVsCodeLogs", () => { it("keeps the -vscode.zip sibling when rename fails", async () => { const zipPath = await makeBundle(); - const before = await fs.stat(zipPath); + const beforeStat = await fs.stat(zipPath); + const beforeBytes = await fs.readFile(zipPath); const sshLog = path.join(tmpDir, "ssh.log"); await fs.writeFile(sshLog, "ssh content"); @@ -186,7 +222,8 @@ describe("appendVsCodeLogs", () => { await appendVsCodeLogs(zipPath, { remoteSshLogPath: sshLog }, logger); - expect((await fs.stat(zipPath)).mtimeMs).toBe(before.mtimeMs); + expect((await fs.stat(zipPath)).mtimeMs).toBe(beforeStat.mtimeMs); + expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); const siblingPath = path.join(tmpDir, "coder-support-123-vscode.zip"); const entries = await readZip(siblingPath); @@ -200,14 +237,16 @@ describe("appendVsCodeLogs", () => { it("leaves the original zip intact and cleans up the partial sibling when corrupted", async () => { const zipPath = path.join(tmpDir, "coder-support-123.zip"); await fs.writeFile(zipPath, "not a zip"); - const before = await fs.stat(zipPath); + const beforeStat = await fs.stat(zipPath); + const beforeBytes = await fs.readFile(zipPath); const logPath = path.join(tmpDir, "ssh.log"); await fs.writeFile(logPath, "content"); await appendVsCodeLogs(zipPath, { remoteSshLogPath: logPath }, logger); - expect((await fs.stat(zipPath)).mtimeMs).toBe(before.mtimeMs); + expect((await fs.stat(zipPath)).mtimeMs).toBe(beforeStat.mtimeMs); + expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); expect(await fs.readdir(tmpDir)).not.toContain( "coder-support-123-vscode.zip", );