socketduct 0.1.26 → 0.1.28
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/changelog.d/20261025070000-reclaim-unreadable-retention-lock.md +14 -0
- package/changelog.d/20261025162000-gateway-unavailability-report-is-not-a-timer.md +1 -0
- package/package.json +1 -1
- package/src/client/multiplexed-transport-set.js +1 -1
- package/src/logging/log-retention.js +36 -1
- package/src/reverse/client.js +9 -5
- package/test/integration/reverse-route-class.test.js +6 -0
- package/test/unit/logging/log-retention.test.js +19 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Reclaim unreadable retention locks
|
|
2
|
+
|
|
3
|
+
Retention now reclaims a `.socketduct-retention.lock` whose owner record this
|
|
4
|
+
process cannot read instead of failing the whole run. A lock written by a
|
|
5
|
+
privileged one-off process (a root repair or cleanup) is mode 0600 and owned by
|
|
6
|
+
another uid, so every later retention attempt raised `EACCES` from
|
|
7
|
+
`readOwnerFile`. That error propagated out of the durable log sink at startup and
|
|
8
|
+
took down the server process that owns the sink.
|
|
9
|
+
|
|
10
|
+
The lock directory is private and peer writers share this process's uid, so an
|
|
11
|
+
unreadable record cannot belong to a live peer of the runtime. Retention now
|
|
12
|
+
unlinks such a file and retries the atomic link. If the file cannot be removed,
|
|
13
|
+
retention fails with `LOG_RETENTION_LOCK_UNREADABLE`, which names the lock path
|
|
14
|
+
and the owning uid instead of surfacing a bare `EACCES`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
A hub route-class unavailability report now lasts for the life of the connection instead of five seconds, so a transport set stops re-selecting a route class the hub already declared unserviceable and no longer fails the caller's first open on it.
|
package/package.json
CHANGED
|
@@ -44,7 +44,7 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
|
|
|
44
44
|
* @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured logger shared by the set and its members.
|
|
45
45
|
* @typedef {object} MemberConnectionStatus
|
|
46
46
|
* @property {string} physicalState - Member lifecycle.
|
|
47
|
-
* @property {boolean} [gatewayUnavailable] - Hub
|
|
47
|
+
* @property {boolean} [gatewayUnavailable] - Hub reported no selectable gateway for this member's route class on the current connection.
|
|
48
48
|
* @property {number} connectionGeneration - Current authenticated generation.
|
|
49
49
|
* @property {number} physicalConnects - Authenticated physical generations.
|
|
50
50
|
* @property {number} reconnectAttempts - Physical reconnect attempts.
|
|
@@ -94,7 +94,20 @@ async function acquireRetentionLock(directory, lockPath) {
|
|
|
94
94
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
95
95
|
const created = await createOwnerFile(directory, lockPath, owner)
|
|
96
96
|
if (created !== undefined) return created
|
|
97
|
-
|
|
97
|
+
let existing
|
|
98
|
+
try {
|
|
99
|
+
existing = await readOwnerFile(lockPath)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// A lock this process cannot read cannot belong to a live peer: peers write
|
|
102
|
+
// their owner record mode 0600 under their own uid into this private
|
|
103
|
+
// directory. Such a file is left behind by a privileged one-off process
|
|
104
|
+
// (a root-run repair or cleanup), and because its record is unreadable here
|
|
105
|
+
// it would block retention for every later run - failing the durable log
|
|
106
|
+
// sink and, with it, the server process that owns the sink.
|
|
107
|
+
if (!isCode(error, "EACCES") && !isCode(error, "EPERM")) throw error
|
|
108
|
+
await reclaimUnreadableLock(directory, lockPath)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
98
111
|
if (existing === undefined) continue
|
|
99
112
|
if (await ownerIsLive(existing.owner)) return undefined
|
|
100
113
|
const current = await lstat(lockPath).catch((error) => { if (isCode(error, "ENOENT")) return undefined; throw error })
|
|
@@ -106,6 +119,28 @@ async function acquireRetentionLock(directory, lockPath) {
|
|
|
106
119
|
return undefined
|
|
107
120
|
}
|
|
108
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Removes a retention lock that this process cannot read, so the caller can retry
|
|
124
|
+
* the atomic link. Unlink is attempted rather than deferred: the directory is
|
|
125
|
+
* private, and a lock whose owner record is unreadable here cannot be held by a
|
|
126
|
+
* peer of this runtime.
|
|
127
|
+
* @param {string} directory - Private log directory.
|
|
128
|
+
* @param {string} lockPath - Unreadable lock path.
|
|
129
|
+
*/
|
|
130
|
+
async function reclaimUnreadableLock(directory, lockPath) {
|
|
131
|
+
const stats = await lstat(lockPath).catch((error) => { if (isCode(error, "ENOENT")) return undefined; throw error })
|
|
132
|
+
if (stats === undefined) return
|
|
133
|
+
if (!stats.isFile()) throw Object.assign(new Error("Unsafe retention lock"), {code: "LOG_RETENTION_LOCK_INVALID"})
|
|
134
|
+
await unlink(lockPath).catch((error) => {
|
|
135
|
+
if (isCode(error, "ENOENT")) return
|
|
136
|
+
throw Object.assign(
|
|
137
|
+
new Error(`Retention lock ${lockPath} is owned by uid ${stats.uid} and cannot be reclaimed by uid ${process.getuid?.()}: ${error instanceof Error ? error.message : String(error)}`),
|
|
138
|
+
{cause: error, code: "LOG_RETENTION_LOCK_UNREADABLE"}
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
await syncDirectory(directory)
|
|
142
|
+
}
|
|
143
|
+
|
|
109
144
|
/**
|
|
110
145
|
* @param {string} directory - Directory.
|
|
111
146
|
* @param {string} lockPath - Destination.
|
package/src/reverse/client.js
CHANGED
|
@@ -32,7 +32,6 @@ import {attachDisconnectAttribution, createDisconnectAttribution, parseDisconnec
|
|
|
32
32
|
const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
|
|
33
33
|
const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
|
|
34
34
|
const MANIFEST_PAGE_STREAMS = 64
|
|
35
|
-
const GATEWAY_UNAVAILABLE_TTL_MS = 5_000
|
|
36
35
|
|
|
37
36
|
/**
|
|
38
37
|
* @typedef {object} ReverseGatewayConnectionOptions
|
|
@@ -373,13 +372,18 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
373
372
|
}
|
|
374
373
|
|
|
375
374
|
/**
|
|
376
|
-
* Whether the last
|
|
377
|
-
*
|
|
378
|
-
*
|
|
375
|
+
* Whether the hub last reported this member's route class as having no selectable
|
|
376
|
+
* gateway. The report is a property of the live connection, not a short-lived cache:
|
|
377
|
+
* it arrives once per connection at selection time and only changes when the hub says
|
|
378
|
+
* otherwise or an open succeeds. Expiring it on a timer made the transport set pick a
|
|
379
|
+
* route class the hub had already declared unserviceable, and the reset that followed
|
|
380
|
+
* failed the caller's operation — a build lost its first transport open this way.
|
|
381
|
+
* The transport set still falls back to marked members when every member is marked, so
|
|
382
|
+
* a mark can never wedge the set.
|
|
383
|
+
* @returns {boolean} True while the hub reports no selectable gateway for this member.
|
|
379
384
|
*/
|
|
380
385
|
#gatewayUnavailable() {
|
|
381
386
|
return this.#gatewayUnavailableSince !== undefined
|
|
382
|
-
&& Date.now() - this.#gatewayUnavailableSince < GATEWAY_UNAVAILABLE_TTL_MS
|
|
383
387
|
}
|
|
384
388
|
|
|
385
389
|
status() {
|
|
@@ -436,6 +436,12 @@ describe("reverse gateway route-class", () => {
|
|
|
436
436
|
expect(client.status().physicalState).toBe("ready")
|
|
437
437
|
expect(client.status().gatewayUnavailable).toBe(true)
|
|
438
438
|
|
|
439
|
+
// The report belongs to the live connection: it must survive any short window a caller
|
|
440
|
+
// might wait between the announce and its first open. A timer-bound mark made the transport
|
|
441
|
+
// set pick this route class again and the resulting reset failed the caller's operation.
|
|
442
|
+
await new Promise((resolve) => setTimeout(resolve, 5200))
|
|
443
|
+
expect({value: client.status().gatewayUnavailable, context: "a hub unavailability report must not expire on a timer"}).toMatchObject({value: true})
|
|
444
|
+
|
|
439
445
|
// Once the gateway registers, a replacement selection announces selectable=true and clears the mark.
|
|
440
446
|
const gatewayA = new OutboundReverseGateway({
|
|
441
447
|
gatewayId: "server-a-starlink",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
|
|
2
2
|
import {describe, expect, it, withTestCleanup} from "@velocious/testing"
|
|
3
|
-
import {readlinkSync} from "node:fs"
|
|
3
|
+
import {chmodSync, chownSync, readlinkSync} from "node:fs"
|
|
4
4
|
import {mkdtemp, readFile, readdir, rm, utimes, writeFile} from "node:fs/promises"
|
|
5
5
|
import {tmpdir} from "node:os"
|
|
6
6
|
import {join} from "node:path"
|
|
@@ -63,6 +63,24 @@ describe("log retention", () => {
|
|
|
63
63
|
await writeFile(lockPath, `${JSON.stringify({version: 1, pid: process.pid, processStart, tid, threadStart})}\n`, {mode: 0o600})
|
|
64
64
|
expect((await enforceLogRetention({directory, maxAgeMs: 60_000, maxBytes: 1024})).skipped).toBe(true)
|
|
65
65
|
}))
|
|
66
|
+
|
|
67
|
+
it("retention reclaims a lock left behind by a privileged process it cannot read", async () => withTestCleanup(async (after) => {
|
|
68
|
+
const directory = await mkdtemp(join(tmpdir(), "socketduct-retention-foreign-"))
|
|
69
|
+
after(() => rm(directory, {recursive: true, force: true}))
|
|
70
|
+
const lockPath = join(directory, ".socketduct-retention.lock")
|
|
71
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
|
|
72
|
+
|
|
73
|
+
await writeFile(lockPath, `${JSON.stringify({version: 1, pid: 1, processStart: "1", tid: 1, threadStart: "1"})}\n`, {mode: 0o600})
|
|
74
|
+
|
|
75
|
+
if (uid === 0) chownSync(lockPath, 65_534, 65_534)
|
|
76
|
+
else if (uid !== undefined) chmodSync(lockPath, 0o000)
|
|
77
|
+
|
|
78
|
+
// The lock owner record is unreadable for this process, so retention cannot
|
|
79
|
+
// wait for it: it must reclaim the file and complete instead of failing the
|
|
80
|
+
// durable log sink for the whole server process.
|
|
81
|
+
expect((await enforceLogRetention({directory, maxAgeMs: 60_000, maxBytes: 1024})).skipped).toBe(false)
|
|
82
|
+
expect((await readdir(directory)).includes(".socketduct-retention.lock")).toBe(false)
|
|
83
|
+
}))
|
|
66
84
|
})
|
|
67
85
|
|
|
68
86
|
/**
|