space-data-module-sdk 0.8.20 → 0.8.22
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/docs/isomorphic-pthreads.html +100 -0
- package/docs/isomorphic-pthreads.md +335 -0
- package/package.json +7 -2
- package/src/browser.js +6 -0
- package/src/compiler/invokeGlue.js +38 -0
- package/src/flow/flatsqlLinkShim.js +295 -0
- package/src/flow/index.d.ts +31 -0
- package/src/flow/index.js +8 -0
- package/src/host/browserCapabilityProbe.js +251 -0
- package/src/host/browserModuleHarness.js +24 -5
- package/src/host/flatsqlIo.js +14 -0
- package/src/host/flatsqlIoConformance.js +349 -0
- package/src/host/flatsqlIoContract.js +240 -0
- package/src/host/flatsqlIoImports.js +335 -0
- package/src/host/flatsqlIoMemoryBackend.js +178 -0
- package/src/host/flatsqlIoServer.js +960 -0
- package/src/host/flatsqlIoWorkers.js +323 -0
- package/src/host/hostWorkerBundleSources.js +11 -0
- package/src/host/hostWorkerBundles.js +79 -0
- package/src/host/index.js +9 -0
- package/src/host/isomorphicLoader.js +10 -2
- package/src/host/nodeSyncFsIo.js +633 -0
- package/src/host/opfsIoBackend.js +280 -0
- package/src/host/opfsIoWorker.mjs +223 -0
- package/src/host/sabIoChannel.js +848 -0
- package/src/host/sabIoMirror.js +306 -0
- package/src/host/wasiThreadBrowserWorker.mjs +21 -11
- package/src/host/wasiThreadHost.js +261 -43
- package/src/host/wasiThreadWorker.mjs +46 -8
- package/src/host/wasiThreadWorkerRuntime.js +54 -0
- package/src/index.d.ts +349 -0
- package/src/runtime/constants.js +6 -0
- package/src/testing/native/wasmedge_wasi_threads_runner.c +13 -5
- package/src/testing/parityHarness.js +7 -1
- package/src/testing/parityLanes.js +19 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Small-file mirror in a SharedArrayBuffer: the A7 head mirror
|
|
2
|
+
// (docs/architecture/flatsql-partition-store.md §22 A7).
|
|
3
|
+
//
|
|
4
|
+
// "After each head pwrite, the writer I/O worker copies the slot into a per-pid
|
|
5
|
+
// seqlock mirror in a SharedArrayBuffer. In the browser, §8 step 3 reads the
|
|
6
|
+
// mirror, never a handle."
|
|
7
|
+
//
|
|
8
|
+
// The mirror knows nothing about heads. It keeps, per mirrored path, the first
|
|
9
|
+
// `entryBytes` bytes of the file plus the file's exact size, as last written
|
|
10
|
+
// through the writing I/O worker. A reader that consults it gets exactly what a
|
|
11
|
+
// read of the file would return (page-cache semantics: written, not
|
|
12
|
+
// necessarily flushed) without a round trip to any I/O worker, so a reader
|
|
13
|
+
// never queues behind a writer's flush for a head.
|
|
14
|
+
//
|
|
15
|
+
// Concurrency: one writer per path (the partition's single writer, law
|
|
16
|
+
// refinement 1), any number of readers. Each entry is a seqlock: the writer
|
|
17
|
+
// makes the sequence odd, copies, and makes it even; a reader retries when the
|
|
18
|
+
// sequence was odd or changed. Entries are found by open addressing on an
|
|
19
|
+
// FNV-1a hash of the path, and the path bytes are compared exactly.
|
|
20
|
+
//
|
|
21
|
+
// Layout (all little-endian):
|
|
22
|
+
// header Int32[16]: magic 'SDMR', version, entries, entryBytes, maxPathBytes
|
|
23
|
+
// records entries x 32 B: Int32 {state, seq, hash, pathLen, covered, rsv}
|
|
24
|
+
// Float64 fileSize at +24
|
|
25
|
+
// paths entries x maxPathBytes
|
|
26
|
+
// data entries x entryBytes
|
|
27
|
+
|
|
28
|
+
const MIRROR_MAGIC = 0x524d4453; // "SDMR"
|
|
29
|
+
const MIRROR_VERSION = 1;
|
|
30
|
+
const HEADER_WORDS = 16;
|
|
31
|
+
const RECORD_BYTES = 32;
|
|
32
|
+
const R_STATE = 0;
|
|
33
|
+
const R_SEQ = 1;
|
|
34
|
+
const R_HASH = 2;
|
|
35
|
+
const R_PATH_LEN = 3;
|
|
36
|
+
const R_COVERED = 4;
|
|
37
|
+
const R_F64_SIZE = 3; // Float64 index within the record
|
|
38
|
+
|
|
39
|
+
const STATE_EMPTY = 0;
|
|
40
|
+
const STATE_CLAIMING = 1;
|
|
41
|
+
const STATE_LIVE = 2;
|
|
42
|
+
const STATE_CLEARED = 3;
|
|
43
|
+
|
|
44
|
+
/** Reads give up on the mirror after this many torn attempts and use the file. */
|
|
45
|
+
const MAX_READ_ATTEMPTS = 64;
|
|
46
|
+
|
|
47
|
+
const encoder = typeof TextEncoder === "function" ? new TextEncoder() : null;
|
|
48
|
+
|
|
49
|
+
function fnv1a(bytes) {
|
|
50
|
+
let hash = 0x811c9dc5;
|
|
51
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
52
|
+
hash ^= bytes[i];
|
|
53
|
+
hash = Math.imul(hash, 0x01000193);
|
|
54
|
+
}
|
|
55
|
+
return hash | 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Allocate a mirror.
|
|
60
|
+
* @param {object} [options]
|
|
61
|
+
* @param {number} [options.entries=2048] mirrored files (the design sizes for
|
|
62
|
+
* 2,048 partitions).
|
|
63
|
+
* @param {number} [options.entryBytes=8192] mirrored prefix per file (a head is
|
|
64
|
+
* two 4 KiB slots).
|
|
65
|
+
* @param {number} [options.maxPathBytes=512]
|
|
66
|
+
*/
|
|
67
|
+
export function createSabIoMirrorBuffer(options = {}) {
|
|
68
|
+
const entries = options.entries ?? 2048;
|
|
69
|
+
const entryBytes = options.entryBytes ?? 8192;
|
|
70
|
+
const maxPathBytes = options.maxPathBytes ?? 512;
|
|
71
|
+
if (!Number.isInteger(entries) || entries < 1) throw new RangeError("entries must be >= 1.");
|
|
72
|
+
if (!Number.isInteger(entryBytes) || entryBytes < 8 || entryBytes % 8 !== 0) {
|
|
73
|
+
throw new RangeError("entryBytes must be a positive multiple of 8.");
|
|
74
|
+
}
|
|
75
|
+
if (!Number.isInteger(maxPathBytes) || maxPathBytes < 8 || maxPathBytes % 8 !== 0) {
|
|
76
|
+
throw new RangeError("maxPathBytes must be a positive multiple of 8.");
|
|
77
|
+
}
|
|
78
|
+
const bytes = HEADER_WORDS * 4 + entries * (RECORD_BYTES + maxPathBytes + entryBytes);
|
|
79
|
+
const buffer = new SharedArrayBuffer(bytes);
|
|
80
|
+
const header = new Int32Array(buffer, 0, HEADER_WORDS);
|
|
81
|
+
header[0] = MIRROR_MAGIC;
|
|
82
|
+
header[1] = MIRROR_VERSION;
|
|
83
|
+
header[2] = entries;
|
|
84
|
+
header[3] = entryBytes;
|
|
85
|
+
header[4] = maxPathBytes;
|
|
86
|
+
return buffer;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const layoutCache = new WeakMap();
|
|
90
|
+
|
|
91
|
+
function layoutOf(buffer) {
|
|
92
|
+
let layout = layoutCache.get(buffer);
|
|
93
|
+
if (layout) return layout;
|
|
94
|
+
const header = new Int32Array(buffer, 0, HEADER_WORDS);
|
|
95
|
+
if (header[0] !== MIRROR_MAGIC || header[1] !== MIRROR_VERSION) {
|
|
96
|
+
throw new TypeError("Not a SAB I/O mirror buffer.");
|
|
97
|
+
}
|
|
98
|
+
const entries = header[2];
|
|
99
|
+
const entryBytes = header[3];
|
|
100
|
+
const maxPathBytes = header[4];
|
|
101
|
+
const recordsOffset = HEADER_WORDS * 4;
|
|
102
|
+
const pathsOffset = recordsOffset + entries * RECORD_BYTES;
|
|
103
|
+
const dataOffset = pathsOffset + entries * maxPathBytes;
|
|
104
|
+
layout = {
|
|
105
|
+
entries,
|
|
106
|
+
entryBytes,
|
|
107
|
+
maxPathBytes,
|
|
108
|
+
i32: new Int32Array(buffer, recordsOffset, (entries * RECORD_BYTES) / 4),
|
|
109
|
+
f64: new Float64Array(buffer, recordsOffset, (entries * RECORD_BYTES) / 8),
|
|
110
|
+
paths: new Uint8Array(buffer, pathsOffset, entries * maxPathBytes),
|
|
111
|
+
data: new Uint8Array(buffer, dataOffset, entries * entryBytes),
|
|
112
|
+
};
|
|
113
|
+
layoutCache.set(buffer, layout);
|
|
114
|
+
return layout;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The mirrored prefix length of this buffer. */
|
|
118
|
+
export function sabIoMirrorEntryBytes(buffer) {
|
|
119
|
+
return layoutOf(buffer).entryBytes;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function toBytes(path) {
|
|
123
|
+
return typeof path === "string" ? encoder.encode(path) : path;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function samePath(layout, index, bytes) {
|
|
127
|
+
const base = index * (RECORD_BYTES / 4);
|
|
128
|
+
if (Atomics.load(layout.i32, base + R_PATH_LEN) !== bytes.length) return false;
|
|
129
|
+
const start = index * layout.maxPathBytes;
|
|
130
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
131
|
+
if (layout.paths[start + i] !== bytes[i]) return false;
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Index of the entry for `path`, or -1. Cleared entries are returned too. */
|
|
137
|
+
function find(layout, bytes, hash) {
|
|
138
|
+
let index = (hash >>> 0) % layout.entries;
|
|
139
|
+
for (let probe = 0; probe < layout.entries; probe += 1) {
|
|
140
|
+
const base = index * (RECORD_BYTES / 4);
|
|
141
|
+
const state = Atomics.load(layout.i32, base + R_STATE);
|
|
142
|
+
if (state === STATE_EMPTY) return -1;
|
|
143
|
+
if (
|
|
144
|
+
(state === STATE_LIVE || state === STATE_CLEARED) &&
|
|
145
|
+
Atomics.load(layout.i32, base + R_HASH) === hash &&
|
|
146
|
+
samePath(layout, index, bytes)
|
|
147
|
+
) {
|
|
148
|
+
return index;
|
|
149
|
+
}
|
|
150
|
+
index = (index + 1) % layout.entries;
|
|
151
|
+
}
|
|
152
|
+
return -1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function insert(layout, bytes, hash) {
|
|
156
|
+
let index = (hash >>> 0) % layout.entries;
|
|
157
|
+
for (let probe = 0; probe < layout.entries; probe += 1) {
|
|
158
|
+
const base = index * (RECORD_BYTES / 4);
|
|
159
|
+
if (
|
|
160
|
+
Atomics.compareExchange(layout.i32, base + R_STATE, STATE_EMPTY, STATE_CLAIMING) ===
|
|
161
|
+
STATE_EMPTY
|
|
162
|
+
) {
|
|
163
|
+
layout.paths.set(bytes, index * layout.maxPathBytes);
|
|
164
|
+
Atomics.store(layout.i32, base + R_PATH_LEN, bytes.length);
|
|
165
|
+
Atomics.store(layout.i32, base + R_HASH, hash);
|
|
166
|
+
Atomics.store(layout.i32, base + R_COVERED, 0);
|
|
167
|
+
layout.f64[index * (RECORD_BYTES / 8) + R_F64_SIZE] = 0;
|
|
168
|
+
Atomics.store(layout.i32, base + R_STATE, STATE_CLEARED);
|
|
169
|
+
return index;
|
|
170
|
+
}
|
|
171
|
+
index = (index + 1) % layout.entries;
|
|
172
|
+
}
|
|
173
|
+
return -1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Entry index for `path`, or -1 when the path is not mirrored. */
|
|
177
|
+
export function findSabIoMirrorEntry(buffer, path) {
|
|
178
|
+
const layout = layoutOf(buffer);
|
|
179
|
+
const bytes = toBytes(path);
|
|
180
|
+
if (bytes.length > layout.maxPathBytes) return -1;
|
|
181
|
+
const index = find(layout, bytes, fnv1a(bytes));
|
|
182
|
+
if (index < 0) return -1;
|
|
183
|
+
return Atomics.load(layout.i32, index * (RECORD_BYTES / 4) + R_STATE) === STATE_LIVE
|
|
184
|
+
? index
|
|
185
|
+
: -1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Record a write of `bytes` at `offset` (writer side). With `truncateTo`, the
|
|
190
|
+
* file's size is set to exactly that value after the write (seeding and
|
|
191
|
+
* truncation). Returns false when the mirror is full or the path too long;
|
|
192
|
+
* readers then use the file.
|
|
193
|
+
*/
|
|
194
|
+
export function updateSabIoMirror(buffer, path, offset, bytes, { truncateTo } = {}) {
|
|
195
|
+
const layout = layoutOf(buffer);
|
|
196
|
+
const key = toBytes(path);
|
|
197
|
+
if (key.length > layout.maxPathBytes) return false;
|
|
198
|
+
const hash = fnv1a(key);
|
|
199
|
+
let index = find(layout, key, hash);
|
|
200
|
+
if (index < 0) index = insert(layout, key, hash);
|
|
201
|
+
if (index < 0) return false;
|
|
202
|
+
const base = index * (RECORD_BYTES / 4);
|
|
203
|
+
const sizeIndex = index * (RECORD_BYTES / 8) + R_F64_SIZE;
|
|
204
|
+
const dataStart = index * layout.entryBytes;
|
|
205
|
+
const wasLive = Atomics.load(layout.i32, base + R_STATE) === STATE_LIVE;
|
|
206
|
+
|
|
207
|
+
Atomics.add(layout.i32, base + R_SEQ, 1); // odd: writing
|
|
208
|
+
let size = wasLive ? layout.f64[sizeIndex] : 0;
|
|
209
|
+
let covered = wasLive ? Atomics.load(layout.i32, base + R_COVERED) : 0;
|
|
210
|
+
if (!wasLive) {
|
|
211
|
+
layout.data.fill(0, dataStart, dataStart + layout.entryBytes);
|
|
212
|
+
}
|
|
213
|
+
if (bytes.length > 0 && offset < layout.entryBytes) {
|
|
214
|
+
const end = Math.min(layout.entryBytes, offset + bytes.length);
|
|
215
|
+
if (offset > covered) {
|
|
216
|
+
layout.data.fill(0, dataStart + covered, dataStart + offset); // a hole reads as zeros
|
|
217
|
+
}
|
|
218
|
+
layout.data.set(bytes.subarray(0, end - offset), dataStart + offset);
|
|
219
|
+
}
|
|
220
|
+
if (bytes.length > 0) {
|
|
221
|
+
size = Math.max(size, offset + bytes.length);
|
|
222
|
+
}
|
|
223
|
+
if (truncateTo !== undefined) {
|
|
224
|
+
if (truncateTo < covered) {
|
|
225
|
+
layout.data.fill(0, dataStart + truncateTo, dataStart + covered);
|
|
226
|
+
}
|
|
227
|
+
size = truncateTo;
|
|
228
|
+
}
|
|
229
|
+
covered = Math.min(size, layout.entryBytes);
|
|
230
|
+
layout.f64[sizeIndex] = size;
|
|
231
|
+
Atomics.store(layout.i32, base + R_COVERED, covered);
|
|
232
|
+
Atomics.store(layout.i32, base + R_STATE, STATE_LIVE);
|
|
233
|
+
Atomics.add(layout.i32, base + R_SEQ, 1); // even: stable
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Forget a path (unlinked, or its writer gave it up). */
|
|
238
|
+
export function clearSabIoMirror(buffer, path) {
|
|
239
|
+
const layout = layoutOf(buffer);
|
|
240
|
+
const key = toBytes(path);
|
|
241
|
+
if (key.length > layout.maxPathBytes) return;
|
|
242
|
+
const index = find(layout, key, fnv1a(key));
|
|
243
|
+
if (index < 0) return;
|
|
244
|
+
const base = index * (RECORD_BYTES / 4);
|
|
245
|
+
Atomics.add(layout.i32, base + R_SEQ, 1);
|
|
246
|
+
Atomics.store(layout.i32, base + R_STATE, STATE_CLEARED);
|
|
247
|
+
Atomics.store(layout.i32, base + R_COVERED, 0);
|
|
248
|
+
layout.f64[index * (RECORD_BYTES / 8) + R_F64_SIZE] = 0;
|
|
249
|
+
Atomics.add(layout.i32, base + R_SEQ, 1);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Serve a read from the mirror (reader side). Copies into `dst` and returns
|
|
254
|
+
* the byte count (short at EOF, 0 past EOF), or -1 when the mirror cannot
|
|
255
|
+
* answer: the path is not mirrored, or the range reaches past the mirrored
|
|
256
|
+
* prefix of a longer file. The caller then reads the file.
|
|
257
|
+
*/
|
|
258
|
+
export function readSabIoMirror(buffer, path, dst, offset) {
|
|
259
|
+
const layout = layoutOf(buffer);
|
|
260
|
+
const key = toBytes(path);
|
|
261
|
+
if (key.length > layout.maxPathBytes) return -1;
|
|
262
|
+
const index = find(layout, key, fnv1a(key));
|
|
263
|
+
if (index < 0) return -1;
|
|
264
|
+
return readSabIoMirrorEntry(buffer, index, dst, offset);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** readSabIoMirror for an entry index already found. */
|
|
268
|
+
export function readSabIoMirrorEntry(buffer, index, dst, offset) {
|
|
269
|
+
const layout = layoutOf(buffer);
|
|
270
|
+
const base = index * (RECORD_BYTES / 4);
|
|
271
|
+
const sizeIndex = index * (RECORD_BYTES / 8) + R_F64_SIZE;
|
|
272
|
+
const dataStart = index * layout.entryBytes;
|
|
273
|
+
for (let attempt = 0; attempt < MAX_READ_ATTEMPTS; attempt += 1) {
|
|
274
|
+
const before = Atomics.load(layout.i32, base + R_SEQ);
|
|
275
|
+
if (before & 1) continue;
|
|
276
|
+
if (Atomics.load(layout.i32, base + R_STATE) !== STATE_LIVE) return -1;
|
|
277
|
+
const size = layout.f64[sizeIndex];
|
|
278
|
+
const covered = Atomics.load(layout.i32, base + R_COVERED);
|
|
279
|
+
let result;
|
|
280
|
+
if (offset >= size) {
|
|
281
|
+
result = 0;
|
|
282
|
+
} else {
|
|
283
|
+
const want = Math.min(dst.length, size - offset);
|
|
284
|
+
if (offset + want > covered) {
|
|
285
|
+
result = -1;
|
|
286
|
+
} else {
|
|
287
|
+
dst.set(layout.data.subarray(dataStart + offset, dataStart + offset + want), 0);
|
|
288
|
+
result = want;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (Atomics.load(layout.i32, base + R_SEQ) === before) return result;
|
|
292
|
+
}
|
|
293
|
+
return -1;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A matcher over path suffixes, e.g. ["/h.fsh"] for partition and type heads. */
|
|
297
|
+
export function createSabIoMirrorMatcher(suffixes = ["/h.fsh"]) {
|
|
298
|
+
const list = Array.from(suffixes, String);
|
|
299
|
+
return (path) => {
|
|
300
|
+
const text = String(path);
|
|
301
|
+
for (const suffix of list) {
|
|
302
|
+
if (text.endsWith(suffix) || text === suffix.replace(/^\//, "")) return true;
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
};
|
|
306
|
+
}
|
|
@@ -1,22 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createWasiThreadWorkerRuntime,
|
|
3
|
+
resolveModuleExtraImports,
|
|
4
|
+
} from "./wasiThreadWorkerRuntime.js";
|
|
5
|
+
|
|
6
|
+
// One pooled browser worker = one guest OS thread. It is pre-started and
|
|
7
|
+
// probed by createWasiThreadSpawn (wasiThreadHost.js), then runs
|
|
8
|
+
// wasi_thread_start for each {t:"run"} it is dispatched. The same source ships
|
|
9
|
+
// as a self-contained classic bundle spawned from a blob: URL (A39,
|
|
10
|
+
// hostWorkerBundles.js); nothing here depends on the module-worker form except
|
|
11
|
+
// `{ moduleUrl }` extraImports entries, which need a module worker.
|
|
2
12
|
|
|
3
|
-
let wasmModule = null;
|
|
4
|
-
let memory = null;
|
|
5
|
-
let hostcallChannel = null;
|
|
6
13
|
let runtime = null;
|
|
7
14
|
|
|
8
|
-
self.onmessage = (event) => {
|
|
15
|
+
self.onmessage = async (event) => {
|
|
9
16
|
const message = event.data ?? {};
|
|
10
17
|
if (message.t === "probe") {
|
|
11
|
-
wasmModule = message.wasmModule;
|
|
12
|
-
memory = message.memory;
|
|
13
|
-
hostcallChannel = message.hostcallChannel ?? null;
|
|
14
18
|
try {
|
|
19
|
+
const extraImports = await resolveModuleExtraImports(message.extraImports ?? []);
|
|
15
20
|
runtime = createWasiThreadWorkerRuntime({
|
|
16
|
-
wasmModule,
|
|
17
|
-
memory,
|
|
18
|
-
hostcallChannel,
|
|
21
|
+
wasmModule: message.wasmModule,
|
|
22
|
+
memory: message.memory,
|
|
23
|
+
hostcallChannel: message.hostcallChannel ?? null,
|
|
19
24
|
processState: message.processState,
|
|
25
|
+
extraImports,
|
|
26
|
+
workerIndex: message.workerIndex,
|
|
20
27
|
});
|
|
21
28
|
runtime.instantiate();
|
|
22
29
|
self.postMessage({ t: "ready", ok: true });
|
|
@@ -38,6 +45,9 @@ self.onmessage = (event) => {
|
|
|
38
45
|
instance.exports.wasi_thread_start(message.tid, message.startArg);
|
|
39
46
|
} catch (error) {
|
|
40
47
|
if (!(error && error.name === "WasiExitError")) {
|
|
48
|
+
// The thread that joins this one may be blocked in the guest and never
|
|
49
|
+
// handle the message below; report the fault from this worker as well.
|
|
50
|
+
console.error(`[wasi-thread] guest thread ${message.tid} trapped:`, error);
|
|
41
51
|
self.postMessage({
|
|
42
52
|
t: "error",
|
|
43
53
|
tid: message.tid,
|