Skip to content

Commit c5eb963

Browse files
authored
ci: run the test suite under AddressSanitizer (#487)
* ci: run the test suite under AddressSanitizer The Test job becomes a two-entry matrix: the existing run, still named Test, and Test (ASan), the same xcodebuild invocation with -enableAddressSanitizer YES. Use-after-free and double-free bugs in the runtime are usually silent in a plain run, so a spec written for one (the nested-worker teardown spec from #479) passes there with or without its fix. Artifact names are unique per workflow run, so the ASan entry uploads test-results-asan and test-diagnostics-asan, and collect-test-diagnostics takes the artifact name as an input. Five worker quality-of-service specs cannot hold in an instrumented build: a worker asked for default or utility reads back user-initiated. They are reported as skipped there, keyed on a new TNSIsAddressSanitizerEnabled() test fixture, and run unchanged everywhere else. * test: let the system assign the test server's port The XCTest harness served the junit report and the HTTP module fixtures on a fixed loopback port. Simulators share the host's loopback interface, so two suite runs on one machine, even on different simulators, could deliver one run's report to the other's listener. The listener now binds a port chosen by the system and hands it to the app through REPORT_BASEURL, which is already the only way the app learns the address. start() blocks until the listener is bound, since the port is only known then, and throws when binding fails instead of leaving the failure unobserved. * test: log the test server's port The port is assigned by the system at bind time, so the run's output is the only place to find it when poking at the server by hand. * test: report test server startup errors as test failures Setup moves to setUpWithError() so a listener that fails to bind, or does not become ready in time, is recorded by XCTest as a failure of the test instead of crashing the runner through try!. tearDown() tolerates a server that was never created, since it still runs after a throwing setup.
1 parent 461954e commit c5eb963

8 files changed

Lines changed: 101 additions & 30 deletions

File tree

‎.github/actions/collect-test-diagnostics/action.yml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ inputs:
99
test-folder:
1010
description: Folder the diagnostics directory is created in
1111
required: true
12+
artifact-name:
13+
description: >
14+
Name of the uploaded artifact. Artifact names are unique per workflow
15+
run, so jobs that can both fail in one run need distinct names.
16+
default: test-diagnostics
1217
runs:
1318
using: composite
1419
steps:
@@ -45,6 +50,6 @@ runs:
4550
- name: Upload test diagnostics
4651
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
4752
with:
48-
name: test-diagnostics
53+
name: ${{ inputs.artifact-name }}
4954
path: ${{ inputs.test-folder }}/diagnostics
5055
if-no-files-found: ignore

‎.github/workflows/pull_request.yml‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,23 @@ jobs:
5656
name: NativeScript-dSYMs
5757
path: dist/dSYMs
5858
test:
59-
name: Test
59+
name: ${{ matrix.name }}
6060
runs-on: macos-15
6161
needs: build
62+
strategy:
63+
# The two variants answer different questions; one failing must not
64+
# cancel the other.
65+
fail-fast: false
66+
matrix:
67+
include:
68+
- name: Test
69+
xcodebuild-args: ""
70+
artifact-suffix: ""
71+
# Only code compiled here is instrumented: the runtime and the test
72+
# fixtures, not the prebuilt V8 archives.
73+
- name: Test (ASan)
74+
xcodebuild-args: "-enableAddressSanitizer YES"
75+
artifact-suffix: "-asan"
6276
steps:
6377
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
6478
with:
@@ -89,13 +103,13 @@ jobs:
89103
# TestRunnerTests.swift) need more than 20m headroom per attempt.
90104
timeout_minutes: 40
91105
max_attempts: 2
92-
command: set -o pipefail && xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=latest,name\=iPhone\ 16\ Pro build test | xcpretty
106+
command: set -o pipefail && xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=latest,name\=iPhone\ 16\ Pro ${{ matrix.xcodebuild-args }} build test | xcpretty
93107
# Keep the failed attempt's bundle — it holds the diagnostics of the
94108
# failure being retried. Everything else at the result path must go
95109
# (including extensionless staging leftovers), or the retry dies with
96110
# "Existing file at -resultBundlePath".
97111
on_retry_command: rm -rf $TEST_FOLDER/test_results_attempt1.xcresult; mv $TEST_FOLDER/test_results.xcresult $TEST_FOLDER/test_results_attempt1.xcresult 2>/dev/null; for f in $TEST_FOLDER/test_results*; do [ "$f" = "$TEST_FOLDER/test_results_attempt1.xcresult" ] || rm -rf "$f"; done; xcrun simctl shutdown all
98-
new_command_on_retry: xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=latest,name\=iPhone\ 16\ Pro build test
112+
new_command_on_retry: xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=latest,name\=iPhone\ 16\ Pro ${{ matrix.xcodebuild-args }} build test
99113
- name: Extract test results
100114
# Runs even when the test step failed: the xcresult usually still
101115
# carries the JS suite's junit attachments for the report step below.
@@ -122,11 +136,12 @@ jobs:
122136
uses: ./.github/actions/collect-test-diagnostics
123137
with:
124138
test-folder: ${{env.TEST_FOLDER}}
139+
artifact-name: test-diagnostics${{ matrix.artifact-suffix }}
125140
- name: Archive Test Result Data
126141
if: always()
127142
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
128143
with:
129-
name: test-results
144+
name: test-results${{ matrix.artifact-suffix }}
130145
path: |
131146
${{env.TEST_FOLDER}}/test_results.xcresult
132147
${{env.TEST_FOLDER}}/test_results_attempt1.xcresult

‎TestFixtures/TNSTestCommon.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ extern "C" {
44

55
bool TNSIsConfigurationDebug();
66

7+
bool TNSIsAddressSanitizerEnabled();
8+
79
NSString* TNSGetOutput();
810

911
void TNSLog(NSString*);

‎TestFixtures/TNSTestCommon.m‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ bool TNSIsConfigurationDebug() {
1111
#endif
1212
}
1313

14+
bool TNSIsAddressSanitizerEnabled() {
15+
#if __has_feature(address_sanitizer)
16+
return true;
17+
#else
18+
return false;
19+
#endif
20+
}
21+
1422
NSString* TNSGetOutput() {
1523
if (TNSTestOutput == nil) {
1624
TNSTestOutput = [NSMutableString new];

‎TestFixtures/exported-symbols.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ _functionWithUnichar
5656
_functionWithUShort
5757
_functionWithUShortPtr
5858
_TNSIsConfigurationDebug
59+
_TNSIsAddressSanitizerEnabled
5960
_TNSClearOutput
6061
_TNSConstant
6162
_TNSConstant10_0Plus

‎TestRunner/app/tests/WorkerOptionsTests.js‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ describe("Worker platform options", function () {
4343
};
4444
};
4545

46+
// In an AddressSanitizer build a worker thread asked for anything below
47+
// user-initiated still reads back user-initiated, so those classes cannot
48+
// be observed there.
49+
var expectQos = function (options, expected, done) {
50+
if (TNSIsAddressSanitizerEnabled() && expected < NSQualityOfService.UserInitiated) {
51+
pending("quality of service below user-initiated is not observable under AddressSanitizer");
52+
return;
53+
}
54+
reportQos(options, done, function (qos) {
55+
expect(qos).toBe(expected);
56+
});
57+
};
58+
4659
// Background is deliberately absent: the system defines that class as work
4760
// that may take minutes, and on a loaded host a background thread has not
4861
// finished booting an isolate within two minutes. It is covered below
@@ -56,9 +69,7 @@ describe("Worker platform options", function () {
5669

5770
priorities.forEach(function (pair) {
5871
it("runs the worker thread at " + pair[0] + " quality of service", function (done) {
59-
reportQos({ ios: { priority: pair[0] } }, done, function (qos) {
60-
expect(qos).toBe(pair[1]);
61-
});
72+
expectQos({ ios: { priority: pair[0] } }, pair[1], done);
6273
});
6374
});
6475

@@ -71,21 +82,16 @@ describe("Worker platform options", function () {
7182
});
7283

7384
it("still honors the deprecated iosPriority option", function (done) {
74-
reportQos({ iosPriority: "utility" }, done, function (qos) {
75-
expect(qos).toBe(NSQualityOfService.Utility);
76-
});
85+
expectQos({ iosPriority: "utility" }, NSQualityOfService.Utility, done);
7786
});
7887

7988
it("prefers ios.priority over iosPriority when both are given", function (done) {
80-
reportQos({ ios: { priority: "userInteractive" }, iosPriority: "background" }, done, function (qos) {
81-
expect(qos).toBe(NSQualityOfService.UserInteractive);
82-
});
89+
expectQos({ ios: { priority: "userInteractive" }, iosPriority: "background" },
90+
NSQualityOfService.UserInteractive, done);
8391
});
8492

8593
it("ignores unknown keys inside ios", function (done) {
86-
reportQos({ ios: { priority: "utility", somethingElse: 42 } }, done, function (qos) {
87-
expect(qos).toBe(NSQualityOfService.Utility);
88-
});
94+
expectQos({ ios: { priority: "utility", somethingElse: 42 } }, NSQualityOfService.Utility, done);
8995
});
9096

9197
it("starts a worker given no options at all", function (done) {
@@ -95,9 +101,7 @@ describe("Worker platform options", function () {
95101
});
96102

97103
it("treats ios: null like an absent ios", function (done) {
98-
reportQos({ ios: null, iosPriority: "utility" }, done, function (qos) {
99-
expect(qos).toBe(NSQualityOfService.Utility);
100-
});
104+
expectQos({ ios: null, iosPriority: "utility" }, NSQualityOfService.Utility, done);
101105
});
102106

103107
it("propagates the error thrown by an option getter", function () {

‎TestRunnerTests/ModuleTestServer.swift‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,57 @@ final class ModuleTestServer {
2222
private let handler: Handler
2323
private var connections: [ObjectIdentifier: NWConnection] = [:]
2424

25-
init(port: UInt16, handler: @escaping Handler) throws {
25+
enum StartError: Error {
26+
case timedOut
27+
case noPort
28+
}
29+
30+
/// The loopback port the system assigned; valid once `start()` returned.
31+
private(set) var port: UInt16 = 0
32+
33+
/// The port is left to the system rather than fixed: simulators share the
34+
/// host's loopback interface, so two test runs on one machine would
35+
/// otherwise answer each other's requests.
36+
init(handler: @escaping Handler) throws {
2637
self.handler = handler
2738
let params = NWParameters.tcp
28-
params.allowLocalEndpointReuse = true
2939
params.requiredLocalEndpoint = NWEndpoint.hostPort(
3040
host: NWEndpoint.Host("127.0.0.1"),
31-
port: NWEndpoint.Port(rawValue: port)!)
41+
port: .any)
3242
listener = try NWListener(using: params)
3343
listener.newConnectionHandler = { [weak self] connection in
3444
self?.accept(connection)
3545
}
3646
}
3747

38-
func start() {
48+
/// Blocks until the listener is bound, because the port is only known then.
49+
func start() throws {
50+
let settled = DispatchSemaphore(value: 0)
51+
var failure: Error?
52+
listener.stateUpdateHandler = { state in
53+
switch state {
54+
case .ready:
55+
settled.signal()
56+
case .failed(let error):
57+
failure = error
58+
settled.signal()
59+
default:
60+
break
61+
}
62+
}
3963
listener.start(queue: queue)
64+
let outcome = settled.wait(timeout: .now() + 10)
65+
listener.stateUpdateHandler = nil
66+
if outcome == .timedOut {
67+
throw StartError.timedOut
68+
}
69+
if let failure = failure {
70+
throw failure
71+
}
72+
guard let bound = listener.port?.rawValue, bound != 0 else {
73+
throw StartError.noPort
74+
}
75+
port = bound
4076
}
4177

4278
func stop() {

‎TestRunnerTests/TestRunnerTests.swift‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import XCTest
22

33
class TestRunnerTests: XCTestCase {
4-
private let port = 63846
54
private var server: ModuleTestServer!
65
private var runtimeUnitTestsExpectation: XCTestExpectation!
76
private var reportDeliveryFailureReason: String?
@@ -12,15 +11,15 @@ class TestRunnerTests: XCTestCase {
1211
private let progressLock = NSLock()
1312
private var lastSpecSeen = "(no spec reported yet)"
1413

15-
override func setUp() {
14+
override func setUpWithError() throws {
1615
continueAfterFailure = false
1716

1817
// Standalone (not via self.expectation(...)) so we can drive it through
1918
// XCTWaiter alongside the crash watchdog without tripping the
2019
// XCTestCase "must waitForExpectations" rule.
2120
runtimeUnitTestsExpectation = XCTestExpectation(description: "Jasmine tests")
2221

23-
self.server = try! ModuleTestServer(port: UInt16(port)) {
22+
self.server = try ModuleTestServer {
2423
(
2524
environ: [String: Any],
2625
startResponse: @escaping ((String, [(String, String)]) -> Void),
@@ -193,11 +192,12 @@ class TestRunnerTests: XCTestCase {
193192
sendBody(Data("Not Found".utf8))
194193
}
195194

196-
server.start()
195+
try server.start()
196+
print("ModuleTestServer listening on 127.0.0.1:\(server.port)")
197197
}
198198

199199
override func tearDown() {
200-
server.stop()
200+
server?.stop()
201201
}
202202

203203
func testRuntime() {
@@ -210,7 +210,7 @@ class TestRunnerTests: XCTestCase {
210210
let jasmineTestsTimeout: TimeInterval = 600
211211

212212
let app = XCUIApplication()
213-
app.launchEnvironment["REPORT_BASEURL"] = "http://127.0.0.1:\(port)/junit_report"
213+
app.launchEnvironment["REPORT_BASEURL"] = "http://127.0.0.1:\(server.port)/junit_report"
214214
// The app's report retries and delivery_failed sentinel count from its
215215
// launch, which precedes the wait below — keep a margin so delivery
216216
// gives up (and the sentinel lands) before our timeout fires.

0 commit comments

Comments
 (0)