fix: repair broken IPC wiring and cut the cost of repository polling
Three handlers referenced a dependency they were never given, which made them throw a ReferenceError as soon as they ran: - deployment:preflight for Gitea Actions profiles (`preflight` was passed to registerOperationsIpc but not to registerDeploymentIpc) - Unraid write-access repair (`safeRelativeRemoteFile` was missing from createUnraidAccessMethods) - a dead reference of the same name in unraid-state-methods no-undef and no-unused-vars were disabled for every file, which is why none of these were caught. Both are now enabled for src/main and src/shared, where the dependency graph is explicit. The renderer keeps them off because its functions are deliberately cross-script globals. Performance: - git.status() spawned three processes (rev-parse, status, remote get-url) per call. A directory holding its own .git is by definition the work tree root, so rev-parse is unnecessary, and the remote URL is cached against the mtime of .git/config, including the failure for a repository without that remote. - git status runs with --no-optional-locks so a read no longer rewrites the index. That stops it fighting a concurrent Git command for the index lock, and is what makes filesystem watching viable at all. - One commit issued four `git status` reads; callers that already hold the status now pass it on, leaving two. - The repository monitor is event driven. A watched repository is read on filesystem activity, with a 30s safety net for watchers that stop delivering and a 1s floor so a busy tree cannot drive a read per event. Repositories that cannot be watched keep using the interval. Idle cost for one repository over 35s: 24 git processes before, 3 after. - Resolving one repository by name no longer refreshes the whole workspace. - Concurrent configuration saves share a single write of the latest state. - Repository discovery follows directory junctions again. The filter that skipped them made the realpath cycle guard dead code, and hid any project folder reached through a junction. Renderer: - render() replaced the whole shell on every poll, discarding focus, caret and scroll position while the user was typing. Those are preserved now, and an unchanged render leaves the DOM alone entirely. - The four sections that enhanceRenderedUi() injected after render moved into the views, so the rendered markup is the single source of truth. - The monitor no longer keeps a repository paused forever when it is unlinked mid-mutation, scheduleAutoRefresh honours its delay argument, the demo bridges no longer block startup, and #app is no longer an aria-live region announcing the entire UI on every render. IPC channel plumbing moved to src/main/ipc/channel.cjs, replacing a module-level mutable diagnostics singleton with an argument. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cf1da8a2fa
commit
9260d35957
@@ -56,6 +56,8 @@ class ConfigStore {
|
||||
this.sessionToken = null;
|
||||
this.data = structuredClone(DEFAULT_CONFIG);
|
||||
this.saveQueue = Promise.resolve();
|
||||
this.pendingSave = null;
|
||||
this.lastWrittenSnapshot = null;
|
||||
}
|
||||
|
||||
migrate(parsed) {
|
||||
@@ -138,16 +140,27 @@ class ConfigStore {
|
||||
}
|
||||
|
||||
async save() {
|
||||
const snapshot = JSON.stringify(this.data, null, 2);
|
||||
// Several callers persist in quick succession (a server scan writes deployment
|
||||
// state per workload). Serializing the configuration once per call is the
|
||||
// expensive part, so saves that are still queued share a single write of the
|
||||
// latest data. That is equivalent because every caller asks for "persist the
|
||||
// current configuration", not "persist the snapshot I saw".
|
||||
if (this.pendingSave) return this.pendingSave;
|
||||
const operation = async () => {
|
||||
this.pendingSave = null;
|
||||
const snapshot = JSON.stringify(this.data, null, 2);
|
||||
if (snapshot === this.lastWrittenSnapshot
|
||||
&& await fs.access(this.filePath).then(() => true).catch(() => false)) return;
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
||||
await fs.writeFile(temporary, snapshot, { mode: 0o600 });
|
||||
await fs.rename(temporary, this.filePath);
|
||||
try { await fs.chmod(this.filePath, 0o600); } catch {}
|
||||
this.lastWrittenSnapshot = snapshot;
|
||||
};
|
||||
this.saveQueue = this.saveQueue.then(operation, operation);
|
||||
return this.saveQueue;
|
||||
this.pendingSave = this.saveQueue.then(operation, operation);
|
||||
this.saveQueue = this.pendingSave.catch(() => {});
|
||||
return this.pendingSave;
|
||||
}
|
||||
|
||||
getGitValidatorState(fullName) {
|
||||
|
||||
@@ -36,10 +36,6 @@ function isRunningStatus(value) {
|
||||
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
|
||||
}
|
||||
|
||||
function isQueuedStatus(value) {
|
||||
return ['pending', 'queued', 'waiting', 'blocked', 'requested'].includes(String(value || '').toLowerCase());
|
||||
}
|
||||
|
||||
class DeploymentService {
|
||||
constructor(store, giteaService, gitService, diagnostics = null) {
|
||||
this.store = store;
|
||||
|
||||
+66
-11
@@ -29,6 +29,13 @@ function parseUnifiedDiff(diffText) {
|
||||
}
|
||||
|
||||
class GitService {
|
||||
constructor() {
|
||||
// `git remote get-url` is only re-run when the repository configuration file
|
||||
// itself changed. Status polling asks for the remote URL of every repository
|
||||
// every few seconds, and on Windows the child process dominates that cost.
|
||||
this.remoteUrlCache = new Map();
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
try {
|
||||
const result = await run('git', ['--version'], { timeout: 10_000 });
|
||||
@@ -42,13 +49,24 @@ class GitService {
|
||||
const resolved = assertSafeRepositoryPath(repoPath);
|
||||
const stat = await fs.stat(resolved).catch(() => null);
|
||||
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
|
||||
// A directory that carries its own `.git` entry is by definition the top level
|
||||
// of that working tree, for plain repositories as well as for submodules and
|
||||
// linked worktrees where `.git` is a file. Spawning `git rev-parse` to learn
|
||||
// that again is pure overhead, and every status poll passes an already
|
||||
// resolved repository root back in.
|
||||
const marker = await fs.stat(path.join(resolved, '.git')).catch(() => null);
|
||||
if (marker) return resolved;
|
||||
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
|
||||
return path.resolve(result.stdout.trim());
|
||||
}
|
||||
|
||||
async status(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const result = await run('git', ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
|
||||
// `--no-optional-locks` keeps a status read from refreshing and rewriting the
|
||||
// index. Without it every read writes inside .git, which both fights a
|
||||
// concurrent Git command for the index lock and retriggers the filesystem
|
||||
// watcher that asked for this read in the first place.
|
||||
const result = await run('git', ['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
|
||||
cwd: root,
|
||||
timeout: 30_000
|
||||
});
|
||||
@@ -66,9 +84,34 @@ class GitService {
|
||||
});
|
||||
}
|
||||
|
||||
remoteUrlCacheKey(repoPath, remote) {
|
||||
return JSON.stringify([path.resolve(repoPath), remote]);
|
||||
}
|
||||
|
||||
async getRemoteUrl(repoPath, remote = 'origin') {
|
||||
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
|
||||
return result.stdout.trim();
|
||||
const cacheKey = this.remoteUrlCacheKey(repoPath, remote);
|
||||
const config = await fs.stat(path.join(repoPath, '.git', 'config')).catch(() => null);
|
||||
const cached = this.remoteUrlCache.get(cacheKey);
|
||||
if (config && cached && cached.mtimeMs === config.mtimeMs && cached.size === config.size) {
|
||||
if (cached.error) throw cached.error;
|
||||
return cached.url;
|
||||
}
|
||||
const remember = (entry) => {
|
||||
if (config) this.remoteUrlCache.set(cacheKey, { ...entry, mtimeMs: config.mtimeMs, size: config.size });
|
||||
else this.remoteUrlCache.delete(cacheKey);
|
||||
};
|
||||
try {
|
||||
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
|
||||
const url = result.stdout.trim();
|
||||
remember({ url, error: null });
|
||||
return url;
|
||||
} catch (error) {
|
||||
// A repository that has no such remote keeps failing until its configuration
|
||||
// changes, so the failure is remembered too. Without this, every status poll
|
||||
// of an unmatched local repository spawns a child process that cannot succeed.
|
||||
remember({ url: '', error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +322,7 @@ class GitService {
|
||||
const name = String(remote || 'origin').trim();
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
|
||||
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
|
||||
this.remoteUrlCache.delete(this.remoteUrlCacheKey(root, name));
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
@@ -354,8 +398,7 @@ class GitService {
|
||||
return { selected, matches };
|
||||
}
|
||||
|
||||
async expandSelectedPaths(root, files, { unstagedOnly = false } = {}) {
|
||||
const status = await this.status(root);
|
||||
expandStatusPaths(status, files, { unstagedOnly = false } = {}) {
|
||||
const { selected, matches } = this.selectedStatusFiles(status, files);
|
||||
if (!selected.length) return [];
|
||||
const expanded = new Set();
|
||||
@@ -367,12 +410,17 @@ class GitService {
|
||||
return [...expanded];
|
||||
}
|
||||
|
||||
async stage(repoPath, files) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
async expandSelectedPaths(root, files, options = {}) {
|
||||
return this.expandStatusPaths(await this.status(root), files, options);
|
||||
}
|
||||
|
||||
// Callers that already read the status pass it in. Reading it again costs a
|
||||
// child process, and a commit used to pay for four of them.
|
||||
async applyStage(root, files, knownStatus = null) {
|
||||
const requested = assertRepositoryRelativePaths(files);
|
||||
if (!requested.length) {
|
||||
await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 });
|
||||
return this.status(root);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only stage records that still have a worktree-side change. Re-running
|
||||
@@ -380,10 +428,16 @@ class GitService {
|
||||
// Git fail with "pathspec did not match any files" because the file no
|
||||
// longer exists in either the worktree or HEAD. Staged-only deletions and
|
||||
// renames are already ready for commit and must therefore be left alone.
|
||||
const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true });
|
||||
const status = knownStatus || await this.status(root);
|
||||
const selected = this.expandStatusPaths(status, requested, { unstagedOnly: true });
|
||||
if (selected.length) {
|
||||
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
|
||||
}
|
||||
}
|
||||
|
||||
async stage(repoPath, files) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
await this.applyStage(root, files);
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
@@ -403,8 +457,9 @@ class GitService {
|
||||
|
||||
async prepareSelectedStage(root, files) {
|
||||
const selected = assertRepositoryRelativePaths(files);
|
||||
let current = null;
|
||||
if (selected.length) {
|
||||
const current = await this.status(root);
|
||||
current = await this.status(root);
|
||||
const excludedStaged = current.files
|
||||
.filter((file) => file.staged)
|
||||
.filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath)))
|
||||
@@ -413,7 +468,7 @@ class GitService {
|
||||
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
|
||||
}
|
||||
}
|
||||
await this.stage(root, selected);
|
||||
await this.applyStage(root, selected, current);
|
||||
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
|
||||
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
|
||||
return selected;
|
||||
|
||||
+14
-68
@@ -1,13 +1,17 @@
|
||||
"use strict";
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs/promises");
|
||||
const { fileURLToPath } = require("node:url");
|
||||
const { ipcMain, dialog, shell, app } = require("electron");
|
||||
const { dialog, shell, app } = require("electron");
|
||||
const { matchRemoteToRepository } = require("../shared/repository-match.cjs");
|
||||
const {
|
||||
cloneDirectoryName,
|
||||
resolveCloneTarget,
|
||||
} = require("../shared/clone-target.cjs");
|
||||
const {
|
||||
createChannelRegistrar,
|
||||
assertTrustedSender,
|
||||
toErrorPayload,
|
||||
} = require("./ipc/channel.cjs");
|
||||
const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs");
|
||||
const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs");
|
||||
const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs");
|
||||
@@ -16,66 +20,6 @@ const {
|
||||
readEncryptedBackup,
|
||||
} = require("./configuration-backup.cjs");
|
||||
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
|
||||
let diagnosticsService = null;
|
||||
const TRUSTED_RENDERER_PATH = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"renderer",
|
||||
"index.html",
|
||||
);
|
||||
|
||||
function toErrorPayload(error) {
|
||||
return {
|
||||
message: error?.message || "Unknown error",
|
||||
code: error?.code || null,
|
||||
status: error?.status || null,
|
||||
recoverable: Boolean(error?.recoverable),
|
||||
commitSha: error?.commitSha || null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertTrustedSender(event) {
|
||||
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || "";
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "file:") throw new Error("not a file URL");
|
||||
const senderPath = path.resolve(fileURLToPath(parsed));
|
||||
const normalize = (value) =>
|
||||
process.platform === "win32" ? value.toLowerCase() : value;
|
||||
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH))
|
||||
throw new Error("unexpected renderer file");
|
||||
} catch {
|
||||
throw new Error("Rejected IPC request from an untrusted renderer origin.");
|
||||
}
|
||||
}
|
||||
|
||||
function register(channel, handler) {
|
||||
ipcMain.handle(channel, async (event, payload) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
assertTrustedSender(event);
|
||||
const data = await handler(payload || {}, event);
|
||||
await diagnosticsService?.debug("ipc.completed", {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
await diagnosticsService?.error("ipc.failed", {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message,
|
||||
code: error?.code,
|
||||
status: error?.status,
|
||||
stack: error?.stack,
|
||||
},
|
||||
});
|
||||
return { ok: false, error: toErrorPayload(error) };
|
||||
}
|
||||
});
|
||||
}
|
||||
function registerIpc({
|
||||
store,
|
||||
git,
|
||||
@@ -95,7 +39,7 @@ function registerIpc({
|
||||
monitor,
|
||||
onPreferencesChanged,
|
||||
}) {
|
||||
diagnosticsService = diagnostics;
|
||||
const register = createChannelRegistrar(diagnostics);
|
||||
const repositoryMutations = new Map();
|
||||
const withRepositoryPause = async (localPath, action) => {
|
||||
monitor?.pause(localPath);
|
||||
@@ -161,8 +105,11 @@ function registerIpc({
|
||||
await repositories.refresh();
|
||||
knownPaths = repositories.getWatchPaths();
|
||||
}
|
||||
const canonicalKnown = await Promise.all(knownPaths.map(canonicalPath));
|
||||
if (!canonicalKnown.some((known) => known === candidate))
|
||||
// Watch paths are already canonical, so re-resolving all of them on every
|
||||
// guarded call is only needed when the cheap comparison finds no match.
|
||||
const matched = knownPaths.some((known) => path.resolve(known) === candidate)
|
||||
|| (await Promise.all(knownPaths.map(canonicalPath))).some((known) => known === candidate);
|
||||
if (!matched)
|
||||
throw new Error(
|
||||
"The requested local repository is not linked or discovered by ForgeFlow.",
|
||||
);
|
||||
@@ -172,9 +119,7 @@ function registerIpc({
|
||||
const resolveRepository = async (repositoryPayload) => {
|
||||
const fullName = String(repositoryPayload?.fullName || "").trim();
|
||||
if (!fullName) throw new Error("Repository identity is required.");
|
||||
const current = (await repositories.refresh()).find(
|
||||
(item) => item.fullName === fullName,
|
||||
);
|
||||
const current = await repositories.resolveByFullName(fullName);
|
||||
if (!current)
|
||||
throw new Error(
|
||||
"The repository is no longer available through the configured Gitea account.",
|
||||
@@ -732,6 +677,7 @@ function registerIpc({
|
||||
registerDeploymentIpc({
|
||||
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
||||
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
||||
preflight,
|
||||
});
|
||||
registerOperationsIpc({
|
||||
register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const { fileURLToPath } = require("node:url");
|
||||
const { ipcMain } = require("electron");
|
||||
|
||||
const TRUSTED_RENDERER_PATH = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"renderer",
|
||||
"index.html",
|
||||
);
|
||||
|
||||
function toErrorPayload(error) {
|
||||
return {
|
||||
message: error?.message || "Unknown error",
|
||||
code: error?.code || null,
|
||||
status: error?.status || null,
|
||||
recoverable: Boolean(error?.recoverable),
|
||||
commitSha: error?.commitSha || null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertTrustedSender(event) {
|
||||
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || "";
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "file:") throw new Error("not a file URL");
|
||||
const senderPath = path.resolve(fileURLToPath(parsed));
|
||||
const normalize = (value) =>
|
||||
process.platform === "win32" ? value.toLowerCase() : value;
|
||||
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH))
|
||||
throw new Error("unexpected renderer file");
|
||||
} catch {
|
||||
throw new Error("Rejected IPC request from an untrusted renderer origin.");
|
||||
}
|
||||
}
|
||||
|
||||
// Built per registerIpc() call so the diagnostics sink is an argument instead of
|
||||
// module-level mutable state that every handler silently depends on.
|
||||
function createChannelRegistrar(diagnostics) {
|
||||
return function register(channel, handler) {
|
||||
ipcMain.handle(channel, async (event, payload) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
assertTrustedSender(event);
|
||||
const data = await handler(payload || {}, event);
|
||||
await diagnostics?.debug("ipc.completed", {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
await diagnostics?.error("ipc.failed", {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
error: {
|
||||
name: error?.name,
|
||||
message: error?.message,
|
||||
code: error?.code,
|
||||
status: error?.status,
|
||||
stack: error?.stack,
|
||||
},
|
||||
});
|
||||
return { ok: false, error: toErrorPayload(error) };
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createChannelRegistrar,
|
||||
assertTrustedSender,
|
||||
toErrorPayload,
|
||||
TRUSTED_RENDERER_PATH,
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
function registerDeploymentIpc({
|
||||
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
||||
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
||||
preflight,
|
||||
}) {
|
||||
register("deployment:save-profile", async ({ fullName, profile }) => {
|
||||
const saved = await store.saveDeploymentProfile(fullName, profile);
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
|
||||
// A watched repository is only re-read when the filesystem reports activity. The
|
||||
// interval below stays as a safety net for watchers that silently stop
|
||||
// delivering, which happens on network shares and removed folders.
|
||||
const SAFETY_CHECK_INTERVAL_MS = 30_000;
|
||||
const WATCH_DEBOUNCE_MS = 250;
|
||||
// Busy trees (a build, an install, a fetch) produce a continuous event stream.
|
||||
// This bounds how often that can turn into a Git read.
|
||||
const MIN_WATCH_CHECK_INTERVAL_MS = 1_000;
|
||||
|
||||
class RepositoryMonitor {
|
||||
constructor({ store, git, onChange, diagnostics = null }) {
|
||||
this.store = store;
|
||||
@@ -11,13 +22,87 @@ class RepositoryMonitor {
|
||||
this.timer = null;
|
||||
this.running = false;
|
||||
this.paused = new Set();
|
||||
this.active = false;
|
||||
this.watchers = new Map();
|
||||
this.changed = new Set();
|
||||
this.lastCheckedAt = new Map();
|
||||
this.watchTimer = null;
|
||||
}
|
||||
|
||||
setPaths(paths) {
|
||||
this.paths = [...new Set((paths || []).filter(Boolean))];
|
||||
const watched = new Set(this.paths);
|
||||
for (const existing of [...this.fingerprints.keys()]) {
|
||||
if (!this.paths.includes(existing)) this.fingerprints.delete(existing);
|
||||
if (!watched.has(existing)) this.fingerprints.delete(existing);
|
||||
}
|
||||
// A repository that is unlinked while a mutation holds it paused would keep
|
||||
// that pause forever, silently freezing its status once it is watched again.
|
||||
for (const existing of [...this.paused]) {
|
||||
if (!watched.has(existing)) this.paused.delete(existing);
|
||||
}
|
||||
for (const existing of [...this.changed]) {
|
||||
if (!watched.has(existing)) this.changed.delete(existing);
|
||||
}
|
||||
for (const existing of [...this.lastCheckedAt.keys()]) {
|
||||
if (!watched.has(existing)) this.lastCheckedAt.delete(existing);
|
||||
}
|
||||
this.syncWatchers();
|
||||
}
|
||||
|
||||
syncWatchers() {
|
||||
for (const [localPath, watcher] of [...this.watchers]) {
|
||||
if (this.active && this.paths.includes(localPath)) continue;
|
||||
this.closeWatcher(localPath, watcher);
|
||||
}
|
||||
if (!this.active) return;
|
||||
for (const localPath of this.paths) {
|
||||
if (this.watchers.has(localPath)) continue;
|
||||
try {
|
||||
const watcher = fs.watch(
|
||||
localPath,
|
||||
{ recursive: true, persistent: false },
|
||||
() => this.noteFilesystemChange(localPath)
|
||||
);
|
||||
watcher.on('error', () => this.dropWatcher(localPath));
|
||||
this.watchers.set(localPath, watcher);
|
||||
} catch {
|
||||
// Watching is unavailable for this folder. Leaving it unwatched makes
|
||||
// shouldCheck() fall back to the interval for that repository only.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeWatcher(localPath, watcher = this.watchers.get(localPath)) {
|
||||
if (!watcher) return;
|
||||
try { watcher.close(); } catch { /* already closed */ }
|
||||
this.watchers.delete(localPath);
|
||||
}
|
||||
|
||||
dropWatcher(localPath) {
|
||||
this.closeWatcher(localPath);
|
||||
this.changed.add(localPath);
|
||||
}
|
||||
|
||||
noteFilesystemChange(localPath) {
|
||||
this.changed.add(localPath);
|
||||
this.scheduleWatchTick();
|
||||
}
|
||||
|
||||
scheduleWatchTick() {
|
||||
if (this.watchTimer) return;
|
||||
this.watchTimer = setTimeout(() => {
|
||||
this.watchTimer = null;
|
||||
this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error));
|
||||
}, WATCH_DEBOUNCE_MS);
|
||||
this.watchTimer.unref?.();
|
||||
}
|
||||
|
||||
shouldCheck(localPath, now) {
|
||||
if (this.paused.has(localPath)) return false;
|
||||
if (!this.watchers.has(localPath)) return true;
|
||||
const sinceLastCheck = now - (this.lastCheckedAt.get(localPath) || 0);
|
||||
if (this.changed.has(localPath)) return sinceLastCheck >= MIN_WATCH_CHECK_INTERVAL_MS;
|
||||
return sinceLastCheck >= SAFETY_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
pause(localPath) { if (localPath) this.paused.add(localPath); }
|
||||
@@ -26,6 +111,8 @@ class RepositoryMonitor {
|
||||
restart() {
|
||||
this.stop();
|
||||
if (!this.store.data.preferences.autoRefresh) return;
|
||||
this.active = true;
|
||||
this.syncWatchers();
|
||||
const seconds = Math.min(Math.max(Number(this.store.data.preferences.repositoryPollSeconds) || 4, 2), 60);
|
||||
this.timer = setInterval(() => this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error)), seconds * 1000);
|
||||
this.timer.unref?.();
|
||||
@@ -34,17 +121,23 @@ class RepositoryMonitor {
|
||||
stop() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
if (this.watchTimer) clearTimeout(this.watchTimer);
|
||||
this.watchTimer = null;
|
||||
this.active = false;
|
||||
this.syncWatchers();
|
||||
}
|
||||
|
||||
async tick() {
|
||||
if (this.running || !this.paths.length) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const queue = [...this.paths];
|
||||
const now = Date.now();
|
||||
const queue = this.paths.filter((localPath) => this.shouldCheck(localPath, now));
|
||||
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const localPath = queue.shift();
|
||||
if (this.paused.has(localPath)) continue;
|
||||
this.changed.delete(localPath);
|
||||
this.lastCheckedAt.set(localPath, Date.now());
|
||||
try {
|
||||
const status = await this.git.status(localPath);
|
||||
const next = this.git.statusFingerprint(status);
|
||||
@@ -68,8 +161,11 @@ class RepositoryMonitor {
|
||||
await Promise.all(workers);
|
||||
} finally {
|
||||
this.running = false;
|
||||
// Activity that arrived while the check was running keeps its flag set, so
|
||||
// it must not wait for the safety interval.
|
||||
if (this.active && this.changed.size) this.scheduleWatchTick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { RepositoryMonitor };
|
||||
module.exports = { RepositoryMonitor, SAFETY_CHECK_INTERVAL_MS, WATCH_DEBOUNCE_MS, MIN_WATCH_CHECK_INTERVAL_MS };
|
||||
|
||||
@@ -35,6 +35,7 @@ class RepositoryService {
|
||||
this.lastDiscoveredPaths = [];
|
||||
this.lastDiscoveryAtMs = 0;
|
||||
this.refreshPromise = null;
|
||||
this.lastResult = null;
|
||||
}
|
||||
|
||||
async discoverInRoot(root, maxDepth = 4) {
|
||||
@@ -57,8 +58,13 @@ class RepositoryService {
|
||||
|
||||
let entries;
|
||||
try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; }
|
||||
// Directory entries report as a symbolic link instead of a directory, which
|
||||
// is how Windows junctions surface. Skipping those made a project folder
|
||||
// that is mapped through a junction invisible; visit() resolves each entry
|
||||
// and the `seen` set above keeps links that point back into the tree from
|
||||
// being scanned twice.
|
||||
await mapLimit(entries
|
||||
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !SKIP_DIRECTORIES.has(entry.name)), 12,
|
||||
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !SKIP_DIRECTORIES.has(entry.name)), 12,
|
||||
(entry) => visit(path.join(real, entry.name), depth + 1));
|
||||
};
|
||||
|
||||
@@ -133,6 +139,38 @@ class RepositoryService {
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Resolving a single repository used to go through a full refresh, which runs
|
||||
// `git status` for every discovered repository. Handlers that act on one
|
||||
// repository only need that one, so its local state is read directly. Anything
|
||||
// this cannot answer confidently still falls back to the full scan.
|
||||
async resolveByFullName(fullName) {
|
||||
const name = String(fullName || '').trim();
|
||||
if (!name) return null;
|
||||
const fromFullRefresh = async () => (await this.refresh()).find((item) => item.fullName === name) || null;
|
||||
|
||||
const remoteResult = await this.getRemoteRepositories({});
|
||||
const remote = remoteResult.repositories.find((item) => item.full_name === name);
|
||||
if (!remote) return fromFullRefresh();
|
||||
|
||||
const explicitPath = this.store.data.repositoryMappings[repositoryKey(remote)];
|
||||
const knownPath = explicitPath || (this.lastResult || []).find((item) => item.fullName === name)?.localPath || null;
|
||||
// Without a known path the link can still exist through remote-URL matching,
|
||||
// which only the discovery pass can establish.
|
||||
if (!knownPath && !this.lastResult) return fromFullRefresh();
|
||||
|
||||
const local = knownPath ? (await this.getLocalDescriptors([knownPath]))[0] : null;
|
||||
const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({
|
||||
...profile,
|
||||
state: this.store.getDeploymentState(profile.id)
|
||||
}));
|
||||
return {
|
||||
...this.decorate(remote, local, profiles),
|
||||
remoteStale: remoteResult.stale,
|
||||
remoteRefreshError: remoteResult.error,
|
||||
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(options = {}) {
|
||||
if (this.refreshPromise) return this.refreshPromise;
|
||||
this.refreshPromise = this.performRefresh(options).finally(() => { this.refreshPromise = null; });
|
||||
@@ -215,6 +253,7 @@ class RepositoryService {
|
||||
attentionCount: sorted.filter((item) => item.attention).length,
|
||||
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
|
||||
});
|
||||
this.lastResult = sorted;
|
||||
return sorted;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const fsp = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
const path = require('node:path').posix;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection }) {
|
||||
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile }) {
|
||||
class UnraidAccessMethods {
|
||||
serverGitRemote(repository, profile) {
|
||||
const candidates = [
|
||||
|
||||
@@ -490,7 +490,7 @@ for (const name of Object.getOwnPropertyNames(preflightMethods)) {
|
||||
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(preflightMethods, name));
|
||||
}
|
||||
|
||||
const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection });
|
||||
const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile });
|
||||
for (const name of Object.getOwnPropertyNames(accessMethods)) {
|
||||
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(accessMethods, name));
|
||||
}
|
||||
|
||||
@@ -127,9 +127,6 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
|
||||
// profile hint no longer matches the real Compose service keys.
|
||||
return this.refreshProfileState(repository.fullName, profileId);
|
||||
}
|
||||
const composeFile = profile.generatedCompose
|
||||
? ".forgeflow/compose.forgeflow.yml"
|
||||
: safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml");
|
||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference);
|
||||
const compose = this.composeInvocation(profile, repository);
|
||||
|
||||
Reference in New Issue
Block a user