nervur 0.22.2-4 → 0.22.2-5

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.
@@ -12,6 +12,8 @@ export class Rows {
12
12
  #placeNames = new Map();
13
13
  /** The last write holding each row, which the next one to hold it waits for. */
14
14
  #holds = new Map();
15
+ // How many times each row's cache has moved, so a read overtaken by a write never lands behind it.
16
+ #moves = new Map();
15
17
  #landed;
16
18
  constructor(memory, crypto, tools, names, key) {
17
19
  this.#memory = memory;
@@ -33,22 +35,36 @@ export class Rows {
33
35
  }
34
36
  return place;
35
37
  }
38
+ // A row's cache moved, by a write that landed or one refused: a read in flight across it is behind.
39
+ #moved(place) {
40
+ this.#moves.set(place, (this.#moves.get(place) ?? 0) + 1);
41
+ }
42
+ #forget(place) {
43
+ this.#moved(place);
44
+ this.#held.delete(place);
45
+ }
36
46
  async #read(place) {
37
- const cached = this.#held.get(place);
38
- if (cached !== undefined)
39
- return cached;
40
- const { entries, version } = await this.#memory.read({ place });
41
- const sealed = entries[ENTRY];
42
- let held = { row: null, text: null, version };
43
- if (sealed !== undefined) {
44
- const opened = await this.#crypto.open(this.#key, sealed.subarray(0, 12), sealed.subarray(12), this.#tools.utf8(place));
45
- const text = opened === null ? null : this.#tools.text(opened);
46
- if (text === null)
47
- throw new Error('a row does not open under these keys');
48
- held = { row: JSON.parse(text), text, version };
47
+ for (;;) {
48
+ const cached = this.#held.get(place);
49
+ if (cached !== undefined)
50
+ return cached;
51
+ const move = this.#moves.get(place) ?? 0;
52
+ const { entries, version } = await this.#memory.read({ place });
53
+ const sealed = entries[ENTRY];
54
+ let held = { row: null, text: null, version };
55
+ if (sealed !== undefined) {
56
+ const opened = await this.#crypto.open(this.#key, sealed.subarray(0, 12), sealed.subarray(12), this.#tools.utf8(place));
57
+ const text = opened === null ? null : this.#tools.text(opened);
58
+ if (text === null)
59
+ throw new Error('a row does not open under these keys');
60
+ held = { row: JSON.parse(text), text, version };
61
+ }
62
+ // A write that moved the row while this read was in flight wins, and this read looks again.
63
+ if ((this.#moves.get(place) ?? 0) !== move)
64
+ continue;
65
+ this.#held.set(place, held);
66
+ return held;
49
67
  }
50
- this.#held.set(place, held);
51
- return held;
52
68
  }
53
69
  /** A row as it stands, or `null`. */
54
70
  async get(place) {
@@ -152,10 +168,11 @@ export class Rows {
152
168
  }
153
169
  if (versions === null) {
154
170
  for (const place of Object.keys(expect))
155
- this.#held.delete(place);
171
+ this.#forget(place);
156
172
  return false;
157
173
  }
158
174
  for (const [place, held] of landed) {
175
+ this.#moved(place);
159
176
  this.#held.set(place, { ...held, version: versions[place] ?? null });
160
177
  this.#landed?.(place, held.row);
161
178
  }
package/dist/node/cli.js CHANGED
@@ -20,6 +20,7 @@ import { connect } from 'node:net';
20
20
  import { basename, join, resolve } from 'node:path';
21
21
  import { createInterface } from 'node:readline';
22
22
  import { fileURLToPath } from 'node:url';
23
+ import { handAt } from './hand.js';
23
24
  import { NodeGround } from './node-ground.js';
24
25
  import { notifyReady } from './notify.js';
25
26
  const USAGE = 'nervur up <folder> | service <folder> | [--at <socket>] help | ask <house> [--id <being>] [<method> [args]] | <faculty> [<method> [args]]';
@@ -127,7 +128,7 @@ const service = (folder) => {
127
128
  };
128
129
  const words = process.argv.slice(2);
129
130
  // The hand: named, or the one a ground in this folder serves.
130
- let at = process.env.NERVUR_HAND ?? (existsSync(join('state', 'hand')) ? join('state', 'hand') : undefined);
131
+ let at = process.env.NERVUR_HAND ?? (existsSync(process.platform === 'win32' ? 'state' : join('state', 'hand')) ? handAt('state') : undefined);
131
132
  if (words[0] === '--at') {
132
133
  at = words[1] ?? fail('--at names no socket');
133
134
  words.splice(0, 2);
@@ -5,6 +5,7 @@
5
5
  import { mkdir, open, readFile, rename } from 'node:fs/promises';
6
6
  import { dirname } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { syncFolder } from './sync-folder.js';
8
9
  const hex = (bytes) => Buffer.from(bytes).toString('hex');
9
10
  const unhex = (text) => new Uint8Array(Buffer.from(text, 'hex'));
10
11
  export class FileMemory {
@@ -80,12 +81,6 @@ export class FileMemory {
80
81
  await file.close();
81
82
  }
82
83
  await rename(temporary, this.#path);
83
- const directory = await open(folder, 'r');
84
- try {
85
- await directory.sync();
86
- }
87
- finally {
88
- await directory.close();
89
- }
84
+ await syncFolder(folder);
90
85
  }
91
86
  }
@@ -21,9 +21,16 @@ export interface HandRequest {
21
21
  /** The answer the owner holds, which makes a `readOnly` ask a watch. */
22
22
  readonly after?: Answer;
23
23
  }
24
+ /**
25
+ * Where a ground's hand stands for its state folder: a socket inside it,
26
+ * or on Windows a named pipe, which has no place in a folder, named for
27
+ * the folder's path.
28
+ */
29
+ export declare const handAt: (state: string, platform?: NodeJS.Platform) => string;
24
30
  /**
25
31
  * Serves `ask` at `path`: a house's own hand, or a ground's, which also
26
32
  * reads `house` and changes its record. A socket left there by a process
27
- * that is gone is replaced.
33
+ * that is gone is replaced. A named pipe on Windows is open to its user
34
+ * alone, as the operating system makes it, and leaves nothing behind.
28
35
  */
29
36
  export declare const serveHand: (ask: (request: HandRequest) => Promise<unknown>, path: string) => Promise<Hand>;
package/dist/node/hand.js CHANGED
@@ -1,8 +1,11 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The hand on a local socket: one JSON object a line in, one answer a
3
- // line out, in order. The socket is readable by the ground's user alone.
2
+ // The hand on a local socket, or on Windows a named pipe: one JSON object
3
+ // a line in, one answer a line out, in order. It is open to the ground's
4
+ // user alone.
5
+ import { createHash } from 'node:crypto';
4
6
  import { chmod } from 'node:fs/promises';
5
7
  import { createServer } from 'node:net';
8
+ import { join, resolve as absolute } from 'node:path';
6
9
  import { createInterface } from 'node:readline';
7
10
  import { gone } from './gone.js';
8
11
  const object = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -46,16 +49,29 @@ const serve = (ask, socket) => {
46
49
  // A local socket's path fits the kernel's address: 103 bytes on macOS and
47
50
  // the BSDs, 107 on Linux, with the closing zero beyond.
48
51
  const LONGEST = process.platform === 'linux' ? 107 : 103;
52
+ const PIPE = /^\\\\[.?]\\pipe\\/;
53
+ /**
54
+ * Where a ground's hand stands for its state folder: a socket inside it,
55
+ * or on Windows a named pipe, which has no place in a folder, named for
56
+ * the folder's path.
57
+ */
58
+ export const handAt = (state, platform = process.platform) => platform === 'win32' ? `\\\\.\\pipe\\nervur-${createHash('sha256').update(absolute(state).toLowerCase()).digest('hex').slice(0, 16)}` : join(state, 'hand');
49
59
  /**
50
60
  * Serves `ask` at `path`: a house's own hand, or a ground's, which also
51
61
  * reads `house` and changes its record. A socket left there by a process
52
- * that is gone is replaced.
62
+ * that is gone is replaced. A named pipe on Windows is open to its user
63
+ * alone, as the operating system makes it, and leaves nothing behind.
53
64
  */
54
65
  export const serveHand = async (ask, path) => {
66
+ if (PIPE.test(path))
67
+ return listen(ask, path, false);
55
68
  const length = Buffer.byteLength(path);
56
69
  if (length > LONGEST)
57
70
  throw new Error(`a socket's path is at most ${LONGEST} bytes here, and ${path} is ${length}: serve the hand from a shorter folder`);
58
71
  await gone(path);
72
+ return listen(ask, path, true);
73
+ };
74
+ const listen = async (ask, path, file) => {
59
75
  const server = createServer((socket) => serve(ask, socket));
60
76
  const sockets = new Set();
61
77
  server.on('connection', (socket) => {
@@ -64,6 +80,8 @@ export const serveHand = async (ask, path) => {
64
80
  });
65
81
  await new Promise((resolve, reject) => {
66
82
  server.once('error', reject);
83
+ if (!file)
84
+ return void server.listen(path, () => resolve());
67
85
  // Closed to everyone from the first moment, then opened to the user.
68
86
  const mask = process.umask(0o177);
69
87
  try {
@@ -73,13 +91,15 @@ export const serveHand = async (ask, path) => {
73
91
  process.umask(mask);
74
92
  }
75
93
  });
76
- await chmod(path, 0o600);
94
+ if (file)
95
+ await chmod(path, 0o600);
77
96
  return {
78
97
  close: async () => {
79
98
  for (const socket of sockets)
80
99
  socket.destroy();
81
100
  await new Promise((resolve) => server.close(() => resolve()));
82
- await gone(path);
101
+ if (file)
102
+ await gone(path);
83
103
  },
84
104
  };
85
105
  };
@@ -9,6 +9,7 @@ import { createHash } from 'node:crypto';
9
9
  import { mkdir, open, readFile, rename, truncate } from 'node:fs/promises';
10
10
  import { dirname } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import { syncFolder } from './sync-folder.js';
12
13
  const GENESIS = { n: 0, hash: '0'.repeat(64) };
13
14
  const hash = (text) => createHash('sha256').update(text).digest('hex');
14
15
  const unhex = (text) => new Uint8Array(Buffer.from(text, 'hex'));
@@ -103,7 +104,7 @@ export class LedgerMemory {
103
104
  const temporary = `${this.#witness}.${process.pid}.tmp`;
104
105
  await synced(temporary, 'w', (handle) => handle.writeFile(JSON.stringify(head)));
105
106
  await rename(temporary, this.#witness);
106
- await synced(dirname(this.#witness), 'r', async () => undefined);
107
+ await syncFolder(dirname(this.#witness));
107
108
  }
108
109
  async read({ place }) {
109
110
  const held = (await this.#open()).places.get(place);
@@ -135,7 +136,7 @@ export class LedgerMemory {
135
136
  try {
136
137
  await synced(this.#path, 'a', (handle) => handle.appendFile(`${raw}\n`));
137
138
  if (line.n === 1)
138
- await synced(dirname(this.#path), 'r', async () => undefined);
139
+ await syncFolder(dirname(this.#path));
139
140
  if (this.#witness !== undefined)
140
141
  await this.#attest(head);
141
142
  }
@@ -20,7 +20,7 @@ import { WebCarry } from '../bodies/web-carry.js';
20
20
  import { Ground } from '../ground/ground.js';
21
21
  import { FolderCustody, KeychainCustody } from './custody.js';
22
22
  import { FolderClasses } from './folder-classes.js';
23
- import { serveHand } from './hand.js';
23
+ import { handAt, serveHand } from './hand.js';
24
24
  import { serveHttp } from './http.js';
25
25
  import { LedgerMemory } from './ledger-memory.js';
26
26
  import { takeLock } from './lock.js';
@@ -127,7 +127,7 @@ export class NodeGround {
127
127
  // 6. Hook: HTTP chains the web carry and every faculty's handler, and the hand takes its socket.
128
128
  if (httpPort !== undefined)
129
129
  http = await serveHttp({ port: httpPort, host: bind }, [web, ...ground.handlers]);
130
- const hand = env.NERVUR_HAND ?? join(state, 'hand');
130
+ const hand = env.NERVUR_HAND ?? handAt(state);
131
131
  const served = await serveHand((request) => ground.hand(request), hand);
132
132
  return new NodeGround({ ground, hand, tcp, lock, served, http });
133
133
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Makes a file's creation or renaming in `folder` durable. On POSIX the
3
+ * folder is synced. Windows opens no folder for syncing, and NTFS journals
4
+ * a creation and a rename itself, so there it does nothing.
5
+ */
6
+ export declare const syncFolder: (folder: string, platform?: NodeJS.Platform) => Promise<void>;
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { open } from 'node:fs/promises';
3
+ /**
4
+ * Makes a file's creation or renaming in `folder` durable. On POSIX the
5
+ * folder is synced. Windows opens no folder for syncing, and NTFS journals
6
+ * a creation and a rename itself, so there it does nothing.
7
+ */
8
+ export const syncFolder = async (folder, platform = process.platform) => {
9
+ if (platform === 'win32')
10
+ return;
11
+ const handle = await open(folder, 'r');
12
+ try {
13
+ await handle.sync();
14
+ }
15
+ finally {
16
+ await handle.close();
17
+ }
18
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nervur",
3
- "version": "0.22.2-4",
3
+ "version": "0.22.2-5",
4
4
  "description": "Nervur's kit of Quo: write beings and faculties, and open the house that holds them on any ground.",
5
5
  "keywords": [
6
6
  "quo",