Skip to content

Commit 128754a

Browse files
committed
Add proxy log file cleanup
Clean up old coder-ssh-*.log files when count exceeds 20 and files are older than 7 days. Uses mtime-based sorting to delete oldest stale files first.
1 parent bd700b6 commit 128754a

File tree

2 files changed

+197
-8
lines changed

2 files changed

+197
-8
lines changed

src/remote/sshProcess.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ export interface SshProcessMonitorOptions {
4040
}
4141

4242
// 1 hour cleanup threshold for old network info files
43-
const CLEANUP_MAX_AGE_MS = 60 * 60 * 1000;
43+
const CLEANUP_NETWORK_MAX_AGE_MS = 60 * 60 * 1000;
44+
// 7 day cleanup threshold for old proxy log files
45+
const CLEANUP_LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
46+
// Maximum number of proxy log files to keep during cleanup
47+
const CLEANUP_MAX_LOG_FILES = 20;
4448

4549
/**
4650
* Monitors the SSH process for a Coder workspace connection and displays
@@ -117,6 +121,61 @@ export class SshProcessMonitor implements vscode.Disposable {
117121
}
118122
}
119123

124+
/**
125+
* Cleans up old proxy log files when there are too many.
126+
* Deletes oldest stale files (by mtime) until count <= maxFilesToKeep.
127+
*/
128+
private static async cleanupOldLogFiles(
129+
logDir: string,
130+
maxFilesToKeep: number,
131+
maxAgeMs: number,
132+
logger: Logger,
133+
): Promise<void> {
134+
try {
135+
const now = Date.now();
136+
const files = await fs.readdir(logDir);
137+
138+
const withStats = await Promise.all(
139+
files
140+
.filter((f) => f.startsWith("coder-ssh") && f.endsWith(".log"))
141+
.map(async (name) => {
142+
try {
143+
const stats = await fs.stat(path.join(logDir, name));
144+
return { name, mtime: stats.mtime.getTime() };
145+
} catch {
146+
return null;
147+
}
148+
}),
149+
);
150+
151+
const toDelete = withStats
152+
.filter((f) => f !== null)
153+
.sort((a, b) => a.mtime - b.mtime) // oldest first
154+
.slice(0, -maxFilesToKeep) // keep the newest maxFilesToKeep
155+
.filter((f) => now - f.mtime > maxAgeMs); // only delete stale files
156+
157+
const deletedFiles: string[] = [];
158+
for (const file of toDelete) {
159+
try {
160+
await fs.unlink(path.join(logDir, file.name));
161+
deletedFiles.push(file.name);
162+
} catch (error) {
163+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
164+
logger.debug(`Failed to clean up log file ${file.name}`, error);
165+
}
166+
}
167+
}
168+
169+
if (deletedFiles.length > 0) {
170+
logger.debug(
171+
`Cleaned up ${deletedFiles.length} old log file(s): ${deletedFiles.join(", ")}`,
172+
);
173+
}
174+
} catch {
175+
// Directory may not exist yet, ignore
176+
}
177+
}
178+
120179
private constructor(options: SshProcessMonitorOptions) {
121180
this.options = {
122181
...options,
@@ -142,12 +201,24 @@ export class SshProcessMonitor implements vscode.Disposable {
142201
// Clean up old network info files (non-blocking, fire-and-forget)
143202
SshProcessMonitor.cleanupOldNetworkFiles(
144203
options.networkInfoPath,
145-
CLEANUP_MAX_AGE_MS,
204+
CLEANUP_NETWORK_MAX_AGE_MS,
146205
options.logger,
147206
).catch(() => {
148207
// Ignore cleanup errors - they shouldn't affect monitoring
149208
});
150209

210+
// Clean up old proxy log files (combined: count + age threshold)
211+
if (options.proxyLogDir) {
212+
SshProcessMonitor.cleanupOldLogFiles(
213+
options.proxyLogDir,
214+
CLEANUP_MAX_LOG_FILES,
215+
CLEANUP_LOG_MAX_AGE_MS,
216+
options.logger,
217+
).catch(() => {
218+
// Ignore cleanup errors - they shouldn't affect monitoring
219+
});
220+
}
221+
151222
monitor.searchForProcess().catch((err) => {
152223
options.logger.error("Error in SSH process monitor", err);
153224
});

test/unit/remote/sshProcess.test.ts

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -424,10 +424,12 @@ describe("SshProcessMonitor", () => {
424424
});
425425

426426
describe("cleanup old network files", () => {
427-
const setOldMtime = (filePath: string) => {
428-
// Default cleanup is 1 hour; set mtime to 2 hours ago to mark as old
429-
const TWO_HOURS_AGO = Date.now() - 2 * 60 * 60 * 1000;
430-
vol.utimesSync(filePath, TWO_HOURS_AGO / 1000, TWO_HOURS_AGO / 1000);
427+
// Network cleanup: 1 hour threshold
428+
const NETWORK_MAX_AGE_MS = 60 * 60 * 1000;
429+
430+
const setMtimeAgo = (filePath: string, ageMs: number) => {
431+
const mtime = (Date.now() - ageMs) / 1000;
432+
vol.utimesSync(filePath, mtime, mtime);
431433
};
432434

433435
it("deletes old .json files but preserves recent and non-.json files", async () => {
@@ -438,8 +440,8 @@ describe("SshProcessMonitor", () => {
438440
"/network/recent.json": "{}",
439441
"/network/old.log": "{}",
440442
});
441-
setOldMtime("/network/old.json");
442-
setOldMtime("/network/old.log");
443+
setMtimeAgo("/network/old.json", NETWORK_MAX_AGE_MS * 2);
444+
setMtimeAgo("/network/old.log", NETWORK_MAX_AGE_MS * 2);
443445

444446
createMonitor({
445447
codeLogDir: "/logs/window1",
@@ -477,6 +479,122 @@ describe("SshProcessMonitor", () => {
477479
});
478480
});
479481

482+
describe("cleanup proxy log files", () => {
483+
// Proxy log cleanup: 7 day threshold, 20 files max
484+
const LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
485+
const LOG_MAX_FILES = 20;
486+
487+
const setMtimeAgo = (filePath: string, ageMs: number) => {
488+
const mtime = (Date.now() - ageMs) / 1000;
489+
vol.utimesSync(filePath, mtime, mtime);
490+
};
491+
492+
const logFileName = (i: number) =>
493+
`coder-ssh-${i.toString().padStart(2, "0")}.log`;
494+
495+
const setupTest = (total: number, stale: number): string[] => {
496+
vol.fromJSON({
497+
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":
498+
"-> socksPort 12345 ->",
499+
});
500+
vol.mkdirSync("/proxy-logs", { recursive: true });
501+
502+
const files = Array.from({ length: total }, (_, i) => logFileName(i + 1));
503+
for (const name of files) {
504+
vol.writeFileSync(`/proxy-logs/${name}`, "");
505+
}
506+
for (let i = 0; i < stale; i++) {
507+
setMtimeAgo(`/proxy-logs/${files[i]}`, LOG_MAX_AGE_MS * 2);
508+
}
509+
return files;
510+
};
511+
512+
interface StaleLogTestCase {
513+
total: number;
514+
stale: number;
515+
expected: number;
516+
desc: string;
517+
}
518+
519+
it.each<StaleLogTestCase>([
520+
{ total: 25, stale: 8, expected: 20, desc: "Deletes until limit" },
521+
{ total: 25, stale: 3, expected: 22, desc: "Only deletes stale" },
522+
{ total: 25, stale: 0, expected: 25, desc: "Keeps recent files" },
523+
{ total: 15, stale: 5, expected: 15, desc: "Keeps under limit" },
524+
])(
525+
"$desc: $total files, $stale stale → $expected remaining",
526+
async ({ total, stale, expected }) => {
527+
setupTest(total, stale);
528+
529+
createMonitor({
530+
codeLogDir: "/logs/window1",
531+
proxyLogDir: "/proxy-logs",
532+
});
533+
534+
await vi.waitFor(() => {
535+
expect(vol.readdirSync("/proxy-logs")).toHaveLength(expected);
536+
});
537+
},
538+
);
539+
540+
it("only matches coder-ssh*.log files", async () => {
541+
const files = setupTest(25, 25);
542+
// Add non-matching files
543+
const nonMatchingFiles = [
544+
"other.log",
545+
"coder-ssh-config.json",
546+
"readme.txt",
547+
];
548+
for (const f of nonMatchingFiles) {
549+
const filePath = `/proxy-logs/${f}`;
550+
vol.writeFileSync(filePath, "");
551+
setMtimeAgo(filePath, LOG_MAX_AGE_MS * 2);
552+
}
553+
554+
createMonitor({
555+
codeLogDir: "/logs/window1",
556+
proxyLogDir: "/proxy-logs",
557+
});
558+
559+
await vi.waitFor(() => {
560+
expect(vol.readdirSync("/proxy-logs")).toHaveLength(
561+
LOG_MAX_FILES + nonMatchingFiles.length,
562+
);
563+
});
564+
565+
const remaining = vol.readdirSync("/proxy-logs") as string[];
566+
// Non-matching files preserved
567+
expect(remaining).toContain("other.log");
568+
expect(remaining).toContain("coder-ssh-config.json");
569+
expect(remaining).toContain("readme.txt");
570+
// Oldest matching files deleted
571+
expect(remaining).not.toContain(files[0]);
572+
expect(remaining).toContain(files[24]);
573+
});
574+
575+
it("does not throw when proxy log directory is missing or empty", () => {
576+
vol.fromJSON({
577+
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":
578+
"-> socksPort 12345 ->",
579+
});
580+
vol.mkdirSync("/empty-proxy-logs", { recursive: true });
581+
582+
expect(() =>
583+
createMonitor({
584+
codeLogDir: "/logs/window1",
585+
proxyLogDir: "/nonexistent-proxy-logs",
586+
}),
587+
).not.toThrow();
588+
589+
expect(() =>
590+
createMonitor({
591+
codeLogDir: "/logs/window1",
592+
proxyLogDir: "/empty-proxy-logs",
593+
}),
594+
).not.toThrow();
595+
});
596+
});
597+
480598
describe("missing file retry logic", () => {
481599
beforeEach(() => vi.useFakeTimers());
482600
afterEach(() => vi.useRealTimers());

0 commit comments

Comments
 (0)