@leiyi-works/dsh-cloud-sync 0.1.5 → 0.1.6
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/client.js +5 -1
- package/index.js +35 -3
- package/package.json +2 -2
- package/sync.js +104 -32
package/client.js
CHANGED
|
@@ -61,6 +61,7 @@ window.__ModuleLoader__.load({
|
|
|
61
61
|
syncing: '同步中…',
|
|
62
62
|
alreadyRunning: '已在同步中,稍候会自动刷新',
|
|
63
63
|
unconfigured: '未配置服务器:在 profile 的 cordis.patch.yml 里填 host,或在 keyrule-secrets/ssh.json 里写连接信息',
|
|
64
|
+
permanent: '环境不可用,已停止自动重试',
|
|
64
65
|
lastSync: '上次同步',
|
|
65
66
|
cloudSize: '云端占用',
|
|
66
67
|
autoPlan: '自动同步',
|
|
@@ -85,6 +86,7 @@ window.__ModuleLoader__.load({
|
|
|
85
86
|
syncing: 'Syncing…',
|
|
86
87
|
alreadyRunning: 'A sync is already running — this panel will refresh automatically',
|
|
87
88
|
unconfigured: 'No server configured: set host in the profile cordis.patch.yml, or put connection info in keyrule-secrets/ssh.json',
|
|
89
|
+
permanent: 'Environment unavailable — automatic retries stopped',
|
|
88
90
|
lastSync: 'Last sync',
|
|
89
91
|
cloudSize: 'Cloud usage',
|
|
90
92
|
autoPlan: 'Auto sync',
|
|
@@ -221,7 +223,9 @@ window.__ModuleLoader__.load({
|
|
|
221
223
|
? `${t('running')}:${job.log?.slice(-1)[0] ?? ''}`
|
|
222
224
|
: job?.phase === 'failed'
|
|
223
225
|
? `${t('failed')}:${job.error ?? ''}`
|
|
224
|
-
:
|
|
226
|
+
: status?.last?.error?.permanent === true
|
|
227
|
+
? `${t('permanent')}:${status.last.error.message ?? ''}`
|
|
228
|
+
: lastPushAt !== undefined
|
|
225
229
|
? `${when(lastPushAt)} · ${status?.last?.push?.uploaded ?? 0} ${t('files')}`
|
|
226
230
|
: t('never')
|
|
227
231
|
|
package/index.js
CHANGED
|
@@ -32,6 +32,14 @@ import {
|
|
|
32
32
|
defaultMachineId,
|
|
33
33
|
isConfigured,
|
|
34
34
|
} from './sync.js'
|
|
35
|
+
import { setSubprocessProvider } from './exec.js'
|
|
36
|
+
|
|
37
|
+
/** 这些错误是「环境不具备」,重试多少次都没用:停止自动重试,交给 UI 提示。 */
|
|
38
|
+
const PERMANENT_ERROR_RE = /ENOENT|EACCES|EPERM|not a function|私钥不存在|未配置服务器|Permission denied|no such identity|Cannot find module|spawn .* ENOENT/i
|
|
39
|
+
|
|
40
|
+
function isPermanentError(message) {
|
|
41
|
+
return PERMANENT_ERROR_RE.test(String(message ?? ''))
|
|
42
|
+
}
|
|
35
43
|
|
|
36
44
|
export const name = 'dsh-cloud-sync'
|
|
37
45
|
export const inject = ['tools', 'webServer']
|
|
@@ -51,6 +59,20 @@ export const Config = z.object({
|
|
|
51
59
|
.description('私钥路径;留空则从 connectionFile 读取'),
|
|
52
60
|
connectionFile: z.string().default(DEFAULTS.connectionFile)
|
|
53
61
|
.description('连接信息 json(默认 <DSH_HOME>/keyrule-secrets/ssh.json,随钥则目录一起同步)'),
|
|
62
|
+
identityFileGuest: z.string().default(DEFAULTS.identityFileGuest)
|
|
63
|
+
.description('guest/VM 世界里的私钥路径(手机端用;填了就不校验宿主路径)'),
|
|
64
|
+
sshBinary: z.string().default(DEFAULTS.sshBinary)
|
|
65
|
+
.description('ssh 可执行文件(留空 = 自动找;手机端可填 guest 里的绝对路径)'),
|
|
66
|
+
sshCompat: z.string().default(DEFAULTS.sshCompat)
|
|
67
|
+
.description('ssh 兼容模式:openssh(默认)/ dropbear(精简参数、-y 接受主机密钥)'),
|
|
68
|
+
connectTimeoutMs: z.natural().default(DEFAULTS.connectTimeoutMs)
|
|
69
|
+
.description('建连超时(毫秒;手机端软件模拟 SSH 很慢,建议 60000 以上)'),
|
|
70
|
+
commandTimeoutMs: z.natural().default(DEFAULTS.commandTimeoutMs)
|
|
71
|
+
.description('单条远程命令超时(毫秒)'),
|
|
72
|
+
stallMs: z.natural().default(DEFAULTS.stallMs)
|
|
73
|
+
.description('传输停滞判定(毫秒):连续这么久没有数据就断开重试'),
|
|
74
|
+
deviceMode: z.boolean().default(DEFAULTS.deviceMode)
|
|
75
|
+
.description('设备模式:默认每天一次、不带附件/插件源码、不自动整份恢复(手机端建议开)'),
|
|
54
76
|
remoteRoot: z.string().default(DEFAULTS.remoteRoot).description('服务器上的备份根目录'),
|
|
55
77
|
machineId: z.string().default('').description('这台机器的槽位名,留空 = 主机名'),
|
|
56
78
|
localPluginRoot: z.string().default(DEFAULTS.localPluginRoot)
|
|
@@ -207,16 +229,17 @@ function createRunner(config) {
|
|
|
207
229
|
} catch (error) {
|
|
208
230
|
job.error = error instanceof Error ? error.message : String(error)
|
|
209
231
|
job.phase = 'failed'
|
|
232
|
+
const permanent = isPermanentError(job.error)
|
|
210
233
|
const next = {
|
|
211
234
|
...readState(),
|
|
212
235
|
version: 1,
|
|
213
236
|
lastKind: kind,
|
|
214
237
|
lastAt: new Date().toISOString(),
|
|
215
|
-
lastError: { kind, message: job.error, at: new Date().toISOString() },
|
|
216
|
-
pendingRetryAt: Date.now() + 30 * 60 * 1000,
|
|
238
|
+
lastError: { kind, message: job.error, at: new Date().toISOString(), ...(permanent ? { permanent: true } : {}) },
|
|
239
|
+
...(permanent ? {} : { pendingRetryAt: Date.now() + 30 * 60 * 1000 }),
|
|
217
240
|
}
|
|
218
241
|
writeState(next)
|
|
219
|
-
appendLog(`${kind}
|
|
242
|
+
appendLog(`${kind} 失败${permanent ? '(环境不可用,已停止自动重试)' : ''}:${job.error}`)
|
|
220
243
|
throw error
|
|
221
244
|
} finally {
|
|
222
245
|
job.finishedAt = Date.now()
|
|
@@ -391,6 +414,13 @@ function summarizeStatus(status) {
|
|
|
391
414
|
|
|
392
415
|
export function apply(ctx, rawConfig) {
|
|
393
416
|
const config = normalizeConfig(rawConfig)
|
|
417
|
+
// 命令执行层:优先用 DSH 的可移植 subprocess 服务(手机/沙箱环境里只有它能起进程)
|
|
418
|
+
try {
|
|
419
|
+
const subprocess = typeof ctx.get === 'function' ? ctx.get('subprocess') : undefined
|
|
420
|
+
setSubprocessProvider(subprocess)
|
|
421
|
+
} catch {
|
|
422
|
+
setSubprocessProvider(undefined)
|
|
423
|
+
}
|
|
394
424
|
const runner = createRunner(config)
|
|
395
425
|
|
|
396
426
|
/* 记「有没有会话在跑」:空闲暂停靠它判断 */
|
|
@@ -552,6 +582,8 @@ export function apply(ctx, rawConfig) {
|
|
|
552
582
|
const tick = () => {
|
|
553
583
|
if (!config.autoSync || runner.active()) return
|
|
554
584
|
const state = readState()
|
|
585
|
+
// 永久性错误(环境不可用:没有 ssh、没有私钥、未配置服务器…)→ 不再自动重试,等用户手动处理
|
|
586
|
+
if (state.lastError !== undefined && state.lastError !== null && state.lastError.permanent === true) return
|
|
555
587
|
const pendingAt = Number(state.pendingRetryAt) || 0
|
|
556
588
|
if (pendingAt > Date.now()) return
|
|
557
589
|
// 上一轮有「刚改过、留到下轮」的文件:到点就补一轮,不受间隔限制
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leiyi-works/dsh-cloud-sync",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"title": "云端同步",
|
|
6
|
-
"description": "
|
|
6
|
+
"description": "云端同步:执行层走 DSH 可移植 subprocess(手机/沙箱也能跑)、ssh 兼容 dropbear、超时与停滞可配置、设备模式、永久错误停止重试;连接信息在 keyrule-secrets/ssh.json。",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": "leiyi-works",
|
|
9
9
|
"keywords": [
|
package/sync.js
CHANGED
|
@@ -25,7 +25,7 @@ import fs from 'node:fs'
|
|
|
25
25
|
import fsp from 'node:fs/promises'
|
|
26
26
|
import path from 'node:path'
|
|
27
27
|
import os from 'node:os'
|
|
28
|
-
import {
|
|
28
|
+
import { spawnManaged } from './exec.js'
|
|
29
29
|
|
|
30
30
|
/* ── 配置 ───────────────────────────────────────────────────────────────── */
|
|
31
31
|
|
|
@@ -37,10 +37,17 @@ export const DEFAULTS = {
|
|
|
37
37
|
host: '',
|
|
38
38
|
port: 22,
|
|
39
39
|
identityFile: '',
|
|
40
|
+
identityFileGuest: '',
|
|
41
|
+
sshBinary: '',
|
|
42
|
+
sshCompat: 'openssh',
|
|
43
|
+
connectTimeoutMs: 15000,
|
|
44
|
+
commandTimeoutMs: 60000,
|
|
45
|
+
stallMs: 120000,
|
|
46
|
+
deviceMode: false,
|
|
40
47
|
remoteRoot: '/opt/dsh-sync',
|
|
41
48
|
connectionFile: '',
|
|
42
49
|
machineId: '',
|
|
43
|
-
localPluginRoot: '
|
|
50
|
+
localPluginRoot: '',
|
|
44
51
|
intervalMinutes: 5,
|
|
45
52
|
dailySync: false,
|
|
46
53
|
autoSync: true,
|
|
@@ -86,25 +93,41 @@ export function normalizeConfig(raw) {
|
|
|
86
93
|
return trimmed === '' ? fallback : trimmed
|
|
87
94
|
}
|
|
88
95
|
const flag = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
|
|
96
|
+
// 设备模式(手机/嵌入式):默认每天一次、不带附件和插件源码、不自动整份恢复
|
|
97
|
+
const device = flag(pick('deviceMode'), DEFAULTS.deviceMode)
|
|
98
|
+
/** 布尔项:显式配置优先;没配时用 deviceMode 的默认值;再退回普通默认值。 */
|
|
99
|
+
const bool = (key, deviceDefault) => (input[key] === undefined || input[key] === null
|
|
100
|
+
? (device ? deviceDefault : DEFAULTS[key])
|
|
101
|
+
: flag(input[key], DEFAULTS[key]))
|
|
89
102
|
const out = {
|
|
90
103
|
host: text(pick('host'), DEFAULTS.host),
|
|
91
104
|
port: num(pick('port'), DEFAULTS.port, 1),
|
|
92
105
|
identityFile: text(pick('identityFile'), DEFAULTS.identityFile).replace(/\\/g, '/'),
|
|
106
|
+
identityFileGuest: text(pick('identityFileGuest'), DEFAULTS.identityFileGuest),
|
|
107
|
+
sshBinary: text(pick('sshBinary'), DEFAULTS.sshBinary),
|
|
108
|
+
sshCompat: text(pick('sshCompat'), DEFAULTS.sshCompat),
|
|
109
|
+
connectTimeoutMs: num(pick('connectTimeoutMs'), DEFAULTS.connectTimeoutMs, 1000),
|
|
110
|
+
commandTimeoutMs: num(pick('commandTimeoutMs'), DEFAULTS.commandTimeoutMs, 1000),
|
|
111
|
+
stallMs: num(pick('stallMs'), DEFAULTS.stallMs, 5000),
|
|
112
|
+
deviceMode: device,
|
|
93
113
|
connectionFile: text(pick('connectionFile'), DEFAULTS.connectionFile),
|
|
94
114
|
remoteRoot: text(pick('remoteRoot'), DEFAULTS.remoteRoot).replace(/\/+$/, '') || DEFAULTS.remoteRoot,
|
|
95
115
|
machineId: sanitizeMachineId(text(pick('machineId'), '')),
|
|
96
|
-
localPluginRoot: text(
|
|
116
|
+
localPluginRoot: text(
|
|
117
|
+
pick('localPluginRoot'),
|
|
118
|
+
process.platform === 'win32' ? 'D:/DSH工作区' : '',
|
|
119
|
+
).replace(/\\/g, '/').replace(/\/+$/, ''),
|
|
97
120
|
intervalMinutes: num(pick('intervalMinutes'), DEFAULTS.intervalMinutes, 1),
|
|
98
|
-
dailySync:
|
|
121
|
+
dailySync: bool('dailySync', true),
|
|
99
122
|
autoSync: flag(pick('autoSync'), DEFAULTS.autoSync),
|
|
100
123
|
applyRemoteDeletes: flag(pick('applyRemoteDeletes'), DEFAULTS.applyRemoteDeletes),
|
|
101
124
|
pauseWhenIdle: flag(pick('pauseWhenIdle'), DEFAULTS.pauseWhenIdle),
|
|
102
125
|
idlePauseMinutes: num(pick('idlePauseMinutes'), DEFAULTS.idlePauseMinutes, 1),
|
|
103
|
-
restoreOnEmpty:
|
|
104
|
-
includeAttachments:
|
|
126
|
+
restoreOnEmpty: bool('restoreOnEmpty', false),
|
|
127
|
+
includeAttachments: bool('includeAttachments', false),
|
|
105
128
|
includeCredentials: flag(pick('includeCredentials'), DEFAULTS.includeCredentials),
|
|
106
129
|
includeKeyruleSecrets: flag(pick('includeKeyruleSecrets'), DEFAULTS.includeKeyruleSecrets),
|
|
107
|
-
includeLocalPlugins:
|
|
130
|
+
includeLocalPlugins: bool('includeLocalPlugins', false),
|
|
108
131
|
quietSeconds: num(pick('quietSeconds'), DEFAULTS.quietSeconds, 0),
|
|
109
132
|
trashDays: num(pick('trashDays'), DEFAULTS.trashDays, 1),
|
|
110
133
|
versionDays: num(pick('versionDays'), DEFAULTS.versionDays, 1),
|
|
@@ -141,7 +164,12 @@ export function sanitizeMachineId(value) {
|
|
|
141
164
|
/** 当前机器名(默认用主机名;用户可在 Config 里覆盖)。 */
|
|
142
165
|
export function defaultMachineId() {
|
|
143
166
|
try {
|
|
144
|
-
|
|
167
|
+
const host = sanitizeMachineId(os.hostname())
|
|
168
|
+
// 手机/容器里 hostname 常是 localhost 或空,多台设备会撞同一槽位 → 加平台后缀
|
|
169
|
+
if (host === '' || host === 'localhost' || host === '127-0-0-1') {
|
|
170
|
+
return sanitizeMachineId(`${host || 'device'}-${process.platform}`) || 'machine'
|
|
171
|
+
}
|
|
172
|
+
return host
|
|
145
173
|
} catch {
|
|
146
174
|
return 'machine'
|
|
147
175
|
}
|
|
@@ -154,9 +182,16 @@ export function dshHome() {
|
|
|
154
182
|
: path.join(os.homedir(), '.dsh')
|
|
155
183
|
}
|
|
156
184
|
|
|
157
|
-
/** 当前 profile
|
|
185
|
+
/** 当前 profile 名:环境变量 → profiles 下唯一子目录 → desktop。 */
|
|
158
186
|
export function profileName() {
|
|
159
|
-
|
|
187
|
+
const fromEnv = process.env.DSH_PROFILE
|
|
188
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv.trim()
|
|
189
|
+
try {
|
|
190
|
+
const root = path.join(dshHome(), 'profiles')
|
|
191
|
+
const entries = fs.readdirSync(root, { withFileTypes: true }).filter((item) => item.isDirectory())
|
|
192
|
+
if (entries.length === 1) return entries[0].name
|
|
193
|
+
} catch { /* 读不到就用默认 */ }
|
|
194
|
+
return 'desktop'
|
|
160
195
|
}
|
|
161
196
|
|
|
162
197
|
/** 当前 profile 目录。 */
|
|
@@ -377,13 +412,24 @@ export async function applyTombstones(options) {
|
|
|
377
412
|
|
|
378
413
|
/* ── 命令执行 ───────────────────────────────────────────────────────────── */
|
|
379
414
|
|
|
380
|
-
/**
|
|
381
|
-
function
|
|
415
|
+
/** 展开 `~` 与 `%VAR%`(配置里写相对/环境变量路径时用)。 */
|
|
416
|
+
export function expandPath(value) {
|
|
417
|
+
if (typeof value !== 'string' || value.trim() === '') return ''
|
|
418
|
+
let out = value.trim()
|
|
419
|
+
if (out === '~') out = os.homedir()
|
|
420
|
+
else if (out.startsWith('~/') || out.startsWith('~\\')) out = path.join(os.homedir(), out.slice(2))
|
|
421
|
+
out = out.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (all, name) => process.env[name] ?? all)
|
|
422
|
+
return out
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** ssh 可执行文件:配置优先 → 环境变量 → Windows 自带路径 → PATH 里的 ssh。 */
|
|
426
|
+
function sshBinary(config) {
|
|
427
|
+
const configured = typeof config.sshBinary === 'string' ? expandPath(config.sshBinary) : ''
|
|
428
|
+
if (configured !== '') return configured
|
|
429
|
+
const fromEnv = typeof process.env.DSH_CLOUD_SYNC_SSH === 'string' ? process.env.DSH_CLOUD_SYNC_SSH : ''
|
|
430
|
+
if (fromEnv !== '') return fromEnv
|
|
382
431
|
if (process.platform !== 'win32') return 'ssh'
|
|
383
|
-
const candidates = [
|
|
384
|
-
process.env.DSH_CLOUD_SYNC_SSH,
|
|
385
|
-
path.join(process.env.WINDIR ?? 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe'),
|
|
386
|
-
].filter((item) => typeof item === 'string' && item !== '')
|
|
432
|
+
const candidates = [path.join(process.env.WINDIR ?? 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')]
|
|
387
433
|
for (const candidate of candidates) {
|
|
388
434
|
try {
|
|
389
435
|
if (fs.existsSync(candidate)) return candidate
|
|
@@ -392,26 +438,52 @@ function sshBinary() {
|
|
|
392
438
|
return 'ssh'
|
|
393
439
|
}
|
|
394
440
|
|
|
441
|
+
/**
|
|
442
|
+
* 私钥路径:`~` 展开;显式配置了却不存在 → 明确报错(不要静默退化成默认密钥,
|
|
443
|
+
* 那样只会得到 Permission denied,看不出真实原因)。
|
|
444
|
+
* `identityFileGuest` 非空时优先用它——那是 guest/VM 世界里的路径,宿主侧无法校验。
|
|
445
|
+
*/
|
|
446
|
+
function identityPath(config) {
|
|
447
|
+
const guest = expandPath(config.identityFileGuest)
|
|
448
|
+
if (guest !== '') return guest
|
|
449
|
+
const raw = expandPath(config.identityFile)
|
|
450
|
+
if (raw === '') return ''
|
|
451
|
+
if (!fs.existsSync(raw)) {
|
|
452
|
+
throw new Error(`私钥不存在:${raw}(检查 ssh.json / identityFile;手机端要填 guest 内可见的路径,可用 identityFileGuest)`)
|
|
453
|
+
}
|
|
454
|
+
return raw
|
|
455
|
+
}
|
|
456
|
+
|
|
395
457
|
/** 远程命令里出现的字符串一律单引号包裹。 */
|
|
396
458
|
export function shQuote(value) {
|
|
397
459
|
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
398
460
|
}
|
|
399
461
|
|
|
400
|
-
/**
|
|
462
|
+
/**
|
|
463
|
+
* 拼出 ssh 的固定参数(不含目标与命令)。
|
|
464
|
+
* `sshCompat`:openssh(默认)/ dropbear(精简 -o,改用 -y 接受主机密钥)。
|
|
465
|
+
*/
|
|
401
466
|
function sshOptions(config) {
|
|
402
|
-
const args = ['-p', String(config.port)
|
|
403
|
-
if (config.
|
|
467
|
+
const args = ['-p', String(config.port)]
|
|
468
|
+
if (config.sshCompat === 'dropbear') {
|
|
469
|
+
args.push('-y')
|
|
470
|
+
} else {
|
|
471
|
+
args.push(
|
|
472
|
+
'-o', 'BatchMode=yes',
|
|
473
|
+
'-o', `ConnectTimeout=${Math.max(1, Math.round(config.connectTimeoutMs / 1000))}`,
|
|
474
|
+
'-o', 'ServerAliveInterval=20',
|
|
475
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
const identity = identityPath(config)
|
|
479
|
+
if (identity !== '') args.push('-i', identity)
|
|
404
480
|
return args
|
|
405
481
|
}
|
|
406
482
|
|
|
407
|
-
/** 打开一条 ssh
|
|
483
|
+
/** 打开一条 ssh 通道(stdin/stdout 均为管道,可流式传 tar)。 */
|
|
408
484
|
export function spawnSsh(config, remoteCommand) {
|
|
409
|
-
const
|
|
410
|
-
return
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function spawnChild(bin, args) {
|
|
414
|
-
return spawn(bin, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
485
|
+
const argv = [sshBinary(config), ...sshOptions(config), config.host, remoteCommand]
|
|
486
|
+
return spawnManaged(argv)
|
|
415
487
|
}
|
|
416
488
|
|
|
417
489
|
/** 跑一条远程命令,收集输出;超时就杀掉,避免一个卡住的连接拖死整个任务。 */
|
|
@@ -423,7 +495,7 @@ export async function runSsh(config, remoteCommand, options = {}) {
|
|
|
423
495
|
child.stderr.on('data', (chunk) => err.push(chunk))
|
|
424
496
|
if (options.stdin !== undefined) child.stdin.end(options.stdin)
|
|
425
497
|
else child.stdin.end()
|
|
426
|
-
const timeoutMs = options.timeoutMs ?? 60000
|
|
498
|
+
const timeoutMs = options.timeoutMs ?? config.commandTimeoutMs ?? 60000
|
|
427
499
|
const timer = setTimeout(() => child.kill(), timeoutMs)
|
|
428
500
|
if (typeof timer.unref === 'function') timer.unref()
|
|
429
501
|
const code = await new Promise((resolve, reject) => {
|
|
@@ -441,7 +513,7 @@ export async function runSsh(config, remoteCommand, options = {}) {
|
|
|
441
513
|
|
|
442
514
|
const BLOCK = 512
|
|
443
515
|
/** 传输卡死判定:连续这么久没收到/发出数据就杀掉 ssh,让任务失败重试而不是永远挂着。 */
|
|
444
|
-
const STALL_MS = 120000
|
|
516
|
+
const STALL_MS = 120000 // 兜底值;实际用 config.stallMs
|
|
445
517
|
|
|
446
518
|
function octal(value, width) {
|
|
447
519
|
const text = Math.max(0, Math.floor(value)).toString(8)
|
|
@@ -1109,7 +1181,7 @@ export async function push(options) {
|
|
|
1109
1181
|
let lastActivity = Date.now()
|
|
1110
1182
|
let stalled = false
|
|
1111
1183
|
const stallTimer = setInterval(() => {
|
|
1112
|
-
if (Date.now() - lastActivity >
|
|
1184
|
+
if (Date.now() - lastActivity > config.stallMs) {
|
|
1113
1185
|
stalled = true
|
|
1114
1186
|
lastActivity = Date.now()
|
|
1115
1187
|
child.kill()
|
|
@@ -1130,7 +1202,7 @@ export async function push(options) {
|
|
|
1130
1202
|
}
|
|
1131
1203
|
const result = await closed
|
|
1132
1204
|
if (stalled) {
|
|
1133
|
-
throw new Error(`上传 ${group} 时传输卡住(${Math.round(
|
|
1205
|
+
throw new Error(`上传 ${group} 时传输卡住(${Math.round(config.stallMs / 1000)} 秒没有进展),已断开;服务端只收到半截进入临时目录,不会污染现有备份,稍后重试即可`)
|
|
1134
1206
|
}
|
|
1135
1207
|
if (result.code !== 0) throw new Error(`上传 ${group} 失败(退出码 ${result.code}):${String(result.stderr).trim()}`)
|
|
1136
1208
|
uploaded += entries.length
|
|
@@ -1403,7 +1475,7 @@ export async function pull(options) {
|
|
|
1403
1475
|
let stalled = false
|
|
1404
1476
|
const reader = new ByteReader(child.stdout, () => { lastActivity = Date.now() })
|
|
1405
1477
|
const stallTimer = setInterval(() => {
|
|
1406
|
-
if (Date.now() - lastActivity >
|
|
1478
|
+
if (Date.now() - lastActivity > config.stallMs) {
|
|
1407
1479
|
stalled = true
|
|
1408
1480
|
lastActivity = Date.now()
|
|
1409
1481
|
child.kill()
|
|
@@ -1459,7 +1531,7 @@ export async function pull(options) {
|
|
|
1459
1531
|
clearInterval(stallTimer)
|
|
1460
1532
|
const result = await closed
|
|
1461
1533
|
if (stalled) {
|
|
1462
|
-
throw new Error(`恢复 ${group} 时传输卡住(${Math.round(
|
|
1534
|
+
throw new Error(`恢复 ${group} 时传输卡住(${Math.round(config.stallMs / 1000)} 秒没有数据),已断开;云端数据没动,稍后重试即可`)
|
|
1463
1535
|
}
|
|
1464
1536
|
if (result.code !== 0) {
|
|
1465
1537
|
log(`恢复 ${group} 时远端退出码 ${result.code}:${String(result.stderr).trim()}`)
|