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
18 changes: 16 additions & 2 deletions packages/cli/src/commands/mail-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,11 @@ export function xmlEscape(s: string): string {
.replace(/'/g, "'");
}

function buildPlist(agent: string, tpsBin: string, extraHookArgs: string[]): string {
/**
* Build the launchd plist for an agent's mail-watch daemon.
* Exported for unit testing (asserts ProcessType=Background + valid XML).
*/
export function buildPlist(agent: string, tpsBin: string, extraHookArgs: string[]): string {
const label = plistLabel(agent);
const stdout = join(logDir(), `mail-watch-${agent}.log`);
const stderr = join(logDir(), `mail-watch-${agent}.error.log`);
Expand Down Expand Up @@ -309,8 +313,18 @@ ${progArgs}
<true/>
</dict>

<!--
ProcessType=Background tells launchd this is a long-lived background daemon.
Without it, the watcher's idle poll timer (waking every ~15s) gets the job
power-classified as "inefficient" and macOS reaps it with a clean exit 0 —
which KeepAlive(Crashed:true) does NOT restart, so the agent goes silently
deaf. Background processing type opts the job out of that idle-reap. (ops-bayh)
-->
<key>ProcessType</key>
<string>Background</string>

<key>ThrottleInterval</key>
<integer>5</integer>
<integer>10</integer>

<key>StandardOutPath</key>
<string>${xmlEscape(stdout)}</string>
Expand Down
41 changes: 40 additions & 1 deletion packages/cli/test/mail-watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdirSync, rmSync, writeFileSync, renameSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { validateAgentId, watchMail, xmlEscape } from "../src/commands/mail-watch.js";
import { buildPlist, validateAgentId, watchMail, xmlEscape } from "../src/commands/mail-watch.js";
import { execFileSync } from "node:child_process";
import { platform } from "node:os";

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -288,6 +290,43 @@ describe("xmlEscape", () => {
});
});

// ---------------------------------------------------------------------------
// buildPlist — generated launchd plist shape (ops-bayh: idle-reap immunity)
// ---------------------------------------------------------------------------

describe("buildPlist", () => {
it("sets ProcessType=Background so macOS does not idle-reap the watcher", () => {
const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []);
expect(xml).toContain("<key>ProcessType</key>");
// The <string> follows the <key> on the next line — assert the pairing.
expect(xml).toMatch(/<key>ProcessType<\/key>\s*<string>Background<\/string>/);
});

it("sets a non-zero ThrottleInterval", () => {
const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []);
expect(xml).toMatch(/<key>ThrottleInterval<\/key>\s*<integer>10<\/integer>/);
});

it("keeps KeepAlive(Crashed:true) so a real crash still restarts", () => {
const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []);
expect(xml).toMatch(/<key>KeepAlive<\/key>\s*<dict>\s*<key>Crashed<\/key>\s*<true\/>/);
});

it("generates plist that passes plutil -lint (valid XML, macOS only)", () => {
if (platform() !== "darwin") return; // plutil is macOS-only
const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", ["arg with spaces & <special>"]);
const tmpFile = join(tmpdir(), `buildplist-lint-${Date.now()}.plist`);
writeFileSync(tmpFile, xml);
try {
// Throws (non-zero exit) if the plist is malformed.
const out = execFileSync("plutil", ["-lint", tmpFile], { encoding: "utf-8" });
expect(out).toContain("OK");
} finally {
rmSync(tmpFile, { force: true });
}
});
});

// ---------------------------------------------------------------------------
// Daemon install (macOS only — validates args without actually calling launchctl)
// ---------------------------------------------------------------------------
Expand Down
Loading