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
4 changes: 2 additions & 2 deletions src/cli/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { reportRegistry } from "../../domain/reports/report_registry.ts";
import { ModelType } from "../../domain/models/model_type.ts";
import { ExtensionApiClient } from "../../infrastructure/http/extension_api_client.ts";
import { openBrowser } from "../../infrastructure/process/browser.ts";
import { registerShutdownHandler } from "../../infrastructure/process/shutdown_handlers.ts";
import {
handleOpenRequest,
type OpenServerState,
Expand Down Expand Up @@ -292,8 +293,7 @@ export const openCommand = new Command()
console.log(JSON.stringify({ status: "stopped" }));
}
};
Deno.addSignalListener("SIGINT", shutdown);
Deno.addSignalListener("SIGTERM", shutdown);
registerShutdownHandler({ handler: shutdown });

await server.finished;
if (state.repoContext) {
Expand Down
22 changes: 9 additions & 13 deletions src/cli/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { executeWorkflowWithLocks } from "../../serve/deps.ts";
import { getSwampLogger } from "../../infrastructure/logging/logger.ts";
import { ScheduledExecutionService } from "../../libswamp/mod.ts";
import { parseWebhookFlag, WebhookService } from "../../serve/webhook.ts";
import { registerShutdownHandler } from "../../infrastructure/process/shutdown_handlers.ts";

// deno-lint-ignore no-explicit-any
type AnyOptions = any;
Expand Down Expand Up @@ -318,19 +319,14 @@ export const serveCommand = new Command()
console.log(JSON.stringify({ status: "stopped" }));
}
};
Deno.addSignalListener("SIGINT", () => {
shutdown().catch((e) =>
logger.error("Shutdown error: {error}", {
error: e instanceof Error ? e.message : String(e),
})
);
});
Deno.addSignalListener("SIGTERM", () => {
shutdown().catch((e) =>
logger.error("Shutdown error: {error}", {
error: e instanceof Error ? e.message : String(e),
})
);
registerShutdownHandler({
handler: () => {
shutdown().catch((e) =>
logger.error("Shutdown error: {error}", {
error: e instanceof Error ? e.message : String(e),
})
);
},
});

await server.finished;
Expand Down
12 changes: 9 additions & 3 deletions src/cli/input_parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import { parse as parseYaml } from "@std/yaml";
import { UserError } from "../domain/errors.ts";
import { homeDirectory } from "../infrastructure/persistence/paths.ts";

// Re-export coerceInputTypes from domain layer for backward compatibility
export { coerceInputTypes } from "../domain/inputs/input_coercion.ts";
Expand Down Expand Up @@ -70,9 +71,14 @@ async function resolveFileValue(
): Promise<string> {
let resolvedPath = filePath;
if (resolvedPath.startsWith("~/")) {
const home = Deno.env.get("HOME");
if (home) {
resolvedPath = home + resolvedPath.slice(1);
// Try HOME (POSIX) then USERPROFILE (Windows). When neither is set,
// intentionally fall through with the literal `~/...` path so the
// downstream `Deno.readTextFile` produces a "file not found" error
// referencing the unexpanded path. Stream 0 pins this behavior.
try {
resolvedPath = homeDirectory() + resolvedPath.slice(1);
} catch {
// No home directory available — leave the path literal.
}
}
try {
Expand Down
47 changes: 26 additions & 21 deletions src/infrastructure/persistence/datastore_sync_coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ import {
import type { DistributedLock } from "../../domain/datastore/distributed_lock.ts";
import { getSwampLogger } from "../logging/logger.ts";
import { getTracer, SpanStatusCode } from "../tracing/mod.ts";
import {
registerShutdownHandler,
type ShutdownHandlerHandle,
} from "../process/shutdown_handlers.ts";
import { summarizeSyncError } from "./sync_error_diagnostic.ts";

/** Options for registering a datastore sync lifecycle. */
Expand Down Expand Up @@ -81,38 +85,39 @@ const PROGRESS_LOG_INTERVAL_MS = 30_000;

/** Map of all registered lock entries, keyed by lock name. */
const entries: Map<string, SyncEntry> = new Map();
let signalHandler: (() => void) | null = null;
let shutdownHandle: ShutdownHandlerHandle | null = null;

/**
* Installs or updates the SIGINT handler to release all held locks.
* Installs the SIGINT handler to release all held locks. SIGINT-only by
* design: this fast-path handler runs on Ctrl-C and `Deno.exit(130)`s
* after a 5s force-exit deadline. Long-form POSIX signals (SIGTERM,
* SIGHUP) are handled by command-level shutdown logic, not here.
*/
function installSignalHandler(): void {
if (signalHandler) return;
if (shutdownHandle) return;

signalHandler = () => {
const forceExit = setTimeout(() => Deno.exit(130), 5_000);
const releases = [...entries.values()]
.filter((e) => e.lock)
.map((e) => e.lock!.release().catch(() => {}));
Promise.all(releases).finally(() => {
clearTimeout(forceExit);
Deno.exit(130);
});
};
Deno.addSignalListener("SIGINT", signalHandler);
shutdownHandle = registerShutdownHandler({
handler: () => {
const forceExit = setTimeout(() => Deno.exit(130), 5_000);
const releases = [...entries.values()]
.filter((e) => e.lock)
.map((e) => e.lock!.release().catch(() => {}));
Promise.all(releases).finally(() => {
clearTimeout(forceExit);
Deno.exit(130);
});
},
includePosixSignals: false,
});
}

/**
* Removes the SIGINT handler if no entries remain.
*/
function maybeRemoveSignalHandler(): void {
if (entries.size > 0 || !signalHandler) return;
try {
Deno.removeSignalListener("SIGINT", signalHandler);
} catch {
// May already be removed
}
signalHandler = null;
if (entries.size > 0 || !shutdownHandle) return;
shutdownHandle.dispose();
shutdownHandle = null;
}

/**
Expand Down
22 changes: 22 additions & 0 deletions src/infrastructure/persistence/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,28 @@ export function toAbsolutePath(repoDir: string, relativePath: string): string {
return join(repoDir, relativePath);
}

/**
* Returns the current user's home directory in a cross-platform way.
*
* Reads `HOME` first (POSIX) and falls back to `USERPROFILE` (Windows).
* Throws when neither is set so callers fail loudly rather than building
* paths from `undefined`. Sites that intentionally tolerate a missing
* home (e.g., the `~/file` literal-pass-through in the input parser)
* must catch this error explicitly.
*
* @returns The absolute path to the user's home directory
* @throws Error when neither HOME nor USERPROFILE is set
*/
export function homeDirectory(): string {
const home = Deno.env.get("HOME") ?? Deno.env.get("USERPROFILE");
if (!home) {
throw new Error(
"Cannot determine home directory: neither HOME nor USERPROFILE is set",
);
}
return home;
}

/**
* Returns the user-level swamp data directory (`~/.swamp/`).
*
Expand Down
56 changes: 56 additions & 0 deletions src/infrastructure/persistence/paths_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
bundleNamespace,
getSwampConfigDir,
getSwampDataDir,
homeDirectory,
SWAMP_DATA_DIR,
SWAMP_MARKER_FILE,
SWAMP_SUBDIRS,
Expand Down Expand Up @@ -245,6 +246,61 @@ Deno.test("getSwampDataDir throws when neither HOME nor USERPROFILE set", () =>
}
});

Deno.test("homeDirectory: returns HOME when set", () => {
const originalHome = Deno.env.get("HOME");
const originalProfile = Deno.env.get("USERPROFILE");
try {
Deno.env.set("HOME", "/home/testuser");
// Set USERPROFILE too — HOME must take precedence on every OS so
// POSIX behavior is consistent regardless of stray Windows-style env
// vars in the inherited environment.
Deno.env.set("USERPROFILE", "C:\\Users\\other");
assertEquals(homeDirectory(), "/home/testuser");
} finally {
if (originalHome !== undefined) Deno.env.set("HOME", originalHome);
else Deno.env.delete("HOME");
if (originalProfile !== undefined) {
Deno.env.set("USERPROFILE", originalProfile);
} else Deno.env.delete("USERPROFILE");
}
});

Deno.test("homeDirectory: falls back to USERPROFILE when HOME is unset", () => {
const originalHome = Deno.env.get("HOME");
const originalProfile = Deno.env.get("USERPROFILE");
try {
Deno.env.delete("HOME");
Deno.env.set("USERPROFILE", "C:\\Users\\testuser");
assertEquals(homeDirectory(), "C:\\Users\\testuser");
} finally {
if (originalHome !== undefined) Deno.env.set("HOME", originalHome);
else Deno.env.delete("HOME");
if (originalProfile !== undefined) {
Deno.env.set("USERPROFILE", originalProfile);
} else Deno.env.delete("USERPROFILE");
}
});

Deno.test("homeDirectory: throws when neither HOME nor USERPROFILE is set", () => {
const originalHome = Deno.env.get("HOME");
const originalProfile = Deno.env.get("USERPROFILE");
try {
Deno.env.delete("HOME");
Deno.env.delete("USERPROFILE");
assertThrows(
() => homeDirectory(),
Error,
"Cannot determine home directory: neither HOME nor USERPROFILE is set",
);
} finally {
if (originalHome !== undefined) Deno.env.set("HOME", originalHome);
else Deno.env.delete("HOME");
if (originalProfile !== undefined) {
Deno.env.set("USERPROFILE", originalProfile);
} else Deno.env.delete("USERPROFILE");
}
});

Deno.test("bundleNamespace: same relative relationship produces same hash", () => {
// Simulates /var/... vs /private/var/... — different absolute prefixes,
// same relative relationship
Expand Down
108 changes: 108 additions & 0 deletions src/infrastructure/process/shutdown_handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Swamp, an Automation Framework
// Copyright (C) 2026 System Initiative, Inc.
//
// This file is part of Swamp.
//
// Swamp is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation, with the Swamp
// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
// file).
//
// Swamp is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

/**
* Cross-platform shutdown signal registration.
*
* Windows only supports `SIGINT` — registering `SIGTERM` or `SIGHUP`
* listeners on Windows throws. This helper centralizes the OS branching so
* each call site stays a single line and works on every platform that
* swamp supports.
*
* On POSIX, `SIGINT`, `SIGTERM`, and `SIGHUP` are all registered (unless
* the caller opts out of POSIX-only signals via `includePosixSignals: false`).
* On Windows, only `SIGINT` is registered.
*/

/** Options passed to {@link registerShutdownHandler}. */
export interface ShutdownHandlerOptions {
/**
* Handler invoked once per signal arrival. Errors thrown synchronously
* are not caught here; the caller is responsible for error handling
* inside the handler.
*/
handler: () => void | Promise<void>;
/**
* If true (default), also register `SIGTERM` and `SIGHUP` on POSIX
* platforms. Set to false for sites that only ever cared about `SIGINT`
* (e.g., the datastore sync coordinator's lock-release fast path).
* Has no effect on Windows where only `SIGINT` is registered regardless.
*/
includePosixSignals?: boolean;
}

/** Disposer returned by {@link registerShutdownHandler}. */
export interface ShutdownHandlerHandle {
/**
* Removes every signal listener that was registered. Idempotent —
* safe to call multiple times.
*/
dispose: () => void;
}

/**
* POSIX-only signals registered when `includePosixSignals` is true.
* Kept as a const tuple so callers see the exact set at a glance.
*/
const POSIX_ONLY_SIGNALS: readonly Deno.Signal[] = ["SIGTERM", "SIGHUP"];

/**
* Registers a shutdown handler against the appropriate OS signals.
*
* - Always registers `SIGINT` (cross-platform).
* - On non-Windows, also registers `SIGTERM` and `SIGHUP` unless
* `includePosixSignals` is explicitly false.
*
* Returns a disposer that calls `Deno.removeSignalListener` for every
* signal that was actually registered.
*/
export function registerShutdownHandler(
options: ShutdownHandlerOptions,
): ShutdownHandlerHandle {
const { handler, includePosixSignals = true } = options;

const registered: Deno.Signal[] = [];

// SIGINT is always safe — both POSIX and Windows support it.
Deno.addSignalListener("SIGINT", handler);
registered.push("SIGINT");

if (includePosixSignals && Deno.build.os !== "windows") {
for (const signal of POSIX_ONLY_SIGNALS) {
Deno.addSignalListener(signal, handler);
registered.push(signal);
}
}

let disposed = false;
return {
dispose: () => {
if (disposed) return;
disposed = true;
for (const signal of registered) {
try {
Deno.removeSignalListener(signal, handler);
} catch {
// Listener may have already been removed (e.g. if Deno's
// signal infrastructure tore it down). Best-effort cleanup.
}
}
},
};
}
Loading
Loading