mixdog 0.9.17 → 0.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/bench/cache-probe-tasks.json +1 -1
- package/scripts/bench/lead-review-tasks-r3.json +1 -1
- package/scripts/bench/r4-mixed-tasks.json +1 -1
- package/scripts/bench/review-tasks.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
- package/src/runtime/channels/backends/discord-gateway.mjs +12 -2
- package/src/runtime/channels/backends/discord.mjs +97 -7
- package/src/runtime/channels/index.mjs +150 -23
- package/src/runtime/channels/lib/crash-log.mjs +21 -3
- package/src/runtime/channels/lib/output-forwarder.mjs +118 -7
- package/src/runtime/channels/lib/runtime-paths.mjs +13 -0
- package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
- package/src/standalone/channel-worker.mjs +14 -2
- package/src/tui/app/transcript-window.mjs +22 -0
- package/src/tui/app/use-transcript-window.mjs +9 -3
- package/src/tui/dist/index.mjs +63 -12
- package/src/tui/engine.mjs +60 -10
|
@@ -1,106 +1,106 @@
|
|
|
1
|
-
# smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
|
|
2
|
-
# runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
|
|
3
|
-
# boot, double-init refusal, hostile-env survival.
|
|
4
|
-
#
|
|
5
|
-
# Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
|
|
6
|
-
|
|
7
|
-
$ErrorActionPreference = 'Stop'
|
|
8
|
-
|
|
9
|
-
$Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
|
|
10
|
-
$Os = if ($env:OS) { $env:OS } else { 'win32' }
|
|
11
|
-
$Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
|
|
12
|
-
$ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
|
|
13
|
-
$PgVer = '16.4'
|
|
14
|
-
$PgvectorVer = '0.8.2'
|
|
15
|
-
|
|
16
|
-
$Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
|
|
17
|
-
$Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
|
|
18
|
-
|
|
19
|
-
$Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
|
|
23
|
-
|
|
24
|
-
Write-Host "==> Test 1: HEAD reaches release asset"
|
|
25
|
-
$resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
|
|
26
|
-
Write-Host " HTTP $($resp.StatusCode)"
|
|
27
|
-
|
|
28
|
-
Write-Host "==> Test 2: full download + sha256"
|
|
29
|
-
$TarPath = Join-Path $Work $Asset
|
|
30
|
-
Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
|
|
31
|
-
$sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
|
|
32
|
-
Write-Host " sha256=$sha"
|
|
33
|
-
|
|
34
|
-
Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
|
|
35
|
-
$CorruptPath = "$Work\corrupt.tar.gz"
|
|
36
|
-
Copy-Item $TarPath $CorruptPath
|
|
37
|
-
$bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
|
|
38
|
-
$stream = [System.IO.File]::OpenWrite($CorruptPath)
|
|
39
|
-
$stream.Position = 102400
|
|
40
|
-
$stream.Write($bytes, 0, 1)
|
|
41
|
-
$stream.Close()
|
|
42
|
-
$CorruptDir = "$Work\corrupt"
|
|
43
|
-
New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
|
|
44
|
-
$TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
|
|
45
|
-
if (-not $TarProc.WaitForExit(15000)) {
|
|
46
|
-
Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
|
|
47
|
-
Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
|
|
48
|
-
} elseif ($TarProc.ExitCode -eq 0) {
|
|
49
|
-
Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
|
|
50
|
-
} else {
|
|
51
|
-
Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
|
|
55
|
-
$FreshDir = "$Work\fresh"
|
|
56
|
-
New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
|
|
57
|
-
& tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
|
|
58
|
-
if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
|
|
59
|
-
|
|
60
|
-
$PgBin = "$FreshDir\bin"
|
|
61
|
-
$Data = "$FreshDir\pgdata"
|
|
62
|
-
$Log = "$FreshDir\pg.log"
|
|
63
|
-
$Port = 55897
|
|
64
|
-
|
|
65
|
-
# Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
|
|
66
|
-
$SavedPath = $env:PATH
|
|
67
|
-
$SavedPgRoot = $env:PGROOT
|
|
68
|
-
$env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
|
|
69
|
-
$env:PGROOT = $null
|
|
70
|
-
$env:PGDATA = $null
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
& "$PgBin\postgres.exe" --version
|
|
74
|
-
if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
|
|
75
|
-
& "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
|
|
76
|
-
if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
|
|
77
|
-
& "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
|
|
78
|
-
if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
& "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
|
|
82
|
-
if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
|
|
83
|
-
$ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
|
|
84
|
-
if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
|
|
85
|
-
Write-Host " PASS: fresh-extract boot + vector extension"
|
|
86
|
-
} finally {
|
|
87
|
-
& "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
Write-Host "==> Test 5: second initdb on initialized dir refused"
|
|
91
|
-
$rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
|
|
92
|
-
if ($rc -eq 0) {
|
|
93
|
-
Write-Host " WARN: second initdb succeeded (unexpected)"
|
|
94
|
-
} else {
|
|
95
|
-
Write-Host " PASS: second initdb refused (exit $rc)"
|
|
96
|
-
}
|
|
97
|
-
} finally {
|
|
98
|
-
$env:PATH = $SavedPath
|
|
99
|
-
$env:PGROOT = $SavedPgRoot
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
Write-Host "==> All negative-path smokes: PASS"
|
|
103
|
-
}
|
|
104
|
-
finally {
|
|
105
|
-
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
|
|
106
|
-
}
|
|
1
|
+
# smoke-runtime-negative.ps1 — Negative-path tests against released win32-x64
|
|
2
|
+
# runtime tarball. Validates: download, sha256, corrupt-tarball, fresh-extract
|
|
3
|
+
# boot, double-init refusal, hostile-env survival.
|
|
4
|
+
#
|
|
5
|
+
# Env: TAG / OS / ARCH / RUNTIME_RELEASE_REPOSITORY (default tribgames/mixdog)
|
|
6
|
+
|
|
7
|
+
$ErrorActionPreference = 'Stop'
|
|
8
|
+
|
|
9
|
+
$Tag = if ($env:TAG) { $env:TAG } else { 'runtime-v0.4.0' }
|
|
10
|
+
$Os = if ($env:OS) { $env:OS } else { 'win32' }
|
|
11
|
+
$Arch = if ($env:ARCH) { $env:ARCH } else { 'x64' }
|
|
12
|
+
$ReleaseRepo = if ($env:RUNTIME_RELEASE_REPOSITORY) { $env:RUNTIME_RELEASE_REPOSITORY } else { 'tribgames/mixdog' }
|
|
13
|
+
$PgVer = '16.4'
|
|
14
|
+
$PgvectorVer = '0.8.2'
|
|
15
|
+
|
|
16
|
+
$Asset = "mixdog-runtime-${Os}-${Arch}-pg${PgVer}-pgvector${PgvectorVer}.tar.gz"
|
|
17
|
+
$Url = "https://github.com/${ReleaseRepo}/releases/download/${Tag}/${Asset}"
|
|
18
|
+
|
|
19
|
+
$Work = New-Item -ItemType Directory -Force -Path "$env:TEMP\smoke-$([guid]::NewGuid().ToString('N'))" | Select-Object -ExpandProperty FullName
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
Write-Host "==> Negative-path smoke for ${Os}-${Arch} from $Url"
|
|
23
|
+
|
|
24
|
+
Write-Host "==> Test 1: HEAD reaches release asset"
|
|
25
|
+
$resp = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -ErrorAction Stop
|
|
26
|
+
Write-Host " HTTP $($resp.StatusCode)"
|
|
27
|
+
|
|
28
|
+
Write-Host "==> Test 2: full download + sha256"
|
|
29
|
+
$TarPath = Join-Path $Work $Asset
|
|
30
|
+
Invoke-WebRequest -Uri $Url -OutFile $TarPath -UseBasicParsing
|
|
31
|
+
$sha = (Get-FileHash -Algorithm SHA256 $TarPath).Hash.ToLower()
|
|
32
|
+
Write-Host " sha256=$sha"
|
|
33
|
+
|
|
34
|
+
Write-Host "==> Test 3: corrupt tarball — flip a byte and ensure tar errors"
|
|
35
|
+
$CorruptPath = "$Work\corrupt.tar.gz"
|
|
36
|
+
Copy-Item $TarPath $CorruptPath
|
|
37
|
+
$bytes = [byte[]]::new(1); $rand = [System.Random]::new(); $rand.NextBytes($bytes)
|
|
38
|
+
$stream = [System.IO.File]::OpenWrite($CorruptPath)
|
|
39
|
+
$stream.Position = 102400
|
|
40
|
+
$stream.Write($bytes, 0, 1)
|
|
41
|
+
$stream.Close()
|
|
42
|
+
$CorruptDir = "$Work\corrupt"
|
|
43
|
+
New-Item -ItemType Directory -Force -Path $CorruptDir | Out-Null
|
|
44
|
+
$TarProc = Start-Process -FilePath tar -ArgumentList @('-xzf', $CorruptPath, '-C', $CorruptDir.Replace('\','/')) -Wait:$false -PassThru -WindowStyle Hidden
|
|
45
|
+
if (-not $TarProc.WaitForExit(15000)) {
|
|
46
|
+
Stop-Process -Id $TarProc.Id -Force -ErrorAction SilentlyContinue
|
|
47
|
+
Write-Host " PASS: corrupted tarball extraction timed out and was stopped"
|
|
48
|
+
} elseif ($TarProc.ExitCode -eq 0) {
|
|
49
|
+
Write-Host " WARN: corrupted tarball extracted without error (tar gzip recovery)"
|
|
50
|
+
} else {
|
|
51
|
+
Write-Host " PASS: corrupted tarball rejected by tar (exit $($TarProc.ExitCode))"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Write-Host "==> Test 4: fresh-extract boot (extension load + distance query)"
|
|
55
|
+
$FreshDir = "$Work\fresh"
|
|
56
|
+
New-Item -ItemType Directory -Force -Path $FreshDir | Out-Null
|
|
57
|
+
& tar -xzf $TarPath -C ($FreshDir.Replace('\','/'))
|
|
58
|
+
if ($LASTEXITCODE -ne 0) { throw "fresh extract failed" }
|
|
59
|
+
|
|
60
|
+
$PgBin = "$FreshDir\bin"
|
|
61
|
+
$Data = "$FreshDir\pgdata"
|
|
62
|
+
$Log = "$FreshDir\pg.log"
|
|
63
|
+
$Port = 55897
|
|
64
|
+
|
|
65
|
+
# Hostile env: minimal PATH (System32 only), no PGROOT/PGDATA
|
|
66
|
+
$SavedPath = $env:PATH
|
|
67
|
+
$SavedPgRoot = $env:PGROOT
|
|
68
|
+
$env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
|
|
69
|
+
$env:PGROOT = $null
|
|
70
|
+
$env:PGDATA = $null
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
& "$PgBin\postgres.exe" --version
|
|
74
|
+
if ($LASTEXITCODE -ne 0) { throw "FAIL: postgres --version" }
|
|
75
|
+
& "$PgBin\initdb.exe" -D $Data --username=postgres --auth-local=trust --no-locale -E UTF8 | Out-Null
|
|
76
|
+
if ($LASTEXITCODE -ne 0) { throw "FAIL: initdb" }
|
|
77
|
+
& "$PgBin\pg_ctl.exe" -D $Data -o "-p $Port -h 127.0.0.1" -l $Log -w start
|
|
78
|
+
if ($LASTEXITCODE -ne 0) { Get-Content $Log | Select-Object -Last 30; throw "FAIL: pg_ctl start" }
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
& "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -c "CREATE EXTENSION vector;" | Out-Null
|
|
82
|
+
if ($LASTEXITCODE -ne 0) { throw "FAIL: CREATE EXTENSION" }
|
|
83
|
+
$ExtV = & "$PgBin\psql.exe" -h 127.0.0.1 -p $Port -U postgres -d postgres -tAc "SELECT extversion FROM pg_extension WHERE extname='vector';"
|
|
84
|
+
if ($ExtV.Trim() -ne $PgvectorVer) { throw "FAIL: extversion='$ExtV' expected='$PgvectorVer'" }
|
|
85
|
+
Write-Host " PASS: fresh-extract boot + vector extension"
|
|
86
|
+
} finally {
|
|
87
|
+
& "$PgBin\pg_ctl.exe" -D $Data -m fast stop 2>$null | Out-Null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
Write-Host "==> Test 5: second initdb on initialized dir refused"
|
|
91
|
+
$rc = (Start-Process -FilePath "$PgBin\initdb.exe" -ArgumentList "-D",$Data,"-U","postgres" -Wait -PassThru -NoNewWindow).ExitCode
|
|
92
|
+
if ($rc -eq 0) {
|
|
93
|
+
Write-Host " WARN: second initdb succeeded (unexpected)"
|
|
94
|
+
} else {
|
|
95
|
+
Write-Host " PASS: second initdb refused (exit $rc)"
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
$env:PATH = $SavedPath
|
|
99
|
+
$env:PGROOT = $SavedPgRoot
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
Write-Host "==> All negative-path smokes: PASS"
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
|
|
106
|
+
}
|
|
@@ -85,4 +85,4 @@ for (const target of ['heavy-worker', 'worker']) {
|
|
|
85
85
|
console.log(` single-call batches: ${batch1}/${batchTot} (${pct(batch1, batchTot)}%)`);
|
|
86
86
|
console.log(` content greps with -C/-A/-B: ${grepContentWithCtx}/${grepContent} (${pct(grepContentWithCtx, grepContent)}%) grep->read follow-ups: ${grepThenRead}`);
|
|
87
87
|
console.log(` same-path re-reads >=3x: ${rereads}`);
|
|
88
|
-
}
|
|
88
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
# Channels
|
|
2
|
-
|
|
3
|
-
- Channel features are handled by the runtime.
|
|
1
|
+
# Channels
|
|
2
|
+
|
|
3
|
+
- Channel features are handled by the runtime.
|
|
@@ -5,13 +5,13 @@ import { join, sep } from "path";
|
|
|
5
5
|
|
|
6
6
|
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
7
7
|
|
|
8
|
-
async function downloadSingleAttachment(att, inboxDir) {
|
|
8
|
+
async function downloadSingleAttachment(att, inboxDir, { timeoutMs = 180_000 } = {}) {
|
|
9
9
|
if (att.size > MAX_ATTACHMENT_BYTES) {
|
|
10
10
|
throw new Error(
|
|
11
11
|
`attachment too large: ${(att.size / 1024 / 1024).toFixed(1)}MB, max ${MAX_ATTACHMENT_BYTES / 1024 / 1024}MB`
|
|
12
12
|
);
|
|
13
13
|
}
|
|
14
|
-
const res = await fetch(att.url, { signal: AbortSignal.timeout(
|
|
14
|
+
const res = await fetch(att.url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
15
15
|
if (!res.ok) {
|
|
16
16
|
throw new Error(`attachment download failed: HTTP ${res.status}`);
|
|
17
17
|
}
|
|
@@ -212,13 +212,23 @@ async function awaitLogin(self) {
|
|
|
212
212
|
resolve();
|
|
213
213
|
});
|
|
214
214
|
});
|
|
215
|
+
// Observe the ready timer from the start: login() can hang past the 30s
|
|
216
|
+
// timer (gateway reconnect), and a rejection on an un-awaited readyPromise
|
|
217
|
+
// becomes a process-global unhandledRejection → fatal crash path
|
|
218
|
+
// (channels/index.mjs unhandledRejection → _fatalCrash → exit(1)). Racing
|
|
219
|
+
// login with readyPromise keeps the rejection inside this connect() chain,
|
|
220
|
+
// which every caller already catches non-fatally.
|
|
221
|
+
const loginPromise = self.client.login(self.token);
|
|
222
|
+
// A late login settlement after the timeout raced ahead must not itself
|
|
223
|
+
// become a second unhandled rejection.
|
|
224
|
+
loginPromise.catch(() => {});
|
|
215
225
|
try {
|
|
216
|
-
await
|
|
226
|
+
await Promise.race([loginPromise, readyPromise]);
|
|
227
|
+
await readyPromise;
|
|
217
228
|
} catch (err) {
|
|
218
229
|
clearTimeout(readyTimeout);
|
|
219
230
|
throw err;
|
|
220
231
|
}
|
|
221
|
-
await readyPromise;
|
|
222
232
|
}
|
|
223
233
|
|
|
224
234
|
export {
|
|
@@ -23,6 +23,14 @@ import { normalizeAccess, safeAttName } from "./discord-access.mjs";
|
|
|
23
23
|
import { MAX_ATTACHMENT_BYTES, downloadSingleAttachment } from "./discord-attachments.mjs";
|
|
24
24
|
import * as gateway from "./discord-gateway.mjs";
|
|
25
25
|
const MAX_CHUNK_LIMIT = MAX_DISCORD_MESSAGE;
|
|
26
|
+
// Per-attempt cap for a single discord.js fetch/send. Without it a wedged
|
|
27
|
+
// socket (network blip, "other side closed") leaves the await pending forever,
|
|
28
|
+
// which upstream leaves the forwarder's `sending` flag stuck and silences all
|
|
29
|
+
// outbound. On timeout we treat the client as dead and reset+reconnect it.
|
|
30
|
+
const SEND_ATTEMPT_TIMEOUT_MS = 30_000;
|
|
31
|
+
// After a timed-out send is reset, cap how long we wait for the original
|
|
32
|
+
// (abandoned) promise to actually settle before giving up with unknown outcome.
|
|
33
|
+
const SETTLE_CAP_MS = 15_000;
|
|
26
34
|
const RECENT_SENT_CAP = 200;
|
|
27
35
|
class DiscordBackend {
|
|
28
36
|
name = "discord";
|
|
@@ -74,11 +82,57 @@ class DiscordBackend {
|
|
|
74
82
|
if (this.client) this.client.destroy();
|
|
75
83
|
this._connectPromise = null;
|
|
76
84
|
}
|
|
85
|
+
// ── Self-heal helpers ──────────────────────────────────────────────
|
|
86
|
+
/** Race a discord.js op-promise against a per-attempt timeout so a wedged
|
|
87
|
+
* socket can't hang the outbound path. On timeout the rejection carries
|
|
88
|
+
* `_timeout` so the caller knows the underlying op is still in flight (and
|
|
89
|
+
* must be settled/adopted, not blindly retried → duplicate). */
|
|
90
|
+
_withTimeout(promise, label) {
|
|
91
|
+
let timer;
|
|
92
|
+
const timeout = new Promise((_, reject) => {
|
|
93
|
+
timer = setTimeout(() => {
|
|
94
|
+
const e = new Error(`${label} timed out after ${SEND_ATTEMPT_TIMEOUT_MS}ms`);
|
|
95
|
+
e._timeout = true;
|
|
96
|
+
reject(e);
|
|
97
|
+
}, SEND_ATTEMPT_TIMEOUT_MS);
|
|
98
|
+
});
|
|
99
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
100
|
+
}
|
|
101
|
+
/** True when an error means the client is dead (aborted, "other side
|
|
102
|
+
* closed", or our per-attempt timeout) and must be reset+reconnected. */
|
|
103
|
+
_shouldResetClient(err) {
|
|
104
|
+
if (err?.name === "AbortError" || err?.code === "ABORT_ERR") return true;
|
|
105
|
+
const msg = String(err?.message || err || "").toLowerCase();
|
|
106
|
+
return msg.includes("aborted") || msg.includes("other side closed") ||
|
|
107
|
+
msg.includes("socket hang up") || msg.includes("econnreset") || msg.includes("timed out");
|
|
108
|
+
}
|
|
109
|
+
/** Destroy the wedged client and rebuild it via connect() so the next
|
|
110
|
+
* fetch/send (and the forwarder's queue-head-preserving retry) hits a fresh
|
|
111
|
+
* gateway/REST client. Serialized behind a single in-flight reset so
|
|
112
|
+
* concurrent callers await the SAME reconnect instead of racing multiple
|
|
113
|
+
* destroy()/login() cycles. */
|
|
114
|
+
async _resetClient() {
|
|
115
|
+
if (this._resetPromise) return this._resetPromise;
|
|
116
|
+
this._resetPromise = (async () => {
|
|
117
|
+
try { this.client?.destroy?.(); } catch {}
|
|
118
|
+
this.client = null;
|
|
119
|
+
this._connectPromise = null;
|
|
120
|
+
await this.connect();
|
|
121
|
+
})().finally(() => { this._resetPromise = null; });
|
|
122
|
+
return this._resetPromise;
|
|
123
|
+
}
|
|
124
|
+
/** Health-check the gateway; reset+reconnect if the ws is not READY. Called
|
|
125
|
+
* by the owner re-claim self-heal path. (discord.js Status.Ready === 0) */
|
|
126
|
+
async healthCheck() {
|
|
127
|
+
if (this.client && this.client.ws?.status === 0) return;
|
|
128
|
+
await this._resetClient();
|
|
129
|
+
}
|
|
77
130
|
resetSendCount() {
|
|
78
131
|
this.sendCount = 0;
|
|
79
132
|
}
|
|
80
133
|
startTyping(channelId) {
|
|
81
134
|
this.stopTyping(channelId);
|
|
135
|
+
if (!this.client) return;
|
|
82
136
|
const ch = this.client.channels.cache.get(channelId);
|
|
83
137
|
if (ch && "sendTyping" in ch) {
|
|
84
138
|
void ch.sendTyping().catch(() => {
|
|
@@ -172,13 +226,38 @@ class DiscordBackend {
|
|
|
172
226
|
// resumes at this chunk index instead of re-sending 0..i-1.
|
|
173
227
|
let attempt = 0;
|
|
174
228
|
for (;;) {
|
|
229
|
+
const sendPromise = ch.send(payload);
|
|
175
230
|
try {
|
|
176
|
-
const sent = await
|
|
231
|
+
const sent = await this._withTimeout(sendPromise, "discord send");
|
|
177
232
|
this.noteSent(sent.id);
|
|
178
233
|
sentIds.push(sent.id);
|
|
179
234
|
break;
|
|
180
235
|
} catch (err) {
|
|
181
236
|
attempt++;
|
|
237
|
+
if (err?._timeout) {
|
|
238
|
+
// Watchdog abandoned the send but the socket may still deliver.
|
|
239
|
+
// Destroy the client to tear down the in-flight REST socket so the
|
|
240
|
+
// ORIGINAL promise settles, then AWAIT + adopt its real outcome:
|
|
241
|
+
// delivered → done (no retry → no duplicate); else requeue against
|
|
242
|
+
// the fresh client via a resume token.
|
|
243
|
+
try { await this._resetClient(); } catch {}
|
|
244
|
+
// Cap the settlement wait: destroy() should abort the in-flight
|
|
245
|
+
// socket, but never hang here if it doesn't. On unknown outcome
|
|
246
|
+
// throw retryable (duplicate risk accepted over a permanent hang).
|
|
247
|
+
sendPromise.catch(() => {}); // swallow a late rejection
|
|
248
|
+
const settled = await Promise.race([
|
|
249
|
+
sendPromise.then((v) => ({ ok: true, v }), () => ({ ok: false })),
|
|
250
|
+
new Promise((r) => setTimeout(() => r({ ok: false }), SETTLE_CAP_MS))
|
|
251
|
+
]);
|
|
252
|
+
if (settled.ok) {
|
|
253
|
+
this.noteSent(settled.v.id);
|
|
254
|
+
sentIds.push(settled.v.id);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
258
|
+
e.resumeToken = { hash: contentHash, nextChunkIdx: i, sentIds: [...sentIds], prefixed: applyPrefix, limit };
|
|
259
|
+
throw e;
|
|
260
|
+
}
|
|
182
261
|
const status = err?.status ?? err?.code ?? err?.httpStatus;
|
|
183
262
|
// Classify: PERMANENT = a 4xx client error (unknown channel/message,
|
|
184
263
|
// missing access/permissions, 404 …) that will never succeed on
|
|
@@ -192,7 +271,15 @@ class DiscordBackend {
|
|
|
192
271
|
e.permanent = true;
|
|
193
272
|
throw e;
|
|
194
273
|
}
|
|
195
|
-
|
|
274
|
+
// Dead client (abort / "other side closed" / per-attempt timeout):
|
|
275
|
+
// destroy + reconnect so the forwarder's transient-retry path hits a
|
|
276
|
+
// fresh client, and throw a resume token now instead of burning the
|
|
277
|
+
// remaining attempts on a stale channel handle bound to the old client.
|
|
278
|
+
const dead = this._shouldResetClient(err);
|
|
279
|
+
if (dead) {
|
|
280
|
+
try { await this._resetClient(); } catch {}
|
|
281
|
+
}
|
|
282
|
+
if (dead || attempt >= 3) {
|
|
196
283
|
// Attach an opaque resume token pointing at the chunk that failed
|
|
197
284
|
// (i). sentIds holds every chunk already delivered (including any
|
|
198
285
|
// seeded from a prior token).
|
|
@@ -301,13 +388,13 @@ class DiscordBackend {
|
|
|
301
388
|
const msg = await ch.messages.fetch(messageId);
|
|
302
389
|
await msg.delete();
|
|
303
390
|
}
|
|
304
|
-
async downloadAttachment(chatId, messageId) {
|
|
391
|
+
async downloadAttachment(chatId, messageId, opts = {}) {
|
|
305
392
|
const ch = await this.fetchAllowedChannel(chatId);
|
|
306
393
|
const msg = await ch.messages.fetch(messageId);
|
|
307
394
|
if (msg.attachments.size === 0) return [];
|
|
308
395
|
const results = [];
|
|
309
396
|
for (const att of msg.attachments.values()) {
|
|
310
|
-
const path = await this.downloadSingleAttachment(att);
|
|
397
|
+
const path = await this.downloadSingleAttachment(att, opts);
|
|
311
398
|
results.push({
|
|
312
399
|
id: att.id,
|
|
313
400
|
path,
|
|
@@ -526,7 +613,10 @@ class DiscordBackend {
|
|
|
526
613
|
}
|
|
527
614
|
// ── Channel helpers ────────────────────────────────────────────────
|
|
528
615
|
async fetchTextChannel(id) {
|
|
529
|
-
|
|
616
|
+
if (!this.client) await this.connect();
|
|
617
|
+
const p = this.client.channels.fetch(id);
|
|
618
|
+
p.catch(() => {}); // if the watchdog wins, don't leak an unhandled rejection
|
|
619
|
+
const ch = await this._withTimeout(p, "discord channels.fetch");
|
|
530
620
|
if (!ch || !ch.isTextBased()) {
|
|
531
621
|
throw new Error(`channel ${id} not found or not text-based`);
|
|
532
622
|
}
|
|
@@ -571,8 +661,8 @@ class DiscordBackend {
|
|
|
571
661
|
throw new Error(`refusing to send channel state: ${f}`);
|
|
572
662
|
}
|
|
573
663
|
}
|
|
574
|
-
async downloadSingleAttachment(att) {
|
|
575
|
-
return downloadSingleAttachment(att, this.inboxDir);
|
|
664
|
+
async downloadSingleAttachment(att, opts = {}) {
|
|
665
|
+
return downloadSingleAttachment(att, this.inboxDir, opts);
|
|
576
666
|
}
|
|
577
667
|
}
|
|
578
668
|
export {
|