Skip to content

Commit c9e2140

Browse files
authored
fix(runtime): read script caches into per-call buffers, publish them atomically (#468)
* fix(runtime): read script caches into per-call buffers, publish them atomically tns::ReadBinary and the 3-arg tns::ReadText served every file up to 1 MiB out of a process-global static buffer. LoadScriptCache handed that pointer to V8 as CachedData(BufferNotOwned), which the deserializer reads for the whole of CodeSerializer::Deserialize. Two Worker threads starting at once both fread their code caches into the same buffer, so one deserialized bytes the other was overwriting with a different file. Release V8 does not checksum code caches, so the corrupted payload was followed until a bad pointer dereferenced: EXC_BAD_ACCESS in Deserializer::ReadObject, seen in production a few seconds after app launch, always with a second worker in the same frame. ReadBinary now returns a caller-owned heap buffer (LoadScriptCache uses BufferOwned) and ReadText reads straight into its std::string; the shared Buffer/BinBuffer statics are gone. WriteBinary writes through a mkstemp file in the cache directory and renames it over the target, stamping the source's mtime before publishing, so a reader never sees a half-written cache and two first-time compilers of the same module no longer interleave writes into one file. Both SaveScriptCache overloads share one helper. WorkerConcurrentStartupTests starts 12 workers together against warm caches, each loading six fixture modules in rotated orders. On a Release build of TestRunner (Debug disables code caches) it fails on main with workers reporting another module's value and one never reporting; it passes with this change. * fix(runtime): guard null code caches and arm the startup spec's timeout early ScriptCompiler::CreateCodeCache returns nullptr for a script V8 declines to serialize; both SaveScriptCache overloads dereferenced the result. They now skip the write instead. Jasmine 2.0.1 arms a spec's async timeout before invoking it, so raising DEFAULT_TIMEOUT_INTERVAL inside WorkerConcurrentStartupTests' spec never applied to that spec (the unfixed-runtime run timed out at exactly the 5 s default). The interval is now raised in beforeEach and restored in afterEach.
1 parent fd521ff commit c9e2140

17 files changed

Lines changed: 3905 additions & 98 deletions

File tree

NativeScript/runtime/Helpers.h

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,10 +243,15 @@ inline bool ToBool(const v8::Local<v8::Value>& value) {
243243
}
244244
bool Exists(const char* fullPath);
245245
v8::Local<v8::String> ReadModule(v8::Isolate* isolate, const std::string& filePath);
246-
const char* ReadText(const std::string& filePath, long& length, bool& isNew);
247246
std::string ReadText(const std::string& file);
248-
uint8_t* ReadBinary(const std::string path, long& length, bool& isNew);
249-
bool WriteBinary(const std::string& path, const void* data, long length);
247+
// The whole file in a heap buffer the caller owns (delete[]); nullptr and a
248+
// zero length when it cannot be read.
249+
uint8_t* ReadBinary(const std::string& path, long& length);
250+
// Replaces the file at `path` atomically: readers see the old contents or the
251+
// new ones, never a partial write. A non-negative `modificationTime` stamps the
252+
// file's mtime before it is published.
253+
bool WriteBinary(const std::string& path, const void* data, long length,
254+
time_t modificationTime = -1);
250255

251256
void SetPrivateValue(const v8::Local<v8::Object>& obj, const v8::Local<v8::String>& propName,
252257
const v8::Local<v8::Value>& value);

NativeScript/runtime/Helpers.mm

Lines changed: 71 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
#include <stdio.h>
99
#include <stdlib.h>
1010
#include <sys/stat.h>
11+
#include <unistd.h>
1112
#include <atomic>
12-
#include <fstream>
13+
#include <cerrno>
1314
#include <mutex>
1415
#include <sstream>
16+
#include <vector>
1517
#include "Caches.h"
1618
#include "ErrorEvents.h"
1719
#include "NativeScriptException.h"
@@ -20,12 +22,6 @@
2022

2123
using namespace v8;
2224

23-
namespace {
24-
const int BUFFER_SIZE = 1024 * 1024;
25-
char* Buffer = new char[BUFFER_SIZE];
26-
uint8_t* BinBuffer = new uint8_t[BUFFER_SIZE];
27-
} // namespace
28-
2925
std::u16string tns::ToUtf16String(Isolate* isolate, const Local<Value>& value) {
3026
// Read the V8 string's native UTF-16 buffer directly instead of round-tripping
3127
// through UTF-8, which corrupts lone surrogates (replaced with U+FFFD) and is
@@ -99,88 +95,107 @@
9995
return str;
10096
}
10197

102-
const char* tns::ReadText(const std::string& filePath, long& length, bool& isNew) {
98+
// The file readers below run on the main thread and on every worker's thread
99+
// at once (workers load shared modules and their code caches while starting),
100+
// so each call reads into storage of its own; no scratch buffer is shared.
101+
102+
std::string tns::ReadText(const std::string& filePath) {
103103
FILE* file = fopen(filePath.c_str(), "rb");
104104
if (file == nullptr) {
105105
tns::Assert(false);
106106
}
107107

108108
fseek(file, 0, SEEK_END);
109-
110-
length = ftell(file);
111-
isNew = length > BUFFER_SIZE;
112-
109+
long length = ftell(file);
113110
rewind(file);
114111

115-
if (isNew) {
116-
char* newBuffer = new char[length];
117-
fread(newBuffer, 1, length, file);
118-
fclose(file);
119-
120-
return newBuffer;
112+
std::string result;
113+
if (length > 0) {
114+
result.resize(length);
115+
size_t readBytes = fread(result.data(), 1, length, file);
116+
result.resize(readBytes);
121117
}
122-
123-
fread(Buffer, 1, length, file);
124118
fclose(file);
125119

126-
return Buffer;
127-
}
128-
129-
std::string tns::ReadText(const std::string& file) {
130-
long length;
131-
bool isNew;
132-
const char* content = tns::ReadText(file, length, isNew);
133-
134-
std::string result(content, length);
135-
136-
if (isNew) {
137-
delete[] content;
138-
}
139-
140120
return result;
141121
}
142122

143-
uint8_t* tns::ReadBinary(const std::string path, long& length, bool& isNew) {
123+
uint8_t* tns::ReadBinary(const std::string& path, long& length) {
144124
length = 0;
145-
std::ifstream ifs(path);
146-
if (ifs.fail()) {
147-
return nullptr;
148-
}
149-
150125
FILE* file = fopen(path.c_str(), "rb");
151126
if (!file) {
152127
return nullptr;
153128
}
154129

155130
fseek(file, 0, SEEK_END);
156-
length = ftell(file);
131+
long fileLength = ftell(file);
157132
rewind(file);
158-
159-
isNew = length > BUFFER_SIZE;
160-
161-
if (isNew) {
162-
uint8_t* data = new uint8_t[length];
163-
fread(data, sizeof(uint8_t), length, file);
133+
if (fileLength <= 0) {
164134
fclose(file);
165-
return data;
135+
return nullptr;
166136
}
167137

168-
fread(BinBuffer, 1, length, file);
138+
uint8_t* data = new uint8_t[fileLength];
139+
size_t readBytes = fread(data, sizeof(uint8_t), fileLength, file);
169140
fclose(file);
141+
if (readBytes != static_cast<size_t>(fileLength)) {
142+
delete[] data;
143+
return nullptr;
144+
}
170145

171-
return BinBuffer;
146+
length = fileLength;
147+
return data;
172148
}
173149

174-
bool tns::WriteBinary(const std::string& path, const void* data, long length) {
175-
FILE* file = fopen(path.c_str(), "wb");
176-
if (!file) {
150+
bool tns::WriteBinary(const std::string& path, const void* data, long length,
151+
time_t modificationTime) {
152+
// Written to a private temp file in the same directory, then renamed over the
153+
// target: rename is atomic on the local file system, so a concurrent reader
154+
// of `path` never observes a truncated or half-written file, and two writers
155+
// racing on the same path leave whichever complete file landed last.
156+
std::string tmpTemplate = path + ".XXXXXX";
157+
std::vector<char> tmpPath(tmpTemplate.begin(), tmpTemplate.end());
158+
tmpPath.push_back('\0');
159+
int fd = mkstemp(tmpPath.data());
160+
if (fd < 0) {
177161
return false;
178162
}
179163

180-
size_t writtenBytes = fwrite(data, sizeof(uint8_t), length, file);
181-
fclose(file);
164+
bool ok = true;
165+
const uint8_t* cursor = static_cast<const uint8_t*>(data);
166+
long remaining = length;
167+
while (remaining > 0) {
168+
ssize_t written = write(fd, cursor, remaining);
169+
if (written < 0) {
170+
if (errno == EINTR) {
171+
continue;
172+
}
173+
ok = false;
174+
break;
175+
}
176+
cursor += written;
177+
remaining -= written;
178+
}
179+
180+
if (ok && modificationTime >= 0) {
181+
struct timespec times[2];
182+
times[0].tv_sec = time(nullptr);
183+
times[0].tv_nsec = 0;
184+
times[1].tv_sec = modificationTime;
185+
times[1].tv_nsec = 0;
186+
ok = futimens(fd, times) == 0;
187+
}
188+
189+
close(fd);
190+
191+
if (ok && rename(tmpPath.data(), path.c_str()) != 0) {
192+
ok = false;
193+
}
194+
if (!ok) {
195+
unlink(tmpPath.data());
196+
}
182197

183-
return writtenBytes == length;
198+
return ok;
184199
}
185200

186201
void tns::SetPrivateValue(const Local<Object>& obj, const Local<v8::String>& propName,

NativeScript/runtime/ModuleInternal.mm

Lines changed: 24 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
#include <sys/stat.h>
44
#include <time.h>
55
#include <unistd.h>
6-
#include <utime.h>
76
#include <cmath>
87
#include <cstring>
98
#include <string>
@@ -1894,44 +1893,39 @@ throw NativeScriptException(
18941893
}
18951894
}
18961895

1897-
bool isNew = false;
1898-
uint8_t* data = tns::ReadBinary(cachePath, length, isNew);
1896+
uint8_t* data = tns::ReadBinary(cachePath, length);
18991897
if (!data) {
19001898
return nullptr;
19011899
}
19021900

1903-
return new ScriptCompiler::CachedData(
1904-
data, (int)length,
1905-
isNew ? ScriptCompiler::CachedData::BufferOwned : ScriptCompiler::CachedData::BufferNotOwned);
1901+
return new ScriptCompiler::CachedData(data, (int)length, ScriptCompiler::CachedData::BufferOwned);
1902+
}
1903+
1904+
// A blob is published carrying its source file's modification time; that is
1905+
// the freshness check LoadScriptCache applies, so the two must land together.
1906+
static void WriteScriptCache(const std::string& cachePath, const std::string& sourcePath,
1907+
const uint8_t* data, long length) {
1908+
time_t sourceModifiedTime = -1;
1909+
struct stat result;
1910+
if (stat(sourcePath.c_str(), &result) == 0) {
1911+
sourceModifiedTime = result.st_mtime;
1912+
}
1913+
tns::WriteBinary(cachePath, data, length, sourceModifiedTime);
19061914
}
19071915

19081916
void ModuleInternal::SaveScriptCache(const ScriptCompiler::CachedData* cache,
19091917
const std::string& path, ScriptCacheKind kind) {
1918+
// CreateCodeCache yields nullptr for a script V8 declines to serialize.
1919+
if (cache == nullptr) {
1920+
return;
1921+
}
1922+
19101923
std::string canonicalPath = NormalizePath(path);
19111924
std::string cachePath = ModuleInternal::GetCacheFileName(
19121925
canonicalPath + ScriptCacheSuffix(kind == ScriptCacheKind::kEsModule));
19131926

1914-
// std::ofstream ofs(cachePath, std::ios::binary);
1915-
// if (!ofs) return; // or throw
1916-
1917-
// ofs.write(reinterpret_cast<const char*>(cache->data),
1918-
// cache->length);
1919-
// ofs.close();
1920-
1921-
int length = cache->length;
1922-
tns::WriteBinary(cachePath, cache->data, length);
1927+
WriteScriptCache(cachePath, canonicalPath, cache->data, cache->length);
19231928
delete cache;
1924-
1925-
// make sure cache and js file have the same modification date
1926-
struct stat result;
1927-
struct utimbuf new_times;
1928-
new_times.actime = time(nullptr);
1929-
new_times.modtime = time(nullptr);
1930-
if (stat(canonicalPath.c_str(), &result) == 0) {
1931-
auto jsLastModifiedTime = result.st_mtime;
1932-
new_times.modtime = jsLastModifiedTime;
1933-
}
1934-
utime(cachePath.c_str(), &new_times);
19351929
}
19361930

19371931
void ModuleInternal::SaveScriptCache(const Local<Script> script, const std::string& path) {
@@ -1944,24 +1938,15 @@ throw NativeScriptException(
19441938
Local<UnboundScript> unboundScript = script->GetUnboundScript();
19451939
// CachedData returned by this function should be owned by the caller (v8 docs)
19461940
ScriptCompiler::CachedData* cachedData = ScriptCompiler::CreateCodeCache(unboundScript);
1941+
if (cachedData == nullptr) {
1942+
return;
1943+
}
19471944

1948-
int length = cachedData->length;
19491945
// Always a classic script: this overload takes a v8::Script.
19501946
std::string cachePath =
19511947
ModuleInternal::GetCacheFileName(canonicalPath + ScriptCacheSuffix(false));
1952-
tns::WriteBinary(cachePath, cachedData->data, length);
1948+
WriteScriptCache(cachePath, canonicalPath, cachedData->data, cachedData->length);
19531949
delete cachedData;
1954-
1955-
// make sure cache and js file have the same modification date
1956-
struct stat result;
1957-
struct utimbuf new_times;
1958-
new_times.actime = time(nullptr);
1959-
new_times.modtime = time(nullptr);
1960-
if (stat(canonicalPath.c_str(), &result) == 0) {
1961-
auto jsLastModifiedTime = result.st_mtime;
1962-
new_times.modtime = jsLastModifiedTime;
1963-
}
1964-
utime(cachePath.c_str(), &new_times);
19651950
}
19661951

19671952
std::string ModuleInternal::GetCacheFileName(const std::string& path) {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
describe("Worker startup", function () {
2+
var moduleNames = ["modA", "modB", "modC", "modD", "modE", "modF"];
3+
var entryCount = 6;
4+
var expected = {};
5+
moduleNames.forEach(function (name) {
6+
expected[name] = require("./concurrentStartup/" + name).value;
7+
});
8+
9+
// Jasmine arms a spec's async timeout before calling it, so the interval
10+
// has to be raised ahead of the spec, not inside it.
11+
var originalTimeout;
12+
beforeEach(function () {
13+
originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
14+
jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000;
15+
});
16+
afterEach(function () {
17+
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
18+
});
19+
20+
// Every worker thread reads script sources and code caches while it starts;
21+
// a batch started together loads the same files at the same moment, which
22+
// is how an app that spins up its workers at launch behaves. The entries
23+
// load the modules in rotated orders, so concurrent workers are reading
24+
// different files at any instant.
25+
it("many workers start at once against warm script caches", function (done) {
26+
var finished = false;
27+
var finish = function () {
28+
if (finished) {
29+
return;
30+
}
31+
finished = true;
32+
done();
33+
};
34+
35+
var start = function (entry, onValues) {
36+
var worker = new Worker("./concurrentStartup/worker" + entry + ".js");
37+
worker.onmessage = function (msg) {
38+
expect(msg.data.values).toEqual(expected);
39+
worker.terminate();
40+
onValues();
41+
};
42+
worker.onerror = function (e) {
43+
expect(String(e && e.message ? e.message : e)).toBe("<no worker error>");
44+
worker.terminate();
45+
finish();
46+
};
47+
};
48+
49+
var startBatch = function () {
50+
var total = entryCount * 2;
51+
var remaining = total;
52+
for (var i = 0; i < total; i++) {
53+
start(i % entryCount, function () {
54+
remaining--;
55+
if (remaining === 0) {
56+
finish();
57+
}
58+
});
59+
}
60+
};
61+
62+
// Each entry once, one after another, so every entry's own code cache
63+
// exists before the batch.
64+
var warm = function (entry) {
65+
if (entry === entryCount) {
66+
startBatch();
67+
return;
68+
}
69+
start(entry, function () {
70+
warm(entry + 1);
71+
});
72+
};
73+
warm(0);
74+
});
75+
});

0 commit comments

Comments
 (0)