This commit is contained in:
Hamid Reza Mohammadi 2026-08-16 15:26:58 +03:30
parent ae6ff5ba28
commit fbb2700afb
5 changed files with 110 additions and 6 deletions

View File

@ -21211,6 +21211,24 @@
current poem count.
</summary>
</member>
<member name="F:RMuseum.Services.Implementation.GanjoorService._publicDataExportRunningLock">
<summary>
Guards against two runs of the same export job (keyed by job name — "PublicDataExport"
/ "TajikPublicDataExport") ever executing concurrently against the same git working
copy. This exists because deleting a job's row on the admin Jobs page only removes its
database record — it does not stop the background Task (or its child git.exe process)
that's still actually running. Without this guard, triggering the job again while a
previous run hadn't finished starts a second concurrent git process against the same
folder, which is exactly what produces a ".git/index.lock: File exists" failure.
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorService.TryStartExclusiveExportJob(System.String)">
<summary>
Returns true (and marks <paramref name="jobName"/> as running) if no run of this job
was already in progress; false if one was, in which case the caller should refuse to
start a second one rather than queueing a background task that would race the first.
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorService.StartBatchExportPublicGitData">
<summary>
start exporting all Ganjoor data (poets/categories/poems/verses) belonging to
@ -24160,6 +24178,14 @@
any reason (e.g. a Windows service running under a service account with its own PATH).
</summary>
</member>
<member name="P:RMuseum.Utils.PublicDataExport.GitRepoPublisherOptions.CommandTimeoutMinutes">
<summary>
how long a single git command is allowed to run before it's killed and the job fails
with a clear timeout error, instead of hanging forever (e.g. if git ever ends up
waiting on an interactive prompt with no terminal to answer it — see
GIT_TERMINAL_PROMPT in GitRepoPublisher)
</summary>
</member>
<member name="T:RMuseum.Utils.PublicDataExport.GitRepoPublisher">
<summary>
Thin wrapper around the native <c>git</c> CLI (not LibGit2Sharp) for the export job: sync a
@ -24187,6 +24213,21 @@
be hand-editing it, so a hard reset is safe and keeps the job idempotent).
</summary>
</member>
<member name="M:RMuseum.Utils.PublicDataExport.GitRepoPublisher.RemoveStaleLockFileIfAny">
<summary>
A .git/index.lock left behind by an abruptly-terminated git process (e.g. an app pool
recycle, or a previous run of this same job that got killed mid-command rather than
finishing) blocks every future git command in this working copy with a confusing
"Another git process seems to be running" error, even when nothing actually is.
GanjoorService's TryStartExclusiveExportJob already guarantees only one run of a given
export job (main or Tajik) executes at a time *within this process* — so reaching this
method at all means the current call is the sole legitimate owner of this working copy
right now, and any lock file found here can only be a leftover from something that is
no longer running (that in-process guard doesn't survive an app pool/process restart,
which is exactly the scenario that leaves an orphaned lock file in the first place).
Safe to remove unconditionally on that basis.
</summary>
</member>
<member name="M:RMuseum.Utils.PublicDataExport.GitRepoPublisher.CommitAndPush(System.String)">
<summary>
Stages every change under the working copy and, if anything actually changed, commits

View File

@ -211,6 +211,7 @@ namespace RMuseum.Services.Implementation
GitUserName = section["GitUserName"],
GitToken = section["GitToken"],
GitExecutablePath = section["GitExecutablePath"],
CommandTimeoutMinutes = int.TryParse(section["CommandTimeoutMinutes"], out var timeout) ? timeout : 45,
};
}
@ -488,6 +489,9 @@ Not available as a static endpoint in this data set yet.
return
$@"# ganjoor-data
**[▶ Live demo](https://ganjoor.github.io/mini/)** — مین‌گنجور, a minimal reading app built
entirely on this data, running client-side in your browser with no server of its own.
Public, git-tracked export of [Ganjoor](https://ganjoor.net)'s poetry content — poets,
categories, and poems, allowlisted to contain none of the site's user-account-linked data
(comments, bookmarks, edit history, etc.). Generated from

View File

@ -159,6 +159,7 @@ namespace RMuseum.Services.Implementation
GitUserName = section["GitUserName"],
GitToken = section["GitToken"],
GitExecutablePath = section["GitExecutablePath"],
CommandTimeoutMinutes = int.TryParse(section["CommandTimeoutMinutes"], out var timeout) ? timeout : 45,
};
}

