Skip to content

Commit 98895fb

Browse files
Fix race condition when launching Oxide helper process (#1520)
Fixes #1519 We were calling `startIfNeeded()` but it did async work before assigning the connection which means if scan() is called once per project the flow looks like this: Project 1-N: `scan()` -> `startIfNeeded()` -> check for existing connection -> `await … ` -> setup connection -> done Since projects are loaded via `Promise.all(…)` every single one will run all the way up to the `await` call, wait, fork a process, setup an RPC connection, and overwrite the `this.helper` and `this.connection` properties. I think the additional processes would stick around because of closure scopes but not 100% sure about that as they should've otherwise been unreferenced. This PR changes the setup in a few ways: - The code that looks for the Oxide helper async is gone. The relative path is resolved at startup. - We synchronously store a promise to the object containing details to access the helper process - Most of the process is now synchronous with a small promise that waits for the process to become ready.
1 parent da37749 commit 98895fb

File tree

6 files changed

+80
-50
lines changed

6 files changed

+80
-50
lines changed

esbuild.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ let ctx = await esbuild.context({
2121
platform: 'node',
2222
external: ['pnpapi', 'vscode', 'lightningcss', '@tailwindcss/oxide'],
2323
format: 'cjs',
24+
define: {
25+
'process.env.TEST': '0',
26+
},
2427
outdir: args.outdir,
2528
outfile: args.outfile,
2629
minify: args.minify,

packages/tailwindcss-language-server/src/oxide-helper.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,11 @@ let connection = rpc.createMessageConnection(
1010

1111
let scanRequest = new rpc.RequestType<ScanOptions, ScanResult, void>('scan')
1212
connection.onRequest<ScanOptions, ScanResult, void>(scanRequest, (options) => scan(options))
13-
1413
connection.listen()
14+
15+
console.log('Listening for messages...')
16+
17+
process.on('disconnect', () => {
18+
console.log('Shutting down...')
19+
process.exit(0)
20+
})
Lines changed: 58 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
11
import * as rpc from 'vscode-jsonrpc/node'
22
import * as proc from 'node:child_process'
33
import * as path from 'node:path'
4-
import * as fs from 'node:fs/promises'
54
import { type ScanOptions, type ScanResult } from './oxide'
65

6+
interface ServerHandle {
7+
helper: proc.ChildProcess
8+
connection: rpc.MessageConnection
9+
}
10+
11+
/**
12+
* The path to the Oxide helper process
13+
*
14+
* TODO:
15+
* - Can we find a way to not require a build first — i.e. point to
16+
* `oxide-helper.ts` and have things "hot reload" during tests?
17+
*/
18+
const helperPath = process.env.TEST
19+
? // This first path is relative to the source file so running tests in Vitest
20+
// result in the correct path — does still point to the built files.
21+
path.resolve(path.dirname(__filename), '../bin/oxide-helper.js')
22+
: // The second path is relative to the built file. This is the same for the
23+
// language server *and* the extension since the file is named identically
24+
// in both builds.
25+
path.resolve(path.dirname(__filename), './oxide-helper.js')
26+
727
/**
828
* This helper starts a session in which we can use Oxide in *another process*
929
* to communicate content scanning results.
@@ -19,75 +39,64 @@ import { type ScanOptions, type ScanResult } from './oxide'
1939
* us sidestep the problem as the process will only be running as needed.
2040
*/
2141
export class OxideSession {
22-
helper: proc.ChildProcess | null = null
23-
connection: rpc.MessageConnection | null = null
42+
/**
43+
* An object that represents the connection to the server
44+
*
45+
* This ensures that either everything is initialized or nothing is
46+
*/
47+
private server: Promise<ServerHandle> | null = null
2448

2549
public async scan(options: ScanOptions): Promise<ScanResult> {
26-
await this.startIfNeeded()
50+
let server = await this.startIfNeeded()
2751

28-
return await this.connection.sendRequest('scan', options)
52+
return await server.connection.sendRequest('scan', options)
2953
}
3054

31-
async startIfNeeded(): Promise<void> {
32-
if (this.connection) return
33-
34-
// TODO: Can we find a way to not require a build first?
35-
// let module = path.resolve(path.dirname(__filename), './oxide-helper.ts')
36-
37-
let modulePaths = [
38-
// Separate Language Server package
39-
'../bin/oxide-helper.js',
40-
41-
// Bundled with the VSCode extension
42-
'../dist/oxide-helper.js',
43-
]
55+
startIfNeeded(): Promise<ServerHandle> {
56+
this.server ??= this.start()
4457

45-
let module: string | null = null
46-
47-
for (let relativePath of modulePaths) {
48-
let filepath = path.resolve(path.dirname(__filename), relativePath)
49-
50-
if (
51-
await fs.access(filepath).then(
52-
() => true,
53-
() => false,
54-
)
55-
) {
56-
module = filepath
57-
break
58-
}
59-
}
58+
return this.server
59+
}
6060

61-
if (!module) throw new Error('unable to load')
61+
private async start(): Promise<ServerHandle> {
62+
// 1. Start the new process
63+
let helper = proc.fork(helperPath)
64+
65+
// 2. If the process fails to spawn we want to throw
66+
//
67+
// We do end up caching the failed promise but that should be
68+
// fine. It seems unlikely that, if this fails, trying again
69+
// would "fix" whatever problem there was and succeed.
70+
await new Promise((resolve, reject) => {
71+
helper.on('spawn', resolve)
72+
helper.on('error', reject)
73+
})
6274

63-
let helper = proc.fork(module)
75+
// 3. Setup a channel to talk to the server
6476
let connection = rpc.createMessageConnection(
6577
new rpc.IPCMessageReader(helper),
6678
new rpc.IPCMessageWriter(helper),
6779
)
6880

69-
helper.on('disconnect', () => {
81+
// 4. If the process exits we can tear down everything
82+
helper.on('close', () => {
7083
connection.dispose()
71-
this.connection = null
72-
this.helper = null
73-
})
74-
75-
helper.on('exit', () => {
76-
connection.dispose()
77-
this.connection = null
78-
this.helper = null
84+
this.server = null
7985
})
8086

87+
// 5. Start listening for messages
8188
connection.listen()
8289

83-
this.helper = helper
84-
this.connection = connection
90+
return { helper, connection }
8591
}
8692

8793
async stop() {
88-
if (!this.helper) return
94+
if (!this.server) return
95+
96+
let server = await this.server
8997

90-
this.helper.disconnect()
91-
this.helper.kill()
98+
// We terminate the server because, if for some reason it gets stuck,
99+
// we don't want it to stick around.
100+
server.helper.kill()
92101
}
93102
}

packages/tailwindcss-language-server/vitest.config.mts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export default defineConfig({
88
silent: 'passed-only',
99
},
1010

11+
define: {
12+
'process.env.TEST': '1',
13+
},
14+
1115
plugins: [
1216
tsconfigPaths(),
1317
{

packages/tailwindcss-language-service/vitest.config.mts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@ export default defineConfig({
55
testTimeout: 15000,
66
silent: 'passed-only',
77
},
8+
9+
define: {
10+
'process.env.TEST': '1',
11+
},
812
})

packages/tailwindcss-language-syntax/vitest.config.mts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@ export default defineConfig({
55
testTimeout: 15000,
66
silent: 'passed-only',
77
},
8+
9+
define: {
10+
'process.env.TEST': '1',
11+
},
812
})

0 commit comments

Comments
 (0)