Skip to content

Commit 931f87d

Browse files
authored
work around api deprecations in deno 1.40.x (#3609) (#3611)
1 parent 22a9cf5 commit 931f87d

File tree

9 files changed

+198
-62
lines changed

9 files changed

+198
-62
lines changed

.github/workflows/ci.yml

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,10 @@ jobs:
8989
with:
9090
node-version: 16
9191

92-
# The version of Deno is pinned because version 1.25.1 was causing test
93-
# flakes due to random segfaults.
94-
- name: Setup Deno 1.24.0
92+
- name: Setup Deno 1.40.0
9593
uses: denoland/setup-deno@main
9694
with:
97-
deno-version: v1.24.0
95+
deno-version: v1.40.0
9896

9997
- name: Check out code into the Go module directory
10098
uses: actions/checkout@v3
@@ -199,8 +197,8 @@ jobs:
199197
200198
make test-yarnpnp
201199
202-
esbuild-old-versions:
203-
name: esbuild CI (old versions)
200+
esbuild-old-go-version:
201+
name: esbuild CI (old Go version)
204202
runs-on: ubuntu-latest
205203

206204
steps:
@@ -221,3 +219,35 @@ jobs:
221219

222220
- name: make test-old-ts
223221
run: make test-old-ts
222+
223+
esbuild-old-deno-version:
224+
name: esbuild CI (old Deno version)
225+
runs-on: ${{ matrix.os }}
226+
strategy:
227+
matrix:
228+
os: [ubuntu-latest, macos-latest, windows-latest]
229+
230+
steps:
231+
- name: Set up Go 1.x
232+
uses: actions/setup-go@v3
233+
with:
234+
go-version: 1.20.12
235+
id: go
236+
237+
# Make sure esbuild works with old versions of Deno. Note: It's important
238+
# to test a version before 1.31.0, which introduced the "Deno.Command" API.
239+
- name: Setup Deno 1.24.0
240+
uses: denoland/setup-deno@main
241+
with:
242+
deno-version: v1.24.0
243+
244+
- name: Check out code into the Go module directory
245+
uses: actions/checkout@v3
246+
247+
- name: Deno Tests (non-Windows)
248+
if: matrix.os != 'windows-latest'
249+
run: make test-deno
250+
251+
- name: Deno Tests (Windows)
252+
if: matrix.os == 'windows-latest'
253+
run: make test-deno-windows

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
* Work around API deprecations in Deno 1.40.x ([#3609](https://github.com/evanw/esbuild/issues/3609), [#3611](https://github.com/evanw/esbuild/pull/3611))
6+
7+
Deno 1.40.0 introduced run-time warnings about certain APIs that esbuild uses. With this release, esbuild will work around these run-time warnings by using newer APIs if they are present and falling back to the original APIs otherwise. This should avoid the warnings without breaking compatibility with older versions of Deno.
8+
9+
Unfortunately, doing this introduces a breaking change. The newer child process APIs lack a way to synchronously terminate esbuild's child process, so calling `esbuild.stop()` from within a Deno test is no longer sufficient to prevent Deno from failing a test that uses esbuild's API (Deno fails tests that create a child process without killing it before the test ends). To work around this, esbuild's `stop()` function has been changed to return a promise, and you now have to change `esbuild.stop()` to `await esbuild.stop()` in all of your Deno tests.
10+
511
* Reorder implicit file extensions within `node_modules` ([#3341](https://github.com/evanw/esbuild/issues/3341), [#3608](https://github.com/evanw/esbuild/issues/3608))
612

713
In [version 0.18.0](https://github.com/evanw/esbuild/releases/v0.18.0), esbuild changed the behavior of implicit file extensions within `node_modules` directories (i.e. in published packages) to prefer `.js` over `.ts` even when the `--resolve-extensions=` order prefers `.ts` over `.js` (which it does by default). However, doing that also accidentally made esbuild prefer `.css` over `.ts`, which caused problems for people that published packages containing both TypeScript and CSS in files with the same name.

lib/deno/mod.ts

Lines changed: 134 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export const analyzeMetafileSync: typeof types.analyzeMetafileSync = () => {
4343
throw new Error(`The "analyzeMetafileSync" API does not work in Deno`)
4444
}
4545

46-
export const stop = () => {
47-
if (stopService) stopService()
46+
export const stop = async () => {
47+
if (stopService) await stopService()
4848
}
4949

5050
let initializeWasCalled = false
@@ -178,63 +178,149 @@ interface Service {
178178

179179
let defaultWD = Deno.cwd()
180180
let longLivedService: Promise<Service> | undefined
181-
let stopService: (() => void) | undefined
181+
let stopService: (() => Promise<void>) | undefined
182+
183+
// Declare a common subprocess API for the two implementations below
184+
type SpawnFn = (cmd: string, options: {
185+
args: string[]
186+
stdin: 'piped' | 'inherit'
187+
stdout: 'piped' | 'inherit'
188+
stderr: 'inherit'
189+
}) => {
190+
write(bytes: Uint8Array): void
191+
read(): Promise<Uint8Array | null>
192+
close(): Promise<void> | void
193+
status(): Promise<{ code: number }>
194+
unref(): void
195+
ref(): void
196+
}
197+
198+
// Deno ≥1.40
199+
const spawnNew: SpawnFn = (cmd, { args, stdin, stdout, stderr }) => {
200+
const child = new Deno.Command(cmd, {
201+
args,
202+
cwd: defaultWD,
203+
stdin,
204+
stdout,
205+
stderr,
206+
}).spawn()
207+
const writer = child.stdin.getWriter()
208+
const reader = child.stdout.getReader()
209+
return {
210+
write: bytes => writer.write(bytes),
211+
read: () => reader.read().then(x => x.value || null),
212+
close: async () => {
213+
// We can't call "kill()" because it doesn't seem to work. Tests will
214+
// still fail with "A child process was opened during the test, but not
215+
// closed during the test" even though we kill the child process.
216+
//
217+
// And we can't call both "writer.close()" and "kill()" because then
218+
// there's a race as the child process exits when stdin is closed, and
219+
// "kill()" fails when the child process has already been killed.
220+
//
221+
// So instead we just call "writer.close()" and then hope that this
222+
// causes the child process to exit. It won't work if the stdin consumer
223+
// thread in the child process is hung or busy, but that may be the best
224+
// we can do.
225+
//
226+
// See this for more info: https://github.com/evanw/esbuild/pull/3611
227+
await writer.close()
228+
await reader.cancel()
229+
230+
// Wait for the process to exit. The new "kill()" API doesn't flag the
231+
// process as having exited because processes can technically ignore the
232+
// kill signal. Without this, Deno will fail tests that use esbuild with
233+
// an error because the test spawned a process but didn't wait for it.
234+
await child.status
235+
},
236+
status: () => child.status,
237+
unref: () => child.unref(),
238+
ref: () => child.ref(),
239+
}
240+
}
182241

183-
let ensureServiceIsRunning = (): Promise<Service> => {
242+
// Deno <1.40
243+
const spawnOld: SpawnFn = (cmd, { args, stdin, stdout, stderr }) => {
244+
const child = Deno.run({
245+
cmd: [cmd].concat(args),
246+
cwd: defaultWD,
247+
stdin,
248+
stdout,
249+
stderr,
250+
})
251+
const stdoutBuffer = new Uint8Array(4 * 1024 * 1024)
252+
let writeQueue: Uint8Array[] = []
253+
let isQueueLocked = false
254+
255+
// We need to keep calling "write()" until it actually writes the data
256+
const startWriteFromQueueWorker = () => {
257+
if (isQueueLocked || writeQueue.length === 0) return
258+
isQueueLocked = true
259+
child.stdin!.write(writeQueue[0]).then(bytesWritten => {
260+
isQueueLocked = false
261+
if (bytesWritten === writeQueue[0].length) writeQueue.shift()
262+
else writeQueue[0] = writeQueue[0].subarray(bytesWritten)
263+
startWriteFromQueueWorker()
264+
})
265+
}
266+
267+
return {
268+
write: bytes => {
269+
writeQueue.push(bytes)
270+
startWriteFromQueueWorker()
271+
},
272+
read: () => child.stdout!.read(stdoutBuffer).then(n => n === null ? null : stdoutBuffer.subarray(0, n)),
273+
close: () => {
274+
child.stdin!.close()
275+
child.stdout!.close()
276+
child.close()
277+
},
278+
status: () => child.status(),
279+
unref: () => { },
280+
ref: () => { },
281+
}
282+
}
283+
284+
// This is a shim for "Deno.run" for newer versions of Deno
285+
const spawn: SpawnFn = Deno.Command ? spawnNew : spawnOld
286+
287+
const ensureServiceIsRunning = (): Promise<Service> => {
184288
if (!longLivedService) {
185289
longLivedService = (async (): Promise<Service> => {
186290
const binPath = await install()
187-
const isTTY = Deno.isatty(Deno.stderr.rid)
291+
const isTTY = Deno.stderr.isTerminal
292+
? Deno.stderr.isTerminal() // Deno ≥1.40
293+
: Deno.isatty(Deno.stderr.rid) // Deno <1.40
188294

189-
const child = Deno.run({
190-
cmd: [binPath, `--service=${version}`],
191-
cwd: defaultWD,
295+
const child = spawn(binPath, {
296+
args: [`--service=${version}`],
192297
stdin: 'piped',
193298
stdout: 'piped',
194299
stderr: 'inherit',
195300
})
196301

197-
stopService = () => {
302+
stopService = async () => {
198303
// Close all resources related to the subprocess.
199-
child.stdin.close()
200-
child.stdout.close()
201-
child.close()
304+
await child.close()
202305
initializeWasCalled = false
203306
longLivedService = undefined
204307
stopService = undefined
205308
}
206309

207-
let writeQueue: Uint8Array[] = []
208-
let isQueueLocked = false
209-
210-
// We need to keep calling "write()" until it actually writes the data
211-
const startWriteFromQueueWorker = () => {
212-
if (isQueueLocked || writeQueue.length === 0) return
213-
isQueueLocked = true
214-
child.stdin.write(writeQueue[0]).then(bytesWritten => {
215-
isQueueLocked = false
216-
if (bytesWritten === writeQueue[0].length) writeQueue.shift()
217-
else writeQueue[0] = writeQueue[0].subarray(bytesWritten)
218-
startWriteFromQueueWorker()
219-
})
220-
}
221-
222310
const { readFromStdout, afterClose, service } = common.createChannel({
223311
writeToStdin(bytes) {
224-
writeQueue.push(bytes)
225-
startWriteFromQueueWorker()
312+
child.write(bytes)
226313
},
227314
isSync: false,
228315
hasFS: true,
229316
esbuild: ourselves,
230317
})
231318

232-
const stdoutBuffer = new Uint8Array(4 * 1024 * 1024)
233-
const readMoreStdout = () => child.stdout.read(stdoutBuffer).then(n => {
234-
if (n === null) {
319+
const readMoreStdout = () => child.read().then(buffer => {
320+
if (buffer === null) {
235321
afterClose(null)
236322
} else {
237-
readFromStdout(stdoutBuffer.subarray(0, n))
323+
readFromStdout(buffer)
238324
readMoreStdout()
239325
}
240326
}).catch(e => {
@@ -247,12 +333,20 @@ let ensureServiceIsRunning = (): Promise<Service> => {
247333
})
248334
readMoreStdout()
249335

336+
let refCount = 0
337+
child.unref() // Allow Deno to exit when esbuild is running
338+
339+
const refs: common.Refs = {
340+
ref() { if (++refCount === 1) child.ref(); },
341+
unref() { if (--refCount === 0) child.unref(); },
342+
}
343+
250344
return {
251345
build: (options: types.BuildOptions) =>
252346
new Promise<types.BuildResult>((resolve, reject) => {
253347
service.buildOrContext({
254348
callName: 'build',
255-
refs: null,
349+
refs,
256350
options,
257351
isTTY,
258352
defaultWD,
@@ -264,7 +358,7 @@ let ensureServiceIsRunning = (): Promise<Service> => {
264358
new Promise<types.BuildContext>((resolve, reject) =>
265359
service.buildOrContext({
266360
callName: 'context',
267-
refs: null,
361+
refs,
268362
options,
269363
isTTY,
270364
defaultWD,
@@ -275,7 +369,7 @@ let ensureServiceIsRunning = (): Promise<Service> => {
275369
new Promise<types.TransformResult>((resolve, reject) =>
276370
service.transform({
277371
callName: 'transform',
278-
refs: null,
372+
refs,
279373
input,
280374
options: options || {},
281375
isTTY,
@@ -308,7 +402,7 @@ let ensureServiceIsRunning = (): Promise<Service> => {
308402
new Promise((resolve, reject) =>
309403
service.formatMessages({
310404
callName: 'formatMessages',
311-
refs: null,
405+
refs,
312406
messages,
313407
options,
314408
callback: (err, res) => err ? reject(err) : resolve(res!),
@@ -318,7 +412,7 @@ let ensureServiceIsRunning = (): Promise<Service> => {
318412
new Promise((resolve, reject) =>
319413
service.analyzeMetafile({
320414
callName: 'analyzeMetafile',
321-
refs: null,
415+
refs,
322416
metafile: typeof metafile === 'string' ? metafile : JSON.stringify(metafile),
323417
options,
324418
callback: (err, res) => err ? reject(err) : resolve(res!),
@@ -331,9 +425,8 @@ let ensureServiceIsRunning = (): Promise<Service> => {
331425

332426
// If we're called as the main script, forward the CLI to the underlying executable
333427
if (import.meta.main) {
334-
Deno.run({
335-
cmd: [await install()].concat(Deno.args),
336-
cwd: defaultWD,
428+
spawn(await install(), {
429+
args: Deno.args,
337430
stdin: 'inherit',
338431
stdout: 'inherit',
339432
stderr: 'inherit',

lib/deno/wasm.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const analyzeMetafileSync: typeof types.analyzeMetafileSync = () => {
5050

5151
export const stop = () => {
5252
if (stopService) stopService()
53+
return Promise.resolve()
5354
}
5455

5556
interface Service {

lib/npm/browser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const analyzeMetafileSync: typeof types.analyzeMetafileSync = () => {
4545

4646
export const stop = () => {
4747
if (stopService) stopService()
48+
return Promise.resolve()
4849
}
4950

5051
interface Service {

lib/npm/node.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ export let analyzeMetafileSync: typeof types.analyzeMetafileSync = (metafile, op
223223
export const stop = () => {
224224
if (stopService) stopService()
225225
if (workerThreadService) workerThreadService.stop()
226+
return Promise.resolve()
226227
}
227228

228229
let initializeWasCalled = false

lib/shared/types.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -663,14 +663,17 @@ export interface InitializeOptions {
663663
export let version: string
664664

665665
// Call this function to terminate esbuild's child process. The child process
666-
// is not terminated and re-created for each API call because it's more
667-
// efficient to keep it around when there are multiple API calls.
666+
// is not terminated and re-created after each API call because it's more
667+
// efficient to keep it around when there are multiple API calls. This child
668+
// process normally exits automatically when the parent process exits, so you
669+
// usually don't need to call this function.
668670
//
669-
// In node this happens automatically before the parent node process exits. So
670-
// you only need to call this if you know you will not make any more esbuild
671-
// API calls and you want to clean up resources.
671+
// One reason you might want to call this is if you know you will not make any
672+
// more esbuild API calls and you want to clean up resources (since the esbuild
673+
// child process takes up some memory even when idle).
672674
//
673-
// Unlike node, Deno lacks the necessary APIs to clean up child processes
674-
// automatically. You must manually call stop() in Deno when you're done
675-
// using esbuild or Deno will continue running forever.
676-
export declare function stop(): void;
675+
// Another reason you might want to call this is if you are using esbuild from
676+
// within a Deno test. Deno fails tests that create a child process without
677+
// killing it before the test ends, so you have to call this function (and
678+
// await the returned promise) in every Deno test that uses esbuild.
679+
export declare function stop(): Promise<void>

0 commit comments

Comments
 (0)