View File

@ -54,6 +54,14 @@ namespace RMuseum.Utils.PublicDataExport
/// any reason (e.g. a Windows service running under a service account with its own PATH).
/// </summary>
public string GitExecutablePath { get; set; }
/// <summary>
/// how long a single git command is allowed to run before it's killed and the job fails
/// with a clear timeout error, instead of hanging forever (e.g. if git ever ends up
/// waiting on an interactive prompt with no terminal to answer it — see
/// GIT_TERMINAL_PROMPT in GitRepoPublisher)
/// </summary>
public int CommandTimeoutMinutes { get; set; } = 45;
}
/// <summary>
@ -209,12 +217,34 @@ namespace RMuseum.Utils.PublicDataExport
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using (var process = new Process { StartInfo = psi })
// Without this, git can end up trying to prompt interactively for credentials or a
// host-key confirmation. There is no interactive terminal in this process (it runs
// under IIS/a background job) — an unanswered prompt just hangs forever with no error
// and no timeout, which is exactly what happened here. This makes git fail fast with
// a real error instead.
psi.EnvironmentVariables["GIT_TERMINAL_PROMPT"] = "0";
var stdout = new StringBuilder();
var stderr = new StringBuilder();
using (var process = new Process { StartInfo = psi, EnableRaisingEvents = true })
{
// Reading both redirected streams asynchronously (rather than e.g.
// StandardOutput.ReadToEnd() before WaitForExit()) is required here, not optional:
// git writes most of its push/fetch progress to stderr, and synchronously draining
// stdout first while nobody drains stderr is a classic .NET Process deadlock if
// stderr's OS pipe buffer fills up — the child blocks writing to stderr, the parent
// blocks reading stdout, and both wait on each other forever. That deadlock (not a
// slow network) is almost certainly what actually produced the multi-day hang this
// was fixed after.
process.OutputDataReceived += (s, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
process.ErrorDataReceived += (s, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
try
{
process.Start();
@ -230,11 +260,37 @@ namespace RMuseum.Utils.PublicDataExport
"the full path of git.exe.", exp);
}
string stdout = process.StandardOutput.ReadToEnd();
string stderr = process.StandardError.ReadToEnd();
process.StandardInput.Close(); // nothing will ever be typed to it
process.BeginOutputReadLine();
process.BeginErrorReadLine();
bool exited = process.WaitForExit(_options.CommandTimeoutMinutes * 60 * 1000);
if (!exited)
{
TryKill(process);
throw new TimeoutException(
$"git {RedactAuth(arguments)} did not finish within {_options.CommandTimeoutMinutes} minutes and was killed. " +
"This is a hang, not normal slowness for this job — most likely git was waiting on a prompt " +
"with no terminal to answer it (should no longer happen with GIT_TERMINAL_PROMPT=0 set above) " +
"or a genuine network stall.");
}
// let the async read handlers finish flushing after the process has exited
process.WaitForExit();
return new GitProcessResult { ExitCode = process.ExitCode, StandardOutput = stdout, StandardError = stderr };
return new GitProcessResult { ExitCode = process.ExitCode, StandardOutput = stdout.ToString(), StandardError = stderr.ToString() };
}
}
private static void TryKill(Process process)
{
try
{
process.Kill(entireProcessTree: true);
}
catch
{
// best-effort - if it's already gone, or can't be killed, there's nothing more to do
}
}

View File

@ -76,7 +76,8 @@
"PushEnabled": "False",
"GitUserName": "",
"GitToken": "",
"GitExecutablePath": ""
"GitExecutablePath": "",
"CommandTimeoutMinutes": "45"
},
"TajikPublicDataExport": {
"LocalWorkingCopyPath": "C:\\ganjoor-tajik-public-data",
@ -87,7 +88,8 @@
"PushEnabled": "False",
"GitUserName": "",
"GitToken": "",
"GitExecutablePath": ""
"GitExecutablePath": "",
"CommandTimeoutMinutes": "45"
},
"ExternalFTPServer": {
"Host": "localhost",