socketduct 0.1.26 → 0.1.27

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.
@@ -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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Resilient authenticated tunnel/proxy for keeping socket streams alive across flaky links.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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
- const existing = await readOwnerFile(lockPath)
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.
@@ -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
  /**